chore: add code formatting and linting (#952)

This commit is contained in:
Caleb 2018-08-07 14:46:54 -06:00 committed by Shawn Erquhart
parent 32e0a9b2b5
commit f801b19221
265 changed files with 5988 additions and 4481 deletions

View File

@ -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
View 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
View File

@ -0,0 +1,3 @@
dist/
bin/
CHANGELOG.md

5
.prettierrc Normal file
View File

@ -0,0 +1,5 @@
{
"trailingComma": "all",
"singleQuote": true,
"printWidth": 100
}

14
.stylelintrc Normal file
View 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"]
}]
}
}

View File

@ -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:

View File

@ -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
- 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
- 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

View File

@ -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.

View File

@ -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.
- 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 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!

View File

@ -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', {
[
'@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', {
[
'inline-svg',
{
svgo: {
plugins: [
{removeViewBox: false},
],
plugins: [{ removeViewBox: false }],
},
}],
['emotion', {
},
],
[
'emotion',
{
sourceMap: true,
autoLabel: true,
}],
},
],
];
}
return [
...defaultPlugins,
['emotion', {
[
'emotion',
{
sourceMap: true,
autoLabel: true,
}],
},
],
];
};

View File

@ -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);

View File

@ -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',
};

View File

@ -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"]
}
}
}

View File

@ -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": [

View File

@ -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([
request = req =>
flow([
this.buildRequest,
this.requestFunction,
p => p.catch(err => Promise.reject(new APIError(err.message, null, "BitBucket"))),
p => p.catch(err => Promise.reject(new APIError(err.message, null, 'BitBucket'))),
])(req);
requestJSON = req => flow([
unsentRequest.withDefaultHeaders({ "Content-Type": "application/json" }),
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"))),
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" }),
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"))),
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 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); }
return result;
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}`);
return { entries: this.processFiles(entries), cursor };
}
};
traverseCursor = async (cursor, action) => flow([
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]));
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,23 +142,26 @@ 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`);
};
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 } = {}) => {
@ -149,16 +169,14 @@ export default class API {
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`,
);
};
}

View File

@ -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);
@ -45,7 +48,7 @@ export default class BitbucketAuthenticationPage extends React.Component {
renderButtonContent={() => (
<React.Fragment>
<LoginButtonIcon type="bitbucket" />
{inProgress ? "Logging in..." : "Login with Bitbucket"}
{inProgress ? 'Logging in...' : 'Login with Bitbucket'}
</React.Fragment>
)}
/>

View File

@ -1,15 +1,15 @@
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;
@ -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;
@ -116,10 +127,13 @@ export default class Bitbucket {
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) => {
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) => {
})
.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) =>
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 })
this.api
.readFile(path, id, { parseText: false })
.then(resolve, reject)
.finally(() => sem.leave())
)
.finally(() => sem.leave()),
),
);
return { id, name, getBlobPromise, url: download_url, path };
}));
}),
);
}
persistEntry(entry, mediaFiles, options = {}) {
@ -215,9 +241,10 @@ 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 })))),
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,
}));
}

View File

@ -1,4 +1,3 @@
export BitbucketBackend from './implementation';
export API from './API';
export AuthenticationPage from './AuthenticationPage';

View File

@ -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,12 +124,16 @@ export default class GitGatewayAuthenticationPage extends React.Component {
return;
}
AuthenticationPage.authClient.login(this.state.email, this.state.password, true)
.then((user) => {
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 });
.catch(error => {
this.setState({
errors: { server: error.description || error.msg || error },
loggingIn: false,
});
});
};
@ -147,7 +151,8 @@ export default class GitGatewayAuthenticationPage extends React.Component {
}
return (
<AuthenticationPage renderPageContent={() => (
<AuthenticationPage
renderPageContent={() => (
<AuthForm onSubmit={this.handleLogin}>
{!error ? null : <ErrorMessage>{error}</ErrorMessage>}
{!errors.server ? null : <ErrorMessage>{errors.server}</ErrorMessage>}
@ -167,9 +172,12 @@ export default class GitGatewayAuthenticationPage extends React.Component {
value={this.state.password}
onChange={partial(this.handleChange, 'password')}
/>
<LoginButton disabled={inProgress}>{inProgress ? 'Logging in...' : 'Login'}</LoginButton>
<LoginButton disabled={inProgress}>
{inProgress ? 'Logging in...' : 'Login'}
</LoginButton>
</AuthForm>
)}/>
)}
/>
);
}
}

View File

@ -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,7 +52,6 @@ export default class API extends GithubAPI {
});
}
urlFor(path, options) {
const cacheBuster = new Date().getTime();
const params = [`ts=${cacheBuster}`];
@ -52,7 +61,7 @@ export default class API extends GithubAPI {
}
}
if (params.length) {
path += `?${ params.join("&") }`;
path += `?${params.join('&')}`;
}
return this.api_root + path;
}
@ -66,9 +75,9 @@ export default class API extends GithubAPI {
let responseStatus;
return this.getRequestHeaders(options.headers || {})
.then(headers => fetch(url, { ...options, headers }))
.then((response) => {
.then(response => {
responseStatus = response.status;
const contentType = response.headers.get("Content-Type");
const contentType = response.headers.get('Content-Type');
if (contentType && contentType.match(/json/)) {
return this.parseJsonResponse(response);
}
@ -79,7 +88,7 @@ export default class API extends GithubAPI {
return text;
})
.catch(error => {
throw new APIError((error.message || error.msg), responseStatus, 'Git Gateway');
throw new APIError(error.message || error.msg, responseStatus, 'Git Gateway');
});
}
@ -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),
});
}
}

View File

@ -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({
authenticateRequest = async req =>
unsentRequest.withHeaders(
{
Authorization: `Bearer ${await this.tokenPromise()}`,
}, req);
},
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);
}

View File

@ -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,29 +46,38 @@ 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()
requestFunction = req =>
this.tokenPromise()
.then(token => unsentRequest.withHeaders({ Authorization: `Bearer ${token}` }, req))
.then(unsentRequest.performRequest);
@ -70,16 +85,19 @@ export default class GitGateway {
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`, {
const { github_enabled, gitlab_enabled, bitbucket_enabled, roles } = await fetch(
`${this.gatewayUrl}/settings`,
{
headers: { Authorization: `Bearer ${token}` },
}).then(res => res.json());
},
).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';
}
}
@ -101,18 +119,18 @@ export default class GitGateway {
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);
}
}

