chore: add code formatting and linting (#952)
This commit is contained in:
parent
32e0a9b2b5
commit
f801b19221
@ -4,7 +4,6 @@ root = true
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
22
.eslintrc
Normal file
22
.eslintrc
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"parser": "babel-eslint",
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:react/recommended"
|
||||
],
|
||||
"env": {
|
||||
"es6": true,
|
||||
"browser": true,
|
||||
"node": true,
|
||||
"jest": true
|
||||
},
|
||||
"globals": {
|
||||
"NETLIFY_CMS_VERSION": false,
|
||||
"NETLIFY_CMS_CORE_VERSION": false,
|
||||
"CMS_ENV": false
|
||||
},
|
||||
"rules": {
|
||||
"no-console": [0],
|
||||
"react/prop-types": [1]
|
||||
}
|
||||
}
|
3
.prettierignore
Normal file
3
.prettierignore
Normal file
@ -0,0 +1,3 @@
|
||||
dist/
|
||||
bin/
|
||||
CHANGELOG.md
|
5
.prettierrc
Normal file
5
.prettierrc
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"trailingComma": "all",
|
||||
"singleQuote": true,
|
||||
"printWidth": 100
|
||||
}
|
14
.stylelintrc
Normal file
14
.stylelintrc
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"processors": ["stylelint-processor-styled-components"],
|
||||
"extends": [
|
||||
"stylelint-config-recommended",
|
||||
"stylelint-config-styled-components"
|
||||
],
|
||||
"rules": {
|
||||
"block-no-empty": null,
|
||||
"no-duplicate-selectors": null,
|
||||
"selector-type-no-unknown": [true, {
|
||||
"ignoreTypes": ["$dummyValue"]
|
||||
}]
|
||||
}
|
||||
}
|
@ -6,10 +6,12 @@ cache:
|
||||
- $HOME/.yarn-cache
|
||||
- node_modules
|
||||
node_js:
|
||||
- "8"
|
||||
- "10"
|
||||
- '8'
|
||||
- '10'
|
||||
install:
|
||||
- yarn bootstrap
|
||||
script:
|
||||
- yarn test-ci
|
||||
notifications:
|
||||
email: false
|
||||
git:
|
||||
|
@ -14,21 +14,21 @@ orientation.
|
||||
Examples of behavior that contributes to creating a positive environment
|
||||
include:
|
||||
|
||||
* Using welcoming and inclusive language
|
||||
* Being respectful of differing viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
* Showing empathy towards other community members
|
||||
- Using welcoming and inclusive language
|
||||
- Being respectful of differing viewpoints and experiences
|
||||
- Gracefully accepting constructive criticism
|
||||
- Focusing on what is best for the community
|
||||
- Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
* The use of sexualized language or imagery and unwelcome sexual attention or
|
||||
advances
|
||||
* Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or electronic
|
||||
- The use of sexualized language or imagery and unwelcome sexual attention or
|
||||
advances
|
||||
- Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or electronic
|
||||
address, without explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
@ -95,6 +95,16 @@ Runs all the CMS package tests.
|
||||
yarn test
|
||||
```
|
||||
|
||||
### `format`
|
||||
|
||||
Formats code and docs according to our style guidelines.
|
||||
|
||||
#### Usage
|
||||
|
||||
```sh
|
||||
yarn format
|
||||
```
|
||||
|
||||
## Pull Requests
|
||||
|
||||
We actively welcome your pull requests.
|
||||
@ -102,8 +112,8 @@ We actively welcome your pull requests.
|
||||
1. Fork the repo and create your branch from `master`.
|
||||
2. If you've added code that should be tested, add tests.
|
||||
3. If you've changed APIs, update the documentation.
|
||||
4. Ensure the test suite passes.
|
||||
5. Make sure your code lints.
|
||||
4. Run `yarn test` and ensure the test suite passes.
|
||||
5. Use `yarn format` to format and lint your code.
|
||||
6. PR's must be rebased before merge (feel free to ask for help)
|
||||
7. PR should be reviewed by two maintainers (@erquhart, @Benaiah, @tech4him1) prior to merging.
|
||||
|
||||
|
11
README.md
11
README.md
@ -1,4 +1,5 @@
|
||||
# Netlify CMS
|
||||
|
||||
[![All Contributors](https://img.shields.io/badge/all_contributors-112-orange.svg)](#contributors)
|
||||
[![Open Source Helpers](https://www.codetriage.com/netlify/netlify-cms/badges/users.svg)](https://www.codetriage.com/netlify/netlify-cms)
|
||||
[![](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/netlify/netlifycms)
|
||||
@ -24,9 +25,9 @@ Read more about Netlify CMS [Core Concepts](https://www.netlifycms.org/docs/intr
|
||||
|
||||
The Netlify CMS can be used in two different ways.
|
||||
|
||||
* A Quick and easy install, that just requires you to create a single HTML file and a configuration file. All the CMS Javascript and CSS are loaded from a CDN.
|
||||
To learn more about this installation method, refer to the [Quick Start Guide](https://www.netlifycms.org/docs/quick-start/)
|
||||
* A complete, more complex install, that gives you more flexibility but requires that you use a static site builder with a build system that supports npm packages.
|
||||
- A Quick and easy install, that just requires you to create a single HTML file and a configuration file. All the CMS Javascript and CSS are loaded from a CDN.
|
||||
To learn more about this installation method, refer to the [Quick Start Guide](https://www.netlifycms.org/docs/quick-start/)
|
||||
- A complete, more complex install, that gives you more flexibility but requires that you use a static site builder with a build system that supports npm packages.
|
||||
|
||||
# Community
|
||||
|
||||
@ -45,7 +46,9 @@ Please make sure you understand its [implications and guarantees](https://writin
|
||||
# Thanks
|
||||
|
||||
## Services
|
||||
|
||||
These services support Netlify CMS development by providing free infrastructure.
|
||||
|
||||
<p>
|
||||
<a href="https://www.travis-ci.org">
|
||||
<img src="https://raw.githubusercontent.com/netlify/netlify-cms/master/img/travis.png" height="38"/>
|
||||
@ -57,6 +60,7 @@ These services support Netlify CMS development by providing free infrastructure.
|
||||
</p>
|
||||
|
||||
## Contributors
|
||||
|
||||
These wonderful folks are responsible for developing and maintaining Netlify CMS. ([emoji key](https://github.com/kentcdodds/all-contributors#emoji-key))
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
@ -79,6 +83,7 @@ These wonderful folks are responsible for developing and maintaining Netlify CMS
|
||||
| [<img src="https://avatars3.githubusercontent.com/u/26639499?v=4" width="100px;"/><br /><sub><b>David Ko</b></sub>](https://github.com/daveyko)<br />[💻](https://github.com/netlify/netlify-cms/commits?author=daveyko "Code") | [<img src="https://avatars3.githubusercontent.com/u/440562?v=4" width="100px;"/><br /><sub><b>Iñaki García</b></sub>](http://www.txorua.com)<br />[🎨](#design-igarbla "Design") | [<img src="https://avatars3.githubusercontent.com/u/27162255?v=4" width="100px;"/><br /><sub><b>Sam</b></sub>](https://github.com/gazebosx3)<br />[💻](https://github.com/netlify/netlify-cms/commits?author=gazebosx3 "Code") | [<img src="https://avatars1.githubusercontent.com/u/174777?v=4" width="100px;"/><br /><sub><b>Josh Dzielak</b></sub>](https://dzello.com)<br />[📖](https://github.com/netlify/netlify-cms/commits?author=dzello "Documentation") | [<img src="https://avatars0.githubusercontent.com/u/2193?v=4" width="100px;"/><br /><sub><b>Jeremy Bise</b></sub>](http://thosegeeks.com)<br />[📖](https://github.com/netlify/netlify-cms/commits?author=jeremybise "Documentation") | [<img src="https://avatars2.githubusercontent.com/u/13282103?v=4" width="100px;"/><br /><sub><b>terrierscript</b></sub>](https://terrierscript.com)<br />[💻](https://github.com/netlify/netlify-cms/commits?author=terrierscript "Code") | [<img src="https://avatars0.githubusercontent.com/u/3949335?v=4" width="100px;"/><br /><sub><b>Christopher Geary</b></sub>](https://twitter.com/crgeary)<br />[🔌](#plugin-crgeary "Plugin/utility libraries") |
|
||||
| [<img src="https://avatars0.githubusercontent.com/u/23248886?v=4" width="100px;"/><br /><sub><b>Brian Macdonald</b></sub>](https://github.com/brianlmacdonald)<br />[💻](https://github.com/netlify/netlify-cms/commits?author=brianlmacdonald "Code") | [<img src="https://avatars1.githubusercontent.com/u/15092?v=4" width="100px;"/><br /><sub><b>John Vandenberg</b></sub>](https://jayvdb.github.io/)<br />[📖](https://github.com/netlify/netlify-cms/commits?author=jayvdb "Documentation") | [<img src="https://avatars2.githubusercontent.com/u/24911956?v=4" width="100px;"/><br /><sub><b>MarkZither</b></sub>](https://github.com/MarkZither)<br />[📖](https://github.com/netlify/netlify-cms/commits?author=MarkZither "Documentation") | [<img src="https://avatars1.githubusercontent.com/u/9257284?v=4" width="100px;"/><br /><sub><b>Rob Phoenix</b></sub>](https://www.robphoenix.com)<br />[📖](https://github.com/netlify/netlify-cms/commits?author=robphoenix "Documentation") | [<img src="https://avatars2.githubusercontent.com/u/3028764?v=4" width="100px;"/><br /><sub><b>Steve Lathrop</b></sub>](https://www.SteLa.io)<br />[💻](https://github.com/netlify/netlify-cms/commits?author=slathrop "Code") [📖](https://github.com/netlify/netlify-cms/commits?author=slathrop "Documentation") [💡](#example-slathrop "Examples") | [<img src="https://avatars0.githubusercontent.com/u/10004167?v=4" width="100px;"/><br /><sub><b>Maciej Matuszewski</b></sub>](https://github.com/maciejmatu)<br />[💻](https://github.com/netlify/netlify-cms/commits?author=maciejmatu "Code") | [<img src="https://avatars0.githubusercontent.com/u/36023898?v=4" width="100px;"/><br /><sub><b>Eko Eryanto</b></sub>](https://github.com/ekoeryanto)<br />[🔌](#plugin-ekoeryanto "Plugin/utility libraries") |
|
||||
| [<img src="https://avatars3.githubusercontent.com/u/366688?v=4" width="100px;"/><br /><sub><b>Taylor D. Edmiston</b></sub>](http://blog.tedmiston.com/)<br />[📖](https://github.com/netlify/netlify-cms/commits?author=tedmiston "Documentation") | [<img src="https://avatars2.githubusercontent.com/u/1088089?v=4" width="100px;"/><br /><sub><b>Daniel Mahon</b></sub>](https://www.mahonstudios.com)<br />[💻](https://github.com/netlify/netlify-cms/commits?author=danielmahon "Code") | [<img src="https://avatars1.githubusercontent.com/u/16711653?v=4" width="100px;"/><br /><sub><b>Evan Hennessy</b></sub>](https://www.hennessyevan.com)<br />[🔌](#plugin-hennessyevan "Plugin/utility libraries") | [<img src="https://avatars1.githubusercontent.com/u/3259517?v=4" width="100px;"/><br /><sub><b>Hasan Azizul Haque</b></sub>](https://hasanavi.me)<br />[💻](https://github.com/netlify/netlify-cms/commits?author=hasanavi "Code") [📖](https://github.com/netlify/netlify-cms/commits?author=hasanavi "Documentation") [🤔](#ideas-hasanavi "Ideas, Planning, & Feedback") | [<img src="https://avatars1.githubusercontent.com/u/5166612?v=4" width="100px;"/><br /><sub><b>Robert Karlsson</b></sub>](https://github.com/robertkarlsson)<br />[🐛](https://github.com/netlify/netlify-cms/issues?q=author%3Arobertkarlsson "Bug reports") | [<img src="https://avatars2.githubusercontent.com/u/3484527?v=4" width="100px;"/><br /><sub><b>Gil Greenberg</b></sub>](http://gilgreenberg.com)<br />[💻](https://github.com/netlify/netlify-cms/commits?author=gil-- "Code") | [<img src="https://avatars0.githubusercontent.com/u/649890?v=4" width="100px;"/><br /><sub><b>Tyler Ipson</b></sub>](http://loremipson.com)<br />[📖](https://github.com/netlify/netlify-cms/commits?author=loremipson "Documentation") |
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
||||
|
||||
This project follows the [all-contributors](https://github.com/kentcdodds/all-contributors) specification. Contributions of any kind welcome!
|
||||
|
@ -3,25 +3,28 @@ const isTest = process.env.NODE_ENV === 'test';
|
||||
|
||||
const presets = () => {
|
||||
if (isTest) {
|
||||
return [
|
||||
'@babel/preset-react',
|
||||
'@babel/preset-env',
|
||||
];
|
||||
return ['@babel/preset-react', '@babel/preset-env'];
|
||||
}
|
||||
return [
|
||||
'@babel/preset-react',
|
||||
['@babel/preset-env', {
|
||||
modules: false,
|
||||
}],
|
||||
[
|
||||
'@babel/preset-env',
|
||||
{
|
||||
modules: false,
|
||||
},
|
||||
],
|
||||
];
|
||||
};
|
||||
|
||||
const plugins = () => {
|
||||
const defaultPlugins = [
|
||||
'lodash',
|
||||
['babel-plugin-transform-builtin-extend', {
|
||||
globals: ['Error']
|
||||
}],
|
||||
[
|
||||
'babel-plugin-transform-builtin-extend',
|
||||
{
|
||||
globals: ['Error'],
|
||||
},
|
||||
],
|
||||
'transform-export-extensions',
|
||||
'@babel/plugin-proposal-class-properties',
|
||||
'@babel/plugin-proposal-object-rest-spread',
|
||||
@ -29,35 +32,39 @@ const plugins = () => {
|
||||
];
|
||||
|
||||
if (isProduction) {
|
||||
return [
|
||||
...defaultPlugins,
|
||||
['emotion', { hoist: true }],
|
||||
];
|
||||
return [...defaultPlugins, ['emotion', { hoist: true }]];
|
||||
}
|
||||
|
||||
if (isTest) {
|
||||
return [
|
||||
...defaultPlugins,
|
||||
['inline-svg', {
|
||||
svgo: {
|
||||
plugins: [
|
||||
{removeViewBox: false},
|
||||
],
|
||||
[
|
||||
'inline-svg',
|
||||
{
|
||||
svgo: {
|
||||
plugins: [{ removeViewBox: false }],
|
||||
},
|
||||
},
|
||||
}],
|
||||
['emotion', {
|
||||
sourceMap: true,
|
||||
autoLabel: true,
|
||||
}],
|
||||
],
|
||||
[
|
||||
'emotion',
|
||||
{
|
||||
sourceMap: true,
|
||||
autoLabel: true,
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
...defaultPlugins,
|
||||
['emotion', {
|
||||
sourceMap: true,
|
||||
autoLabel: true,
|
||||
}],
|
||||
[
|
||||
'emotion',
|
||||
{
|
||||
sourceMap: true,
|
||||
autoLabel: true,
|
||||
},
|
||||
],
|
||||
];
|
||||
};
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
const babelJest = require('babel-jest')
|
||||
const babelJest = require('babel-jest');
|
||||
const babelConfig = require('./packages/netlify-cms-core/babel.config.js');
|
||||
|
||||
module.exports = babelJest.createTransformer(babelConfig)
|
||||
module.exports = babelJest.createTransformer(babelConfig);
|
||||
|
@ -1,11 +1,11 @@
|
||||
module.exports = {
|
||||
setupTestFrameworkScriptFile: "<rootDir>/setupTestFramework.js",
|
||||
setupTestFrameworkScriptFile: '<rootDir>/setupTestFramework.js',
|
||||
transform: {
|
||||
"\\.js$": "<rootDir>/custom-preprocessor.js",
|
||||
'\\.js$': '<rootDir>/custom-preprocessor.js',
|
||||
},
|
||||
moduleNameMapper: {
|
||||
"netlify-cms-lib-util": "<rootDir>/packages/netlify-cms-lib-util/src/index.js",
|
||||
"netlify-cms-ui-default": "<rootDir>/packages/netlify-cms-ui-default/src/index.js",
|
||||
'netlify-cms-lib-util': '<rootDir>/packages/netlify-cms-lib-util/src/index.js',
|
||||
'netlify-cms-ui-default': '<rootDir>/packages/netlify-cms-ui-default/src/index.js',
|
||||
},
|
||||
testEnvironment: "node",
|
||||
testEnvironment: 'node',
|
||||
};
|
||||
|
@ -1,8 +1,6 @@
|
||||
{
|
||||
"lerna": "2.11.0",
|
||||
"packages": [
|
||||
"packages/*"
|
||||
],
|
||||
"packages": ["packages/*"],
|
||||
"version": "independent",
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
@ -11,9 +9,7 @@
|
||||
"publish": {
|
||||
"allowBranch": "master",
|
||||
"conventionalCommits": true,
|
||||
"ignoreChanges": [
|
||||
"CHANGELOG.md"
|
||||
]
|
||||
"ignoreChanges": ["CHANGELOG.md"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
17
package.json
17
package.json
@ -10,7 +10,7 @@
|
||||
"reset": "npm run clean && lerna clean --yes",
|
||||
"cache-ci": "node scripts/cache.js",
|
||||
"test": "run-s jest e2e",
|
||||
"test-ci": "run-s cache-ci jest e2e-ci",
|
||||
"test-ci": "run-s cache-ci jest e2e-ci lint-quiet",
|
||||
"jest": "cross-env NODE_ENV=test jest --no-cache",
|
||||
"e2e-prep": "npm run build && cp -r packages/netlify-cms/dist dev-test/",
|
||||
"e2e-serve": "http-server dev-test",
|
||||
@ -24,6 +24,13 @@
|
||||
"e2e-dev": "start-test develop 8080 e2e-exec-dev",
|
||||
"dryrun": "lerna publish --skip-npm --skip-git",
|
||||
"publish": "run-s bootstrap dryrun build && git checkout . && lerna publish",
|
||||
"format": "run-s \"lint:css -- --fix --quiet\" \"lint:js -- --fix --quiet\" \"format:prettier -- --write\"",
|
||||
"format:prettier": "prettier \"{{packages,scripts,website}/**/,}*.{js,css,md,json,yml,yaml}\"",
|
||||
"lint": "run-p -c --aggregate-output lint:*",
|
||||
"lint-quiet": "run-p -c --aggregate-output \"lint:* -- --quiet\"",
|
||||
"lint:css": "stylelint --ignore-path .gitignore \"{packages/**/*.{css,js},website/**/*.css}\"",
|
||||
"lint:js": "eslint --ignore-path .gitignore \"{{packages,scripts,website}/**/,}*.js\"",
|
||||
"lint:format": "npm run format:prettier -- --list-different",
|
||||
"add-contributor": "all-contributors add"
|
||||
},
|
||||
"browserslist": [
|
||||
@ -45,6 +52,7 @@
|
||||
"@babel/preset-react": "^7.0.0-beta.54",
|
||||
"all-contributors-cli": "^4.4.0",
|
||||
"babel-core": "^7.0.0-bridge.0",
|
||||
"babel-eslint": "^9.0.0-beta.3",
|
||||
"babel-jest": "^23.4.0",
|
||||
"babel-loader": "^8.0.0-beta",
|
||||
"babel-plugin-emotion": "^9.2.4",
|
||||
@ -60,6 +68,8 @@
|
||||
"deep-equal": "^1.0.1",
|
||||
"enzyme": "^3.1.0",
|
||||
"enzyme-adapter-react-16": "^1.0.2",
|
||||
"eslint": "^5.3.0",
|
||||
"eslint-plugin-react": "^7.10.0",
|
||||
"friendly-errors-webpack-plugin": "^1.7.0",
|
||||
"http-server": "^0.11.1",
|
||||
"jest": "^23.4.0",
|
||||
@ -67,11 +77,16 @@
|
||||
"jest-emotion": "^9.2.7",
|
||||
"lerna": "^2.11.0",
|
||||
"npm-run-all": "^4.1.3",
|
||||
"prettier": "1.14.0",
|
||||
"raf": "^3.4.0",
|
||||
"react-test-renderer": "^16.0.0",
|
||||
"rimraf": "^2.6.2",
|
||||
"start-server-and-test": "^1.7.0",
|
||||
"style-loader": "^0.21.0",
|
||||
"stylelint": "^9.4.0",
|
||||
"stylelint-config-recommended": "^2.1.0",
|
||||
"stylelint-config-styled-components": "^0.1.1",
|
||||
"stylelint-processor-styled-components": "^1.3.1",
|
||||
"svg-inline-loader": "^0.8.0"
|
||||
},
|
||||
"workspaces": [
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { flow, get } from "lodash";
|
||||
import { flow, get } from 'lodash';
|
||||
import {
|
||||
localForage,
|
||||
unsentRequest,
|
||||
@ -6,48 +6,49 @@ import {
|
||||
then,
|
||||
basename,
|
||||
Cursor,
|
||||
APIError
|
||||
} from "netlify-cms-lib-util";
|
||||
APIError,
|
||||
} from 'netlify-cms-lib-util';
|
||||
|
||||
export default class API {
|
||||
constructor(config) {
|
||||
this.api_root = config.api_root || "https://api.bitbucket.org/2.0";
|
||||
this.branch = config.branch || "master";
|
||||
this.repo = config.repo || "";
|
||||
this.api_root = config.api_root || 'https://api.bitbucket.org/2.0';
|
||||
this.branch = config.branch || 'master';
|
||||
this.repo = config.repo || '';
|
||||
this.requestFunction = config.requestFunction || unsentRequest.performRequest;
|
||||
// Allow overriding this.hasWriteAccess
|
||||
this.hasWriteAccess = config.hasWriteAccess || this.hasWriteAccess;
|
||||
this.repoURL = this.repo ? `/repositories/${ this.repo }` : "";
|
||||
this.repoURL = this.repo ? `/repositories/${this.repo}` : '';
|
||||
}
|
||||
|
||||
buildRequest = req => flow([
|
||||
unsentRequest.withRoot(this.api_root),
|
||||
unsentRequest.withTimestamp,
|
||||
])(req);
|
||||
buildRequest = req =>
|
||||
flow([unsentRequest.withRoot(this.api_root), unsentRequest.withTimestamp])(req);
|
||||
|
||||
request = req => flow([
|
||||
this.buildRequest,
|
||||
this.requestFunction,
|
||||
p => p.catch(err => Promise.reject(new APIError(err.message, null, "BitBucket"))),
|
||||
])(req);
|
||||
request = req =>
|
||||
flow([
|
||||
this.buildRequest,
|
||||
this.requestFunction,
|
||||
p => p.catch(err => Promise.reject(new APIError(err.message, null, 'BitBucket'))),
|
||||
])(req);
|
||||
|
||||
requestJSON = req => flow([
|
||||
unsentRequest.withDefaultHeaders({ "Content-Type": "application/json" }),
|
||||
this.request,
|
||||
then(responseParser({ format: "json" })),
|
||||
p => p.catch(err => Promise.reject(new APIError(err.message, null, "BitBucket"))),
|
||||
])(req);
|
||||
requestText = req => flow([
|
||||
unsentRequest.withDefaultHeaders({ "Content-Type": "text/plain" }),
|
||||
this.request,
|
||||
then(responseParser({ format: "text" })),
|
||||
p => p.catch(err => Promise.reject(new APIError(err.message, null, "BitBucket"))),
|
||||
])(req);
|
||||
requestJSON = req =>
|
||||
flow([
|
||||
unsentRequest.withDefaultHeaders({ 'Content-Type': 'application/json' }),
|
||||
this.request,
|
||||
then(responseParser({ format: 'json' })),
|
||||
p => p.catch(err => Promise.reject(new APIError(err.message, null, 'BitBucket'))),
|
||||
])(req);
|
||||
requestText = req =>
|
||||
flow([
|
||||
unsentRequest.withDefaultHeaders({ 'Content-Type': 'text/plain' }),
|
||||
this.request,
|
||||
then(responseParser({ format: 'text' })),
|
||||
p => p.catch(err => Promise.reject(new APIError(err.message, null, 'BitBucket'))),
|
||||
])(req);
|
||||
|
||||
user = () => this.request("/user");
|
||||
hasWriteAccess = user => this.request(this.repoURL).then(res => res.ok);
|
||||
user = () => this.request('/user');
|
||||
hasWriteAccess = () => this.request(this.repoURL).then(res => res.ok);
|
||||
|
||||
isFile = ({ type }) => type === "commit_file";
|
||||
isFile = ({ type }) => type === 'commit_file';
|
||||
processFile = file => ({
|
||||
...file,
|
||||
name: basename(file.path),
|
||||
@ -59,59 +60,75 @@ export default class API {
|
||||
// that will help with caching (though not as well as a normal
|
||||
// SHA, since it will change even if the individual file itself
|
||||
// doesn't.)
|
||||
...(file.commit && file.commit.hash
|
||||
? { id: `${ file.commit.hash }/${ file.path }` }
|
||||
: {}),
|
||||
...(file.commit && file.commit.hash ? { id: `${file.commit.hash}/${file.path}` } : {}),
|
||||
});
|
||||
processFiles = files => files.filter(this.isFile).map(this.processFile);
|
||||
|
||||
readFile = async (path, sha, { ref = this.branch, parseText = true } = {}) => {
|
||||
const cacheKey = parseText ? `bb.${ sha }` : `bb.${ sha }.blob`;
|
||||
const cacheKey = parseText ? `bb.${sha}` : `bb.${sha}.blob`;
|
||||
const cachedFile = sha ? await localForage.getItem(cacheKey) : null;
|
||||
if (cachedFile) { return cachedFile; }
|
||||
if (cachedFile) {
|
||||
return cachedFile;
|
||||
}
|
||||
const result = await this.request({
|
||||
url: `${ this.repoURL }/src/${ ref }/${ path }`,
|
||||
cache: "no-store",
|
||||
}).then(parseText ? responseParser({ format: "text" }) : responseParser({ format: "blob" }));
|
||||
if (sha) { localForage.setItem(cacheKey, result); }
|
||||
url: `${this.repoURL}/src/${ref}/${path}`,
|
||||
cache: 'no-store',
|
||||
}).then(parseText ? responseParser({ format: 'text' }) : responseParser({ format: 'blob' }));
|
||||
if (sha) {
|
||||
localForage.setItem(cacheKey, result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
getEntriesAndCursor = jsonResponse => {
|
||||
const { size: count, page: index, pagelen: pageSize, next, previous: prev, values: entries } = jsonResponse;
|
||||
const pageCount = (pageSize && count) ? Math.ceil(count / pageSize) : undefined;
|
||||
const {
|
||||
size: count,
|
||||
page: index,
|
||||
pagelen: pageSize,
|
||||
next,
|
||||
previous: prev,
|
||||
values: entries,
|
||||
} = jsonResponse;
|
||||
const pageCount = pageSize && count ? Math.ceil(count / pageSize) : undefined;
|
||||
return {
|
||||
entries,
|
||||
cursor: Cursor.create({
|
||||
actions: [...(next ? ["next"] : []), ...(prev ? ["prev"] : [])],
|
||||
actions: [...(next ? ['next'] : []), ...(prev ? ['prev'] : [])],
|
||||
meta: { index, count, pageSize, pageCount },
|
||||
data: { links: { next, prev } },
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
listFiles = async path => {
|
||||
const { entries, cursor } = await flow([
|
||||
// sort files by filename ascending
|
||||
unsentRequest.withParams({ sort: "-path" }),
|
||||
unsentRequest.withParams({ sort: '-path' }),
|
||||
this.requestJSON,
|
||||
then(this.getEntriesAndCursor),
|
||||
])(`${ this.repoURL }/src/${ this.branch }/${ path }`);
|
||||
])(`${this.repoURL}/src/${this.branch}/${path}`);
|
||||
return { entries: this.processFiles(entries), cursor };
|
||||
}
|
||||
};
|
||||
|
||||
traverseCursor = async (cursor, action) => flow([
|
||||
this.requestJSON,
|
||||
then(this.getEntriesAndCursor),
|
||||
then(({ cursor: newCursor, entries }) => ({ cursor: newCursor, entries: this.processFiles(entries) })),
|
||||
])(cursor.data.getIn(["links", action]));
|
||||
traverseCursor = async (cursor, action) =>
|
||||
flow([
|
||||
this.requestJSON,
|
||||
then(this.getEntriesAndCursor),
|
||||
then(({ cursor: newCursor, entries }) => ({
|
||||
cursor: newCursor,
|
||||
entries: this.processFiles(entries),
|
||||
})),
|
||||
])(cursor.data.getIn(['links', action]));
|
||||
|
||||
listAllFiles = async path => {
|
||||
const { cursor: initialCursor, entries: initialEntries } = await this.listFiles(path);
|
||||
const entries = [...initialEntries];
|
||||
let currentCursor = initialCursor;
|
||||
while (currentCursor && currentCursor.actions.has("next")) {
|
||||
const { cursor: newCursor, entries: newEntries } = await this.traverseCursor(currentCursor, "next");
|
||||
while (currentCursor && currentCursor.actions.has('next')) {
|
||||
const { cursor: newCursor, entries: newEntries } = await this.traverseCursor(
|
||||
currentCursor,
|
||||
'next',
|
||||
);
|
||||
entries.push(...newEntries);
|
||||
currentCursor = newCursor;
|
||||
}
|
||||
@ -125,40 +142,41 @@ export default class API {
|
||||
formData.append(item.path, contentBlob, basename(item.path));
|
||||
formData.append('branch', branch);
|
||||
if (commitMessage) {
|
||||
formData.append("message", commitMessage);
|
||||
formData.append('message', commitMessage);
|
||||
}
|
||||
if (this.commitAuthor) {
|
||||
const { name, email } = this.commitAuthor;
|
||||
formData.append("author", `${name} <${email}>`);
|
||||
formData.append('author', `${name} <${email}>`);
|
||||
}
|
||||
|
||||
return flow([
|
||||
unsentRequest.withMethod("POST"),
|
||||
unsentRequest.withMethod('POST'),
|
||||
unsentRequest.withBody(formData),
|
||||
this.request,
|
||||
then(() => ({ ...item, uploaded: true })),
|
||||
])(`${ this.repoURL }/src`);
|
||||
])(`${this.repoURL}/src`);
|
||||
};
|
||||
|
||||
persistFiles = (files, { commitMessage }) => Promise.all(
|
||||
files.filter(({ uploaded }) => !uploaded).map(file => this.uploadBlob(file, { commitMessage }))
|
||||
);
|
||||
persistFiles = (files, { commitMessage }) =>
|
||||
Promise.all(
|
||||
files
|
||||
.filter(({ uploaded }) => !uploaded)
|
||||
.map(file => this.uploadBlob(file, { commitMessage })),
|
||||
);
|
||||
|
||||
deleteFile = (path, message, { branch = this.branch } = {}) => {
|
||||
const body = new FormData();
|
||||
body.append('files', path);
|
||||
body.append('branch', branch);
|
||||
if (message) {
|
||||
body.append("message", message);
|
||||
body.append('message', message);
|
||||
}
|
||||
if (this.commitAuthor) {
|
||||
const { name, email } = this.commitAuthor;
|
||||
body.append("author", `${name} <${email}>`);
|
||||
body.append('author', `${name} <${email}>`);
|
||||
}
|
||||
return flow([
|
||||
unsentRequest.withMethod("POST"),
|
||||
unsentRequest.withBody(body),
|
||||
this.request,
|
||||
])(`${ this.repoURL }/src`);
|
||||
return flow([unsentRequest.withMethod('POST'), unsentRequest.withBody(body), this.request])(
|
||||
`${this.repoURL}/src`,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
@ -6,7 +6,7 @@ import { AuthenticationPage, Icon } from 'netlify-cms-ui-default';
|
||||
|
||||
const LoginButtonIcon = styled(Icon)`
|
||||
margin-right: 18px;
|
||||
`
|
||||
`;
|
||||
|
||||
export default class BitbucketAuthenticationPage extends React.Component {
|
||||
static propTypes = {
|
||||
@ -16,11 +16,14 @@ export default class BitbucketAuthenticationPage extends React.Component {
|
||||
|
||||
state = {};
|
||||
|
||||
handleLogin = (e) => {
|
||||
handleLogin = e => {
|
||||
e.preventDefault();
|
||||
const cfg = {
|
||||
base_url: this.props.base_url,
|
||||
site_id: (document.location.host.split(':')[0] === 'localhost') ? 'cms.netlify.com' : this.props.site_id,
|
||||
site_id:
|
||||
document.location.host.split(':')[0] === 'localhost'
|
||||
? 'cms.netlify.com'
|
||||
: this.props.site_id,
|
||||
auth_endpoint: this.props.authEndpoint,
|
||||
};
|
||||
const auth = new NetlifyAuthenticator(cfg);
|
||||
@ -44,8 +47,8 @@ export default class BitbucketAuthenticationPage extends React.Component {
|
||||
loginErrorMessage={this.state.loginError}
|
||||
renderButtonContent={() => (
|
||||
<React.Fragment>
|
||||
<LoginButtonIcon type="bitbucket"/>
|
||||
{inProgress ? "Logging in..." : "Login with Bitbucket"}
|
||||
<LoginButtonIcon type="bitbucket" />
|
||||
{inProgress ? 'Logging in...' : 'Login with Bitbucket'}
|
||||
</React.Fragment>
|
||||
)}
|
||||
/>
|
||||
|
@ -1,21 +1,21 @@
|
||||
import semaphore from "semaphore";
|
||||
import { flow, trimStart } from "lodash";
|
||||
import semaphore from 'semaphore';
|
||||
import { flow, trimStart } from 'lodash';
|
||||
import {
|
||||
CURSOR_COMPATIBILITY_SYMBOL,
|
||||
filterByPropExtension,
|
||||
resolvePromiseProperties,
|
||||
then,
|
||||
unsentRequest,
|
||||
} from "netlify-cms-lib-util";
|
||||
} from 'netlify-cms-lib-util';
|
||||
import { NetlifyAuthenticator } from 'netlify-cms-lib-auth';
|
||||
import AuthenticationPage from "./AuthenticationPage";
|
||||
import API from "./API";
|
||||
import AuthenticationPage from './AuthenticationPage';
|
||||
import API from './API';
|
||||
|
||||
const MAX_CONCURRENT_DOWNLOADS = 10;
|
||||
|
||||
// Implementation wrapper class
|
||||
export default class Bitbucket {
|
||||
constructor(config, options={}) {
|
||||
constructor(config, options = {}) {
|
||||
this.config = config;
|
||||
this.options = {
|
||||
proxied: false,
|
||||
@ -25,23 +25,23 @@ export default class Bitbucket {
|
||||
};
|
||||
|
||||
if (this.options.useWorkflow) {
|
||||
throw new Error("The BitBucket backend does not support the Editorial Workflow.");
|
||||
throw new Error('The BitBucket backend does not support the Editorial Workflow.');
|
||||
}
|
||||
|
||||
if (!this.options.proxied && !config.getIn(["backend", "repo"], false)) {
|
||||
throw new Error("The BitBucket backend needs a \"repo\" in the backend configuration.");
|
||||
if (!this.options.proxied && !config.getIn(['backend', 'repo'], false)) {
|
||||
throw new Error('The BitBucket backend needs a "repo" in the backend configuration.');
|
||||
}
|
||||
|
||||
this.api = this.options.API || null;
|
||||
|
||||
this.updateUserCredentials = this.options.updateUserCredentials;
|
||||
|
||||
this.repo = config.getIn(["backend", "repo"], "");
|
||||
this.branch = config.getIn(["backend", "branch"], "master");
|
||||
this.api_root = config.getIn(["backend", "api_root"], "https://api.bitbucket.org/2.0");
|
||||
this.base_url = config.get("base_url");
|
||||
this.site_id = config.get("site_id");
|
||||
this.token = "";
|
||||
this.repo = config.getIn(['backend', 'repo'], '');
|
||||
this.branch = config.getIn(['backend', 'branch'], 'master');
|
||||
this.api_root = config.getIn(['backend', 'api_root'], 'https://api.bitbucket.org/2.0');
|
||||
this.base_url = config.get('base_url');
|
||||
this.site_id = config.get('site_id');
|
||||
this.token = '';
|
||||
}
|
||||
|
||||
authComponent() {
|
||||
@ -50,7 +50,11 @@ export default class Bitbucket {
|
||||
|
||||
setUser(user) {
|
||||
this.token = user.token;
|
||||
this.api = new API({ requestFunction: this.apiRequestFunction, branch: this.branch, repo: this.repo });
|
||||
this.api = new API({
|
||||
requestFunction: this.apiRequestFunction,
|
||||
branch: this.branch,
|
||||
repo: this.repo,
|
||||
});
|
||||
}
|
||||
|
||||
restoreUser(user) {
|
||||
@ -60,13 +64,19 @@ export default class Bitbucket {
|
||||
authenticate(state) {
|
||||
this.token = state.token;
|
||||
this.refreshToken = state.refresh_token;
|
||||
this.api = new API({ requestFunction: this.apiRequestFunction, branch: this.branch, repo: this.repo, api_root: this.api_root });
|
||||
this.api = new API({
|
||||
requestFunction: this.apiRequestFunction,
|
||||
branch: this.branch,
|
||||
repo: this.repo,
|
||||
api_root: this.api_root,
|
||||
});
|
||||
|
||||
return this.api.user().then(user =>
|
||||
this.api.hasWriteAccess(user).then(isCollab => {
|
||||
if (!isCollab) throw new Error("Your BitBucker user account does not have access to this repo.");
|
||||
if (!isCollab)
|
||||
throw new Error('Your BitBucker user account does not have access to this repo.');
|
||||
return Object.assign({}, user, { token: state.token, refresh_token: state.refresh_token });
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@ -84,7 +94,8 @@ export default class Bitbucket {
|
||||
this.authenticator = new NetlifyAuthenticator(cfg);
|
||||
}
|
||||
|
||||
this.refreshedTokenPromise = this.authenticator.refresh({ provider: "bitbucket", refresh_token: this.refreshToken })
|
||||
this.refreshedTokenPromise = this.authenticator
|
||||
.refresh({ provider: 'bitbucket', refresh_token: this.refreshToken })
|
||||
.then(({ token, refresh_token }) => {
|
||||
this.token = token;
|
||||
this.refreshToken = refresh_token;
|
||||
@ -112,14 +123,17 @@ export default class Bitbucket {
|
||||
apiRequestFunction = async req => {
|
||||
const token = this.refreshedTokenPromise ? await this.refreshedTokenPromise : this.token;
|
||||
return flow([
|
||||
unsentRequest.withHeaders({ Authorization: `Bearer ${ token }` }),
|
||||
unsentRequest.withHeaders({ Authorization: `Bearer ${token}` }),
|
||||
unsentRequest.performRequest,
|
||||
then(async res => {
|
||||
if (res.status === 401) {
|
||||
const json = (await res.json().catch(() => null));
|
||||
if (json && json.type === "error" && /^access token expired/i.test(json.error.message)) {
|
||||
const json = await res.json().catch(() => null);
|
||||
if (json && json.type === 'error' && /^access token expired/i.test(json.error.message)) {
|
||||
const newToken = await this.getRefreshedAccessToken();
|
||||
const reqWithNewToken = unsentRequest.withHeaders({ Authorization: `Bearer ${ newToken }` }, req);
|
||||
const reqWithNewToken = unsentRequest.withHeaders(
|
||||
{ Authorization: `Bearer ${newToken}` },
|
||||
req,
|
||||
);
|
||||
return unsentRequest.performRequest(reqWithNewToken);
|
||||
}
|
||||
}
|
||||
@ -129,11 +143,11 @@ export default class Bitbucket {
|
||||
};
|
||||
|
||||
entriesByFolder(collection, extension) {
|
||||
const listPromise = this.api.listFiles(collection.get("folder"));
|
||||
const listPromise = this.api.listFiles(collection.get('folder'));
|
||||
return resolvePromiseProperties({
|
||||
files: listPromise
|
||||
.then(({ entries }) => entries)
|
||||
.then(filterByPropExtension(extension, "path"))
|
||||
.then(filterByPropExtension(extension, 'path'))
|
||||
.then(this.fetchFiles),
|
||||
cursor: listPromise.then(({ cursor }) => cursor),
|
||||
}).then(({ files, cursor }) => {
|
||||
@ -143,37 +157,46 @@ export default class Bitbucket {
|
||||
}
|
||||
|
||||
allEntriesByFolder(collection, extension) {
|
||||
return this.api.listAllFiles(collection.get("folder"))
|
||||
.then(filterByPropExtension(extension, "path"))
|
||||
return this.api
|
||||
.listAllFiles(collection.get('folder'))
|
||||
.then(filterByPropExtension(extension, 'path'))
|
||||
.then(this.fetchFiles);
|
||||
}
|
||||
|
||||
entriesByFiles(collection) {
|
||||
const files = collection.get("files").map(collectionFile => ({
|
||||
path: collectionFile.get("file"),
|
||||
label: collectionFile.get("label"),
|
||||
const files = collection.get('files').map(collectionFile => ({
|
||||
path: collectionFile.get('file'),
|
||||
label: collectionFile.get('label'),
|
||||
}));
|
||||
return this.fetchFiles(files);
|
||||
}
|
||||
|
||||
fetchFiles = (files) => {
|
||||
fetchFiles = files => {
|
||||
const sem = semaphore(MAX_CONCURRENT_DOWNLOADS);
|
||||
const promises = [];
|
||||
files.forEach((file) => {
|
||||
promises.push(new Promise(resolve => (
|
||||
sem.take(() => this.api.readFile(file.path, file.id).then((data) => {
|
||||
resolve({ file, data });
|
||||
sem.leave();
|
||||
}).catch((error = true) => {
|
||||
sem.leave();
|
||||
console.error(`failed to load file from BitBucket: ${ file.path }`);
|
||||
resolve({ error });
|
||||
}))
|
||||
)));
|
||||
files.forEach(file => {
|
||||
promises.push(
|
||||
new Promise(resolve =>
|
||||
sem.take(() =>
|
||||
this.api
|
||||
.readFile(file.path, file.id)
|
||||
.then(data => {
|
||||
resolve({ file, data });
|
||||
sem.leave();
|
||||
})
|
||||
.catch((error = true) => {
|
||||
sem.leave();
|
||||
console.error(`failed to load file from BitBucket: ${file.path}`);
|
||||
resolve({ error });
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
return Promise.all(promises)
|
||||
.then(loadedEntries => loadedEntries.filter(loadedEntry => !loadedEntry.error));
|
||||
}
|
||||
return Promise.all(promises).then(loadedEntries =>
|
||||
loadedEntries.filter(loadedEntry => !loadedEntry.error),
|
||||
);
|
||||
};
|
||||
|
||||
getEntry(collection, slug, path) {
|
||||
return this.api.readFile(path).then(data => ({
|
||||
@ -185,18 +208,21 @@ export default class Bitbucket {
|
||||
getMedia() {
|
||||
const sem = semaphore(MAX_CONCURRENT_DOWNLOADS);
|
||||
|
||||
return this.api.listAllFiles(this.config.get("media_folder"))
|
||||
.then(files => files.map(({ id, name, download_url, path }) => {
|
||||
const getBlobPromise = () => new Promise((resolve, reject) =>
|
||||
sem.take(() =>
|
||||
this.api.readFile(path, id, { parseText: false })
|
||||
.then(resolve, reject)
|
||||
.finally(() => sem.leave())
|
||||
)
|
||||
);
|
||||
return this.api.listAllFiles(this.config.get('media_folder')).then(files =>
|
||||
files.map(({ id, name, download_url, path }) => {
|
||||
const getBlobPromise = () =>
|
||||
new Promise((resolve, reject) =>
|
||||
sem.take(() =>
|
||||
this.api
|
||||
.readFile(path, id, { parseText: false })
|
||||
.then(resolve, reject)
|
||||
.finally(() => sem.leave()),
|
||||
),
|
||||
);
|
||||
|
||||
return { id, name, getBlobPromise, url: download_url, path };
|
||||
}));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
persistEntry(entry, mediaFiles, options = {}) {
|
||||
@ -215,10 +241,11 @@ export default class Bitbucket {
|
||||
}
|
||||
|
||||
traverseCursor(cursor, action) {
|
||||
return this.api.traverseCursor(cursor, action)
|
||||
.then(async ({ entries, cursor: newCursor }) => ({
|
||||
entries: await Promise.all(entries.map(file => this.api.readFile(file.path, file.id).then(data => ({ file, data })))),
|
||||
cursor: newCursor,
|
||||
}));
|
||||
return this.api.traverseCursor(cursor, action).then(async ({ entries, cursor: newCursor }) => ({
|
||||
entries: await Promise.all(
|
||||
entries.map(file => this.api.readFile(file.path, file.id).then(data => ({ file, data }))),
|
||||
),
|
||||
cursor: newCursor,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,3 @@
|
||||
export BitbucketBackend from './implementation';
|
||||
export API from './API';
|
||||
export AuthenticationPage from './AuthenticationPage';
|
||||
|
||||
|
@ -8,7 +8,7 @@ import {
|
||||
shadows,
|
||||
colors,
|
||||
colorsRaw,
|
||||
lengths
|
||||
lengths,
|
||||
} from 'netlify-cms-ui-default';
|
||||
|
||||
const LoginButton = styled.button`
|
||||
@ -21,12 +21,12 @@ const LoginButton = styled.button`
|
||||
display: block;
|
||||
margin-top: 20px;
|
||||
margin-left: auto;
|
||||
`
|
||||
`;
|
||||
|
||||
const AuthForm = styled.form`
|
||||
width: 350px;
|
||||
margin-top: -80px;
|
||||
`
|
||||
`;
|
||||
|
||||
const AuthInput = styled.input`
|
||||
background-color: ${colorsRaw.white};
|
||||
@ -44,16 +44,16 @@ const AuthInput = styled.input`
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px ${colors.active};
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
const ErrorMessage = styled.p`
|
||||
color: ${colors.errorText};
|
||||
`
|
||||
`;
|
||||
|
||||
let component = null;
|
||||
|
||||
if (window.netlifyIdentity) {
|
||||
window.netlifyIdentity.on('login', (user) => {
|
||||
window.netlifyIdentity.on('login', user => {
|
||||
component && component.handleIdentityLogin(user);
|
||||
});
|
||||
window.netlifyIdentity.on('logout', () => {
|
||||
@ -78,14 +78,14 @@ export default class GitGatewayAuthenticationPage extends React.Component {
|
||||
component = null;
|
||||
}
|
||||
|
||||
handleIdentityLogin = (user) => {
|
||||
handleIdentityLogin = user => {
|
||||
this.props.onLogin(user);
|
||||
window.netlifyIdentity.close();
|
||||
}
|
||||
};
|
||||
|
||||
handleIdentityLogout = () => {
|
||||
window.netlifyIdentity.open();
|
||||
}
|
||||
};
|
||||
|
||||
handleIdentity = () => {
|
||||
const user = window.netlifyIdentity.currentUser();
|
||||
@ -94,20 +94,20 @@ export default class GitGatewayAuthenticationPage extends React.Component {
|
||||
} else {
|
||||
window.netlifyIdentity.open();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
onLogin: PropTypes.func.isRequired,
|
||||
inProgress: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
state = { email: "", password: "", errors: {} };
|
||||
state = { email: '', password: '', errors: {} };
|
||||
|
||||
handleChange = (name, e) => {
|
||||
this.setState({ ...this.state, [name]: e.target.value });
|
||||
};
|
||||
|
||||
handleLogin = (e) => {
|
||||
handleLogin = e => {
|
||||
e.preventDefault();
|
||||
|
||||
const { email, password } = this.state;
|
||||
@ -124,13 +124,17 @@ export default class GitGatewayAuthenticationPage extends React.Component {
|
||||
return;
|
||||
}
|
||||
|
||||
AuthenticationPage.authClient.login(this.state.email, this.state.password, true)
|
||||
.then((user) => {
|
||||
this.props.onLogin(user);
|
||||
})
|
||||
.catch((error) => {
|
||||
this.setState({ errors: { server: error.description || error.msg || error }, loggingIn: false });
|
||||
});
|
||||
AuthenticationPage.authClient
|
||||
.login(this.state.email, this.state.password, true)
|
||||
.then(user => {
|
||||
this.props.onLogin(user);
|
||||
})
|
||||
.catch(error => {
|
||||
this.setState({
|
||||
errors: { server: error.description || error.msg || error },
|
||||
loggingIn: false,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
@ -147,29 +151,33 @@ export default class GitGatewayAuthenticationPage extends React.Component {
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthenticationPage renderPageContent={() => (
|
||||
<AuthForm onSubmit={this.handleLogin}>
|
||||
{!error ? null : <ErrorMessage>{error}</ErrorMessage>}
|
||||
{!errors.server ? null : <ErrorMessage>{errors.server}</ErrorMessage>}
|
||||
<ErrorMessage>{errors.email || null}</ErrorMessage>
|
||||
<AuthInput
|
||||
type="text"
|
||||
name="email"
|
||||
placeholder="Email"
|
||||
value={this.state.email}
|
||||
onChange={partial(this.handleChange, 'email')}
|
||||
/>
|
||||
<ErrorMessage>{errors.password || null}</ErrorMessage>
|
||||
<AuthInput
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
value={this.state.password}
|
||||
onChange={partial(this.handleChange, 'password')}
|
||||
/>
|
||||
<LoginButton disabled={inProgress}>{inProgress ? 'Logging in...' : 'Login'}</LoginButton>
|
||||
</AuthForm>
|
||||
)}/>
|
||||
<AuthenticationPage
|
||||
renderPageContent={() => (
|
||||
<AuthForm onSubmit={this.handleLogin}>
|
||||
{!error ? null : <ErrorMessage>{error}</ErrorMessage>}
|
||||
{!errors.server ? null : <ErrorMessage>{errors.server}</ErrorMessage>}
|
||||
<ErrorMessage>{errors.email || null}</ErrorMessage>
|
||||
<AuthInput
|
||||
type="text"
|
||||
name="email"
|
||||
placeholder="Email"
|
||||
value={this.state.email}
|
||||
onChange={partial(this.handleChange, 'email')}
|
||||
/>
|
||||
<ErrorMessage>{errors.password || null}</ErrorMessage>
|
||||
<AuthInput
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
value={this.state.password}
|
||||
onChange={partial(this.handleChange, 'password')}
|
||||
/>
|
||||
<LoginButton disabled={inProgress}>
|
||||
{inProgress ? 'Logging in...' : 'Login'}
|
||||
</LoginButton>
|
||||
</AuthForm>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { API as GithubAPI } from "netlify-cms-backend-github";
|
||||
import { APIError } from "netlify-cms-lib-util";
|
||||
import { API as GithubAPI } from 'netlify-cms-backend-github';
|
||||
import { APIError } from 'netlify-cms-lib-util';
|
||||
|
||||
export default class API extends GithubAPI {
|
||||
constructor(config) {
|
||||
@ -7,7 +7,7 @@ export default class API extends GithubAPI {
|
||||
this.api_root = config.api_root;
|
||||
this.tokenPromise = config.tokenPromise;
|
||||
this.commitAuthor = config.commitAuthor;
|
||||
this.repoURL = "";
|
||||
this.repoURL = '';
|
||||
}
|
||||
|
||||
hasWriteAccess() {
|
||||
@ -15,26 +15,36 @@ export default class API extends GithubAPI {
|
||||
.then(() => true)
|
||||
.catch(error => {
|
||||
if (error.status === 401) {
|
||||
if (error.message === "Bad credentials") {
|
||||
throw new APIError("Git Gateway Error: Please ask your site administrator to reissue the Git Gateway token.", error.status, 'Git Gateway');
|
||||
if (error.message === 'Bad credentials') {
|
||||
throw new APIError(
|
||||
'Git Gateway Error: Please ask your site administrator to reissue the Git Gateway token.',
|
||||
error.status,
|
||||
'Git Gateway',
|
||||
);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else if (error.status === 404 && (error.message === undefined || error.message === "Unable to locate site configuration")) {
|
||||
throw new APIError(`Git Gateway Error: Please make sure Git Gateway is enabled on your site.`, error.status, 'Git Gateway');
|
||||
} else if (
|
||||
error.status === 404 &&
|
||||
(error.message === undefined || error.message === 'Unable to locate site configuration')
|
||||
) {
|
||||
throw new APIError(
|
||||
`Git Gateway Error: Please make sure Git Gateway is enabled on your site.`,
|
||||
error.status,
|
||||
'Git Gateway',
|
||||
);
|
||||
} else {
|
||||
console.error("Problem fetching repo data from Git Gateway");
|
||||
console.error('Problem fetching repo data from Git Gateway');
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getRequestHeaders(headers = {}) {
|
||||
return this.tokenPromise()
|
||||
.then((jwtToken) => {
|
||||
return this.tokenPromise().then(jwtToken => {
|
||||
const baseHeader = {
|
||||
"Authorization": `Bearer ${ jwtToken }`,
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${jwtToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
};
|
||||
|
||||
@ -42,17 +52,16 @@ export default class API extends GithubAPI {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
urlFor(path, options) {
|
||||
const cacheBuster = new Date().getTime();
|
||||
const params = [`ts=${ cacheBuster }`];
|
||||
const params = [`ts=${cacheBuster}`];
|
||||
if (options.params) {
|
||||
for (const key in options.params) {
|
||||
params.push(`${ key }=${ encodeURIComponent(options.params[key]) }`);
|
||||
params.push(`${key}=${encodeURIComponent(options.params[key])}`);
|
||||
}
|
||||
}
|
||||
if (params.length) {
|
||||
path += `?${ params.join("&") }`;
|
||||
path += `?${params.join('&')}`;
|
||||
}
|
||||
return this.api_root + path;
|
||||
}
|
||||
@ -65,22 +74,22 @@ export default class API extends GithubAPI {
|
||||
const url = this.urlFor(path, options);
|
||||
let responseStatus;
|
||||
return this.getRequestHeaders(options.headers || {})
|
||||
.then(headers => fetch(url, { ...options, headers }))
|
||||
.then((response) => {
|
||||
responseStatus = response.status;
|
||||
const contentType = response.headers.get("Content-Type");
|
||||
if (contentType && contentType.match(/json/)) {
|
||||
return this.parseJsonResponse(response);
|
||||
}
|
||||
const text = response.text();
|
||||
if (!response.ok) {
|
||||
return Promise.reject(text);
|
||||
}
|
||||
return text;
|
||||
})
|
||||
.catch(error => {
|
||||
throw new APIError((error.message || error.msg), responseStatus, 'Git Gateway');
|
||||
});
|
||||
.then(headers => fetch(url, { ...options, headers }))
|
||||
.then(response => {
|
||||
responseStatus = response.status;
|
||||
const contentType = response.headers.get('Content-Type');
|
||||
if (contentType && contentType.match(/json/)) {
|
||||
return this.parseJsonResponse(response);
|
||||
}
|
||||
const text = response.text();
|
||||
if (!response.ok) {
|
||||
return Promise.reject(text);
|
||||
}
|
||||
return text;
|
||||
})
|
||||
.catch(error => {
|
||||
throw new APIError(error.message || error.msg, responseStatus, 'Git Gateway');
|
||||
});
|
||||
}
|
||||
|
||||
commit(message, changeTree) {
|
||||
@ -97,10 +106,9 @@ export default class API extends GithubAPI {
|
||||
};
|
||||
}
|
||||
|
||||
return this.request("/git/commits", {
|
||||
method: "POST",
|
||||
return this.request('/git/commits', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(commitParams),
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,24 +1,25 @@
|
||||
import { flow } from "lodash";
|
||||
import { API as GitlabAPI } from "netlify-cms-backend-gitlab";
|
||||
import { unsentRequest, then } from "netlify-cms-lib-util";
|
||||
import { flow } from 'lodash';
|
||||
import { API as GitlabAPI } from 'netlify-cms-backend-gitlab';
|
||||
import { unsentRequest, then } from 'netlify-cms-lib-util';
|
||||
|
||||
export default class API extends GitlabAPI {
|
||||
constructor(config) {
|
||||
super(config);
|
||||
this.tokenPromise = config.tokenPromise;
|
||||
this.commitAuthor = config.commitAuthor;
|
||||
this.repoURL = "";
|
||||
this.repoURL = '';
|
||||
}
|
||||
|
||||
authenticateRequest = async req => unsentRequest.withHeaders({
|
||||
Authorization: `Bearer ${ await this.tokenPromise() }`,
|
||||
}, req);
|
||||
authenticateRequest = async req =>
|
||||
unsentRequest.withHeaders(
|
||||
{
|
||||
Authorization: `Bearer ${await this.tokenPromise()}`,
|
||||
},
|
||||
req,
|
||||
);
|
||||
|
||||
request = async req => flow([
|
||||
this.buildRequest,
|
||||
this.authenticateRequest,
|
||||
then(unsentRequest.performRequest),
|
||||
])(req);
|
||||
request = async req =>
|
||||
flow([this.buildRequest, this.authenticateRequest, then(unsentRequest.performRequest)])(req);
|
||||
|
||||
hasWriteAccess = () => Promise.resolve(true)
|
||||
hasWriteAccess = () => Promise.resolve(true);
|
||||
}
|
||||
|
@ -1,13 +1,13 @@
|
||||
import GoTrue from "gotrue-js";
|
||||
import GoTrue from 'gotrue-js';
|
||||
import jwtDecode from 'jwt-decode';
|
||||
import { get, pick, intersection } from "lodash";
|
||||
import { unsentRequest } from "netlify-cms-lib-util";
|
||||
import { GitHubBackend } from "netlify-cms-backend-github";
|
||||
import { GitLabBackend } from "netlify-cms-backend-gitlab";
|
||||
import { BitBucketBackend, API as BitBucketAPI } from "netlify-cms-backend-bitbucket";
|
||||
import GitHubAPI from "./GitHubAPI";
|
||||
import GitLabAPI from "./GitLabAPI";
|
||||
import AuthenticationPage from "./AuthenticationPage";
|
||||
import { get, pick, intersection } from 'lodash';
|
||||
import { unsentRequest } from 'netlify-cms-lib-util';
|
||||
import { GitHubBackend } from 'netlify-cms-backend-github';
|
||||
import { GitLabBackend } from 'netlify-cms-backend-gitlab';
|
||||
import { BitBucketBackend, API as BitBucketAPI } from 'netlify-cms-backend-bitbucket';
|
||||
import GitHubAPI from './GitHubAPI';
|
||||
import GitLabAPI from './GitLabAPI';
|
||||
import AuthenticationPage from './AuthenticationPage';
|
||||
|
||||
const localHosts = {
|
||||
localhost: true,
|
||||
@ -20,14 +20,20 @@ const defaults = {
|
||||
};
|
||||
|
||||
function getEndpoint(endpoint, netlifySiteURL) {
|
||||
if (localHosts[document.location.host.split(":").shift()] && netlifySiteURL && endpoint.match(/^\/\.netlify\//)) {
|
||||
if (
|
||||
localHosts[document.location.host.split(':').shift()] &&
|
||||
netlifySiteURL &&
|
||||
endpoint.match(/^\/\.netlify\//)
|
||||
) {
|
||||
const parts = [];
|
||||
if (netlifySiteURL) {
|
||||
parts.push(netlifySiteURL);
|
||||
if (!netlifySiteURL.match(/\/$/)) { parts.push("/"); }
|
||||
if (!netlifySiteURL.match(/\/$/)) {
|
||||
parts.push('/');
|
||||
}
|
||||
}
|
||||
parts.push(endpoint.replace(/^\//, ''));
|
||||
return parts.join("");
|
||||
return parts.join('');
|
||||
}
|
||||
return endpoint;
|
||||
}
|
||||
@ -40,46 +46,58 @@ export default class GitGateway {
|
||||
...options,
|
||||
};
|
||||
this.config = config;
|
||||
this.branch = config.getIn(["backend", "branch"], "master").trim();
|
||||
this.squash_merges = config.getIn(["backend", "squash_merges"]);
|
||||
this.branch = config.getIn(['backend', 'branch'], 'master').trim();
|
||||
this.squash_merges = config.getIn(['backend', 'squash_merges']);
|
||||
|
||||
const netlifySiteURL = localStorage.getItem("netlifySiteURL");
|
||||
const APIUrl = getEndpoint(config.getIn(["backend", "identity_url"], defaults.identity), netlifySiteURL);
|
||||
this.gatewayUrl = getEndpoint(config.getIn(["backend", "gateway_url"], defaults.gateway), netlifySiteURL);
|
||||
const netlifySiteURL = localStorage.getItem('netlifySiteURL');
|
||||
const APIUrl = getEndpoint(
|
||||
config.getIn(['backend', 'identity_url'], defaults.identity),
|
||||
netlifySiteURL,
|
||||
);
|
||||
this.gatewayUrl = getEndpoint(
|
||||
config.getIn(['backend', 'gateway_url'], defaults.gateway),
|
||||
netlifySiteURL,
|
||||
);
|
||||
|
||||
const backendTypeRegex = /\/(github|gitlab|bitbucket)\/?$/;
|
||||
const backendTypeMatches = this.gatewayUrl.match(backendTypeRegex);
|
||||
if (backendTypeMatches) {
|
||||
this.backendType = backendTypeMatches[1];
|
||||
this.gatewayUrl = this.gatewayUrl.replace(backendTypeRegex, "/");
|
||||
this.gatewayUrl = this.gatewayUrl.replace(backendTypeRegex, '/');
|
||||
} else {
|
||||
this.backendType = null;
|
||||
}
|
||||
|
||||
this.authClient = window.netlifyIdentity ? window.netlifyIdentity.gotrue : new GoTrue({ APIUrl });
|
||||
this.authClient = window.netlifyIdentity
|
||||
? window.netlifyIdentity.gotrue
|
||||
: new GoTrue({ APIUrl });
|
||||
AuthenticationPage.authClient = this.authClient;
|
||||
|
||||
this.backend = null;
|
||||
}
|
||||
|
||||
requestFunction = req => this.tokenPromise()
|
||||
.then(token => unsentRequest.withHeaders({ Authorization: `Bearer ${ token }` }, req))
|
||||
.then(unsentRequest.performRequest);
|
||||
requestFunction = req =>
|
||||
this.tokenPromise()
|
||||
.then(token => unsentRequest.withHeaders({ Authorization: `Bearer ${token}` }, req))
|
||||
.then(unsentRequest.performRequest);
|
||||
|
||||
authenticate(user) {
|
||||
this.tokenPromise = user.jwt.bind(user);
|
||||
return this.tokenPromise().then(async token => {
|
||||
if (!this.backendType) {
|
||||
const { github_enabled, gitlab_enabled, bitbucket_enabled, roles } = await fetch(`${ this.gatewayUrl }/settings`, {
|
||||
headers: { Authorization: `Bearer ${ token }` },
|
||||
}).then(res => res.json());
|
||||
const { github_enabled, gitlab_enabled, bitbucket_enabled, roles } = await fetch(
|
||||
`${this.gatewayUrl}/settings`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
).then(res => res.json());
|
||||
this.acceptRoles = roles;
|
||||
if (github_enabled) {
|
||||
this.backendType = "github";
|
||||
this.backendType = 'github';
|
||||
} else if (gitlab_enabled) {
|
||||
this.backendType = "gitlab";
|
||||
this.backendType = 'gitlab';
|
||||
} else if (bitbucket_enabled) {
|
||||
this.backendType = "bitbucket";
|
||||
this.backendType = 'bitbucket';
|
||||
}
|
||||
}
|
||||
|
||||
@ -98,21 +116,21 @@ export default class GitGateway {
|
||||
metadata: user.user_metadata,
|
||||
};
|
||||
const apiConfig = {
|
||||
api_root: `${ this.gatewayUrl }/${ this.backendType }`,
|
||||
api_root: `${this.gatewayUrl}/${this.backendType}`,
|
||||
branch: this.branch,
|
||||
tokenPromise: this.tokenPromise,
|
||||
commitAuthor: pick(userData, ["name", "email"]),
|
||||
commitAuthor: pick(userData, ['name', 'email']),
|
||||
squash_merges: this.squash_merges,
|
||||
initialWorkflowStatus: this.options.initialWorkflowStatus,
|
||||
};
|
||||
|
||||
if (this.backendType === "github") {
|
||||
if (this.backendType === 'github') {
|
||||
this.api = new GitHubAPI(apiConfig);
|
||||
this.backend = new GitHubBackend(this.config, { ...this.options, API: this.api });
|
||||
} else if (this.backendType === "gitlab") {
|
||||
} else if (this.backendType === 'gitlab') {
|
||||
this.api = new GitLabAPI(apiConfig);
|
||||
this.backend = new GitLabBackend(this.config, { ...this.options, API: this.api });
|
||||
} else if (this.backendType === "bitbucket") {
|
||||
} else if (this.backendType === 'bitbucket') {
|
||||
this.api = new BitBucketAPI({
|
||||
...apiConfig,
|
||||
requestFunction: this.requestFunction,
|
||||
@ -145,18 +163,46 @@ export default class GitGateway {
|
||||
return this.tokenPromise();
|
||||
}
|
||||
|
||||
entriesByFolder(collection, extension) { return this.backend.entriesByFolder(collection, extension); }
|
||||
entriesByFiles(collection) { return this.backend.entriesByFiles(collection); }
|
||||
fetchFiles(files) { return this.backend.fetchFiles(files); }
|
||||
getEntry(collection, slug, path) { return this.backend.getEntry(collection, slug, path); }
|
||||
getMedia() { return this.backend.getMedia(); }
|
||||
persistEntry(entry, mediaFiles, options) { return this.backend.persistEntry(entry, mediaFiles, options); }
|
||||
persistMedia(mediaFile, options) { return this.backend.persistMedia(mediaFile, options); }
|
||||
deleteFile(path, commitMessage, options) { return this.backend.deleteFile(path, commitMessage, options); }
|
||||
unpublishedEntries() { return this.backend.unpublishedEntries(); }
|
||||
unpublishedEntry(collection, slug) { return this.backend.unpublishedEntry(collection, slug); }
|
||||
updateUnpublishedEntryStatus(collection, slug, newStatus) { return this.backend.updateUnpublishedEntryStatus(collection, slug, newStatus); }
|
||||
deleteUnpublishedEntry(collection, slug) { return this.backend.deleteUnpublishedEntry(collection, slug); }
|
||||
publishUnpublishedEntry(collection, slug) { return this.backend.publishUnpublishedEntry(collection, slug); }
|
||||
traverseCursor(cursor, action) { return this.backend.traverseCursor(cursor, action); }
|
||||
entriesByFolder(collection, extension) {
|
||||
return this.backend.entriesByFolder(collection, extension);
|
||||
}
|
||||
entriesByFiles(collection) {
|
||||
return this.backend.entriesByFiles(collection);
|
||||
}
|
||||
fetchFiles(files) {
|
||||
return this.backend.fetchFiles(files);
|
||||
}
|
||||
getEntry(collection, slug, path) {
|
||||
return this.backend.getEntry(collection, slug, path);
|
||||
}
|
||||
getMedia() {
|
||||
return this.backend.getMedia();
|
||||
}
|
||||
persistEntry(entry, mediaFiles, options) {
|
||||
return this.backend.persistEntry(entry, mediaFiles, options);
|
||||
}
|
||||
persistMedia(mediaFile, options) {
|
||||
return this.backend.persistMedia(mediaFile, options);
|
||||
}
|
||||
deleteFile(path, commitMessage, options) {
|
||||
return this.backend.deleteFile(path, commitMessage, options);
|
||||
}
|
||||
unpublishedEntries() {
|
||||
return this.backend.unpublishedEntries();
|
||||
}
|
||||
unpublishedEntry(collection, slug) {
|
||||
return this.backend.unpublishedEntry(collection, slug);
|
||||
}
|
||||
updateUnpublishedEntryStatus(collection, slug, newStatus) {
|
||||
return this.backend.updateUnpublishedEntryStatus(collection, slug, newStatus);
|
||||
}
|
||||
deleteUnpublishedEntry(collection, slug) {
|
||||
return this.backend.deleteUnpublishedEntry(collection, slug);
|
||||
}
|
||||
publishUnpublishedEntry(collection, slug) {
|
||||
return this.backend.publishUnpublishedEntry(collection, slug);
|
||||
}
|
||||
traverseCursor(cursor, action) {
|
||||
return this.backend.traverseCursor(cursor, action);
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,2 @@
|
||||
export GitGatewayBackend from './implementation';
|
||||
export AuthenticationPage from './AuthenticationPage';
|
||||
|
||||
|
@ -1,43 +1,43 @@
|
||||
import { localForage } from "netlify-cms-lib-util";
|
||||
import { Base64 } from "js-base64";
|
||||
import { uniq, initial, last, get, find, hasIn, partial, result } from "lodash";
|
||||
import { filterPromises, resolvePromiseProperties } from "netlify-cms-lib-util";
|
||||
import { APIError, EditorialWorkflowError } from "netlify-cms-lib-util";
|
||||
import { localForage } from 'netlify-cms-lib-util';
|
||||
import { Base64 } from 'js-base64';
|
||||
import { uniq, initial, last, get, find, hasIn, partial, result } from 'lodash';
|
||||
import { filterPromises, resolvePromiseProperties } from 'netlify-cms-lib-util';
|
||||
import { APIError, EditorialWorkflowError } from 'netlify-cms-lib-util';
|
||||
|
||||
const CMS_BRANCH_PREFIX = 'cms/';
|
||||
|
||||
export default class API {
|
||||
constructor(config) {
|
||||
this.api_root = config.api_root || "https://api.github.com";
|
||||
this.api_root = config.api_root || 'https://api.github.com';
|
||||
this.token = config.token || false;
|
||||
this.branch = config.branch || "master";
|
||||
this.repo = config.repo || "";
|
||||
this.repoURL = `/repos/${ this.repo }`;
|
||||
this.merge_method = config.squash_merges ? "squash" : "merge";
|
||||
this.branch = config.branch || 'master';
|
||||
this.repo = config.repo || '';
|
||||
this.repoURL = `/repos/${this.repo}`;
|
||||
this.merge_method = config.squash_merges ? 'squash' : 'merge';
|
||||
this.initialWorkflowStatus = config.initialWorkflowStatus;
|
||||
}
|
||||
|
||||
user() {
|
||||
return this.request("/user");
|
||||
return this.request('/user');
|
||||
}
|
||||
|
||||
hasWriteAccess() {
|
||||
return this.request(this.repoURL)
|
||||
.then(repo => repo.permissions.push)
|
||||
.catch(error => {
|
||||
console.error("Problem fetching repo data from GitHub");
|
||||
console.error('Problem fetching repo data from GitHub');
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
requestHeaders(headers = {}) {
|
||||
const baseHeader = {
|
||||
"Content-Type": "application/json",
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
baseHeader.Authorization = `token ${ this.token }`;
|
||||
baseHeader.Authorization = `token ${this.token}`;
|
||||
return baseHeader;
|
||||
}
|
||||
|
||||
@ -45,7 +45,7 @@ export default class API {
|
||||
}
|
||||
|
||||
parseJsonResponse(response) {
|
||||
return response.json().then((json) => {
|
||||
return response.json().then(json => {
|
||||
if (!response.ok) {
|
||||
return Promise.reject(json);
|
||||
}
|
||||
@ -59,11 +59,11 @@ export default class API {
|
||||
const params = [`ts=${cacheBuster}`];
|
||||
if (options.params) {
|
||||
for (const key in options.params) {
|
||||
params.push(`${ key }=${ encodeURIComponent(options.params[key]) }`);
|
||||
params.push(`${key}=${encodeURIComponent(options.params[key])}`);
|
||||
}
|
||||
}
|
||||
if (params.length) {
|
||||
path += `?${ params.join("&") }`;
|
||||
path += `?${params.join('&')}`;
|
||||
}
|
||||
return this.api_root + path;
|
||||
}
|
||||
@ -72,21 +72,22 @@ export default class API {
|
||||
const headers = this.requestHeaders(options.headers || {});
|
||||
const url = this.urlFor(path, options);
|
||||
let responseStatus;
|
||||
return fetch(url, { ...options, headers }).then((response) => {
|
||||
responseStatus = response.status;
|
||||
const contentType = response.headers.get("Content-Type");
|
||||
if (contentType && contentType.match(/json/)) {
|
||||
return this.parseJsonResponse(response);
|
||||
}
|
||||
const text = response.text();
|
||||
if (!response.ok) {
|
||||
return Promise.reject(text);
|
||||
}
|
||||
return text;
|
||||
})
|
||||
.catch((error) => {
|
||||
throw new APIError(error.message, responseStatus, 'GitHub');
|
||||
});
|
||||
return fetch(url, { ...options, headers })
|
||||
.then(response => {
|
||||
responseStatus = response.status;
|
||||
const contentType = response.headers.get('Content-Type');
|
||||
if (contentType && contentType.match(/json/)) {
|
||||
return this.parseJsonResponse(response);
|
||||
}
|
||||
const text = response.text();
|
||||
if (!response.ok) {
|
||||
return Promise.reject(text);
|
||||
}
|
||||
return text;
|
||||
})
|
||||
.catch(error => {
|
||||
throw new APIError(error.message, responseStatus, 'GitHub');
|
||||
});
|
||||
}
|
||||
|
||||
generateBranchName(basename) {
|
||||
@ -94,63 +95,78 @@ export default class API {
|
||||
}
|
||||
|
||||
checkMetadataRef() {
|
||||
return this.request(`${ this.repoURL }/git/refs/meta/_netlify_cms?${ Date.now() }`, {
|
||||
cache: "no-store",
|
||||
return this.request(`${this.repoURL}/git/refs/meta/_netlify_cms?${Date.now()}`, {
|
||||
cache: 'no-store',
|
||||
})
|
||||
.then(response => response.object)
|
||||
.catch(() => {
|
||||
// Meta ref doesn't exist
|
||||
const readme = {
|
||||
raw: "# Netlify CMS\n\nThis tree is used by the Netlify CMS to store metadata information for specific files and branches.",
|
||||
};
|
||||
.then(response => response.object)
|
||||
.catch(() => {
|
||||
// Meta ref doesn't exist
|
||||
const readme = {
|
||||
raw:
|
||||
'# Netlify CMS\n\nThis tree is used by the Netlify CMS to store metadata information for specific files and branches.',
|
||||
};
|
||||
|
||||
return this.uploadBlob(readme)
|
||||
.then(item => this.request(`${ this.repoURL }/git/trees`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ tree: [{ path: "README.md", mode: "100644", type: "blob", sha: item.sha }] }),
|
||||
}))
|
||||
.then(tree => this.commit("First Commit", tree))
|
||||
.then(response => this.createRef("meta", "_netlify_cms", response.sha))
|
||||
.then(response => response.object);
|
||||
});
|
||||
return this.uploadBlob(readme)
|
||||
.then(item =>
|
||||
this.request(`${this.repoURL}/git/trees`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
tree: [{ path: 'README.md', mode: '100644', type: 'blob', sha: item.sha }],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.then(tree => this.commit('First Commit', tree))
|
||||
.then(response => this.createRef('meta', '_netlify_cms', response.sha))
|
||||
.then(response => response.object);
|
||||
});
|
||||
}
|
||||
|
||||
storeMetadata(key, data) {
|
||||
return this.checkMetadataRef()
|
||||
.then((branchData) => {
|
||||
return this.checkMetadataRef().then(branchData => {
|
||||
const fileTree = {
|
||||
[`${ key }.json`]: {
|
||||
path: `${ key }.json`,
|
||||
[`${key}.json`]: {
|
||||
path: `${key}.json`,
|
||||
raw: JSON.stringify(data),
|
||||
file: true,
|
||||
},
|
||||
};
|
||||
|
||||
return this.uploadBlob(fileTree[`${ key }.json`])
|
||||
.then(() => this.updateTree(branchData.sha, "/", fileTree))
|
||||
.then(changeTree => this.commit(`Updating “${ key }” metadata`, changeTree))
|
||||
.then(response => this.patchRef("meta", "_netlify_cms", response.sha))
|
||||
.then(() => {
|
||||
localForage.setItem(`gh.meta.${ key }`, {
|
||||
expires: Date.now() + 300000, // In 5 minutes
|
||||
data,
|
||||
return this.uploadBlob(fileTree[`${key}.json`])
|
||||
.then(() => this.updateTree(branchData.sha, '/', fileTree))
|
||||
.then(changeTree => this.commit(`Updating “${key}” metadata`, changeTree))
|
||||
.then(response => this.patchRef('meta', '_netlify_cms', response.sha))
|
||||
.then(() => {
|
||||
localForage.setItem(`gh.meta.${key}`, {
|
||||
expires: Date.now() + 300000, // In 5 minutes
|
||||
data,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
retrieveMetadata(key) {
|
||||
const cache = localForage.getItem(`gh.meta.${ key }`);
|
||||
return cache.then((cached) => {
|
||||
if (cached && cached.expires > Date.now()) { return cached.data; }
|
||||
console.log("%c Checking for MetaData files", "line-height: 30px;text-align: center;font-weight: bold");
|
||||
return this.request(`${ this.repoURL }/contents/${ key }.json`, {
|
||||
params: { ref: "refs/meta/_netlify_cms" },
|
||||
headers: { Accept: "application/vnd.github.VERSION.raw" },
|
||||
cache: "no-store",
|
||||
const cache = localForage.getItem(`gh.meta.${key}`);
|
||||
return cache.then(cached => {
|
||||
if (cached && cached.expires > Date.now()) {
|
||||
return cached.data;
|
||||
}
|
||||
console.log(
|
||||
'%c Checking for MetaData files',
|
||||
'line-height: 30px;text-align: center;font-weight: bold',
|
||||
);
|
||||
return this.request(`${this.repoURL}/contents/${key}.json`, {
|
||||
params: { ref: 'refs/meta/_netlify_cms' },
|
||||
headers: { Accept: 'application/vnd.github.VERSION.raw' },
|
||||
cache: 'no-store',
|
||||
})
|
||||
.then(response => JSON.parse(response))
|
||||
.catch(() => console.log("%c %s does not have metadata", "line-height: 30px;text-align: center;font-weight: bold", key));
|
||||
.then(response => JSON.parse(response))
|
||||
.catch(() =>
|
||||
console.log(
|
||||
'%c %s does not have metadata',
|
||||
'line-height: 30px;text-align: center;font-weight: bold',
|
||||
key,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@ -158,13 +174,16 @@ export default class API {
|
||||
if (sha) {
|
||||
return this.getBlob(sha);
|
||||
} else {
|
||||
return this.request(`${ this.repoURL }/contents/${ path }`, {
|
||||
headers: { Accept: "application/vnd.github.VERSION.raw" },
|
||||
return this.request(`${this.repoURL}/contents/${path}`, {
|
||||
headers: { Accept: 'application/vnd.github.VERSION.raw' },
|
||||
params: { ref: branch },
|
||||
cache: "no-store",
|
||||
cache: 'no-store',
|
||||
}).catch(error => {
|
||||
if (hasIn(error, 'message.errors') && find(error.message.errors, { code: "too_large" })) {
|
||||
const dir = path.split('/').slice(0, -1).join('/');
|
||||
if (hasIn(error, 'message.errors') && find(error.message.errors, { code: 'too_large' })) {
|
||||
const dir = path
|
||||
.split('/')
|
||||
.slice(0, -1)
|
||||
.join('/');
|
||||
return this.listFiles(dir)
|
||||
.then(files => files.find(file => file.path === path))
|
||||
.then(file => this.getBlob(file.sha));
|
||||
@ -176,10 +195,12 @@ export default class API {
|
||||
|
||||
getBlob(sha) {
|
||||
return localForage.getItem(`gh.${sha}`).then(cached => {
|
||||
if (cached) { return cached; }
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
return this.request(`${this.repoURL}/git/blobs/${sha}`, {
|
||||
headers: { Accept: "application/vnd.github.VERSION.raw" },
|
||||
headers: { Accept: 'application/vnd.github.VERSION.raw' },
|
||||
}).then(result => {
|
||||
localForage.setItem(`gh.${sha}`, result);
|
||||
return result;
|
||||
@ -188,66 +209,75 @@ export default class API {
|
||||
}
|
||||
|
||||
listFiles(path) {
|
||||
return this.request(`${ this.repoURL }/contents/${ path.replace(/\/$/, '') }`, {
|
||||
return this.request(`${this.repoURL}/contents/${path.replace(/\/$/, '')}`, {
|
||||
params: { ref: this.branch },
|
||||
})
|
||||
.then(files => {
|
||||
if (!Array.isArray(files)) {
|
||||
throw new Error(`Cannot list files, path ${path} is not a directory but a ${files.type}`);
|
||||
}
|
||||
return files;
|
||||
})
|
||||
.then(files => files.filter(file => file.type === "file"));
|
||||
.then(files => {
|
||||
if (!Array.isArray(files)) {
|
||||
throw new Error(`Cannot list files, path ${path} is not a directory but a ${files.type}`);
|
||||
}
|
||||
return files;
|
||||
})
|
||||
.then(files => files.filter(file => file.type === 'file'));
|
||||
}
|
||||
|
||||
readUnpublishedBranchFile(contentKey) {
|
||||
const metaDataPromise = this.retrieveMetadata(contentKey)
|
||||
.then(data => (data.objects.entry.path ? data : Promise.reject(null)));
|
||||
const metaDataPromise = this.retrieveMetadata(contentKey).then(
|
||||
data => (data.objects.entry.path ? data : Promise.reject(null)),
|
||||
);
|
||||
return resolvePromiseProperties({
|
||||
metaData: metaDataPromise,
|
||||
fileData: metaDataPromise.then(
|
||||
data => this.readFile(data.objects.entry.path, null, data.branch)),
|
||||
isModification: metaDataPromise.then(
|
||||
data => this.isUnpublishedEntryModification(data.objects.entry.path, this.branch)),
|
||||
})
|
||||
.catch(() => {
|
||||
fileData: metaDataPromise.then(data =>
|
||||
this.readFile(data.objects.entry.path, null, data.branch),
|
||||
),
|
||||
isModification: metaDataPromise.then(data =>
|
||||
this.isUnpublishedEntryModification(data.objects.entry.path, this.branch),
|
||||
),
|
||||
}).catch(() => {
|
||||
throw new EditorialWorkflowError('content is not under editorial workflow', true);
|
||||
});
|
||||
}
|
||||
|
||||
isUnpublishedEntryModification(path, branch) {
|
||||
return this.readFile(path, null, branch)
|
||||
.then(() => true)
|
||||
.catch((err) => {
|
||||
if (err.message && err.message === "Not Found") {
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
.then(() => true)
|
||||
.catch(err => {
|
||||
if (err.message && err.message === 'Not Found') {
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
listUnpublishedBranches() {
|
||||
console.log("%c Checking for Unpublished entries", "line-height: 30px;text-align: center;font-weight: bold");
|
||||
return this.request(`${ this.repoURL }/git/refs/heads/cms`)
|
||||
.then(branches => filterPromises(branches, (branch) => {
|
||||
const branchName = branch.ref.substring("/refs/heads/".length - 1);
|
||||
console.log(
|
||||
'%c Checking for Unpublished entries',
|
||||
'line-height: 30px;text-align: center;font-weight: bold',
|
||||
);
|
||||
return this.request(`${this.repoURL}/git/refs/heads/cms`)
|
||||
.then(branches =>
|
||||
filterPromises(branches, branch => {
|
||||
const branchName = branch.ref.substring('/refs/heads/'.length - 1);
|
||||
|
||||
// Get PRs with a `head` of `branchName`. Note that this is a
|
||||
// substring match, so we need to check that the `head.ref` of
|
||||
// at least one of the returned objects matches `branchName`.
|
||||
return this.request(`${ this.repoURL }/pulls`, {
|
||||
params: {
|
||||
head: branchName,
|
||||
state: 'open',
|
||||
base: this.branch,
|
||||
},
|
||||
})
|
||||
.then(prs => prs.some(pr => pr.head.ref === branchName));
|
||||
}))
|
||||
.catch((error) => {
|
||||
console.log("%c No Unpublished entries", "line-height: 30px;text-align: center;font-weight: bold");
|
||||
throw error;
|
||||
});
|
||||
// Get PRs with a `head` of `branchName`. Note that this is a
|
||||
// substring match, so we need to check that the `head.ref` of
|
||||
// at least one of the returned objects matches `branchName`.
|
||||
return this.request(`${this.repoURL}/pulls`, {
|
||||
params: {
|
||||
head: branchName,
|
||||
state: 'open',
|
||||
base: this.branch,
|
||||
},
|
||||
}).then(prs => prs.some(pr => pr.head.ref === branchName));
|
||||
}),
|
||||
)
|
||||
.catch(error => {
|
||||
console.log(
|
||||
'%c No Unpublished entries',
|
||||
'line-height: 30px;text-align: center;font-weight: bold',
|
||||
);
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
composeFileTree(files) {
|
||||
@ -257,12 +287,15 @@ export default class API {
|
||||
let subtree;
|
||||
const fileTree = {};
|
||||
|
||||
files.forEach((file) => {
|
||||
if (file.uploaded) { return; }
|
||||
parts = file.path.split("/").filter(part => part);
|
||||
files.forEach(file => {
|
||||
if (file.uploaded) {
|
||||
return;
|
||||
}
|
||||
parts = file.path.split('/').filter(part => part);
|
||||
filename = parts.pop();
|
||||
subtree = fileTree;
|
||||
while (part = parts.shift()) { // eslint-disable-line no-cond-assign
|
||||
while ((part = parts.shift())) {
|
||||
// eslint-disable-line no-cond-assign
|
||||
subtree[part] = subtree[part] || {};
|
||||
subtree = subtree[part];
|
||||
}
|
||||
@ -277,8 +310,10 @@ export default class API {
|
||||
const uploadPromises = [];
|
||||
const files = entry ? mediaFiles.concat(entry) : mediaFiles;
|
||||
|
||||
files.forEach((file) => {
|
||||
if (file.uploaded) { return; }
|
||||
files.forEach(file => {
|
||||
if (file.uploaded) {
|
||||
return;
|
||||
}
|
||||
uploadPromises.push(this.uploadBlob(file));
|
||||
});
|
||||
|
||||
@ -287,10 +322,9 @@ export default class API {
|
||||
return Promise.all(uploadPromises).then(() => {
|
||||
if (!options.useWorkflow) {
|
||||
return this.getBranch()
|
||||
.then(branchData => this.updateTree(branchData.commit.sha, "/", fileTree))
|
||||
.then(changeTree => this.commit(options.commitMessage, changeTree))
|
||||
.then(response => this.patchBranch(this.branch, response.sha));
|
||||
|
||||
.then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree))
|
||||
.then(changeTree => this.commit(options.commitMessage, changeTree))
|
||||
.then(response => this.patchBranch(this.branch, response.sha));
|
||||
} else {
|
||||
const mediaFilesList = mediaFiles.map(file => ({ path: file.path, sha: file.sha }));
|
||||
return this.editorialWorkflowGit(fileTree, entry, mediaFilesList, options);
|
||||
@ -298,32 +332,31 @@ export default class API {
|
||||
});
|
||||
}
|
||||
|
||||
deleteFile(path, message, options={}) {
|
||||
deleteFile(path, message, options = {}) {
|
||||
const branch = options.branch || this.branch;
|
||||
const pathArray = path.split('/');
|
||||
const filename = last(pathArray);
|
||||
const directory = initial(pathArray).join('/');
|
||||
const fileDataPath = encodeURIComponent(directory);
|
||||
const fileDataURL = `${this.repoURL}/git/trees/${branch}:${fileDataPath}`;
|
||||
const fileURL = `${ this.repoURL }/contents/${ path }`;
|
||||
const fileURL = `${this.repoURL}/contents/${path}`;
|
||||
|
||||
/**
|
||||
* We need to request the tree first to get the SHA. We use extended SHA-1
|
||||
* syntax (<rev>:<path>) to get a blob from a tree without having to recurse
|
||||
* through the tree.
|
||||
*/
|
||||
return this.request(fileDataURL, { cache: 'no-store' })
|
||||
.then(resp => {
|
||||
const { sha } = resp.tree.find(file => file.path === filename);
|
||||
const opts = { method: 'DELETE', params: { sha, message, branch } };
|
||||
if (this.commitAuthor) {
|
||||
opts.params.author = {
|
||||
...this.commitAuthor,
|
||||
date: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
return this.request(fileURL, opts);
|
||||
});
|
||||
return this.request(fileDataURL, { cache: 'no-store' }).then(resp => {
|
||||
const { sha } = resp.tree.find(file => file.path === filename);
|
||||
const opts = { method: 'DELETE', params: { sha, message, branch } };
|
||||
if (this.commitAuthor) {
|
||||
opts.params.author = {
|
||||
...this.commitAuthor,
|
||||
date: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
return this.request(fileURL, opts);
|
||||
});
|
||||
}
|
||||
|
||||
editorialWorkflowGit(fileTree, entry, filesList, options) {
|
||||
@ -335,42 +368,42 @@ export default class API {
|
||||
let prResponse;
|
||||
|
||||
return this.getBranch()
|
||||
.then(branchData => this.updateTree(branchData.commit.sha, "/", fileTree))
|
||||
.then(changeTree => this.commit(options.commitMessage, changeTree))
|
||||
.then(commitResponse => this.createBranch(branchName, commitResponse.sha))
|
||||
.then(() => this.createPR(options.commitMessage, branchName))
|
||||
.then(pr => {
|
||||
prResponse = pr;
|
||||
return this.user();
|
||||
})
|
||||
.then(user => {
|
||||
return this.storeMetadata(contentKey, {
|
||||
type: "PR",
|
||||
pr: {
|
||||
number: prResponse.number,
|
||||
head: prResponse.head && prResponse.head.sha,
|
||||
},
|
||||
user: user.name || user.login,
|
||||
status: this.initialWorkflowStatus,
|
||||
branch: branchName,
|
||||
collection: options.collectionName,
|
||||
title: options.parsedData && options.parsedData.title,
|
||||
description: options.parsedData && options.parsedData.description,
|
||||
objects: {
|
||||
entry: {
|
||||
path: entry.path,
|
||||
sha: entry.sha,
|
||||
.then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree))
|
||||
.then(changeTree => this.commit(options.commitMessage, changeTree))
|
||||
.then(commitResponse => this.createBranch(branchName, commitResponse.sha))
|
||||
.then(() => this.createPR(options.commitMessage, branchName))
|
||||
.then(pr => {
|
||||
prResponse = pr;
|
||||
return this.user();
|
||||
})
|
||||
.then(user => {
|
||||
return this.storeMetadata(contentKey, {
|
||||
type: 'PR',
|
||||
pr: {
|
||||
number: prResponse.number,
|
||||
head: prResponse.head && prResponse.head.sha,
|
||||
},
|
||||
files: filesList,
|
||||
},
|
||||
timeStamp: new Date().toISOString(),
|
||||
user: user.name || user.login,
|
||||
status: this.initialWorkflowStatus,
|
||||
branch: branchName,
|
||||
collection: options.collectionName,
|
||||
title: options.parsedData && options.parsedData.title,
|
||||
description: options.parsedData && options.parsedData.description,
|
||||
objects: {
|
||||
entry: {
|
||||
path: entry.path,
|
||||
sha: entry.sha,
|
||||
},
|
||||
files: filesList,
|
||||
},
|
||||
timeStamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Entry is already on editorial review workflow - just update metadata and commit to existing branch
|
||||
let newHead;
|
||||
return this.getBranch(branchName)
|
||||
.then(branchData => this.updateTree(branchData.commit.sha, "/", fileTree))
|
||||
.then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree))
|
||||
.then(changeTree => this.commit(options.commitMessage, changeTree))
|
||||
.then(commit => {
|
||||
newHead = commit;
|
||||
@ -379,7 +412,7 @@ export default class API {
|
||||
.then(metadata => {
|
||||
const { title, description } = options.parsedData || {};
|
||||
const metadataFiles = get(metadata.objects, 'files', []);
|
||||
const files = [ ...metadataFiles, ...filesList ];
|
||||
const files = [...metadataFiles, ...filesList];
|
||||
const pr = { ...metadata.pr, head: newHead.sha };
|
||||
const objects = {
|
||||
entry: { path: entry.path, sha: entry.sha },
|
||||
@ -392,8 +425,9 @@ export default class API {
|
||||
* can just finish the persist operation here.
|
||||
*/
|
||||
if (options.hasAssetStore) {
|
||||
return this.storeMetadata(contentKey, updatedMetadata)
|
||||
.then(() => this.patchBranch(branchName, newHead.sha));
|
||||
return this.storeMetadata(contentKey, updatedMetadata).then(() =>
|
||||
this.patchBranch(branchName, newHead.sha),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -440,8 +474,7 @@ export default class API {
|
||||
const updatedMetadata = { ...metadata, pr, timeStamp };
|
||||
await this.storeMetadata(contentKey, updatedMetadata);
|
||||
return this.patchBranch(branchName, rebasedHead.sha, { force: true });
|
||||
}
|
||||
catch(error) {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw error;
|
||||
}
|
||||
@ -496,39 +529,40 @@ export default class API {
|
||||
/**
|
||||
* Set the base commit as the parent.
|
||||
*/
|
||||
const parent = [ baseCommit.sha ];
|
||||
const parent = [baseCommit.sha];
|
||||
|
||||
/**
|
||||
* Get the blob data by path.
|
||||
*/
|
||||
return this.getBlobInTree(commit.tree.sha, pathToBlob)
|
||||
return (
|
||||
this.getBlobInTree(commit.tree.sha, pathToBlob)
|
||||
|
||||
/**
|
||||
* Create a new tree consisting of the base tree and the single updated
|
||||
* blob. Use the full path to indicate nesting, GitHub will take care of
|
||||
* subtree creation.
|
||||
*/
|
||||
.then(blob => this.createTree(baseCommit.tree.sha, [{ ...blob, path: pathToBlob }]))
|
||||
/**
|
||||
* Create a new tree consisting of the base tree and the single updated
|
||||
* blob. Use the full path to indicate nesting, GitHub will take care of
|
||||
* subtree creation.
|
||||
*/
|
||||
.then(blob => this.createTree(baseCommit.tree.sha, [{ ...blob, path: pathToBlob }]))
|
||||
|
||||
/**
|
||||
* Create a new commit with the updated tree and original commit metadata.
|
||||
*/
|
||||
.then(tree => this.createCommit(message, tree.sha, parent, author, committer));
|
||||
/**
|
||||
* Create a new commit with the updated tree and original commit metadata.
|
||||
*/
|
||||
.then(tree => this.createCommit(message, tree.sha, parent, author, committer))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get a pull request by PR number.
|
||||
*/
|
||||
getPullRequest(prNumber) {
|
||||
return this.request(`${ this.repoURL }/pulls/${prNumber} }`);
|
||||
return this.request(`${this.repoURL}/pulls/${prNumber} }`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of commits for a given pull request.
|
||||
*/
|
||||
getPullRequestCommits (prNumber) {
|
||||
return this.request(`${ this.repoURL }/pulls/${prNumber}/commits`);
|
||||
getPullRequestCommits(prNumber) {
|
||||
return this.request(`${this.repoURL}/pulls/${prNumber}/commits`);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -552,66 +586,67 @@ export default class API {
|
||||
updateUnpublishedEntryStatus(collection, slug, status) {
|
||||
const contentKey = slug;
|
||||
return this.retrieveMetadata(contentKey)
|
||||
.then(metadata => ({
|
||||
...metadata,
|
||||
status,
|
||||
}))
|
||||
.then(updatedMetadata => this.storeMetadata(contentKey, updatedMetadata));
|
||||
.then(metadata => ({
|
||||
...metadata,
|
||||
status,
|
||||
}))
|
||||
.then(updatedMetadata => this.storeMetadata(contentKey, updatedMetadata));
|
||||
}
|
||||
|
||||
deleteUnpublishedEntry(collection, slug) {
|
||||
const contentKey = slug;
|
||||
const branchName = this.generateBranchName(contentKey);
|
||||
return this.retrieveMetadata(contentKey)
|
||||
.then(metadata => this.closePR(metadata.pr))
|
||||
.then(() => this.deleteBranch(branchName))
|
||||
// If the PR doesn't exist, then this has already been deleted -
|
||||
// deletion should be idempotent, so we can consider this a
|
||||
// success.
|
||||
.catch((err) => {
|
||||
if (err.message === "Reference does not exist") {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(err);
|
||||
});
|
||||
return (
|
||||
this.retrieveMetadata(contentKey)
|
||||
.then(metadata => this.closePR(metadata.pr))
|
||||
.then(() => this.deleteBranch(branchName))
|
||||
// If the PR doesn't exist, then this has already been deleted -
|
||||
// deletion should be idempotent, so we can consider this a
|
||||
// success.
|
||||
.catch(err => {
|
||||
if (err.message === 'Reference does not exist') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(err);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
publishUnpublishedEntry(collection, slug) {
|
||||
const contentKey = slug;
|
||||
const branchName = this.generateBranchName(contentKey);
|
||||
return this.retrieveMetadata(contentKey)
|
||||
.then(metadata => this.mergePR(metadata.pr, metadata.objects))
|
||||
.then(() => this.deleteBranch(branchName));
|
||||
.then(metadata => this.mergePR(metadata.pr, metadata.objects))
|
||||
.then(() => this.deleteBranch(branchName));
|
||||
}
|
||||
|
||||
|
||||
createRef(type, name, sha) {
|
||||
return this.request(`${ this.repoURL }/git/refs`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ref: `refs/${ type }/${ name }`, sha }),
|
||||
return this.request(`${this.repoURL}/git/refs`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ref: `refs/${type}/${name}`, sha }),
|
||||
});
|
||||
}
|
||||
|
||||
patchRef(type, name, sha, opts = {}) {
|
||||
const force = opts.force || false;
|
||||
return this.request(`${ this.repoURL }/git/refs/${ type }/${ encodeURIComponent(name) }`, {
|
||||
method: "PATCH",
|
||||
return this.request(`${this.repoURL}/git/refs/${type}/${encodeURIComponent(name)}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ sha, force }),
|
||||
});
|
||||
}
|
||||
|
||||
deleteRef(type, name) {
|
||||
return this.request(`${ this.repoURL }/git/refs/${ type }/${ encodeURIComponent(name) }`, {
|
||||
return this.request(`${this.repoURL}/git/refs/${type}/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
getBranch(branch = this.branch) {
|
||||
return this.request(`${ this.repoURL }/branches/${ encodeURIComponent(branch) }`);
|
||||
return this.request(`${this.repoURL}/branches/${encodeURIComponent(branch)}`);
|
||||
}
|
||||
|
||||
createBranch(branchName, sha) {
|
||||
return this.createRef("heads", branchName, sha);
|
||||
return this.createRef('heads', branchName, sha);
|
||||
}
|
||||
|
||||
assertCmsBranch(branchName) {
|
||||
@ -623,26 +658,26 @@ export default class API {
|
||||
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 });
|
||||
return this.patchRef('heads', branchName, sha, { force });
|
||||
}
|
||||
|
||||
deleteBranch(branchName) {
|
||||
return this.deleteRef("heads", branchName);
|
||||
return this.deleteRef('heads', branchName);
|
||||
}
|
||||
|
||||
createPR(title, head, base = this.branch) {
|
||||
const body = "Automatically generated by Netlify CMS";
|
||||
return this.request(`${ this.repoURL }/pulls`, {
|
||||
method: "POST",
|
||||
const body = 'Automatically generated by Netlify CMS';
|
||||
return this.request(`${this.repoURL}/pulls`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title, body, head, base }),
|
||||
});
|
||||
}
|
||||
|
||||
closePR(pullrequest) {
|
||||
const prNumber = pullrequest.number;
|
||||
console.log("%c Deleting PR", "line-height: 30px;text-align: center;font-weight: bold");
|
||||
return this.request(`${ this.repoURL }/pulls/${ prNumber }`, {
|
||||
method: "PATCH",
|
||||
console.log('%c Deleting PR', 'line-height: 30px;text-align: center;font-weight: bold');
|
||||
return this.request(`${this.repoURL}/pulls/${prNumber}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
state: closed,
|
||||
}),
|
||||
@ -652,16 +687,15 @@ export default class API {
|
||||
mergePR(pullrequest, objects) {
|
||||
const headSha = pullrequest.head;
|
||||
const prNumber = pullrequest.number;
|
||||
console.log("%c Merging PR", "line-height: 30px;text-align: center;font-weight: bold");
|
||||
return this.request(`${ this.repoURL }/pulls/${ prNumber }/merge`, {
|
||||
method: "PUT",
|
||||
console.log('%c Merging PR', 'line-height: 30px;text-align: center;font-weight: bold');
|
||||
return this.request(`${this.repoURL}/pulls/${prNumber}/merge`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
commit_message: "Automatically generated. Merged on Netlify CMS.",
|
||||
commit_message: 'Automatically generated. Merged on Netlify CMS.',
|
||||
sha: headSha,
|
||||
merge_method: this.merge_method,
|
||||
}),
|
||||
})
|
||||
.catch((error) => {
|
||||
}).catch(error => {
|
||||
if (error instanceof APIError && error.status === 405) {
|
||||
return this.forceMergePR(pullrequest, objects);
|
||||
} else {
|
||||
@ -673,15 +707,18 @@ export default class API {
|
||||
forceMergePR(pullrequest, objects) {
|
||||
const files = objects.files.concat(objects.entry);
|
||||
const fileTree = this.composeFileTree(files);
|
||||
let commitMessage = "Automatically generated. Merged on Netlify CMS\n\nForce merge of:";
|
||||
files.forEach((file) => {
|
||||
commitMessage += `\n* "${ file.path }"`;
|
||||
let commitMessage = 'Automatically generated. Merged on Netlify CMS\n\nForce merge of:';
|
||||
files.forEach(file => {
|
||||
commitMessage += `\n* "${file.path}"`;
|
||||
});
|
||||
console.log("%c Automatic merge not possible - Forcing merge.", "line-height: 30px;text-align: center;font-weight: bold");
|
||||
console.log(
|
||||
'%c Automatic merge not possible - Forcing merge.',
|
||||
'line-height: 30px;text-align: center;font-weight: bold',
|
||||
);
|
||||
return this.getBranch()
|
||||
.then(branchData => this.updateTree(branchData.commit.sha, "/", fileTree))
|
||||
.then(changeTree => this.commit(commitMessage, changeTree))
|
||||
.then(response => this.patchBranch(this.branch, response.sha));
|
||||
.then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree))
|
||||
.then(changeTree => this.commit(commitMessage, changeTree))
|
||||
.then(response => this.patchBranch(this.branch, response.sha));
|
||||
}
|
||||
|
||||
getTree(sha) {
|
||||
@ -701,7 +738,7 @@ export default class API {
|
||||
const filename = pathSegments.slice(-1)[0];
|
||||
const baseTree = this.getTree(treeSha);
|
||||
const subTreePromise = directories.reduce((treePromise, segment) => {
|
||||
return treePromise.then(tree => {
|
||||
return treePromise.then(tree => {
|
||||
const subTreeSha = find(tree.tree, { path: segment }).sha;
|
||||
return this.getTree(subTreeSha);
|
||||
});
|
||||
@ -710,65 +747,73 @@ export default class API {
|
||||
}
|
||||
|
||||
toBase64(str) {
|
||||
return Promise.resolve(
|
||||
Base64.encode(str)
|
||||
);
|
||||
return Promise.resolve(Base64.encode(str));
|
||||
}
|
||||
|
||||
uploadBlob(item) {
|
||||
const content = result(item, 'toBase64', partial(this.toBase64, item.raw));
|
||||
|
||||
return content.then(contentBase64 => this.request(`${ this.repoURL }/git/blobs`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
content: contentBase64,
|
||||
encoding: "base64",
|
||||
return content.then(contentBase64 =>
|
||||
this.request(`${this.repoURL}/git/blobs`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
content: contentBase64,
|
||||
encoding: 'base64',
|
||||
}),
|
||||
}).then(response => {
|
||||
item.sha = response.sha;
|
||||
item.uploaded = true;
|
||||
return item;
|
||||
}),
|
||||
}).then((response) => {
|
||||
item.sha = response.sha;
|
||||
item.uploaded = true;
|
||||
return item;
|
||||
}));
|
||||
);
|
||||
}
|
||||
|
||||
updateTree(sha, path, fileTree) {
|
||||
return this.getTree(sha)
|
||||
.then((tree) => {
|
||||
let obj;
|
||||
let filename;
|
||||
let fileOrDir;
|
||||
const updates = [];
|
||||
const added = {};
|
||||
return this.getTree(sha).then(tree => {
|
||||
let obj;
|
||||
let filename;
|
||||
let fileOrDir;
|
||||
const updates = [];
|
||||
const added = {};
|
||||
|
||||
for (let i = 0, len = tree.tree.length; i < len; i++) {
|
||||
obj = tree.tree[i];
|
||||
if (fileOrDir = fileTree[obj.path]) { // eslint-disable-line no-cond-assign
|
||||
added[obj.path] = true;
|
||||
if (fileOrDir.file) {
|
||||
updates.push({ path: obj.path, mode: obj.mode, type: obj.type, sha: fileOrDir.sha });
|
||||
} else {
|
||||
updates.push(this.updateTree(obj.sha, obj.path, fileOrDir));
|
||||
}
|
||||
for (let i = 0, len = tree.tree.length; i < len; i++) {
|
||||
obj = tree.tree[i];
|
||||
if ((fileOrDir = fileTree[obj.path])) {
|
||||
// eslint-disable-line no-cond-assign
|
||||
added[obj.path] = true;
|
||||
if (fileOrDir.file) {
|
||||
updates.push({ path: obj.path, mode: obj.mode, type: obj.type, sha: fileOrDir.sha });
|
||||
} else {
|
||||
updates.push(this.updateTree(obj.sha, obj.path, fileOrDir));
|
||||
}
|
||||
}
|
||||
for (filename in fileTree) {
|
||||
fileOrDir = fileTree[filename];
|
||||
if (added[filename]) { continue; }
|
||||
updates.push(
|
||||
fileOrDir.file ?
|
||||
{ path: filename, mode: "100644", type: "blob", sha: fileOrDir.sha } :
|
||||
this.updateTree(null, filename, fileOrDir)
|
||||
);
|
||||
}
|
||||
for (filename in fileTree) {
|
||||
fileOrDir = fileTree[filename];
|
||||
if (added[filename]) {
|
||||
continue;
|
||||
}
|
||||
return Promise.all(updates)
|
||||
.then(tree => this.createTree(sha, tree))
|
||||
.then(response => ({ path, mode: "040000", type: "tree", sha: response.sha, parentSha: sha }));
|
||||
});
|
||||
updates.push(
|
||||
fileOrDir.file
|
||||
? { path: filename, mode: '100644', type: 'blob', sha: fileOrDir.sha }
|
||||
: this.updateTree(null, filename, fileOrDir),
|
||||
);
|
||||
}
|
||||
return Promise.all(updates)
|
||||
.then(tree => this.createTree(sha, tree))
|
||||
.then(response => ({
|
||||
path,
|
||||
mode: '040000',
|
||||
type: 'tree',
|
||||
sha: response.sha,
|
||||
parentSha: sha,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
createTree(baseSha, tree) {
|
||||
return this.request(`${ this.repoURL }/git/trees`, {
|
||||
method: "POST",
|
||||
return this.request(`${this.repoURL}/git/trees`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ base_tree: baseSha, tree }),
|
||||
});
|
||||
}
|
||||
@ -792,8 +837,8 @@ export default class API {
|
||||
}
|
||||
|
||||
createCommit(message, treeSha, parents, author, committer) {
|
||||
return this.request(`${ this.repoURL }/git/commits`, {
|
||||
method: "POST",
|
||||
return this.request(`${this.repoURL}/git/commits`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ message, tree: treeSha, parents, author, committer }),
|
||||
});
|
||||
}
|
||||
|
@ -6,7 +6,7 @@ import { AuthenticationPage, Icon } from 'netlify-cms-ui-default';
|
||||
|
||||
const LoginButtonIcon = styled(Icon)`
|
||||
margin-right: 18px;
|
||||
`
|
||||
`;
|
||||
|
||||
export default class GitHubAuthenticationPage extends React.Component {
|
||||
static propTypes = {
|
||||
@ -19,11 +19,14 @@ export default class GitHubAuthenticationPage extends React.Component {
|
||||
|
||||
state = {};
|
||||
|
||||
handleLogin = (e) => {
|
||||
handleLogin = e => {
|
||||
e.preventDefault();
|
||||
const cfg = {
|
||||
base_url: this.props.base_url,
|
||||
site_id: (document.location.host.split(':')[0] === 'localhost') ? 'cms.netlify.com' : this.props.siteId,
|
||||
site_id:
|
||||
document.location.host.split(':')[0] === 'localhost'
|
||||
? 'cms.netlify.com'
|
||||
: this.props.siteId,
|
||||
auth_endpoint: this.props.authEndpoint,
|
||||
};
|
||||
const auth = new NetlifyAuthenticator(cfg);
|
||||
@ -46,7 +49,7 @@ export default class GitHubAuthenticationPage extends React.Component {
|
||||
loginErrorMessage={this.state.loginError}
|
||||
renderButtonContent={() => (
|
||||
<React.Fragment>
|
||||
<LoginButtonIcon type="github"/> {inProgress ? "Logging in..." : "Login with GitHub"}
|
||||
<LoginButtonIcon type="github" /> {inProgress ? 'Logging in...' : 'Login with GitHub'}
|
||||
</React.Fragment>
|
||||
)}
|
||||
/>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import API from "../API";
|
||||
import API from '../API';
|
||||
|
||||
describe('github API', () => {
|
||||
const mockAPI = (api, responses) => {
|
||||
@ -7,9 +7,9 @@ describe('github API', () => {
|
||||
const response = responses[normalizedPath];
|
||||
return typeof response === 'function'
|
||||
? Promise.resolve(response(options))
|
||||
: Promise.reject(new Error(`No response for path '${normalizedPath}'`))
|
||||
: Promise.reject(new Error(`No response for path '${normalizedPath}'`));
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
it('should create PR with correct base branch name when publishing with editorial workflow', () => {
|
||||
let prBaseBranch = null;
|
||||
@ -20,19 +20,20 @@ describe('github API', () => {
|
||||
'/repos/my-repo/git/trees': () => ({}),
|
||||
'/repos/my-repo/git/commits': () => ({}),
|
||||
'/repos/my-repo/git/refs': () => ({}),
|
||||
'/repos/my-repo/pulls': (pullRequest) => {
|
||||
'/repos/my-repo/pulls': pullRequest => {
|
||||
prBaseBranch = JSON.parse(pullRequest.body).base;
|
||||
return { head: { sha: 'cbd' } };
|
||||
},
|
||||
'/user': () => ({}),
|
||||
'/repos/my-repo/git/blobs': () => ({}),
|
||||
'/repos/my-repo/git/refs/meta/_netlify_cms': () => ({ 'object': {} })
|
||||
'/repos/my-repo/git/refs/meta/_netlify_cms': () => ({ object: {} }),
|
||||
};
|
||||
mockAPI(api, responses);
|
||||
|
||||
return expect(
|
||||
api.editorialWorkflowGit(null, { slug: 'entry', sha: 'abc' }, null, {})
|
||||
.then(() => prBaseBranch)
|
||||
).resolves.toEqual('gh-pages')
|
||||
api
|
||||
.editorialWorkflowGit(null, { slug: 'entry', sha: 'abc' }, null, {})
|
||||
.then(() => prBaseBranch),
|
||||
).resolves.toEqual('gh-pages');
|
||||
});
|
||||
});
|
||||
|
@ -1,12 +1,12 @@
|
||||
import trimStart from 'lodash/trimStart';
|
||||
import semaphore from "semaphore";
|
||||
import AuthenticationPage from "./AuthenticationPage";
|
||||
import API from "./API";
|
||||
import semaphore from 'semaphore';
|
||||
import AuthenticationPage from './AuthenticationPage';
|
||||
import API from './API';
|
||||
|
||||
const MAX_CONCURRENT_DOWNLOADS = 10;
|
||||
|
||||
export default class GitHub {
|
||||
constructor(config, options={}) {
|
||||
constructor(config, options = {}) {
|
||||
this.config = config;
|
||||
this.options = {
|
||||
proxied: false,
|
||||
@ -14,17 +14,17 @@ export default class GitHub {
|
||||
...options,
|
||||
};
|
||||
|
||||
if (!this.options.proxied && config.getIn(["backend", "repo"]) == null) {
|
||||
throw new Error("The GitHub backend needs a \"repo\" in the backend configuration.");
|
||||
if (!this.options.proxied && config.getIn(['backend', 'repo']) == null) {
|
||||
throw new Error('The GitHub backend needs a "repo" in the backend configuration.');
|
||||
}
|
||||
|
||||
this.api = this.options.API || null;
|
||||
|
||||
this.repo = config.getIn(["backend", "repo"], "");
|
||||
this.branch = config.getIn(["backend", "branch"], "master").trim();
|
||||
this.api_root = config.getIn(["backend", "api_root"], "https://api.github.com");
|
||||
this.repo = config.getIn(['backend', 'repo'], '');
|
||||
this.branch = config.getIn(['backend', 'branch'], 'master').trim();
|
||||
this.api_root = config.getIn(['backend', 'api_root'], 'https://api.github.com');
|
||||
this.token = '';
|
||||
this.squash_merges = config.getIn(["backend", "squash_merges"]);
|
||||
this.squash_merges = config.getIn(['backend', 'squash_merges']);
|
||||
}
|
||||
|
||||
authComponent() {
|
||||
@ -46,13 +46,14 @@ export default class GitHub {
|
||||
initialWorkflowStatus: this.options.initialWorkflowStatus,
|
||||
});
|
||||
return this.api.user().then(user =>
|
||||
this.api.hasWriteAccess().then((isCollab) => {
|
||||
this.api.hasWriteAccess().then(isCollab => {
|
||||
// Unauthorized user
|
||||
if (!isCollab) throw new Error("Your GitHub user account does not have access to this repo.");
|
||||
if (!isCollab)
|
||||
throw new Error('Your GitHub user account does not have access to this repo.');
|
||||
// Authorized user
|
||||
user.token = state.token;
|
||||
return user;
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@ -66,36 +67,45 @@ export default class GitHub {
|
||||
}
|
||||
|
||||
entriesByFolder(collection, extension) {
|
||||
return this.api.listFiles(collection.get("folder"))
|
||||
.then(files => files.filter(file => file.name.endsWith('.' + extension)))
|
||||
.then(this.fetchFiles);
|
||||
return this.api
|
||||
.listFiles(collection.get('folder'))
|
||||
.then(files => files.filter(file => file.name.endsWith('.' + extension)))
|
||||
.then(this.fetchFiles);
|
||||
}
|
||||
|
||||
entriesByFiles(collection) {
|
||||
const files = collection.get("files").map(collectionFile => ({
|
||||
path: collectionFile.get("file"),
|
||||
label: collectionFile.get("label"),
|
||||
const files = collection.get('files').map(collectionFile => ({
|
||||
path: collectionFile.get('file'),
|
||||
label: collectionFile.get('label'),
|
||||
}));
|
||||
return this.fetchFiles(files);
|
||||
}
|
||||
|
||||
fetchFiles = (files) => {
|
||||
fetchFiles = files => {
|
||||
const sem = semaphore(MAX_CONCURRENT_DOWNLOADS);
|
||||
const promises = [];
|
||||
files.forEach((file) => {
|
||||
promises.push(new Promise(resolve => (
|
||||
sem.take(() => this.api.readFile(file.path, file.sha).then((data) => {
|
||||
resolve({ file, data });
|
||||
sem.leave();
|
||||
}).catch((err = true) => {
|
||||
sem.leave();
|
||||
console.error(`failed to load file from GitHub: ${file.path}`);
|
||||
resolve({ error: err });
|
||||
}))
|
||||
)));
|
||||
files.forEach(file => {
|
||||
promises.push(
|
||||
new Promise(resolve =>
|
||||
sem.take(() =>
|
||||
this.api
|
||||
.readFile(file.path, file.sha)
|
||||
.then(data => {
|
||||
resolve({ file, data });
|
||||
sem.leave();
|
||||
})
|
||||
.catch((err = true) => {
|
||||
sem.leave();
|
||||
console.error(`failed to load file from GitHub: ${file.path}`);
|
||||
resolve({ error: err });
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
return Promise.all(promises)
|
||||
.then(loadedEntries => loadedEntries.filter(loadedEntry => !loadedEntry.error));
|
||||
return Promise.all(promises).then(loadedEntries =>
|
||||
loadedEntries.filter(loadedEntry => !loadedEntry.error),
|
||||
);
|
||||
};
|
||||
|
||||
// Fetches a single entry.
|
||||
@ -107,14 +117,15 @@ export default class GitHub {
|
||||
}
|
||||
|
||||
getMedia() {
|
||||
return this.api.listFiles(this.config.get('media_folder'))
|
||||
.then(files => files.map(({ sha, name, size, download_url, path }) => {
|
||||
return this.api.listFiles(this.config.get('media_folder')).then(files =>
|
||||
files.map(({ sha, name, size, download_url, path }) => {
|
||||
const url = new URL(download_url);
|
||||
if (url.pathname.match(/.svg$/)) {
|
||||
url.search += (url.search.slice(1) === '' ? '?' : '&') + 'sanitize=true';
|
||||
}
|
||||
return { id: sha, name, size, url: url.href, path };
|
||||
}));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
persistEntry(entry, mediaFiles = [], options = {}) {
|
||||
@ -124,12 +135,11 @@ export default class GitHub {
|
||||
async persistMedia(mediaFile, options = {}) {
|
||||
try {
|
||||
await this.api.persistFiles(null, [mediaFile], options);
|
||||
|
||||
|
||||
const { sha, value, path, fileObj } = mediaFile;
|
||||
const url = URL.createObjectURL(fileObj);
|
||||
return { id: sha, name: value, size: fileObj.size, url, path: trimStart(path, '/') };
|
||||
}
|
||||
catch(error) {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw error;
|
||||
}
|
||||
@ -140,46 +150,54 @@ export default class GitHub {
|
||||
}
|
||||
|
||||
unpublishedEntries() {
|
||||
return this.api.listUnpublishedBranches().then((branches) => {
|
||||
const sem = semaphore(MAX_CONCURRENT_DOWNLOADS);
|
||||
const promises = [];
|
||||
branches.map((branch) => {
|
||||
promises.push(new Promise(resolve => {
|
||||
const slug = branch.ref.split("refs/heads/cms/").pop();
|
||||
return sem.take(() => this.api.readUnpublishedBranchFile(slug).then((data) => {
|
||||
if (data === null || data === undefined) {
|
||||
resolve(null);
|
||||
sem.leave();
|
||||
} else {
|
||||
const path = data.metaData.objects.entry.path;
|
||||
resolve({
|
||||
slug,
|
||||
file: { path },
|
||||
data: data.fileData,
|
||||
metaData: data.metaData,
|
||||
isModification: data.isModification,
|
||||
});
|
||||
sem.leave();
|
||||
}
|
||||
}).catch(() => {
|
||||
sem.leave();
|
||||
resolve(null);
|
||||
}));
|
||||
}));
|
||||
return this.api
|
||||
.listUnpublishedBranches()
|
||||
.then(branches => {
|
||||
const sem = semaphore(MAX_CONCURRENT_DOWNLOADS);
|
||||
const promises = [];
|
||||
branches.map(branch => {
|
||||
promises.push(
|
||||
new Promise(resolve => {
|
||||
const slug = branch.ref.split('refs/heads/cms/').pop();
|
||||
return sem.take(() =>
|
||||
this.api
|
||||
.readUnpublishedBranchFile(slug)
|
||||
.then(data => {
|
||||
if (data === null || data === undefined) {
|
||||
resolve(null);
|
||||
sem.leave();
|
||||
} else {
|
||||
const path = data.metaData.objects.entry.path;
|
||||
resolve({
|
||||
slug,
|
||||
file: { path },
|
||||
data: data.fileData,
|
||||
metaData: data.metaData,
|
||||
isModification: data.isModification,
|
||||
});
|
||||
sem.leave();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
sem.leave();
|
||||
resolve(null);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
return Promise.all(promises);
|
||||
})
|
||||
.catch(error => {
|
||||
if (error.message === 'Not Found') {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
return error;
|
||||
});
|
||||
return Promise.all(promises);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.message === "Not Found") {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
return error;
|
||||
});
|
||||
}
|
||||
|
||||
unpublishedEntry(collection, slug) {
|
||||
return this.api.readUnpublishedBranchFile(slug)
|
||||
.then((data) => {
|
||||
return this.api.readUnpublishedBranchFile(slug).then(data => {
|
||||
if (!data) return null;
|
||||
return {
|
||||
slug,
|
||||
|
@ -1,4 +1,3 @@
|
||||
export GitHubBackend from './implementation';
|
||||
export API from './API';
|
||||
export AuthenticationPage from './AuthenticationPage';
|
||||
|
||||
|
@ -1,105 +1,119 @@
|
||||
import { localForage, unsentRequest, then, APIError, Cursor } from "netlify-cms-lib-util";
|
||||
import { Base64 } from "js-base64";
|
||||
import { List, Map } from "immutable";
|
||||
import { flow, partial, pick, get, result } from "lodash";
|
||||
import { localForage, unsentRequest, then, APIError, Cursor } from 'netlify-cms-lib-util';
|
||||
import { Base64 } from 'js-base64';
|
||||
import { List, Map } from 'immutable';
|
||||
import { flow, partial, result } from 'lodash';
|
||||
|
||||
export default class API {
|
||||
constructor(config) {
|
||||
this.api_root = config.api_root || "https://gitlab.com/api/v4";
|
||||
this.api_root = config.api_root || 'https://gitlab.com/api/v4';
|
||||
this.token = config.token || false;
|
||||
this.branch = config.branch || "master";
|
||||
this.repo = config.repo || "";
|
||||
this.repoURL = `/projects/${ encodeURIComponent(this.repo) }`;
|
||||
this.branch = config.branch || 'master';
|
||||
this.repo = config.repo || '';
|
||||
this.repoURL = `/projects/${encodeURIComponent(this.repo)}`;
|
||||
}
|
||||
|
||||
withAuthorizationHeaders = req => (
|
||||
unsentRequest.withHeaders(this.token ? { Authorization: `Bearer ${ this.token }` } : {}, req)
|
||||
);
|
||||
withAuthorizationHeaders = req =>
|
||||
unsentRequest.withHeaders(this.token ? { Authorization: `Bearer ${this.token}` } : {}, req);
|
||||
|
||||
buildRequest = req => flow([
|
||||
unsentRequest.withRoot(this.api_root),
|
||||
this.withAuthorizationHeaders,
|
||||
unsentRequest.withTimestamp,
|
||||
])(req);
|
||||
buildRequest = req =>
|
||||
flow([
|
||||
unsentRequest.withRoot(this.api_root),
|
||||
this.withAuthorizationHeaders,
|
||||
unsentRequest.withTimestamp,
|
||||
])(req);
|
||||
|
||||
request = async req => flow([
|
||||
this.buildRequest,
|
||||
unsentRequest.performRequest,
|
||||
p => p.catch(err => Promise.reject(new APIError(err.message, null, "GitLab"))),
|
||||
])(req);
|
||||
request = async req =>
|
||||
flow([
|
||||
this.buildRequest,
|
||||
unsentRequest.performRequest,
|
||||
p => p.catch(err => Promise.reject(new APIError(err.message, null, 'GitLab'))),
|
||||
])(req);
|
||||
|
||||
parseResponse = async (res, { expectingOk=true, expectingFormat=false }) => {
|
||||
const contentType = res.headers.get("Content-Type");
|
||||
const isJSON = contentType === "application/json";
|
||||
parseResponse = async (res, { expectingOk = true, expectingFormat = false }) => {
|
||||
const contentType = res.headers.get('Content-Type');
|
||||
const isJSON = contentType === 'application/json';
|
||||
let body;
|
||||
try {
|
||||
body = await ((expectingFormat === "json" || isJSON) ? res.json() : res.text());
|
||||
body = await (expectingFormat === 'json' || isJSON ? res.json() : res.text());
|
||||
} catch (err) {
|
||||
throw new APIError(err.message, res.status, "GitLab");
|
||||
throw new APIError(err.message, res.status, 'GitLab');
|
||||
}
|
||||
if (expectingOk && !res.ok) {
|
||||
throw new APIError((isJSON && body.message) ? body.message : body, res.status, "GitLab");
|
||||
throw new APIError(isJSON && body.message ? body.message : body, res.status, 'GitLab');
|
||||
}
|
||||
return body;
|
||||
};
|
||||
|
||||
responseToJSON = res => this.parseResponse(res, { expectingFormat: "json" });
|
||||
responseToText = res => this.parseResponse(res, { expectingFormat: "text" });
|
||||
responseToJSON = res => this.parseResponse(res, { expectingFormat: 'json' });
|
||||
responseToText = res => this.parseResponse(res, { expectingFormat: 'text' });
|
||||
requestJSON = req => this.request(req).then(this.responseToJSON);
|
||||
requestText = req => this.request(req).then(this.responseToText);
|
||||
|
||||
user = () => this.requestJSON("/user");
|
||||
user = () => this.requestJSON('/user');
|
||||
|
||||
WRITE_ACCESS = 30;
|
||||
hasWriteAccess = () => this.requestJSON(this.repoURL).then(({ permissions }) => {
|
||||
const { project_access, group_access } = permissions;
|
||||
if (project_access && (project_access.access_level >= this.WRITE_ACCESS)) {
|
||||
return true;
|
||||
}
|
||||
if (group_access && (group_access.access_level >= this.WRITE_ACCESS)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
readFile = async (path, sha, ref=this.branch) => {
|
||||
const cachedFile = sha ? await localForage.getItem(`gl.${ sha }`) : null;
|
||||
if (cachedFile) { return cachedFile; }
|
||||
const result = await this.requestText({
|
||||
url: `${ this.repoURL }/repository/files/${ encodeURIComponent(path) }/raw`,
|
||||
params: { ref },
|
||||
cache: "no-store",
|
||||
hasWriteAccess = () =>
|
||||
this.requestJSON(this.repoURL).then(({ permissions }) => {
|
||||
const { project_access, group_access } = permissions;
|
||||
if (project_access && project_access.access_level >= this.WRITE_ACCESS) {
|
||||
return true;
|
||||
}
|
||||
if (group_access && group_access.access_level >= this.WRITE_ACCESS) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (sha) { localForage.setItem(`gl.${ sha }`, result) }
|
||||
|
||||
readFile = async (path, sha, ref = this.branch) => {
|
||||
const cachedFile = sha ? await localForage.getItem(`gl.${sha}`) : null;
|
||||
if (cachedFile) {
|
||||
return cachedFile;
|
||||
}
|
||||
const result = await this.requestText({
|
||||
url: `${this.repoURL}/repository/files/${encodeURIComponent(path)}/raw`,
|
||||
params: { ref },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (sha) {
|
||||
localForage.setItem(`gl.${sha}`, result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
fileDownloadURL = (path, ref=this.branch) => unsentRequest.toURL(this.buildRequest({
|
||||
url: `${ this.repoURL }/repository/files/${ encodeURIComponent(path) }/raw`,
|
||||
params: { ref },
|
||||
}));
|
||||
fileDownloadURL = (path, ref = this.branch) =>
|
||||
unsentRequest.toURL(
|
||||
this.buildRequest({
|
||||
url: `${this.repoURL}/repository/files/${encodeURIComponent(path)}/raw`,
|
||||
params: { ref },
|
||||
}),
|
||||
);
|
||||
|
||||
getCursorFromHeaders = headers => {
|
||||
// indices and page counts are assumed to be zero-based, but the
|
||||
// indices and page counts returned from GitLab are one-based
|
||||
const index = parseInt(headers.get("X-Page"), 10) - 1;
|
||||
const pageCount = parseInt(headers.get("X-Total-Pages"), 10) - 1;
|
||||
const pageSize = parseInt(headers.get("X-Per-Page"), 10);
|
||||
const count = parseInt(headers.get("X-Total"), 10);
|
||||
const linksRaw = headers.get("Link");
|
||||
const links = List(linksRaw.split(","))
|
||||
.map(str => str.trim().split(";"))
|
||||
const index = parseInt(headers.get('X-Page'), 10) - 1;
|
||||
const pageCount = parseInt(headers.get('X-Total-Pages'), 10) - 1;
|
||||
const pageSize = parseInt(headers.get('X-Per-Page'), 10);
|
||||
const count = parseInt(headers.get('X-Total'), 10);
|
||||
const linksRaw = headers.get('Link');
|
||||
const links = List(linksRaw.split(','))
|
||||
.map(str => str.trim().split(';'))
|
||||
.map(([linkStr, keyStr]) => [
|
||||
keyStr.match(/rel="(.*?)"/)[1],
|
||||
unsentRequest.fromURL(linkStr.trim().match(/<(.*?)>/)[1]),
|
||||
])
|
||||
.update(list => Map(list));
|
||||
const actions = links.keySeq().flatMap(key => (
|
||||
(key === "prev" && index > 0) ||
|
||||
(key === "next" && index < pageCount) ||
|
||||
(key === "first" && index > 0) ||
|
||||
(key === "last" && index < pageCount)
|
||||
) ? [key] : []);
|
||||
const actions = links
|
||||
.keySeq()
|
||||
.flatMap(
|
||||
key =>
|
||||
(key === 'prev' && index > 0) ||
|
||||
(key === 'next' && index < pageCount) ||
|
||||
(key === 'first' && index > 0) ||
|
||||
(key === 'last' && index < pageCount)
|
||||
? [key]
|
||||
: [],
|
||||
);
|
||||
return Cursor.create({
|
||||
actions,
|
||||
meta: { index, count, pageSize, pageCount },
|
||||
@ -111,36 +125,42 @@ export default class API {
|
||||
|
||||
// Gets a cursor without retrieving the entries by using a HEAD
|
||||
// request
|
||||
fetchCursor = req => flow([unsentRequest.withMethod("HEAD"), this.request, then(this.getCursor)])(req);
|
||||
fetchCursorAndEntries = req => flow([
|
||||
unsentRequest.withMethod("GET"),
|
||||
this.request,
|
||||
p => Promise.all([p.then(this.getCursor), p.then(this.responseToJSON)]),
|
||||
then(([cursor, entries]) => ({ cursor, entries })),
|
||||
])(req);
|
||||
fetchCursor = req =>
|
||||
flow([unsentRequest.withMethod('HEAD'), this.request, then(this.getCursor)])(req);
|
||||
fetchCursorAndEntries = req =>
|
||||
flow([
|
||||
unsentRequest.withMethod('GET'),
|
||||
this.request,
|
||||
p => Promise.all([p.then(this.getCursor), p.then(this.responseToJSON)]),
|
||||
then(([cursor, entries]) => ({ cursor, entries })),
|
||||
])(req);
|
||||
fetchRelativeCursor = async (cursor, action) => this.fetchCursor(cursor.data.links[action]);
|
||||
|
||||
reversableActions = Map({
|
||||
first: "last",
|
||||
last: "first",
|
||||
next: "prev",
|
||||
prev: "next",
|
||||
first: 'last',
|
||||
last: 'first',
|
||||
next: 'prev',
|
||||
prev: 'next',
|
||||
});
|
||||
|
||||
reverseCursor = cursor => {
|
||||
const pageCount = cursor.meta.get("pageCount", 0);
|
||||
const currentIndex = cursor.meta.get("index", 0);
|
||||
const pageCount = cursor.meta.get('pageCount', 0);
|
||||
const currentIndex = cursor.meta.get('index', 0);
|
||||
const newIndex = pageCount - currentIndex;
|
||||
|
||||
const links = cursor.data.get("links", Map());
|
||||
const links = cursor.data.get('links', Map());
|
||||
const reversedLinks = links.mapEntries(([k, v]) => [this.reversableActions.get(k) || k, v]);
|
||||
|
||||
const reversedActions = cursor.actions.map(action => this.reversableActions.get(action) || action);
|
||||
const reversedActions = cursor.actions.map(
|
||||
action => this.reversableActions.get(action) || action,
|
||||
);
|
||||
|
||||
return cursor.updateStore(store => store
|
||||
.setIn(["meta", "index"], newIndex)
|
||||
.setIn(["data", "links"], reversedLinks)
|
||||
.set("actions", reversedActions));
|
||||
return cursor.updateStore(store =>
|
||||
store
|
||||
.setIn(['meta', 'index'], newIndex)
|
||||
.setIn(['data', 'links'], reversedLinks)
|
||||
.set('actions', reversedActions),
|
||||
);
|
||||
};
|
||||
|
||||
// The exported listFiles and traverseCursor reverse the direction
|
||||
@ -151,16 +171,19 @@ export default class API {
|
||||
// refactored.
|
||||
listFiles = async path => {
|
||||
const firstPageCursor = await this.fetchCursor({
|
||||
url: `${ this.repoURL }/repository/tree`,
|
||||
url: `${this.repoURL}/repository/tree`,
|
||||
params: { path, ref: this.branch },
|
||||
});
|
||||
const lastPageLink = firstPageCursor.data.getIn(["links", "last"]);
|
||||
const lastPageLink = firstPageCursor.data.getIn(['links', 'last']);
|
||||
const { entries, cursor } = await this.fetchCursorAndEntries(lastPageLink);
|
||||
return { files: entries.filter(({ type }) => type === "blob").reverse(), cursor: this.reverseCursor(cursor) };
|
||||
return {
|
||||
files: entries.filter(({ type }) => type === 'blob').reverse(),
|
||||
cursor: this.reverseCursor(cursor),
|
||||
};
|
||||
};
|
||||
|
||||
traverseCursor = async (cursor, action) => {
|
||||
const link = cursor.data.getIn(["links", action]);
|
||||
const link = cursor.data.getIn(['links', action]);
|
||||
const { entries, cursor: newCursor } = await this.fetchCursorAndEntries(link);
|
||||
return { entries: entries.reverse(), cursor: this.reverseCursor(newCursor) };
|
||||
};
|
||||
@ -168,28 +191,31 @@ export default class API {
|
||||
listAllFiles = async path => {
|
||||
const entries = [];
|
||||
let { cursor, entries: initialEntries } = await this.fetchCursorAndEntries({
|
||||
url: `${ this.repoURL }/repository/tree`,
|
||||
url: `${this.repoURL}/repository/tree`,
|
||||
// Get the maximum number of entries per page
|
||||
params: { path, ref: this.branch, per_page: 100 },
|
||||
});
|
||||
entries.push(...initialEntries);
|
||||
while (cursor && cursor.actions.has("next")) {
|
||||
const link = cursor.data.getIn(["links", "next"]);
|
||||
while (cursor && cursor.actions.has('next')) {
|
||||
const link = cursor.data.getIn(['links', 'next']);
|
||||
const { cursor: newCursor, entries: newEntries } = await this.fetchCursorAndEntries(link);
|
||||
entries.push(...newEntries);
|
||||
cursor = newCursor;
|
||||
}
|
||||
return entries.filter(({ type }) => type === "blob");
|
||||
return entries.filter(({ type }) => type === 'blob');
|
||||
};
|
||||
|
||||
toBase64 = str => Promise.resolve(Base64.encode(str));
|
||||
fromBase64 = str => Base64.decode(str);
|
||||
uploadAndCommit = async (item, { commitMessage, updateFile = false, branch = this.branch, author = this.commitAuthor }) => {
|
||||
uploadAndCommit = async (
|
||||
item,
|
||||
{ commitMessage, updateFile = false, branch = this.branch, author = this.commitAuthor },
|
||||
) => {
|
||||
const content = await result(item, 'toBase64', partial(this.toBase64, item.raw));
|
||||
const file_path = item.path.replace(/^\//, "");
|
||||
const action = (updateFile ? "update" : "create");
|
||||
const encoding = "base64";
|
||||
|
||||
const file_path = item.path.replace(/^\//, '');
|
||||
const action = updateFile ? 'update' : 'create';
|
||||
const encoding = 'base64';
|
||||
|
||||
const commitParams = {
|
||||
branch,
|
||||
commit_message: commitMessage,
|
||||
@ -202,9 +228,9 @@ export default class API {
|
||||
}
|
||||
|
||||
await this.request({
|
||||
url: `${ this.repoURL }/repository/commits`,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
url: `${this.repoURL}/repository/commits`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(commitParams),
|
||||
});
|
||||
|
||||
@ -212,7 +238,11 @@ export default class API {
|
||||
};
|
||||
|
||||
persistFiles = (files, { commitMessage, newEntry }) =>
|
||||
Promise.all(files.map(file => this.uploadAndCommit(file, { commitMessage, updateFile: newEntry === false })));
|
||||
Promise.all(
|
||||
files.map(file =>
|
||||
this.uploadAndCommit(file, { commitMessage, updateFile: newEntry === false }),
|
||||
),
|
||||
);
|
||||
|
||||
deleteFile = (path, commit_message, options = {}) => {
|
||||
const branch = options.branch || this.branch;
|
||||
@ -223,10 +253,10 @@ export default class API {
|
||||
commitParams.author_email = email;
|
||||
}
|
||||
return flow([
|
||||
unsentRequest.withMethod("DELETE"),
|
||||
unsentRequest.withMethod('DELETE'),
|
||||
// TODO: only send author params if they are defined.
|
||||
unsentRequest.withParams(commitParams),
|
||||
this.request,
|
||||
])(`${ this.repoURL }/repository/files/${ encodeURIComponent(path) }`);
|
||||
])(`${this.repoURL}/repository/files/${encodeURIComponent(path)}`);
|
||||
};
|
||||
}
|
||||
|
@ -6,7 +6,7 @@ import { AuthenticationPage, Icon } from 'netlify-cms-ui-default';
|
||||
|
||||
const LoginButtonIcon = styled(Icon)`
|
||||
margin-right: 18px;
|
||||
`
|
||||
`;
|
||||
|
||||
export default class GitLabAuthenticationPage extends React.Component {
|
||||
static propTypes = {
|
||||
@ -18,9 +18,9 @@ export default class GitLabAuthenticationPage extends React.Component {
|
||||
|
||||
componentDidMount() {
|
||||
const authType = this.props.config.getIn(['backend', 'auth_type']);
|
||||
if (authType === "implicit") {
|
||||
if (authType === 'implicit') {
|
||||
this.auth = new ImplicitAuthenticator({
|
||||
base_url: this.props.config.getIn(['backend', 'base_url'], "https://gitlab.com"),
|
||||
base_url: this.props.config.getIn(['backend', 'base_url'], 'https://gitlab.com'),
|
||||
auth_endpoint: this.props.config.getIn(['backend', 'auth_endpoint'], 'oauth/authorize'),
|
||||
app_id: this.props.config.getIn(['backend', 'app_id']),
|
||||
clearHash: this.props.clearHash,
|
||||
@ -36,13 +36,16 @@ export default class GitLabAuthenticationPage extends React.Component {
|
||||
} else {
|
||||
this.auth = new NetlifyAuthenticator({
|
||||
base_url: this.props.base_url,
|
||||
site_id: (document.location.host.split(':')[0] === 'localhost') ? 'cms.netlify.com' : this.props.siteId,
|
||||
site_id:
|
||||
document.location.host.split(':')[0] === 'localhost'
|
||||
? 'cms.netlify.com'
|
||||
: this.props.siteId,
|
||||
auth_endpoint: this.props.authEndpoint,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleLogin = (e) => {
|
||||
handleLogin = e => {
|
||||
e.preventDefault();
|
||||
this.auth.authenticate({ provider: 'gitlab', scope: 'api' }, (err, data) => {
|
||||
if (err) {
|
||||
@ -62,7 +65,7 @@ export default class GitLabAuthenticationPage extends React.Component {
|
||||
loginErrorMessage={this.state.loginError}
|
||||
renderButtonContent={() => (
|
||||
<React.Fragment>
|
||||
<LoginButtonIcon type="gitlab"/> {inProgress ? "Logging in..." : "Login with GitLab"}
|
||||
<LoginButtonIcon type="gitlab" /> {inProgress ? 'Logging in...' : 'Login with GitLab'}
|
||||
</React.Fragment>
|
||||
)}
|
||||
/>
|
||||
|
@ -1,8 +1,8 @@
|
||||
import trimStart from 'lodash/trimStart';
|
||||
import semaphore from "semaphore";
|
||||
import semaphore from 'semaphore';
|
||||
import { CURSOR_COMPATIBILITY_SYMBOL } from 'netlify-cms-lib-util';
|
||||
import AuthenticationPage from "./AuthenticationPage";
|
||||
import API from "./API";
|
||||
import AuthenticationPage from './AuthenticationPage';
|
||||
import API from './API';
|
||||
|
||||
const MAX_CONCURRENT_DOWNLOADS = 10;
|
||||
|
||||
@ -16,18 +16,18 @@ export default class GitLab {
|
||||
};
|
||||
|
||||
if (this.options.useWorkflow) {
|
||||
throw new Error("The GitLab backend does not support the Editorial Workflow.")
|
||||
throw new Error('The GitLab backend does not support the Editorial Workflow.');
|
||||
}
|
||||
|
||||
if (!this.options.proxied && config.getIn(["backend", "repo"]) == null) {
|
||||
throw new Error("The GitLab backend needs a \"repo\" in the backend configuration.");
|
||||
if (!this.options.proxied && config.getIn(['backend', 'repo']) == null) {
|
||||
throw new Error('The GitLab backend needs a "repo" in the backend configuration.');
|
||||
}
|
||||
|
||||
this.api = this.options.API || null;
|
||||
|
||||
this.repo = config.getIn(["backend", "repo"], "");
|
||||
this.branch = config.getIn(["backend", "branch"], "master");
|
||||
this.api_root = config.getIn(["backend", "api_root"], "https://gitlab.com/api/v4");
|
||||
this.repo = config.getIn(['backend', 'repo'], '');
|
||||
this.branch = config.getIn(['backend', 'branch'], 'master');
|
||||
this.api_root = config.getIn(['backend', 'api_root'], 'https://gitlab.com/api/v4');
|
||||
this.token = '';
|
||||
}
|
||||
|
||||
@ -41,14 +41,20 @@ export default class GitLab {
|
||||
|
||||
authenticate(state) {
|
||||
this.token = state.token;
|
||||
this.api = new API({ token: this.token, branch: this.branch, repo: this.repo, api_root: this.api_root });
|
||||
this.api = new API({
|
||||
token: this.token,
|
||||
branch: this.branch,
|
||||
repo: this.repo,
|
||||
api_root: this.api_root,
|
||||
});
|
||||
return this.api.user().then(user =>
|
||||
this.api.hasWriteAccess(user).then((isCollab) => {
|
||||
this.api.hasWriteAccess(user).then(isCollab => {
|
||||
// Unauthorized user
|
||||
if (!isCollab) throw new Error("Your GitLab user account does not have access to this repo.");
|
||||
if (!isCollab)
|
||||
throw new Error('Your GitLab user account does not have access to this repo.');
|
||||
// Authorized user
|
||||
return Object.assign({}, user, { token: state.token });
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@ -62,32 +68,27 @@ export default class GitLab {
|
||||
}
|
||||
|
||||
entriesByFolder(collection, extension) {
|
||||
return this.api.listFiles(collection.get("folder"))
|
||||
.then(({ files, cursor }) =>
|
||||
this.fetchFiles(
|
||||
files.filter(file => file.name.endsWith('.' + extension))
|
||||
)
|
||||
.then(fetchedFiles => {
|
||||
const returnedFiles = fetchedFiles;
|
||||
returnedFiles[CURSOR_COMPATIBILITY_SYMBOL] = cursor;
|
||||
return returnedFiles;
|
||||
})
|
||||
return this.api.listFiles(collection.get('folder')).then(({ files, cursor }) =>
|
||||
this.fetchFiles(files.filter(file => file.name.endsWith('.' + extension))).then(
|
||||
fetchedFiles => {
|
||||
const returnedFiles = fetchedFiles;
|
||||
returnedFiles[CURSOR_COMPATIBILITY_SYMBOL] = cursor;
|
||||
return returnedFiles;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
allEntriesByFolder(collection, extension) {
|
||||
return this.api.listAllFiles(collection.get("folder"))
|
||||
.then(files =>
|
||||
this.fetchFiles(
|
||||
files.filter(file => file.name.endsWith('.' + extension))
|
||||
)
|
||||
);
|
||||
return this.api
|
||||
.listAllFiles(collection.get('folder'))
|
||||
.then(files => this.fetchFiles(files.filter(file => file.name.endsWith('.' + extension))));
|
||||
}
|
||||
|
||||
entriesByFiles(collection) {
|
||||
const files = collection.get("files").map(collectionFile => ({
|
||||
path: collectionFile.get("file"),
|
||||
label: collectionFile.get("label"),
|
||||
const files = collection.get('files').map(collectionFile => ({
|
||||
path: collectionFile.get('file'),
|
||||
label: collectionFile.get('label'),
|
||||
}));
|
||||
return this.fetchFiles(files).then(fetchedFiles => {
|
||||
const returnedFiles = fetchedFiles;
|
||||
@ -95,23 +96,31 @@ export default class GitLab {
|
||||
});
|
||||
}
|
||||
|
||||
fetchFiles = (files) => {
|
||||
fetchFiles = files => {
|
||||
const sem = semaphore(MAX_CONCURRENT_DOWNLOADS);
|
||||
const promises = [];
|
||||
files.forEach((file) => {
|
||||
promises.push(new Promise(resolve => (
|
||||
sem.take(() => this.api.readFile(file.path, file.id).then((data) => {
|
||||
resolve({ file, data });
|
||||
sem.leave();
|
||||
}).catch((error = true) => {
|
||||
sem.leave();
|
||||
console.error(`failed to load file from GitLab: ${ file.path }`);
|
||||
resolve({ error });
|
||||
}))
|
||||
)));
|
||||
files.forEach(file => {
|
||||
promises.push(
|
||||
new Promise(resolve =>
|
||||
sem.take(() =>
|
||||
this.api
|
||||
.readFile(file.path, file.id)
|
||||
.then(data => {
|
||||
resolve({ file, data });
|
||||
sem.leave();
|
||||
})
|
||||
.catch((error = true) => {
|
||||
sem.leave();
|
||||
console.error(`failed to load file from GitLab: ${file.path}`);
|
||||
resolve({ error });
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
return Promise.all(promises)
|
||||
.then(loadedEntries => loadedEntries.filter(loadedEntry => !loadedEntry.error));
|
||||
return Promise.all(promises).then(loadedEntries =>
|
||||
loadedEntries.filter(loadedEntry => !loadedEntry.error),
|
||||
);
|
||||
};
|
||||
|
||||
// Fetches a single entry.
|
||||
@ -123,17 +132,17 @@ export default class GitLab {
|
||||
}
|
||||
|
||||
getMedia() {
|
||||
return this.api.listAllFiles(this.config.get('media_folder'))
|
||||
.then(files => files.map(({ id, name, path }) => {
|
||||
return this.api.listAllFiles(this.config.get('media_folder')).then(files =>
|
||||
files.map(({ id, name, path }) => {
|
||||
const url = new URL(this.api.fileDownloadURL(path));
|
||||
if (url.pathname.match(/.svg$/)) {
|
||||
url.search += (url.search.slice(1) === '' ? '?' : '&') + 'sanitize=true';
|
||||
}
|
||||
return { id, name, url: url.href, path };
|
||||
}));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
async persistEntry(entry, mediaFiles, options = {}) {
|
||||
return this.api.persistFiles([entry], options);
|
||||
}
|
||||
@ -150,10 +159,11 @@ export default class GitLab {
|
||||
}
|
||||
|
||||
traverseCursor(cursor, action) {
|
||||
return this.api.traverseCursor(cursor, action)
|
||||
.then(async ({ entries, cursor: newCursor }) => ({
|
||||
entries: await Promise.all(entries.map(file => this.api.readFile(file.path, file.id).then(data => ({ file, data })))),
|
||||
cursor: newCursor,
|
||||
}));
|
||||
return this.api.traverseCursor(cursor, action).then(async ({ entries, cursor: newCursor }) => ({
|
||||
entries: await Promise.all(
|
||||
entries.map(file => this.api.readFile(file.path, file.id).then(data => ({ file, data }))),
|
||||
),
|
||||
cursor: newCursor,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,3 @@
|
||||
export GitLabBackend from './implementation';
|
||||
export API from './API';
|
||||
export AuthenticationPage from './AuthenticationPage';
|
||||
|
||||
|
@ -10,12 +10,12 @@ const StyledAuthenticationPage = styled.section`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
`
|
||||
`;
|
||||
|
||||
const PageLogoIcon = styled(Icon)`
|
||||
color: #c4c6d2;
|
||||
margin-top: -300px;
|
||||
`
|
||||
`;
|
||||
|
||||
const LoginButton = styled.button`
|
||||
${buttons.button};
|
||||
@ -32,7 +32,7 @@ const LoginButton = styled.button`
|
||||
${Icon} {
|
||||
margin-right: 18px;
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
export default class AuthenticationPage extends React.Component {
|
||||
static propTypes = {
|
||||
@ -51,7 +51,7 @@ export default class AuthenticationPage extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
handleLogin = (e) => {
|
||||
handleLogin = e => {
|
||||
e.preventDefault();
|
||||
this.props.onLogin(this.state);
|
||||
};
|
||||
@ -61,9 +61,9 @@ export default class AuthenticationPage extends React.Component {
|
||||
|
||||
return (
|
||||
<StyledAuthenticationPage>
|
||||
<PageLogoIcon size="300px" type="netlify-cms"/>
|
||||
<PageLogoIcon size="300px" type="netlify-cms" />
|
||||
<LoginButton disabled={inProgress} onClick={this.handleLogin}>
|
||||
{inProgress ? "Logging in..." : "Login"}
|
||||
{inProgress ? 'Logging in...' : 'Login'}
|
||||
</LoginButton>
|
||||
</StyledAuthenticationPage>
|
||||
);
|
||||
|
@ -23,8 +23,8 @@ const getCursor = (collection, extension, entries, index) => {
|
||||
const pageCount = Math.floor(count / pageSize);
|
||||
return Cursor.create({
|
||||
actions: [
|
||||
...(index < pageCount ? ["next", "last"] : []),
|
||||
...(index > 0 ? ["prev", "first"] : []),
|
||||
...(index < pageCount ? ['next', 'last'] : []),
|
||||
...(index > 0 ? ['prev', 'first'] : []),
|
||||
],
|
||||
meta: { index, count, pageSize, pageCount },
|
||||
data: { collection, extension, index, pageCount },
|
||||
@ -33,9 +33,9 @@ const getCursor = (collection, extension, entries, index) => {
|
||||
|
||||
const getFolderEntries = (folder, extension) => {
|
||||
return Object.keys(window.repoFiles[folder] || {})
|
||||
.filter(path => path.endsWith(`.${ extension }`))
|
||||
.filter(path => path.endsWith(`.${extension}`))
|
||||
.map(path => ({
|
||||
file: { path: `${ folder }/${ path }` },
|
||||
file: { path: `${folder}/${path}` },
|
||||
data: window.repoFiles[folder][path].content,
|
||||
}))
|
||||
.reverse();
|
||||
@ -71,14 +71,22 @@ export default class TestRepo {
|
||||
traverseCursor(cursor, action) {
|
||||
const { collection, extension, index, pageCount } = cursor.data.toObject();
|
||||
const newIndex = (() => {
|
||||
if (action === "next") { return index + 1; }
|
||||
if (action === "prev") { return index - 1; }
|
||||
if (action === "first") { return 0; }
|
||||
if (action === "last") { return pageCount; }
|
||||
if (action === 'next') {
|
||||
return index + 1;
|
||||
}
|
||||
if (action === 'prev') {
|
||||
return index - 1;
|
||||
}
|
||||
if (action === 'first') {
|
||||
return 0;
|
||||
}
|
||||
if (action === 'last') {
|
||||
return pageCount;
|
||||
}
|
||||
})();
|
||||
// TODO: stop assuming cursors are for collections
|
||||
const allEntries = getFolderEntries(collection.get('folder'), extension);
|
||||
const entries = allEntries.slice(newIndex * pageSize, (newIndex * pageSize) + pageSize);
|
||||
const entries = allEntries.slice(newIndex * pageSize, newIndex * pageSize + pageSize);
|
||||
const newCursor = getCursor(collection, extension, allEntries, newIndex);
|
||||
return Promise.resolve({ entries, cursor: newCursor });
|
||||
}
|
||||
@ -97,10 +105,12 @@ export default class TestRepo {
|
||||
path: collectionFile.get('file'),
|
||||
label: collectionFile.get('label'),
|
||||
}));
|
||||
return Promise.all(files.map(file => ({
|
||||
file,
|
||||
data: getFile(file.path).content,
|
||||
})));
|
||||
return Promise.all(
|
||||
files.map(file => ({
|
||||
file,
|
||||
data: getFile(file.path).content,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
getEntry(collection, slug, path) {
|
||||
@ -115,20 +125,22 @@ export default class TestRepo {
|
||||
}
|
||||
|
||||
unpublishedEntry(collection, slug) {
|
||||
const entry = window.repoFilesUnpublished.find(e => (
|
||||
e.metaData.collection === collection.get('name') && e.slug === slug
|
||||
));
|
||||
const entry = window.repoFilesUnpublished.find(
|
||||
e => e.metaData.collection === collection.get('name') && e.slug === slug,
|
||||
);
|
||||
if (!entry) {
|
||||
return Promise.reject(new EditorialWorkflowError('content is not under editorial workflow', true));
|
||||
return Promise.reject(
|
||||
new EditorialWorkflowError('content is not under editorial workflow', true),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(entry);
|
||||
}
|
||||
|
||||
deleteUnpublishedEntry(collection, slug) {
|
||||
const unpubStore = window.repoFilesUnpublished;
|
||||
const existingEntryIndex = unpubStore.findIndex(e => (
|
||||
e.metaData.collection === collection && e.slug === slug
|
||||
));
|
||||
const existingEntryIndex = unpubStore.findIndex(
|
||||
e => e.metaData.collection === collection && e.slug === slug,
|
||||
);
|
||||
unpubStore.splice(existingEntryIndex, 1);
|
||||
return Promise.resolve();
|
||||
}
|
||||
@ -176,18 +188,18 @@ export default class TestRepo {
|
||||
|
||||
updateUnpublishedEntryStatus(collection, slug, newStatus) {
|
||||
const unpubStore = window.repoFilesUnpublished;
|
||||
const entryIndex = unpubStore.findIndex(e => (
|
||||
e.metaData.collection === collection && e.slug === slug
|
||||
));
|
||||
const entryIndex = unpubStore.findIndex(
|
||||
e => e.metaData.collection === collection && e.slug === slug,
|
||||
);
|
||||
unpubStore[entryIndex].metaData.status = newStatus;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
publishUnpublishedEntry(collection, slug) {
|
||||
const unpubStore = window.repoFilesUnpublished;
|
||||
const unpubEntryIndex = unpubStore.findIndex(e => (
|
||||
e.metaData.collection === collection && e.slug === slug
|
||||
));
|
||||
const unpubEntryIndex = unpubStore.findIndex(
|
||||
e => e.metaData.collection === collection && e.slug === slug,
|
||||
);
|
||||
const unpubEntry = unpubStore[unpubEntryIndex];
|
||||
const entry = { raw: unpubEntry.data, slug: unpubEntry.slug, path: unpubEntry.file.path };
|
||||
unpubStore.splice(unpubEntryIndex, 1);
|
||||
@ -211,9 +223,7 @@ export default class TestRepo {
|
||||
const assetIndex = this.assets.findIndex(asset => asset.path === path);
|
||||
if (assetIndex > -1) {
|
||||
this.assets.splice(assetIndex, 1);
|
||||
}
|
||||
|
||||
else {
|
||||
} else {
|
||||
const folder = path.substring(0, path.lastIndexOf('/'));
|
||||
const fileName = path.substring(path.lastIndexOf('/') + 1);
|
||||
delete window.repoFiles[folder][fileName];
|
||||
|
@ -6,20 +6,23 @@ module.exports = {
|
||||
plugins: [
|
||||
...babelConfig.plugins,
|
||||
'react-hot-loader/babel',
|
||||
['module-resolver', {
|
||||
root: path.join(__dirname, 'src/components'),
|
||||
alias: {
|
||||
src: path.join(__dirname, 'src'),
|
||||
Actions: path.join(__dirname, 'src/actions/'),
|
||||
Constants: path.join(__dirname, 'src/constants/'),
|
||||
Formats: path.join(__dirname, 'src/formats/'),
|
||||
Integrations: path.join(__dirname, 'src/integrations/'),
|
||||
Lib: path.join(__dirname, 'src/lib/'),
|
||||
Reducers: path.join(__dirname, 'src/reducers/'),
|
||||
Redux: path.join(__dirname, 'src/redux/'),
|
||||
Routing: path.join(__dirname, 'src/routing/'),
|
||||
ValueObjects: path.join(__dirname, 'src/valueObjects/'),
|
||||
}
|
||||
}],
|
||||
[
|
||||
'module-resolver',
|
||||
{
|
||||
root: path.join(__dirname, 'src/components'),
|
||||
alias: {
|
||||
src: path.join(__dirname, 'src'),
|
||||
Actions: path.join(__dirname, 'src/actions/'),
|
||||
Constants: path.join(__dirname, 'src/constants/'),
|
||||
Formats: path.join(__dirname, 'src/formats/'),
|
||||
Integrations: path.join(__dirname, 'src/integrations/'),
|
||||
Lib: path.join(__dirname, 'src/lib/'),
|
||||
Reducers: path.join(__dirname, 'src/reducers/'),
|
||||
Redux: path.join(__dirname, 'src/redux/'),
|
||||
Routing: path.join(__dirname, 'src/routing/'),
|
||||
ValueObjects: path.join(__dirname, 'src/valueObjects/'),
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
|
@ -9,11 +9,7 @@ describe('config', () => {
|
||||
media_folder: 'path/to/media',
|
||||
public_folder: '/path/to/media',
|
||||
});
|
||||
expect(
|
||||
applyDefaults(config)
|
||||
).toEqual(
|
||||
config.set('publish_mode', 'simple')
|
||||
);
|
||||
expect(applyDefaults(config)).toEqual(config.set('publish_mode', 'simple'));
|
||||
});
|
||||
|
||||
it('should set publish_mode from config', () => {
|
||||
@ -23,36 +19,44 @@ describe('config', () => {
|
||||
media_folder: 'path/to/media',
|
||||
public_folder: '/path/to/media',
|
||||
});
|
||||
expect(
|
||||
applyDefaults(config)
|
||||
).toEqual(
|
||||
config
|
||||
);
|
||||
expect(applyDefaults(config)).toEqual(config);
|
||||
});
|
||||
|
||||
it('should set public_folder based on media_folder if not set', () => {
|
||||
expect(applyDefaults(fromJS({
|
||||
foo: 'bar',
|
||||
media_folder: 'path/to/media',
|
||||
}))).toEqual(fromJS({
|
||||
foo: 'bar',
|
||||
publish_mode: 'simple',
|
||||
media_folder: 'path/to/media',
|
||||
public_folder: '/path/to/media',
|
||||
}));
|
||||
expect(
|
||||
applyDefaults(
|
||||
fromJS({
|
||||
foo: 'bar',
|
||||
media_folder: 'path/to/media',
|
||||
}),
|
||||
),
|
||||
).toEqual(
|
||||
fromJS({
|
||||
foo: 'bar',
|
||||
publish_mode: 'simple',
|
||||
media_folder: 'path/to/media',
|
||||
public_folder: '/path/to/media',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not overwrite public_folder if set', () => {
|
||||
expect(applyDefaults(fromJS({
|
||||
foo: 'bar',
|
||||
media_folder: 'path/to/media',
|
||||
public_folder: '/publib/path',
|
||||
}))).toEqual(fromJS({
|
||||
foo: 'bar',
|
||||
publish_mode: 'simple',
|
||||
media_folder: 'path/to/media',
|
||||
public_folder: '/publib/path',
|
||||
}));
|
||||
expect(
|
||||
applyDefaults(
|
||||
fromJS({
|
||||
foo: 'bar',
|
||||
media_folder: 'path/to/media',
|
||||
public_folder: '/publib/path',
|
||||
}),
|
||||
),
|
||||
).toEqual(
|
||||
fromJS({
|
||||
foo: 'bar',
|
||||
publish_mode: 'simple',
|
||||
media_folder: 'path/to/media',
|
||||
public_folder: '/publib/path',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -48,15 +48,16 @@ export function authenticateUser() {
|
||||
const state = getState();
|
||||
const backend = currentBackend(state.config);
|
||||
dispatch(authenticating());
|
||||
return backend.currentUser()
|
||||
.then((user) => {
|
||||
return backend
|
||||
.currentUser()
|
||||
.then(user => {
|
||||
if (user) {
|
||||
dispatch(authenticate(user));
|
||||
} else {
|
||||
dispatch(doneAuthenticating());
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
.catch(error => {
|
||||
dispatch(authError(error));
|
||||
dispatch(logoutUser());
|
||||
});
|
||||
@ -69,16 +70,19 @@ export function loginUser(credentials) {
|
||||
const backend = currentBackend(state.config);
|
||||
|
||||
dispatch(authenticating());
|
||||
return backend.authenticate(credentials)
|
||||
.then((user) => {
|
||||
return backend
|
||||
.authenticate(credentials)
|
||||
.then(user => {
|
||||
dispatch(authenticate(user));
|
||||
})
|
||||
.catch((error) => {
|
||||
dispatch(notifSend({
|
||||
message: `${ error.message }`,
|
||||
kind: 'warning',
|
||||
dismissAfter: 8000,
|
||||
}));
|
||||
.catch(error => {
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: `${error.message}`,
|
||||
kind: 'warning',
|
||||
dismissAfter: 8000,
|
||||
}),
|
||||
);
|
||||
dispatch(authError(error));
|
||||
});
|
||||
};
|
||||
|
@ -1,15 +1,14 @@
|
||||
import yaml from "js-yaml";
|
||||
import { Map, fromJS } from "immutable";
|
||||
import { trimStart, flow, get } from "lodash";
|
||||
import { authenticateUser } from "Actions/auth";
|
||||
import * as publishModes from "Constants/publishModes";
|
||||
import yaml from 'js-yaml';
|
||||
import { Map, fromJS } from 'immutable';
|
||||
import { trimStart, get } from 'lodash';
|
||||
import { authenticateUser } from 'Actions/auth';
|
||||
import * as publishModes from 'Constants/publishModes';
|
||||
import { validateConfig } from 'Constants/configSchema';
|
||||
|
||||
export const CONFIG_REQUEST = "CONFIG_REQUEST";
|
||||
export const CONFIG_SUCCESS = "CONFIG_SUCCESS";
|
||||
export const CONFIG_FAILURE = "CONFIG_FAILURE";
|
||||
export const CONFIG_MERGE = "CONFIG_MERGE";
|
||||
|
||||
export const CONFIG_REQUEST = 'CONFIG_REQUEST';
|
||||
export const CONFIG_SUCCESS = 'CONFIG_SUCCESS';
|
||||
export const CONFIG_FAILURE = 'CONFIG_FAILURE';
|
||||
export const CONFIG_MERGE = 'CONFIG_MERGE';
|
||||
|
||||
const getConfigUrl = () => {
|
||||
const validTypes = { 'text/yaml': 'yaml', 'application/x-yaml': 'yaml' };
|
||||
@ -21,7 +20,7 @@ const getConfigUrl = () => {
|
||||
return link;
|
||||
}
|
||||
return 'config.yml';
|
||||
}
|
||||
};
|
||||
|
||||
const defaults = {
|
||||
publish_mode: publishModes.SIMPLE,
|
||||
@ -48,8 +47,8 @@ function mergePreloadedConfig(preloadedConfig, loadedConfig) {
|
||||
|
||||
function parseConfig(data) {
|
||||
const config = yaml.safeLoad(data);
|
||||
if (typeof CMS_ENV === "string" && config[CMS_ENV]) {
|
||||
Object.keys(config[CMS_ENV]).forEach((key) => {
|
||||
if (typeof CMS_ENV === 'string' && config[CMS_ENV]) {
|
||||
Object.keys(config[CMS_ENV]).forEach(key => {
|
||||
config[key] = config[CMS_ENV][key];
|
||||
});
|
||||
}
|
||||
@ -60,12 +59,12 @@ async function getConfig(file, isPreloaded) {
|
||||
const response = await fetch(file, { credentials: 'same-origin' });
|
||||
if (response.status !== 200) {
|
||||
if (isPreloaded) return parseConfig('');
|
||||
throw new Error(`Failed to load config.yml (${ response.status })`);
|
||||
throw new Error(`Failed to load config.yml (${response.status})`);
|
||||
}
|
||||
const contentType = response.headers.get('Content-Type') || 'Not-Found';
|
||||
const isYaml = contentType.indexOf('yaml') !== -1;
|
||||
if (!isYaml) {
|
||||
console.log(`Response for ${ file } was not yaml. (Content-Type: ${ contentType })`);
|
||||
console.log(`Response for ${file} was not yaml. (Content-Type: ${contentType})`);
|
||||
if (isPreloaded) return parseConfig('');
|
||||
}
|
||||
return parseConfig(await response.text());
|
||||
@ -87,13 +86,13 @@ export function configLoading() {
|
||||
export function configFailed(err) {
|
||||
return {
|
||||
type: CONFIG_FAILURE,
|
||||
error: "Error loading config",
|
||||
error: 'Error loading config',
|
||||
payload: err,
|
||||
};
|
||||
}
|
||||
|
||||
export function configDidLoad(config) {
|
||||
return (dispatch) => {
|
||||
return dispatch => {
|
||||
dispatch(configLoaded(config));
|
||||
};
|
||||
}
|
||||
@ -124,10 +123,9 @@ export function loadConfig() {
|
||||
|
||||
dispatch(configDidLoad(config));
|
||||
dispatch(authenticateUser());
|
||||
}
|
||||
catch(err) {
|
||||
} catch (err) {
|
||||
dispatch(configFailed(err));
|
||||
throw(err)
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -56,7 +56,7 @@ function unpublishedEntryLoading(collection, slug) {
|
||||
function unpublishedEntryLoaded(collection, entry) {
|
||||
return {
|
||||
type: UNPUBLISHED_ENTRY_SUCCESS,
|
||||
payload: {
|
||||
payload: {
|
||||
collection: collection.get('name'),
|
||||
entry,
|
||||
},
|
||||
@ -66,7 +66,7 @@ function unpublishedEntryLoaded(collection, entry) {
|
||||
function unpublishedEntryRedirected(collection, slug) {
|
||||
return {
|
||||
type: UNPUBLISHED_ENTRY_REDIRECT,
|
||||
payload: {
|
||||
payload: {
|
||||
collection: collection.get('name'),
|
||||
slug,
|
||||
},
|
||||
@ -97,7 +97,6 @@ function unpublishedEntriesFailed(error) {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function unpublishedEntryPersisting(collection, entry, transactionID) {
|
||||
return {
|
||||
type: UNPUBLISHED_ENTRY_PERSIST_REQUEST,
|
||||
@ -112,7 +111,7 @@ function unpublishedEntryPersisting(collection, entry, transactionID) {
|
||||
function unpublishedEntryPersisted(collection, entry, transactionID, slug) {
|
||||
return {
|
||||
type: UNPUBLISHED_ENTRY_PERSIST_SUCCESS,
|
||||
payload: {
|
||||
payload: {
|
||||
collection: collection.get('name'),
|
||||
entry,
|
||||
slug,
|
||||
@ -130,10 +129,16 @@ function unpublishedEntryPersistedFail(error, transactionID) {
|
||||
};
|
||||
}
|
||||
|
||||
function unpublishedEntryStatusChangeRequest(collection, slug, oldStatus, newStatus, transactionID) {
|
||||
function unpublishedEntryStatusChangeRequest(
|
||||
collection,
|
||||
slug,
|
||||
oldStatus,
|
||||
newStatus,
|
||||
transactionID,
|
||||
) {
|
||||
return {
|
||||
type: UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST,
|
||||
payload: {
|
||||
payload: {
|
||||
collection,
|
||||
slug,
|
||||
oldStatus,
|
||||
@ -143,10 +148,16 @@ function unpublishedEntryStatusChangeRequest(collection, slug, oldStatus, newSta
|
||||
};
|
||||
}
|
||||
|
||||
function unpublishedEntryStatusChangePersisted(collection, slug, oldStatus, newStatus, transactionID) {
|
||||
function unpublishedEntryStatusChangePersisted(
|
||||
collection,
|
||||
slug,
|
||||
oldStatus,
|
||||
newStatus,
|
||||
transactionID,
|
||||
) {
|
||||
return {
|
||||
type: UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS,
|
||||
payload: {
|
||||
payload: {
|
||||
collection,
|
||||
slug,
|
||||
oldStatus,
|
||||
@ -223,20 +234,23 @@ export function loadUnpublishedEntry(collection, slug) {
|
||||
const state = getState();
|
||||
const backend = currentBackend(state.config);
|
||||
dispatch(unpublishedEntryLoading(collection, slug));
|
||||
backend.unpublishedEntry(collection, slug)
|
||||
.then(entry => dispatch(unpublishedEntryLoaded(collection, entry)))
|
||||
.catch((error) => {
|
||||
if (error instanceof EditorialWorkflowError && error.notUnderEditorialWorkflow) {
|
||||
dispatch(unpublishedEntryRedirected(collection, slug));
|
||||
dispatch(loadEntry(collection, slug));
|
||||
} else {
|
||||
dispatch(notifSend({
|
||||
message: `Error loading entry: ${ error }`,
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}));
|
||||
}
|
||||
});
|
||||
backend
|
||||
.unpublishedEntry(collection, slug)
|
||||
.then(entry => dispatch(unpublishedEntryLoaded(collection, entry)))
|
||||
.catch(error => {
|
||||
if (error instanceof EditorialWorkflowError && error.notUnderEditorialWorkflow) {
|
||||
dispatch(unpublishedEntryRedirected(collection, slug));
|
||||
dispatch(loadEntry(collection, slug));
|
||||
} else {
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: `Error loading entry: ${error}`,
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@ -246,16 +260,19 @@ export function loadUnpublishedEntries(collections) {
|
||||
if (state.config.get('publish_mode') !== EDITORIAL_WORKFLOW) return;
|
||||
const backend = currentBackend(state.config);
|
||||
dispatch(unpublishedEntriesLoading());
|
||||
backend.unpublishedEntries(collections)
|
||||
backend
|
||||
.unpublishedEntries(collections)
|
||||
.then(response => dispatch(unpublishedEntriesLoaded(response.entries, response.pagination)))
|
||||
.catch(error => {
|
||||
dispatch(notifSend({
|
||||
message: `Error loading entries: ${ error }`,
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}));
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: `Error loading entries: ${error}`,
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}),
|
||||
);
|
||||
dispatch(unpublishedEntriesFailed(error));
|
||||
Promise.reject(error)
|
||||
Promise.reject(error);
|
||||
});
|
||||
};
|
||||
}
|
||||
@ -268,17 +285,20 @@ export function persistUnpublishedEntry(collection, existingUnpublishedEntry) {
|
||||
|
||||
// Early return if draft contains validation errors
|
||||
if (!fieldsErrors.isEmpty()) {
|
||||
const hasPresenceErrors = fieldsErrors
|
||||
.some(errors => errors.some(error => error.type && error.type === ValidationErrorTypes.PRESENCE));
|
||||
const hasPresenceErrors = fieldsErrors.some(errors =>
|
||||
errors.some(error => error.type && error.type === ValidationErrorTypes.PRESENCE),
|
||||
);
|
||||
|
||||
if (hasPresenceErrors) {
|
||||
dispatch(notifSend({
|
||||
message: 'Oops, you\'ve missed a required field. Please complete before saving.',
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}));
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: "Oops, you've missed a required field. Please complete before saving.",
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Promise.reject()
|
||||
return Promise.reject();
|
||||
}
|
||||
|
||||
const backend = currentBackend(state.config);
|
||||
@ -296,7 +316,9 @@ export function persistUnpublishedEntry(collection, existingUnpublishedEntry) {
|
||||
const serializedEntryDraft = entryDraft.set('entry', serializedEntry);
|
||||
|
||||
dispatch(unpublishedEntryPersisting(collection, serializedEntry, transactionID));
|
||||
const persistAction = existingUnpublishedEntry ? backend.persistUnpublishedEntry : backend.persistEntry;
|
||||
const persistAction = existingUnpublishedEntry
|
||||
? backend.persistUnpublishedEntry
|
||||
: backend.persistEntry;
|
||||
const persistCallArgs = [
|
||||
backend,
|
||||
state.config,
|
||||
@ -308,19 +330,22 @@ export function persistUnpublishedEntry(collection, existingUnpublishedEntry) {
|
||||
|
||||
try {
|
||||
const newSlug = await persistAction.call(...persistCallArgs);
|
||||
dispatch(notifSend({
|
||||
message: 'Entry saved',
|
||||
kind: 'success',
|
||||
dismissAfter: 4000,
|
||||
}));
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: 'Entry saved',
|
||||
kind: 'success',
|
||||
dismissAfter: 4000,
|
||||
}),
|
||||
);
|
||||
dispatch(unpublishedEntryPersisted(collection, serializedEntry, transactionID, newSlug));
|
||||
}
|
||||
catch(error) {
|
||||
dispatch(notifSend({
|
||||
message: `Failed to persist entry: ${ error }`,
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}));
|
||||
} catch (error) {
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: `Failed to persist entry: ${error}`,
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}),
|
||||
);
|
||||
return Promise.reject(dispatch(unpublishedEntryPersistedFail(error, transactionID)));
|
||||
}
|
||||
};
|
||||
@ -331,24 +356,39 @@ export function updateUnpublishedEntryStatus(collection, slug, oldStatus, newSta
|
||||
const state = getState();
|
||||
const backend = currentBackend(state.config);
|
||||
const transactionID = uuid();
|
||||
dispatch(unpublishedEntryStatusChangeRequest(collection, slug, oldStatus, newStatus, transactionID));
|
||||
backend.updateUnpublishedEntryStatus(collection, slug, newStatus)
|
||||
.then(() => {
|
||||
dispatch(notifSend({
|
||||
message: 'Entry status updated',
|
||||
kind: 'success',
|
||||
dismissAfter: 4000,
|
||||
}));
|
||||
dispatch(unpublishedEntryStatusChangePersisted(collection, slug, oldStatus, newStatus, transactionID));
|
||||
})
|
||||
.catch((error) => {
|
||||
dispatch(notifSend({
|
||||
message: `Failed to update status: ${ error }`,
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}));
|
||||
dispatch(unpublishedEntryStatusChangeError(collection, slug, transactionID));
|
||||
});
|
||||
dispatch(
|
||||
unpublishedEntryStatusChangeRequest(collection, slug, oldStatus, newStatus, transactionID),
|
||||
);
|
||||
backend
|
||||
.updateUnpublishedEntryStatus(collection, slug, newStatus)
|
||||
.then(() => {
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: 'Entry status updated',
|
||||
kind: 'success',
|
||||
dismissAfter: 4000,
|
||||
}),
|
||||
);
|
||||
dispatch(
|
||||
unpublishedEntryStatusChangePersisted(
|
||||
collection,
|
||||
slug,
|
||||
oldStatus,
|
||||
newStatus,
|
||||
transactionID,
|
||||
),
|
||||
);
|
||||
})
|
||||
.catch(error => {
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: `Failed to update status: ${error}`,
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}),
|
||||
);
|
||||
dispatch(unpublishedEntryStatusChangeError(collection, slug, transactionID));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@ -358,23 +398,28 @@ export function deleteUnpublishedEntry(collection, slug) {
|
||||
const backend = currentBackend(state.config);
|
||||
const transactionID = uuid();
|
||||
dispatch(unpublishedEntryDeleteRequest(collection, slug, transactionID));
|
||||
return backend.deleteUnpublishedEntry(collection, slug)
|
||||
.then(() => {
|
||||
dispatch(notifSend({
|
||||
message: 'Unpublished changes deleted',
|
||||
kind: 'success',
|
||||
dismissAfter: 4000,
|
||||
}));
|
||||
dispatch(unpublishedEntryDeleted(collection, slug, transactionID));
|
||||
})
|
||||
.catch((error) => {
|
||||
dispatch(notifSend({
|
||||
message: `Failed to delete unpublished changes: ${ error }`,
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}));
|
||||
dispatch(unpublishedEntryDeleteError(collection, slug, transactionID));
|
||||
});
|
||||
return backend
|
||||
.deleteUnpublishedEntry(collection, slug)
|
||||
.then(() => {
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: 'Unpublished changes deleted',
|
||||
kind: 'success',
|
||||
dismissAfter: 4000,
|
||||
}),
|
||||
);
|
||||
dispatch(unpublishedEntryDeleted(collection, slug, transactionID));
|
||||
})
|
||||
.catch(error => {
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: `Failed to delete unpublished changes: ${error}`,
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}),
|
||||
);
|
||||
dispatch(unpublishedEntryDeleteError(collection, slug, transactionID));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@ -384,22 +429,27 @@ export function publishUnpublishedEntry(collection, slug) {
|
||||
const backend = currentBackend(state.config);
|
||||
const transactionID = uuid();
|
||||
dispatch(unpublishedEntryPublishRequest(collection, slug, transactionID));
|
||||
return backend.publishUnpublishedEntry(collection, slug)
|
||||
.then(() => {
|
||||
dispatch(notifSend({
|
||||
message: 'Entry published',
|
||||
kind: 'success',
|
||||
dismissAfter: 4000,
|
||||
}));
|
||||
dispatch(unpublishedEntryPublished(collection, slug, transactionID));
|
||||
})
|
||||
.catch((error) => {
|
||||
dispatch(notifSend({
|
||||
message: `Failed to publish: ${ error }`,
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}));
|
||||
dispatch(unpublishedEntryPublishError(collection, slug, transactionID));
|
||||
});
|
||||
return backend
|
||||
.publishUnpublishedEntry(collection, slug)
|
||||
.then(() => {
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: 'Entry published',
|
||||
kind: 'success',
|
||||
dismissAfter: 4000,
|
||||
}),
|
||||
);
|
||||
dispatch(unpublishedEntryPublished(collection, slug, transactionID));
|
||||
})
|
||||
.catch(error => {
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: `Failed to publish: ${error}`,
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}),
|
||||
);
|
||||
dispatch(unpublishedEntryPublishError(collection, slug, transactionID));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
@ -188,7 +188,6 @@ export function createDraftFromEntry(entry, metadata) {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export function discardDraft() {
|
||||
return {
|
||||
type: DRAFT_DISCARD,
|
||||
@ -216,7 +215,6 @@ export function changeDraftFieldValidation(field, errors) {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Exported Thunk Action Creators
|
||||
*/
|
||||
@ -226,31 +224,33 @@ export function loadEntry(collection, slug) {
|
||||
const state = getState();
|
||||
const backend = currentBackend(state.config);
|
||||
dispatch(entryLoading(collection, slug));
|
||||
return backend.getEntry(collection, slug)
|
||||
return backend
|
||||
.getEntry(collection, slug)
|
||||
.then(loadedEntry => {
|
||||
return dispatch(entryLoaded(collection, loadedEntry))
|
||||
return dispatch(entryLoaded(collection, loadedEntry));
|
||||
})
|
||||
.catch((error) => {
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
dispatch(notifSend({
|
||||
message: `Failed to load entry: ${ error.message }`,
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}));
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: `Failed to load entry: ${error.message}`,
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}),
|
||||
);
|
||||
dispatch(entryLoadError(error, collection, slug));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const appendActions = fromJS({
|
||||
["append_next"]: { action: "next", append: true },
|
||||
['append_next']: { action: 'next', append: true },
|
||||
});
|
||||
|
||||
const addAppendActionsToCursor = cursor => Cursor
|
||||
.create(cursor)
|
||||
.updateStore("actions", actions => actions.union(
|
||||
appendActions.filter(v => actions.has(v.get("action"))).keySeq()
|
||||
));
|
||||
const addAppendActionsToCursor = cursor =>
|
||||
Cursor.create(cursor).updateStore('actions', actions =>
|
||||
actions.union(appendActions.filter(v => actions.has(v.get('action'))).keySeq()),
|
||||
);
|
||||
|
||||
export function loadEntries(collection, page = 0) {
|
||||
return (dispatch, getState) => {
|
||||
@ -260,46 +260,59 @@ export function loadEntries(collection, page = 0) {
|
||||
const state = getState();
|
||||
const backend = currentBackend(state.config);
|
||||
const integration = selectIntegration(state, collection.get('name'), 'listEntries');
|
||||
const provider = integration ? getIntegrationProvider(state.integrations, backend.getToken, integration) : backend;
|
||||
const provider = integration
|
||||
? getIntegrationProvider(state.integrations, backend.getToken, integration)
|
||||
: backend;
|
||||
const append = !!(page && !isNaN(page) && page > 0);
|
||||
dispatch(entriesLoading(collection));
|
||||
provider.listEntries(collection, page)
|
||||
.then(response => ({
|
||||
...response,
|
||||
provider
|
||||
.listEntries(collection, page)
|
||||
.then(response => ({
|
||||
...response,
|
||||
|
||||
// The only existing backend using the pagination system is the
|
||||
// Algolia integration, which is also the only integration used
|
||||
// to list entries. Thus, this checking for an integration can
|
||||
// determine whether or not this is using the old integer-based
|
||||
// pagination API. Other backends will simply store an empty
|
||||
// cursor, which behaves identically to no cursor at all.
|
||||
cursor: integration
|
||||
? Cursor.create({ actions: ["next"], meta: { usingOldPaginationAPI: true }, data: { nextPage: page + 1 } })
|
||||
: Cursor.create(response.cursor),
|
||||
}))
|
||||
.then(response => dispatch(entriesLoaded(
|
||||
collection,
|
||||
response.cursor.meta.get('usingOldPaginationAPI')
|
||||
? response.entries.reverse()
|
||||
: response.entries,
|
||||
response.pagination,
|
||||
addAppendActionsToCursor(response.cursor),
|
||||
append,
|
||||
)))
|
||||
.catch(err => {
|
||||
dispatch(notifSend({
|
||||
message: `Failed to load entries: ${ err }`,
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}));
|
||||
return Promise.reject(dispatch(entriesFailed(collection, err)));
|
||||
});
|
||||
// The only existing backend using the pagination system is the
|
||||
// Algolia integration, which is also the only integration used
|
||||
// to list entries. Thus, this checking for an integration can
|
||||
// determine whether or not this is using the old integer-based
|
||||
// pagination API. Other backends will simply store an empty
|
||||
// cursor, which behaves identically to no cursor at all.
|
||||
cursor: integration
|
||||
? Cursor.create({
|
||||
actions: ['next'],
|
||||
meta: { usingOldPaginationAPI: true },
|
||||
data: { nextPage: page + 1 },
|
||||
})
|
||||
: Cursor.create(response.cursor),
|
||||
}))
|
||||
.then(response =>
|
||||
dispatch(
|
||||
entriesLoaded(
|
||||
collection,
|
||||
response.cursor.meta.get('usingOldPaginationAPI')
|
||||
? response.entries.reverse()
|
||||
: response.entries,
|
||||
response.pagination,
|
||||
addAppendActionsToCursor(response.cursor),
|
||||
append,
|
||||
),
|
||||
),
|
||||
)
|
||||
.catch(err => {
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: `Failed to load entries: ${err}`,
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}),
|
||||
);
|
||||
return Promise.reject(dispatch(entriesFailed(collection, err)));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function traverseCursor(backend, cursor, action) {
|
||||
if (!cursor.actions.has(action)) {
|
||||
throw new Error(`The current cursor does not support the pagination action "${ action }".`);
|
||||
throw new Error(`The current cursor does not support the pagination action "${action}".`);
|
||||
}
|
||||
return backend.traverseCursor(cursor, action);
|
||||
}
|
||||
@ -307,7 +320,7 @@ function traverseCursor(backend, cursor, action) {
|
||||
export function traverseCollectionCursor(collection, action) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
if (state.entries.getIn(['pages', `${ collection.get('name') }`, 'isFetching',])) {
|
||||
if (state.entries.getIn(['pages', `${collection.get('name')}`, 'isFetching'])) {
|
||||
return;
|
||||
}
|
||||
const backend = currentBackend(state.config);
|
||||
@ -319,8 +332,8 @@ export function traverseCollectionCursor(collection, action) {
|
||||
|
||||
// Handle cursors representing pages in the old, integer-based
|
||||
// pagination API
|
||||
if (cursor.meta.get("usingOldPaginationAPI", false)) {
|
||||
return dispatch(loadEntries(collection, cursor.data.get("nextPage")));
|
||||
if (cursor.meta.get('usingOldPaginationAPI', false)) {
|
||||
return dispatch(loadEntries(collection, cursor.data.get('nextPage')));
|
||||
}
|
||||
|
||||
try {
|
||||
@ -328,23 +341,27 @@ export function traverseCollectionCursor(collection, action) {
|
||||
const { entries, cursor: newCursor } = await traverseCursor(backend, cursor, realAction);
|
||||
// Pass null for the old pagination argument - this will
|
||||
// eventually be removed.
|
||||
return dispatch(entriesLoaded(collection, entries, null, addAppendActionsToCursor(newCursor), append));
|
||||
return dispatch(
|
||||
entriesLoaded(collection, entries, null, addAppendActionsToCursor(newCursor), append),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
dispatch(notifSend({
|
||||
message: `Failed to persist entry: ${ err }`,
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}));
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: `Failed to persist entry: ${err}`,
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}),
|
||||
);
|
||||
return Promise.reject(dispatch(entriesFailed(collection, err)));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmptyDraft(collection) {
|
||||
return (dispatch) => {
|
||||
return dispatch => {
|
||||
const dataFields = {};
|
||||
collection.get('fields', List()).forEach((field) => {
|
||||
collection.get('fields', List()).forEach(field => {
|
||||
dataFields[field.get('name')] = field.get('default');
|
||||
});
|
||||
const newEntry = createEntry(collection.get('name'), '', '', { data: dataFields });
|
||||
@ -360,15 +377,18 @@ export function persistEntry(collection) {
|
||||
|
||||
// Early return if draft contains validation errors
|
||||
if (!fieldsErrors.isEmpty()) {
|
||||
const hasPresenceErrors = fieldsErrors
|
||||
.some(errors => errors.some(error => error.type && error.type === ValidationErrorTypes.PRESENCE));
|
||||
const hasPresenceErrors = fieldsErrors.some(errors =>
|
||||
errors.some(error => error.type && error.type === ValidationErrorTypes.PRESENCE),
|
||||
);
|
||||
|
||||
if (hasPresenceErrors) {
|
||||
dispatch(notifSend({
|
||||
message: 'Oops, you\'ve missed a required field. Please complete before saving.',
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}));
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: "Oops, you've missed a required field. Please complete before saving.",
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.reject();
|
||||
@ -390,20 +410,24 @@ export function persistEntry(collection) {
|
||||
return backend
|
||||
.persistEntry(state.config, collection, serializedEntryDraft, assetProxies.toJS())
|
||||
.then(slug => {
|
||||
dispatch(notifSend({
|
||||
message: 'Entry saved',
|
||||
kind: 'success',
|
||||
dismissAfter: 4000,
|
||||
}));
|
||||
dispatch(entryPersisted(collection, serializedEntry, slug))
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: 'Entry saved',
|
||||
kind: 'success',
|
||||
dismissAfter: 4000,
|
||||
}),
|
||||
);
|
||||
dispatch(entryPersisted(collection, serializedEntry, slug));
|
||||
})
|
||||
.catch((error) => {
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
dispatch(notifSend({
|
||||
message: `Failed to persist entry: ${ error }`,
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}));
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: `Failed to persist entry: ${error}`,
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}),
|
||||
);
|
||||
return Promise.reject(dispatch(entryPersistFail(collection, serializedEntry, error)));
|
||||
});
|
||||
};
|
||||
@ -415,18 +439,21 @@ export function deleteEntry(collection, slug) {
|
||||
const backend = currentBackend(state.config);
|
||||
|
||||
dispatch(entryDeleting(collection, slug));
|
||||
return backend.deleteEntry(state.config, collection, slug)
|
||||
.then(() => {
|
||||
return dispatch(entryDeleted(collection, slug));
|
||||
})
|
||||
.catch((error) => {
|
||||
dispatch(notifSend({
|
||||
message: `Failed to delete entry: ${ error }`,
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}));
|
||||
console.error(error);
|
||||
return Promise.reject(dispatch(entryDeleteFail(collection, slug, error)));
|
||||
});
|
||||
return backend
|
||||
.deleteEntry(state.config, collection, slug)
|
||||
.then(() => {
|
||||
return dispatch(entryDeleted(collection, slug));
|
||||
})
|
||||
.catch(error => {
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: `Failed to delete entry: ${error}`,
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}),
|
||||
);
|
||||
console.error(error);
|
||||
return Promise.reject(dispatch(entryDeleteFail(collection, slug, error)));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ import { createAssetProxy } from 'ValueObjects/AssetProxy';
|
||||
import { selectIntegration } from 'Reducers';
|
||||
import { getIntegrationProvider } from 'Integrations';
|
||||
import { addAsset } from './media';
|
||||
import { sanitizeSlug } from "Lib/urlHelper";
|
||||
import { sanitizeSlug } from 'Lib/urlHelper';
|
||||
|
||||
const { notifSend } = notifActions;
|
||||
|
||||
@ -57,18 +57,20 @@ export function loadMedia(opts = {}) {
|
||||
privateUpload,
|
||||
};
|
||||
return dispatch(mediaLoaded(files, mediaLoadedOpts));
|
||||
}
|
||||
catch(error) {
|
||||
} catch (error) {
|
||||
return dispatch(mediaLoadFailed({ privateUpload }));
|
||||
}
|
||||
}
|
||||
dispatch(mediaLoading(page));
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => resolve(
|
||||
backend.getMedia()
|
||||
.then(files => dispatch(mediaLoaded(files)))
|
||||
.catch((error) => dispatch(error.status === 404 ? mediaLoaded() : mediaLoadFailed()))
|
||||
));
|
||||
setTimeout(() =>
|
||||
resolve(
|
||||
backend
|
||||
.getMedia()
|
||||
.then(files => dispatch(mediaLoaded(files)))
|
||||
.catch(error => dispatch(error.status === 404 ? mediaLoaded() : mediaLoadFailed())),
|
||||
),
|
||||
);
|
||||
}, delay);
|
||||
};
|
||||
}
|
||||
@ -107,14 +109,15 @@ export function persistMedia(file, opts = {}) {
|
||||
return dispatch(mediaPersisted(asset));
|
||||
}
|
||||
return dispatch(mediaPersisted(assetProxy.asset, { privateUpload }));
|
||||
}
|
||||
catch(error) {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
dispatch(notifSend({
|
||||
message: `Failed to persist media: ${ error }`,
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}));
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: `Failed to persist media: ${error}`,
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}),
|
||||
);
|
||||
return dispatch(mediaPersistFailed({ privateUpload }));
|
||||
}
|
||||
};
|
||||
@ -129,32 +132,38 @@ export function deleteMedia(file, opts = {}) {
|
||||
if (integration) {
|
||||
const provider = getIntegrationProvider(state.integrations, backend.getToken, integration);
|
||||
dispatch(mediaDeleting());
|
||||
return provider.delete(file.id)
|
||||
return provider
|
||||
.delete(file.id)
|
||||
.then(() => {
|
||||
return dispatch(mediaDeleted(file, { privateUpload }));
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
dispatch(notifSend({
|
||||
message: `Failed to delete media: ${ error.message }`,
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}));
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: `Failed to delete media: ${error.message}`,
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}),
|
||||
);
|
||||
return dispatch(mediaDeleteFailed({ privateUpload }));
|
||||
});
|
||||
}
|
||||
dispatch(mediaDeleting());
|
||||
return backend.deleteMedia(state.config, file.path)
|
||||
return backend
|
||||
.deleteMedia(state.config, file.path)
|
||||
.then(() => {
|
||||
return dispatch(mediaDeleted(file));
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
dispatch(notifSend({
|
||||
message: `Failed to delete media: ${ error.message }`,
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}));
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: `Failed to delete media: ${error.message}`,
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}),
|
||||
);
|
||||
return dispatch(mediaDeleteFailed());
|
||||
});
|
||||
};
|
||||
@ -164,13 +173,13 @@ export function mediaLoading(page) {
|
||||
return {
|
||||
type: MEDIA_LOAD_REQUEST,
|
||||
payload: { page },
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function mediaLoaded(files, opts = {}) {
|
||||
return {
|
||||
type: MEDIA_LOAD_SUCCESS,
|
||||
payload: { files, ...opts }
|
||||
payload: { files, ...opts },
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -93,7 +93,6 @@ export function clearSearch() {
|
||||
return { type: SEARCH_CLEAR };
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Exported Thunk Action Creators
|
||||
*/
|
||||
@ -106,16 +105,22 @@ export function searchEntries(searchTerm, page = 0) {
|
||||
const state = getState();
|
||||
const backend = currentBackend(state.config);
|
||||
const allCollections = state.collections.keySeq().toArray();
|
||||
const collections = allCollections.filter(collection => selectIntegration(state, collection, 'search'));
|
||||
const collections = allCollections.filter(collection =>
|
||||
selectIntegration(state, collection, 'search'),
|
||||
);
|
||||
const integration = selectIntegration(state, collections[0], 'search');
|
||||
|
||||
const searchPromise = integration
|
||||
? getIntegrationProvider(state.integrations, backend.getToken, integration).search(collections, searchTerm, page)
|
||||
? getIntegrationProvider(state.integrations, backend.getToken, integration).search(
|
||||
collections,
|
||||
searchTerm,
|
||||
page,
|
||||
)
|
||||
: backend.search(state.collections.valueSeq().toArray(), searchTerm);
|
||||
|
||||
return searchPromise.then(
|
||||
response => dispatch(searchSuccess(searchTerm, response.entries, response.pagination)),
|
||||
error => dispatch(searchFailure(searchTerm, error))
|
||||
error => dispatch(searchFailure(searchTerm, error)),
|
||||
);
|
||||
};
|
||||
}
|
||||
@ -129,16 +134,22 @@ export function query(namespace, collectionName, searchFields, searchTerm) {
|
||||
const state = getState();
|
||||
const backend = currentBackend(state.config);
|
||||
const integration = selectIntegration(state, collectionName, 'search');
|
||||
const collection = state.collections.find(collection => collection.get('name') === collectionName);
|
||||
const collection = state.collections.find(
|
||||
collection => collection.get('name') === collectionName,
|
||||
);
|
||||
|
||||
const queryPromise = integration
|
||||
? getIntegrationProvider(state.integrations, backend.getToken, integration)
|
||||
.searchBy(searchFields.map(f => `data.${ f }`), collectionName, searchTerm)
|
||||
? getIntegrationProvider(state.integrations, backend.getToken, integration).searchBy(
|
||||
searchFields.map(f => `data.${f}`),
|
||||
collectionName,
|
||||
searchTerm,
|
||||
)
|
||||
: backend.query(collection, searchFields, searchTerm);
|
||||
|
||||
return queryPromise.then(
|
||||
response => dispatch(querySuccess(namespace, collectionName, searchFields, searchTerm, response)),
|
||||
error => dispatch(queryFailure(namespace, collectionName, searchFields, searchTerm, error))
|
||||
response =>
|
||||
dispatch(querySuccess(namespace, collectionName, searchFields, searchTerm, response)),
|
||||
error => dispatch(queryFailure(namespace, collectionName, searchFields, searchTerm, error)),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { attempt, flatten, isError } from 'lodash';
|
||||
import { Map } from 'immutable';
|
||||
import fuzzy from 'fuzzy';
|
||||
import { resolveFormat } from "Formats/formats";
|
||||
import { resolveFormat } from 'Formats/formats';
|
||||
import { selectIntegration } from 'Reducers/integrations';
|
||||
import {
|
||||
selectListMethod,
|
||||
@ -12,15 +12,15 @@ import {
|
||||
selectFolderEntryExtension,
|
||||
selectIdentifier,
|
||||
selectInferedField,
|
||||
} from "Reducers/collections";
|
||||
import { createEntry } from "ValueObjects/Entry";
|
||||
import { sanitizeSlug } from "Lib/urlHelper";
|
||||
} from 'Reducers/collections';
|
||||
import { createEntry } from 'ValueObjects/Entry';
|
||||
import { sanitizeSlug } from 'Lib/urlHelper';
|
||||
import { getBackend } from 'Lib/registry';
|
||||
import { Cursor, CURSOR_COMPATIBILITY_SYMBOL } from 'netlify-cms-lib-util';
|
||||
import { EDITORIAL_WORKFLOW, status } from 'Constants/publishModes';
|
||||
|
||||
class LocalStorageAuthStore {
|
||||
storageKey = "netlify-cms-user";
|
||||
storageKey = 'netlify-cms-user';
|
||||
|
||||
retrieve() {
|
||||
const data = window.localStorage.getItem(this.storageKey);
|
||||
@ -37,39 +37,40 @@ class LocalStorageAuthStore {
|
||||
}
|
||||
|
||||
const slugFormatter = (collection, entryData, slugConfig) => {
|
||||
const template = collection.get('slug') || "{{slug}}";
|
||||
const template = collection.get('slug') || '{{slug}}';
|
||||
const date = new Date();
|
||||
|
||||
const identifier = entryData.get(selectIdentifier(collection));
|
||||
if (!identifier) {
|
||||
throw new Error("Collection must have a field name that is a valid entry identifier");
|
||||
throw new Error('Collection must have a field name that is a valid entry identifier');
|
||||
}
|
||||
|
||||
const slug = template.replace(/\{\{([^}]+)\}\}/g, (_, field) => {
|
||||
switch (field) {
|
||||
case "year":
|
||||
return date.getFullYear();
|
||||
case "month":
|
||||
return (`0${ date.getMonth() + 1 }`).slice(-2);
|
||||
case "day":
|
||||
return (`0${ date.getDate() }`).slice(-2);
|
||||
case "hour":
|
||||
return (`0${ date.getHours() }`).slice(-2);
|
||||
case "minute":
|
||||
return (`0${ date.getMinutes() }`).slice(-2);
|
||||
case "second":
|
||||
return (`0${ date.getSeconds() }`).slice(-2);
|
||||
case "slug":
|
||||
return identifier.trim();
|
||||
default:
|
||||
return entryData.get(field, "").trim();
|
||||
}
|
||||
})
|
||||
// Convert slug to lower-case
|
||||
.toLocaleLowerCase()
|
||||
const slug = template
|
||||
.replace(/\{\{([^}]+)\}\}/g, (_, field) => {
|
||||
switch (field) {
|
||||
case 'year':
|
||||
return date.getFullYear();
|
||||
case 'month':
|
||||
return `0${date.getMonth() + 1}`.slice(-2);
|
||||
case 'day':
|
||||
return `0${date.getDate()}`.slice(-2);
|
||||
case 'hour':
|
||||
return `0${date.getHours()}`.slice(-2);
|
||||
case 'minute':
|
||||
return `0${date.getMinutes()}`.slice(-2);
|
||||
case 'second':
|
||||
return `0${date.getSeconds()}`.slice(-2);
|
||||
case 'slug':
|
||||
return identifier.trim();
|
||||
default:
|
||||
return entryData.get(field, '').trim();
|
||||
}
|
||||
})
|
||||
// Convert slug to lower-case
|
||||
.toLocaleLowerCase()
|
||||
|
||||
// Replace periods with dashes.
|
||||
.replace(/[.]/g, '-');
|
||||
// Replace periods with dashes.
|
||||
.replace(/[.]/g, '-');
|
||||
|
||||
return sanitizeSlug(slug, slugConfig);
|
||||
};
|
||||
@ -79,11 +80,13 @@ const commitMessageTemplates = Map({
|
||||
update: 'Update {{collection}} “{{slug}}”',
|
||||
delete: 'Delete {{collection}} “{{slug}}”',
|
||||
uploadMedia: 'Upload “{{path}}”',
|
||||
deleteMedia: 'Delete “{{path}}”'
|
||||
deleteMedia: 'Delete “{{path}}”',
|
||||
});
|
||||
|
||||
const commitMessageFormatter = (type, config, { slug, path, collection }) => {
|
||||
const templates = commitMessageTemplates.merge(config.getIn(['backend', 'commit_messages'], Map()));
|
||||
const templates = commitMessageTemplates.merge(
|
||||
config.getIn(['backend', 'commit_messages'], Map()),
|
||||
);
|
||||
const messageTemplate = templates.get(type);
|
||||
return messageTemplate.replace(/\{\{([^}]+)\}\}/g, (_, variable) => {
|
||||
switch (variable) {
|
||||
@ -94,16 +97,17 @@ const commitMessageFormatter = (type, config, { slug, path, collection }) => {
|
||||
case 'collection':
|
||||
return collection.get('label');
|
||||
default:
|
||||
console.warn(`Ignoring unknown variable “${ variable }” in commit message template.`);
|
||||
console.warn(`Ignoring unknown variable “${variable}” in commit message template.`);
|
||||
return '';
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const extractSearchFields = searchFields => entry => searchFields.reduce((acc, field) => {
|
||||
const f = entry.data[field];
|
||||
return f ? `${acc} ${f}` : acc;
|
||||
}, "");
|
||||
const extractSearchFields = searchFields => entry =>
|
||||
searchFields.reduce((acc, field) => {
|
||||
const f = entry.data[field];
|
||||
return f ? `${acc} ${f}` : acc;
|
||||
}, '');
|
||||
|
||||
const sortByScore = (a, b) => {
|
||||
if (a.score > b.score) return -1;
|
||||
@ -114,23 +118,25 @@ const sortByScore = (a, b) => {
|
||||
class Backend {
|
||||
constructor(implementation, { backendName, authStore = null, config } = {}) {
|
||||
this.implementation = implementation.init(config, {
|
||||
useWorkflow: config.getIn(["publish_mode"]) === EDITORIAL_WORKFLOW,
|
||||
useWorkflow: config.getIn(['publish_mode']) === EDITORIAL_WORKFLOW,
|
||||
updateUserCredentials: this.updateUserCredentials,
|
||||
initialWorkflowStatus: status.first(),
|
||||
});
|
||||
this.backendName = backendName;
|
||||
this.authStore = authStore;
|
||||
if (this.implementation === null) {
|
||||
throw new Error("Cannot instantiate a Backend with no implementation");
|
||||
throw new Error('Cannot instantiate a Backend with no implementation');
|
||||
}
|
||||
}
|
||||
|
||||
currentUser() {
|
||||
if (this.user) { return this.user; }
|
||||
if (this.user) {
|
||||
return this.user;
|
||||
}
|
||||
const stored = this.authStore && this.authStore.retrieve();
|
||||
if (stored && stored.backendName === this.backendName) {
|
||||
return Promise.resolve(this.implementation.restoreUser(stored)).then((user) => {
|
||||
const newUser = {...user, backendName: this.backendName};
|
||||
return Promise.resolve(this.implementation.restoreUser(stored)).then(user => {
|
||||
const newUser = { ...user, backendName: this.backendName };
|
||||
// return confirmed/rehydrated user object instead of stored
|
||||
this.authStore.store(newUser);
|
||||
return newUser;
|
||||
@ -153,9 +159,11 @@ class Backend {
|
||||
}
|
||||
|
||||
authenticate(credentials) {
|
||||
return this.implementation.authenticate(credentials).then((user) => {
|
||||
const newUser = {...user, backendName: this.backendName};
|
||||
if (this.authStore) { this.authStore.store(newUser); }
|
||||
return this.implementation.authenticate(credentials).then(user => {
|
||||
const newUser = { ...user, backendName: this.backendName };
|
||||
if (this.authStore) {
|
||||
this.authStore.store(newUser);
|
||||
}
|
||||
return newUser;
|
||||
});
|
||||
}
|
||||
@ -172,12 +180,14 @@ class Backend {
|
||||
|
||||
processEntries(loadedEntries, collection) {
|
||||
const collectionFilter = collection.get('filter');
|
||||
const entries = loadedEntries.map(loadedEntry => createEntry(
|
||||
collection.get("name"),
|
||||
selectEntrySlug(collection, loadedEntry.file.path),
|
||||
loadedEntry.file.path,
|
||||
{ raw: loadedEntry.data || '', label: loadedEntry.file.label }
|
||||
));
|
||||
const entries = loadedEntries.map(loadedEntry =>
|
||||
createEntry(
|
||||
collection.get('name'),
|
||||
selectEntrySlug(collection, loadedEntry.file.path),
|
||||
loadedEntry.file.path,
|
||||
{ raw: loadedEntry.data || '', label: loadedEntry.file.label },
|
||||
),
|
||||
);
|
||||
const formattedEntries = entries.map(this.entryWithFormat(collection));
|
||||
// If this collection has a "filter" property, filter entries accordingly
|
||||
const filteredEntries = collectionFilter
|
||||
@ -186,23 +196,21 @@ class Backend {
|
||||
return filteredEntries;
|
||||
}
|
||||
|
||||
|
||||
listEntries(collection) {
|
||||
const listMethod = this.implementation[selectListMethod(collection)];
|
||||
const extension = selectFolderEntryExtension(collection);
|
||||
return listMethod.call(this.implementation, collection, extension)
|
||||
.then(loadedEntries => ({
|
||||
entries: this.processEntries(loadedEntries, collection),
|
||||
/*
|
||||
return listMethod.call(this.implementation, collection, extension).then(loadedEntries => ({
|
||||
entries: this.processEntries(loadedEntries, collection),
|
||||
/*
|
||||
Wrap cursors so we can tell which collection the cursor is
|
||||
from. This is done to prevent traverseCursor from requiring a
|
||||
`collection` argument.
|
||||
*/
|
||||
cursor: Cursor.create(loadedEntries[CURSOR_COMPATIBILITY_SYMBOL]).wrapData({
|
||||
cursorType: "collectionEntries",
|
||||
collection,
|
||||
}),
|
||||
}));
|
||||
cursor: Cursor.create(loadedEntries[CURSOR_COMPATIBILITY_SYMBOL]).wrapData({
|
||||
cursorType: 'collectionEntries',
|
||||
collection,
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
// The same as listEntries, except that if a cursor with the "next"
|
||||
@ -211,17 +219,18 @@ class Backend {
|
||||
// returns all the collected entries. Used to retrieve all entries
|
||||
// for local searches and queries.
|
||||
async listAllEntries(collection) {
|
||||
if (collection.get("folder") && this.implementation.allEntriesByFolder) {
|
||||
if (collection.get('folder') && this.implementation.allEntriesByFolder) {
|
||||
const extension = selectFolderEntryExtension(collection);
|
||||
return this.implementation.allEntriesByFolder(collection, extension)
|
||||
.then(entries => this.processEntries(entries, collection));
|
||||
return this.implementation
|
||||
.allEntriesByFolder(collection, extension)
|
||||
.then(entries => this.processEntries(entries, collection));
|
||||
}
|
||||
|
||||
const response = await this.listEntries(collection);
|
||||
const { entries } = response;
|
||||
let { cursor } = response;
|
||||
while (cursor && cursor.actions.includes("next")) {
|
||||
const { entries: newEntries, cursor: newCursor } = await this.traverseCursor(cursor, "next");
|
||||
while (cursor && cursor.actions.includes('next')) {
|
||||
const { entries: newEntries, cursor: newCursor } = await this.traverseCursor(cursor, 'next');
|
||||
entries.push(...newEntries);
|
||||
cursor = newCursor;
|
||||
}
|
||||
@ -233,31 +242,37 @@ class Backend {
|
||||
// collection, load it, search, and call onCollectionResults with
|
||||
// its results.
|
||||
const errors = [];
|
||||
const collectionEntriesRequests = collections.map(async collection => {
|
||||
// TODO: pass search fields in as an argument
|
||||
const searchFields = [
|
||||
selectInferedField(collection, 'title'),
|
||||
selectInferedField(collection, 'shortTitle'),
|
||||
selectInferedField(collection, 'author'),
|
||||
];
|
||||
const collectionEntries = await this.listAllEntries(collection);
|
||||
return fuzzy.filter(searchTerm, collectionEntries, {
|
||||
extract: extractSearchFields(searchFields),
|
||||
});
|
||||
}).map(p => p.catch(err => errors.push(err) && []));
|
||||
const collectionEntriesRequests = collections
|
||||
.map(async collection => {
|
||||
// TODO: pass search fields in as an argument
|
||||
const searchFields = [
|
||||
selectInferedField(collection, 'title'),
|
||||
selectInferedField(collection, 'shortTitle'),
|
||||
selectInferedField(collection, 'author'),
|
||||
];
|
||||
const collectionEntries = await this.listAllEntries(collection);
|
||||
return fuzzy.filter(searchTerm, collectionEntries, {
|
||||
extract: extractSearchFields(searchFields),
|
||||
});
|
||||
})
|
||||
.map(p => p.catch(err => errors.push(err) && []));
|
||||
|
||||
const entries = await Promise.all(collectionEntriesRequests).then(arrs => flatten(arrs));
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error({ message: "Errors ocurred while searching entries locally!", errors });
|
||||
throw new Error({ message: 'Errors ocurred while searching entries locally!', errors });
|
||||
}
|
||||
const hits = entries.filter(({ score }) => score > 5).sort(sortByScore).map(f => f.original);
|
||||
const hits = entries
|
||||
.filter(({ score }) => score > 5)
|
||||
.sort(sortByScore)
|
||||
.map(f => f.original);
|
||||
return { entries: hits };
|
||||
}
|
||||
|
||||
async query(collection, searchFields, searchTerm) {
|
||||
const entries = await this.listAllEntries(collection);
|
||||
const hits = fuzzy.filter(searchTerm, entries, { extract: extractSearchFields(searchFields) })
|
||||
const hits = fuzzy
|
||||
.filter(searchTerm, entries, { extract: extractSearchFields(searchFields) })
|
||||
.filter(entry => entry.score > 5)
|
||||
.sort(sortByScore)
|
||||
.map(f => f.original);
|
||||
@ -267,26 +282,29 @@ class Backend {
|
||||
traverseCursor(cursor, action) {
|
||||
const [data, unwrappedCursor] = cursor.unwrapData();
|
||||
// TODO: stop assuming all cursors are for collections
|
||||
const collection = data.get("collection");
|
||||
return this.implementation.traverseCursor(unwrappedCursor, action)
|
||||
const collection = data.get('collection');
|
||||
return this.implementation
|
||||
.traverseCursor(unwrappedCursor, action)
|
||||
.then(async ({ entries, cursor: newCursor }) => ({
|
||||
entries: this.processEntries(entries, collection),
|
||||
cursor: Cursor.create(newCursor).wrapData({
|
||||
cursorType: "collectionEntries",
|
||||
cursorType: 'collectionEntries',
|
||||
collection,
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
getEntry(collection, slug) {
|
||||
return this.implementation.getEntry(collection, slug, selectEntryPath(collection, slug))
|
||||
.then(loadedEntry => this.entryWithFormat(collection, slug)(createEntry(
|
||||
collection.get("name"),
|
||||
slug,
|
||||
loadedEntry.file.path,
|
||||
{ raw: loadedEntry.data, label: loadedEntry.file.label }
|
||||
))
|
||||
);
|
||||
return this.implementation
|
||||
.getEntry(collection, slug, selectEntryPath(collection, slug))
|
||||
.then(loadedEntry =>
|
||||
this.entryWithFormat(collection, slug)(
|
||||
createEntry(collection.get('name'), slug, loadedEntry.file.path, {
|
||||
raw: loadedEntry.data,
|
||||
label: loadedEntry.file.label,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
getMedia() {
|
||||
@ -294,7 +312,7 @@ class Backend {
|
||||
}
|
||||
|
||||
entryWithFormat(collectionOrEntity) {
|
||||
return (entry) => {
|
||||
return entry => {
|
||||
const format = resolveFormat(collectionOrEntity, entry);
|
||||
if (entry && entry.raw !== undefined) {
|
||||
const data = (format && attempt(format.fromFile.bind(format, entry.raw))) || {};
|
||||
@ -306,87 +324,93 @@ class Backend {
|
||||
}
|
||||
|
||||
unpublishedEntries(collections) {
|
||||
return this.implementation.unpublishedEntries()
|
||||
.then(loadedEntries => loadedEntries.filter(entry => entry !== null))
|
||||
.then(entries => (
|
||||
entries.map((loadedEntry) => {
|
||||
const entry = createEntry(
|
||||
loadedEntry.metaData.collection,
|
||||
loadedEntry.slug,
|
||||
loadedEntry.file.path,
|
||||
{
|
||||
raw: loadedEntry.data,
|
||||
isModification: loadedEntry.isModification,
|
||||
return this.implementation
|
||||
.unpublishedEntries()
|
||||
.then(loadedEntries => loadedEntries.filter(entry => entry !== null))
|
||||
.then(entries =>
|
||||
entries.map(loadedEntry => {
|
||||
const entry = createEntry(
|
||||
loadedEntry.metaData.collection,
|
||||
loadedEntry.slug,
|
||||
loadedEntry.file.path,
|
||||
{
|
||||
raw: loadedEntry.data,
|
||||
isModification: loadedEntry.isModification,
|
||||
},
|
||||
);
|
||||
entry.metaData = loadedEntry.metaData;
|
||||
return entry;
|
||||
}),
|
||||
)
|
||||
.then(entries => ({
|
||||
pagination: 0,
|
||||
entries: entries.reduce((acc, entry) => {
|
||||
const collection = collections.get(entry.collection);
|
||||
if (collection) {
|
||||
acc.push(this.entryWithFormat(collection)(entry));
|
||||
}
|
||||
);
|
||||
entry.metaData = loadedEntry.metaData;
|
||||
return entry;
|
||||
})
|
||||
))
|
||||
.then(entries => ({
|
||||
pagination: 0,
|
||||
entries: entries.reduce((acc, entry) => {
|
||||
const collection = collections.get(entry.collection);
|
||||
if (collection) {
|
||||
acc.push(this.entryWithFormat(collection)(entry));
|
||||
}
|
||||
return acc;
|
||||
}, []),
|
||||
}));
|
||||
return acc;
|
||||
}, []),
|
||||
}));
|
||||
}
|
||||
|
||||
unpublishedEntry(collection, slug) {
|
||||
return this.implementation.unpublishedEntry(collection, slug)
|
||||
.then((loadedEntry) => {
|
||||
const entry = createEntry(
|
||||
"draft",
|
||||
loadedEntry.slug,
|
||||
loadedEntry.file.path,
|
||||
{
|
||||
return this.implementation
|
||||
.unpublishedEntry(collection, slug)
|
||||
.then(loadedEntry => {
|
||||
const entry = createEntry('draft', loadedEntry.slug, loadedEntry.file.path, {
|
||||
raw: loadedEntry.data,
|
||||
isModification: loadedEntry.isModification,
|
||||
});
|
||||
entry.metaData = loadedEntry.metaData;
|
||||
return entry;
|
||||
})
|
||||
.then(this.entryWithFormat(collection, slug));
|
||||
entry.metaData = loadedEntry.metaData;
|
||||
return entry;
|
||||
})
|
||||
.then(this.entryWithFormat(collection, slug));
|
||||
}
|
||||
|
||||
persistEntry(config, collection, entryDraft, MediaFiles, integrations, options = {}) {
|
||||
const newEntry = entryDraft.getIn(["entry", "newRecord"]) || false;
|
||||
const newEntry = entryDraft.getIn(['entry', 'newRecord']) || false;
|
||||
|
||||
const parsedData = {
|
||||
title: entryDraft.getIn(["entry", "data", "title"], "No Title"),
|
||||
description: entryDraft.getIn(["entry", "data", "description"], "No Description!"),
|
||||
title: entryDraft.getIn(['entry', 'data', 'title'], 'No Title'),
|
||||
description: entryDraft.getIn(['entry', 'data', 'description'], 'No Description!'),
|
||||
};
|
||||
|
||||
let entryObj;
|
||||
if (newEntry) {
|
||||
if (!selectAllowNewEntries(collection)) {
|
||||
throw (new Error("Not allowed to create new entries in this collection"));
|
||||
throw new Error('Not allowed to create new entries in this collection');
|
||||
}
|
||||
const slug = slugFormatter(collection, entryDraft.getIn(["entry", "data"]), config.get("slug"));
|
||||
const slug = slugFormatter(
|
||||
collection,
|
||||
entryDraft.getIn(['entry', 'data']),
|
||||
config.get('slug'),
|
||||
);
|
||||
const path = selectEntryPath(collection, slug);
|
||||
entryObj = {
|
||||
path,
|
||||
slug,
|
||||
raw: this.entryToRaw(collection, entryDraft.get("entry")),
|
||||
raw: this.entryToRaw(collection, entryDraft.get('entry')),
|
||||
};
|
||||
} else {
|
||||
const path = entryDraft.getIn(["entry", "path"]);
|
||||
const slug = entryDraft.getIn(["entry", "slug"]);
|
||||
const path = entryDraft.getIn(['entry', 'path']);
|
||||
const slug = entryDraft.getIn(['entry', 'slug']);
|
||||
entryObj = {
|
||||
path,
|
||||
slug,
|
||||
raw: this.entryToRaw(collection, entryDraft.get("entry")),
|
||||
raw: this.entryToRaw(collection, entryDraft.get('entry')),
|
||||
};
|
||||
}
|
||||
|
||||
const commitMessage = commitMessageFormatter(newEntry ? 'create' : 'update', config, { collection, slug: entryObj.slug, path: entryObj.path });
|
||||
const commitMessage = commitMessageFormatter(newEntry ? 'create' : 'update', config, {
|
||||
collection,
|
||||
slug: entryObj.slug,
|
||||
path: entryObj.path,
|
||||
});
|
||||
|
||||
const useWorkflow = config.getIn(["publish_mode"]) === EDITORIAL_WORKFLOW;
|
||||
const useWorkflow = config.getIn(['publish_mode']) === EDITORIAL_WORKFLOW;
|
||||
|
||||
const collectionName = collection.get("name");
|
||||
const collectionName = collection.get('name');
|
||||
|
||||
/**
|
||||
* Determine whether an asset store integration is in use.
|
||||
@ -399,11 +423,10 @@ class Backend {
|
||||
commitMessage,
|
||||
collectionName,
|
||||
useWorkflow,
|
||||
...updatedOptions
|
||||
...updatedOptions,
|
||||
};
|
||||
|
||||
return this.implementation.persistEntry(entryObj, MediaFiles, opts)
|
||||
.then(() => entryObj.slug);
|
||||
return this.implementation.persistEntry(entryObj, MediaFiles, opts).then(() => entryObj.slug);
|
||||
}
|
||||
|
||||
persistMedia(config, file) {
|
||||
@ -417,7 +440,7 @@ class Backend {
|
||||
const path = selectEntryPath(collection, slug);
|
||||
|
||||
if (!selectAllowDeletion(collection)) {
|
||||
throw (new Error("Not allowed to delete entries in this collection"));
|
||||
throw new Error('Not allowed to delete entries in this collection');
|
||||
}
|
||||
|
||||
const commitMessage = commitMessageFormatter('delete', config, { collection, slug, path });
|
||||
@ -448,52 +471,60 @@ class Backend {
|
||||
entryToRaw(collection, entry) {
|
||||
const format = resolveFormat(collection, entry.toJS());
|
||||
const fieldsOrder = this.fieldsOrder(collection, entry);
|
||||
return format && format.toFile(entry.get("data").toJS(), fieldsOrder);
|
||||
return format && format.toFile(entry.get('data').toJS(), fieldsOrder);
|
||||
}
|
||||
|
||||
fieldsOrder(collection, entry) {
|
||||
const fields = collection.get('fields');
|
||||
if (fields) {
|
||||
return collection.get('fields').map(f => f.get('name')).toArray();
|
||||
return collection
|
||||
.get('fields')
|
||||
.map(f => f.get('name'))
|
||||
.toArray();
|
||||
}
|
||||
|
||||
const files = collection.get('files');
|
||||
const file = (files || []).filter(f => f.get("name") === entry.get("slug")).get(0);
|
||||
const file = (files || []).filter(f => f.get('name') === entry.get('slug')).get(0);
|
||||
if (file == null) {
|
||||
throw new Error(`No file found for ${ entry.get("slug") } in ${ collection.get('name') }`);
|
||||
throw new Error(`No file found for ${entry.get('slug')} in ${collection.get('name')}`);
|
||||
}
|
||||
return file.get('fields').map(f => f.get('name')).toArray();
|
||||
return file
|
||||
.get('fields')
|
||||
.map(f => f.get('name'))
|
||||
.toArray();
|
||||
}
|
||||
|
||||
filterEntries(collection, filterRule) {
|
||||
return collection.entries.filter(entry => (
|
||||
entry.data[filterRule.get('field')] === filterRule.get('value')
|
||||
));
|
||||
return collection.entries.filter(
|
||||
entry => entry.data[filterRule.get('field')] === filterRule.get('value'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveBackend(config) {
|
||||
const name = config.getIn(["backend", "name"]);
|
||||
const name = config.getIn(['backend', 'name']);
|
||||
if (name == null) {
|
||||
throw new Error("No backend defined in configuration");
|
||||
throw new Error('No backend defined in configuration');
|
||||
}
|
||||
|
||||
const authStore = new LocalStorageAuthStore();
|
||||
|
||||
if (!getBackend(name)) {
|
||||
throw new Error(`Backend not found: ${ name }`);
|
||||
throw new Error(`Backend not found: ${name}`);
|
||||
} else {
|
||||
return new Backend(getBackend(name), { backendName: name, authStore, config });
|
||||
}
|
||||
}
|
||||
|
||||
export const currentBackend = (function () {
|
||||
export const currentBackend = (function() {
|
||||
let backend = null;
|
||||
|
||||
return (config) => {
|
||||
if (backend) { return backend; }
|
||||
if (config.get("backend")) {
|
||||
return backend = resolveBackend(config);
|
||||
return config => {
|
||||
if (backend) {
|
||||
return backend;
|
||||
}
|
||||
if (config.get('backend')) {
|
||||
return (backend = resolveBackend(config));
|
||||
}
|
||||
};
|
||||
}());
|
||||
})();
|
||||
|
4
packages/netlify-cms-core/src/bootstrap.js
vendored
4
packages/netlify-cms-core/src/bootstrap.js
vendored
@ -7,7 +7,7 @@ import history from 'Routing/history';
|
||||
import configureStore from 'Redux/configureStore';
|
||||
import { mergeConfig } from 'Actions/config';
|
||||
import { setStore } from 'ValueObjects/AssetProxy';
|
||||
import { ErrorBoundary } from 'UI'
|
||||
import { ErrorBoundary } from 'UI';
|
||||
import App from 'App/App';
|
||||
import 'EditorWidgets';
|
||||
import 'what-input';
|
||||
@ -73,7 +73,7 @@ function bootstrap(opts = {}) {
|
||||
<ErrorBoundary>
|
||||
<Provider store={store}>
|
||||
<ConnectedRouter history={history}>
|
||||
<Route component={App}/>
|
||||
<Route component={App} />
|
||||
</ConnectedRouter>
|
||||
</Provider>
|
||||
</ErrorBoundary>
|
||||
|
@ -36,20 +36,19 @@ const AppMainContainer = styled.div`
|
||||
min-width: 800px;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
`
|
||||
`;
|
||||
|
||||
const ErrorContainer = styled.div`
|
||||
margin: 20px;
|
||||
`
|
||||
`;
|
||||
|
||||
const ErrorCodeBlock = styled.pre`
|
||||
margin-left: 20px;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
`
|
||||
`;
|
||||
|
||||
class App extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
auth: ImmutablePropTypes.map,
|
||||
config: ImmutablePropTypes.map,
|
||||
@ -89,24 +88,26 @@ class App extends React.Component {
|
||||
const backend = currentBackend(this.props.config);
|
||||
|
||||
if (backend == null) {
|
||||
return <div><h1>Waiting for backend...</h1></div>;
|
||||
return (
|
||||
<div>
|
||||
<h1>Waiting for backend...</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Notifs CustomComponent={Toast} />
|
||||
{
|
||||
React.createElement(backend.authComponent(), {
|
||||
onLogin: this.handleLogin.bind(this),
|
||||
error: auth && auth.get('error'),
|
||||
isFetching: auth && auth.get('isFetching'),
|
||||
siteId: this.props.config.getIn(["backend", "site_domain"]),
|
||||
base_url: this.props.config.getIn(["backend", "base_url"], null),
|
||||
authEndpoint: this.props.config.getIn(["backend", "auth_endpoint"]),
|
||||
config: this.props.config,
|
||||
clearHash: () => history.replace('/'),
|
||||
})
|
||||
}
|
||||
{React.createElement(backend.authComponent(), {
|
||||
onLogin: this.handleLogin.bind(this),
|
||||
error: auth && auth.get('error'),
|
||||
isFetching: auth && auth.get('isFetching'),
|
||||
siteId: this.props.config.getIn(['backend', 'site_domain']),
|
||||
base_url: this.props.config.getIn(['backend', 'base_url'], null),
|
||||
authEndpoint: this.props.config.getIn(['backend', 'auth_endpoint']),
|
||||
config: this.props.config,
|
||||
clearHash: () => history.replace('/'),
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -159,19 +160,25 @@ class App extends React.Component {
|
||||
displayUrl={config.get('display_url')}
|
||||
/>
|
||||
<AppMainContainer>
|
||||
{ isFetching && <TopBarProgress /> }
|
||||
{isFetching && <TopBarProgress />}
|
||||
<div>
|
||||
<Switch>
|
||||
<Redirect exact from="/" to={defaultPath} />
|
||||
<Redirect exact from="/search/" to={defaultPath} />
|
||||
{ hasWorkflow ? <Route path="/workflow" component={Workflow}/> : null }
|
||||
{hasWorkflow ? <Route path="/workflow" component={Workflow} /> : null}
|
||||
<Route exact path="/collections/:name" component={Collection} />
|
||||
<Route path="/collections/:name/new" render={props => <Editor {...props} newRecord />} />
|
||||
<Route
|
||||
path="/collections/:name/new"
|
||||
render={props => <Editor {...props} newRecord />}
|
||||
/>
|
||||
<Route path="/collections/:name/entries/:slug" component={Editor} />
|
||||
<Route path="/search/:searchTerm" render={props => <Collection {...props} isSearchResults />} />
|
||||
<Route
|
||||
path="/search/:searchTerm"
|
||||
render={props => <Collection {...props} isSearchResults />}
|
||||
/>
|
||||
<Route component={NotFoundPage} />
|
||||
</Switch>
|
||||
<MediaLibrary/>
|
||||
<MediaLibrary />
|
||||
</div>
|
||||
</AppMainContainer>
|
||||
</div>
|
||||
@ -200,5 +207,8 @@ function mapDispatchToProps(dispatch) {
|
||||
}
|
||||
|
||||
export default hot(module)(
|
||||
connect(mapStateToProps, mapDispatchToProps)(App)
|
||||
connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
)(App),
|
||||
);
|
||||
|
@ -1,6 +1,6 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from "react";
|
||||
import ImmutablePropTypes from "react-immutable-proptypes";
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import styled, { css } from 'react-emotion';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import {
|
||||
@ -33,7 +33,7 @@ const AppHeader = styled.div`
|
||||
background-color: ${colors.foreground};
|
||||
z-index: 300;
|
||||
height: ${lengths.topBarHeight};
|
||||
`
|
||||
`;
|
||||
|
||||
const AppHeaderContent = styled.div`
|
||||
display: flex;
|
||||
@ -69,15 +69,15 @@ const AppHeaderButton = styled.button`
|
||||
${styles.buttonActive};
|
||||
}
|
||||
}
|
||||
`}
|
||||
`
|
||||
`};
|
||||
`;
|
||||
|
||||
const AppHeaderNavLink = AppHeaderButton.withComponent(NavLink);
|
||||
|
||||
const AppHeaderActions = styled.div`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
`
|
||||
`;
|
||||
|
||||
const AppHeaderQuickNewButton = styled(StyledDropdownButton)`
|
||||
${buttons.button};
|
||||
@ -88,7 +88,7 @@ const AppHeaderQuickNewButton = styled(StyledDropdownButton)`
|
||||
&:after {
|
||||
top: 11px;
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
export default class Header extends React.Component {
|
||||
static propTypes = {
|
||||
@ -99,7 +99,7 @@ export default class Header extends React.Component {
|
||||
displayUrl: PropTypes.string,
|
||||
};
|
||||
|
||||
handleCreatePostClick = (collectionName) => {
|
||||
handleCreatePostClick = collectionName => {
|
||||
const { onCreateEntryClick } = this.props;
|
||||
if (onCreateEntryClick) {
|
||||
onCreateEntryClick(collectionName);
|
||||
@ -126,19 +126,17 @@ export default class Header extends React.Component {
|
||||
activeClassName="header-link-active"
|
||||
isActive={(match, location) => location.pathname.startsWith('/collections/')}
|
||||
>
|
||||
<Icon type="page"/>
|
||||
<Icon type="page" />
|
||||
Content
|
||||
</AppHeaderNavLink>
|
||||
{
|
||||
hasWorkflow
|
||||
? <AppHeaderNavLink to="/workflow" activeClassName="header-link-active">
|
||||
<Icon type="workflow"/>
|
||||
Workflow
|
||||
</AppHeaderNavLink>
|
||||
: null
|
||||
}
|
||||
{hasWorkflow ? (
|
||||
<AppHeaderNavLink to="/workflow" activeClassName="header-link-active">
|
||||
<Icon type="workflow" />
|
||||
Workflow
|
||||
</AppHeaderNavLink>
|
||||
) : null}
|
||||
<AppHeaderButton onClick={openMediaLibrary}>
|
||||
<Icon type="media-alt"/>
|
||||
<Icon type="media-alt" />
|
||||
Media
|
||||
</AppHeaderButton>
|
||||
</nav>
|
||||
@ -149,15 +147,16 @@ export default class Header extends React.Component {
|
||||
dropdownWidth="160px"
|
||||
dropdownPosition="left"
|
||||
>
|
||||
{
|
||||
collections.filter(collection => collection.get('create')).toList().map(collection =>
|
||||
{collections
|
||||
.filter(collection => collection.get('create'))
|
||||
.toList()
|
||||
.map(collection => (
|
||||
<DropdownItem
|
||||
key={collection.get("name")}
|
||||
label={collection.get("label_singular") || collection.get("label")}
|
||||
key={collection.get('name')}
|
||||
label={collection.get('label_singular') || collection.get('label')}
|
||||
onClick={() => this.handleCreatePostClick(collection.get('name'))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
))}
|
||||
</Dropdown>
|
||||
<SettingsDropdown
|
||||
displayUrl={displayUrl}
|
||||
|
@ -2,7 +2,6 @@ import React from 'react';
|
||||
import styled from 'react-emotion';
|
||||
import { lengths } from 'netlify-cms-ui-default';
|
||||
|
||||
|
||||
const NotFoundContainer = styled.div`
|
||||
margin: ${lengths.pageMargin};
|
||||
`;
|
||||
|
@ -12,11 +12,11 @@ import { VIEW_STYLE_LIST } from 'Constants/collectionViews';
|
||||
|
||||
const CollectionContainer = styled.div`
|
||||
margin: ${lengths.pageMargin};
|
||||
`
|
||||
`;
|
||||
|
||||
const CollectionMain = styled.main`
|
||||
padding-left: 280px;
|
||||
`
|
||||
`;
|
||||
|
||||
class Collection extends React.Component {
|
||||
static propTypes = {
|
||||
@ -30,40 +30,40 @@ class Collection extends React.Component {
|
||||
|
||||
renderEntriesCollection = () => {
|
||||
const { name, collection } = this.props;
|
||||
return <EntriesCollection collection={collection} name={name} viewStyle={this.state.viewStyle}/>
|
||||
return (
|
||||
<EntriesCollection collection={collection} name={name} viewStyle={this.state.viewStyle} />
|
||||
);
|
||||
};
|
||||
|
||||
renderEntriesSearch = () => {
|
||||
const { searchTerm, collections } = this.props;
|
||||
return <EntriesSearch collections={collections} searchTerm={searchTerm} />
|
||||
return <EntriesSearch collections={collections} searchTerm={searchTerm} />;
|
||||
};
|
||||
|
||||
handleChangeViewStyle = (viewStyle) => {
|
||||
handleChangeViewStyle = viewStyle => {
|
||||
if (this.state.viewStyle !== viewStyle) {
|
||||
this.setState({ viewStyle });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { collection, collections, collectionName, isSearchResults, searchTerm } = this.props;
|
||||
const newEntryUrl = collection.get('create') ? getNewEntryUrl(collectionName) : '';
|
||||
return (
|
||||
<CollectionContainer>
|
||||
<Sidebar collections={collections} searchTerm={searchTerm}/>
|
||||
<Sidebar collections={collections} searchTerm={searchTerm} />
|
||||
<CollectionMain>
|
||||
{
|
||||
isSearchResults
|
||||
? null
|
||||
: <CollectionTop
|
||||
collectionLabel={collection.get('label')}
|
||||
collectionLabelSingular={collection.get('label_singular')}
|
||||
collectionDescription={collection.get('description')}
|
||||
newEntryUrl={newEntryUrl}
|
||||
viewStyle={this.state.viewStyle}
|
||||
onChangeViewStyle={this.handleChangeViewStyle}
|
||||
/>
|
||||
}
|
||||
{ isSearchResults ? this.renderEntriesSearch() : this.renderEntriesCollection() }
|
||||
{isSearchResults ? null : (
|
||||
<CollectionTop
|
||||
collectionLabel={collection.get('label')}
|
||||
collectionLabelSingular={collection.get('label_singular')}
|
||||
collectionDescription={collection.get('description')}
|
||||
newEntryUrl={newEntryUrl}
|
||||
viewStyle={this.state.viewStyle}
|
||||
onChangeViewStyle={this.handleChangeViewStyle}
|
||||
/>
|
||||
)}
|
||||
{isSearchResults ? this.renderEntriesSearch() : this.renderEntriesCollection()}
|
||||
</CollectionMain>
|
||||
</CollectionContainer>
|
||||
);
|
||||
|
@ -7,18 +7,18 @@ import { VIEW_STYLE_LIST, VIEW_STYLE_GRID } from 'Constants/collectionViews';
|
||||
|
||||
const CollectionTopContainer = styled.div`
|
||||
${components.cardTop};
|
||||
`
|
||||
`;
|
||||
|
||||
const CollectionTopRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
`
|
||||
`;
|
||||
|
||||
const CollectionTopHeading = styled.h1`
|
||||
${components.cardTopHeading};
|
||||
`
|
||||
`;
|
||||
|
||||
const CollectionTopNewButton = styled(Link)`
|
||||
${buttons.button};
|
||||
@ -27,28 +27,28 @@ const CollectionTopNewButton = styled(Link)`
|
||||
${buttons.gray};
|
||||
|
||||
padding: 0 30px;
|
||||
`
|
||||
`;
|
||||
|
||||
const CollectionTopDescription = styled.p`
|
||||
${components.cardTopDescription};
|
||||
`
|
||||
`;
|
||||
|
||||
const ViewControls = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-top: 24px;
|
||||
`
|
||||
`;
|
||||
|
||||
const ViewControlsText = styled.span`
|
||||
font-size: 14px;
|
||||
color: ${colors.text};
|
||||
margin-right: 12px;
|
||||
`
|
||||
`;
|
||||
|
||||
const ViewControlsButton = styled.button`
|
||||
${buttons.button};
|
||||
color: ${props => props.isActive ? colors.active : '#b3b9c4'};
|
||||
color: ${props => (props.isActive ? colors.active : '#b3b9c4')};
|
||||
background-color: transparent;
|
||||
display: block;
|
||||
padding: 0;
|
||||
@ -61,7 +61,7 @@ const ViewControlsButton = styled.button`
|
||||
${Icon} {
|
||||
display: block;
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
const CollectionTop = ({
|
||||
collectionLabel,
|
||||
@ -75,32 +75,28 @@ const CollectionTop = ({
|
||||
<CollectionTopContainer>
|
||||
<CollectionTopRow>
|
||||
<CollectionTopHeading>{collectionLabel}</CollectionTopHeading>
|
||||
{
|
||||
newEntryUrl
|
||||
? <CollectionTopNewButton to={newEntryUrl}>
|
||||
{`New ${collectionLabelSingular || collectionLabel}`}
|
||||
</CollectionTopNewButton>
|
||||
: null
|
||||
}
|
||||
{newEntryUrl ? (
|
||||
<CollectionTopNewButton to={newEntryUrl}>
|
||||
{`New ${collectionLabelSingular || collectionLabel}`}
|
||||
</CollectionTopNewButton>
|
||||
) : null}
|
||||
</CollectionTopRow>
|
||||
{
|
||||
collectionDescription
|
||||
? <CollectionTopDescription>{collectionDescription}</CollectionTopDescription>
|
||||
: null
|
||||
}
|
||||
{collectionDescription ? (
|
||||
<CollectionTopDescription>{collectionDescription}</CollectionTopDescription>
|
||||
) : null}
|
||||
<ViewControls>
|
||||
<ViewControlsText>View as:</ViewControlsText>
|
||||
<ViewControlsButton
|
||||
isActive={viewStyle === VIEW_STYLE_LIST}
|
||||
onClick={() => onChangeViewStyle(VIEW_STYLE_LIST)}
|
||||
>
|
||||
<Icon type="list"/>
|
||||
<Icon type="list" />
|
||||
</ViewControlsButton>
|
||||
<ViewControlsButton
|
||||
isActive={viewStyle === VIEW_STYLE_GRID}
|
||||
onClick={() => onChangeViewStyle(VIEW_STYLE_GRID)}
|
||||
>
|
||||
<Icon type="grid"/>
|
||||
<Icon type="grid" />
|
||||
</ViewControlsButton>
|
||||
</ViewControls>
|
||||
</CollectionTopContainer>
|
||||
@ -110,7 +106,7 @@ const CollectionTop = ({
|
||||
CollectionTop.propTypes = {
|
||||
collectionLabel: PropTypes.string.isRequired,
|
||||
collectionDescription: PropTypes.string,
|
||||
newEntryUrl: PropTypes.string
|
||||
newEntryUrl: PropTypes.string,
|
||||
};
|
||||
|
||||
export default CollectionTop;
|
||||
|
@ -13,11 +13,7 @@ const Entries = ({
|
||||
cursor,
|
||||
handleCursorActions,
|
||||
}) => {
|
||||
const loadingMessages = [
|
||||
'Loading Entries',
|
||||
'Caching Entries',
|
||||
'This might take several minutes',
|
||||
];
|
||||
const loadingMessages = ['Loading Entries', 'Caching Entries', 'This might take several minutes'];
|
||||
|
||||
if (entries) {
|
||||
return (
|
||||
@ -37,7 +33,7 @@ const Entries = ({
|
||||
}
|
||||
|
||||
return <div className="nc-collectionPage-noEntries">No Entries</div>;
|
||||
}
|
||||
};
|
||||
|
||||
Entries.propTypes = {
|
||||
collections: ImmutablePropTypes.map.isRequired,
|
||||
|
@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { connect } from 'react-redux';
|
||||
import { partial } from 'lodash';
|
||||
import { Cursor } from 'netlify-cms-lib-util'
|
||||
import { Cursor } from 'netlify-cms-lib-util';
|
||||
import {
|
||||
loadEntries as actionLoadEntries,
|
||||
traverseCollectionCursor as actionTraverseCollectionCursor,
|
||||
@ -43,7 +43,7 @@ class EntriesCollection extends React.Component {
|
||||
traverseCollectionCursor(collection, action);
|
||||
};
|
||||
|
||||
render () {
|
||||
render() {
|
||||
const { collection, entries, publicFolder, isFetching, viewStyle, cursor } = this.props;
|
||||
|
||||
return (
|
||||
@ -70,7 +70,7 @@ function mapStateToProps(state, ownProps) {
|
||||
const entries = selectEntries(state, collection.get('name'));
|
||||
const isFetching = state.entries.getIn(['pages', collection.get('name'), 'isFetching'], false);
|
||||
|
||||
const rawCursor = selectCollectionEntriesCursor(state.cursors, collection.get("name"));
|
||||
const rawCursor = selectCollectionEntriesCursor(state.cursors, collection.get('name'));
|
||||
const cursor = Cursor.create(rawCursor).clearData();
|
||||
|
||||
return { publicFolder, collection, page, entries, isFetching, viewStyle, cursor };
|
||||
@ -81,4 +81,7 @@ const mapDispatchToProps = {
|
||||
traverseCollectionCursor: actionTraverseCollectionCursor,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(EntriesCollection);
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
)(EntriesCollection);
|
||||
|
@ -6,7 +6,7 @@ import { Cursor } from 'netlify-cms-lib-util';
|
||||
import { selectSearchedEntries } from 'Reducers';
|
||||
import {
|
||||
searchEntries as actionSearchEntries,
|
||||
clearSearch as actionClearSearch
|
||||
clearSearch as actionClearSearch,
|
||||
} from 'Actions/search';
|
||||
import Entries from './Entries';
|
||||
|
||||
@ -40,19 +40,19 @@ class EntriesSearch extends React.Component {
|
||||
getCursor = () => {
|
||||
const { page } = this.props;
|
||||
return Cursor.create({
|
||||
actions: isNaN(page) ? [] : ["append_next"],
|
||||
actions: isNaN(page) ? [] : ['append_next'],
|
||||
});
|
||||
};
|
||||
|
||||
handleCursorActions = (action) => {
|
||||
handleCursorActions = action => {
|
||||
const { page, searchTerm, searchEntries } = this.props;
|
||||
if (action === "append_next") {
|
||||
if (action === 'append_next') {
|
||||
const nextPage = page + 1;
|
||||
searchEntries(searchTerm, nextPage);
|
||||
}
|
||||
};
|
||||
|
||||
render () {
|
||||
render() {
|
||||
const { collections, entries, publicFolder, isFetching } = this.props;
|
||||
return (
|
||||
<Entries
|
||||
@ -83,4 +83,7 @@ const mapDispatchToProps = {
|
||||
clearSearch: actionClearSearch,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(EntriesSearch);
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
)(EntriesSearch);
|
||||
|
@ -10,7 +10,7 @@ const ListCard = styled.li`
|
||||
width: ${lengths.topCardWidth};
|
||||
margin-left: 12px;
|
||||
margin-bottom: 16px;
|
||||
`
|
||||
`;
|
||||
|
||||
const ListCardLink = styled(Link)`
|
||||
display: block;
|
||||
@ -19,7 +19,7 @@ const ListCardLink = styled(Link)`
|
||||
&:hover {
|
||||
background-color: ${colors.foreground};
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
const GridCard = styled.li`
|
||||
${components.card};
|
||||
@ -28,29 +28,30 @@ const GridCard = styled.li`
|
||||
overflow: hidden;
|
||||
margin-left: 12px;
|
||||
margin-bottom: 16px;
|
||||
`
|
||||
`;
|
||||
|
||||
const GridCardLink = styled(Link)`
|
||||
display: block;
|
||||
&, &:hover {
|
||||
&,
|
||||
&:hover {
|
||||
background-color: ${colors.foreground};
|
||||
color: ${colors.text};
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
const CollectionLabel = styled.h2`
|
||||
font-size: 12px;
|
||||
color: ${colors.textLead};
|
||||
text-transform: uppercase;
|
||||
`
|
||||
`;
|
||||
|
||||
const ListCardTitle = styled.h2`
|
||||
margin-bottom: 0;
|
||||
`
|
||||
`;
|
||||
|
||||
const CardHeading = styled.h2`
|
||||
margin: 0 0 2px;
|
||||
`
|
||||
`;
|
||||
|
||||
const CardBody = styled.div`
|
||||
padding: 16px 22px;
|
||||
@ -69,7 +70,7 @@ const CardBody = styled.div`
|
||||
width: 140%;
|
||||
box-shadow: inset 0 -15px 24px ${colorsRaw.white};
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
const CardImage = styled.div`
|
||||
background-image: url(${props => props.url});
|
||||
@ -77,7 +78,7 @@ const CardImage = styled.div`
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
height: 150px;
|
||||
`
|
||||
`;
|
||||
|
||||
const EntryCard = ({
|
||||
collection,
|
||||
@ -92,7 +93,7 @@ const EntryCard = ({
|
||||
const path = `/collections/${collection.get('name')}/entries/${entry.get('slug')}`;
|
||||
let image = entry.getIn(['data', inferedFields.imageField]);
|
||||
image = resolvePath(image, publicFolder);
|
||||
if(image) {
|
||||
if (image) {
|
||||
image = encodeURI(image);
|
||||
}
|
||||
|
||||
@ -100,8 +101,8 @@ const EntryCard = ({
|
||||
return (
|
||||
<ListCard>
|
||||
<ListCardLink to={path}>
|
||||
{ collectionLabel ? <CollectionLabel>{collectionLabel}</CollectionLabel> : null }
|
||||
<ListCardTitle>{ title }</ListCardTitle>
|
||||
{collectionLabel ? <CollectionLabel>{collectionLabel}</CollectionLabel> : null}
|
||||
<ListCardTitle>{title}</ListCardTitle>
|
||||
</ListCardLink>
|
||||
</ListCard>
|
||||
);
|
||||
@ -112,14 +113,14 @@ const EntryCard = ({
|
||||
<GridCard>
|
||||
<GridCardLink to={path}>
|
||||
<CardBody hasImage={image}>
|
||||
{ collectionLabel ? <CollectionLabel>{collectionLabel}</CollectionLabel> : null }
|
||||
{collectionLabel ? <CollectionLabel>{collectionLabel}</CollectionLabel> : null}
|
||||
<CardHeading>{title}</CardHeading>
|
||||
</CardBody>
|
||||
{ image ? <CardImage url={image}/> : null }
|
||||
{image ? <CardImage url={image} /> : null}
|
||||
</GridCardLink>
|
||||
</GridCard>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default EntryCard;
|
||||
|
@ -13,23 +13,21 @@ const CardsGrid = styled.ul`
|
||||
flex-flow: row wrap;
|
||||
list-style-type: none;
|
||||
margin-left: -12px;
|
||||
`
|
||||
`;
|
||||
|
||||
export default class EntryListing extends React.Component {
|
||||
static propTypes = {
|
||||
publicFolder: PropTypes.string.isRequired,
|
||||
collections: PropTypes.oneOfType([
|
||||
ImmutablePropTypes.map,
|
||||
ImmutablePropTypes.iterable,
|
||||
]).isRequired,
|
||||
collections: PropTypes.oneOfType([ImmutablePropTypes.map, ImmutablePropTypes.iterable])
|
||||
.isRequired,
|
||||
entries: ImmutablePropTypes.list,
|
||||
viewStyle: PropTypes.string,
|
||||
};
|
||||
|
||||
handleLoadMore = () => {
|
||||
const { cursor, handleCursorActions } = this.props;
|
||||
if (Cursor.create(cursor).actions.has("append_next")) {
|
||||
handleCursorActions("append_next");
|
||||
if (Cursor.create(cursor).actions.has('append_next')) {
|
||||
handleCursorActions('append_next');
|
||||
}
|
||||
};
|
||||
|
||||
@ -39,7 +37,8 @@ export default class EntryListing extends React.Component {
|
||||
const imageField = selectInferedField(collection, 'image');
|
||||
const fields = selectFields(collection);
|
||||
const inferedFields = [titleField, descriptionField, imageField];
|
||||
const remainingFields = fields && fields.filter(f => inferedFields.indexOf(f.get('name')) === -1);
|
||||
const remainingFields =
|
||||
fields && fields.filter(f => inferedFields.indexOf(f.get('name')) === -1);
|
||||
return { titleField, descriptionField, imageField, remainingFields };
|
||||
};
|
||||
|
||||
@ -68,11 +67,9 @@ export default class EntryListing extends React.Component {
|
||||
return (
|
||||
<div>
|
||||
<CardsGrid>
|
||||
{
|
||||
Map.isMap(collections)
|
||||
? this.renderCardsForSingleCollection()
|
||||
: this.renderCardsForMultipleCollections()
|
||||
}
|
||||
{Map.isMap(collections)
|
||||
? this.renderCardsForSingleCollection()
|
||||
: this.renderCardsForMultipleCollections()}
|
||||
<Waypoint onEnter={this.handleLoadMore} />
|
||||
</CardsGrid>
|
||||
</div>
|
||||
|
@ -20,7 +20,7 @@ const SidebarContainer = styled.aside`
|
||||
position: fixed;
|
||||
max-height: calc(100vh - 112px);
|
||||
overflow: auto;
|
||||
`
|
||||
`;
|
||||
|
||||
const SidebarHeading = styled.h2`
|
||||
font-size: 23px;
|
||||
@ -28,7 +28,7 @@ const SidebarHeading = styled.h2`
|
||||
padding: 0;
|
||||
margin: 18px 12px 12px;
|
||||
color: ${colors.textLead};
|
||||
`
|
||||
`;
|
||||
|
||||
const SearchContainer = styled.div`
|
||||
display: flex;
|
||||
@ -46,7 +46,7 @@ const SearchContainer = styled.div`
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
const SearchInput = styled.input`
|
||||
background-color: #eff0f4;
|
||||
@ -61,7 +61,7 @@ const SearchInput = styled.input`
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px ${colorsRaw.blue};
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
const SidebarNavLink = styled(NavLink)`
|
||||
display: flex;
|
||||
@ -77,19 +77,16 @@ const SidebarNavLink = styled(NavLink)`
|
||||
&.${props.activeClassName} {
|
||||
${styles.sidebarNavLinkActive};
|
||||
}
|
||||
`}
|
||||
|
||||
&:first-of-type {
|
||||
`} &:first-of-type {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
${Icon} {
|
||||
margin-right: 8px;
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
export default class Sidebar extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
collections: ImmutablePropTypes.orderedMap.isRequired,
|
||||
};
|
||||
@ -104,13 +101,12 @@ export default class Sidebar extends React.Component {
|
||||
to={`/collections/${collectionName}`}
|
||||
activeClassName="sidebar-active"
|
||||
>
|
||||
<Icon type="write"/>
|
||||
<Icon type="write" />
|
||||
{collection.get('label')}
|
||||
</SidebarNavLink>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
render() {
|
||||
const { collections } = this.props;
|
||||
const { query } = this.state;
|
||||
@ -119,7 +115,7 @@ export default class Sidebar extends React.Component {
|
||||
<SidebarContainer>
|
||||
<SidebarHeading>Collections</SidebarHeading>
|
||||
<SearchContainer>
|
||||
<Icon type="search" size="small"/>
|
||||
<Icon type="search" size="small" />
|
||||
<SearchInput
|
||||
onChange={e => this.setState({ query: e.target.value })}
|
||||
onKeyDown={e => e.key === 'Enter' && searchCollections(query)}
|
||||
|
@ -19,7 +19,7 @@ import {
|
||||
import {
|
||||
updateUnpublishedEntryStatus,
|
||||
publishUnpublishedEntry,
|
||||
deleteUnpublishedEntry
|
||||
deleteUnpublishedEntry,
|
||||
} from 'Actions/editorialWorkflow';
|
||||
import { deserializeValues } from 'Lib/serializeEntryValues';
|
||||
import { selectEntry, selectUnpublishedEntry, getAsset } from 'Reducers';
|
||||
@ -29,10 +29,11 @@ import { EDITORIAL_WORKFLOW } from 'Constants/publishModes';
|
||||
import EditorInterface from './EditorInterface';
|
||||
import withWorkflow from './withWorkflow';
|
||||
|
||||
const navigateCollection = (collectionPath) => history.push(`/collections/${collectionPath}`);
|
||||
const navigateCollection = collectionPath => history.push(`/collections/${collectionPath}`);
|
||||
const navigateToCollection = collectionName => navigateCollection(collectionName);
|
||||
const navigateToNewEntry = collectionName => navigateCollection(`${collectionName}/new`);
|
||||
const navigateToEntry = (collectionName, slug) => navigateCollection(`${collectionName}/entries/${slug}`);
|
||||
const navigateToEntry = (collectionName, slug) =>
|
||||
navigateCollection(`${collectionName}/entries/${slug}`);
|
||||
|
||||
class Editor extends React.Component {
|
||||
static propTypes = {
|
||||
@ -83,7 +84,7 @@ class Editor extends React.Component {
|
||||
|
||||
const leaveMessage = 'Are you sure you want to leave this page?';
|
||||
|
||||
this.exitBlocker = (event) => {
|
||||
this.exitBlocker = event => {
|
||||
if (this.props.entryDraft.get('hasChanged')) {
|
||||
// This message is ignored in most browsers, but its presence
|
||||
// triggers the confirmation dialog
|
||||
@ -100,14 +101,18 @@ class Editor extends React.Component {
|
||||
const isPersisting = this.props.entryDraft.getIn(['entry', 'isPersisting']);
|
||||
const newRecord = this.props.entryDraft.getIn(['entry', 'newRecord']);
|
||||
const newEntryPath = `/collections/${collection.get('name')}/new`;
|
||||
if (isPersisting && newRecord && this.props.location.pathname === newEntryPath && action === 'PUSH') {
|
||||
if (
|
||||
isPersisting &&
|
||||
newRecord &&
|
||||
this.props.location.pathname === newEntryPath &&
|
||||
action === 'PUSH'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.props.hasChanged) {
|
||||
return leaveMessage;
|
||||
}
|
||||
|
||||
};
|
||||
const unblock = history.block(navigationBlocker);
|
||||
|
||||
@ -119,7 +124,10 @@ class Editor extends React.Component {
|
||||
const newEntryPath = `/collections/${collection.get('name')}/new`;
|
||||
const entriesPath = `/collections/${collection.get('name')}/entries/`;
|
||||
const { pathname } = location;
|
||||
if (pathname.startsWith(newEntryPath) || pathname.startsWith(entriesPath) && action === 'PUSH') {
|
||||
if (
|
||||
pathname.startsWith(newEntryPath) ||
|
||||
(pathname.startsWith(entriesPath) && action === 'PUSH')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
unblock();
|
||||
@ -147,7 +155,6 @@ class Editor extends React.Component {
|
||||
const { entry, newEntry, fields, collection } = this.props;
|
||||
|
||||
if (entry && !entry.get('isFetching') && !entry.get('error')) {
|
||||
|
||||
/**
|
||||
* Deserialize entry values for widgets with registered serializers before
|
||||
* creating the entry draft.
|
||||
@ -170,34 +177,54 @@ class Editor extends React.Component {
|
||||
if (entry) this.props.createDraftFromEntry(entry, metadata);
|
||||
};
|
||||
|
||||
handleChangeStatus = (newStatusName) => {
|
||||
const { entryDraft, updateUnpublishedEntryStatus, collection, slug, currentStatus } = this.props;
|
||||
handleChangeStatus = newStatusName => {
|
||||
const {
|
||||
entryDraft,
|
||||
updateUnpublishedEntryStatus,
|
||||
collection,
|
||||
slug,
|
||||
currentStatus,
|
||||
} = this.props;
|
||||
if (entryDraft.get('hasChanged')) {
|
||||
window.alert('You have unsaved changes, please save before updating status.');
|
||||
return;
|
||||
}
|
||||
const newStatus = status.get(newStatusName);
|
||||
updateUnpublishedEntryStatus(collection.get('name'), slug, currentStatus, newStatus);
|
||||
}
|
||||
};
|
||||
|
||||
handlePersistEntry = async (opts = {}) => {
|
||||
const { createNew = false } = opts;
|
||||
const { persistEntry, collection, currentStatus, hasWorkflow, loadEntry, slug, createEmptyDraft } = this.props;
|
||||
const {
|
||||
persistEntry,
|
||||
collection,
|
||||
currentStatus,
|
||||
hasWorkflow,
|
||||
loadEntry,
|
||||
slug,
|
||||
createEmptyDraft,
|
||||
} = this.props;
|
||||
|
||||
await persistEntry(collection)
|
||||
await persistEntry(collection);
|
||||
|
||||
if (createNew) {
|
||||
navigateToNewEntry(collection.get('name'));
|
||||
createEmptyDraft(collection);
|
||||
}
|
||||
else if (slug && hasWorkflow && !currentStatus) {
|
||||
} else if (slug && hasWorkflow && !currentStatus) {
|
||||
loadEntry(collection, slug);
|
||||
}
|
||||
};
|
||||
|
||||
handlePublishEntry = async (opts = {}) => {
|
||||
const { createNew = false } = opts;
|
||||
const { publishUnpublishedEntry, entryDraft, collection, slug, currentStatus, loadEntry } = this.props;
|
||||
const {
|
||||
publishUnpublishedEntry,
|
||||
entryDraft,
|
||||
collection,
|
||||
slug,
|
||||
currentStatus,
|
||||
loadEntry,
|
||||
} = this.props;
|
||||
if (currentStatus !== status.last()) {
|
||||
window.alert('Please update status to "Ready" before publishing.');
|
||||
return;
|
||||
@ -212,8 +239,7 @@ class Editor extends React.Component {
|
||||
|
||||
if (createNew) {
|
||||
navigateToNewEntry(collection.get('name'));
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
loadEntry(collection, slug);
|
||||
}
|
||||
};
|
||||
@ -221,7 +247,11 @@ class Editor extends React.Component {
|
||||
handleDeleteEntry = () => {
|
||||
const { entryDraft, newEntry, collection, deleteEntry, slug } = this.props;
|
||||
if (entryDraft.get('hasChanged')) {
|
||||
if (!window.confirm('Are you sure you want to delete this published entry, as well as your unsaved changes from the current session?')) {
|
||||
if (
|
||||
!window.confirm(
|
||||
'Are you sure you want to delete this published entry, as well as your unsaved changes from the current session?',
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
} else if (!window.confirm('Are you sure you want to delete this published entry?')) {
|
||||
@ -238,10 +268,26 @@ class Editor extends React.Component {
|
||||
};
|
||||
|
||||
handleDeleteUnpublishedChanges = async () => {
|
||||
const { entryDraft, collection, slug, deleteUnpublishedEntry, loadEntry, isModification } = this.props;
|
||||
if (entryDraft.get('hasChanged') && !window.confirm('This will delete all unpublished changes to this entry, as well as your unsaved changes from the current session. Do you still want to delete?')) {
|
||||
const {
|
||||
entryDraft,
|
||||
collection,
|
||||
slug,
|
||||
deleteUnpublishedEntry,
|
||||
loadEntry,
|
||||
isModification,
|
||||
} = this.props;
|
||||
if (
|
||||
entryDraft.get('hasChanged') &&
|
||||
!window.confirm(
|
||||
'This will delete all unpublished changes to this entry, as well as your unsaved changes from the current session. Do you still want to delete?',
|
||||
)
|
||||
) {
|
||||
return;
|
||||
} else if (!window.confirm('All unpublished changes to this entry will be deleted. Do you still want to delete?')) {
|
||||
} else if (
|
||||
!window.confirm(
|
||||
'All unpublished changes to this entry will be deleted. Do you still want to delete?',
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await deleteUnpublishedEntry(collection.get('name'), slug);
|
||||
@ -274,10 +320,16 @@ class Editor extends React.Component {
|
||||
} = this.props;
|
||||
|
||||
if (entry && entry.get('error')) {
|
||||
return <div><h3>{ entry.get('error') }</h3></div>;
|
||||
} else if (entryDraft == null
|
||||
|| entryDraft.get('entry') === undefined
|
||||
|| (entry && entry.get('isFetching'))) {
|
||||
return (
|
||||
<div>
|
||||
<h3>{entry.get('error')}</h3>
|
||||
</div>
|
||||
);
|
||||
} else if (
|
||||
entryDraft == null ||
|
||||
entryDraft.get('entry') === undefined ||
|
||||
(entry && entry.get('isFetching'))
|
||||
) {
|
||||
return <Loader active>Loading entry...</Loader>;
|
||||
}
|
||||
|
||||
@ -325,7 +377,7 @@ function mapStateToProps(state, ownProps) {
|
||||
const displayUrl = config.get('display_url');
|
||||
const hasWorkflow = config.get('publish_mode') === EDITORIAL_WORKFLOW;
|
||||
const isModification = entryDraft.getIn(['entry', 'isModification']);
|
||||
const collectionEntriesLoaded = !!entries.getIn(['entities', collectionName])
|
||||
const collectionEntriesLoaded = !!entries.getIn(['entities', collectionName]);
|
||||
const unpublishedEntry = selectUnpublishedEntry(state, collectionName, slug);
|
||||
const currentStatus = unpublishedEntry && unpublishedEntry.getIn(['metaData', 'status']);
|
||||
return {
|
||||
@ -363,5 +415,5 @@ export default connect(
|
||||
publishUnpublishedEntry,
|
||||
deleteUnpublishedEntry,
|
||||
logoutUser,
|
||||
}
|
||||
},
|
||||
)(withWorkflow(Editor));
|
||||
|
@ -89,7 +89,7 @@ const ControlContainer = styled.div`
|
||||
&:first-child {
|
||||
margin-top: 36px;
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
const ControlErrorsList = styled.ul`
|
||||
list-style-type: none;
|
||||
@ -101,9 +101,7 @@ const ControlErrorsList = styled.ul`
|
||||
position: relative;
|
||||
font-weight: 600;
|
||||
top: 20px;
|
||||
`
|
||||
|
||||
|
||||
`;
|
||||
|
||||
class EditorControl extends React.Component {
|
||||
state = {
|
||||
@ -138,13 +136,14 @@ class EditorControl extends React.Component {
|
||||
return (
|
||||
<ControlContainer>
|
||||
<ControlErrorsList>
|
||||
{
|
||||
errors && errors.map(error =>
|
||||
error.message &&
|
||||
typeof error.message === 'string' &&
|
||||
<li key={error.message.trim().replace(/[^a-z0-9]+/gi, '-')}>{error.message}</li>
|
||||
)
|
||||
}
|
||||
{errors &&
|
||||
errors.map(
|
||||
error =>
|
||||
error.message &&
|
||||
typeof error.message === 'string' && (
|
||||
<li key={error.message.trim().replace(/[^a-z0-9]+/gi, '-')}>{error.message}</li>
|
||||
),
|
||||
)}
|
||||
</ControlErrorsList>
|
||||
<label
|
||||
className={cx(
|
||||
|
@ -12,7 +12,7 @@ const ControlPaneContainer = styled.div`
|
||||
p {
|
||||
font-size: 16px;
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
export default class ControlPane extends React.Component {
|
||||
componentValidate = {};
|
||||
@ -23,9 +23,9 @@ export default class ControlPane extends React.Component {
|
||||
};
|
||||
|
||||
validate = () => {
|
||||
this.props.fields.forEach((field) => {
|
||||
this.props.fields.forEach(field => {
|
||||
if (field.get('widget') === 'hidden') return;
|
||||
this.componentValidate[field.get("name")]();
|
||||
this.componentValidate[field.get('name')]();
|
||||
});
|
||||
};
|
||||
|
||||
@ -50,17 +50,20 @@ export default class ControlPane extends React.Component {
|
||||
|
||||
return (
|
||||
<ControlPaneContainer>
|
||||
{fields.map((field, i) => field.get('widget') === 'hidden' ? null :
|
||||
<EditorControl
|
||||
key={i}
|
||||
field={field}
|
||||
value={entry.getIn(['data', field.get('name')])}
|
||||
fieldsMetaData={fieldsMetaData}
|
||||
fieldsErrors={fieldsErrors}
|
||||
onChange={onChange}
|
||||
onValidate={onValidate}
|
||||
processControlRef={this.processControlRef}
|
||||
/>
|
||||
{fields.map(
|
||||
(field, i) =>
|
||||
field.get('widget') === 'hidden' ? null : (
|
||||
<EditorControl
|
||||
key={i}
|
||||
field={field}
|
||||
value={entry.getIn(['data', field.get('name')])}
|
||||
fieldsMetaData={fieldsMetaData}
|
||||
fieldsErrors={fieldsErrors}
|
||||
onChange={onChange}
|
||||
onValidate={onValidate}
|
||||
processControlRef={this.processControlRef}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</ControlPaneContainer>
|
||||
);
|
||||
|
@ -1,17 +1,16 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import ImmutablePropTypes from "react-immutable-proptypes";
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { Map } from 'immutable';
|
||||
import ValidationErrorTypes from 'Constants/validationErrorTypes';
|
||||
|
||||
const truthy = () => ({ error: false });
|
||||
|
||||
const isEmpty = value => (
|
||||
const isEmpty = value =>
|
||||
value === null ||
|
||||
value === undefined ||
|
||||
(value.hasOwnProperty('length') && value.length === 0) ||
|
||||
(value.constructor === Object && Object.keys(value).length === 0)
|
||||
);
|
||||
(value.constructor === Object && Object.keys(value).length === 0);
|
||||
|
||||
export default class Widget extends Component {
|
||||
static propTypes = {
|
||||
@ -44,10 +43,7 @@ export default class Widget extends Component {
|
||||
isFetching: PropTypes.bool,
|
||||
query: PropTypes.func.isRequired,
|
||||
clearSearch: PropTypes.func.isRequired,
|
||||
queryHits: PropTypes.oneOfType([
|
||||
PropTypes.array,
|
||||
PropTypes.object,
|
||||
]),
|
||||
queryHits: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
|
||||
};
|
||||
|
||||
shouldComponentUpdate(nextProps) {
|
||||
@ -57,9 +53,11 @@ export default class Widget extends Component {
|
||||
if (this.wrappedControlShouldComponentUpdate) {
|
||||
return this.wrappedControlShouldComponentUpdate(nextProps);
|
||||
}
|
||||
return this.props.value !== nextProps.value
|
||||
|| this.props.classNameWrapper !== nextProps.classNameWrapper
|
||||
|| this.props.hasActiveStyle !== nextProps.hasActiveStyle;
|
||||
return (
|
||||
this.props.value !== nextProps.value ||
|
||||
this.props.classNameWrapper !== nextProps.classNameWrapper ||
|
||||
this.props.hasActiveStyle !== nextProps.hasActiveStyle
|
||||
);
|
||||
}
|
||||
|
||||
processInnerControlRef = ref => {
|
||||
@ -87,7 +85,7 @@ export default class Widget extends Component {
|
||||
const { field, value } = this.props;
|
||||
const errors = [];
|
||||
const validations = [this.validatePresence, this.validatePattern];
|
||||
validations.forEach((func) => {
|
||||
validations.forEach(func => {
|
||||
const response = func(field, value);
|
||||
if (response.error) errors.push(response.error);
|
||||
});
|
||||
@ -105,7 +103,7 @@ export default class Widget extends Component {
|
||||
if (isRequired && isEmpty(value)) {
|
||||
const error = {
|
||||
type: ValidationErrorTypes.PRESENCE,
|
||||
message: `${ field.get('label', field.get('name')) } is required.`,
|
||||
message: `${field.get('label', field.get('name'))} is required.`,
|
||||
};
|
||||
|
||||
return { error };
|
||||
@ -123,7 +121,10 @@ export default class Widget extends Component {
|
||||
if (pattern && !RegExp(pattern.first()).test(value)) {
|
||||
const error = {
|
||||
type: ValidationErrorTypes.PATTERN,
|
||||
message: `${ field.get('label', field.get('name')) } didn't match the pattern: ${ pattern.last() }`,
|
||||
message: `${field.get(
|
||||
'label',
|
||||
field.get('name'),
|
||||
)} didn't match the pattern: ${pattern.last()}`,
|
||||
};
|
||||
|
||||
return { error };
|
||||
@ -132,29 +133,31 @@ export default class Widget extends Component {
|
||||
return { error: false };
|
||||
};
|
||||
|
||||
validateWrappedControl = (field) => {
|
||||
validateWrappedControl = field => {
|
||||
const response = this.wrappedControlValid();
|
||||
if (typeof response === "boolean") {
|
||||
if (typeof response === 'boolean') {
|
||||
const isValid = response;
|
||||
return { error: (!isValid) };
|
||||
return { error: !isValid };
|
||||
} else if (response.hasOwnProperty('error')) {
|
||||
return response;
|
||||
} else if (response instanceof Promise) {
|
||||
response.then(
|
||||
() => { this.validate({ error: false }); },
|
||||
(err) => {
|
||||
() => {
|
||||
this.validate({ error: false });
|
||||
},
|
||||
err => {
|
||||
const error = {
|
||||
type: ValidationErrorTypes.CUSTOM,
|
||||
message: `${ field.get('label', field.get('name')) } - ${ err }.`,
|
||||
message: `${field.get('label', field.get('name'))} - ${err}.`,
|
||||
};
|
||||
|
||||
this.validate({ error });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const error = {
|
||||
type: ValidationErrorTypes.CUSTOM,
|
||||
message: `${ field.get('label', field.get('name')) } is processing.`,
|
||||
message: `${field.get('label', field.get('name'))} is processing.`,
|
||||
};
|
||||
|
||||
return { error };
|
||||
|
@ -23,7 +23,7 @@ const styles = {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
`,
|
||||
}
|
||||
};
|
||||
|
||||
injectGlobal`
|
||||
/**
|
||||
@ -51,7 +51,7 @@ injectGlobal`
|
||||
}
|
||||
}
|
||||
|
||||
`
|
||||
`;
|
||||
|
||||
const StyledSplitPane = styled(SplitPane)`
|
||||
${styles.splitPane};
|
||||
@ -62,11 +62,11 @@ const StyledSplitPane = styled(SplitPane)`
|
||||
.Pane {
|
||||
height: 100%;
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
const NoPreviewContainer = styled.div`
|
||||
${styles.splitPane};
|
||||
`
|
||||
`;
|
||||
|
||||
const EditorContainer = styled.div`
|
||||
width: 100%;
|
||||
@ -78,39 +78,39 @@ const EditorContainer = styled.div`
|
||||
overflow: hidden;
|
||||
padding-top: 66px;
|
||||
background-color: ${colors.background};
|
||||
`
|
||||
`;
|
||||
|
||||
const Editor = styled.div`
|
||||
max-width: 1600px;
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
`
|
||||
`;
|
||||
|
||||
const PreviewPaneContainer = styled.div`
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
pointer-events: ${props => props.blockEntry ? 'none' : 'auto'};
|
||||
`
|
||||
pointer-events: ${props => (props.blockEntry ? 'none' : 'auto')};
|
||||
`;
|
||||
|
||||
const ControlPaneContainer = styled(PreviewPaneContainer)`
|
||||
padding: 0 16px;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
`
|
||||
`;
|
||||
|
||||
const ViewControls = styled.div`
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 299;
|
||||
`
|
||||
`;
|
||||
|
||||
class EditorInterface extends Component {
|
||||
state = {
|
||||
showEventBlocker: false,
|
||||
previewVisible: localStorage.getItem(PREVIEW_VISIBLE) !== "false",
|
||||
scrollSyncEnabled: localStorage.getItem(SCROLL_SYNC_ENABLED) !== "false",
|
||||
previewVisible: localStorage.getItem(PREVIEW_VISIBLE) !== 'false',
|
||||
scrollSyncEnabled: localStorage.getItem(SCROLL_SYNC_ENABLED) !== 'false',
|
||||
};
|
||||
|
||||
handleSplitPaneDragStart = () => {
|
||||
@ -185,7 +185,7 @@ class EditorInterface extends Component {
|
||||
fieldsErrors={fieldsErrors}
|
||||
onChange={onChange}
|
||||
onValidate={onValidate}
|
||||
ref={c => this.controlPaneRef = c}
|
||||
ref={c => (this.controlPaneRef = c)}
|
||||
/>
|
||||
</ControlPaneContainer>
|
||||
);
|
||||
@ -255,11 +255,11 @@ class EditorInterface extends Component {
|
||||
icon="scroll"
|
||||
/>
|
||||
</ViewControls>
|
||||
{
|
||||
collectionPreviewEnabled && this.state.previewVisible
|
||||
? editorWithPreview
|
||||
: <NoPreviewContainer>{editor}</NoPreviewContainer>
|
||||
}
|
||||
{collectionPreviewEnabled && this.state.previewVisible ? (
|
||||
editorWithPreview
|
||||
) : (
|
||||
<NoPreviewContainer>{editor}</NoPreviewContainer>
|
||||
)}
|
||||
</Editor>
|
||||
</EditorContainer>
|
||||
);
|
||||
|
@ -8,8 +8,8 @@ function isVisible(field) {
|
||||
}
|
||||
|
||||
const PreviewContainer = styled.div`
|
||||
font-family: Roboto, "Helvetica Neue", HelveticaNeue, Helvetica, Arial, sans-serif;
|
||||
`
|
||||
font-family: Roboto, 'Helvetica Neue', HelveticaNeue, Helvetica, Arial, sans-serif;
|
||||
`;
|
||||
|
||||
/**
|
||||
* Use a stateful component so that child components can effectively utilize
|
||||
|
@ -19,10 +19,9 @@ const PreviewPaneFrame = styled(Frame)`
|
||||
border: none;
|
||||
background: #fff;
|
||||
border-radius: ${lengths.borderRadius};
|
||||
`
|
||||
`;
|
||||
|
||||
export default class PreviewPane extends React.Component {
|
||||
|
||||
getWidget = (field, value, props) => {
|
||||
const { fieldsMetaData, getAsset, entry } = props;
|
||||
const widget = resolveWidget(field.get('widget'));
|
||||
@ -77,8 +76,16 @@ export default class PreviewPane extends React.Component {
|
||||
const labelledWidgets = ['string', 'text', 'number'];
|
||||
if (Object.keys(this.inferedFields).indexOf(name) !== -1) {
|
||||
value = this.inferedFields[name].defaultPreview(value);
|
||||
} else if (value && labelledWidgets.indexOf(field.get('widget')) !== -1 && value.toString().length < 50) {
|
||||
value = <div><strong>{field.get('label')}:</strong> {value}</div>;
|
||||
} else if (
|
||||
value &&
|
||||
labelledWidgets.indexOf(field.get('widget')) !== -1 &&
|
||||
value.toString().length < 50
|
||||
) {
|
||||
value = (
|
||||
<div>
|
||||
<strong>{field.get('label')}:</strong> {value}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return value ? this.getWidget(field, value, this.props) : null;
|
||||
@ -109,7 +116,7 @@ export default class PreviewPane extends React.Component {
|
||||
*
|
||||
* TODO: see if widgetFor can now provide this functionality for preview templates
|
||||
*/
|
||||
widgetsFor = (name) => {
|
||||
widgetsFor = name => {
|
||||
const { fields, entry } = this.props;
|
||||
const field = fields.find(f => f.get('name') === name);
|
||||
const nestedFields = field && field.get('fields');
|
||||
@ -117,14 +124,23 @@ export default class PreviewPane extends React.Component {
|
||||
|
||||
if (List.isList(value)) {
|
||||
return value.map(val => {
|
||||
const widgets = nestedFields && Map(nestedFields.map((f, i) => [f.get('name'), <div key={i}>{this.getWidget(f, val, this.props)}</div>]));
|
||||
const widgets =
|
||||
nestedFields &&
|
||||
Map(
|
||||
nestedFields.map((f, i) => [
|
||||
f.get('name'),
|
||||
<div key={i}>{this.getWidget(f, val, this.props)}</div>,
|
||||
]),
|
||||
);
|
||||
return Map({ data: val, widgets });
|
||||
});
|
||||
}
|
||||
|
||||
return Map({
|
||||
data: value,
|
||||
widgets: nestedFields && Map(nestedFields.map(f => [f.get('name'), this.getWidget(f, value, this.props)])),
|
||||
widgets:
|
||||
nestedFields &&
|
||||
Map(nestedFields.map(f => [f.get('name'), this.getWidget(f, value, this.props)])),
|
||||
});
|
||||
};
|
||||
|
||||
@ -136,8 +152,7 @@ export default class PreviewPane extends React.Component {
|
||||
}
|
||||
|
||||
const previewComponent =
|
||||
getPreviewTemplate(selectTemplateName(collection, entry.get('slug'))) ||
|
||||
EditorPreview;
|
||||
getPreviewTemplate(selectTemplateName(collection, entry.get('slug'))) || EditorPreview;
|
||||
|
||||
this.inferFields();
|
||||
|
||||
@ -147,16 +162,15 @@ export default class PreviewPane extends React.Component {
|
||||
widgetsFor: this.widgetsFor,
|
||||
};
|
||||
|
||||
const styleEls = getPreviewStyles()
|
||||
.map((style, i) => {
|
||||
if (style.raw) {
|
||||
return <style key={i}>{style.value}</style>
|
||||
}
|
||||
return <link key={i} href={style.value} type="text/css" rel="stylesheet" />;
|
||||
});
|
||||
const styleEls = getPreviewStyles().map((style, i) => {
|
||||
if (style.raw) {
|
||||
return <style key={i}>{style.value}</style>;
|
||||
}
|
||||
return <link key={i} href={style.value} type="text/css" rel="stylesheet" />;
|
||||
});
|
||||
|
||||
if (!collection) {
|
||||
<PreviewPaneFrame head={styleEls}/>
|
||||
<PreviewPaneFrame head={styleEls} />;
|
||||
}
|
||||
|
||||
const initialContent = `
|
||||
@ -170,7 +184,7 @@ export default class PreviewPane extends React.Component {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<PreviewPaneFrame head={styleEls} initialContent={initialContent}>
|
||||
<EditorPreviewContent {...{ previewComponent, previewProps }}/>
|
||||
<EditorPreviewContent {...{ previewComponent, previewProps }} />
|
||||
</PreviewPaneFrame>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
class PreviewHOC extends React.Component {
|
||||
|
||||
/**
|
||||
* Only re-render on value change, but always re-render objects and lists.
|
||||
* Their child widgets will each also be wrapped with this component, and
|
||||
|
@ -16,12 +16,14 @@ const EditorToggleButton = styled.button`
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
margin-bottom: 12px;
|
||||
`
|
||||
`;
|
||||
|
||||
const EditorToggle = ({ enabled, active, onClick, icon }) => !enabled ? null :
|
||||
<EditorToggleButton onClick={onClick} isActive={active}>
|
||||
<Icon type={icon} size="large"/>
|
||||
</EditorToggleButton>;
|
||||
const EditorToggle = ({ enabled, active, onClick, icon }) =>
|
||||
!enabled ? null : (
|
||||
<EditorToggleButton onClick={onClick} isActive={active}>
|
||||
<Icon type={icon} size="large" />
|
||||
</EditorToggleButton>
|
||||
);
|
||||
|
||||
EditorToggle.propTypes = {
|
||||
enabled: PropTypes.bool,
|
||||
|
@ -27,12 +27,11 @@ const styles = {
|
||||
align-items: center;
|
||||
border: 0 solid ${colors.textFieldBorder};
|
||||
`,
|
||||
}
|
||||
};
|
||||
|
||||
const ToolbarContainer = styled.div`
|
||||
box-shadow: 0 2px 6px 0 rgba(68, 74, 87, 0.05),
|
||||
0 1px 3px 0 rgba(68, 74, 87, 0.10),
|
||||
0 2px 54px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 2px 6px 0 rgba(68, 74, 87, 0.05), 0 1px 3px 0 rgba(68, 74, 87, 0.1),
|
||||
0 2px 54px rgba(0, 0, 0, 0.1);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@ -43,7 +42,7 @@ const ToolbarContainer = styled.div`
|
||||
height: 66px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
`
|
||||
`;
|
||||
|
||||
const ToolbarSectionMain = styled.div`
|
||||
${styles.toolbarSection};
|
||||
@ -51,15 +50,15 @@ const ToolbarSectionMain = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 10px;
|
||||
`
|
||||
`;
|
||||
|
||||
const ToolbarSubSectionFirst = styled.div`
|
||||
display: flex;
|
||||
`
|
||||
`;
|
||||
|
||||
const ToolbarSubSectionLast = styled(ToolbarSubSectionFirst)`
|
||||
justify-content: flex-end;
|
||||
`
|
||||
`;
|
||||
|
||||
const ToolbarSectionBackLink = styled(Link)`
|
||||
${styles.toolbarSection};
|
||||
@ -69,15 +68,15 @@ const ToolbarSectionBackLink = styled(Link)`
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: #F1F2F4;
|
||||
background-color: #f1f2f4;
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
const ToolbarSectionMeta = styled.div`
|
||||
${styles.toolbarSection};
|
||||
border-left-width: 1px;
|
||||
padding: 0 7px;
|
||||
`
|
||||
`;
|
||||
|
||||
const ToolbarDropdown = styled(Dropdown)`
|
||||
${styles.buttonMargin};
|
||||
@ -85,23 +84,23 @@ const ToolbarDropdown = styled(Dropdown)`
|
||||
${Icon} {
|
||||
color: ${colorsRaw.teal};
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
const BackArrow = styled.div`
|
||||
color: ${colors.textLead};
|
||||
font-size: 21px;
|
||||
font-weight: 600;
|
||||
margin-right: 16px;
|
||||
`
|
||||
`;
|
||||
|
||||
const BackCollection = styled.div`
|
||||
color: ${colors.textLead};
|
||||
font-size: 14px;
|
||||
`
|
||||
`;
|
||||
|
||||
const BackStatus = styled.div`
|
||||
margin-top: 6px;
|
||||
`
|
||||
`;
|
||||
|
||||
const BackStatusUnchanged = styled(BackStatus)`
|
||||
${components.textBadgeSuccess};
|
||||
@ -117,26 +116,26 @@ const BackStatusUnchanged = styled(BackStatus)`
|
||||
|
||||
content: url("data:image/svg+xml; utf8, <svg xmlns='http://www.w3.org/2000/svg' width='15' height='11'><path fill='#005614' fill-rule='nonzero' d='M4.016 11l-.648-.946a6.202 6.202 0 0 0-.157-.22 9.526 9.526 0 0 1-.096-.133l-.511-.7a7.413 7.413 0 0 0-.162-.214l-.102-.134-.265-.346a26.903 26.903 0 0 0-.543-.687l-.11-.136c-.143-.179-.291-.363-.442-.54l-.278-.332a8.854 8.854 0 0 0-.192-.225L.417 6.28l-.283-.324L0 5.805l1.376-1.602c.04.027.186.132.186.132l.377.272.129.095c.08.058.16.115.237.175l.37.28c.192.142.382.292.565.436l.162.126c.27.21.503.398.714.574l.477.393c.078.064.156.127.23.194l.433.375.171-.205A50.865 50.865 0 0 1 8.18 4.023a35.163 35.163 0 0 1 2.382-2.213c.207-.174.42-.349.635-.518l.328-.255.333-.245c.072-.055.146-.107.221-.159l.117-.083c.11-.077.225-.155.341-.23.163-.11.334-.217.503-.32l1.158 1.74a11.908 11.908 0 0 0-.64.55l-.065.06c-.07.062-.139.125-.207.192l-.258.249-.26.265c-.173.176-.345.357-.512.539a32.626 32.626 0 0 0-1.915 2.313 52.115 52.115 0 0 0-2.572 3.746l-.392.642-.19.322-.233.382H4.016z'/></svg>");
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
const BackStatusChanged = styled(BackStatus)`
|
||||
${components.textBadgeDanger};
|
||||
`
|
||||
`;
|
||||
|
||||
const ToolbarButton = styled.button`
|
||||
${buttons.button};
|
||||
${buttons.default};
|
||||
${styles.buttonMargin};
|
||||
display: block;
|
||||
`
|
||||
`;
|
||||
|
||||
const DeleteButton = styled(ToolbarButton)`
|
||||
${buttons.lightRed};
|
||||
`
|
||||
`;
|
||||
|
||||
const SaveButton = styled(ToolbarButton)`
|
||||
${buttons.lightBlue};
|
||||
`
|
||||
`;
|
||||
|
||||
const StatusPublished = styled.div`
|
||||
${styles.buttonMargin};
|
||||
@ -149,22 +148,22 @@ const StatusPublished = styled.div`
|
||||
cursor: default;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
`
|
||||
`;
|
||||
|
||||
const PublishButton = styled(StyledDropdownButton)`
|
||||
background-color: ${colorsRaw.teal};
|
||||
`
|
||||
`;
|
||||
|
||||
const StatusButton = styled(StyledDropdownButton)`
|
||||
background-color: ${colorsRaw.tealLight};
|
||||
color: ${colorsRaw.teal};
|
||||
`
|
||||
`;
|
||||
|
||||
const StatusDropdownItem = styled(DropdownItem)`
|
||||
${Icon} {
|
||||
color: ${colors.infoText};
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
export default class EditorToolbar extends React.Component {
|
||||
static propTypes = {
|
||||
@ -196,14 +195,19 @@ export default class EditorToolbar extends React.Component {
|
||||
renderSimpleSaveControls = () => {
|
||||
const { showDelete, onDelete } = this.props;
|
||||
return (
|
||||
<div>
|
||||
{ showDelete ? <DeleteButton onClick={onDelete}>Delete entry</DeleteButton> : null }
|
||||
</div>
|
||||
<div>{showDelete ? <DeleteButton onClick={onDelete}>Delete entry</DeleteButton> : null}</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderSimplePublishControls = () => {
|
||||
const { collection, onPersist, onPersistAndNew, isPersisting, hasChanged, isNewEntry } = this.props;
|
||||
const {
|
||||
collection,
|
||||
onPersist,
|
||||
onPersistAndNew,
|
||||
isPersisting,
|
||||
hasChanged,
|
||||
isNewEntry,
|
||||
} = this.props;
|
||||
if (!isNewEntry && !hasChanged) {
|
||||
return <StatusPublished>Published</StatusPublished>;
|
||||
}
|
||||
@ -216,12 +220,15 @@ export default class EditorToolbar extends React.Component {
|
||||
<PublishButton>{isPersisting ? 'Publishing...' : 'Publish'}</PublishButton>
|
||||
)}
|
||||
>
|
||||
<DropdownItem label="Publish now" icon="arrow" iconDirection="right" onClick={onPersist}/>
|
||||
{
|
||||
collection.get('create')
|
||||
? <DropdownItem label="Publish and create new" icon="add" onClick={onPersistAndNew}/>
|
||||
: null
|
||||
}
|
||||
<DropdownItem
|
||||
label="Publish now"
|
||||
icon="arrow"
|
||||
iconDirection="right"
|
||||
onClick={onPersist}
|
||||
/>
|
||||
{collection.get('create') ? (
|
||||
<DropdownItem label="Publish and create new" icon="add" onClick={onPersistAndNew} />
|
||||
) : null}
|
||||
</ToolbarDropdown>
|
||||
</div>
|
||||
);
|
||||
@ -240,21 +247,23 @@ export default class EditorToolbar extends React.Component {
|
||||
isModification,
|
||||
} = this.props;
|
||||
|
||||
const deleteLabel = (hasUnpublishedChanges && isModification && 'Delete unpublished changes')
|
||||
|| (hasUnpublishedChanges && (isNewEntry || !isModification) && 'Delete unpublished entry')
|
||||
|| (!hasUnpublishedChanges && !isModification && 'Delete published entry');
|
||||
const deleteLabel =
|
||||
(hasUnpublishedChanges && isModification && 'Delete unpublished changes') ||
|
||||
(hasUnpublishedChanges && (isNewEntry || !isModification) && 'Delete unpublished entry') ||
|
||||
(!hasUnpublishedChanges && !isModification && 'Delete published entry');
|
||||
|
||||
return [
|
||||
<SaveButton key="save-button" onClick={() => hasChanged && onPersist()}>
|
||||
{isPersisting ? 'Saving...' : 'Save'}
|
||||
</SaveButton>,
|
||||
isNewEntry || !deleteLabel ? null
|
||||
: <DeleteButton
|
||||
key="delete-button"
|
||||
onClick={hasUnpublishedChanges ? onDeleteUnpublishedChanges : onDelete}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : deleteLabel}
|
||||
</DeleteButton>,
|
||||
isNewEntry || !deleteLabel ? null : (
|
||||
<DeleteButton
|
||||
key="delete-button"
|
||||
onClick={hasUnpublishedChanges ? onDeleteUnpublishedChanges : onDelete}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : deleteLabel}
|
||||
</DeleteButton>
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
@ -270,63 +279,59 @@ export default class EditorToolbar extends React.Component {
|
||||
isNewEntry,
|
||||
} = this.props;
|
||||
if (currentStatus) {
|
||||
return (<>
|
||||
<ToolbarDropdown
|
||||
dropdownTopOverlap="40px"
|
||||
dropdownWidth="120px"
|
||||
renderButton={() => (
|
||||
<StatusButton>{isUpdatingStatus ? 'Updating...' : 'Set status'}</StatusButton>
|
||||
)}
|
||||
>
|
||||
<StatusDropdownItem
|
||||
label="Draft"
|
||||
onClick={() => onChangeStatus('DRAFT')}
|
||||
icon={currentStatus === status.get('DRAFT') && 'check'}
|
||||
/>
|
||||
<StatusDropdownItem
|
||||
label="In review"
|
||||
onClick={() => onChangeStatus('PENDING_REVIEW')}
|
||||
icon={currentStatus === status.get('PENDING_REVIEW') && 'check'}
|
||||
/>
|
||||
<StatusDropdownItem
|
||||
label="Ready"
|
||||
onClick={() => onChangeStatus('PENDING_PUBLISH')}
|
||||
icon={currentStatus === status.get('PENDING_PUBLISH') && 'check'}
|
||||
/>
|
||||
</ToolbarDropdown>
|
||||
<ToolbarDropdown
|
||||
dropdownTopOverlap="40px"
|
||||
dropdownWidth="150px"
|
||||
renderButton={() => (
|
||||
<PublishButton>{isPublishing ? 'Publishing...' : 'Publish'}</PublishButton>
|
||||
)}
|
||||
>
|
||||
<DropdownItem label="Publish now" icon="arrow" iconDirection="right" onClick={onPublish}/>
|
||||
{
|
||||
collection.get('create')
|
||||
? <DropdownItem label="Publish and create new" icon="add" onClick={onPublishAndNew}/>
|
||||
: null
|
||||
}
|
||||
</ToolbarDropdown>
|
||||
</>);
|
||||
return (
|
||||
<>
|
||||
<ToolbarDropdown
|
||||
dropdownTopOverlap="40px"
|
||||
dropdownWidth="120px"
|
||||
renderButton={() => (
|
||||
<StatusButton>{isUpdatingStatus ? 'Updating...' : 'Set status'}</StatusButton>
|
||||
)}
|
||||
>
|
||||
<StatusDropdownItem
|
||||
label="Draft"
|
||||
onClick={() => onChangeStatus('DRAFT')}
|
||||
icon={currentStatus === status.get('DRAFT') && 'check'}
|
||||
/>
|
||||
<StatusDropdownItem
|
||||
label="In review"
|
||||
onClick={() => onChangeStatus('PENDING_REVIEW')}
|
||||
icon={currentStatus === status.get('PENDING_REVIEW') && 'check'}
|
||||
/>
|
||||
<StatusDropdownItem
|
||||
label="Ready"
|
||||
onClick={() => onChangeStatus('PENDING_PUBLISH')}
|
||||
icon={currentStatus === status.get('PENDING_PUBLISH') && 'check'}
|
||||
/>
|
||||
</ToolbarDropdown>
|
||||
<ToolbarDropdown
|
||||
dropdownTopOverlap="40px"
|
||||
dropdownWidth="150px"
|
||||
renderButton={() => (
|
||||
<PublishButton>{isPublishing ? 'Publishing...' : 'Publish'}</PublishButton>
|
||||
)}
|
||||
>
|
||||
<DropdownItem
|
||||
label="Publish now"
|
||||
icon="arrow"
|
||||
iconDirection="right"
|
||||
onClick={onPublish}
|
||||
/>
|
||||
{collection.get('create') ? (
|
||||
<DropdownItem label="Publish and create new" icon="add" onClick={onPublishAndNew} />
|
||||
) : null}
|
||||
</ToolbarDropdown>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isNewEntry) {
|
||||
return <StatusPublished>Published</StatusPublished>
|
||||
return <StatusPublished>Published</StatusPublished>;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
render() {
|
||||
const {
|
||||
user,
|
||||
hasChanged,
|
||||
displayUrl,
|
||||
collection,
|
||||
hasWorkflow,
|
||||
onLogoutClick,
|
||||
} = this.props;
|
||||
const { user, hasChanged, displayUrl, collection, hasWorkflow, onLogoutClick } = this.props;
|
||||
|
||||
return (
|
||||
<ToolbarContainer>
|
||||
@ -336,19 +341,21 @@ export default class EditorToolbar extends React.Component {
|
||||
<BackCollection>
|
||||
Writing in <strong>{collection.get('label')}</strong> collection
|
||||
</BackCollection>
|
||||
{
|
||||
hasChanged
|
||||
? <BackStatusChanged>Unsaved Changes</BackStatusChanged>
|
||||
: <BackStatusUnchanged>Changes saved</BackStatusUnchanged>
|
||||
}
|
||||
{hasChanged ? (
|
||||
<BackStatusChanged>Unsaved Changes</BackStatusChanged>
|
||||
) : (
|
||||
<BackStatusUnchanged>Changes saved</BackStatusUnchanged>
|
||||
)}
|
||||
</div>
|
||||
</ToolbarSectionBackLink>
|
||||
<ToolbarSectionMain>
|
||||
<ToolbarSubSectionFirst>
|
||||
{ hasWorkflow ? this.renderWorkflowSaveControls() : this.renderSimpleSaveControls() }
|
||||
{hasWorkflow ? this.renderWorkflowSaveControls() : this.renderSimpleSaveControls()}
|
||||
</ToolbarSubSectionFirst>
|
||||
<ToolbarSubSectionLast>
|
||||
{ hasWorkflow ? this.renderWorkflowPublishControls() : this.renderSimplePublishControls() }
|
||||
{hasWorkflow
|
||||
? this.renderWorkflowPublishControls()
|
||||
: this.renderSimplePublishControls()}
|
||||
</ToolbarSubSectionLast>
|
||||
</ToolbarSectionMain>
|
||||
<ToolbarSectionMeta>
|
||||
|
@ -7,7 +7,7 @@ import { loadUnpublishedEntry, persistUnpublishedEntry } from 'Actions/editorial
|
||||
|
||||
function mapStateToProps(state, ownProps) {
|
||||
const { collections } = state;
|
||||
const isEditorialWorkflow = (state.config.get('publish_mode') === EDITORIAL_WORKFLOW);
|
||||
const isEditorialWorkflow = state.config.get('publish_mode') === EDITORIAL_WORKFLOW;
|
||||
const collection = collections.get(ownProps.match.params.name);
|
||||
const returnObj = {
|
||||
isEditorialWorkflow,
|
||||
@ -31,8 +31,7 @@ function mergeProps(stateProps, dispatchProps, ownProps) {
|
||||
|
||||
if (isEditorialWorkflow) {
|
||||
// Overwrite loadEntry to loadUnpublishedEntry
|
||||
returnObj.loadEntry = (collection, slug) =>
|
||||
dispatch(loadUnpublishedEntry(collection, slug));
|
||||
returnObj.loadEntry = (collection, slug) => dispatch(loadUnpublishedEntry(collection, slug));
|
||||
|
||||
// Overwrite persistEntry to persistUnpublishedEntry
|
||||
returnObj.persistEntry = collection =>
|
||||
@ -47,12 +46,15 @@ function mergeProps(stateProps, dispatchProps, ownProps) {
|
||||
}
|
||||
|
||||
export default function withWorkflow(Editor) {
|
||||
return connect(mapStateToProps, null, mergeProps)(
|
||||
return connect(
|
||||
mapStateToProps,
|
||||
null,
|
||||
mergeProps,
|
||||
)(
|
||||
class WorkflowEditor extends React.Component {
|
||||
render() {
|
||||
return <Editor {...this.props} />;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -2,7 +2,12 @@ import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
export default function UnknownPreview({ field }) {
|
||||
return <div className='nc-widgetPreview'>No preview for widget “{field.get('widget')}”.</div>;
|
||||
return (
|
||||
<div className="nc-widgetPreview">
|
||||
No preview for widget “{field.get('widget')}
|
||||
”.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
UnknownPreview.propTypes = {
|
||||
|
@ -3,14 +3,14 @@ import PropTypes from 'prop-types';
|
||||
import styled from 'react-emotion';
|
||||
import { colors } from 'netlify-cms-ui-default';
|
||||
|
||||
const EmptyMessageContainer= styled.div`
|
||||
const EmptyMessageContainer = styled.div`
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: ${props => props.isPrivate && colors.textFieldBorder};
|
||||
`
|
||||
`;
|
||||
|
||||
const EmptyMessage = ({ content, isPrivate }) => (
|
||||
<EmptyMessageContainer isPrivate={isPrivate}>
|
||||
|
@ -16,11 +16,10 @@ import MediaLibraryModal from './MediaLibraryModal';
|
||||
* Extensions used to determine which files to show when the media library is
|
||||
* accessed from an image insertion field.
|
||||
*/
|
||||
const IMAGE_EXTENSIONS_VIEWABLE = [ 'jpg', 'jpeg', 'webp', 'gif', 'png', 'bmp', 'tiff', 'svg' ];
|
||||
const IMAGE_EXTENSIONS = [ ...IMAGE_EXTENSIONS_VIEWABLE ];
|
||||
const IMAGE_EXTENSIONS_VIEWABLE = ['jpg', 'jpeg', 'webp', 'gif', 'png', 'bmp', 'tiff', 'svg'];
|
||||
const IMAGE_EXTENSIONS = [...IMAGE_EXTENSIONS_VIEWABLE];
|
||||
|
||||
class MediaLibrary extends React.Component {
|
||||
|
||||
/**
|
||||
* The currently selected file and query are tracked in component state as
|
||||
* they do not impact the rest of the application.
|
||||
@ -49,7 +48,7 @@ class MediaLibrary extends React.Component {
|
||||
componentDidUpdate(prevProps) {
|
||||
const isOpening = !prevProps.isVisible && this.props.isVisible;
|
||||
|
||||
if (isOpening && (prevProps.privateUpload !== this.props.privateUpload)) {
|
||||
if (isOpening && prevProps.privateUpload !== this.props.privateUpload) {
|
||||
this.props.loadMedia({ privateUpload: this.props.privateUpload });
|
||||
}
|
||||
}
|
||||
@ -68,20 +67,22 @@ class MediaLibrary extends React.Component {
|
||||
* Transform file data for table display.
|
||||
*/
|
||||
toTableData = files => {
|
||||
const tableData = files && files.map(({ key, name, size, queryOrder, url, urlIsPublicPath }) => {
|
||||
const ext = fileExtension(name).toLowerCase();
|
||||
return {
|
||||
key,
|
||||
name,
|
||||
type: ext.toUpperCase(),
|
||||
size,
|
||||
queryOrder,
|
||||
url,
|
||||
urlIsPublicPath,
|
||||
isImage: IMAGE_EXTENSIONS.includes(ext),
|
||||
isViewableImage: IMAGE_EXTENSIONS_VIEWABLE.includes(ext),
|
||||
};
|
||||
});
|
||||
const tableData =
|
||||
files &&
|
||||
files.map(({ key, name, size, queryOrder, url, urlIsPublicPath }) => {
|
||||
const ext = fileExtension(name).toLowerCase();
|
||||
return {
|
||||
key,
|
||||
name,
|
||||
type: ext.toUpperCase(),
|
||||
size,
|
||||
queryOrder,
|
||||
url,
|
||||
urlIsPublicPath,
|
||||
isImage: IMAGE_EXTENSIONS.includes(ext),
|
||||
isViewableImage: IMAGE_EXTENSIONS_VIEWABLE.includes(ext),
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the sort order for use with `lodash.orderBy`, and always add the
|
||||
@ -152,10 +153,9 @@ class MediaLibrary extends React.Component {
|
||||
return;
|
||||
}
|
||||
const file = files.find(file => selectedFile.key === file.key);
|
||||
deleteMedia(file, { privateUpload })
|
||||
.then(() => {
|
||||
this.setState({ selectedFile: {} });
|
||||
});
|
||||
deleteMedia(file, { privateUpload }).then(() => {
|
||||
this.setState({ selectedFile: {} });
|
||||
});
|
||||
};
|
||||
|
||||
handleLoadMore = () => {
|
||||
@ -170,17 +170,17 @@ class MediaLibrary extends React.Component {
|
||||
* the GitHub backend, search is in-memory and occurs as the query is typed,
|
||||
* so this handler has no impact.
|
||||
*/
|
||||
handleSearchKeyDown = async (event) => {
|
||||
handleSearchKeyDown = async event => {
|
||||
const { dynamicSearch, loadMedia, privateUpload } = this.props;
|
||||
if (event.key === 'Enter' && dynamicSearch) {
|
||||
await loadMedia({ query: this.state.query, privateUpload })
|
||||
await loadMedia({ query: this.state.query, privateUpload });
|
||||
this.scrollToTop();
|
||||
}
|
||||
};
|
||||
|
||||
scrollToTop = () => {
|
||||
this.scrollContainerRef.scrollTop = 0;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates query state as the user types in the search field.
|
||||
@ -248,7 +248,7 @@ class MediaLibrary extends React.Component {
|
||||
handlePersist={this.handlePersist}
|
||||
handleDelete={this.handleDelete}
|
||||
handleInsert={this.handleInsert}
|
||||
setScrollContainerRef={ref => this.scrollContainerRef = ref}
|
||||
setScrollContainerRef={ref => (this.scrollContainerRef = ref)}
|
||||
handleAssetClick={this.handleAssetClick}
|
||||
handleLoadMore={this.handleLoadMore}
|
||||
/>
|
||||
@ -288,4 +288,7 @@ const mapDispatchToProps = {
|
||||
closeMediaLibrary: closeMediaLibraryAction,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(MediaLibrary);
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
)(MediaLibrary);
|
||||
|
@ -17,11 +17,11 @@ const styles = {
|
||||
cursor: default;
|
||||
}
|
||||
`,
|
||||
}
|
||||
};
|
||||
|
||||
const ActionsContainer = styled.div`
|
||||
text-align: right;
|
||||
`
|
||||
`;
|
||||
|
||||
const StyledUploadButton = styled(FileUploadButton)`
|
||||
${styles.button};
|
||||
@ -38,8 +38,8 @@ const StyledUploadButton = styled(FileUploadButton)`
|
||||
}
|
||||
|
||||
input {
|
||||
height: .1px;
|
||||
width: .1px;
|
||||
height: 0.1px;
|
||||
width: 0.1px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
@ -48,21 +48,21 @@ const StyledUploadButton = styled(FileUploadButton)`
|
||||
z-index: 0;
|
||||
outline: none;
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
const DeleteButton = styled.button`
|
||||
${styles.button};
|
||||
${buttons.lightRed};
|
||||
`
|
||||
`;
|
||||
|
||||
const InsertButton = styled.button`
|
||||
${styles.button};
|
||||
${buttons.green};
|
||||
`
|
||||
`;
|
||||
|
||||
const LowerActionsContainer = styled.div`
|
||||
margin-top: 30px;
|
||||
`
|
||||
`;
|
||||
|
||||
const MediaLibraryActions = ({
|
||||
uploadButtonLabel,
|
||||
@ -88,11 +88,11 @@ const MediaLibraryActions = ({
|
||||
<DeleteButton onClick={onDelete} disabled={!deleteEnabled}>
|
||||
{deleteButtonLabel}
|
||||
</DeleteButton>
|
||||
{ !insertVisible ? null :
|
||||
{!insertVisible ? null : (
|
||||
<InsertButton onClick={onInsert} disabled={!insertEnabled}>
|
||||
{insertButtonLabel}
|
||||
</InsertButton>
|
||||
}
|
||||
)}
|
||||
</LowerActionsContainer>
|
||||
</ActionsContainer>
|
||||
);
|
||||
|
@ -17,14 +17,14 @@ const Card = styled.div`
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
const CardImage = styled.img`
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
object-fit: cover;
|
||||
border-radius: 2px 2px 0 0;
|
||||
`
|
||||
`;
|
||||
|
||||
const CardImagePlaceholder = CardImage.withComponent(`div`);
|
||||
|
||||
@ -34,7 +34,7 @@ const CardText = styled.p`
|
||||
margin-top: 20px;
|
||||
overflow-wrap: break-word;
|
||||
line-height: 1.3 !important;
|
||||
`
|
||||
`;
|
||||
|
||||
const MediaLibraryCard = ({ isSelected, imageUrl, text, onClick, width, margin, isPrivate }) => (
|
||||
<Card
|
||||
@ -45,9 +45,7 @@ const MediaLibraryCard = ({ isSelected, imageUrl, text, onClick, width, margin,
|
||||
tabIndex="-1"
|
||||
isPrivate={isPrivate}
|
||||
>
|
||||
<div>
|
||||
{ imageUrl ? <CardImage src={imageUrl}/> : <CardImagePlaceholder/> }
|
||||
</div>
|
||||
<div>{imageUrl ? <CardImage src={imageUrl} /> : <CardImagePlaceholder />}</div>
|
||||
<CardText>{text}</CardText>
|
||||
</Card>
|
||||
);
|
||||
|
@ -1,25 +1,25 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styled from 'react-emotion'
|
||||
import styled from 'react-emotion';
|
||||
import Waypoint from 'react-waypoint';
|
||||
import MediaLibraryCard from './MediaLibraryCard';
|
||||
import { colors } from 'netlify-cms-ui-default';
|
||||
|
||||
const CardGridContainer = styled.div`
|
||||
overflow-y: auto;
|
||||
`
|
||||
`;
|
||||
|
||||
const CardGrid = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
margin-left: -10px;
|
||||
margin-left: -10px;
|
||||
margin-right: -10px;
|
||||
`
|
||||
`;
|
||||
|
||||
const PaginatingMessage = styled.h1`
|
||||
color: ${props => props.isPrivate && colors.textFieldBorder};
|
||||
`
|
||||
`;
|
||||
|
||||
const MediaLibraryCardGrid = ({
|
||||
setScrollContainerRef,
|
||||
@ -36,38 +36,36 @@ const MediaLibraryCardGrid = ({
|
||||
}) => (
|
||||
<CardGridContainer innerRef={setScrollContainerRef}>
|
||||
<CardGrid>
|
||||
{
|
||||
mediaItems.map(file =>
|
||||
<MediaLibraryCard
|
||||
key={file.key}
|
||||
isSelected={isSelectedFile(file)}
|
||||
imageUrl={file.isViewableImage && file.url}
|
||||
text={file.name}
|
||||
onClick={() => onAssetClick(file)}
|
||||
width={cardWidth}
|
||||
margin={cardMargin}
|
||||
isPrivate={isPrivate}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{!canLoadMore ? null : <Waypoint onEnter={onLoadMore}/>}
|
||||
{mediaItems.map(file => (
|
||||
<MediaLibraryCard
|
||||
key={file.key}
|
||||
isSelected={isSelectedFile(file)}
|
||||
imageUrl={file.isViewableImage && file.url}
|
||||
text={file.name}
|
||||
onClick={() => onAssetClick(file)}
|
||||
width={cardWidth}
|
||||
margin={cardMargin}
|
||||
isPrivate={isPrivate}
|
||||
/>
|
||||
))}
|
||||
{!canLoadMore ? null : <Waypoint onEnter={onLoadMore} />}
|
||||
</CardGrid>
|
||||
{
|
||||
!isPaginating ? null :
|
||||
<PaginatingMessage isPrivate={isPrivate}>{paginatingMessage}</PaginatingMessage>
|
||||
}
|
||||
|
||||
{!isPaginating ? null : (
|
||||
<PaginatingMessage isPrivate={isPrivate}>{paginatingMessage}</PaginatingMessage>
|
||||
)}
|
||||
</CardGridContainer>
|
||||
);
|
||||
|
||||
MediaLibraryCardGrid.propTypes = {
|
||||
setScrollContainerRef: PropTypes.func.isRequired,
|
||||
mediaItems: PropTypes.arrayOf(PropTypes.shape({
|
||||
key: PropTypes.string.isRequired,
|
||||
isViewableImage: PropTypes.bool,
|
||||
url: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
})).isRequired,
|
||||
mediaItems: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
key: PropTypes.string.isRequired,
|
||||
isViewableImage: PropTypes.bool,
|
||||
url: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
}),
|
||||
).isRequired,
|
||||
isSelectedFile: PropTypes.func.isRequired,
|
||||
onAssetClick: PropTypes.func.isRequired,
|
||||
canLoadMore: PropTypes.bool,
|
||||
|
@ -17,7 +17,7 @@ const CloseButton = styled.button`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`
|
||||
`;
|
||||
|
||||
const LibraryTitle = styled.h1`
|
||||
line-height: 36px;
|
||||
@ -25,12 +25,12 @@ const LibraryTitle = styled.h1`
|
||||
text-align: left;
|
||||
margin-bottom: 25px;
|
||||
color: ${props => props.isPrivate && colors.textFieldBorder};
|
||||
`
|
||||
`;
|
||||
|
||||
const MediaLibraryHeader = ({ onClose, title, isPrivate }) => (
|
||||
<div>
|
||||
<CloseButton onClick={onClose}>
|
||||
<Icon type="close"/>
|
||||
<Icon type="close" />
|
||||
</CloseButton>
|
||||
<LibraryTitle isPrivate={isPrivate}>{title}</LibraryTitle>
|
||||
</div>
|
||||
|
@ -27,7 +27,7 @@ const LibraryTop = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
`
|
||||
`;
|
||||
|
||||
const StyledModal = styled(Modal)`
|
||||
display: grid;
|
||||
@ -63,7 +63,7 @@ const StyledModal = styled(Modal)`
|
||||
label[disabled] {
|
||||
background-color: ${props => props.isPrivate && `rgba(217, 217, 217, 0.15)`};
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
const MediaLibraryModal = ({
|
||||
isVisible,
|
||||
@ -94,18 +94,19 @@ const MediaLibraryModal = ({
|
||||
handleLoadMore,
|
||||
}) => {
|
||||
const filteredFiles = forImage ? handleFilter(files) : files;
|
||||
const queriedFiles = (!dynamicSearch && query) ? handleQuery(query, filteredFiles) : filteredFiles;
|
||||
const queriedFiles = !dynamicSearch && query ? handleQuery(query, filteredFiles) : filteredFiles;
|
||||
const tableData = toTableData(queriedFiles);
|
||||
const hasFiles = files && !!files.length;
|
||||
const hasFilteredFiles = filteredFiles && !!filteredFiles.length;
|
||||
const hasSearchResults = queriedFiles && !!queriedFiles.length;
|
||||
const hasMedia = hasSearchResults;
|
||||
const shouldShowEmptyMessage = !hasMedia;
|
||||
const emptyMessage = (isLoading && !hasMedia && 'Loading...')
|
||||
|| (dynamicSearchActive && 'No results.')
|
||||
|| (!hasFiles && 'No assets found.')
|
||||
|| (!hasFilteredFiles && 'No images found.')
|
||||
|| (!hasSearchResults && 'No results.');
|
||||
const emptyMessage =
|
||||
(isLoading && !hasMedia && 'Loading...') ||
|
||||
(dynamicSearchActive && 'No results.') ||
|
||||
(!hasFiles && 'No assets found.') ||
|
||||
(!hasFilteredFiles && 'No images found.') ||
|
||||
(!hasSearchResults && 'No results.');
|
||||
const hasSelection = hasMedia && !isEmpty(selectedFile);
|
||||
const shouldShowButtonLoader = isPersisting || isDeleting;
|
||||
|
||||
@ -140,7 +141,9 @@ const MediaLibraryModal = ({
|
||||
onInsert={handleInsert}
|
||||
/>
|
||||
</LibraryTop>
|
||||
{ !shouldShowEmptyMessage ? null : <EmptyMessage content={emptyMessage} isPrivate={privateUpload}/> }
|
||||
{!shouldShowEmptyMessage ? null : (
|
||||
<EmptyMessage content={emptyMessage} isPrivate={privateUpload} />
|
||||
)}
|
||||
<MediaLibraryCardGrid
|
||||
setScrollContainerRef={setScrollContainerRef}
|
||||
mediaItems={tableData}
|
||||
@ -156,7 +159,7 @@ const MediaLibraryModal = ({
|
||||
/>
|
||||
</StyledModal>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const fileShape = {
|
||||
key: PropTypes.string.isRequired,
|
||||
|
@ -9,7 +9,7 @@ const SearchContainer = styled.div`
|
||||
align-items: center;
|
||||
position: relative;
|
||||
width: 400px;
|
||||
`
|
||||
`;
|
||||
|
||||
const SearchInput = styled.input`
|
||||
background-color: #eff0f4;
|
||||
@ -25,7 +25,7 @@ const SearchInput = styled.input`
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px ${colors.active};
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
const SearchIcon = styled(Icon)`
|
||||
position: absolute;
|
||||
@ -33,11 +33,11 @@ const SearchIcon = styled(Icon)`
|
||||
left: 6px;
|
||||
z-index: 2;
|
||||
transform: translate(0, -50%);
|
||||
`
|
||||
`;
|
||||
|
||||
const MediaLibrarySearch = ({ value, onChange, onKeyDown, placeholder, disabled }) => (
|
||||
<SearchContainer>
|
||||
<SearchIcon type="search" size="small"/>
|
||||
<SearchIcon type="search" size="small" />
|
||||
<SearchInput
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
|
@ -2,7 +2,7 @@ import ReactDNDHTML5Backend from 'react-dnd-html5-backend';
|
||||
import {
|
||||
DragDropContext as ReactDNDDragDropContext,
|
||||
DragSource as ReactDNDDragSource,
|
||||
DropTarget as ReactDNDDropTarget
|
||||
DropTarget as ReactDNDDropTarget,
|
||||
} from 'react-dnd';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
@ -11,7 +11,8 @@ export const DragSource = ({ namespace, ...props }) => {
|
||||
const DragComponent = ReactDNDDragSource(
|
||||
namespace,
|
||||
{
|
||||
beginDrag({ children, isDragging, connectDragComponent, ...ownProps }) { // eslint-disable-line no-unused-vars
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
beginDrag({ children, isDragging, connectDragComponent, ...ownProps }) {
|
||||
// We return the rest of the props as the ID of the element being dragged.
|
||||
return ownProps;
|
||||
},
|
||||
@ -19,9 +20,7 @@ export const DragSource = ({ namespace, ...props }) => {
|
||||
connect => ({
|
||||
connectDragComponent: connect.dragSource(),
|
||||
}),
|
||||
)(
|
||||
({ children, connectDragComponent }) => children(connectDragComponent)
|
||||
);
|
||||
)(({ children, connectDragComponent }) => children(connectDragComponent));
|
||||
|
||||
return React.createElement(DragComponent, props, props.children);
|
||||
};
|
||||
@ -41,9 +40,7 @@ export const DropTarget = ({ onDrop, namespace, ...props }) => {
|
||||
connectDropTarget: connect.dropTarget(),
|
||||
isHovered: monitor.isOver(),
|
||||
}),
|
||||
)(
|
||||
({ children, connectDropTarget, isHovered }) => children(connectDropTarget, { isHovered })
|
||||
);
|
||||
)(({ children, connectDropTarget, isHovered }) => children(connectDropTarget, { isHovered }));
|
||||
|
||||
return React.createElement(DropComponent, props, props.children);
|
||||
};
|
||||
|
@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { css } from 'react-emotion';
|
||||
import { colors } from 'netlify-cms-ui-default';
|
||||
|
||||
const ISSUE_URL = "https://github.com/netlify/netlify-cms/issues/new";
|
||||
const ISSUE_URL = 'https://github.com/netlify/netlify-cms/issues/new';
|
||||
|
||||
const styles = {
|
||||
errorBoundary: css`
|
||||
@ -34,7 +34,15 @@ export class ErrorBoundary extends React.Component {
|
||||
<h1 className={styles.errorBoundaryText}>Sorry!</h1>
|
||||
<p>
|
||||
<span>{"There's been an error - please "}</span>
|
||||
<a href={ISSUE_URL} target="_blank" rel="noopener noreferrer" className={styles.errorBoundaryText}>report it</a>!
|
||||
<a
|
||||
href={ISSUE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.errorBoundaryText}
|
||||
>
|
||||
report it
|
||||
</a>
|
||||
!
|
||||
</p>
|
||||
<p>{errorMessage}</p>
|
||||
</div>
|
||||
|
@ -46,8 +46,7 @@ const styles = {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
opacity: 0;
|
||||
`,
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
export class Modal extends React.Component {
|
||||
static propTypes = {
|
||||
@ -55,7 +54,7 @@ export class Modal extends React.Component {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
className: PropTypes.string,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
}
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
ReactModal.setAppElement('#nc-root');
|
||||
|
@ -16,54 +16,52 @@ const AppHeaderAvatar = styled.button`
|
||||
cursor: pointer;
|
||||
color: #1e2532;
|
||||
background-color: transparent;
|
||||
`
|
||||
`;
|
||||
|
||||
const AvatarImage = styled.img`
|
||||
${styles.avatarImage};
|
||||
`
|
||||
`;
|
||||
|
||||
const AvatarPlaceholderIcon = styled(Icon)`
|
||||
${styles.avatarImage};
|
||||
height: 32px;
|
||||
color: #1e2532;
|
||||
background-color: ${colors.textFieldBorder};
|
||||
`
|
||||
`;
|
||||
|
||||
const AppHeaderSiteLink = styled.a`
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #7b8290;
|
||||
padding: 10px 16px;
|
||||
`
|
||||
`;
|
||||
|
||||
const Avatar = ({ imageUrl }) => (
|
||||
<AppHeaderAvatar>
|
||||
{imageUrl ? <AvatarImage src={imageUrl}/> : <AvatarPlaceholderIcon type="user" size="large"/>}
|
||||
{imageUrl ? <AvatarImage src={imageUrl} /> : <AvatarPlaceholderIcon type="user" size="large" />}
|
||||
</AppHeaderAvatar>
|
||||
);
|
||||
|
||||
const SettingsDropdown = ({ displayUrl, imageUrl, onLogoutClick }) => (
|
||||
<React.Fragment>
|
||||
{
|
||||
displayUrl
|
||||
? <AppHeaderSiteLink href={displayUrl} target="_blank">
|
||||
{stripProtocol(displayUrl)}
|
||||
</AppHeaderSiteLink>
|
||||
: null
|
||||
}
|
||||
{displayUrl ? (
|
||||
<AppHeaderSiteLink href={displayUrl} target="_blank">
|
||||
{stripProtocol(displayUrl)}
|
||||
</AppHeaderSiteLink>
|
||||
) : null}
|
||||
<Dropdown
|
||||
dropdownTopOverlap="50px"
|
||||
dropdownWidth="100px"
|
||||
dropdownPosition="right"
|
||||
renderButton={() => (
|
||||
<DropdownButton>
|
||||
<Avatar imageUrl={imageUrl}/>
|
||||
<Avatar imageUrl={imageUrl} />
|
||||
</DropdownButton>
|
||||
)}
|
||||
>
|
||||
<DropdownItem label="Log Out" onClick={onLogoutClick}/>
|
||||
<DropdownItem label="Log Out" onClick={onLogoutClick} />
|
||||
</Dropdown>
|
||||
</React.Fragment>
|
||||
)
|
||||
);
|
||||
|
||||
export default SettingsDropdown;
|
||||
|
@ -40,10 +40,9 @@ const styles = {
|
||||
`,
|
||||
};
|
||||
|
||||
export const Toast = ({ kind, message }) =>
|
||||
<div className={cx(styles.toast, styles[kind])}>
|
||||
{message}
|
||||
</div>;
|
||||
export const Toast = ({ kind, message }) => (
|
||||
<div className={cx(styles.toast, styles[kind])}>{message}</div>
|
||||
);
|
||||
|
||||
Toast.propTypes = {
|
||||
kind: PropTypes.oneOf(['info', 'success', 'warning', 'danger']).isRequired,
|
||||
|
@ -18,7 +18,7 @@ import {
|
||||
loadUnpublishedEntries,
|
||||
updateUnpublishedEntryStatus,
|
||||
publishUnpublishedEntry,
|
||||
deleteUnpublishedEntry
|
||||
deleteUnpublishedEntry,
|
||||
} from 'Actions/editorialWorkflow';
|
||||
import { selectUnpublishedEntriesByStatus } from 'Reducers';
|
||||
import { EDITORIAL_WORKFLOW, status } from 'Constants/publishModes';
|
||||
@ -27,28 +27,28 @@ import WorkflowList from './WorkflowList';
|
||||
const WorkflowContainer = styled.div`
|
||||
padding: ${lengths.pageMargin} 0;
|
||||
height: 100vh;
|
||||
`
|
||||
`;
|
||||
|
||||
const WorkflowTop = styled.div`
|
||||
${components.cardTop};
|
||||
`
|
||||
`;
|
||||
|
||||
const WorkflowTopRow = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
span[role="button"] {
|
||||
span[role='button'] {
|
||||
${shadows.dropDeep};
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
const WorkflowTopHeading = styled.h1`
|
||||
${components.cardTopHeading};
|
||||
`
|
||||
`;
|
||||
|
||||
const WorkflowTopDescription = styled.p`
|
||||
${components.cardTopDescription};
|
||||
`
|
||||
`;
|
||||
|
||||
class Workflow extends Component {
|
||||
static propTypes = {
|
||||
@ -96,19 +96,21 @@ class Workflow extends Component {
|
||||
dropdownTopOverlap="40px"
|
||||
renderButton={() => <StyledDropdownButton>New Post</StyledDropdownButton>}
|
||||
>
|
||||
{
|
||||
collections.filter(collection => collection.get('create')).toList().map(collection =>
|
||||
{collections
|
||||
.filter(collection => collection.get('create'))
|
||||
.toList()
|
||||
.map(collection => (
|
||||
<DropdownItem
|
||||
key={collection.get("name")}
|
||||
label={collection.get("label")}
|
||||
key={collection.get('name')}
|
||||
label={collection.get('label')}
|
||||
onClick={() => createNewEntry(collection.get('name'))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
))}
|
||||
</Dropdown>
|
||||
</WorkflowTopRow>
|
||||
<WorkflowTopDescription>
|
||||
{reviewCount} {reviewCount === 1 ? 'entry' : 'entries'} waiting for review, {readyCount} ready to go live.
|
||||
{reviewCount} {reviewCount === 1 ? 'entry' : 'entries'} waiting for review, {readyCount}{' '}
|
||||
ready to go live.
|
||||
</WorkflowTopDescription>
|
||||
</WorkflowTop>
|
||||
<WorkflowList
|
||||
@ -124,7 +126,7 @@ class Workflow extends Component {
|
||||
|
||||
function mapStateToProps(state) {
|
||||
const { collections } = state;
|
||||
const isEditorialWorkflow = (state.config.get('publish_mode') === EDITORIAL_WORKFLOW);
|
||||
const isEditorialWorkflow = state.config.get('publish_mode') === EDITORIAL_WORKFLOW;
|
||||
const returnObj = { collections, isEditorialWorkflow };
|
||||
|
||||
if (isEditorialWorkflow) {
|
||||
@ -137,15 +139,18 @@ function mapStateToProps(state) {
|
||||
*/
|
||||
returnObj.unpublishedEntries = status.reduce((acc, currStatus) => {
|
||||
const entries = selectUnpublishedEntriesByStatus(state, currStatus);
|
||||
return acc.set(currStatus, entries)
|
||||
return acc.set(currStatus, entries);
|
||||
}, OrderedMap());
|
||||
}
|
||||
return returnObj;
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, {
|
||||
loadUnpublishedEntries,
|
||||
updateUnpublishedEntryStatus,
|
||||
publishUnpublishedEntry,
|
||||
deleteUnpublishedEntry,
|
||||
})(Workflow);
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
{
|
||||
loadUnpublishedEntries,
|
||||
updateUnpublishedEntryStatus,
|
||||
publishUnpublishedEntry,
|
||||
deleteUnpublishedEntry,
|
||||
},
|
||||
)(Workflow);
|
||||
|
@ -23,7 +23,7 @@ const WorkflowLink = styled(Link)`
|
||||
padding: 0 18px 18px;
|
||||
height: 200px;
|
||||
overflow: hidden;
|
||||
`
|
||||
`;
|
||||
|
||||
const CardCollection = styled.div`
|
||||
font-size: 14px;
|
||||
@ -33,16 +33,16 @@ const CardCollection = styled.div`
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
`
|
||||
`;
|
||||
|
||||
const CardTitle = styled.h2`
|
||||
margin: 28px 0 0;
|
||||
color: ${colors.textLead};
|
||||
`
|
||||
`;
|
||||
|
||||
const CardDate = styled.div`
|
||||
${styles.text};
|
||||
`
|
||||
`;
|
||||
|
||||
const CardBody = styled.p`
|
||||
${styles.text};
|
||||
@ -51,7 +51,7 @@ const CardBody = styled.p`
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
hyphens: auto;
|
||||
`
|
||||
`;
|
||||
|
||||
const CardButtonContainer = styled.div`
|
||||
background-color: ${colors.foreground};
|
||||
@ -63,14 +63,14 @@ const CardButtonContainer = styled.div`
|
||||
opacity: 0;
|
||||
transition: opacity ${transitions.main};
|
||||
cursor: pointer;
|
||||
`
|
||||
`;
|
||||
|
||||
const DeleteButton = styled.button`
|
||||
${styles.button};
|
||||
background-color: ${colorsRaw.redLight};
|
||||
color: ${colorsRaw.red};
|
||||
margin-right: 6px;
|
||||
`
|
||||
`;
|
||||
|
||||
const PublishButton = styled.button`
|
||||
${styles.button};
|
||||
@ -82,7 +82,7 @@ const PublishButton = styled.button`
|
||||
background-color: ${colorsRaw.grayLight};
|
||||
color: ${colorsRaw.gray};
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
const WorkflowCardContainer = styled.div`
|
||||
${components.card};
|
||||
@ -93,7 +93,7 @@ const WorkflowCardContainer = styled.div`
|
||||
&:hover ${CardButtonContainer} {
|
||||
opacity: 1;
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
const WorkflowCard = ({
|
||||
collectionName,
|
||||
@ -111,7 +111,9 @@ const WorkflowCard = ({
|
||||
<WorkflowLink to={editLink}>
|
||||
<CardCollection>{collectionName}</CardCollection>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDate>{timestamp} by {authorLastChange}</CardDate>
|
||||
<CardDate>
|
||||
{timestamp} by {authorLastChange}
|
||||
</CardDate>
|
||||
<CardBody>{body}</CardBody>
|
||||
</WorkflowLink>
|
||||
<CardButtonContainer>
|
||||
|
@ -5,19 +5,19 @@ import styled, { css, cx } from 'react-emotion';
|
||||
import moment from 'moment';
|
||||
import { colors, lengths } from 'netlify-cms-ui-default';
|
||||
import { status } from 'Constants/publishModes';
|
||||
import { DragSource, DropTarget, HTML5DragDrop } from 'UI'
|
||||
import { DragSource, DropTarget, HTML5DragDrop } from 'UI';
|
||||
import WorkflowCard from './WorkflowCard';
|
||||
|
||||
const WorkflowListContainer = styled.div`
|
||||
min-height: 60%;
|
||||
display: grid;
|
||||
grid-template-columns: 33.3% 33.3% 33.3%;
|
||||
`
|
||||
`;
|
||||
|
||||
const styles = {
|
||||
column: css`
|
||||
margin: 0 20px;
|
||||
transition: background-color .5s ease;
|
||||
transition: background-color 0.5s ease;
|
||||
border: 2px dashed transparent;
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
@ -39,7 +39,7 @@ const styles = {
|
||||
width: 2px;
|
||||
height: 80%;
|
||||
top: 76px;
|
||||
background-color: ${colors.textFieldBorder}
|
||||
background-color: ${colors.textFieldBorder};
|
||||
}
|
||||
|
||||
&:before {
|
||||
@ -63,21 +63,27 @@ const ColumnHeader = styled.h2`
|
||||
border-radius: ${lengths.borderRadius};
|
||||
margin-bottom: 28px;
|
||||
|
||||
${props => props.name === 'draft' && css`
|
||||
background-color: ${colors.statusDraftBackground};
|
||||
color: ${colors.statusDraftText};
|
||||
`}
|
||||
${props =>
|
||||
props.name === 'draft' &&
|
||||
css`
|
||||
background-color: ${colors.statusDraftBackground};
|
||||
color: ${colors.statusDraftText};
|
||||
`}
|
||||
|
||||
${props => props.name === 'pending_review' && css`
|
||||
background-color: ${colors.statusReviewBackground};
|
||||
color: ${colors.statusReviewText};
|
||||
`}
|
||||
${props =>
|
||||
props.name === 'pending_review' &&
|
||||
css`
|
||||
background-color: ${colors.statusReviewBackground};
|
||||
color: ${colors.statusReviewText};
|
||||
`}
|
||||
|
||||
${props => props.name === 'pending_publish' && css`
|
||||
background-color: ${colors.statusReadyBackground};
|
||||
color: ${colors.statusReadyText};
|
||||
`}
|
||||
`
|
||||
${props =>
|
||||
props.name === 'pending_publish' &&
|
||||
css`
|
||||
background-color: ${colors.statusReadyBackground};
|
||||
color: ${colors.statusReadyText};
|
||||
`}
|
||||
`;
|
||||
|
||||
const ColumnCount = styled.p`
|
||||
font-size: 13px;
|
||||
@ -85,18 +91,21 @@ const ColumnCount = styled.p`
|
||||
color: ${colors.text};
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 6px;
|
||||
`
|
||||
`;
|
||||
|
||||
// This is a namespace so that we can only drop these elements on a DropTarget with the same
|
||||
const DNDNamespace = 'cms-workflow';
|
||||
|
||||
const getColumnHeaderText = columnName => {
|
||||
switch (columnName) {
|
||||
case 'draft': return 'Drafts';
|
||||
case 'pending_review': return 'In Review';
|
||||
case 'pending_publish': return 'Ready';
|
||||
case 'draft':
|
||||
return 'Drafts';
|
||||
case 'pending_review':
|
||||
return 'In Review';
|
||||
case 'pending_publish':
|
||||
return 'Ready';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
class WorkflowList extends React.Component {
|
||||
static propTypes = {
|
||||
@ -122,9 +131,9 @@ class WorkflowList extends React.Component {
|
||||
requestPublish = (collection, slug, ownStatus) => {
|
||||
if (ownStatus !== status.last()) {
|
||||
window.alert(
|
||||
`Only items with a "Ready" status can be published.
|
||||
`Only items with a "Ready" status can be published.
|
||||
|
||||
Please drag the card to the "Ready" column to enable publishing.`
|
||||
Please drag the card to the "Ready" column to enable publishing.`,
|
||||
);
|
||||
return;
|
||||
} else if (!window.confirm('Are you sure you want to publish this entry?')) {
|
||||
@ -143,66 +152,69 @@ Please drag the card to the "Ready" column to enable publishing.`
|
||||
key={currColumn}
|
||||
onDrop={this.handleChangeStatus.bind(this, currColumn)}
|
||||
>
|
||||
{(connect, { isHovered }) => connect(
|
||||
<div className={cx(styles.column, { [styles.columnHovered]: isHovered })}>
|
||||
<ColumnHeader name={currColumn}>{getColumnHeaderText(currColumn)}</ColumnHeader>
|
||||
<ColumnCount>
|
||||
{currEntries.size} {currEntries.size === 1 ? 'entry' : 'entries'}
|
||||
</ColumnCount>
|
||||
{this.renderColumns(currEntries, currColumn)}
|
||||
</div>
|
||||
)}
|
||||
{(connect, { isHovered }) =>
|
||||
connect(
|
||||
<div className={cx(styles.column, { [styles.columnHovered]: isHovered })}>
|
||||
<ColumnHeader name={currColumn}>{getColumnHeaderText(currColumn)}</ColumnHeader>
|
||||
<ColumnCount>
|
||||
{currEntries.size} {currEntries.size === 1 ? 'entry' : 'entries'}
|
||||
</ColumnCount>
|
||||
{this.renderColumns(currEntries, currColumn)}
|
||||
</div>,
|
||||
)
|
||||
}
|
||||
</DropTarget>
|
||||
));
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
{
|
||||
entries.map((entry) => {
|
||||
const timestamp = moment(entry.getIn(['metaData', 'timeStamp'])).format('MMMM D');
|
||||
const editLink = `collections/${ entry.getIn(['metaData', 'collection']) }/entries/${ entry.get('slug') }`;
|
||||
const slug = entry.get('slug');
|
||||
const ownStatus = entry.getIn(['metaData', 'status']);
|
||||
const collection = entry.getIn(['metaData', 'collection']);
|
||||
const isModification = entry.get('isModification');
|
||||
const canPublish = ownStatus === status.last() && !entry.get('isPersisting', false);
|
||||
return (
|
||||
<DragSource
|
||||
namespace={DNDNamespace}
|
||||
key={slug}
|
||||
slug={slug}
|
||||
collection={collection}
|
||||
ownStatus={ownStatus}
|
||||
>
|
||||
{connect => connect(
|
||||
<div>
|
||||
<WorkflowCard
|
||||
collectionName={collection}
|
||||
title={entry.getIn(['data', 'title'])}
|
||||
authorLastChange={entry.getIn(['metaData', 'user'])}
|
||||
body={entry.getIn(['data', 'body'])}
|
||||
isModification={isModification}
|
||||
editLink={editLink}
|
||||
timestamp={timestamp}
|
||||
onDelete={this.requestDelete.bind(this, collection, slug, ownStatus)}
|
||||
canPublish={canPublish}
|
||||
onPublish={this.requestPublish.bind(this, collection, slug, ownStatus)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DragSource>
|
||||
);
|
||||
})
|
||||
}
|
||||
{entries.map(entry => {
|
||||
const timestamp = moment(entry.getIn(['metaData', 'timeStamp'])).format('MMMM D');
|
||||
const editLink = `collections/${entry.getIn([
|
||||
'metaData',
|
||||
'collection',
|
||||
])}/entries/${entry.get('slug')}`;
|
||||
const slug = entry.get('slug');
|
||||
const ownStatus = entry.getIn(['metaData', 'status']);
|
||||
const collection = entry.getIn(['metaData', 'collection']);
|
||||
const isModification = entry.get('isModification');
|
||||
const canPublish = ownStatus === status.last() && !entry.get('isPersisting', false);
|
||||
return (
|
||||
<DragSource
|
||||
namespace={DNDNamespace}
|
||||
key={slug}
|
||||
slug={slug}
|
||||
collection={collection}
|
||||
ownStatus={ownStatus}
|
||||
>
|
||||
{connect =>
|
||||
connect(
|
||||
<div>
|
||||
<WorkflowCard
|
||||
collectionName={collection}
|
||||
title={entry.getIn(['data', 'title'])}
|
||||
authorLastChange={entry.getIn(['metaData', 'user'])}
|
||||
body={entry.getIn(['data', 'body'])}
|
||||
isModification={isModification}
|
||||
editLink={editLink}
|
||||
timestamp={timestamp}
|
||||
onDelete={this.requestDelete.bind(this, collection, slug, ownStatus)}
|
||||
canPublish={canPublish}
|
||||
onPublish={this.requestPublish.bind(this, collection, slug, ownStatus)}
|
||||
/>
|
||||
</div>,
|
||||
)
|
||||
}
|
||||
</DragSource>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const columns = this.renderColumns(this.props.entries);
|
||||
return (
|
||||
<WorkflowListContainer>{columns}</WorkflowListContainer>
|
||||
);
|
||||
return <WorkflowListContainer>{columns}</WorkflowListContainer>;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -6,8 +6,8 @@ describe('config', () => {
|
||||
* log test failures and associated errors as expected.
|
||||
*/
|
||||
beforeEach(() => {
|
||||
spyOn(console, 'error')
|
||||
})
|
||||
jest.spyOn(console, 'error');
|
||||
});
|
||||
|
||||
describe('validateConfig', () => {
|
||||
it('should not throw if no errors', () => {
|
||||
@ -15,12 +15,14 @@ describe('config', () => {
|
||||
foo: 'bar',
|
||||
backend: { name: 'bar' },
|
||||
media_folder: 'baz',
|
||||
collections: [{
|
||||
name: 'posts',
|
||||
label: 'Posts',
|
||||
folder: '_posts',
|
||||
fields: [{ name: 'title', label: 'title', widget: 'string' }],
|
||||
}],
|
||||
collections: [
|
||||
{
|
||||
name: 'posts',
|
||||
label: 'Posts',
|
||||
folder: '_posts',
|
||||
fields: [{ name: 'title', label: 'title', widget: 'string' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(() => {
|
||||
validateConfig(config);
|
||||
@ -41,7 +43,7 @@ describe('config', () => {
|
||||
|
||||
it('should throw if backend name is not a string in config', () => {
|
||||
expect(() => {
|
||||
validateConfig({ foo: 'bar', backend: { name: { } } });
|
||||
validateConfig({ foo: 'bar', backend: { name: {} } });
|
||||
}).toThrowError("'backend.name' should be string");
|
||||
});
|
||||
|
||||
@ -65,20 +67,35 @@ describe('config', () => {
|
||||
|
||||
it('should throw if collections not an array in config', () => {
|
||||
expect(() => {
|
||||
validateConfig({ foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz', collections: {} });
|
||||
validateConfig({
|
||||
foo: 'bar',
|
||||
backend: { name: 'bar' },
|
||||
media_folder: 'baz',
|
||||
collections: {},
|
||||
});
|
||||
}).toThrowError("'collections' should be array");
|
||||
});
|
||||
|
||||
it('should throw if collections is an empty array in config', () => {
|
||||
expect(() => {
|
||||
validateConfig({ foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz', collections: [] });
|
||||
validateConfig({
|
||||
foo: 'bar',
|
||||
backend: { name: 'bar' },
|
||||
media_folder: 'baz',
|
||||
collections: [],
|
||||
});
|
||||
}).toThrowError("'collections' should NOT have less than 1 items");
|
||||
});
|
||||
|
||||
it('should throw if collections is an array with a single null element in config', () => {
|
||||
expect(() => {
|
||||
validateConfig({ foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz', collections: [null] });
|
||||
validateConfig({
|
||||
foo: 'bar',
|
||||
backend: { name: 'bar' },
|
||||
media_folder: 'baz',
|
||||
collections: [null],
|
||||
});
|
||||
}).toThrowError("'collections[0]' should be object");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,28 +1,24 @@
|
||||
import AJV from 'ajv';
|
||||
import ajvErrors from 'ajv-errors';
|
||||
import {
|
||||
formatExtensions,
|
||||
frontmatterFormats,
|
||||
extensionFormatters,
|
||||
} from "Formats/formats";
|
||||
import { IDENTIFIER_FIELDS } from "Constants/fieldInference";
|
||||
import { formatExtensions, frontmatterFormats, extensionFormatters } from 'Formats/formats';
|
||||
import { IDENTIFIER_FIELDS } from 'Constants/fieldInference';
|
||||
|
||||
/**
|
||||
* Config for fields in both file and folder collections.
|
||||
*/
|
||||
const fieldsConfig = {
|
||||
type: "array",
|
||||
type: 'array',
|
||||
minItems: 1,
|
||||
items: {
|
||||
// ------- Each field: -------
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
label: { type: "string" },
|
||||
widget: { type: "string" },
|
||||
required: { type: "boolean" },
|
||||
name: { type: 'string' },
|
||||
label: { type: 'string' },
|
||||
widget: { type: 'string' },
|
||||
required: { type: 'boolean' },
|
||||
},
|
||||
required: ["name"],
|
||||
required: ['name'],
|
||||
},
|
||||
};
|
||||
|
||||
@ -32,72 +28,72 @@ const fieldsConfig = {
|
||||
* where the imports get resolved asyncronously.
|
||||
*/
|
||||
const getConfigSchema = () => ({
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
backend: {
|
||||
type: "object",
|
||||
properties: { name: { type: "string", examples: ["test-repo"] } },
|
||||
required: ["name"],
|
||||
type: 'object',
|
||||
properties: { name: { type: 'string', examples: ['test-repo'] } },
|
||||
required: ['name'],
|
||||
},
|
||||
display_url: { type: "string", examples: ["https://example.com"] },
|
||||
media_folder: { type: "string", examples: ["assets/uploads"] },
|
||||
public_folder: { type: "string", examples: ["/uploads"] },
|
||||
display_url: { type: 'string', examples: ['https://example.com'] },
|
||||
media_folder: { type: 'string', examples: ['assets/uploads'] },
|
||||
public_folder: { type: 'string', examples: ['/uploads'] },
|
||||
publish_mode: {
|
||||
type: "string",
|
||||
enum: ["editorial_workflow"],
|
||||
examples: ["editorial_workflow"],
|
||||
type: 'string',
|
||||
enum: ['editorial_workflow'],
|
||||
examples: ['editorial_workflow'],
|
||||
},
|
||||
slug: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
encoding: { type: "string", enum: ["unicode", "ascii"] },
|
||||
clean_accents: { type: "boolean" },
|
||||
encoding: { type: 'string', enum: ['unicode', 'ascii'] },
|
||||
clean_accents: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
collections: {
|
||||
type: "array",
|
||||
type: 'array',
|
||||
minItems: 1,
|
||||
items: {
|
||||
// ------- Each collection: -------
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
label: { type: "string" },
|
||||
label_singular: { type: "string" },
|
||||
description: { type: "string" },
|
||||
folder: { type: "string" },
|
||||
name: { type: 'string' },
|
||||
label: { type: 'string' },
|
||||
label_singular: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
folder: { type: 'string' },
|
||||
files: {
|
||||
type: "array",
|
||||
type: 'array',
|
||||
items: {
|
||||
// ------- Each file: -------
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
label: { type: "string" },
|
||||
label_singular: { type: "string" },
|
||||
description: { type: "string" },
|
||||
file: { type: "string" },
|
||||
name: { type: 'string' },
|
||||
label: { type: 'string' },
|
||||
label_singular: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
file: { type: 'string' },
|
||||
fields: fieldsConfig,
|
||||
},
|
||||
required: ["name", "label", "file", "fields"],
|
||||
required: ['name', 'label', 'file', 'fields'],
|
||||
},
|
||||
},
|
||||
slug: { type: "string" },
|
||||
create: { type: "boolean" },
|
||||
slug: { type: 'string' },
|
||||
create: { type: 'boolean' },
|
||||
editor: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
preview: { type: "boolean" },
|
||||
preview: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
format: { type: "string", enum: Object.keys(formatExtensions) },
|
||||
extension: { type: "string" },
|
||||
frontmatter_delimiter: { type: "string" },
|
||||
format: { type: 'string', enum: Object.keys(formatExtensions) },
|
||||
extension: { type: 'string' },
|
||||
frontmatter_delimiter: { type: 'string' },
|
||||
fields: fieldsConfig,
|
||||
},
|
||||
required: ["name", "label"],
|
||||
oneOf: [{ required: ["files"] }, { required: ["folder", "fields"] }],
|
||||
if: { required: ["extension"] },
|
||||
required: ['name', 'label'],
|
||||
oneOf: [{ required: ['files'] }, { required: ['folder', 'fields'] }],
|
||||
if: { required: ['extension'] },
|
||||
then: {
|
||||
// Cannot infer format from extension.
|
||||
if: {
|
||||
@ -105,14 +101,14 @@ const getConfigSchema = () => ({
|
||||
extension: { enum: Object.keys(extensionFormatters) },
|
||||
},
|
||||
},
|
||||
else: { required: ["format"] },
|
||||
else: { required: ['format'] },
|
||||
},
|
||||
dependencies: {
|
||||
frontmatter_delimiter: {
|
||||
properties: {
|
||||
format: { enum: frontmatterFormats },
|
||||
},
|
||||
required: ["format"],
|
||||
required: ['format'],
|
||||
},
|
||||
folder: {
|
||||
errorMessage: {
|
||||
@ -132,7 +128,7 @@ const getConfigSchema = () => ({
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["backend", "media_folder", "collections"],
|
||||
required: ['backend', 'media_folder', 'collections'],
|
||||
});
|
||||
|
||||
class ConfigError extends Error {
|
||||
@ -141,13 +137,13 @@ class ConfigError extends Error {
|
||||
.map(({ message, dataPath }) => {
|
||||
const dotPath = dataPath
|
||||
.slice(1)
|
||||
.split("/")
|
||||
.split('/')
|
||||
.map(seg => (seg.match(/^\d+$/) ? `[${seg}]` : `.${seg}`))
|
||||
.join("")
|
||||
.join('')
|
||||
.slice(1);
|
||||
return `${dotPath ? `'${dotPath}'` : "config"} ${message}`;
|
||||
return `${dotPath ? `'${dotPath}'` : 'config'} ${message}`;
|
||||
})
|
||||
.join("\n");
|
||||
.join('\n');
|
||||
super(message, ...args);
|
||||
|
||||
this.errors = errors;
|
||||
@ -161,7 +157,7 @@ class ConfigError extends Error {
|
||||
|
||||
/**
|
||||
* `validateConfig` is a pure function. It does not mutate
|
||||
* the config that is passed in.
|
||||
* the config that is passed in.
|
||||
*/
|
||||
export function validateConfig(config) {
|
||||
const ajv = new AJV({ allErrors: true, jsonPointers: true });
|
||||
|
@ -7,7 +7,7 @@ export const INFERABLE_FIELDS = {
|
||||
type: 'string',
|
||||
secondaryTypes: [],
|
||||
synonyms: ['title', 'name', 'label', 'headline', 'header'],
|
||||
defaultPreview: value => <h1>{ value }</h1>, // eslint-disable-line react/display-name
|
||||
defaultPreview: value => <h1>{value}</h1>, // eslint-disable-line react/display-name
|
||||
fallbackToFirstField: true,
|
||||
showError: true,
|
||||
},
|
||||
@ -15,7 +15,7 @@ export const INFERABLE_FIELDS = {
|
||||
type: 'string',
|
||||
secondaryTypes: [],
|
||||
synonyms: ['short_title', 'shortTitle', 'short'],
|
||||
defaultPreview: value => <h2>{ value }</h2>, // eslint-disable-line react/display-name
|
||||
defaultPreview: value => <h2>{value}</h2>, // eslint-disable-line react/display-name
|
||||
fallbackToFirstField: false,
|
||||
showError: false,
|
||||
},
|
||||
@ -23,14 +23,26 @@ export const INFERABLE_FIELDS = {
|
||||
type: 'string',
|
||||
secondaryTypes: [],
|
||||
synonyms: ['author', 'name', 'by', 'byline', 'owner'],
|
||||
defaultPreview: value => <strong>{ value }</strong>, // eslint-disable-line react/display-name
|
||||
defaultPreview: value => <strong>{value}</strong>, // eslint-disable-line react/display-name
|
||||
fallbackToFirstField: false,
|
||||
showError: false,
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
secondaryTypes: ['text', 'markdown'],
|
||||
synonyms: ['shortDescription', 'short_description', 'shortdescription', 'description', 'intro', 'introduction', 'brief', 'content', 'biography', 'bio', 'summary'],
|
||||
synonyms: [
|
||||
'shortDescription',
|
||||
'short_description',
|
||||
'shortdescription',
|
||||
'description',
|
||||
'intro',
|
||||
'introduction',
|
||||
'brief',
|
||||
'content',
|
||||
'biography',
|
||||
'bio',
|
||||
'summary',
|
||||
],
|
||||
defaultPreview: value => value,
|
||||
fallbackToFirstField: false,
|
||||
showError: false,
|
||||
|
@ -1,166 +1,167 @@
|
||||
import { FrontmatterInfer, frontmatterJSON, frontmatterTOML, frontmatterYAML } from '../frontmatter';
|
||||
import {
|
||||
FrontmatterInfer,
|
||||
frontmatterJSON,
|
||||
frontmatterTOML,
|
||||
frontmatterYAML,
|
||||
} from '../frontmatter';
|
||||
|
||||
jest.mock("../../valueObjects/AssetProxy.js");
|
||||
jest.mock('../../valueObjects/AssetProxy.js');
|
||||
|
||||
describe('Frontmatter', () => {
|
||||
it('should parse YAML with --- delimiters', () => {
|
||||
expect(
|
||||
FrontmatterInfer.fromFile('---\ntitle: YAML\ndescription: Something longer\n---\nContent')
|
||||
).toEqual(
|
||||
{
|
||||
title: 'YAML',
|
||||
description: 'Something longer',
|
||||
body: 'Content',
|
||||
}
|
||||
);
|
||||
FrontmatterInfer.fromFile('---\ntitle: YAML\ndescription: Something longer\n---\nContent'),
|
||||
).toEqual({
|
||||
title: 'YAML',
|
||||
description: 'Something longer',
|
||||
body: 'Content',
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse YAML with --- delimiters when it is explicitly set as the format without a custom delimiter', () => {
|
||||
expect(
|
||||
frontmatterYAML().fromFile('---\ntitle: YAML\ndescription: Something longer\n---\nContent')
|
||||
).toEqual(
|
||||
{
|
||||
title: 'YAML',
|
||||
description: 'Something longer',
|
||||
body: 'Content',
|
||||
}
|
||||
);
|
||||
frontmatterYAML().fromFile('---\ntitle: YAML\ndescription: Something longer\n---\nContent'),
|
||||
).toEqual({
|
||||
title: 'YAML',
|
||||
description: 'Something longer',
|
||||
body: 'Content',
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse YAML with custom delimiters when it is explicitly set as the format with a custom delimiter', () => {
|
||||
expect(
|
||||
frontmatterYAML("~~~").fromFile('~~~\ntitle: YAML\ndescription: Something longer\n~~~\nContent')
|
||||
).toEqual(
|
||||
{
|
||||
title: 'YAML',
|
||||
description: 'Something longer',
|
||||
body: 'Content',
|
||||
}
|
||||
);
|
||||
frontmatterYAML('~~~').fromFile(
|
||||
'~~~\ntitle: YAML\ndescription: Something longer\n~~~\nContent',
|
||||
),
|
||||
).toEqual({
|
||||
title: 'YAML',
|
||||
description: 'Something longer',
|
||||
body: 'Content',
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse YAML with custom delimiters when it is explicitly set as the format with different custom delimiters', () => {
|
||||
expect(
|
||||
frontmatterYAML(["~~~", "^^^"]).fromFile('~~~\ntitle: YAML\ndescription: Something longer\n^^^\nContent')
|
||||
).toEqual(
|
||||
{
|
||||
title: 'YAML',
|
||||
description: 'Something longer',
|
||||
body: 'Content',
|
||||
}
|
||||
);
|
||||
frontmatterYAML(['~~~', '^^^']).fromFile(
|
||||
'~~~\ntitle: YAML\ndescription: Something longer\n^^^\nContent',
|
||||
),
|
||||
).toEqual({
|
||||
title: 'YAML',
|
||||
description: 'Something longer',
|
||||
body: 'Content',
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse YAML with ---yaml delimiters', () => {
|
||||
expect(
|
||||
FrontmatterInfer.fromFile('---yaml\ntitle: YAML\ndescription: Something longer\n---\nContent')
|
||||
).toEqual(
|
||||
{
|
||||
title: 'YAML',
|
||||
description: 'Something longer',
|
||||
body: 'Content',
|
||||
}
|
||||
);
|
||||
FrontmatterInfer.fromFile(
|
||||
'---yaml\ntitle: YAML\ndescription: Something longer\n---\nContent',
|
||||
),
|
||||
).toEqual({
|
||||
title: 'YAML',
|
||||
description: 'Something longer',
|
||||
body: 'Content',
|
||||
});
|
||||
});
|
||||
|
||||
it('should overwrite any body param in the front matter', () => {
|
||||
expect(
|
||||
FrontmatterInfer.fromFile('---\ntitle: The Title\nbody: Something longer\n---\nContent')
|
||||
).toEqual(
|
||||
{
|
||||
title: 'The Title',
|
||||
body: 'Content',
|
||||
}
|
||||
);
|
||||
FrontmatterInfer.fromFile('---\ntitle: The Title\nbody: Something longer\n---\nContent'),
|
||||
).toEqual({
|
||||
title: 'The Title',
|
||||
body: 'Content',
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse TOML with +++ delimiters', () => {
|
||||
expect(
|
||||
FrontmatterInfer.fromFile('+++\ntitle = "TOML"\ndescription = "Front matter"\n+++\nContent')
|
||||
).toEqual(
|
||||
{
|
||||
title: 'TOML',
|
||||
description: 'Front matter',
|
||||
body: 'Content',
|
||||
}
|
||||
);
|
||||
FrontmatterInfer.fromFile('+++\ntitle = "TOML"\ndescription = "Front matter"\n+++\nContent'),
|
||||
).toEqual({
|
||||
title: 'TOML',
|
||||
description: 'Front matter',
|
||||
body: 'Content',
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse TOML with +++ delimiters when it is explicitly set as the format without a custom delimiter', () => {
|
||||
expect(
|
||||
frontmatterTOML("~~~").fromFile('~~~\ntitle = "TOML"\ndescription = "Front matter"\n~~~\nContent')
|
||||
).toEqual(
|
||||
{
|
||||
title: 'TOML',
|
||||
description: 'Front matter',
|
||||
body: 'Content',
|
||||
}
|
||||
);
|
||||
frontmatterTOML('~~~').fromFile(
|
||||
'~~~\ntitle = "TOML"\ndescription = "Front matter"\n~~~\nContent',
|
||||
),
|
||||
).toEqual({
|
||||
title: 'TOML',
|
||||
description: 'Front matter',
|
||||
body: 'Content',
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse TOML with ---toml delimiters', () => {
|
||||
expect(
|
||||
FrontmatterInfer.fromFile('---toml\ntitle = "TOML"\ndescription = "Something longer"\n---\nContent')
|
||||
).toEqual(
|
||||
{
|
||||
title: 'TOML',
|
||||
description: 'Something longer',
|
||||
body: 'Content',
|
||||
}
|
||||
);
|
||||
FrontmatterInfer.fromFile(
|
||||
'---toml\ntitle = "TOML"\ndescription = "Something longer"\n---\nContent',
|
||||
),
|
||||
).toEqual({
|
||||
title: 'TOML',
|
||||
description: 'Something longer',
|
||||
body: 'Content',
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse JSON with { } delimiters', () => {
|
||||
expect(
|
||||
FrontmatterInfer.fromFile('{\n"title": "The Title",\n"description": "Something longer"\n}\nContent')
|
||||
).toEqual(
|
||||
{
|
||||
title: 'The Title',
|
||||
description: 'Something longer',
|
||||
body: 'Content',
|
||||
}
|
||||
);
|
||||
FrontmatterInfer.fromFile(
|
||||
'{\n"title": "The Title",\n"description": "Something longer"\n}\nContent',
|
||||
),
|
||||
).toEqual({
|
||||
title: 'The Title',
|
||||
description: 'Something longer',
|
||||
body: 'Content',
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse JSON with { } delimiters when it is explicitly set as the format without a custom delimiter', () => {
|
||||
expect(
|
||||
frontmatterJSON().fromFile('{\n"title": "The Title",\n"description": "Something longer"\n}\nContent')
|
||||
).toEqual(
|
||||
{
|
||||
title: 'The Title',
|
||||
description: 'Something longer',
|
||||
body: 'Content',
|
||||
}
|
||||
);
|
||||
frontmatterJSON().fromFile(
|
||||
'{\n"title": "The Title",\n"description": "Something longer"\n}\nContent',
|
||||
),
|
||||
).toEqual({
|
||||
title: 'The Title',
|
||||
description: 'Something longer',
|
||||
body: 'Content',
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse JSON with { } delimiters when it is explicitly set as the format with a custom delimiter', () => {
|
||||
expect(
|
||||
frontmatterJSON("~~~").fromFile('~~~\n"title": "The Title",\n"description": "Something longer"\n~~~\nContent')
|
||||
).toEqual(
|
||||
{
|
||||
title: 'The Title',
|
||||
description: 'Something longer',
|
||||
body: 'Content',
|
||||
}
|
||||
);
|
||||
frontmatterJSON('~~~').fromFile(
|
||||
'~~~\n"title": "The Title",\n"description": "Something longer"\n~~~\nContent',
|
||||
),
|
||||
).toEqual({
|
||||
title: 'The Title',
|
||||
description: 'Something longer',
|
||||
body: 'Content',
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse JSON with ---json delimiters', () => {
|
||||
expect(
|
||||
FrontmatterInfer.fromFile('---json\n{\n"title": "The Title",\n"description": "Something longer"\n}\n---\nContent')
|
||||
).toEqual(
|
||||
{
|
||||
title: 'The Title',
|
||||
description: 'Something longer',
|
||||
body: 'Content',
|
||||
}
|
||||
);
|
||||
FrontmatterInfer.fromFile(
|
||||
'---json\n{\n"title": "The Title",\n"description": "Something longer"\n}\n---\nContent',
|
||||
),
|
||||
).toEqual({
|
||||
title: 'The Title',
|
||||
description: 'Something longer',
|
||||
body: 'Content',
|
||||
});
|
||||
});
|
||||
|
||||
it('should stringify YAML with --- delimiters', () => {
|
||||
expect(
|
||||
FrontmatterInfer.toFile({ body: 'Some content\nOn another line', tags: ['front matter', 'yaml'], title: 'YAML' })
|
||||
FrontmatterInfer.toFile({
|
||||
body: 'Some content\nOn another line',
|
||||
tags: ['front matter', 'yaml'],
|
||||
title: 'YAML',
|
||||
}),
|
||||
).toEqual(
|
||||
[
|
||||
'---',
|
||||
@ -171,31 +172,23 @@ describe('Frontmatter', () => {
|
||||
'---',
|
||||
'Some content',
|
||||
'On another line\n',
|
||||
].join('\n')
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should stringify YAML with missing body', () => {
|
||||
expect(
|
||||
FrontmatterInfer.toFile({ tags: ['front matter', 'yaml'], title: 'YAML' })
|
||||
).toEqual(
|
||||
[
|
||||
'---',
|
||||
'tags:',
|
||||
' - front matter',
|
||||
' - yaml',
|
||||
'title: YAML',
|
||||
'---',
|
||||
'',
|
||||
'',
|
||||
].join('\n')
|
||||
expect(FrontmatterInfer.toFile({ tags: ['front matter', 'yaml'], title: 'YAML' })).toEqual(
|
||||
['---', 'tags:', ' - front matter', ' - yaml', 'title: YAML', '---', '', ''].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should stringify YAML with --- delimiters when it is explicitly set as the format without a custom delimiter',
|
||||
() => {
|
||||
it('should stringify YAML with --- delimiters when it is explicitly set as the format without a custom delimiter', () => {
|
||||
expect(
|
||||
frontmatterYAML().toFile({ body: 'Some content\nOn another line', tags: ['front matter', 'yaml'], title: 'YAML' })
|
||||
frontmatterYAML().toFile({
|
||||
body: 'Some content\nOn another line',
|
||||
tags: ['front matter', 'yaml'],
|
||||
title: 'YAML',
|
||||
}),
|
||||
).toEqual(
|
||||
[
|
||||
'---',
|
||||
@ -206,14 +199,17 @@ describe('Frontmatter', () => {
|
||||
'---',
|
||||
'Some content',
|
||||
'On another line\n',
|
||||
].join('\n')
|
||||
);
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should stringify YAML with --- delimiters when it is explicitly set as the format with a custom delimiter',
|
||||
() => {
|
||||
it('should stringify YAML with --- delimiters when it is explicitly set as the format with a custom delimiter', () => {
|
||||
expect(
|
||||
frontmatterYAML("~~~").toFile({ body: 'Some content\nOn another line', tags: ['front matter', 'yaml'], title: 'YAML' })
|
||||
frontmatterYAML('~~~').toFile({
|
||||
body: 'Some content\nOn another line',
|
||||
tags: ['front matter', 'yaml'],
|
||||
title: 'YAML',
|
||||
}),
|
||||
).toEqual(
|
||||
[
|
||||
'~~~',
|
||||
@ -224,14 +220,17 @@ describe('Frontmatter', () => {
|
||||
'~~~',
|
||||
'Some content',
|
||||
'On another line\n',
|
||||
].join('\n')
|
||||
);
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should stringify YAML with --- delimiters when it is explicitly set as the format with different custom delimiters',
|
||||
() => {
|
||||
it('should stringify YAML with --- delimiters when it is explicitly set as the format with different custom delimiters', () => {
|
||||
expect(
|
||||
frontmatterYAML(["~~~", "^^^"]).toFile({ body: 'Some content\nOn another line', tags: ['front matter', 'yaml'], title: 'YAML' })
|
||||
frontmatterYAML(['~~~', '^^^']).toFile({
|
||||
body: 'Some content\nOn another line',
|
||||
tags: ['front matter', 'yaml'],
|
||||
title: 'YAML',
|
||||
}),
|
||||
).toEqual(
|
||||
[
|
||||
'~~~',
|
||||
@ -242,14 +241,17 @@ describe('Frontmatter', () => {
|
||||
'^^^',
|
||||
'Some content',
|
||||
'On another line\n',
|
||||
].join('\n')
|
||||
);
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should stringify TOML with +++ delimiters when it is explicitly set as the format without a custom delimiter',
|
||||
() => {
|
||||
it('should stringify TOML with +++ delimiters when it is explicitly set as the format without a custom delimiter', () => {
|
||||
expect(
|
||||
frontmatterTOML().toFile({ body: 'Some content\nOn another line', tags: ['front matter', 'toml'], title: 'TOML' })
|
||||
frontmatterTOML().toFile({
|
||||
body: 'Some content\nOn another line',
|
||||
tags: ['front matter', 'toml'],
|
||||
title: 'TOML',
|
||||
}),
|
||||
).toEqual(
|
||||
[
|
||||
'+++',
|
||||
@ -258,14 +260,17 @@ describe('Frontmatter', () => {
|
||||
'+++',
|
||||
'Some content',
|
||||
'On another line\n',
|
||||
].join('\n')
|
||||
);
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should stringify TOML with +++ delimiters when it is explicitly set as the format with a custom delimiter',
|
||||
() => {
|
||||
it('should stringify TOML with +++ delimiters when it is explicitly set as the format with a custom delimiter', () => {
|
||||
expect(
|
||||
frontmatterTOML("~~~").toFile({ body: 'Some content\nOn another line', tags: ['front matter', 'toml'], title: 'TOML' })
|
||||
frontmatterTOML('~~~').toFile({
|
||||
body: 'Some content\nOn another line',
|
||||
tags: ['front matter', 'toml'],
|
||||
title: 'TOML',
|
||||
}),
|
||||
).toEqual(
|
||||
[
|
||||
'~~~',
|
||||
@ -274,14 +279,17 @@ describe('Frontmatter', () => {
|
||||
'~~~',
|
||||
'Some content',
|
||||
'On another line\n',
|
||||
].join('\n')
|
||||
);
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should stringify JSON with { } delimiters when it is explicitly set as the format without a custom delimiter',
|
||||
() => {
|
||||
it('should stringify JSON with { } delimiters when it is explicitly set as the format without a custom delimiter', () => {
|
||||
expect(
|
||||
frontmatterJSON().toFile({ body: 'Some content\nOn another line', tags: ['front matter', 'json'], title: 'JSON' })
|
||||
frontmatterJSON().toFile({
|
||||
body: 'Some content\nOn another line',
|
||||
tags: ['front matter', 'json'],
|
||||
title: 'JSON',
|
||||
}),
|
||||
).toEqual(
|
||||
[
|
||||
'{',
|
||||
@ -293,14 +301,17 @@ describe('Frontmatter', () => {
|
||||
'}',
|
||||
'Some content',
|
||||
'On another line\n',
|
||||
].join('\n')
|
||||
);
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should stringify JSON with { } delimiters when it is explicitly set as the format with a custom delimiter',
|
||||
() => {
|
||||
it('should stringify JSON with { } delimiters when it is explicitly set as the format with a custom delimiter', () => {
|
||||
expect(
|
||||
frontmatterJSON("~~~").toFile({ body: 'Some content\nOn another line', tags: ['front matter', 'json'], title: 'JSON' })
|
||||
frontmatterJSON('~~~').toFile({
|
||||
body: 'Some content\nOn another line',
|
||||
tags: ['front matter', 'json'],
|
||||
title: 'JSON',
|
||||
}),
|
||||
).toEqual(
|
||||
[
|
||||
'~~~',
|
||||
@ -312,7 +323,7 @@ describe('Frontmatter', () => {
|
||||
'~~~',
|
||||
'Some content',
|
||||
'On another line\n',
|
||||
].join('\n')
|
||||
);
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -2,14 +2,8 @@ import tomlFormatter from '../toml';
|
||||
|
||||
describe('tomlFormatter', () => {
|
||||
it('should output TOML integer values without decimals', () => {
|
||||
expect(
|
||||
tomlFormatter.toFile({ testFloat: 123.456, testInteger: 789, title: 'TOML' })
|
||||
).toEqual(
|
||||
[
|
||||
'testFloat = 123.456',
|
||||
'testInteger = 789',
|
||||
'title = "TOML"'
|
||||
].join('\n')
|
||||
);
|
||||
expect(tomlFormatter.toFile({ testFloat: 123.456, testInteger: 789, title: 'TOML' })).toEqual(
|
||||
['testFloat = 123.456', 'testInteger = 789', 'title = "TOML"'].join('\n'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -5,7 +5,7 @@ import tomlFormatter from './toml';
|
||||
import jsonFormatter from './json';
|
||||
import { FrontmatterInfer, frontmatterJSON, frontmatterTOML, frontmatterYAML } from './frontmatter';
|
||||
|
||||
export const frontmatterFormats = ['yaml-frontmatter','toml-frontmatter','json-frontmatter']
|
||||
export const frontmatterFormats = ['yaml-frontmatter', 'toml-frontmatter', 'json-frontmatter'];
|
||||
|
||||
export const formatExtensions = {
|
||||
yml: 'yml',
|
||||
@ -28,21 +28,24 @@ export const extensionFormatters = {
|
||||
html: FrontmatterInfer,
|
||||
};
|
||||
|
||||
const formatByName = (name, customDelimiter) => ({
|
||||
yml: yamlFormatter,
|
||||
yaml: yamlFormatter,
|
||||
toml: tomlFormatter,
|
||||
json: jsonFormatter,
|
||||
frontmatter: FrontmatterInfer,
|
||||
'json-frontmatter': frontmatterJSON(customDelimiter),
|
||||
'toml-frontmatter': frontmatterTOML(customDelimiter),
|
||||
'yaml-frontmatter': frontmatterYAML(customDelimiter),
|
||||
}[name]);
|
||||
const formatByName = (name, customDelimiter) =>
|
||||
({
|
||||
yml: yamlFormatter,
|
||||
yaml: yamlFormatter,
|
||||
toml: tomlFormatter,
|
||||
json: jsonFormatter,
|
||||
frontmatter: FrontmatterInfer,
|
||||
'json-frontmatter': frontmatterJSON(customDelimiter),
|
||||
'toml-frontmatter': frontmatterTOML(customDelimiter),
|
||||
'yaml-frontmatter': frontmatterYAML(customDelimiter),
|
||||
}[name]);
|
||||
|
||||
export function resolveFormat(collectionOrEntity, entry) {
|
||||
// Check for custom delimiter
|
||||
const frontmatter_delimiter = collectionOrEntity.get('frontmatter_delimiter');
|
||||
const customDelimiter = List.isList(frontmatter_delimiter) ? frontmatter_delimiter.toArray() : frontmatter_delimiter;
|
||||
const customDelimiter = List.isList(frontmatter_delimiter)
|
||||
? frontmatter_delimiter.toArray()
|
||||
: frontmatter_delimiter;
|
||||
|
||||
// If the format is specified in the collection, use that format.
|
||||
const formatSpecification = collectionOrEntity.get('format');
|
||||
|
@ -33,31 +33,32 @@ const parsers = {
|
||||
parse: input => yamlFormatter.fromFile(input),
|
||||
stringify: (metadata, { sortedKeys }) => yamlFormatter.toFile(metadata, sortedKeys),
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
function inferFrontmatterFormat(str) {
|
||||
const firstLine = str.substr(0, str.indexOf('\n')).trim();
|
||||
if ((firstLine.length > 3) && (firstLine.substr(0, 3) === "---")) {
|
||||
if (firstLine.length > 3 && firstLine.substr(0, 3) === '---') {
|
||||
// No need to infer, `gray-matter` will handle things like `---toml` for us.
|
||||
return;
|
||||
}
|
||||
switch (firstLine) {
|
||||
case "---":
|
||||
case '---':
|
||||
return getFormatOpts('yaml');
|
||||
case "+++":
|
||||
case '+++':
|
||||
return getFormatOpts('toml');
|
||||
case "{":
|
||||
case '{':
|
||||
return getFormatOpts('json');
|
||||
default:
|
||||
throw "Unrecognized front-matter format.";
|
||||
throw 'Unrecognized front-matter format.';
|
||||
}
|
||||
}
|
||||
|
||||
export const getFormatOpts = format => ({
|
||||
yaml: { language: "yaml", delimiters: "---" },
|
||||
toml: { language: "toml", delimiters: "+++" },
|
||||
json: { language: "json", delimiters: ["{", "}"] },
|
||||
}[format]);
|
||||
export const getFormatOpts = format =>
|
||||
({
|
||||
yaml: { language: 'yaml', delimiters: '---' },
|
||||
toml: { language: 'toml', delimiters: '+++' },
|
||||
json: { language: 'json', delimiters: ['{', '}'] },
|
||||
}[format]);
|
||||
|
||||
class FrontmatterFormatter {
|
||||
constructor(format, customDelimiter) {
|
||||
|
@ -5,5 +5,5 @@ export default {
|
||||
|
||||
toFile(data) {
|
||||
return JSON.stringify(data, null, 2);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
@ -9,7 +9,7 @@ const outputReplacer = (key, value) => {
|
||||
return value.format(value._f);
|
||||
}
|
||||
if (value instanceof AssetProxy) {
|
||||
return `${ value.path }`;
|
||||
return `${value.path}`;
|
||||
}
|
||||
if (Number.isInteger(value)) {
|
||||
// Return the string representation of integers so tomlify won't render with tenths (".0")
|
||||
@ -26,5 +26,5 @@ export default {
|
||||
|
||||
toFile(data, sortedKeys = []) {
|
||||
return tomlify.toToml(data, { replace: outputReplacer, sort: sortKeys(sortedKeys) });
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
@ -20,7 +20,7 @@ const ImageType = new yaml.Type('image', {
|
||||
kind: 'scalar',
|
||||
instanceOf: AssetProxy,
|
||||
represent(value) {
|
||||
return `${ value.path }`;
|
||||
return `${value.path}`;
|
||||
},
|
||||
resolve(value) {
|
||||
if (value === null) return false;
|
||||
@ -29,7 +29,6 @@ const ImageType = new yaml.Type('image', {
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const OutputSchema = new yaml.Schema({
|
||||
include: yaml.DEFAULT_SAFE_SCHEMA.include,
|
||||
implicit: [MomentType, ImageType].concat(yaml.DEFAULT_SAFE_SCHEMA.implicit),
|
||||
@ -43,5 +42,5 @@ export default {
|
||||
|
||||
toFile(data, sortedKeys = []) {
|
||||
return yaml.safeDump(data, { schema: OutputSchema, sortKeys: sortKeys(sortedKeys) });
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
@ -10,14 +10,16 @@ export function resolveIntegrations(interationsConfig, getToken) {
|
||||
integrationInstances = integrationInstances.set('algolia', new Algolia(providerData));
|
||||
break;
|
||||
case 'assetStore':
|
||||
integrationInstances = integrationInstances.set('assetStore', new AssetStore(providerData, getToken));
|
||||
integrationInstances = integrationInstances.set(
|
||||
'assetStore',
|
||||
new AssetStore(providerData, getToken),
|
||||
);
|
||||
break;
|
||||
}
|
||||
});
|
||||
return integrationInstances;
|
||||
}
|
||||
|
||||
|
||||
export const getIntegrationProvider = (function() {
|
||||
let integrations = null;
|
||||
|
||||
@ -29,4 +31,4 @@ export const getIntegrationProvider = (function() {
|
||||
return integrations.get(provider);
|
||||
}
|
||||
};
|
||||
}());
|
||||
})();
|
||||
|
@ -3,14 +3,16 @@ import { createEntry } from 'ValueObjects/Entry';
|
||||
import { selectEntrySlug } from 'Reducers/collections';
|
||||
|
||||
function getSlug(path) {
|
||||
return path.split('/').pop().replace(/\.[^.]+$/, '');
|
||||
return path
|
||||
.split('/')
|
||||
.pop()
|
||||
.replace(/\.[^.]+$/, '');
|
||||
}
|
||||
|
||||
export default class Algolia {
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
if (config.get('applicationID') == null ||
|
||||
config.get('apiKey') == null) {
|
||||
if (config.get('applicationID') == null || config.get('apiKey') == null) {
|
||||
throw 'The Algolia search integration needs the credentials (applicationID and apiKey) in the integration configuration.';
|
||||
}
|
||||
|
||||
@ -18,9 +20,9 @@ export default class Algolia {
|
||||
this.apiKey = config.get('apiKey');
|
||||
|
||||
const prefix = config.get('indexPrefix');
|
||||
this.indexPrefix = prefix ? `${ prefix }-` : '';
|
||||
this.indexPrefix = prefix ? `${prefix}-` : '';
|
||||
|
||||
this.searchURL = `https://${ this.applicationID }-dsn.algolia.net/1`;
|
||||
this.searchURL = `https://${this.applicationID}-dsn.algolia.net/1`;
|
||||
|
||||
this.entriesCache = {
|
||||
collection: null,
|
||||
@ -39,7 +41,7 @@ export default class Algolia {
|
||||
}
|
||||
|
||||
parseJsonResponse(response) {
|
||||
return response.json().then((json) => {
|
||||
return response.json().then(json => {
|
||||
if (!response.ok) {
|
||||
return Promise.reject(json);
|
||||
}
|
||||
@ -52,11 +54,11 @@ export default class Algolia {
|
||||
const params = [];
|
||||
if (options.params) {
|
||||
for (const key in options.params) {
|
||||
params.push(`${ key }=${ encodeURIComponent(options.params[key]) }`);
|
||||
params.push(`${key}=${encodeURIComponent(options.params[key])}`);
|
||||
}
|
||||
}
|
||||
if (params.length) {
|
||||
path += `?${ params.join('&') }`;
|
||||
path += `?${params.join('&')}`;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
@ -64,7 +66,7 @@ export default class Algolia {
|
||||
request(path, options = {}) {
|
||||
const headers = this.requestHeaders(options.headers || {});
|
||||
const url = this.urlFor(path, options);
|
||||
return fetch(url, { ...options, headers }).then((response) => {
|
||||
return fetch(url, { ...options, headers }).then(response => {
|
||||
const contentType = response.headers.get('Content-Type');
|
||||
if (contentType && contentType.match(/json/)) {
|
||||
return this.parseJsonResponse(response);
|
||||
@ -75,25 +77,28 @@ export default class Algolia {
|
||||
}
|
||||
|
||||
search(collections, searchTerm, page) {
|
||||
const searchCollections = collections.map(collection => (
|
||||
{ indexName: `${ this.indexPrefix }${ collection }`, params: `query=${ searchTerm }&page=${ page }` }
|
||||
));
|
||||
const searchCollections = collections.map(collection => ({
|
||||
indexName: `${this.indexPrefix}${collection}`,
|
||||
params: `query=${searchTerm}&page=${page}`,
|
||||
}));
|
||||
|
||||
return this.request(`${ this.searchURL }/indexes/*/queries`, {
|
||||
return this.request(`${this.searchURL}/indexes/*/queries`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ requests: searchCollections }),
|
||||
}).then((response) => {
|
||||
const entries = response.results.map((result, index) => result.hits.map((hit) => {
|
||||
const slug = getSlug(hit.path);
|
||||
return createEntry(collections[index], slug, hit.path, { data: hit.data, partial: true });
|
||||
}));
|
||||
}).then(response => {
|
||||
const entries = response.results.map((result, index) =>
|
||||
result.hits.map(hit => {
|
||||
const slug = getSlug(hit.path);
|
||||
return createEntry(collections[index], slug, hit.path, { data: hit.data, partial: true });
|
||||
}),
|
||||
);
|
||||
|
||||
return { entries: _.flatten(entries), pagination: page };
|
||||
});
|
||||
}
|
||||
|
||||
searchBy(field, collection, query) {
|
||||
return this.request(`${ this.searchURL }/indexes/${ this.indexPrefix }${ collection }`, {
|
||||
return this.request(`${this.searchURL}/indexes/${this.indexPrefix}${collection}`, {
|
||||
params: {
|
||||
restrictSearchableAttributes: field,
|
||||
query,
|
||||
@ -105,12 +110,18 @@ export default class Algolia {
|
||||
if (this.entriesCache.collection === collection && this.entriesCache.page === page) {
|
||||
return Promise.resolve({ page: this.entriesCache.page, entries: this.entriesCache.entries });
|
||||
} else {
|
||||
return this.request(`${ this.searchURL }/indexes/${ this.indexPrefix }${ collection.get('name') }`, {
|
||||
params: { page },
|
||||
}).then((response) => {
|
||||
const entries = response.hits.map((hit) => {
|
||||
return this.request(
|
||||
`${this.searchURL}/indexes/${this.indexPrefix}${collection.get('name')}`,
|
||||
{
|
||||
params: { page },
|
||||
},
|
||||
).then(response => {
|
||||
const entries = response.hits.map(hit => {
|
||||
const slug = selectEntrySlug(collection, hit.path);
|
||||
return createEntry(collection.get('name'), slug, hit.path, { data: hit.data, partial: true });
|
||||
return createEntry(collection.get('name'), slug, hit.path, {
|
||||
data: hit.data,
|
||||
partial: true,
|
||||
});
|
||||
});
|
||||
this.entriesCache = { collection, pagination: response.page, entries };
|
||||
return { entries, pagination: response.page };
|
||||
@ -119,9 +130,12 @@ export default class Algolia {
|
||||
}
|
||||
|
||||
getEntry(collection, slug) {
|
||||
return this.searchBy('slug', collection.get('name'), slug).then((response) => {
|
||||
return this.searchBy('slug', collection.get('name'), slug).then(response => {
|
||||
const entry = response.hits.filter(hit => hit.slug === slug)[0];
|
||||
return createEntry(collection.get('name'), slug, entry.path, { data: entry.data, partial: true });
|
||||
return createEntry(collection.get('name'), slug, entry.path, {
|
||||
data: entry.data,
|
||||
partial: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -14,7 +14,7 @@ export default class AssetStore {
|
||||
}
|
||||
|
||||
parseJsonResponse(response) {
|
||||
return response.json().then((json) => {
|
||||
return response.json().then(json => {
|
||||
if (!response.ok) {
|
||||
return Promise.reject(json);
|
||||
}
|
||||
@ -27,16 +27,15 @@ export default class AssetStore {
|
||||
const params = [];
|
||||
if (options.params) {
|
||||
for (const key in options.params) {
|
||||
params.push(`${ key }=${ encodeURIComponent(options.params[key]) }`);
|
||||
params.push(`${key}=${encodeURIComponent(options.params[key])}`);
|
||||
}
|
||||
}
|
||||
if (params.length) {
|
||||
path += `?${ params.join('&') }`;
|
||||
path += `?${params.join('&')}`;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
|
||||
requestHeaders(headers = {}) {
|
||||
return {
|
||||
...headers,
|
||||
@ -44,15 +43,16 @@ export default class AssetStore {
|
||||
}
|
||||
|
||||
confirmRequest(assetID) {
|
||||
this.getToken()
|
||||
.then(token => this.request(`${ this.getSignedFormURL }/${ assetID }`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${ token }`,
|
||||
},
|
||||
body: JSON.stringify({ state: 'uploaded' }),
|
||||
}));
|
||||
this.getToken().then(token =>
|
||||
this.request(`${this.getSignedFormURL}/${assetID}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ state: 'uploaded' }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async request(path, options = {}) {
|
||||
@ -66,12 +66,15 @@ export default class AssetStore {
|
||||
}
|
||||
|
||||
async retrieve(query, page, privateUpload) {
|
||||
const params = pickBy({ search: query, page, filter: privateUpload ? 'private' : 'public' }, val => !!val);
|
||||
const params = pickBy(
|
||||
{ search: query, page, filter: privateUpload ? 'private' : 'public' },
|
||||
val => !!val,
|
||||
);
|
||||
const url = addParams(this.getSignedFormURL, params);
|
||||
const token = await this.getToken();
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${ token }`,
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
const response = await this.request(url, { headers });
|
||||
const files = response.map(({ id, name, size, url }) => {
|
||||
@ -81,21 +84,22 @@ export default class AssetStore {
|
||||
}
|
||||
|
||||
delete(assetID) {
|
||||
const url = `${ this.getSignedFormURL }/${ assetID }`
|
||||
return this.getToken()
|
||||
.then(token => this.request(url, {
|
||||
const url = `${this.getSignedFormURL}/${assetID}`;
|
||||
return this.getToken().then(token =>
|
||||
this.request(url, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${ token }`,
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async upload(file, privateUpload = false) {
|
||||
const fileData = {
|
||||
name: file.name,
|
||||
size: file.size
|
||||
size: file.size,
|
||||
};
|
||||
if (file.type) {
|
||||
fileData.content_type = file.type;
|
||||
@ -111,7 +115,7 @@ export default class AssetStore {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${ token }`,
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(fileData),
|
||||
});
|
||||
@ -131,8 +135,7 @@ export default class AssetStore {
|
||||
|
||||
const asset = { id, name, size, url, urlIsPublicPath: true };
|
||||
return { success: true, url, asset };
|
||||
}
|
||||
catch(error) {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
@ -4,111 +4,101 @@ import { sanitizeURI, sanitizeSlug } from '../urlHelper';
|
||||
describe('sanitizeURI', () => {
|
||||
// `sanitizeURI` tests from RFC 3987
|
||||
it('should keep valid URI chars (letters digits _ - . ~)', () => {
|
||||
expect(
|
||||
sanitizeURI("This, that-one_or.the~other 123!")
|
||||
).toEqual('Thisthat-one_or.the~other123');
|
||||
expect(sanitizeURI('This, that-one_or.the~other 123!')).toEqual('Thisthat-one_or.the~other123');
|
||||
});
|
||||
|
||||
|
||||
it('should not remove accents', () => {
|
||||
expect(
|
||||
sanitizeURI("ěščřžý")
|
||||
).toEqual('ěščřžý');
|
||||
expect(sanitizeURI('ěščřžý')).toEqual('ěščřžý');
|
||||
});
|
||||
|
||||
|
||||
it('should keep valid non-latin chars (ucschars in RFC 3987)', () => {
|
||||
expect(
|
||||
sanitizeURI("日本語のタイトル")
|
||||
).toEqual('日本語のタイトル');
|
||||
expect(sanitizeURI('日本語のタイトル')).toEqual('日本語のタイトル');
|
||||
});
|
||||
|
||||
it('should not keep valid non-latin chars (ucschars in RFC 3987) if set to ASCII mode', () => {
|
||||
expect(
|
||||
sanitizeURI("ěščřžý日本語のタイトル", { encoding: 'ascii' })
|
||||
).toEqual('');
|
||||
expect(sanitizeURI('ěščřžý日本語のタイトル', { encoding: 'ascii' })).toEqual('');
|
||||
});
|
||||
|
||||
it('should not normalize Unicode strings', () => {
|
||||
expect(
|
||||
sanitizeURI('\u017F\u0323\u0307')
|
||||
).toEqual('\u017F\u0323\u0307');
|
||||
expect(
|
||||
sanitizeURI('\u017F\u0323\u0307')
|
||||
).not.toEqual('\u1E9B\u0323');
|
||||
expect(sanitizeURI('\u017F\u0323\u0307')).toEqual('\u017F\u0323\u0307');
|
||||
expect(sanitizeURI('\u017F\u0323\u0307')).not.toEqual('\u1E9B\u0323');
|
||||
});
|
||||
|
||||
|
||||
it('should allow a custom replacement character', () => {
|
||||
expect(
|
||||
sanitizeURI("duck\\goose.elephant", { replacement: '-' })
|
||||
).toEqual('duck-goose.elephant');
|
||||
expect(sanitizeURI('duck\\goose.elephant', { replacement: '-' })).toEqual(
|
||||
'duck-goose.elephant',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
it('should not allow an improper replacement character', () => {
|
||||
expect(() => {
|
||||
sanitizeURI("I! like! dollars!", { replacement: '$' });
|
||||
}).toThrow();
|
||||
sanitizeURI('I! like! dollars!', { replacement: '$' });
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
|
||||
it('should not actually URI-encode the characters', () => {
|
||||
expect(
|
||||
sanitizeURI("🎉")
|
||||
).toEqual('🎉');
|
||||
expect(
|
||||
sanitizeURI("🎉")
|
||||
).not.toEqual("%F0%9F%8E%89");
|
||||
expect(sanitizeURI('🎉')).toEqual('🎉');
|
||||
expect(sanitizeURI('🎉')).not.toEqual('%F0%9F%8E%89');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('sanitizeSlug', ()=> {
|
||||
|
||||
describe('sanitizeSlug', () => {
|
||||
it('throws an error for non-strings', () => {
|
||||
expect(() => sanitizeSlug({})).toThrowError("The input slug must be a string.");
|
||||
expect(() => sanitizeSlug([])).toThrowError("The input slug must be a string.");
|
||||
expect(() => sanitizeSlug(false)).toThrowError("The input slug must be a string.");
|
||||
expect(() => sanitizeSlug(null)).toThrowError("The input slug must be a string.");
|
||||
expect(() => sanitizeSlug(11234)).toThrowError("The input slug must be a string.");
|
||||
expect(() => sanitizeSlug(undefined)).toThrowError("The input slug must be a string.");
|
||||
expect(() => sanitizeSlug(()=>{})).toThrowError("The input slug must be a string.");
|
||||
expect(() => sanitizeSlug({})).toThrowError('The input slug must be a string.');
|
||||
expect(() => sanitizeSlug([])).toThrowError('The input slug must be a string.');
|
||||
expect(() => sanitizeSlug(false)).toThrowError('The input slug must be a string.');
|
||||
expect(() => sanitizeSlug(null)).toThrowError('The input slug must be a string.');
|
||||
expect(() => sanitizeSlug(11234)).toThrowError('The input slug must be a string.');
|
||||
expect(() => sanitizeSlug(undefined)).toThrowError('The input slug must be a string.');
|
||||
expect(() => sanitizeSlug(() => {})).toThrowError('The input slug must be a string.');
|
||||
});
|
||||
|
||||
it('throws an error for non-string replacements', () => {
|
||||
expect(() => sanitizeSlug('test', Map({ sanitize_replacement: {} }))).toThrowError("`options.replacement` must be a string.");
|
||||
expect(() => sanitizeSlug('test', Map({ sanitize_replacement: [] }))).toThrowError("`options.replacement` must be a string.");
|
||||
expect(() => sanitizeSlug('test', Map({ sanitize_replacement: false }))).toThrowError("`options.replacement` must be a string.");
|
||||
expect(() => sanitizeSlug('test', Map({ sanitize_replacement: null } ))).toThrowError("`options.replacement` must be a string.");
|
||||
expect(() => sanitizeSlug('test', Map({ sanitize_replacement: 11232 }))).toThrowError("`options.replacement` must be a string.");
|
||||
// do not test undefined for this variant since a default is set in the cosntructor.
|
||||
expect(() => sanitizeSlug('test', Map({ sanitize_replacement: {} }))).toThrowError(
|
||||
'`options.replacement` must be a string.',
|
||||
);
|
||||
expect(() => sanitizeSlug('test', Map({ sanitize_replacement: [] }))).toThrowError(
|
||||
'`options.replacement` must be a string.',
|
||||
);
|
||||
expect(() => sanitizeSlug('test', Map({ sanitize_replacement: false }))).toThrowError(
|
||||
'`options.replacement` must be a string.',
|
||||
);
|
||||
expect(() => sanitizeSlug('test', Map({ sanitize_replacement: null }))).toThrowError(
|
||||
'`options.replacement` must be a string.',
|
||||
);
|
||||
expect(() => sanitizeSlug('test', Map({ sanitize_replacement: 11232 }))).toThrowError(
|
||||
'`options.replacement` must be a string.',
|
||||
);
|
||||
// do not test undefined for this variant since a default is set in the cosntructor.
|
||||
//expect(() => sanitizeSlug('test', { sanitize_replacement: undefined })).toThrowError("`options.replacement` must be a string.");
|
||||
expect(() => sanitizeSlug('test', Map({ sanitize_replacement: ()=>{} }))).toThrowError("`options.replacement` must be a string.");
|
||||
expect(() => sanitizeSlug('test', Map({ sanitize_replacement: () => {} }))).toThrowError(
|
||||
'`options.replacement` must be a string.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should keep valid URI chars (letters digits _ - . ~)', () => {
|
||||
expect(
|
||||
sanitizeSlug("This, that-one_or.the~other 123!")
|
||||
).toEqual('This-that-one_or.the~other-123');
|
||||
expect(sanitizeSlug('This, that-one_or.the~other 123!')).toEqual(
|
||||
'This-that-one_or.the~other-123',
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove accents with `clean_accents` set', () => {
|
||||
expect(
|
||||
sanitizeSlug("ěščřžý", Map({ clean_accents: true }))
|
||||
).toEqual('escrzy');
|
||||
expect(sanitizeSlug('ěščřžý', Map({ clean_accents: true }))).toEqual('escrzy');
|
||||
});
|
||||
|
||||
it('should remove non-latin chars in "ascii" mode', () => {
|
||||
expect(
|
||||
sanitizeSlug("ěščřžý日本語のタイトル", Map({ encoding: 'ascii' }))
|
||||
).toEqual('');
|
||||
expect(sanitizeSlug('ěščřžý日本語のタイトル', Map({ encoding: 'ascii' }))).toEqual('');
|
||||
});
|
||||
|
||||
it('should clean accents and strip non-latin chars in "ascii" mode with `clean_accents` set', () => {
|
||||
expect(
|
||||
sanitizeSlug("ěščřžý日本語のタイトル", Map({ encoding: 'ascii', clean_accents: true }))
|
||||
sanitizeSlug('ěščřžý日本語のタイトル', Map({ encoding: 'ascii', clean_accents: true })),
|
||||
).toEqual('escrzy');
|
||||
});
|
||||
|
||||
it('removes double replacements', () => {
|
||||
expect(sanitizeSlug('test--test')).toEqual('test-test');
|
||||
expect(sanitizeSlug('test test')).toEqual('test-test');
|
||||
expect(sanitizeSlug('test--test')).toEqual('test-test');
|
||||
expect(sanitizeSlug('test test')).toEqual('test-test');
|
||||
});
|
||||
|
||||
it('removes trailing replacemenets', () => {
|
||||
@ -118,5 +108,4 @@ describe('sanitizeSlug', ()=> {
|
||||
it('uses alternate replacements', () => {
|
||||
expect(sanitizeSlug('test test ', Map({ sanitize_replacement: '_' }))).toEqual('test_test');
|
||||
});
|
||||
|
||||
});
|
||||
|
@ -1,7 +1,7 @@
|
||||
export default function consoleError(title, description) {
|
||||
console.error(
|
||||
`%c ⛔ ${ title }\n` + `%c${ description }\n\n`,
|
||||
`%c ⛔ ${title}\n` + `%c${description}\n\n`,
|
||||
'color: black; font-weight: bold; font-size: 16px; line-height: 50px;',
|
||||
'color: black;'
|
||||
'color: black;',
|
||||
);
|
||||
}
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { Map } from 'immutable';
|
||||
import EditorComponent from 'ValueObjects/EditorComponent'
|
||||
import EditorComponent from 'ValueObjects/EditorComponent';
|
||||
|
||||
/**
|
||||
* Global Registry Object
|
||||
*/
|
||||
const registry = {
|
||||
backends: { },
|
||||
backends: {},
|
||||
templates: {},
|
||||
previewStyles: [],
|
||||
widgets: {},
|
||||
@ -29,7 +29,6 @@ export default {
|
||||
getBackend,
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Preview Styles
|
||||
*
|
||||
@ -43,7 +42,6 @@ export function getPreviewStyles() {
|
||||
return registry.previewStyles;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Preview Templates
|
||||
*/
|
||||
@ -54,7 +52,6 @@ export function getPreviewTemplate(name) {
|
||||
return registry.templates[name];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Editor Widgets
|
||||
*/
|
||||
@ -71,7 +68,6 @@ export function resolveWidget(name) {
|
||||
return getWidget(name || 'string') || getWidget('unknown');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Markdown Editor Custom Components
|
||||
*/
|
||||
@ -83,7 +79,6 @@ export function getEditorComponents() {
|
||||
return registry.editorComponents;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Widget Serializers
|
||||
*/
|
||||
@ -99,9 +94,11 @@ export function getWidgetValueSerializer(widgetName) {
|
||||
*/
|
||||
export function registerBackend(name, BackendClass) {
|
||||
if (!name || !BackendClass) {
|
||||
console.error("Backend parameters invalid. example: CMS.registerBackend('myBackend', BackendClass)");
|
||||
console.error(
|
||||
"Backend parameters invalid. example: CMS.registerBackend('myBackend', BackendClass)",
|
||||
);
|
||||
} else if (registry.backends[name]) {
|
||||
console.error(`Backend [${ name }] already registered. Please choose a different name.`);
|
||||
console.error(`Backend [${name}] already registered. Please choose a different name.`);
|
||||
} else {
|
||||
registry.backends[name] = {
|
||||
init: (...args) => new BackendClass(...args),
|
||||
@ -112,4 +109,3 @@ export function registerBackend(name, BackendClass) {
|
||||
export function getBackend(name) {
|
||||
return registry.backends[name];
|
||||
}
|
||||
|
||||
|
@ -21,7 +21,6 @@ import { getWidgetValueSerializer } from './registry';
|
||||
* handlers run on persist.
|
||||
*/
|
||||
const runSerializer = (values, fields, method) => {
|
||||
|
||||
/**
|
||||
* Reduce the list of fields to a map where keys are field names and values
|
||||
* are field values, serializing the values of fields whose widgets have
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user