View File

@ -1,3 +1,2 @@
export GitGatewayBackend from './implementation';
export AuthenticationPage from './AuthenticationPage';

View File

@ -1,38 +1,38 @@
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.branch = config.branch || 'master';
this.repo = config.repo || '';
this.repoURL = `/repos/${this.repo}`;
this.merge_method = config.squash_merges ? "squash" : "merge";
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,
};
@ -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);
}
@ -63,7 +63,7 @@ export default class API {
}
}
if (params.length) {
path += `?${ params.join("&") }`;
path += `?${params.join('&')}`;
}
return this.api_root + path;
}
@ -72,9 +72,10 @@ 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) => {
return fetch(url, { ...options, headers })
.then(response => {
responseStatus = response.status;
const contentType = response.headers.get("Content-Type");
const contentType = response.headers.get('Content-Type');
if (contentType && contentType.match(/json/)) {
return this.parseJsonResponse(response);
}
@ -84,7 +85,7 @@ export default class API {
}
return text;
})
.catch((error) => {
.catch(error => {
throw new APIError(error.message, responseStatus, 'GitHub');
});
}
@ -95,29 +96,33 @@ export default class API {
checkMetadataRef() {
return this.request(`${this.repoURL}/git/refs/meta/_netlify_cms?${Date.now()}`, {
cache: "no-store",
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.",
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(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`,
@ -127,9 +132,9 @@ export default class API {
};
return this.uploadBlob(fileTree[`${key}.json`])
.then(() => this.updateTree(branchData.sha, "/", fileTree))
.then(() => this.updateTree(branchData.sha, '/', fileTree))
.then(changeTree => this.commit(`Updating “${key}” metadata`, changeTree))
.then(response => this.patchRef("meta", "_netlify_cms", response.sha))
.then(response => this.patchRef('meta', '_netlify_cms', response.sha))
.then(() => {
localForage.setItem(`gh.meta.${key}`, {
expires: Date.now() + 300000, // In 5 minutes
@ -141,16 +146,27 @@ export default class API {
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 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",
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));
.catch(() =>
console.log(
'%c %s does not have metadata',
'line-height: 30px;text-align: center;font-weight: bold',
key,
),
);
});
}
@ -159,12 +175,15 @@ export default class API {
return this.getBlob(sha);
} else {
return this.request(`${this.repoURL}/contents/${path}`, {
headers: { Accept: "application/vnd.github.VERSION.raw" },
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;
@ -197,20 +218,22 @@ export default class API {
}
return files;
})
.then(files => files.filter(file => file.type === "file"));
.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);
});
}
@ -218,8 +241,8 @@ export default class API {
isUnpublishedEntryModification(path, branch) {
return this.readFile(path, null, branch)
.then(() => true)
.catch((err) => {
if (err.message && err.message === "Not Found") {
.catch(err => {
if (err.message && err.message === 'Not Found') {
return false;
}
throw err;
@ -227,10 +250,14 @@ export default class API {
}
listUnpublishedBranches() {
console.log("%c Checking for Unpublished entries", "line-height: 30px;text-align: center;font-weight: bold");
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);
.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
@ -241,11 +268,14 @@ export default class API {
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");
}).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;
});
}
@ -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(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);
@ -312,8 +346,7 @@ export default class API {
* 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 => {
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) {
@ -335,7 +368,7 @@ export default class API {
let prResponse;
return this.getBranch()
.then(branchData => this.updateTree(branchData.commit.sha, "/", fileTree))
.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))
@ -345,7 +378,7 @@ export default class API {
})
.then(user => {
return this.storeMetadata(contentKey, {
type: "PR",
type: 'PR',
pr: {
number: prResponse.number,
head: prResponse.head && prResponse.head.sha,
@ -370,7 +403,7 @@ export default class API {
// 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;
@ -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;
}
@ -501,7 +534,8 @@ export default class API {
/**
* 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
@ -513,10 +547,10 @@ export default class API {
/**
* Create a new commit with the updated tree and original commit metadata.
*/
.then(tree => this.createCommit(message, tree.sha, parent, author, committer));
.then(tree => this.createCommit(message, tree.sha, parent, author, committer))
);
}
/**
* Get a pull request by PR number.
*/
@ -562,18 +596,20 @@ export default class API {
deleteUnpublishedEntry(collection, slug) {
const contentKey = slug;
const branchName = this.generateBranchName(contentKey);
return this.retrieveMetadata(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") {
.catch(err => {
if (err.message === 'Reference does not exist') {
return Promise.resolve();
}
return Promise.reject(err);
});
})
);
}
publishUnpublishedEntry(collection, slug) {
@ -584,10 +620,9 @@ export default class API {
.then(() => this.deleteBranch(branchName));
}
createRef(type, name, sha) {
return this.request(`${this.repoURL}/git/refs`, {
method: "POST",
method: 'POST',
body: JSON.stringify({ ref: `refs/${type}/${name}`, sha }),
});
}
@ -595,7 +630,7 @@ export default class API {
patchRef(type, name, sha, opts = {}) {
const force = opts.force || false;
return this.request(`${this.repoURL}/git/refs/${type}/${encodeURIComponent(name)}`, {
method: "PATCH",
method: 'PATCH',
body: JSON.stringify({ sha, force }),
});
}
@ -611,7 +646,7 @@ export default class API {
}
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";
const body = 'Automatically generated by Netlify CMS';
return this.request(`${this.repoURL}/pulls`, {
method: "POST",
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");
console.log('%c Deleting PR', 'line-height: 30px;text-align: center;font-weight: bold');
return this.request(`${this.repoURL}/pulls/${prNumber}`, {
method: "PATCH",
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");
console.log('%c Merging PR', 'line-height: 30px;text-align: center;font-weight: bold');
return this.request(`${this.repoURL}/pulls/${prNumber}/merge`, {
method: "PUT",
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,13 +707,16 @@ 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) => {
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(branchData => this.updateTree(branchData.commit.sha, '/', fileTree))
.then(changeTree => this.commit(commitMessage, changeTree))
.then(response => this.patchBranch(this.branch, response.sha));
}
@ -710,30 +747,29 @@ 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",
return content.then(contentBase64 =>
this.request(`${this.repoURL}/git/blobs`, {
method: 'POST',
body: JSON.stringify({
content: contentBase64,
encoding: "base64",
encoding: 'base64',
}),
}).then((response) => {
}).then(response => {
item.sha = response.sha;
item.uploaded = true;
return item;
}));
}),
);
}
updateTree(sha, path, fileTree) {
return this.getTree(sha)
.then((tree) => {
return this.getTree(sha).then(tree => {
let obj;
let filename;
let fileOrDir;
@ -742,7 +778,8 @@ export default class API {
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
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 });
@ -753,22 +790,30 @@ export default class API {
}
for (filename in fileTree) {
fileOrDir = fileTree[filename];
if (added[filename]) { continue; }
if (added[filename]) {
continue;
}
updates.push(
fileOrDir.file ?
{ path: filename, mode: "100644", type: "blob", sha: fileOrDir.sha } :
this.updateTree(null, filename, fileOrDir)
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 }));
.then(response => ({
path,
mode: '040000',
type: 'tree',
sha: response.sha,
parentSha: sha,
}));
});
}
createTree(baseSha, tree) {
return this.request(`${this.repoURL}/git/trees`, {
method: "POST",
method: 'POST',
body: JSON.stringify({ base_tree: baseSha, tree }),
});
}
@ -793,7 +838,7 @@ export default class API {
createCommit(message, treeSha, parents, author, committer) {
return this.request(`${this.repoURL}/git/commits`, {
method: "POST",
method: 'POST',
body: JSON.stringify({ message, tree: treeSha, parents, author, committer }),
});
}

View File

@ -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>
)}
/>

View File

@ -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');
});
});

View File

@ -1,7 +1,7 @@
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;
@ -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"))
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) => {
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) => {
})
.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 = {}) {
@ -128,8 +139,7 @@ export default class GitHub {
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,13 +150,19 @@ export default class GitHub {
}
unpublishedEntries() {
return this.api.listUnpublishedBranches().then((branches) => {
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) => {
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();
@ -161,16 +177,19 @@ export default class GitHub {
});
sem.leave();
}
}).catch(() => {
})
.catch(() => {
sem.leave();
resolve(null);
}));
}));
}),
);
}),
);
});
return Promise.all(promises);
})
.catch((error) => {
if (error.message === "Not Found") {
.catch(error => {
if (error.message === 'Not Found') {
return Promise.resolve([]);
}
return error;
@ -178,8 +197,7 @@ export default class GitHub {
}
unpublishedEntry(collection, slug) {
return this.api.readUnpublishedBranchFile(slug)
.then((data) => {
return this.api.readUnpublishedBranchFile(slug).then(data => {
if (!data) return null;
return {
slug,

View File

@ -1,4 +1,3 @@
export GitHubBackend from './implementation';
export API from './API';
export AuthenticationPage from './AuthenticationPage';

View File

@ -1,62 +1,64 @@
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.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([
buildRequest = req =>
flow([
unsentRequest.withRoot(this.api_root),
this.withAuthorizationHeaders,
unsentRequest.withTimestamp,
])(req);
request = async req => flow([
request = async req =>
flow([
this.buildRequest,
unsentRequest.performRequest,
p => p.catch(err => Promise.reject(new APIError(err.message, null, "GitLab"))),
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";
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 }) => {
hasWriteAccess = () =>
this.requestJSON(this.repoURL).then(({ permissions }) => {
const { project_access, group_access } = permissions;
if (project_access && (project_access.access_level >= this.WRITE_ACCESS)) {
if (project_access && project_access.access_level >= this.WRITE_ACCESS) {
return true;
}
if (group_access && (group_access.access_level >= this.WRITE_ACCESS)) {
if (group_access && group_access.access_level >= this.WRITE_ACCESS) {
return true;
}
return false;
@ -64,42 +66,54 @@ export default class API {
readFile = async (path, sha, ref = this.branch) => {
const cachedFile = sha ? await localForage.getItem(`gl.${sha}`) : null;
if (cachedFile) { return cachedFile; }
if (cachedFile) {
return cachedFile;
}
const result = await this.requestText({
url: `${this.repoURL}/repository/files/${encodeURIComponent(path)}/raw`,
params: { ref },
cache: "no-store",
cache: 'no-store',
});
if (sha) { localForage.setItem(`gl.${ sha }`, result) }
if (sha) {
localForage.setItem(`gl.${sha}`, result);
}
return result;
};
fileDownloadURL = (path, ref=this.branch) => unsentRequest.toURL(this.buildRequest({
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,9 +125,11 @@ 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"),
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 })),
@ -121,26 +137,30 @@ export default class API {
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
@ -154,13 +174,16 @@ export default class API {
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) };
};
@ -173,22 +196,25 @@ export default class API {
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,
@ -203,8 +229,8 @@ export default class API {
await this.request({
url: `${this.repoURL}/repository/commits`,
method: "POST",
headers: { "Content-Type": "application/json" },
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,7 +253,7 @@ 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,

View File

@ -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>
)}
/>

View File

@ -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 => {
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) => {
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) => {
})
.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,9 +159,10 @@ 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 })))),
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,
}));
}

View File

@ -1,4 +1,3 @@
export GitLabBackend from './implementation';
export API from './API';
export AuthenticationPage from './AuthenticationPage';

View File

@ -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);
};
@ -63,7 +63,7 @@ export default class AuthenticationPage extends React.Component {
<StyledAuthenticationPage>
<PageLogoIcon size="300px" type="netlify-cms" />
<LoginButton disabled={inProgress} onClick={this.handleLogin}>
{inProgress ? "Logging in..." : "Login"}
{inProgress ? 'Logging in...' : 'Login'}
</LoginButton>
</StyledAuthenticationPage>
);

View File

@ -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 },
@ -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 => ({
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];

View File

@ -6,7 +6,9 @@ module.exports = {
plugins: [
...babelConfig.plugins,
'react-hot-loader/babel',
['module-resolver', {
[
'module-resolver',
{
root: path.join(__dirname, 'src/components'),
alias: {
src: path.join(__dirname, 'src'),
@ -19,7 +21,8 @@ module.exports = {
Redux: path.join(__dirname, 'src/redux/'),
Routing: path.join(__dirname, 'src/routing/'),
ValueObjects: path.join(__dirname, 'src/valueObjects/'),
}
}],
},
},
],
],
};

View File

@ -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({
expect(
applyDefaults(
fromJS({
foo: 'bar',
media_folder: 'path/to/media',
}))).toEqual(fromJS({
}),
),
).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({
expect(
applyDefaults(
fromJS({
foo: 'bar',
media_folder: 'path/to/media',
public_folder: '/publib/path',
}))).toEqual(fromJS({
}),
),
).toEqual(
fromJS({
foo: 'bar',
publish_mode: 'simple',
media_folder: 'path/to/media',
public_folder: '/publib/path',
}));
}),
);
});
});
});

View File

@ -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({
.catch(error => {
dispatch(
notifSend({
message: `${error.message}`,
kind: 'warning',
dismissAfter: 8000,
}));
}),
);
dispatch(authError(error));
});
};

View File

@ -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];
});
}
@ -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;
}
};
}

View File

@ -97,7 +97,6 @@ function unpublishedEntriesFailed(error) {
};
}
function unpublishedEntryPersisting(collection, entry, transactionID) {
return {
type: UNPUBLISHED_ENTRY_PERSIST_REQUEST,
@ -130,7 +129,13 @@ 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: {
@ -143,7 +148,13 @@ 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: {
@ -223,18 +234,21 @@ export function loadUnpublishedEntry(collection, slug) {
const state = getState();
const backend = currentBackend(state.config);
dispatch(unpublishedEntryLoading(collection, slug));
backend.unpublishedEntry(collection, slug)
backend
.unpublishedEntry(collection, slug)
.then(entry => dispatch(unpublishedEntryLoaded(collection, entry)))
.catch((error) => {
.catch(error => {
if (error instanceof EditorialWorkflowError && error.notUnderEditorialWorkflow) {
dispatch(unpublishedEntryRedirected(collection, slug));
dispatch(loadEntry(collection, slug));
} else {
dispatch(notifSend({
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({
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.',
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({
dispatch(
notifSend({
message: 'Entry saved',
kind: 'success',
dismissAfter: 4000,
}));
}),
);
dispatch(unpublishedEntryPersisted(collection, serializedEntry, transactionID, newSlug));
}
catch(error) {
dispatch(notifSend({
} catch (error) {
dispatch(
notifSend({
message: `Failed to persist entry: ${error}`,
kind: 'danger',
dismissAfter: 8000,
}));
}),
);
return Promise.reject(dispatch(unpublishedEntryPersistedFail(error, transactionID)));
}
};
@ -331,22 +356,37 @@ 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)
dispatch(
unpublishedEntryStatusChangeRequest(collection, slug, oldStatus, newStatus, transactionID),
);
backend
.updateUnpublishedEntryStatus(collection, slug, newStatus)
.then(() => {
dispatch(notifSend({
dispatch(
notifSend({
message: 'Entry status updated',
kind: 'success',
dismissAfter: 4000,
}));
dispatch(unpublishedEntryStatusChangePersisted(collection, slug, oldStatus, newStatus, transactionID));
}),
);
dispatch(
unpublishedEntryStatusChangePersisted(
collection,
slug,
oldStatus,
newStatus,
transactionID,
),
);
})
.catch((error) => {
dispatch(notifSend({
.catch(error => {
dispatch(
notifSend({
message: `Failed to update status: ${error}`,
kind: 'danger',
dismissAfter: 8000,
}));
}),
);
dispatch(unpublishedEntryStatusChangeError(collection, slug, transactionID));
});
};
@ -358,21 +398,26 @@ export function deleteUnpublishedEntry(collection, slug) {
const backend = currentBackend(state.config);
const transactionID = uuid();
dispatch(unpublishedEntryDeleteRequest(collection, slug, transactionID));
return backend.deleteUnpublishedEntry(collection, slug)
return backend
.deleteUnpublishedEntry(collection, slug)
.then(() => {
dispatch(notifSend({
dispatch(
notifSend({
message: 'Unpublished changes deleted',
kind: 'success',
dismissAfter: 4000,
}));
}),
);
dispatch(unpublishedEntryDeleted(collection, slug, transactionID));
})
.catch((error) => {
dispatch(notifSend({
.catch(error => {
dispatch(
notifSend({
message: `Failed to delete unpublished changes: ${error}`,
kind: 'danger',
dismissAfter: 8000,
}));
}),
);
dispatch(unpublishedEntryDeleteError(collection, slug, transactionID));
});
};
@ -384,21 +429,26 @@ export function publishUnpublishedEntry(collection, slug) {
const backend = currentBackend(state.config);
const transactionID = uuid();
dispatch(unpublishedEntryPublishRequest(collection, slug, transactionID));
return backend.publishUnpublishedEntry(collection, slug)
return backend
.publishUnpublishedEntry(collection, slug)
.then(() => {
dispatch(notifSend({
dispatch(
notifSend({
message: 'Entry published',
kind: 'success',
dismissAfter: 4000,
}));
}),
);
dispatch(unpublishedEntryPublished(collection, slug, transactionID));
})
.catch((error) => {
dispatch(notifSend({
.catch(error => {
dispatch(
notifSend({
message: `Failed to publish: ${error}`,
kind: 'danger',
dismissAfter: 8000,
}));
}),
);
dispatch(unpublishedEntryPublishError(collection, slug, transactionID));
});
};

View File

@ -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({
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,10 +260,13 @@ 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)
provider
.listEntries(collection, page)
.then(response => ({
...response,
@ -274,10 +277,16 @@ export function loadEntries(collection, page = 0) {
// 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({
actions: ['next'],
meta: { usingOldPaginationAPI: true },
data: { nextPage: page + 1 },
})
: Cursor.create(response.cursor),
}))
.then(response => dispatch(entriesLoaded(
.then(response =>
dispatch(
entriesLoaded(
collection,
response.cursor.meta.get('usingOldPaginationAPI')
? response.entries.reverse()
@ -285,13 +294,17 @@ export function loadEntries(collection, page = 0) {
response.pagination,
addAppendActionsToCursor(response.cursor),
append,
)))
),
),
)
.catch(err => {
dispatch(notifSend({
dispatch(
notifSend({
message: `Failed to load entries: ${err}`,
kind: 'danger',
dismissAfter: 8000,
}));
}),
);
return Promise.reject(dispatch(entriesFailed(collection, err)));
});
};
@ -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({
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.',
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({
dispatch(
notifSend({
message: 'Entry saved',
kind: 'success',
dismissAfter: 4000,
}));
dispatch(entryPersisted(collection, serializedEntry, slug))
}),
);
dispatch(entryPersisted(collection, serializedEntry, slug));
})
.catch((error) => {
.catch(error => {
console.error(error);
dispatch(notifSend({
dispatch(
notifSend({
message: `Failed to persist entry: ${error}`,
kind: 'danger',
dismissAfter: 8000,
}));
}),
);
return Promise.reject(dispatch(entryPersistFail(collection, serializedEntry, error)));
});
};
@ -415,16 +439,19 @@ export function deleteEntry(collection, slug) {
const backend = currentBackend(state.config);
dispatch(entryDeleting(collection, slug));
return backend.deleteEntry(state.config, collection, slug)
return backend
.deleteEntry(state.config, collection, slug)
.then(() => {
return dispatch(entryDeleted(collection, slug));
})
.catch((error) => {
dispatch(notifSend({
.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)));
});

View File

@ -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()
setTimeout(() =>
resolve(
backend
.getMedia()
.then(files => dispatch(mediaLoaded(files)))
.catch((error) => dispatch(error.status === 404 ? mediaLoaded() : mediaLoadFailed()))
));
.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({
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({
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({
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 },
};
}

View File

@ -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)),
);
};
}

View File

@ -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,32 +37,33 @@ 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) => {
const slug = template
.replace(/\{\{([^}]+)\}\}/g, (_, field) => {
switch (field) {
case "year":
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":
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();
return entryData.get(field, '').trim();
}
})
// Convert slug to lower-case
@ -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) {
@ -98,12 +101,13 @@ const commitMessageFormatter = (type, config, { slug, path, collection }) => {
return '';
}
});
}
};
const extractSearchFields = searchFields => entry => searchFields.reduce((acc, field) => {
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,22 +118,24 @@ 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) => {
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);
@ -153,9 +159,11 @@ class Backend {
}
authenticate(credentials) {
return this.implementation.authenticate(credentials).then((user) => {
return this.implementation.authenticate(credentials).then(user => {
const newUser = { ...user, backendName: this.backendName };
if (this.authStore) { this.authStore.store(newUser); }
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"),
const entries = loadedEntries.map(loadedEntry =>
createEntry(
collection.get('name'),
selectEntrySlug(collection, loadedEntry.file.path),
loadedEntry.file.path,
{ raw: loadedEntry.data || '', label: loadedEntry.file.label }
));
{ 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,12 +196,10 @@ 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 => ({
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
@ -199,7 +207,7 @@ class Backend {
`collection` argument.
*/
cursor: Cursor.create(loadedEntries[CURSOR_COMPATIBILITY_SYMBOL]).wrapData({
cursorType: "collectionEntries",
cursorType: 'collectionEntries',
collection,
}),
}));
@ -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)
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,7 +242,8 @@ class Backend {
// collection, load it, search, and call onCollectionResults with
// its results.
const errors = [];
const collectionEntriesRequests = collections.map(async collection => {
const collectionEntriesRequests = collections
.map(async collection => {
// TODO: pass search fields in as an argument
const searchFields = [
selectInferedField(collection, 'title'),
@ -244,20 +254,25 @@ class Backend {
return fuzzy.filter(searchTerm, collectionEntries, {
extract: extractSearchFields(searchFields),
});
}).map(p => p.catch(err => errors.push(err) && []));
})
.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,25 +282,28 @@ 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,
}),
),
);
}
@ -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,10 +324,11 @@ class Backend {
}
unpublishedEntries(collections) {
return this.implementation.unpublishedEntries()
return this.implementation
.unpublishedEntries()
.then(loadedEntries => loadedEntries.filter(entry => entry !== null))
.then(entries => (
entries.map((loadedEntry) => {
.then(entries =>
entries.map(loadedEntry => {
const entry = createEntry(
loadedEntry.metaData.collection,
loadedEntry.slug,
@ -317,12 +336,12 @@ class Backend {
{
raw: loadedEntry.data,
isModification: loadedEntry.isModification,
}
},
);
entry.metaData = loadedEntry.metaData;
return entry;
})
))
}),
)
.then(entries => ({
pagination: 0,
entries: entries.reduce((acc, entry) => {
@ -336,13 +355,10 @@ class Backend {
}
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,
});
@ -353,40 +369,48 @@ class Backend {
}
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,34 +471,40 @@ 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();
@ -490,10 +519,12 @@ export function resolveBackend(config) {
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));
}
};
}());
})();

View File

@ -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';

View File

@ -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(), {
{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"]),
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>
);
}
@ -166,9 +167,15 @@ class App extends React.Component {
<Redirect exact from="/search/" to={defaultPath} />
{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 />
@ -200,5 +207,8 @@ function mapDispatchToProps(dispatch) {
}
export default hot(module)(
connect(mapStateToProps, mapDispatchToProps)(App)
connect(
mapStateToProps,
mapDispatchToProps,
)(App),
);

View File

@ -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);
@ -129,14 +129,12 @@ export default class Header extends React.Component {
<Icon type="page" />
Content
</AppHeaderNavLink>
{
hasWorkflow
? <AppHeaderNavLink to="/workflow" activeClassName="header-link-active">
{hasWorkflow ? (
<AppHeaderNavLink to="/workflow" activeClassName="header-link-active">
<Icon type="workflow" />
Workflow
</AppHeaderNavLink>
: null
}
) : null}
<AppHeaderButton onClick={openMediaLibrary}>
<Icon type="media-alt" />
Media
@ -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}

View File

@ -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};
`;

View File

@ -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,19 +30,21 @@ 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;
@ -51,10 +53,8 @@ class Collection extends React.Component {
<CollectionContainer>
<Sidebar collections={collections} searchTerm={searchTerm} />
<CollectionMain>
{
isSearchResults
? null
: <CollectionTop
{isSearchResults ? null : (
<CollectionTop
collectionLabel={collection.get('label')}
collectionLabelSingular={collection.get('label_singular')}
collectionDescription={collection.get('description')}
@ -62,7 +62,7 @@ class Collection extends React.Component {
viewStyle={this.state.viewStyle}
onChangeViewStyle={this.handleChangeViewStyle}
/>
}
)}
{isSearchResults ? this.renderEntriesSearch() : this.renderEntriesCollection()}
</CollectionMain>
</CollectionContainer>

View File

@ -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,19 +75,15 @@ const CollectionTop = ({
<CollectionTopContainer>
<CollectionTopRow>
<CollectionTopHeading>{collectionLabel}</CollectionTopHeading>
{
newEntryUrl
? <CollectionTopNewButton to={newEntryUrl}>
{newEntryUrl ? (
<CollectionTopNewButton to={newEntryUrl}>
{`New ${collectionLabelSingular || collectionLabel}`}
</CollectionTopNewButton>
: null
}
) : null}
</CollectionTopRow>
{
collectionDescription
? <CollectionTopDescription>{collectionDescription}</CollectionTopDescription>
: null
}
{collectionDescription ? (
<CollectionTopDescription>{collectionDescription}</CollectionTopDescription>
) : null}
<ViewControls>
<ViewControlsText>View as:</ViewControlsText>
<ViewControlsButton
@ -110,7 +106,7 @@ const CollectionTop = ({
CollectionTop.propTypes = {
collectionLabel: PropTypes.string.isRequired,
collectionDescription: PropTypes.string,
newEntryUrl: PropTypes.string
newEntryUrl: PropTypes.string,
};
export default CollectionTop;

View File

@ -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,

View File

@ -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,
@ -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);

View File

@ -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,13 +40,13 @@ 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);
}
@ -83,4 +83,7 @@ const mapDispatchToProps = {
clearSearch: actionClearSearch,
};
export default connect(mapStateToProps, mapDispatchToProps)(EntriesSearch);
export default connect(
mapStateToProps,
mapDispatchToProps,
)(EntriesSearch);

View File

@ -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,
@ -120,6 +121,6 @@ const EntryCard = ({
</GridCard>
);
}
}
};
export default EntryCard;

View File

@ -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)
{Map.isMap(collections)
? this.renderCardsForSingleCollection()
: this.renderCardsForMultipleCollections()
}
: this.renderCardsForMultipleCollections()}
<Waypoint onEnter={this.handleLoadMore} />
</CardsGrid>
</div>

View File

@ -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,
};
@ -110,7 +107,6 @@ export default class Sidebar extends React.Component {
);
};
render() {
const { collections } = this.props;
const { query } = this.state;

View File

@ -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));

View File

@ -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 =>
{errors &&
errors.map(
error =>
error.message &&
typeof error.message === 'string' &&
typeof error.message === 'string' && (
<li key={error.message.trim().replace(/[^a-z0-9]+/gi, '-')}>{error.message}</li>
)
}
),
)}
</ControlErrorsList>
<label
className={cx(

View File

@ -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,7 +50,9 @@ export default class ControlPane extends React.Component {
return (
<ControlPaneContainer>
{fields.map((field, i) => field.get('widget') === 'hidden' ? null :
{fields.map(
(field, i) =>
field.get('widget') === 'hidden' ? null : (
<EditorControl
key={i}
field={field}
@ -61,6 +63,7 @@ export default class ControlPane extends React.Component {
onValidate={onValidate}
processControlRef={this.processControlRef}
/>
),
)}
</ControlPaneContainer>
);

View File

@ -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);
});
@ -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,24 +133,26 @@ 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}.`,
};
this.validate({ error });
}
},
);
const error = {

View File

@ -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>
);

View File

@ -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

View File

@ -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) => {
const styleEls = getPreviewStyles().map((style, i) => {
if (style.raw) {
return <style key={i}>{style.value}</style>
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 = `

View File

@ -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

View File

@ -16,12 +16,14 @@ const EditorToggleButton = styled.button`
height: 40px;
padding: 0;
margin-bottom: 12px;
`
`;
const EditorToggle = ({ enabled, active, onClick, icon }) => !enabled ? null :
const EditorToggle = ({ enabled, active, onClick, icon }) =>
!enabled ? null : (
<EditorToggleButton onClick={onClick} isActive={active}>
<Icon type={icon} size="large" />
</EditorToggleButton>;
</EditorToggleButton>
);
EditorToggle.propTypes = {
enabled: PropTypes.bool,

View File

@ -27,11 +27,10 @@ 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),
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;
@ -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
isNewEntry || !deleteLabel ? null : (
<DeleteButton
key="delete-button"
onClick={hasUnpublishedChanges ? onDeleteUnpublishedChanges : onDelete}
>
{isDeleting ? 'Deleting...' : deleteLabel}
</DeleteButton>,
</DeleteButton>
),
];
};
@ -270,7 +279,8 @@ export default class EditorToolbar extends React.Component {
isNewEntry,
} = this.props;
if (currentStatus) {
return (<>
return (
<>
<ToolbarDropdown
dropdownTopOverlap="40px"
dropdownWidth="120px"
@ -301,32 +311,27 @@ export default class EditorToolbar extends React.Component {
<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
}
<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,11 +341,11 @@ 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>
@ -348,7 +353,9 @@ export default class EditorToolbar extends React.Component {
{hasWorkflow ? this.renderWorkflowSaveControls() : this.renderSimpleSaveControls()}
</ToolbarSubSectionFirst>
<ToolbarSubSectionLast>
{ hasWorkflow ? this.renderWorkflowPublishControls() : this.renderSimplePublishControls() }
{hasWorkflow
? this.renderWorkflowPublishControls()
: this.renderSimplePublishControls()}
</ToolbarSubSectionLast>
</ToolbarSectionMain>
<ToolbarSectionMeta>

View File

@ -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} />;
}
}
},
);
}

View File

@ -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 = {

View File

@ -10,7 +10,7 @@ const EmptyMessageContainer= styled.div`
justify-content: center;
align-items: center;
color: ${props => props.isPrivate && colors.textFieldBorder};
`
`;
const EmptyMessage = ({ content, isPrivate }) => (
<EmptyMessageContainer isPrivate={isPrivate}>

View File

@ -20,7 +20,6 @@ const IMAGE_EXTENSIONS_VIEWABLE = [ 'jpg', 'jpeg', 'webp', 'gif', 'png', 'bmp',
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,7 +67,9 @@ 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 tableData =
files &&
files.map(({ key, name, size, queryOrder, url, urlIsPublicPath }) => {
const ext = fileExtension(name).toLowerCase();
return {
key,
@ -152,8 +153,7 @@ class MediaLibrary extends React.Component {
return;
}
const file = files.find(file => selectedFile.key === file.key);
deleteMedia(file, { privateUpload })
.then(() => {
deleteMedia(file, { privateUpload }).then(() => {
this.setState({ selectedFile: {} });
});
};
@ -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);

View File

@ -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>
);

View File

@ -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>
);

View File

@ -1,13 +1,13 @@
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;
@ -15,11 +15,11 @@ const CardGrid = styled.div`
margin-left: -10px;
margin-right: -10px;
`
`;
const PaginatingMessage = styled.h1`
color: ${props => props.isPrivate && colors.textFieldBorder};
`
`;
const MediaLibraryCardGrid = ({
setScrollContainerRef,
@ -36,8 +36,7 @@ const MediaLibraryCardGrid = ({
}) => (
<CardGridContainer innerRef={setScrollContainerRef}>
<CardGrid>
{
mediaItems.map(file =>
{mediaItems.map(file => (
<MediaLibraryCard
key={file.key}
isSelected={isSelectedFile(file)}
@ -48,26 +47,25 @@ const MediaLibraryCardGrid = ({
margin={cardMargin}
isPrivate={isPrivate}
/>
)
}
))}
{!canLoadMore ? null : <Waypoint onEnter={onLoadMore} />}
</CardGrid>
{
!isPaginating ? null :
{!isPaginating ? null : (
<PaginatingMessage isPrivate={isPrivate}>{paginatingMessage}</PaginatingMessage>
}
)}
</CardGridContainer>
);
MediaLibraryCardGrid.propTypes = {
setScrollContainerRef: PropTypes.func.isRequired,
mediaItems: PropTypes.arrayOf(PropTypes.shape({
mediaItems: PropTypes.arrayOf(
PropTypes.shape({
key: PropTypes.string.isRequired,
isViewableImage: PropTypes.bool,
url: PropTypes.string,
name: PropTypes.string,
})).isRequired,
}),
).isRequired,
isSelectedFile: PropTypes.func.isRequired,
onAssetClick: PropTypes.func.isRequired,
canLoadMore: PropTypes.bool,

View File

@ -17,7 +17,7 @@ const CloseButton = styled.button`
display: flex;
justify-content: center;
align-items: center;
`
`;
const LibraryTitle = styled.h1`
line-height: 36px;
@ -25,7 +25,7 @@ const LibraryTitle = styled.h1`
text-align: left;
margin-bottom: 25px;
color: ${props => props.isPrivate && colors.textFieldBorder};
`
`;
const MediaLibraryHeader = ({ onClose, title, isPrivate }) => (
<div>

View File

@ -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,

View File

@ -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,7 +33,7 @@ const SearchIcon = styled(Icon)`
left: 6px;
z-index: 2;
transform: translate(0, -50%);
`
`;
const MediaLibrarySearch = ({ value, onChange, onKeyDown, placeholder, disabled }) => (
<SearchContainer>

View File

@ -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);
};

View File

@ -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>

View File

@ -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');

View File

@ -16,25 +16,25 @@ 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>
@ -44,13 +44,11 @@ const Avatar = ({ imageUrl }) => (
const SettingsDropdown = ({ displayUrl, imageUrl, onLogoutClick }) => (
<React.Fragment>
{
displayUrl
? <AppHeaderSiteLink href={displayUrl} target="_blank">
{displayUrl ? (
<AppHeaderSiteLink href={displayUrl} target="_blank">
{stripProtocol(displayUrl)}
</AppHeaderSiteLink>
: null
}
) : null}
<Dropdown
dropdownTopOverlap="50px"
dropdownWidth="100px"
@ -64,6 +62,6 @@ const SettingsDropdown = ({ displayUrl, imageUrl, onLogoutClick }) => (
<DropdownItem label="Log Out" onClick={onLogoutClick} />
</Dropdown>
</React.Fragment>
)
);
export default SettingsDropdown;

View File

@ -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,

View File

@ -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, {
export default connect(
mapStateToProps,
{
loadUnpublishedEntries,
updateUnpublishedEntryStatus,
publishUnpublishedEntry,
deleteUnpublishedEntry,
})(Workflow);
},
)(Workflow);

View File

@ -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>

View File

@ -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`
${props =>
props.name === 'draft' &&
css`
background-color: ${colors.statusDraftBackground};
color: ${colors.statusDraftText};
`}
${props => props.name === 'pending_review' && css`
${props =>
props.name === 'pending_review' &&
css`
background-color: ${colors.statusReviewBackground};
color: ${colors.statusReviewText};
`}
${props => props.name === 'pending_publish' && css`
${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 = {
@ -124,7 +133,7 @@ class WorkflowList extends React.Component {
window.alert(
`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,24 +152,28 @@ Please drag the card to the "Ready" column to enable publishing.`
key={currColumn}
onDrop={this.handleChangeStatus.bind(this, currColumn)}
>
{(connect, { isHovered }) => connect(
{(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>
)}
</div>,
)
}
</DropTarget>
));
}
return (
<div>
{
entries.map((entry) => {
{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 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']);
@ -174,7 +187,8 @@ Please drag the card to the "Ready" column to enable publishing.`
collection={collection}
ownStatus={ownStatus}
>
{connect => connect(
{connect =>
connect(
<div>
<WorkflowCard
collectionName={collection}
@ -188,21 +202,19 @@ Please drag the card to the "Ready" column to enable publishing.`
canPublish={canPublish}
onPublish={this.requestPublish.bind(this, collection, slug, ownStatus)}
/>
</div>
)}
</div>,
)
}
</DragSource>
);
})
}
})}
</div>
);
};
render() {
const columns = this.renderColumns(this.props.entries);
return (
<WorkflowListContainer>{columns}</WorkflowListContainer>
);
return <WorkflowListContainer>{columns}</WorkflowListContainer>;
}
}

View File

@ -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: [{
collections: [
{
name: 'posts',
label: 'Posts',
folder: '_posts',
fields: [{ name: 'title', label: 'title', widget: 'string' }],
}],
},
],
};
expect(() => {
validateConfig(config);
@ -65,19 +67,34 @@ 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");
});
});

View File

@ -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;

View File

@ -30,7 +30,19 @@ export const INFERABLE_FIELDS = {
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,

View File

@ -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(
{
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(
{
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(
{
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(
{
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(
{
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(
{
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(
{
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(
{
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(
{
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(
{
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(
{
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(
{
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(
{
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'),
);
});
});

View File

@ -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'),
);
});
});

View File

@ -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,7 +28,8 @@ export const extensionFormatters = {
html: FrontmatterInfer,
};
const formatByName = (name, customDelimiter) => ({
const formatByName = (name, customDelimiter) =>
({
yml: yamlFormatter,
yaml: yamlFormatter,
toml: tomlFormatter,
@ -42,7 +43,9 @@ const formatByName = (name, customDelimiter) => ({
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');

View File

@ -33,30 +33,31 @@ 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: ["{", "}"] },
export const getFormatOpts = format =>
({
yaml: { language: 'yaml', delimiters: '---' },
toml: { language: 'toml', delimiters: '+++' },
json: { language: 'json', delimiters: ['{', '}'] },
}[format]);
class FrontmatterFormatter {

View File

@ -5,5 +5,5 @@ export default {
toFile(data) {
return JSON.stringify(data, null, 2);
}
}
},
};

View File

@ -26,5 +26,5 @@ export default {
toFile(data, sortedKeys = []) {
return tomlify.toToml(data, { replace: outputReplacer, sort: sortKeys(sortedKeys) });
}
}
},
};

View File

@ -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) });
}
}
},
};

View File

@ -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);
}
};
}());
})();

View File

@ -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.';
}
@ -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);
}
@ -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,18 +77,21 @@ 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`, {
method: 'POST',
body: JSON.stringify({ requests: searchCollections }),
}).then((response) => {
const entries = response.results.map((result, index) => result.hits.map((hit) => {
}).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 };
});
@ -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') }`, {
return this.request(
`${this.searchURL}/indexes/${this.indexPrefix}${collection.get('name')}`,
{
params: { page },
}).then((response) => {
const entries = response.hits.map((hit) => {
},
).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,
});
});
}
}

View File

@ -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);
}
@ -36,7 +36,6 @@ export default class AssetStore {
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 }`, {
this.getToken().then(token =>
this.request(`${this.getSignedFormURL}/${assetID}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${ token }`,
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);
}
}

View File

@ -4,105 +4,95 @@ 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: '$' });
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', () => {
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.");
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');
});
@ -118,5 +108,4 @@ describe('sanitizeSlug', ()=> {
it('uses alternate replacements', () => {
expect(sanitizeSlug('test test ', Map({ sanitize_replacement: '_' }))).toEqual('test_test');
});
});

View File

@ -2,6 +2,6 @@ export default function consoleError(title, description) {
console.error(
`%c ⛔ ${title}\n` + `%c${description}\n\n`,
'color: black; font-weight: bold; font-size: 16px; line-height: 50px;',
'color: black;'
'color: black;',
);
}

View File

@ -1,5 +1,5 @@
import { Map } from 'immutable';
import EditorComponent from 'ValueObjects/EditorComponent'
import EditorComponent from 'ValueObjects/EditorComponent';
/**
* Global Registry Object
@ -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,7 +94,9 @@ 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.`);
} else {
@ -112,4 +109,3 @@ export function registerBackend(name, BackendClass) {
export function getBackend(name) {
return registry.backends[name];
}

View File

@ -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