begin scaffolding for lerna

This commit is contained in:
Shawn Erquhart
2018-07-03 15:47:15 -04:00
parent 26f7c38a9f
commit 768fcbaa1d
320 changed files with 50292 additions and 464 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 808 KiB

View File

@ -0,0 +1,173 @@
backend:
name: test-repo
display_url: https://example.com
media_folder: "assets/uploads"
publish_mode: editorial_workflow # optional, enables publishing workflow
collections: # A list of collections the CMS should be able to edit
- name: "posts" # Used in routes, ie.: /admin/collections/:slug/edit
label: "Posts" # Used in the UI
label_singular: "Post" # Used in the UI, ie: "New Post"
description: >
The description is a great place for tone setting, high level information, and editing
guidelines that are specific to a collection.
folder: "_posts"
slug: "{{year}}-{{month}}-{{day}}-{{slug}}"
create: true # Allow users to create new documents in this collection
fields: # The fields each document in this collection have
- {label: "Title", name: "title", widget: "string", tagname: "h1"}
- {label: "Publish Date", name: "date", widget: "datetime", format: "YYYY-MM-DD hh:mma"}
- {label: "Cover Image", name: "image", widget: "image", required: false, tagname: ""}
- {label: "Body", name: "body", widget: "markdown"}
meta:
- {label: "SEO Description", name: "description", widget: "text"}
- name: "faq" # Used in routes, ie.: /admin/collections/:slug/edit
label: "FAQ" # Used in the UI
folder: "_faqs"
create: true # Allow users to create new documents in this collection
fields: # The fields each document in this collection have
- {label: "Question", name: "title", widget: "string", tagname: "h1"}
- {label: "Answer", name: "body", widget: "markdown"}
- name: "settings"
label: "Settings"
delete: false # Prevent users from deleting documents in this collection
editor:
preview: false
files:
- name: "general"
label: "Site Settings"
file: "_data/settings.json"
description: "General Site Settings"
fields:
- {label: "Global title", name: "site_title", widget: "string"}
- label: "Post Settings"
name: posts
widget: "object"
fields:
- {label: "Number of posts on frontpage", name: front_limit, widget: number}
- {label: "Default Author", name: author, widget: string}
- {label: "Default Thumbnail", name: thumb, widget: image, class: "thumb"}
- name: "authors"
label: "Authors"
file: "_data/authors.yml"
description: "Author descriptions"
fields:
- name: authors
label: Authors
label_singular: "Author"
widget: list
fields:
- {label: "Name", name: "name", widget: "string"}
- {label: "Description", name: "description", widget: "markdown"}
- name: "kitchenSink" # all the things in one entry, for documentation and quick testing
label: "Kitchen Sink"
folder: "_sink"
create: true
fields:
- label: "Related Post"
name: "post"
widget: "relationKitchenSinkPost"
collection: "posts"
displayFields: ["title", "date"]
searchFields: ["title", "body"]
valueField: "title"
- {label: "Title", name: "title", widget: "string"}
- {label: "Boolean", name: "boolean", widget: "boolean", default: true}
- {label: "Text", name: "text", widget: "text"}
- {label: "Number", name: "number", widget: "number"}
- {label: "Markdown", name: "markdown", widget: "markdown"}
- {label: "Datetime", name: "datetime", widget: "datetime"}
- {label: "Date", name: "date", widget: "date"}
- {label: "Image", name: "image", widget: "image"}
- {label: "File", name: "file", widget: "file"}
- {label: "Select", name: "select", widget: "select", options: ["a", "b", "c"]}
- {label: "Hidden", name: "hidden", widget: "hidden", default: "hidden"}
- label: "Object"
name: "object"
widget: "object"
fields:
- label: "Related Post"
name: "post"
widget: "relationKitchenSinkPost"
collection: "posts"
searchFields: ["title", "body"]
valueField: "title"
- {label: "String", name: "string", widget: "string"}
- {label: "Boolean", name: "boolean", widget: "boolean", default: false}
- {label: "Text", name: "text", widget: "text"}
- {label: "Number", name: "number", widget: "number"}
- {label: "Markdown", name: "markdown", widget: "markdown"}
- {label: "Datetime", name: "datetime", widget: "datetime"}
- {label: "Date", name: "date", widget: "date"}
- {label: "Image", name: "image", widget: "image"}
- {label: "File", name: "file", widget: "file"}
- {label: "Select", name: "select", widget: "select", options: ["a", "b", "c"]}
- label: "List"
name: "list"
widget: "list"
fields:
- {label: "String", name: "string", widget: "string"}
- {label: "Boolean", name: "boolean", widget: "boolean"}
- {label: "Text", name: "text", widget: "text"}
- {label: "Number", name: "number", widget: "number"}
- {label: "Markdown", name: "markdown", widget: "markdown"}
- {label: "Datetime", name: "datetime", widget: "datetime"}
- {label: "Date", name: "date", widget: "date"}
- {label: "Image", name: "image", widget: "image"}
- {label: "File", name: "file", widget: "file"}
- {label: "Select", name: "select", widget: "select", options: ["a", "b", "c"]}
- label: "Object"
name: "object"
widget: "object"
fields:
- {label: "String", name: "string", widget: "string"}
- {label: "Boolean", name: "boolean", widget: "boolean"}
- {label: "Text", name: "text", widget: "text"}
- {label: "Number", name: "number", widget: "number"}
- {label: "Markdown", name: "markdown", widget: "markdown"}
- {label: "Datetime", name: "datetime", widget: "datetime"}
- {label: "Date", name: "date", widget: "date"}
- {label: "Image", name: "image", widget: "image"}
- {label: "File", name: "file", widget: "file"}
- {label: "Select", name: "select", widget: "select", options: ["a", "b", "c"]}
- label: "List"
name: "list"
widget: "list"
fields:
- label: "Related Post"
name: "post"
widget: "relationKitchenSinkPost"
collection: "posts"
searchFields: ["title", "body"]
valueField: "title"
- {label: "String", name: "string", widget: "string"}
- {label: "Boolean", name: "boolean", widget: "boolean"}
- {label: "Text", name: "text", widget: "text"}
- {label: "Number", name: "number", widget: "number"}
- {label: "Markdown", name: "markdown", widget: "markdown"}
- {label: "Datetime", name: "datetime", widget: "datetime"}
- {label: "Date", name: "date", widget: "date"}
- {label: "Image", name: "image", widget: "image"}
- {label: "File", name: "file", widget: "file"}
- {label: "Select", name: "select", widget: "select", options: ["a", "b", "c"]}
- {label: "Hidden", name: "hidden", widget: "hidden", default: "hidden"}
- label: "Object"
name: "object"
widget: "object"
fields:
- {label: "String", name: "string", widget: "string"}
- {label: "Boolean", name: "boolean", widget: "boolean"}
- {label: "Text", name: "text", widget: "text"}
- {label: "Number", name: "number", widget: "number"}
- {label: "Markdown", name: "markdown", widget: "markdown"}
- {label: "Datetime", name: "datetime", widget: "datetime"}
- {label: "Date", name: "date", widget: "date"}
- {label: "Image", name: "image", widget: "image"}
- {label: "File", name: "file", widget: "file"}
- {label: "Select", name: "select", widget: "select", options: ["a", "b", "c"]}

View File

@ -0,0 +1,21 @@
html,
body {
color: #444;
font-size: 14px;
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
body {
padding: 20px;
}
h1 {
margin-top: 20px;
color: #666;
font-weight: bold;
font-size: 32px;
}
img {
max-width: 100%;
}

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

27009
packages/netlify-cms-core/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,106 @@
{
"name": "netlify-cms-core",
"description": "Netlify CMS lets content editors work on structured content stored in git",
"version": "2.0.0-alpha.0",
"main": "dist/cms.js",
"lint-staged": {
"*.js": [
"eslint --fix",
"jest --findRelatedTests",
"git add"
]
},
"files": [
"src/",
"dist/"
],
"scripts": {
"start": "npm run dev",
"dev": "parcel example/index.html --open"
},
"keywords": [
"netlify",
"cms",
"content editing",
"static site generators",
"jamstack"
],
"license": "MIT",
"dependencies": {
"classnames": "^2.2.5",
"create-react-class": "^15.6.0",
"diacritics": "^1.3.0",
"fuzzy": "^0.1.1",
"gotrue-js": "^0.9.15",
"gray-matter": "^3.0.6",
"history": "^4.7.2",
"immutable": "^3.7.6",
"is-hotkey": "^0.1.1",
"js-base64": "^2.1.9",
"js-yaml": "^3.10.0",
"jwt-decode": "^2.1.0",
"lib": "^3.0.2",
"localforage": "^1.4.2",
"lodash": "^4.13.1",
"mdast-util-definitions": "^1.2.2",
"mdast-util-to-string": "^1.0.4",
"moment": "^2.11.2",
"netlify-cms-lib-auth": "file:../netlify-cms-lib-auth",
"netlify-cms-lib-util": "file:../netlify-cms-lib-util",
"netlify-cms-ui-default": "file:../netlify-cms-ui-default",
"prop-types": "^15.5.10",
"react": "^16.0.0",
"react-aria-menubutton": "^5.1.0",
"react-autosuggest": "^9.3.2",
"react-datetime": "^2.11.0",
"react-dnd": "^2.5.4",
"react-dnd-html5-backend": "^2.5.4",
"react-dom": "^16.0.0",
"react-frame-component": "^2.0.0",
"react-hot-loader": "^4.0.0",
"react-immutable-proptypes": "^2.1.0",
"react-is": "16.3.1",
"react-modal": "^3.1.5",
"react-redux": "^4.4.0",
"react-router-dom": "^4.2.2",
"react-router-redux": "^5.0.0-alpha.8",
"react-scroll-sync": "^0.4.0",
"react-sortable-hoc": "^0.6.8",
"react-split-pane": "^0.1.81",
"react-textarea-autosize": "^5.2.0",
"react-toggled": "^1.1.2",
"react-topbar-progress-indicator": "^2.0.0",
"react-transition-group": "^2.2.1",
"react-waypoint": "^7.1.0",
"redux": "^3.3.1",
"redux-notifications": "^4.0.1",
"redux-optimist": "^0.0.2",
"redux-thunk": "^1.0.3",
"rehype-parse": "^3.1.0",
"rehype-remark": "^2.0.0",
"rehype-stringify": "^3.0.0",
"remark-parse": "^3.0.1",
"remark-rehype": "^2.0.0",
"remark-stringify": "^3.0.1",
"sanitize-filename": "^1.6.1",
"semaphore": "^1.0.5",
"slate": "^0.30.0",
"slate-edit-list": "^0.10.1",
"slate-edit-table": "^0.12.0",
"slate-plain-serializer": "^0.4.0",
"slate-react": "0.10.11",
"slate-soft-break": "^0.6.0",
"toml-j0.4": "^1.1.1",
"tomlify-j0.4": "^3.0.0-alpha.0",
"unified": "^6.1.4",
"unist-builder": "^1.0.2",
"unist-util-visit-parents": "^1.1.1",
"url": "^0.11.0",
"uuid": "^3.1.0",
"what-input": "^5.0.3"
},
"devDependencies": {
"cross-env": "^5.2.0",
"parcel-bundler": "^1.9.4"
}
}

View File

@ -0,0 +1,131 @@
import { fromJS } from 'immutable';
import { applyDefaults, validateConfig } from '../config';
describe('config', () => {
describe('applyDefaults', () => {
it('should set publish_mode if not set', () => {
const config = fromJS({
foo: 'bar',
media_folder: 'path/to/media',
public_folder: '/path/to/media',
});
expect(
applyDefaults(config)
).toEqual(
config.set('publish_mode', 'simple')
);
});
it('should set publish_mode from config', () => {
const config = fromJS({
foo: 'bar',
publish_mode: 'complex',
media_folder: 'path/to/media',
public_folder: '/path/to/media',
});
expect(
applyDefaults(config)
).toEqual(
config
);
});
it('should set public_folder based on media_folder if not set', () => {
expect(applyDefaults(fromJS({
foo: 'bar',
media_folder: 'path/to/media',
}))).toEqual(fromJS({
foo: 'bar',
publish_mode: 'simple',
media_folder: 'path/to/media',
public_folder: '/path/to/media',
}));
});
it('should not overwrite public_folder if set', () => {
expect(applyDefaults(fromJS({
foo: 'bar',
media_folder: 'path/to/media',
public_folder: '/publib/path',
}))).toEqual(fromJS({
foo: 'bar',
publish_mode: 'simple',
media_folder: 'path/to/media',
public_folder: '/publib/path',
}));
});
});
describe('validateConfig', () => {
it('should return the config if no errors', () => {
const collections = [{
name: 'posts',
folder: '_posts',
fields: [{ name: 'title', label: 'title' }],
}];
const config = fromJS({
foo: 'bar',
backend: { name: 'bar' },
media_folder: 'baz',
collections,
});
expect(
validateConfig(config)
).toEqual(config);
});
it('should throw if backend is not defined in config', () => {
expect(() => {
validateConfig(fromJS({ foo: 'bar' }));
}).toThrowError('Error in configuration file: A `backend` wasn\'t found. Check your config.yml file.');
});
it('should throw if backend name is not defined in config', () => {
expect(() => {
validateConfig(fromJS({ foo: 'bar', backend: {} }));
}).toThrowError('Error in configuration file: A `backend.name` wasn\'t found. Check your config.yml file.');
});
it('should throw if backend name is not a string in config', () => {
expect(() => {
validateConfig(fromJS({ foo: 'bar', backend: { name: { } } }));
}).toThrowError('Error in configuration file: Your `backend.name` must be a string. Check your config.yml file.');
});
it('should throw if media_folder is not defined in config', () => {
expect(() => {
validateConfig(fromJS({ foo: 'bar', backend: { name: 'bar' } }));
}).toThrowError('Error in configuration file: A `media_folder` wasn\'t found. Check your config.yml file.');
});
it('should throw if media_folder is not a string in config', () => {
expect(() => {
validateConfig(fromJS({ foo: 'bar', backend: { name: 'bar' }, media_folder: {} }));
}).toThrowError('Error in configuration file: Your `media_folder` must be a string. Check your config.yml file.');
});
it('should throw if collections is not defined in config', () => {
expect(() => {
validateConfig(fromJS({ foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz' }));
}).toThrowError('Error in configuration file: A `collections` wasn\'t found. Check your config.yml file.');
});
it('should throw if collections not an array in config', () => {
expect(() => {
validateConfig(fromJS({ foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz', collections: {} }));
}).toThrowError('Error in configuration file: Your `collections` must be an array with at least one element. Check your config.yml file.');
});
it('should throw if collections is an empty array in config', () => {
expect(() => {
validateConfig(fromJS({ foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz', collections: [] }));
}).toThrowError('Error in configuration file: Your `collections` must be an array with at least one element. Check your config.yml file.');
});
it('should throw if collections is an array with a single null element in config', () => {
expect(() => {
validateConfig(fromJS({ foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz', collections: [null] }));
}).toThrowError('Error in configuration file: Your `collections` must be an array with at least one element. Check your config.yml file.');
});
});
});

View File

@ -0,0 +1,95 @@
import { actions as notifActions } from 'redux-notifications';
import { currentBackend } from 'Backends/backend';
const { notifSend } = notifActions;
export const AUTH_REQUEST = 'AUTH_REQUEST';
export const AUTH_SUCCESS = 'AUTH_SUCCESS';
export const AUTH_FAILURE = 'AUTH_FAILURE';
export const AUTH_REQUEST_DONE = 'AUTH_REQUEST_DONE';
export const LOGOUT = 'LOGOUT';
export function authenticating() {
return {
type: AUTH_REQUEST,
};
}
export function authenticate(userData) {
return {
type: AUTH_SUCCESS,
payload: userData,
};
}
export function authError(error) {
return {
type: AUTH_FAILURE,
error: 'Failed to authenticate',
payload: error,
};
}
export function doneAuthenticating() {
return {
type: AUTH_REQUEST_DONE,
};
}
export function logout() {
return {
type: LOGOUT,
};
}
// Check if user data token is cached and is valid
export function authenticateUser() {
return (dispatch, getState) => {
const state = getState();
const backend = currentBackend(state.config);
dispatch(authenticating());
return backend.currentUser()
.then((user) => {
if (user) {
dispatch(authenticate(user));
} else {
dispatch(doneAuthenticating());
}
})
.catch((error) => {
dispatch(authError(error));
dispatch(logoutUser());
});
};
}
export function loginUser(credentials) {
return (dispatch, getState) => {
const state = getState();
const backend = currentBackend(state.config);
dispatch(authenticating());
return backend.authenticate(credentials)
.then((user) => {
dispatch(authenticate(user));
})
.catch((error) => {
dispatch(notifSend({
message: `${ error.message }`,
kind: 'warning',
dismissAfter: 8000,
}));
dispatch(authError(error));
});
};
}
export function logoutUser() {
return (dispatch, getState) => {
const state = getState();
const backend = currentBackend(state.config);
Promise.resolve(backend.logout()).then(() => {
dispatch(logout());
});
};
}

View File

@ -0,0 +1,14 @@
import history from 'Routing/history';
import { getCollectionUrl, getNewEntryUrl } from 'Lib/urlHelper';
export function searchCollections(query) {
history.push(`/search/${query}`);
}
export function showCollection(collectionName) {
history.push(getCollectionUrl(collectionName));
}
export function createNewEntry(collectionName) {
history.push(getNewEntryUrl(collectionName));
}

View File

@ -0,0 +1,204 @@
import yaml from "js-yaml";
import { Map, List, fromJS } from "immutable";
import { trimStart, flow, isBoolean, get } from "lodash";
import { authenticateUser } from "Actions/auth";
import { formatByExtension, supportedFormats, frontmatterFormats } from "Formats/formats";
import { selectIdentifier } from "Reducers/collections";
import { IDENTIFIER_FIELDS } from "Constants/fieldInference";
import * as publishModes from "Constants/publishModes";
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' };
const configLinkEl = document.querySelector('link[rel="cms-config-url"]');
const isValidLink = configLinkEl && validTypes[configLinkEl.type] && get(configLinkEl, 'href');
if (isValidLink) {
const link = get(configLinkEl, 'href');
console.log(`Using config file path: "${link}"`);
return link;
}
return 'config.yml';
}
const defaults = {
publish_mode: publishModes.SIMPLE,
};
export function applyDefaults(config) {
return Map(defaults)
.mergeDeep(config)
.withMutations(map => {
/**
* Use media_folder as default public_folder.
*/
const defaultPublicFolder = `/${trimStart(map.get('media_folder'), '/')}`;
if (!map.get('public_folder')) {
map.set('public_folder', defaultPublicFolder);
}
});
}
function validateCollection(collection) {
const {
name,
folder,
files,
format,
extension,
frontmatter_delimiter: delimiter,
fields,
} = collection.toJS();
if (!folder && !files) {
throw new Error(`Unknown collection type for collection "${name}". Collections can be either Folder based or File based.`);
}
if (format && !supportedFormats.includes(format)) {
throw new Error(`Unknown collection format for collection "${name}". Supported formats are ${supportedFormats.join(',')}`);
}
if (!format && extension && !formatByExtension(extension)) {
// Cannot infer format from extension.
throw new Error(`Please set a format for collection "${name}". Supported formats are ${supportedFormats.join(',')}`);
}
if (delimiter && !frontmatterFormats.includes(format)) {
// Cannot set custom delimiter without explicit and proper frontmatter format declaration
throw new Error(`Please set a proper frontmatter format for collection "${name}" to use a custom delimiter. Supported frontmatter formats are yaml-frontmatter, toml-frontmatter, and json-frontmatter.`);
}
if (folder && !selectIdentifier(collection)) {
// Verify that folder-type collections have an identifier field for slug creation.
throw new Error(`Collection "${name}" must have a field that is a valid entry identifier. Supported fields are ${IDENTIFIER_FIELDS.join(', ')}.`);
}
}
export function validateConfig(config) {
if (!config.get('backend')) {
throw new Error("Error in configuration file: A `backend` wasn't found. Check your config.yml file.");
}
if (!config.getIn(['backend', 'name'])) {
throw new Error("Error in configuration file: A `backend.name` wasn't found. Check your config.yml file.");
}
if (typeof config.getIn(['backend', 'name']) !== 'string') {
throw new Error("Error in configuration file: Your `backend.name` must be a string. Check your config.yml file.");
}
if (!config.get('media_folder')) {
throw new Error("Error in configuration file: A `media_folder` wasn\'t found. Check your config.yml file.");
}
if (typeof config.get('media_folder') !== 'string') {
throw new Error("Error in configuration file: Your `media_folder` must be a string. Check your config.yml file.");
}
const slug_encoding = config.getIn(['slug', 'encoding'], "unicode");
if (slug_encoding !== "unicode" && slug_encoding !== "ascii") {
throw new Error("Error in configuration file: Your `slug.encoding` must be either `unicode` or `ascii`. Check your config.yml file.")
}
if (!isBoolean(config.getIn(['slug', 'clean_accents'], false))) {
throw new Error("Error in configuration file: Your `slug.clean_accents` must be a boolean. Check your config.yml file.");
}
if (!config.get('collections')) {
throw new Error("Error in configuration file: A `collections` wasn\'t found. Check your config.yml file.");
}
const collections = config.get('collections');
if (!List.isList(collections) || collections.isEmpty() || !collections.first()) {
throw new Error("Error in configuration file: Your `collections` must be an array with at least one element. Check your config.yml file.");
}
/**
* Validate Collections
*/
config.get('collections').forEach(validateCollection);
return config;
}
function mergePreloadedConfig(preloadedConfig, loadedConfig) {
const map = fromJS(loadedConfig) || Map();
return preloadedConfig ? preloadedConfig.mergeDeep(map) : map;
}
function parseConfig(data) {
const config = yaml.safeLoad(data);
if (typeof CMS_ENV === "string" && config[CMS_ENV]) {
Object.keys(config[CMS_ENV]).forEach((key) => {
config[key] = config[CMS_ENV][key];
});
}
return config;
}
async function getConfig(file, isPreloaded) {
const response = await fetch(file, { credentials: 'same-origin' });
if (response.status !== 200) {
if (isPreloaded) return parseConfig('');
throw new Error(`Failed to load config.yml (${ response.status })`);
}
const contentType = response.headers.get('Content-Type') || 'Not-Found';
const isYaml = contentType.indexOf('yaml') !== -1;
if (!isYaml) {
console.log(`Response for ${ file } was not yaml. (Content-Type: ${ contentType })`);
if (isPreloaded) return parseConfig('');
}
return parseConfig(await response.text());
}
export function configLoaded(config) {
return {
type: CONFIG_SUCCESS,
payload: config,
};
}
export function configLoading() {
return {
type: CONFIG_REQUEST,
};
}
export function configFailed(err) {
return {
type: CONFIG_FAILURE,
error: "Error loading config",
payload: err,
};
}
export function configDidLoad(config) {
return (dispatch) => {
dispatch(configLoaded(config));
};
}
export function mergeConfig(config) {
return { type: CONFIG_MERGE, payload: config };
}
export function loadConfig() {
if (window.CMS_CONFIG) {
return configDidLoad(fromJS(window.CMS_CONFIG));
}
return async (dispatch, getState) => {
dispatch(configLoading());
try {
const preloadedConfig = getState().config;
const configUrl = getConfigUrl();
const loadedConfig = await getConfig(configUrl, preloadedConfig && preloadedConfig.size > 1);
/**
* Merge any existing configuration so the result can be validated.
*/
const mergedConfig = mergePreloadedConfig(preloadedConfig, loadedConfig);
const config = flow(validateConfig, applyDefaults)(mergedConfig);
dispatch(configDidLoad(config));
dispatch(authenticateUser());
}
catch(err) {
dispatch(configFailed(err));
throw(err)
}
};
}

View File

@ -0,0 +1,403 @@
import uuid from 'uuid/v4';
import { actions as notifActions } from 'redux-notifications';
import { BEGIN, COMMIT, REVERT } from 'redux-optimist';
import { serializeValues } from 'Lib/serializeEntryValues';
import { currentBackend } from 'Backends/backend';
import { getAsset } from 'Reducers';
import { selectFields } from 'Reducers/collections';
import { status, EDITORIAL_WORKFLOW } from 'Constants/publishModes';
import { EditorialWorkflowError } from "ValueObjects/errors";
import { loadEntry } from './entries';
import ValidationErrorTypes from 'Constants/validationErrorTypes';
const { notifSend } = notifActions;
/*
* Contant Declarations
*/
export const UNPUBLISHED_ENTRY_REQUEST = 'UNPUBLISHED_ENTRY_REQUEST';
export const UNPUBLISHED_ENTRY_SUCCESS = 'UNPUBLISHED_ENTRY_SUCCESS';
export const UNPUBLISHED_ENTRY_REDIRECT = 'UNPUBLISHED_ENTRY_REDIRECT';
export const UNPUBLISHED_ENTRIES_REQUEST = 'UNPUBLISHED_ENTRIES_REQUEST';
export const UNPUBLISHED_ENTRIES_SUCCESS = 'UNPUBLISHED_ENTRIES_SUCCESS';
export const UNPUBLISHED_ENTRIES_FAILURE = 'UNPUBLISHED_ENTRIES_FAILURE';
export const UNPUBLISHED_ENTRY_PERSIST_REQUEST = 'UNPUBLISHED_ENTRY_PERSIST_REQUEST';
export const UNPUBLISHED_ENTRY_PERSIST_SUCCESS = 'UNPUBLISHED_ENTRY_PERSIST_SUCCESS';
export const UNPUBLISHED_ENTRY_PERSIST_FAILURE = 'UNPUBLISHED_ENTRY_PERSIST_FAILURE';
export const UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST = 'UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST';
export const UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS = 'UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS';
export const UNPUBLISHED_ENTRY_STATUS_CHANGE_FAILURE = 'UNPUBLISHED_ENTRY_STATUS_CHANGE_FAILURE';
export const UNPUBLISHED_ENTRY_PUBLISH_REQUEST = 'UNPUBLISHED_ENTRY_PUBLISH_REQUEST';
export const UNPUBLISHED_ENTRY_PUBLISH_SUCCESS = 'UNPUBLISHED_ENTRY_PUBLISH_SUCCESS';
export const UNPUBLISHED_ENTRY_PUBLISH_FAILURE = 'UNPUBLISHED_ENTRY_PUBLISH_FAILURE';
export const UNPUBLISHED_ENTRY_DELETE_REQUEST = 'UNPUBLISHED_ENTRY_DELETE_REQUEST';
export const UNPUBLISHED_ENTRY_DELETE_SUCCESS = 'UNPUBLISHED_ENTRY_DELETE_SUCCESS';
export const UNPUBLISHED_ENTRY_DELETE_FAILURE = 'UNPUBLISHED_ENTRY_DELETE_FAILURE';
/*
* Simple Action Creators (Internal)
*/
function unpublishedEntryLoading(collection, slug) {
return {
type: UNPUBLISHED_ENTRY_REQUEST,
payload: {
collection: collection.get('name'),
slug,
},
};
}
function unpublishedEntryLoaded(collection, entry) {
return {
type: UNPUBLISHED_ENTRY_SUCCESS,
payload: {
collection: collection.get('name'),
entry,
},
};
}
function unpublishedEntryRedirected(collection, slug) {
return {
type: UNPUBLISHED_ENTRY_REDIRECT,
payload: {
collection: collection.get('name'),
slug,
},
};
}
function unpublishedEntriesLoading() {
return {
type: UNPUBLISHED_ENTRIES_REQUEST,
};
}
function unpublishedEntriesLoaded(entries, pagination) {
return {
type: UNPUBLISHED_ENTRIES_SUCCESS,
payload: {
entries,
pages: pagination,
},
};
}
function unpublishedEntriesFailed(error) {
return {
type: UNPUBLISHED_ENTRIES_FAILURE,
error: 'Failed to load entries',
payload: error,
};
}
function unpublishedEntryPersisting(collection, entry, transactionID) {
return {
type: UNPUBLISHED_ENTRY_PERSIST_REQUEST,
payload: {
collection: collection.get('name'),
entry,
},
optimist: { type: BEGIN, id: transactionID },
};
}
function unpublishedEntryPersisted(collection, entry, transactionID, slug) {
return {
type: UNPUBLISHED_ENTRY_PERSIST_SUCCESS,
payload: {
collection: collection.get('name'),
entry,
slug,
},
optimist: { type: COMMIT, id: transactionID },
};
}
function unpublishedEntryPersistedFail(error, transactionID) {
return {
type: UNPUBLISHED_ENTRY_PERSIST_FAILURE,
payload: { error },
optimist: { type: REVERT, id: transactionID },
error,
};
}
function unpublishedEntryStatusChangeRequest(collection, slug, oldStatus, newStatus, transactionID) {
return {
type: UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST,
payload: {
collection,
slug,
oldStatus,
newStatus,
},
optimist: { type: BEGIN, id: transactionID },
};
}
function unpublishedEntryStatusChangePersisted(collection, slug, oldStatus, newStatus, transactionID) {
return {
type: UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS,
payload: {
collection,
slug,
oldStatus,
newStatus,
},
optimist: { type: COMMIT, id: transactionID },
};
}
function unpublishedEntryStatusChangeError(collection, slug, transactionID) {
return {
type: UNPUBLISHED_ENTRY_STATUS_CHANGE_FAILURE,
payload: { collection, slug },
optimist: { type: REVERT, id: transactionID },
};
}
function unpublishedEntryPublishRequest(collection, slug, transactionID) {
return {
type: UNPUBLISHED_ENTRY_PUBLISH_REQUEST,
payload: { collection, slug },
optimist: { type: BEGIN, id: transactionID },
};
}
function unpublishedEntryPublished(collection, slug, transactionID) {
return {
type: UNPUBLISHED_ENTRY_PUBLISH_SUCCESS,
payload: { collection, slug },
optimist: { type: COMMIT, id: transactionID },
};
}
function unpublishedEntryPublishError(collection, slug, transactionID) {
return {
type: UNPUBLISHED_ENTRY_PUBLISH_FAILURE,
payload: { collection, slug },
optimist: { type: REVERT, id: transactionID },
};
}
function unpublishedEntryDeleteRequest(collection, slug, transactionID) {
return {
type: UNPUBLISHED_ENTRY_DELETE_REQUEST,
payload: { collection, slug },
optimist: { type: BEGIN, id: transactionID },
};
}
function unpublishedEntryDeleted(collection, slug, transactionID) {
return {
type: UNPUBLISHED_ENTRY_DELETE_SUCCESS,
payload: { collection, slug },
optimist: { type: COMMIT, id: transactionID },
};
}
function unpublishedEntryDeleteError(collection, slug, transactionID) {
return {
type: UNPUBLISHED_ENTRY_DELETE_FAILURE,
payload: { collection, slug },
optimist: { type: REVERT, id: transactionID },
};
}
/*
* Exported Thunk Action Creators
*/
export function loadUnpublishedEntry(collection, slug) {
return (dispatch, getState) => {
const state = getState();
const backend = currentBackend(state.config);
dispatch(unpublishedEntryLoading(collection, slug));
backend.unpublishedEntry(collection, slug)
.then(entry => dispatch(unpublishedEntryLoaded(collection, entry)))
.catch((error) => {
if (error instanceof EditorialWorkflowError && error.notUnderEditorialWorkflow) {
dispatch(unpublishedEntryRedirected(collection, slug));
dispatch(loadEntry(collection, slug));
} else {
dispatch(notifSend({
message: `Error loading entry: ${ error }`,
kind: 'danger',
dismissAfter: 8000,
}));
}
});
};
}
export function loadUnpublishedEntries(collections) {
return (dispatch, getState) => {
const state = getState();
if (state.config.get('publish_mode') !== EDITORIAL_WORKFLOW) return;
const backend = currentBackend(state.config);
dispatch(unpublishedEntriesLoading());
backend.unpublishedEntries(collections)
.then(response => dispatch(unpublishedEntriesLoaded(response.entries, response.pagination)))
.catch(error => {
dispatch(notifSend({
message: `Error loading entries: ${ error }`,
kind: 'danger',
dismissAfter: 8000,
}));
dispatch(unpublishedEntriesFailed(error));
Promise.reject(error)
});
};
}
export function persistUnpublishedEntry(collection, existingUnpublishedEntry) {
return async (dispatch, getState) => {
const state = getState();
const entryDraft = state.entryDraft;
const fieldsErrors = entryDraft.get('fieldsErrors');
// Early return if draft contains validation errors
if (!fieldsErrors.isEmpty()) {
const hasPresenceErrors = fieldsErrors
.some(errors => errors.some(error => error.type && error.type === ValidationErrorTypes.PRESENCE));
if (hasPresenceErrors) {
dispatch(notifSend({
message: 'Oops, you\'ve missed a required field. Please complete before saving.',
kind: 'danger',
dismissAfter: 8000,
}));
}
return Promise.reject()
}
const backend = currentBackend(state.config);
const transactionID = uuid();
const assetProxies = entryDraft.get('mediaFiles').map(path => getAsset(state, path));
const entry = entryDraft.get('entry');
/**
* Serialize the values of any fields with registered serializers, and
* update the entry and entryDraft with the serialized values.
*/
const fields = selectFields(collection, entry.get('slug'));
const serializedData = serializeValues(entryDraft.getIn(['entry', 'data']), fields);
const serializedEntry = entry.set('data', serializedData);
const serializedEntryDraft = entryDraft.set('entry', serializedEntry);
dispatch(unpublishedEntryPersisting(collection, serializedEntry, transactionID));
const persistAction = existingUnpublishedEntry ? backend.persistUnpublishedEntry : backend.persistEntry;
const persistCallArgs = [
backend,
state.config,
collection,
serializedEntryDraft,
assetProxies.toJS(),
state.integrations,
];
try {
const newSlug = await persistAction.call(...persistCallArgs);
dispatch(notifSend({
message: 'Entry saved',
kind: 'success',
dismissAfter: 4000,
}));
dispatch(unpublishedEntryPersisted(collection, serializedEntry, transactionID, newSlug));
}
catch(error) {
dispatch(notifSend({
message: `Failed to persist entry: ${ error }`,
kind: 'danger',
dismissAfter: 8000,
}));
return Promise.reject(dispatch(unpublishedEntryPersistedFail(error, transactionID)));
}
};
}
export function updateUnpublishedEntryStatus(collection, slug, oldStatus, newStatus) {
return (dispatch, getState) => {
const state = getState();
const backend = currentBackend(state.config);
const transactionID = uuid();
dispatch(unpublishedEntryStatusChangeRequest(collection, slug, oldStatus, newStatus, transactionID));
backend.updateUnpublishedEntryStatus(collection, slug, newStatus)
.then(() => {
dispatch(notifSend({
message: 'Entry status updated',
kind: 'success',
dismissAfter: 4000,
}));
dispatch(unpublishedEntryStatusChangePersisted(collection, slug, oldStatus, newStatus, transactionID));
})
.catch((error) => {
dispatch(notifSend({
message: `Failed to update status: ${ error }`,
kind: 'danger',
dismissAfter: 8000,
}));
dispatch(unpublishedEntryStatusChangeError(collection, slug, transactionID));
});
};
}
export function deleteUnpublishedEntry(collection, slug) {
return (dispatch, getState) => {
const state = getState();
const backend = currentBackend(state.config);
const transactionID = uuid();
dispatch(unpublishedEntryDeleteRequest(collection, slug, transactionID));
return backend.deleteUnpublishedEntry(collection, slug)
.then(() => {
dispatch(notifSend({
message: 'Unpublished changes deleted',
kind: 'success',
dismissAfter: 4000,
}));
dispatch(unpublishedEntryDeleted(collection, slug, transactionID));
})
.catch((error) => {
dispatch(notifSend({
message: `Failed to delete unpublished changes: ${ error }`,
kind: 'danger',
dismissAfter: 8000,
}));
dispatch(unpublishedEntryDeleteError(collection, slug, transactionID));
});
};
}
export function publishUnpublishedEntry(collection, slug) {
return (dispatch, getState) => {
const state = getState();
const backend = currentBackend(state.config);
const transactionID = uuid();
dispatch(unpublishedEntryPublishRequest(collection, slug, transactionID));
return backend.publishUnpublishedEntry(collection, slug)
.then(() => {
dispatch(notifSend({
message: 'Entry published',
kind: 'success',
dismissAfter: 4000,
}));
dispatch(unpublishedEntryPublished(collection, slug, transactionID));
})
.catch((error) => {
dispatch(notifSend({
message: `Failed to publish: ${ error }`,
kind: 'danger',
dismissAfter: 8000,
}));
dispatch(unpublishedEntryPublishError(collection, slug, transactionID));
});
};
}

View File

@ -0,0 +1,433 @@
import { fromJS, List, Set } from 'immutable';
import { actions as notifActions } from 'redux-notifications';
import { serializeValues } from 'Lib/serializeEntryValues';
import { currentBackend } from 'Backends/backend';
import { getIntegrationProvider } from 'Integrations';
import { getAsset, selectIntegration } from 'Reducers';
import { selectFields } from 'Reducers/collections';
import { selectCollectionEntriesCursor } from 'Reducers/cursors';
import Cursor from 'ValueObjects/Cursor';
import { createEntry } from 'ValueObjects/Entry';
import ValidationErrorTypes from 'Constants/validationErrorTypes';
import isArray from 'lodash/isArray';
const { notifSend } = notifActions;
/*
* Contant Declarations
*/
export const ENTRY_REQUEST = 'ENTRY_REQUEST';
export const ENTRY_SUCCESS = 'ENTRY_SUCCESS';
export const ENTRY_FAILURE = 'ENTRY_FAILURE';
export const ENTRIES_REQUEST = 'ENTRIES_REQUEST';
export const ENTRIES_SUCCESS = 'ENTRIES_SUCCESS';
export const ENTRIES_FAILURE = 'ENTRIES_FAILURE';
export const DRAFT_CREATE_FROM_ENTRY = 'DRAFT_CREATE_FROM_ENTRY';
export const DRAFT_CREATE_EMPTY = 'DRAFT_CREATE_EMPTY';
export const DRAFT_DISCARD = 'DRAFT_DISCARD';
export const DRAFT_CHANGE = 'DRAFT_CHANGE';
export const DRAFT_CHANGE_FIELD = 'DRAFT_CHANGE_FIELD';
export const DRAFT_VALIDATION_ERRORS = 'DRAFT_VALIDATION_ERRORS';
export const ENTRY_PERSIST_REQUEST = 'ENTRY_PERSIST_REQUEST';
export const ENTRY_PERSIST_SUCCESS = 'ENTRY_PERSIST_SUCCESS';
export const ENTRY_PERSIST_FAILURE = 'ENTRY_PERSIST_FAILURE';
export const ENTRY_DELETE_REQUEST = 'ENTRY_DELETE_REQUEST';
export const ENTRY_DELETE_SUCCESS = 'ENTRY_DELETE_SUCCESS';
export const ENTRY_DELETE_FAILURE = 'ENTRY_DELETE_FAILURE';
/*
* Simple Action Creators (Internal)
* We still need to export them for tests
*/
export function entryLoading(collection, slug) {
return {
type: ENTRY_REQUEST,
payload: {
collection: collection.get('name'),
slug,
},
};
}
export function entryLoaded(collection, entry) {
return {
type: ENTRY_SUCCESS,
payload: {
collection: collection.get('name'),
entry,
},
};
}
export function entryLoadError(error, collection, slug) {
return {
type: ENTRY_FAILURE,
payload: {
error,
collection: collection.get('name'),
slug,
},
};
}
export function entriesLoading(collection) {
return {
type: ENTRIES_REQUEST,
payload: {
collection: collection.get('name'),
},
};
}
export function entriesLoaded(collection, entries, pagination, cursor, append = true) {
return {
type: ENTRIES_SUCCESS,
payload: {
collection: collection.get('name'),
entries,
page: pagination,
cursor: Cursor.create(cursor),
append,
},
};
}
export function entriesFailed(collection, error) {
return {
type: ENTRIES_FAILURE,
error: 'Failed to load entries',
payload: error.toString(),
meta: { collection: collection.get('name') },
};
}
export function entryPersisting(collection, entry) {
return {
type: ENTRY_PERSIST_REQUEST,
payload: {
collectionName: collection.get('name'),
entrySlug: entry.get('slug'),
},
};
}
export function entryPersisted(collection, entry, slug) {
return {
type: ENTRY_PERSIST_SUCCESS,
payload: {
collectionName: collection.get('name'),
entrySlug: entry.get('slug'),
/**
* Pass slug from backend for newly created entries.
*/
slug,
},
};
}
export function entryPersistFail(collection, entry, error) {
return {
type: ENTRY_PERSIST_FAILURE,
error: 'Failed to persist entry',
payload: {
collectionName: collection.get('name'),
entrySlug: entry.get('slug'),
error: error.toString(),
},
};
}
export function entryDeleting(collection, slug) {
return {
type: ENTRY_DELETE_REQUEST,
payload: {
collectionName: collection.get('name'),
entrySlug: slug,
},
};
}
export function entryDeleted(collection, slug) {
return {
type: ENTRY_DELETE_SUCCESS,
payload: {
collectionName: collection.get('name'),
entrySlug: slug,
},
};
}
export function entryDeleteFail(collection, slug, error) {
return {
type: ENTRY_DELETE_FAILURE,
payload: {
collectionName: collection.get('name'),
entrySlug: slug,
error: error.toString(),
},
};
}
export function emptyDraftCreated(entry) {
return {
type: DRAFT_CREATE_EMPTY,
payload: entry,
};
}
/*
* Exported simple Action Creators
*/
export function createDraftFromEntry(entry, metadata) {
return {
type: DRAFT_CREATE_FROM_ENTRY,
payload: { entry, metadata },
};
}
export function discardDraft() {
return {
type: DRAFT_DISCARD,
};
}
export function changeDraft(entry) {
return {
type: DRAFT_CHANGE,
payload: entry,
};
}
export function changeDraftField(field, value, metadata) {
return {
type: DRAFT_CHANGE_FIELD,
payload: { field, value, metadata },
};
}
export function changeDraftFieldValidation(field, errors) {
return {
type: DRAFT_VALIDATION_ERRORS,
payload: { field, errors },
};
}
/*
* Exported Thunk Action Creators
*/
export function loadEntry(collection, slug) {
return (dispatch, getState) => {
const state = getState();
const backend = currentBackend(state.config);
dispatch(entryLoading(collection, slug));
return backend.getEntry(collection, slug)
.then(loadedEntry => {
return dispatch(entryLoaded(collection, loadedEntry))
})
.catch((error) => {
console.error(error);
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 },
});
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) => {
if (collection.get('isFetching')) {
return;
}
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 append = !!(page && !isNaN(page) && page > 0);
dispatch(entriesLoading(collection));
provider.listEntries(collection, page)
.then(response => ({
...response,
// The only existing backend using the pagination system is the
// Algolia integration, which is also the only integration used
// to list entries. Thus, this checking for an integration can
// determine whether or not this is using the old integer-based
// pagination API. Other backends will simply store an empty
// cursor, which behaves identically to no cursor at all.
cursor: integration
? Cursor.create({ actions: ["next"], meta: { usingOldPaginationAPI: true }, data: { nextPage: page + 1 } })
: Cursor.create(response.cursor),
}))
.then(response => dispatch(entriesLoaded(
collection,
response.cursor.meta.get('usingOldPaginationAPI')
? response.entries.reverse()
: response.entries,
response.pagination,
addAppendActionsToCursor(response.cursor),
append,
)))
.catch(err => {
dispatch(notifSend({
message: `Failed to load entries: ${ err }`,
kind: 'danger',
dismissAfter: 8000,
}));
return Promise.reject(dispatch(entriesFailed(collection, err)));
});
};
}
function traverseCursor(backend, cursor, action) {
if (!cursor.actions.has(action)) {
throw new Error(`The current cursor does not support the pagination action "${ action }".`);
}
return backend.traverseCursor(cursor, action);
}
export function traverseCollectionCursor(collection, action) {
return async (dispatch, getState) => {
const state = getState();
if (state.entries.getIn(['pages', `${ collection.get('name') }`, 'isFetching',])) {
return;
}
const backend = currentBackend(state.config);
const { action: realAction, append } = appendActions.has(action)
? appendActions.get(action).toJS()
: { action, append: false };
const cursor = selectCollectionEntriesCursor(state.cursors, collection.get('name'));
// 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")));
}
try {
dispatch(entriesLoading(collection));
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));
} catch (err) {
console.error(err);
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) => {
const dataFields = {};
collection.get('fields', List()).forEach((field) => {
dataFields[field.get('name')] = field.get('default');
});
const newEntry = createEntry(collection.get('name'), '', '', { data: dataFields });
dispatch(emptyDraftCreated(newEntry));
};
}
export function persistEntry(collection) {
return (dispatch, getState) => {
const state = getState();
const entryDraft = state.entryDraft;
const fieldsErrors = entryDraft.get('fieldsErrors');
// Early return if draft contains validation errors
if (!fieldsErrors.isEmpty()) {
const hasPresenceErrors = fieldsErrors
.some(errors => errors.some(error => error.type && error.type === ValidationErrorTypes.PRESENCE));
if (hasPresenceErrors) {
dispatch(notifSend({
message: 'Oops, you\'ve missed a required field. Please complete before saving.',
kind: 'danger',
dismissAfter: 8000,
}));
}
return Promise.reject();
}
const backend = currentBackend(state.config);
const assetProxies = entryDraft.get('mediaFiles').map(path => getAsset(state, path));
const entry = entryDraft.get('entry');
/**
* Serialize the values of any fields with registered serializers, and
* update the entry and entryDraft with the serialized values.
*/
const fields = selectFields(collection, entry.get('slug'));
const serializedData = serializeValues(entryDraft.getIn(['entry', 'data']), fields);
const serializedEntry = entry.set('data', serializedData);
const serializedEntryDraft = entryDraft.set('entry', serializedEntry);
dispatch(entryPersisting(collection, serializedEntry));
return backend
.persistEntry(state.config, collection, serializedEntryDraft, assetProxies.toJS())
.then(slug => {
dispatch(notifSend({
message: 'Entry saved',
kind: 'success',
dismissAfter: 4000,
}));
dispatch(entryPersisted(collection, serializedEntry, slug))
})
.catch((error) => {
console.error(error);
dispatch(notifSend({
message: `Failed to persist entry: ${ error }`,
kind: 'danger',
dismissAfter: 8000,
}));
return Promise.reject(dispatch(entryPersistFail(collection, serializedEntry, error)));
});
};
}
export function deleteEntry(collection, slug) {
return (dispatch, getState) => {
const state = getState();
const backend = currentBackend(state.config);
dispatch(entryDeleting(collection, slug));
return backend.deleteEntry(state.config, collection, slug)
.then(() => {
return dispatch(entryDeleted(collection, slug));
})
.catch((error) => {
dispatch(notifSend({
message: `Failed to delete entry: ${ error }`,
kind: 'danger',
dismissAfter: 8000,
}));
console.error(error);
return Promise.reject(dispatch(entryDeleteFail(collection, slug, error)));
});
};
}

View File

@ -0,0 +1,10 @@
export const ADD_ASSET = 'ADD_ASSET';
export const REMOVE_ASSET = 'REMOVE_ASSET';
export function addAsset(assetProxy) {
return { type: ADD_ASSET, payload: assetProxy };
}
export function removeAsset(path) {
return { type: REMOVE_ASSET, payload: path };
}

View File

@ -0,0 +1,214 @@
import { actions as notifActions } from 'redux-notifications';
import { currentBackend } from 'Backends/backend';
import { createAssetProxy } from 'ValueObjects/AssetProxy';
import { getAsset, selectIntegration } from 'Reducers';
import { getIntegrationProvider } from 'Integrations';
import { addAsset } from './media';
import { sanitizeSlug } from "Lib/urlHelper";
const { notifSend } = notifActions;
export const MEDIA_LIBRARY_OPEN = 'MEDIA_LIBRARY_OPEN';
export const MEDIA_LIBRARY_CLOSE = 'MEDIA_LIBRARY_CLOSE';
export const MEDIA_INSERT = 'MEDIA_INSERT';
export const MEDIA_REMOVE_INSERTED = 'MEDIA_REMOVE_INSERTED';
export const MEDIA_LOAD_REQUEST = 'MEDIA_LOAD_REQUEST';
export const MEDIA_LOAD_SUCCESS = 'MEDIA_LOAD_SUCCESS';
export const MEDIA_LOAD_FAILURE = 'MEDIA_LOAD_FAILURE';
export const MEDIA_PERSIST_REQUEST = 'MEDIA_PERSIST_REQUEST';
export const MEDIA_PERSIST_SUCCESS = 'MEDIA_PERSIST_SUCCESS';
export const MEDIA_PERSIST_FAILURE = 'MEDIA_PERSIST_FAILURE';
export const MEDIA_DELETE_REQUEST = 'MEDIA_DELETE_REQUEST';
export const MEDIA_DELETE_SUCCESS = 'MEDIA_DELETE_SUCCESS';
export const MEDIA_DELETE_FAILURE = 'MEDIA_DELETE_FAILURE';
export function openMediaLibrary(payload) {
return { type: MEDIA_LIBRARY_OPEN, payload };
}
export function closeMediaLibrary() {
return { type: MEDIA_LIBRARY_CLOSE };
}
export function insertMedia(mediaPath) {
return { type: MEDIA_INSERT, payload: { mediaPath } };
}
export function removeInsertedMedia(controlID) {
return { type: MEDIA_REMOVE_INSERTED, payload: { controlID } };
}
export function loadMedia(opts = {}) {
const { delay = 0, query = '', page = 1, privateUpload } = opts;
return async (dispatch, getState) => {
const state = getState();
const backend = currentBackend(state.config);
const integration = selectIntegration(state, null, 'assetStore');
if (integration) {
const provider = getIntegrationProvider(state.integrations, backend.getToken, integration);
dispatch(mediaLoading(page));
try {
const files = await provider.retrieve(query, page, privateUpload);
const mediaLoadedOpts = {
page,
canPaginate: true,
dynamicSearch: true,
dynamicSearchQuery: query,
privateUpload,
};
return dispatch(mediaLoaded(files, mediaLoadedOpts));
}
catch(error) {
return dispatch(mediaLoadFailed({ privateUpload }));
}
}
dispatch(mediaLoading(page));
return new Promise(resolve => {
setTimeout(() => resolve(
backend.getMedia()
.then(files => dispatch(mediaLoaded(files)))
.catch((error) => dispatch(error.status === 404 ? mediaLoaded() : mediaLoadFailed()))
));
}, delay);
};
}
export function persistMedia(file, opts = {}) {
const { privateUpload } = opts;
return async (dispatch, getState) => {
const state = getState();
const backend = currentBackend(state.config);
const integration = selectIntegration(state, null, 'assetStore');
const files = state.mediaLibrary.get('files');
const fileName = sanitizeSlug(file.name.toLowerCase(), state.config.get('slug'));
const existingFile = files.find(existingFile => existingFile.name.toLowerCase() === fileName);
/**
* Check for existing files of the same name before persisting. If no asset
* store integration is used, files are being stored in Git, so we can
* expect file names to be unique. If an asset store is in use, file names
* may not be unique, so we forego this check.
*/
if (!integration && existingFile) {
if (!window.confirm(`${existingFile.name} already exists. Do you want to replace it?`)) {
return;
} else {
await dispatch(deleteMedia(existingFile, { privateUpload }));
}
}
dispatch(mediaPersisting());
try {
const assetProxy = await createAssetProxy(fileName, file, false, privateUpload);
dispatch(addAsset(assetProxy));
if (!integration) {
const asset = await backend.persistMedia(state.config, assetProxy);
return dispatch(mediaPersisted(asset));
}
return dispatch(mediaPersisted(assetProxy.asset, { privateUpload }));
}
catch(error) {
console.error(error);
dispatch(notifSend({
message: `Failed to persist media: ${ error }`,
kind: 'danger',
dismissAfter: 8000,
}));
return dispatch(mediaPersistFailed({ privateUpload }));
}
};
}
export function deleteMedia(file, opts = {}) {
const { privateUpload } = opts;
return (dispatch, getState) => {
const state = getState();
const backend = currentBackend(state.config);
const integration = selectIntegration(state, null, 'assetStore');
if (integration) {
const provider = getIntegrationProvider(state.integrations, backend.getToken, integration);
dispatch(mediaDeleting());
return provider.delete(file.id)
.then(() => {
return dispatch(mediaDeleted(file, { privateUpload }));
})
.catch(error => {
console.error(error);
dispatch(notifSend({
message: `Failed to delete media: ${ error.message }`,
kind: 'danger',
dismissAfter: 8000,
}));
return dispatch(mediaDeleteFailed({ privateUpload }));
});
}
dispatch(mediaDeleting());
return backend.deleteMedia(state.config, file.path)
.then(() => {
return dispatch(mediaDeleted(file));
})
.catch(error => {
console.error(error);
dispatch(notifSend({
message: `Failed to delete media: ${ error.message }`,
kind: 'danger',
dismissAfter: 8000,
}));
return dispatch(mediaDeleteFailed());
});
};
}
export function mediaLoading(page) {
return {
type: MEDIA_LOAD_REQUEST,
payload: { page },
}
}
export function mediaLoaded(files, opts = {}) {
return {
type: MEDIA_LOAD_SUCCESS,
payload: { files, ...opts }
};
}
export function mediaLoadFailed(error, opts = {}) {
const { privateUpload } = opts;
return { type: MEDIA_LOAD_FAILURE, payload: { privateUpload } };
}
export function mediaPersisting() {
return { type: MEDIA_PERSIST_REQUEST };
}
export function mediaPersisted(asset, opts = {}) {
const { privateUpload } = opts;
return {
type: MEDIA_PERSIST_SUCCESS,
payload: { file: asset, privateUpload },
};
}
export function mediaPersistFailed(error, opts = {}) {
const { privateUpload } = opts;
return { type: MEDIA_PERSIST_FAILURE, payload: { privateUpload } };
}
export function mediaDeleting() {
return { type: MEDIA_DELETE_REQUEST };
}
export function mediaDeleted(file, opts = {}) {
const { privateUpload } = opts;
return {
type: MEDIA_DELETE_SUCCESS,
payload: { file, privateUpload },
};
}
export function mediaDeleteFailed(error, opts = {}) {
const { privateUpload } = opts;
return { type: MEDIA_DELETE_FAILURE, payload: { privateUpload } };
}

View File

@ -0,0 +1,148 @@
import fuzzy from 'fuzzy';
import { currentBackend } from 'Backends/backend';
import { getIntegrationProvider } from 'Integrations';
import { selectIntegration, selectEntries } from 'Reducers';
import { selectInferedField } from 'Reducers/collections';
import { WAIT_UNTIL_ACTION } from 'Redux/middleware/waitUntilAction';
import { loadEntries, ENTRIES_SUCCESS } from './entries';
/*
* Contant Declarations
*/
export const SEARCH_ENTRIES_REQUEST = 'SEARCH_ENTRIES_REQUEST';
export const SEARCH_ENTRIES_SUCCESS = 'SEARCH_ENTRIES_SUCCESS';
export const SEARCH_ENTRIES_FAILURE = 'SEARCH_ENTRIES_FAILURE';
export const QUERY_REQUEST = 'INIT_QUERY';
export const QUERY_SUCCESS = 'QUERY_OK';
export const QUERY_FAILURE = 'QUERY_ERROR';
export const SEARCH_CLEAR = 'SEARCH_CLEAR';
/*
* Simple Action Creators (Internal)
* We still need to export them for tests
*/
export function searchingEntries(searchTerm) {
return {
type: SEARCH_ENTRIES_REQUEST,
payload: { searchTerm },
};
}
export function searchSuccess(searchTerm, entries, page) {
return {
type: SEARCH_ENTRIES_SUCCESS,
payload: {
searchTerm,
entries,
page,
},
};
}
export function searchFailure(searchTerm, error) {
return {
type: SEARCH_ENTRIES_FAILURE,
payload: {
searchTerm,
error,
},
};
}
export function querying(namespace, collection, searchFields, searchTerm) {
return {
type: QUERY_REQUEST,
payload: {
namespace,
collection,
searchFields,
searchTerm,
},
};
}
export function querySuccess(namespace, collection, searchFields, searchTerm, response) {
return {
type: QUERY_SUCCESS,
payload: {
namespace,
collection,
searchFields,
searchTerm,
response,
},
};
}
export function queryFailure(namespace, collection, searchFields, searchTerm, error) {
return {
type: QUERY_SUCCESS,
payload: {
namespace,
collection,
searchFields,
searchTerm,
error,
},
};
}
/*
* Exported simple Action Creators
*/
export function clearSearch() {
return { type: SEARCH_CLEAR };
}
/*
* Exported Thunk Action Creators
*/
// SearchEntries will search for complete entries in all collections.
export function searchEntries(searchTerm, page = 0) {
return (dispatch, getState) => {
dispatch(searchingEntries(searchTerm));
const state = getState();
const backend = currentBackend(state.config);
const allCollections = state.collections.keySeq().toArray();
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)
: backend.search(state.collections.valueSeq().toArray(), searchTerm);
return searchPromise.then(
response => dispatch(searchSuccess(searchTerm, response.entries, response.pagination)),
error => dispatch(searchFailure(searchTerm, error))
);
};
}
// Instead of searching for complete entries, query will search for specific fields
// in specific collections and return raw data (no entries).
export function query(namespace, collectionName, searchFields, searchTerm) {
return (dispatch, getState) => {
dispatch(querying(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 queryPromise = integration
? 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))
);
};
}

View File

@ -0,0 +1,503 @@
import { attempt, flatten, isError } from 'lodash';
import { fromJS, Map } from 'immutable';
import fuzzy from 'fuzzy';
import { resolveFormat } from "Formats/formats";
import { selectIntegration } from 'Reducers/integrations';
import {
selectListMethod,
selectEntrySlug,
selectEntryPath,
selectAllowNewEntries,
selectAllowDeletion,
selectFolderEntryExtension,
selectIdentifier,
selectInferedField,
} from "Reducers/collections";
import { createEntry } from "ValueObjects/Entry";
import { sanitizeSlug } from "Lib/urlHelper";
import TestRepoBackend from "./test-repo/implementation";
import GitHubBackend from "./github/implementation";
import GitLabBackend from "./gitlab/implementation";
import BitBucketBackend from "./bitbucket/implementation";
import GitGatewayBackend from "./git-gateway/implementation";
import { registerBackend, getBackend } from 'Lib/registry';
import Cursor, { CURSOR_COMPATIBILITY_SYMBOL } from '../valueObjects/Cursor';
/**
* Register internal backends
*/
registerBackend('git-gateway', GitGatewayBackend);
registerBackend('github', GitHubBackend);
registerBackend('gitlab', GitLabBackend);
registerBackend('bitbucket', BitBucketBackend);
registerBackend('test-repo', TestRepoBackend);
class LocalStorageAuthStore {
storageKey = "netlify-cms-user";
retrieve() {
const data = window.localStorage.getItem(this.storageKey);
return data && JSON.parse(data);
}
store(userData) {
window.localStorage.setItem(this.storageKey, JSON.stringify(userData));
}
logout() {
window.localStorage.removeItem(this.storageKey);
}
}
const slugFormatter = (collection, entryData, slugConfig) => {
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");
}
const slug = template.replace(/\{\{([^\}]+)\}\}/g, (_, field) => {
switch (field) {
case "year":
return date.getFullYear();
case "month":
return (`0${ date.getMonth() + 1 }`).slice(-2);
case "day":
return (`0${ date.getDate() }`).slice(-2);
case "hour":
return (`0${ date.getHours() }`).slice(-2);
case "minute":
return (`0${ date.getMinutes() }`).slice(-2);
case "second":
return (`0${ date.getSeconds() }`).slice(-2);
case "slug":
return identifier.trim();
default:
return entryData.get(field, "").trim();
}
})
// Convert slug to lower-case
.toLocaleLowerCase()
// Replace periods with dashes.
.replace(/[.]/g, '-');
return sanitizeSlug(slug, slugConfig);
};
const commitMessageTemplates = Map({
create: 'Create {{collection}} “{{slug}}”',
update: 'Update {{collection}} “{{slug}}”',
delete: 'Delete {{collection}} “{{slug}}”',
uploadMedia: 'Upload “{{path}}”',
deleteMedia: 'Delete “{{path}}”'
});
const commitMessageFormatter = (type, config, { slug, path, collection }) => {
const templates = commitMessageTemplates.merge(config.getIn(['backend', 'commit_messages'], Map()));
const messageTemplate = templates.get(type);
return messageTemplate.replace(/\{\{([^\}]+)\}\}/g, (_, variable) => {
switch (variable) {
case 'slug':
return slug;
case 'path':
return path;
case 'collection':
return collection.get('label');
default:
console.warn(`Ignoring unknown variable “${ variable }” in commit message template.`);
return '';
}
});
}
const extractSearchFields = searchFields => entry => searchFields.reduce((acc, field) => {
const f = entry.data[field];
return f ? `${acc} ${f}` : acc;
}, "");
const sortByScore = (a, b) => {
if (a.score > b.score) return -1;
if (a.score < b.score) return 1;
return 0;
};
class Backend {
constructor(implementation, { authStore = null, backendName, config } = {}) {
this.implementation = implementation.init(config, { updateUserCredentials: this.updateUserCredentials });
this.backendName = backendName;
this.authStore = authStore;
if (this.implementation === null) {
throw new Error("Cannot instantiate a Backend with no implementation");
}
}
currentUser() {
if (this.user) { return this.user; }
const stored = this.authStore && this.authStore.retrieve();
if (stored && stored.backendName === this.backendName) {
return Promise.resolve(this.implementation.restoreUser(stored)).then((user) => {
const newUser = {...user, backendName: this.backendName};
// return confirmed/rehydrated user object instead of stored
this.authStore.store(newUser);
return newUser;
});
}
return Promise.resolve(null);
}
updateUserCredentials = updatedCredentials => {
const storedUser = this.authStore && this.authStore.retrieve();
if (storedUser && storedUser.backendName === this.backendName) {
const newUser = { ...storedUser, ...updatedCredentials };
this.authStore.store(newUser);
return newUser;
}
};
authComponent() {
return this.implementation.authComponent();
}
authenticate(credentials) {
return this.implementation.authenticate(credentials).then((user) => {
const newUser = {...user, backendName: this.backendName};
if (this.authStore) { this.authStore.store(newUser); }
return newUser;
});
}
logout() {
return Promise.resolve(this.implementation.logout()).then(() => {
if (this.authStore) {
this.authStore.logout();
}
});
}
getToken = () => this.implementation.getToken();
processEntries(loadedEntries, collection) {
const collectionFilter = collection.get('filter');
const entries = loadedEntries.map(loadedEntry => createEntry(
collection.get("name"),
selectEntrySlug(collection, loadedEntry.file.path),
loadedEntry.file.path,
{ raw: loadedEntry.data || '', label: loadedEntry.file.label }
));
const formattedEntries = entries.map(this.entryWithFormat(collection));
// If this collection has a "filter" property, filter entries accordingly
const filteredEntries = collectionFilter
? this.filterEntries({ entries: formattedEntries }, collectionFilter)
: formattedEntries;
return filteredEntries;
}
listEntries(collection) {
const listMethod = this.implementation[selectListMethod(collection)];
const extension = selectFolderEntryExtension(collection);
return listMethod.call(this.implementation, collection, extension)
.then(loadedEntries => ({
entries: this.processEntries(loadedEntries, collection),
/*
Wrap cursors so we can tell which collection the cursor is
from. This is done to prevent traverseCursor from requiring a
`collection` argument.
*/
cursor: Cursor.create(loadedEntries[CURSOR_COMPATIBILITY_SYMBOL]).wrapData({
cursorType: "collectionEntries",
collection,
}),
}));
}
// The same as listEntries, except that if a cursor with the "next"
// action available is returned, it calls "next" on the cursor and
// repeats the process. Once there is no available "next" action, it
// returns all the collected entries. Used to retrieve all entries
// for local searches and queries.
async listAllEntries(collection) {
if (collection.get("folder") && this.implementation.allEntriesByFolder) {
const extension = selectFolderEntryExtension(collection);
return this.implementation.allEntriesByFolder(collection, extension)
.then(entries => this.processEntries(entries, collection));
}
const response = await this.listEntries(collection);
const { entries } = response;
let { cursor } = response;
while (cursor && cursor.actions.includes("next")) {
const { entries: newEntries, cursor: newCursor } = await this.traverseCursor(cursor, "next");
entries.push(...newEntries);
cursor = newCursor;
}
return entries;
}
async search(collections, searchTerm) {
// Perform a local search by requesting all entries. For each
// collection, load it, search, and call onCollectionResults with
// its results.
const errors = [];
const collectionEntriesRequests = collections.map(async collection => {
// TODO: pass search fields in as an argument
const searchFields = [
selectInferedField(collection, 'title'),
selectInferedField(collection, 'shortTitle'),
selectInferedField(collection, 'author'),
];
const collectionEntries = await this.listAllEntries(collection);
return fuzzy.filter(searchTerm, collectionEntries, {
extract: extractSearchFields(searchFields),
});
}).map(p => p.catch(err => errors.push(err) && []));
const entries = await Promise.all(collectionEntriesRequests).then(arrs => flatten(arrs));
if (errors.length > 0) {
throw new Error({ message: "Errors ocurred while searching entries locally!", errors });
}
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) })
.filter(entry => entry.score > 5)
.sort(sortByScore)
.map(f => f.original);
return { query: searchTerm, hits };
}
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)
.then(async ({ entries, cursor: newCursor }) => ({
entries: this.processEntries(entries, collection),
cursor: Cursor.create(newCursor).wrapData({
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 }
))
);
}
getMedia() {
return this.implementation.getMedia();
}
entryWithFormat(collectionOrEntity) {
return (entry) => {
const format = resolveFormat(collectionOrEntity, entry);
if (entry && entry.raw !== undefined) {
const data = (format && attempt(format.fromFile.bind(format, entry.raw))) || {};
if (isError(data)) console.error(data);
return Object.assign(entry, { data: isError(data) ? {} : data });
}
return format.fromFile(entry);
};
}
unpublishedEntries(collections) {
return this.implementation.unpublishedEntries()
.then(loadedEntries => loadedEntries.filter(entry => entry !== null))
.then(entries => (
entries.map((loadedEntry) => {
const entry = createEntry(
loadedEntry.metaData.collection,
loadedEntry.slug,
loadedEntry.file.path,
{
raw: loadedEntry.data,
isModification: loadedEntry.isModification,
}
);
entry.metaData = loadedEntry.metaData;
return entry;
})
))
.then(entries => ({
pagination: 0,
entries: entries.reduce((acc, entry) => {
const collection = collections.get(entry.collection);
if (collection) {
acc.push(this.entryWithFormat(collection)(entry));
}
return acc;
}, []),
}));
}
unpublishedEntry(collection, slug) {
return this.implementation.unpublishedEntry(collection, slug)
.then((loadedEntry) => {
const entry = createEntry(
"draft",
loadedEntry.slug,
loadedEntry.file.path,
{
raw: loadedEntry.data,
isModification: loadedEntry.isModification,
});
entry.metaData = loadedEntry.metaData;
return entry;
})
.then(this.entryWithFormat(collection, slug));
}
persistEntry(config, collection, entryDraft, MediaFiles, integrations, options = {}) {
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!"),
};
const entryData = entryDraft.getIn(["entry", "data"]).toJS();
let entryObj;
if (newEntry) {
if (!selectAllowNewEntries(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 path = selectEntryPath(collection, slug);
entryObj = {
path,
slug,
raw: this.entryToRaw(collection, entryDraft.get("entry")),
};
} else {
const path = entryDraft.getIn(["entry", "path"]);
const slug = entryDraft.getIn(["entry", "slug"]);
entryObj = {
path,
slug,
raw: this.entryToRaw(collection, entryDraft.get("entry")),
};
}
const commitMessage = commitMessageFormatter(newEntry ? 'create' : 'update', config, { collection, slug: entryObj.slug, path: entryObj.path });
const mode = config.get("publish_mode");
const collectionName = collection.get("name");
/**
* Determine whether an asset store integration is in use.
*/
const hasAssetStore = integrations && !!selectIntegration(integrations, null, 'assetStore');
const updatedOptions = { ...options, hasAssetStore };
const opts = { newEntry, parsedData, commitMessage, collectionName, mode, ...updatedOptions };
return this.implementation.persistEntry(entryObj, MediaFiles, opts)
.then(() => entryObj.slug);
}
persistMedia(config, file) {
const options = {
commitMessage: commitMessageFormatter('uploadMedia', config, { path: file.path }),
};
return this.implementation.persistMedia(file, options);
}
deleteEntry(config, collection, slug) {
const path = selectEntryPath(collection, slug);
if (!selectAllowDeletion(collection)) {
throw (new Error("Not allowed to delete entries in this collection"));
}
const commitMessage = commitMessageFormatter('delete', config, { collection, slug, path });
return this.implementation.deleteFile(path, commitMessage);
}
deleteMedia(config, path) {
const commitMessage = commitMessageFormatter('deleteMedia', config, { path });
return this.implementation.deleteFile(path, commitMessage);
}
persistUnpublishedEntry(...args) {
return this.persistEntry(...args, { unpublished: true });
}
updateUnpublishedEntryStatus(collection, slug, newStatus) {
return this.implementation.updateUnpublishedEntryStatus(collection, slug, newStatus);
}
publishUnpublishedEntry(collection, slug) {
return this.implementation.publishUnpublishedEntry(collection, slug);
}
deleteUnpublishedEntry(collection, slug) {
return this.implementation.deleteUnpublishedEntry(collection, slug);
}
entryToRaw(collection, entry) {
const format = resolveFormat(collection, entry.toJS());
const fieldsOrder = this.fieldsOrder(collection, entry);
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();
}
const files = collection.get('files');
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') }`);
}
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')
));
}
}
export function resolveBackend(config) {
const name = config.getIn(["backend", "name"]);
if (name == null) {
throw new Error("No backend defined in configuration");
}
const authStore = new LocalStorageAuthStore();
if (!getBackend(name)) {
throw new Error(`Backend not found: ${ name }`);
} else {
return new Backend(getBackend(name), { backendName: name, authStore, config });
}
}
export const currentBackend = (function () {
let backend = null;
return (config) => {
if (backend) { return backend; }
if (config.get("backend")) {
return backend = resolveBackend(config);
}
};
}());

View File

@ -0,0 +1,106 @@
import GithubAPI from "Backends/github/API";
import { APIError } from "ValueObjects/errors";
export default class API extends GithubAPI {
constructor(config) {
super(config);
this.api_root = config.api_root;
this.tokenPromise = config.tokenPromise;
this.commitAuthor = config.commitAuthor;
this.repoURL = "";
}
hasWriteAccess() {
return this.getBranch()
.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');
} 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 {
console.error("Problem fetching repo data from Git Gateway");
throw error;
}
});
}
getRequestHeaders(headers = {}) {
return this.tokenPromise()
.then((jwtToken) => {
const baseHeader = {
"Authorization": `Bearer ${ jwtToken }`,
"Content-Type": "application/json",
...headers,
};
return baseHeader;
});
}
urlFor(path, options) {
const cacheBuster = new Date().getTime();
const params = [`ts=${ cacheBuster }`];
if (options.params) {
for (const key in options.params) {
params.push(`${ key }=${ encodeURIComponent(options.params[key]) }`);
}
}
if (params.length) {
path += `?${ params.join("&") }`;
}
return this.api_root + path;
}
user() {
return Promise.resolve(this.commitAuthor);
}
request(path, options = {}) {
const url = this.urlFor(path, options);
let responseStatus;
return this.getRequestHeaders(options.headers || {})
.then(headers => fetch(url, { ...options, headers }))
.then((response) => {
responseStatus = response.status;
const contentType = response.headers.get("Content-Type");
if (contentType && contentType.match(/json/)) {
return this.parseJsonResponse(response);
}
const text = response.text();
if (!response.ok) {
return Promise.reject(text);
}
return text;
})
.catch(error => {
throw new APIError((error.message || error.msg), responseStatus, 'Git Gateway');
});
}
commit(message, changeTree) {
const commitParams = {
message,
tree: changeTree.sha,
parents: changeTree.parentSha ? [changeTree.parentSha] : [],
};
if (this.commitAuthor) {
commitParams.author = {
...this.commitAuthor,
date: new Date().toISOString(),
};
}
return this.request("/git/commits", {
method: "POST",
body: JSON.stringify(commitParams),
});
}
}

View File

@ -0,0 +1,45 @@
.nc-gitGatewayAuthenticationPage-root {
display: flex;
flex-flow: column nowrap;
align-items: center;
justify-content: center;
height: 100vh;
}
.nc-gitGatewayAuthenticationPage-form {
width: 350px;
margin-top: -80px;
& input {
background-color: #fff;
border-radius: var(--borderRadius);
font-size: 14px;
padding: 10px 10px;
margin-bottom: 15px;
margin-top: 6px;
width: 100%;
position: relative;
z-index: 1;
&:focus {
outline: none;
box-shadow: inset 0 0 0 2px var(--colorBlue);
}
}
}
.nc-gitGatewayAuthenticationPage-button {
@apply(--button);
@apply(--dropShadowDeep);
@apply(--buttonDefault);
@apply(--buttonGray);
padding: 0 30px;
display: block;
margin-top: 20px;
margin-left: auto;
}
.nc-gitGatewayAuthenticationPage-errorMsg {
color: var(--colorErrorText);
}

View File

@ -0,0 +1,135 @@
import PropTypes from 'prop-types';
import React from "react";
import { partial } from 'lodash';
import { Icon } from 'netlify-cms-ui-default';
let component = null;
if (window.netlifyIdentity) {
window.netlifyIdentity.on('login', (user) => {
component && component.handleIdentityLogin(user);
});
window.netlifyIdentity.on('logout', () => {
component && component.handleIdentityLogout();
});
}
export default class AuthenticationPage extends React.Component {
constructor(props) {
super(props);
component = this;
}
componentDidMount() {
if (!this.loggedIn && window.netlifyIdentity && window.netlifyIdentity.currentUser()) {
this.props.onLogin(window.netlifyIdentity.currentUser());
window.netlifyIdentity.close();
}
}
componentWillUnmount() {
component = null;
}
handleIdentityLogin = (user) => {
this.props.onLogin(user);
window.netlifyIdentity.close();
}
handleIdentityLogout = () => {
window.netlifyIdentity.open();
}
handleIdentity = () => {
const user = window.netlifyIdentity.currentUser();
if (user) {
this.props.onLogin(user);
} else {
window.netlifyIdentity.open();
}
}
static propTypes = {
onLogin: PropTypes.func.isRequired,
inProgress: PropTypes.bool.isRequired,
};
state = { email: "", password: "", errors: {} };
handleChange = (name, e) => {
this.setState({ ...this.state, [name]: e.target.value });
};
handleLogin = (e) => {
e.preventDefault();
const { email, password } = this.state;
const errors = {};
if (!email) {
errors.email = 'Make sure to enter your email.';
}
if (!password) {
errors.password = 'Please enter your password.';
}
if (Object.keys(errors).length > 0) {
this.setState({ errors });
return;
}
AuthenticationPage.authClient.login(this.state.email, this.state.password, true)
.then((user) => {
this.props.onLogin(user);
})
.catch((error) => {
this.setState({ errors: { server: error.description || error.msg || error }, loggingIn: false });
});
};
render() {
const { errors } = this.state;
const { error, inProgress } = this.props;
if (window.netlifyIdentity) {
return <section className="nc-gitGatewayAuthenticationPage-root">
<Icon className="nc-githubAuthenticationPage-logo" size="500px" type="netlify-cms"/>
<button className="nc-githubAuthenticationPage-button" onClick={this.handleIdentity}>
Login with Netlify Identity
</button>
</section>
}
return (
<section className="nc-gitGatewayAuthenticationPage-root">
<Icon className="nc-githubAuthenticationPage-logo" size="500px" type="netlify-cms"/>
<form className="nc-gitGatewayAuthenticationPage-form" onSubmit={this.handleLogin}>
{!error && <p>
<span className="nc-gitGatewayAuthenticationPage-errorMsg">{error}</span>
</p>}
{!errors.server && <p>
<span className="nc-gitGatewayAuthenticationPage-errorMsg">{errors.server}</span>
</p>}
<div className="nc-gitGatewayAuthenticationPage-errorMsg">{ errors.email || null }</div>
<input
type="text"
name="email"
placeholder="Email"
value={this.state.email}
onChange={partial(this.handleChange, 'email')}
/>
<div className="nc-gitGatewayAuthenticationPage-errorMsg">{ errors.password || null }</div>
<input
type="password"
name="password"
placeholder="Password"
value={this.state.password}
onChange={partial(this.handleChange, 'password')}
/>
<button className="nc-gitGatewayAuthenticationPage-button" disabled={inProgress}>
{inProgress ? "Logging in..." : "Login"}
</button>
</form>
</section>
);
}
}

View File

@ -0,0 +1,106 @@
import GithubAPI from "Backends/github/API";
import { APIError } from "ValueObjects/errors";
export default class API extends GithubAPI {
constructor(config) {
super(config);
this.api_root = config.api_root;
this.tokenPromise = config.tokenPromise;
this.commitAuthor = config.commitAuthor;
this.repoURL = "";
}
hasWriteAccess() {
return this.getBranch()
.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');
} 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 {
console.error("Problem fetching repo data from Git Gateway");
throw error;
}
});
}
getRequestHeaders(headers = {}) {
return this.tokenPromise()
.then((jwtToken) => {
const baseHeader = {
"Authorization": `Bearer ${ jwtToken }`,
"Content-Type": "application/json",
...headers,
};
return baseHeader;
});
}
urlFor(path, options) {
const cacheBuster = new Date().getTime();
const params = [`ts=${ cacheBuster }`];
if (options.params) {
for (const key in options.params) {
params.push(`${ key }=${ encodeURIComponent(options.params[key]) }`);
}
}
if (params.length) {
path += `?${ params.join("&") }`;
}
return this.api_root + path;
}
user() {
return Promise.resolve(this.commitAuthor);
}
request(path, options = {}) {
const url = this.urlFor(path, options);
let responseStatus;
return this.getRequestHeaders(options.headers || {})
.then(headers => fetch(url, { ...options, headers }))
.then((response) => {
responseStatus = response.status;
const contentType = response.headers.get("Content-Type");
if (contentType && contentType.match(/json/)) {
return this.parseJsonResponse(response);
}
const text = response.text();
if (!response.ok) {
return Promise.reject(text);
}
return text;
})
.catch(error => {
throw new APIError((error.message || error.msg), responseStatus, 'Git Gateway');
});
}
commit(message, changeTree) {
const commitParams = {
message,
tree: changeTree.sha,
parents: changeTree.parentSha ? [changeTree.parentSha] : [],
};
if (this.commitAuthor) {
commitParams.author = {
...this.commitAuthor,
date: new Date().toISOString(),
};
}
return this.request("/git/commits", {
method: "POST",
body: JSON.stringify(commitParams),
});
}
}

View File

@ -0,0 +1,25 @@
import { flow } from "lodash";
import unsentRequest from "netlify-cms-lib-util/unsentRequest";
import { then } from "netlify-cms-lib-util/promise";
import GitlabAPI from "Backends/gitlab/API";
export default class API extends GitlabAPI {
constructor(config) {
super(config);
this.tokenPromise = config.tokenPromise;
this.commitAuthor = config.commitAuthor;
this.repoURL = "";
}
authenticateRequest = async req => unsentRequest.withHeaders({
Authorization: `Bearer ${ await this.tokenPromise() }`,
}, req);
request = async req => flow([
this.buildRequest,
this.authenticateRequest,
then(unsentRequest.performRequest),
])(req);
hasWriteAccess = () => Promise.resolve(true)
}

View File

@ -0,0 +1,159 @@
import GoTrue from "gotrue-js";
import jwtDecode from 'jwt-decode';
import {List} from 'immutable';
import { get, pick, intersection } from "lodash";
import unsentRequest from "Lib/unsentRequest";
import GitHubBackend from "Backends/github/implementation";
import GitLabBackend from "Backends/gitlab/implementation";
import BitBucketBackend from "Backends/bitbucket/implementation";
import GitHubAPI from "./GitHubAPI";
import GitLabAPI from "./GitLabAPI";
import BitBucketAPI from "Backends/bitbucket/API";
import AuthenticationPage from "./AuthenticationPage";
const localHosts = {
localhost: true,
'127.0.0.1': true,
'0.0.0.0': true,
};
const defaults = {
identity: '/.netlify/identity',
gateway: '/.netlify/git',
};
function getEndpoint(endpoint, netlifySiteURL) {
if (localHosts[document.location.host.split(":").shift()] && netlifySiteURL && endpoint.match(/^\/\.netlify\//)) {
const parts = [];
if (netlifySiteURL) {
parts.push(netlifySiteURL);
if (!netlifySiteURL.match(/\/$/)) { parts.push("/"); }
}
parts.push(endpoint.replace(/^\//, ''));
return parts.join("");
}
return endpoint;
}
export default class GitGateway {
constructor(config) {
this.config = config;
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 backendTypeRegex = /\/(github|gitlab|bitbucket)\/?$/;
const backendTypeMatches = this.gatewayUrl.match(backendTypeRegex);
if (backendTypeMatches) {
this.backendType = backendTypeMatches[1];
this.gatewayUrl = this.gatewayUrl.replace(backendTypeRegex, "/");
} else {
this.backendType = null;
}
this.authClient = window.netlifyIdentity ? window.netlifyIdentity.gotrue : new GoTrue({ APIUrl });
AuthenticationPage.authClient = this.authClient;
this.backend = null;
}
requestFunction = req => this.tokenPromise()
.then(token => unsentRequest.withHeaders({ Authorization: `Bearer ${ token }` }, req))
.then(unsentRequest.performRequest);
authenticate(user) {
this.tokenPromise = user.jwt.bind(user);
return this.tokenPromise().then(async token => {
if (!this.backendType) {
const { github_enabled, gitlab_enabled, bitbucket_enabled, roles } = await fetch(`${ this.gatewayUrl }/settings`, {
headers: { Authorization: `Bearer ${ token }` },
}).then(res => res.json());
this.acceptRoles = roles;
if (github_enabled) {
this.backendType = "github";
} else if (gitlab_enabled) {
this.backendType = "gitlab";
} else if (bitbucket_enabled) {
this.backendType = "bitbucket";
}
}
if (this.acceptRoles && this.acceptRoles.length > 0) {
const userRoles = get(jwtDecode(token), 'app_metadata.roles', []);
const validRole = intersection(userRoles, this.acceptRoles).length > 0;
if (!validRole) {
throw new Error("You don't have sufficient permissions to access Netlify CMS");
}
}
const userData = {
name: user.user_metadata.name || user.email.split('@').shift(),
email: user.email,
avatar_url: user.user_metadata.avatar_url,
metadata: user.user_metadata,
};
const apiConfig = {
api_root: `${ this.gatewayUrl }/${ this.backendType }`,
branch: this.branch,
tokenPromise: this.tokenPromise,
commitAuthor: pick(userData, ["name", "email"]),
squash_merges: this.squash_merges,
};
if (this.backendType === "github") {
this.api = new GitHubAPI(apiConfig);
this.backend = new GitHubBackend(this.config, { proxied: true, API: this.api });
} else if (this.backendType === "gitlab") {
this.api = new GitLabAPI(apiConfig);
this.backend = new GitLabBackend(this.config, { proxied: true, API: this.api });
} else if (this.backendType === "bitbucket") {
this.api = new BitBucketAPI({
...apiConfig,
requestFunction: this.requestFunction,
hasWriteAccess: async () => true,
});
console.log(this.api);
this.backend = new BitBucketBackend(this.config, { proxied: true, API: this.api });
}
if (!(await this.api.hasWriteAccess())) {
throw new Error("You don't have sufficient permissions to access Netlify CMS");
}
});
}
restoreUser() {
const user = this.authClient && this.authClient.currentUser();
if (!user) return Promise.reject();
return this.authenticate(user);
}
authComponent() {
return AuthenticationPage;
}
logout() {
if (window.netlifyIdentity) {
return window.netlifyIdentity.logout();
}
const user = this.authClient.currentUser();
return user && user.logout();
}
getToken() {
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); }
}

View File

@ -0,0 +1,797 @@
import localForage from "netlify-cms-lib-util/localForage";
import { Base64 } from "js-base64";
import { uniq, initial, last, get, find, hasIn } from "lodash";
import { filterPromises, resolvePromiseProperties } from "netlify-cms-lib-util/promise";
import AssetProxy from "ValueObjects/AssetProxy";
import { SIMPLE, EDITORIAL_WORKFLOW, status } from "Constants/publishModes";
import { APIError, EditorialWorkflowError } from "ValueObjects/errors";
const CMS_BRANCH_PREFIX = 'cms/';
export default class API {
constructor(config) {
this.api_root = config.api_root || "https://api.github.com";
this.token = config.token || false;
this.branch = config.branch || "master";
this.repo = config.repo || "";
this.repoURL = `/repos/${ this.repo }`;
this.merge_method = config.squash_merges ? "squash" : "merge";
}
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");
throw error;
});
}
requestHeaders(headers = {}) {
const baseHeader = {
"Content-Type": "application/json",
...headers,
};
if (this.token) {
baseHeader.Authorization = `token ${ this.token }`;
return baseHeader;
}
return baseHeader;
}
parseJsonResponse(response) {
return response.json().then((json) => {
if (!response.ok) {
return Promise.reject(json);
}
return json;
});
}
urlFor(path, options) {
const cacheBuster = new Date().getTime();
const params = [`ts=${cacheBuster}`];
if (options.params) {
for (const key in options.params) {
params.push(`${ key }=${ encodeURIComponent(options.params[key]) }`);
}
}
if (params.length) {
path += `?${ params.join("&") }`;
}
return this.api_root + path;
}
request(path, options = {}) {
const headers = this.requestHeaders(options.headers || {});
const url = this.urlFor(path, options);
let responseStatus;
return fetch(url, { ...options, headers }).then((response) => {
responseStatus = response.status;
const contentType = response.headers.get("Content-Type");
if (contentType && contentType.match(/json/)) {
return this.parseJsonResponse(response);
}
const text = response.text();
if (!response.ok) {
return Promise.reject(text);
}
return text;
})
.catch((error) => {
throw new APIError(error.message, responseStatus, 'GitHub');
});
}
generateBranchName(basename) {
return `${CMS_BRANCH_PREFIX}${basename}`;
}
checkMetadataRef() {
return this.request(`${ this.repoURL }/git/refs/meta/_netlify_cms?${ Date.now() }`, {
cache: "no-store",
})
.then(response => response.object)
.catch((error) => {
// Meta ref doesn't exist
const readme = {
raw: "# Netlify CMS\n\nThis tree is used by the Netlify CMS to store metadata information for specific files and branches.",
};
return this.uploadBlob(readme)
.then(item => this.request(`${ this.repoURL }/git/trees`, {
method: "POST",
body: JSON.stringify({ tree: [{ path: "README.md", mode: "100644", type: "blob", sha: item.sha }] }),
}))
.then(tree => this.commit("First Commit", tree))
.then(response => this.createRef("meta", "_netlify_cms", response.sha))
.then(response => response.object);
});
}
storeMetadata(key, data) {
return this.checkMetadataRef()
.then((branchData) => {
const fileTree = {
[`${ key }.json`]: {
path: `${ key }.json`,
raw: JSON.stringify(data),
file: true,
},
};
return this.uploadBlob(fileTree[`${ key }.json`])
.then(item => this.updateTree(branchData.sha, "/", fileTree))
.then(changeTree => this.commit(`Updating “${ key }” metadata`, changeTree))
.then(response => this.patchRef("meta", "_netlify_cms", response.sha))
.then(() => {
localForage.setItem(`gh.meta.${ key }`, {
expires: Date.now() + 300000, // In 5 minutes
data,
});
});
});
}
retrieveMetadata(key) {
const cache = localForage.getItem(`gh.meta.${ key }`);
return cache.then((cached) => {
if (cached && cached.expires > Date.now()) { return cached.data; }
console.log("%c Checking for MetaData files", "line-height: 30px;text-align: center;font-weight: bold"); // eslint-disable-line
return this.request(`${ this.repoURL }/contents/${ key }.json`, {
params: { ref: "refs/meta/_netlify_cms" },
headers: { Accept: "application/vnd.github.VERSION.raw" },
cache: "no-store",
})
.then(response => JSON.parse(response))
.catch(error => console.log("%c %s does not have metadata", "line-height: 30px;text-align: center;font-weight: bold", key)); // eslint-disable-line
});
}
readFile(path, sha, branch = this.branch) {
if (sha) {
return this.getBlob(sha);
} else {
return this.request(`${ this.repoURL }/contents/${ path }`, {
headers: { Accept: "application/vnd.github.VERSION.raw" },
params: { ref: branch },
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('/');
return this.listFiles(dir)
.then(files => files.find(file => file.path === path))
.then(file => this.getBlob(file.sha));
}
throw error;
});
}
}
getBlob(sha) {
return localForage.getItem(`gh.${sha}`).then(cached => {
if (cached) { return cached; }
return this.request(`${this.repoURL}/git/blobs/${sha}`, {
headers: { Accept: "application/vnd.github.VERSION.raw" },
}).then(result => {
localForage.setItem(`gh.${sha}`, result);
return result;
});
});
}
listFiles(path) {
return this.request(`${ this.repoURL }/contents/${ path.replace(/\/$/, '') }`, {
params: { ref: this.branch },
})
.then(files => {
if (!Array.isArray(files)) {
throw new Error(`Cannot list files, path ${path} is not a directory but a ${files.type}`);
}
return files;
})
.then(files => files.filter(file => file.type === "file"));
}
readUnpublishedBranchFile(contentKey) {
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(() => {
throw new EditorialWorkflowError('content is not under editorial workflow', true);
});
}
isUnpublishedEntryModification(path, branch) {
return this.readFile(path, null, branch)
.then(data => true)
.catch((err) => {
if (err.message && err.message === "Not Found") {
return false;
}
throw err;
});
}
listUnpublishedBranches() {
console.log("%c Checking for Unpublished entries", "line-height: 30px;text-align: center;font-weight: bold"); // eslint-disable-line
return this.request(`${ this.repoURL }/git/refs/heads/cms`)
.then(branches => filterPromises(branches, (branch) => {
const branchName = branch.ref.substring("/refs/heads/".length - 1);
// Get PRs with a `head` of `branchName`. Note that this is a
// substring match, so we need to check that the `head.ref` of
// at least one of the returned objects matches `branchName`.
return this.request(`${ this.repoURL }/pulls`, {
params: {
head: branchName,
state: 'open',
base: this.branch,
},
})
.then(prs => prs.some(pr => pr.head.ref === branchName));
}))
.catch((error) => {
console.log("%c No Unpublished entries", "line-height: 30px;text-align: center;font-weight: bold"); // eslint-disable-line
throw error;
});
}
composeFileTree(files) {
let filename;
let part;
let parts;
let subtree;
const fileTree = {};
files.forEach((file) => {
if (file.uploaded) { return; }
parts = file.path.split("/").filter(part => part);
filename = parts.pop();
subtree = fileTree;
while (part = parts.shift()) {
subtree[part] = subtree[part] || {};
subtree = subtree[part];
}
subtree[filename] = file;
file.file = true;
});
return fileTree;
}
persistFiles(entry, mediaFiles, options) {
const uploadPromises = [];
const files = entry ? mediaFiles.concat(entry) : mediaFiles;
files.forEach((file) => {
if (file.uploaded) { return; }
uploadPromises.push(this.uploadBlob(file));
});
const fileTree = this.composeFileTree(files);
return Promise.all(uploadPromises).then(() => {
if (!options.mode || (options.mode && options.mode === SIMPLE)) {
return this.getBranch()
.then(branchData => this.updateTree(branchData.commit.sha, "/", fileTree))
.then(changeTree => this.commit(options.commitMessage, changeTree))
.then(response => this.patchBranch(this.branch, response.sha));
} else if (options.mode && options.mode === EDITORIAL_WORKFLOW) {
const mediaFilesList = mediaFiles.map(file => ({ path: file.path, sha: file.sha }));
return this.editorialWorkflowGit(fileTree, entry, mediaFilesList, options);
}
});
}
deleteFile(path, message, options={}) {
const branch = options.branch || this.branch;
const pathArray = path.split('/');
const filename = last(pathArray);
const directory = initial(pathArray).join('/');
const fileDataPath = encodeURIComponent(directory);
const fileDataURL = `${this.repoURL}/git/trees/${branch}:${fileDataPath}`;
const fileURL = `${ this.repoURL }/contents/${ path }`;
/**
* We need to request the tree first to get the SHA. We use extended SHA-1
* syntax (<rev>:<path>) to get a blob from a tree without having to recurse
* through the tree.
*/
return this.request(fileDataURL, { cache: 'no-store' })
.then(resp => {
const { sha } = resp.tree.find(file => file.path === filename);
const opts = { method: 'DELETE', params: { sha, message, branch } };
return this.request(fileURL, opts);
});
}
editorialWorkflowGit(fileTree, entry, filesList, options) {
const contentKey = entry.slug;
const branchName = this.generateBranchName(contentKey);
const unpublished = options.unpublished || false;
if (!unpublished) {
// Open new editorial review workflow for this entry - Create new metadata and commit to new branch`
let prResponse;
return this.getBranch()
.then(branchData => this.updateTree(branchData.commit.sha, "/", fileTree))
.then(changeTree => this.commit(options.commitMessage, changeTree))
.then(commitResponse => this.createBranch(branchName, commitResponse.sha))
.then(branchResponse => this.createPR(options.commitMessage, branchName))
.then(pr => {
prResponse = pr;
return this.user();
})
.then(user => {
return this.storeMetadata(contentKey, {
type: "PR",
pr: {
number: prResponse.number,
head: prResponse.head && prResponse.head.sha,
},
user: user.name || user.login,
status: status.first(),
branch: branchName,
collection: options.collectionName,
title: options.parsedData && options.parsedData.title,
description: options.parsedData && options.parsedData.description,
objects: {
entry: {
path: entry.path,
sha: entry.sha,
},
files: filesList,
},
timeStamp: new Date().toISOString(),
});
});
} else {
// Entry is already on editorial review workflow - just update metadata and commit to existing branch
let newHead;
return this.getBranch(branchName)
.then(branchData => this.updateTree(branchData.commit.sha, "/", fileTree))
.then(changeTree => this.commit(options.commitMessage, changeTree))
.then(commit => {
newHead = commit;
return this.retrieveMetadata(contentKey);
})
.then(metadata => {
const { title, description } = options.parsedData || {};
const metadataFiles = get(metadata.objects, 'files', []);
const files = [ ...metadataFiles, ...filesList ];
const pr = { ...metadata.pr, head: newHead.sha };
const objects = {
entry: { path: entry.path, sha: entry.sha },
files: uniq(files),
};
const updatedMetadata = { ...metadata, pr, title, description, objects };
/**
* If an asset store is in use, assets are always accessible, so we
* can just finish the persist operation here.
*/
if (options.hasAssetStore) {
return this.storeMetadata(contentKey, updatedMetadata)
.then(() => this.patchBranch(branchName, newHead.sha));
}
/**
* If no asset store is in use, assets are being stored in the content
* repo, which means pull requests opened for editorial workflow
* entries must be rebased if assets have been added or removed.
*/
return this.rebasePullRequest(pr.number, branchName, contentKey, metadata, newHead);
});
}
}
/**
* Rebase a pull request onto the latest HEAD of it's target base branch
* (should generally be the configured backend branch). Only rebases changes
* in the entry file.
*/
async rebasePullRequest(prNumber, branchName, contentKey, metadata, head) {
const { path } = metadata.objects.entry;
try {
/**
* Get the published branch and create new commits over it. If the pull
* request is up to date, no rebase will occur.
*/
const baseBranch = await this.getBranch();
const commits = await this.getPullRequestCommits(prNumber, head);
/**
* Sometimes the list of commits for a pull request isn't updated
* immediately after the PR branch is patched. There's also the possibility
* that the branch has changed unexpectedly. We account for both by adding
* the head if it's missing, or else throwing an error if the PR head is
* neither the head we expect nor its parent.
*/
const finalCommits = this.assertHead(commits, head);
const rebasedHead = await this.rebaseSingleBlobCommits(baseBranch.commit, finalCommits, path);
/**
* Update metadata, then force update the pull request branch head.
*/
const pr = { ...metadata.pr, head: rebasedHead.sha };
const timeStamp = new Date().toISOString();
const updatedMetadata = { ...metadata, pr, timeStamp };
await this.storeMetadata(contentKey, updatedMetadata);
return this.patchBranch(branchName, rebasedHead.sha, { force: true });
}
catch(error) {
console.error(error);
throw error;
}
}
/**
* Rebase an array of commits one-by-one, starting from a given base SHA. Can
* accept an array of commits as received from the GitHub API. All commits are
* expected to change the same, single blob.
*/
rebaseSingleBlobCommits(baseCommit, commits, pathToBlob) {
/**
* If the parent of the first commit already matches the target base,
* return commits as is.
*/
if (commits.length === 0 || commits[0].parents[0].sha === baseCommit.sha) {
return Promise.resolve(last(commits));
}
/**
* Re-create each commit over the new base, applying each to the previous,
* changing only the parent SHA and tree for each, but retaining all other
* info, such as the author/committer data.
*/
const newHeadPromise = commits.reduce((lastCommitPromise, commit, idx) => {
return lastCommitPromise.then(newParent => {
/**
* Normalize commit data to ensure it's not nested in `commit.commit`.
*/
const parent = this.normalizeCommit(newParent);
const commitToRebase = this.normalizeCommit(commit);
return this.rebaseSingleBlobCommit(parent, commitToRebase, pathToBlob);
});
}, Promise.resolve(baseCommit));
/**
* Return a promise that resolves when all commits have been created.
*/
return newHeadPromise;
}
/**
* Rebase a commit that changes a single blob. Also handles updating the tree.
*/
rebaseSingleBlobCommit(baseCommit, commit, pathToBlob) {
/**
* Retain original commit metadata.
*/
const { message, author, committer } = commit;
/**
* Set the base commit as the parent.
*/
const parent = [ baseCommit.sha ];
/**
* Get the blob data by path.
*/
return this.getBlobInTree(commit.tree.sha, pathToBlob)
/**
* Create a new tree consisting of the base tree and the single updated
* blob. Use the full path to indicate nesting, GitHub will take care of
* subtree creation.
*/
.then(blob => this.createTree(baseCommit.tree.sha, [{ ...blob, path: pathToBlob }]))
/**
* Create a new commit with the updated tree and original commit metadata.
*/
.then(tree => this.createCommit(message, tree.sha, parent, author, committer));
}
/**
* Get a pull request by PR number.
*/
getPullRequest(prNumber) {
return this.request(`${ this.repoURL }/pulls/${prNumber} }`);
}
/**
* Get the list of commits for a given pull request.
*/
getPullRequestCommits (prNumber) {
return this.request(`${ this.repoURL }/pulls/${prNumber}/commits`);
}
/**
* Returns `commits` with `headToAssert` appended if it's the child of the
* last commit in `commits`. Returns `commits` unaltered if `headToAssert` is
* already the last commit in `commits`. Otherwise throws an error.
*/
assertHead(commits, headToAssert) {
const headIsMissing = headToAssert.parents[0].sha === last(commits).sha;
const headIsNotMissing = headToAssert.sha === last(commits).sha;
if (headIsMissing) {
return commits.concat(headToAssert);
} else if (headIsNotMissing) {
return commits;
}
throw Error('Editorial workflow branch changed unexpectedly.');
}
updateUnpublishedEntryStatus(collection, slug, status) {
const contentKey = slug;
return this.retrieveMetadata(contentKey)
.then(metadata => ({
...metadata,
status,
}))
.then(updatedMetadata => this.storeMetadata(contentKey, updatedMetadata));
}
deleteUnpublishedEntry(collection, slug) {
const contentKey = slug;
const branchName = this.generateBranchName(contentKey);
return this.retrieveMetadata(contentKey)
.then(metadata => this.closePR(metadata.pr, metadata.objects))
.then(() => this.deleteBranch(branchName))
// If the PR doesn't exist, then this has already been deleted -
// deletion should be idempotent, so we can consider this a
// success.
.catch((err) => {
if (err.message === "Reference does not exist") {
return Promise.resolve();
}
return Promise.reject(err);
});
}
publishUnpublishedEntry(collection, slug) {
const contentKey = slug;
const branchName = this.generateBranchName(contentKey);
let prNumber;
return this.retrieveMetadata(contentKey)
.then(metadata => this.mergePR(metadata.pr, metadata.objects))
.then(() => this.deleteBranch(branchName));
}
createRef(type, name, sha) {
return this.request(`${ this.repoURL }/git/refs`, {
method: "POST",
body: JSON.stringify({ ref: `refs/${ type }/${ name }`, sha }),
});
}
patchRef(type, name, sha, opts = {}) {
const force = opts.force || false;
return this.request(`${ this.repoURL }/git/refs/${ type }/${ encodeURIComponent(name) }`, {
method: "PATCH",
body: JSON.stringify({ sha, force }),
});
}
deleteRef(type, name, sha) {
return this.request(`${ this.repoURL }/git/refs/${ type }/${ encodeURIComponent(name) }`, {
method: 'DELETE',
});
}
getBranch(branch = this.branch) {
return this.request(`${ this.repoURL }/branches/${ encodeURIComponent(branch) }`);
}
createBranch(branchName, sha) {
return this.createRef("heads", branchName, sha);
}
assertCmsBranch(branchName) {
return branchName.startsWith(CMS_BRANCH_PREFIX);
}
patchBranch(branchName, sha, opts = {}) {
const force = opts.force || false;
if (force && !this.assertCmsBranch(branchName)) {
throw Error(`Only CMS branches can be force updated, cannot force update ${branchName}`);
}
return this.patchRef("heads", branchName, sha, { force });
}
deleteBranch(branchName) {
return this.deleteRef("heads", branchName);
}
createPR(title, head, base = this.branch) {
const body = "Automatically generated by Netlify CMS";
return this.request(`${ this.repoURL }/pulls`, {
method: "POST",
body: JSON.stringify({ title, body, head, base }),
});
}
closePR(pullrequest, objects) {
const headSha = pullrequest.head;
const prNumber = pullrequest.number;
console.log("%c Deleting PR", "line-height: 30px;text-align: center;font-weight: bold"); // eslint-disable-line
return this.request(`${ this.repoURL }/pulls/${ prNumber }`, {
method: "PATCH",
body: JSON.stringify({
state: closed,
}),
});
}
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"); // eslint-disable-line
return this.request(`${ this.repoURL }/pulls/${ prNumber }/merge`, {
method: "PUT",
body: JSON.stringify({
commit_message: "Automatically generated. Merged on Netlify CMS.",
sha: headSha,
merge_method: this.merge_method,
}),
})
.catch((error) => {
if (error instanceof APIError && error.status === 405) {
return this.forceMergePR(pullrequest, objects);
} else {
throw error;
}
});
}
forceMergePR(pullrequest, objects) {
const files = objects.files.concat(objects.entry);
const fileTree = this.composeFileTree(files);
let commitMessage = "Automatically generated. Merged on Netlify CMS\n\nForce merge of:";
files.forEach((file) => {
commitMessage += `\n* "${ file.path }"`;
});
console.log("%c Automatic merge not possible - Forcing merge.", "line-height: 30px;text-align: center;font-weight: bold"); // eslint-disable-line
return this.getBranch()
.then(branchData => this.updateTree(branchData.commit.sha, "/", fileTree))
.then(changeTree => this.commit(commitMessage, changeTree))
.then(response => this.patchBranch(this.branch, response.sha));
}
getTree(sha) {
if (sha) {
return this.request(`${this.repoURL}/git/trees/${sha}`);
}
return Promise.resolve({ tree: [] });
}
/**
* Get a blob from a tree. Requests individual subtrees recursively if blob is
* nested within one or more directories.
*/
getBlobInTree(treeSha, pathToBlob) {
const pathSegments = pathToBlob.split('/').filter(val => val);
const directories = pathSegments.slice(0, -1);
const filename = pathSegments.slice(-1)[0];
const baseTree = this.getTree(treeSha);
const subTreePromise = directories.reduce((treePromise, segment) => {
return treePromise.then(tree => {
const subTreeSha = find(tree.tree, { path: segment }).sha;
return this.getTree(subTreeSha);
});
}, baseTree);
return subTreePromise.then(subTree => find(subTree.tree, { path: filename }));
}
toBase64(str) {
return Promise.resolve(
Base64.encode(str)
);
}
uploadBlob(item) {
const content = item instanceof AssetProxy ? item.toBase64() : this.toBase64(item.raw);
return content.then(contentBase64 => this.request(`${ this.repoURL }/git/blobs`, {
method: "POST",
body: JSON.stringify({
content: contentBase64,
encoding: "base64",
}),
}).then((response) => {
item.sha = response.sha;
item.uploaded = true;
return item;
}));
}
updateTree(sha, path, fileTree) {
return this.getTree(sha)
.then((tree) => {
let obj;
let filename;
let fileOrDir;
const updates = [];
const added = {};
for (let i = 0, len = tree.tree.length; i < len; i++) {
obj = tree.tree[i];
if (fileOrDir = fileTree[obj.path]) {
added[obj.path] = true;
if (fileOrDir.file) {
updates.push({ path: obj.path, mode: obj.mode, type: obj.type, sha: fileOrDir.sha });
} else {
updates.push(this.updateTree(obj.sha, obj.path, fileOrDir));
}
}
}
for (filename in fileTree) {
fileOrDir = fileTree[filename];
if (added[filename]) { continue; }
updates.push(
fileOrDir.file ?
{ path: filename, mode: "100644", type: "blob", sha: fileOrDir.sha } :
this.updateTree(null, filename, fileOrDir)
);
}
return Promise.all(updates)
.then(tree => this.createTree(sha, tree))
.then(response => ({ path, mode: "040000", type: "tree", sha: response.sha, parentSha: sha }));
});
}
createTree(baseSha, tree) {
return this.request(`${ this.repoURL }/git/trees`, {
method: "POST",
body: JSON.stringify({ base_tree: baseSha, tree }),
});
}
/**
* Some GitHub API calls return commit data in a nested `commit` property,
* with the SHA outside of the nested property, while others return a
* flatter object with no nested `commit` property. This normalizes a commit
* to resemble the latter.
*/
normalizeCommit(commit) {
if (commit.commit) {
return { ...commit.commit, sha: commit.sha };
}
return commit;
}
commit(message, changeTree) {
const parents = changeTree.parentSha ? [changeTree.parentSha] : [];
return this.createCommit(message, changeTree.sha, parents);
}
createCommit(message, treeSha, parents, author, committer) {
return this.request(`${ this.repoURL }/git/commits`, {
method: "POST",
body: JSON.stringify({ message, tree: treeSha, parents, author, committer }),
});
}
}

View File

@ -0,0 +1,29 @@
.nc-githubAuthenticationPage-root {
display: flex;
flex-flow: column nowrap;
align-items: center;
justify-content: center;
height: 100vh;
}
.nc-githubAuthenticationPage-logo {
color: #c4c6d2;
margin-top: -300px;
}
.nc-githubAuthenticationPage-button {
@apply(--button);
@apply(--dropShadowDeep);
@apply(--buttonDefault);
@apply(--buttonGray);
padding: 0 30px;
margin-top: -80px;
display: flex;
align-items: center;
position: relative;
& .nc-icon {
margin-right: 18px;
}
}

View File

@ -0,0 +1,53 @@
import PropTypes from 'prop-types';
import React from 'react';
import Authenticator from 'netlify-cms-lib-auth/netlify-auth';
import { Icon } from 'netlify-cms-ui-default';
export default class AuthenticationPage extends React.Component {
static propTypes = {
onLogin: PropTypes.func.isRequired,
inProgress: PropTypes.bool,
base_url: PropTypes.string,
siteId: PropTypes.string,
authEndpoint: PropTypes.string,
};
state = {};
handleLogin = (e) => {
e.preventDefault();
const cfg = {
base_url: this.props.base_url,
site_id: (document.location.host.split(':')[0] === 'localhost') ? 'cms.netlify.com' : this.props.siteId,
auth_endpoint: this.props.authEndpoint,
};
const auth = new Authenticator(cfg);
auth.authenticate({ provider: 'github', scope: 'repo' }, (err, data) => {
if (err) {
this.setState({ loginError: err.toString() });
return;
}
this.props.onLogin(data);
});
};
render() {
const { loginError } = this.state;
const { inProgress } = this.props;
return (
<section className="nc-githubAuthenticationPage-root">
<Icon className="nc-githubAuthenticationPage-logo" size="500px" type="netlify-cms"/>
{loginError && <p>{loginError}</p>}
<button
className="nc-githubAuthenticationPage-button"
disabled={inProgress}
onClick={this.handleLogin}
>
<Icon type="github" /> {inProgress ? "Logging in..." : "Login with GitHub"}
</button>
</section>
);
}
}

View File

@ -0,0 +1,39 @@
import AssetProxy from "ValueObjects/AssetProxy";
import API from "../API";
describe('github API', () => {
const mockAPI = (api, responses) => {
api.request = (path, options = {}) => {
const normalizedPath = path.indexOf('?') !== -1 ? path.substr(0, path.indexOf('?')) : path;
const response = responses[normalizedPath];
return typeof response === 'function'
? Promise.resolve(response(options))
: 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;
const api = new API({ branch: 'gh-pages', repo: 'my-repo' });
const responses = {
'/repos/my-repo/branches/gh-pages': () => ({ commit: { sha: 'def' } }),
'/repos/my-repo/git/trees/def': () => ({ tree: [] }),
'/repos/my-repo/git/trees': () => ({}),
'/repos/my-repo/git/commits': () => ({}),
'/repos/my-repo/git/refs': () => ({}),
'/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': {} })
};
mockAPI(api, responses);
return expect(
api.editorialWorkflowGit(null, { slug: 'entry', sha: 'abc' }, null, {})
.then(() => prBaseBranch)
).resolves.toEqual('gh-pages')
});
});

View File

@ -0,0 +1,197 @@
import trimStart from 'lodash/trimStart';
import semaphore from "semaphore";
import AuthenticationPage from "./AuthenticationPage";
import API from "./API";
const MAX_CONCURRENT_DOWNLOADS = 10;
export default class GitHub {
constructor(config, options={}) {
this.config = config;
this.options = {
proxied: false,
API: null,
...options,
};
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.token = '';
this.squash_merges = config.getIn(["backend", "squash_merges"]);
}
authComponent() {
return AuthenticationPage;
}
restoreUser(user) {
return this.authenticate(user);
}
authenticate(state) {
this.token = state.token;
this.api = new API({ token: this.token, branch: this.branch, repo: this.repo, api_root: this.api_root, squash_merges: this.squash_merges });
return this.api.user().then(user =>
this.api.hasWriteAccess().then((isCollab) => {
// Unauthorized user
if (!isCollab) throw new Error("Your GitHub user account does not have access to this repo.");
// Authorized user
user.token = state.token;
return user;
})
);
}
logout() {
this.token = null;
return;
}
getToken() {
return Promise.resolve(this.token);
}
entriesByFolder(collection, extension) {
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"),
}));
return this.fetchFiles(files);
}
fetchFiles = (files) => {
const sem = semaphore(MAX_CONCURRENT_DOWNLOADS);
const promises = [];
files.forEach((file) => {
promises.push(new Promise((resolve, reject) => (
sem.take(() => this.api.readFile(file.path, file.sha).then((data) => {
resolve({ file, data });
sem.leave();
}).catch((err = true) => {
sem.leave();
console.error(`failed to load file from GitHub: ${file.path}`);
resolve({ error: err });
}))
)));
});
return Promise.all(promises)
.then(loadedEntries => loadedEntries.filter(loadedEntry => !loadedEntry.error));
};
// Fetches a single entry.
getEntry(collection, slug, path) {
return this.api.readFile(path).then(data => ({
file: { path },
data,
}));
}
getMedia() {
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 = {}) {
return this.api.persistFiles(entry, mediaFiles, options);
}
async persistMedia(mediaFile, options = {}) {
try {
const response = await this.api.persistFiles(null, [mediaFile], options);
const { sha, value, size, path, fileObj } = mediaFile;
const url = URL.createObjectURL(fileObj);
return { id: sha, name: value, size: fileObj.size, url, path: trimStart(path, '/') };
}
catch(error) {
console.error(error);
throw error;
}
}
deleteFile(path, commitMessage, options) {
return this.api.deleteFile(path, commitMessage, options);
}
unpublishedEntries() {
return this.api.listUnpublishedBranches().then((branches) => {
const sem = semaphore(MAX_CONCURRENT_DOWNLOADS);
const promises = [];
branches.map((branch) => {
promises.push(new Promise((resolve, reject) => {
const slug = branch.ref.split("refs/heads/cms/").pop();
return sem.take(() => this.api.readUnpublishedBranchFile(slug).then((data) => {
if (data === null || data === undefined) {
resolve(null);
sem.leave();
} else {
const path = data.metaData.objects.entry.path;
resolve({
slug,
file: { path },
data: data.fileData,
metaData: data.metaData,
isModification: data.isModification,
});
sem.leave();
}
}).catch((err) => {
sem.leave();
resolve(null);
}));
}));
});
return Promise.all(promises);
})
.catch((error) => {
if (error.message === "Not Found") {
return Promise.resolve([]);
}
return error;
});
}
unpublishedEntry(collection, slug) {
return this.api.readUnpublishedBranchFile(slug)
.then((data) => {
if (!data) return null;
return {
slug,
file: { path: data.metaData.objects.entry.path },
data: data.fileData,
metaData: data.metaData,
isModification: data.isModification,
};
});
}
updateUnpublishedEntryStatus(collection, slug, newStatus) {
return this.api.updateUnpublishedEntryStatus(collection, slug, newStatus);
}
deleteUnpublishedEntry(collection, slug) {
return this.api.deleteUnpublishedEntry(collection, slug);
}
publishUnpublishedEntry(collection, slug) {
return this.api.publishUnpublishedEntry(collection, slug);
}
}

View File

@ -0,0 +1,224 @@
import localForage from "netlify-cms-lib-util/localForage";
import { Base64 } from "js-base64";
import { fromJS, List, Map } from "immutable";
import { cond, flow, isString, partial, partialRight, pick, omit, set, update } from "lodash";
import unsentRequest from "netlify-cms-lib-util/unsentRequest";
import { then } from "netlify-cms-lib-util/promise";
import AssetProxy from "ValueObjects/AssetProxy";
import { APIError } from "ValueObjects/errors";
import Cursor from "ValueObjects/Cursor"
export default class API {
constructor(config) {
this.api_root = config.api_root || "https://gitlab.com/api/v4";
this.token = config.token || false;
this.branch = config.branch || "master";
this.repo = config.repo || "";
this.repoURL = `/projects/${ encodeURIComponent(this.repo) }`;
}
withAuthorizationHeaders = req =>
unsentRequest.withHeaders(this.token ? { Authorization: `Bearer ${ this.token }` } : {}, req);
buildRequest = req => flow([
unsentRequest.withRoot(this.api_root),
this.withAuthorizationHeaders,
unsentRequest.withTimestamp,
])(req);
request = async req => flow([
this.buildRequest,
unsentRequest.performRequest,
p => p.catch(err => Promise.reject(new APIError(err.message, null, "GitLab"))),
])(req);
parseResponse = async (res, { expectingOk=true, expectingFormat=false }) => {
const contentType = res.headers.get("Content-Type");
const isJSON = contentType === "application/json";
let body;
try {
body = await ((expectingFormat === "json" || isJSON) ? res.json() : res.text());
} catch (err) {
throw new APIError(err.message, res.status, "GitLab");
}
if (expectingOk && !res.ok) {
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" });
requestJSON = req => this.request(req).then(this.responseToJSON);
requestText = req => this.request(req).then(this.responseToText);
user = () => this.requestJSON("/user");
WRITE_ACCESS = 30;
hasWriteAccess = user => this.requestJSON(this.repoURL).then(({ permissions }) => {
const { project_access, group_access } = permissions;
if (project_access && (project_access.access_level >= this.WRITE_ACCESS)) {
return true;
}
if (group_access && (group_access.access_level >= this.WRITE_ACCESS)) {
return true;
}
return false;
});
readFile = async (path, sha, ref=this.branch) => {
const cachedFile = sha ? await localForage.getItem(`gl.${ sha }`) : null;
if (cachedFile) { return cachedFile; }
const result = await this.requestText({
url: `${ this.repoURL }/repository/files/${ encodeURIComponent(path) }/raw`,
params: { ref },
cache: "no-store",
});
if (sha) { localForage.setItem(`gl.${ sha }`, result) }
return result;
};
fileDownloadURL = (path, ref=this.branch) => unsentRequest.toURL(this.buildRequest({
url: `${ this.repoURL }/repository/files/${ encodeURIComponent(path) }/raw`,
params: { ref },
}));
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(";"))
.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] : []);
return Cursor.create({
actions,
meta: { index, count, pageSize, pageCount },
data: { links },
});
};
getCursor = ({ headers }) => this.getCursorFromHeaders(headers);
// Gets a cursor without retrieving the entries by using a HEAD
// request
fetchCursor = req => flow([unsentRequest.withMethod("HEAD"), this.request, then(this.getCursor)])(req);
fetchCursorAndEntries = req => flow([
unsentRequest.withMethod("GET"),
this.request,
p => Promise.all([p.then(this.getCursor), p.then(this.responseToJSON)]),
then(([cursor, entries]) => ({ cursor, entries })),
])(req);
fetchRelativeCursor = async (cursor, action) => this.fetchCursor(cursor.data.links[action]);
reversableActions = Map({
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 newIndex = pageCount - currentIndex;
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);
return cursor.updateStore(store => store
.setIn(["meta", "index"], newIndex)
.setIn(["data", "links"], reversedLinks)
.set("actions", reversedActions));
};
// The exported listFiles and traverseCursor reverse the direction
// of the cursors, since GitLab's pagination sorts the opposite way
// we want to sort by default (it sorts by filename _descending_,
// while the CMS defaults to sorting by filename _ascending_, at
// least in the current GitHub backend). This should eventually be
// refactored.
listFiles = async path => {
const firstPageCursor = await this.fetchCursor({
url: `${ this.repoURL }/repository/tree`,
params: { path, ref: this.branch },
});
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) };
};
traverseCursor = async (cursor, action) => {
const link = cursor.data.getIn(["links", action]);
const { entries, cursor: newCursor } = await this.fetchCursorAndEntries(link);
return { entries: entries.reverse(), cursor: this.reverseCursor(newCursor) };
};
listAllFiles = async path => {
const entries = [];
let { cursor, entries: initialEntries } = await this.fetchCursorAndEntries({
url: `${ this.repoURL }/repository/tree`,
// Get the maximum number of entries per page
params: { path, ref: this.branch, per_page: 100 },
});
entries.push(...initialEntries);
while (cursor && cursor.actions.has("next")) {
const link = cursor.data.getIn(["links", "next"]);
const { cursor: newCursor, entries: newEntries } = await this.fetchCursorAndEntries(link);
entries.push(...newEntries);
cursor = newCursor;
}
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 }) => {
const content = await (item instanceof AssetProxy ? item.toBase64() : this.toBase64(item.raw));
const file_path = item.path.replace(/^\//, "");
const action = (updateFile ? "update" : "create");
const encoding = "base64";
const { name: author_name, email: author_email } = pick(author || {}, ["name", "email"]);
const body = JSON.stringify({
branch,
commit_message: commitMessage,
actions: [{ action, file_path, content, encoding }],
});
await this.request({
url: `${ this.repoURL }/repository/commits`,
method: "POST",
headers: { "Content-Type": "application/json" },
body,
});
return { ...item, uploaded: true };
};
persistFiles = (files, { commitMessage, newEntry }) =>
Promise.all(files.map(file => this.uploadAndCommit(file, { commitMessage, updateFile: newEntry === false })));
deleteFile = (path, commit_message, options = {}) => {
const branch = options.branch || this.branch;
return flow([
unsentRequest.withMethod("DELETE"),
unsentRequest.withParams({ commit_message, branch }),
this.request,
])(`${ this.repoURL }/repository/files/${ encodeURIComponent(path) }`);
};
}

View File

@ -0,0 +1,70 @@
import PropTypes from 'prop-types';
import React from 'react';
import NetlifyAuthenticator from 'netlify-cms-lib-auth/netlify-auth';
import ImplicitAuthenticator from 'netlify-cms-lib-auth/implicit-oauth';
import { Icon } from 'netlify-cms-ui-default';
export default class AuthenticationPage extends React.Component {
static propTypes = {
onLogin: PropTypes.func.isRequired,
inProgress: PropTypes.bool,
};
state = {};
componentDidMount() {
const authType = this.props.config.getIn(['backend', 'auth_type']);
if (authType === "implicit") {
this.auth = new ImplicitAuthenticator({
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,
});
// Complete implicit authentication if we were redirected back to from the provider.
this.auth.completeAuth((err, data) => {
if (err) {
this.setState({ loginError: err.toString() });
return;
}
this.props.onLogin(data);
});
} else {
this.auth = new NetlifyAuthenticator({
base_url: this.props.base_url,
site_id: (document.location.host.split(':')[0] === 'localhost') ? 'cms.netlify.com' : this.props.siteId,
auth_endpoint: this.props.authEndpoint,
});
}
}
handleLogin = (e) => {
e.preventDefault();
this.auth.authenticate({ provider: 'gitlab', scope: 'api' }, (err, data) => {
if (err) {
this.setState({ loginError: err.toString() });
return;
}
this.props.onLogin(data);
});
};
render() {
const { loginError } = this.state;
const { inProgress } = this.props;
return (
<section className="nc-githubAuthenticationPage-root">
<Icon className="nc-githubAuthenticationPage-logo" size="500px" type="netlify-cms"/>
{loginError && <p>{loginError}</p>}
<button
className="nc-githubAuthenticationPage-button"
disabled={inProgress}
onClick={this.handleLogin}
>
<Icon type="gitlab" /> {inProgress ? "Logging in..." : "Login with GitLab"}
</button>
</section>
);
}
}

View File

@ -0,0 +1,160 @@
import trimStart from 'lodash/trimStart';
import semaphore from "semaphore";
import AuthenticationPage from "./AuthenticationPage";
import API from "./API";
import { CURSOR_COMPATIBILITY_SYMBOL } from 'ValueObjects/Cursor';
import { EDITORIAL_WORKFLOW } from "Constants/publishModes";
const MAX_CONCURRENT_DOWNLOADS = 10;
export default class GitLab {
constructor(config, options={}) {
this.config = config;
this.options = {
proxied: false,
API: null,
...options,
};
if (config.getIn(["publish_mode"]) === 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.");
}
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.token = '';
}
authComponent() {
return AuthenticationPage;
}
restoreUser(user) {
return this.authenticate(user);
}
authenticate(state) {
this.token = state.token;
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) => {
// Unauthorized user
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 });
})
);
}
logout() {
this.token = null;
return;
}
getToken() {
return Promise.resolve(this.token);
}
entriesByFolder(collection, extension) {
return this.api.listFiles(collection.get("folder"))
.then(({ files, cursor }) =>
this.fetchFiles(
files.filter(file => file.name.endsWith('.' + extension))
)
.then(fetchedFiles => {
const returnedFiles = fetchedFiles;
returnedFiles[CURSOR_COMPATIBILITY_SYMBOL] = cursor;
return returnedFiles;
})
);
}
allEntriesByFolder(collection, 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"),
}));
return this.fetchFiles(files).then(fetchedFiles => {
const returnedFiles = fetchedFiles;
return returnedFiles;
});
}
fetchFiles = (files) => {
const sem = semaphore(MAX_CONCURRENT_DOWNLOADS);
const promises = [];
files.forEach((file) => {
promises.push(new Promise((resolve, reject) => (
sem.take(() => this.api.readFile(file.path, file.id).then((data) => {
resolve({ file, data });
sem.leave();
}).catch((error = true) => {
sem.leave();
console.error(`failed to load file from GitLab: ${ file.path }`);
resolve({ error });
}))
)));
});
return Promise.all(promises)
.then(loadedEntries => loadedEntries.filter(loadedEntry => !loadedEntry.error));
};
// Fetches a single entry.
getEntry(collection, slug, path) {
return this.api.readFile(path).then(data => ({
file: { path },
data,
}));
}
getMedia() {
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);
}
async persistMedia(mediaFile, options = {}) {
await this.api.persistFiles([mediaFile], options);
const { value, path, fileObj } = mediaFile;
const url = this.api.fileDownloadURL(path);
return { name: value, size: fileObj.size, url, path: trimStart(path, '/') };
}
deleteFile(path, commitMessage, options) {
return this.api.deleteFile(path, commitMessage, options);
}
traverseCursor(cursor, action) {
return this.api.traverseCursor(cursor, action)
.then(async ({ entries, cursor: newCursor }) => ({
entries: await Promise.all(entries.map(file => this.api.readFile(file.path, file.id).then(data => ({ file, data })))),
cursor: newCursor,
}));
}
}

View File

@ -0,0 +1,44 @@
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import React from 'react';
import { Icon } from 'netlify-cms-ui-default';
export default class AuthenticationPage extends React.Component {
static propTypes = {
onLogin: PropTypes.func.isRequired,
inProgress: PropTypes.bool,
config: ImmutablePropTypes.map.isRequired,
};
componentWillMount() {
/**
* Allow login screen to be skipped for demo purposes.
*/
const skipLogin = this.props.config.getIn(['backend', 'login']) === false;
if (skipLogin) {
this.props.onLogin(this.state);
}
}
handleLogin = (e) => {
e.preventDefault();
this.props.onLogin(this.state);
};
render() {
const { inProgress } = this.props;
return (
<section className="nc-githubAuthenticationPage-root">
<Icon className="nc-githubAuthenticationPage-logo" size="500px" type="netlify-cms"/>
<button
className="nc-githubAuthenticationPage-button"
disabled={inProgress}
onClick={this.handleLogin}
>
{inProgress ? "Logging in..." : "Login"}
</button>
</section>
);
}
}

View File

@ -0,0 +1,225 @@
import { remove, attempt, isError, take } from 'lodash';
import uuid from 'uuid/v4';
import { fromJS } from 'immutable';
import { EDITORIAL_WORKFLOW, status } from 'Constants/publishModes';
import { EditorialWorkflowError } from 'ValueObjects/errors';
import Cursor, { CURSOR_COMPATIBILITY_SYMBOL } from 'ValueObjects/Cursor'
import AuthenticationPage from './AuthenticationPage';
window.repoFiles = window.repoFiles || {};
window.repoFilesUnpublished = window.repoFilesUnpublished || [];
function getFile(path) {
const segments = path.split('/');
let obj = window.repoFiles;
while (obj && segments.length) {
obj = obj[segments.shift()];
}
return obj || {};
}
const pageSize = 10;
const getCursor = (collection, extension, entries, index) => {
const count = entries.length;
const pageCount = Math.floor(count / pageSize);
return Cursor.create({
actions: [
...(index < pageCount ? ["next", "last"] : []),
...(index > 0 ? ["prev", "first"] : []),
],
meta: { index, count, pageSize, pageCount },
data: { collection, extension, index, pageCount },
});
};
const getFolderEntries = (folder, extension) => {
return Object.keys(window.repoFiles[folder] || {})
.filter(path => path.endsWith(`.${ extension }`))
.map(path => ({
file: { path: `${ folder }/${ path }` },
data: window.repoFiles[folder][path].content,
}))
.reverse();
};
export default class TestRepo {
constructor(config) {
this.config = config;
this.assets = [];
}
authComponent() {
return AuthenticationPage;
}
restoreUser(user) {
return this.authenticate(user);
}
authenticate() {
return Promise.resolve();
}
logout() {
return null;
}
getToken() {
return Promise.resolve('');
}
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; }
})();
// TODO: stop assuming cursors are for collections
const allEntries = getFolderEntries(collection.get('folder'), extension);
const entries = allEntries.slice(newIndex * pageSize, (newIndex * pageSize) + pageSize);
const newCursor = getCursor(collection, extension, allEntries, newIndex);
return Promise.resolve({ entries, cursor: newCursor });
}
entriesByFolder(collection, extension) {
const folder = collection.get('folder');
const entries = folder ? getFolderEntries(folder, extension) : [];
const cursor = getCursor(collection, extension, entries, 0);
const ret = take(entries, pageSize);
ret[CURSOR_COMPATIBILITY_SYMBOL] = cursor;
return Promise.resolve(ret);
}
entriesByFiles(collection) {
const files = collection.get('files').map(collectionFile => ({
path: collectionFile.get('file'),
label: collectionFile.get('label'),
}));
return Promise.all(files.map(file => ({
file,
data: getFile(file.path).content,
})));
}
getEntry(collection, slug, path) {
return Promise.resolve({
file: { path },
data: getFile(path).content,
});
}
unpublishedEntries() {
return Promise.resolve(window.repoFilesUnpublished);
}
unpublishedEntry(collection, 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.resolve(entry);
}
deleteUnpublishedEntry(collection, slug) {
const unpubStore = window.repoFilesUnpublished;
const existingEntryIndex = unpubStore.findIndex(e => (
e.metaData.collection === collection && e.slug === slug
));
unpubStore.splice(existingEntryIndex, 1);
return Promise.resolve();
}
persistEntry({ path, raw, slug }, mediaFiles = [], options = {}) {
if (options.mode === EDITORIAL_WORKFLOW) {
const unpubStore = window.repoFilesUnpublished;
const existingEntryIndex = unpubStore.findIndex(e => e.file.path === path);
if (existingEntryIndex >= 0) {
const unpubEntry = { ...unpubStore[existingEntryIndex], data: raw };
unpubEntry.title = options.parsedData && options.parsedData.title;
unpubEntry.description = options.parsedData && options.parsedData.description;
unpubStore.splice(existingEntryIndex, 1, unpubEntry);
} else {
const unpubEntry = {
data: raw,
file: {
path,
},
metaData: {
collection: options.collectionName,
status: status.first(),
title: options.parsedData && options.parsedData.title,
description: options.parsedData && options.parsedData.description,
},
slug,
};
unpubStore.push(unpubEntry);
}
return Promise.resolve();
}
const newEntry = options.newEntry || false;
const folder = path.substring(0, path.lastIndexOf('/'));
const fileName = path.substring(path.lastIndexOf('/') + 1);
window.repoFiles[folder] = window.repoFiles[folder] || {};
window.repoFiles[folder][fileName] = window.repoFiles[folder][fileName] || {};
if (newEntry) {
window.repoFiles[folder][fileName] = { content: raw };
} else {
window.repoFiles[folder][fileName].content = raw;
}
return Promise.resolve();
}
updateUnpublishedEntryStatus(collection, slug, newStatus) {
const unpubStore = window.repoFilesUnpublished;
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 unpubEntry = unpubStore[unpubEntryIndex];
const entry = { raw: unpubEntry.data, slug: unpubEntry.slug, path: unpubEntry.file.path };
unpubStore.splice(unpubEntryIndex, 1);
return this.persistEntry(entry);
}
getMedia() {
return Promise.resolve(this.assets);
}
persistMedia({ fileObj }) {
const { name, size } = fileObj;
const objectUrl = attempt(window.URL.createObjectURL, fileObj);
const url = isError(objectUrl) ? '' : objectUrl;
const normalizedAsset = { id: uuid(), name, size, path: url, url };
this.assets.push(normalizedAsset);
return Promise.resolve(normalizedAsset);
}
deleteFile(path, commitMessage) {
const assetIndex = this.assets.findIndex(asset => asset.path === path);
if (assetIndex > -1) {
this.assets.splice(assetIndex, 1);
}
else {
const folder = path.substring(0, path.lastIndexOf('/'));
const fileName = path.substring(path.lastIndexOf('/') + 1);
delete window.repoFiles[folder][fileName];
}
return Promise.resolve();
}
}

View File

@ -0,0 +1,85 @@
import React from 'react';
import { render } from 'react-dom';
import { Provider } from 'react-redux';
import { Route } from 'react-router-dom';
import { ConnectedRouter } from 'react-router-redux';
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 App from 'App/App';
import 'EditorWidgets';
import 'MarkdownPlugins';
import './index.css';
const ROOT_ID = 'nc-root';
function bootstrap(opts = {}) {
const { config } = opts;
/**
* Log the version number.
*/
console.log(`Netlify CMS version ${process.env.NETLIFY_CMS_VERSION}`);
/**
* Get DOM element where app will mount.
*/
function getRoot() {
/**
* Return existing root if found.
*/
const existingRoot = document.getElementById(ROOT_ID);
if (existingRoot) {
return existingRoot;
}
/**
* If no existing root, create and return a new root.
*/
const newRoot = document.createElement('div');
newRoot.id = ROOT_ID;
document.body.appendChild(newRoot);
return newRoot;
}
/**
* Configure Redux store.
*/
const store = configureStore();
/**
* Dispatch config to store if received. This config will be merged into
* config.yml if it exists, and any portion that produces a conflict will be
* overwritten.
*/
if (config) {
store.dispatch(mergeConfig(config));
}
/**
* Pass initial state into AssetProxy factory.
*/
setStore(store);
/**
* Create connected root component.
*/
const Root = () => (
<ErrorBoundary>
<Provider store={store}>
<ConnectedRouter history={history}>
<Route component={App}/>
</ConnectedRouter>
</Provider>
</ErrorBoundary>
);
/**
* Render application root.
*/
render(<Root />, getRoot());
}
export default bootstrap;

View File

@ -0,0 +1,8 @@
@import "./NotFoundPage.css";
@import "./Header.css";
.nc-app-main {
min-width: 800px;
max-width: 1440px;
margin: 0 auto;
}

View File

@ -0,0 +1,189 @@
import PropTypes from 'prop-types';
import React from 'react';
import { hot } from 'react-hot-loader';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { Route, Switch, Link, Redirect } from 'react-router-dom';
import { Notifs } from 'redux-notifications';
import TopBarProgress from 'react-topbar-progress-indicator';
import { loadConfig as actionLoadConfig } from 'Actions/config';
import { loginUser as actionLoginUser, logoutUser as actionLogoutUser } from 'Actions/auth';
import { currentBackend } from 'Backends/backend';
import { showCollection, createNewEntry } from 'Actions/collections';
import { openMediaLibrary as actionOpenMediaLibrary } from 'Actions/mediaLibrary';
import MediaLibrary from 'MediaLibrary/MediaLibrary';
import { Toast } from 'UI';
import { Loader } from 'netlify-cms-ui-default';
import history from 'Routing/history';
import { getCollectionUrl, getNewEntryUrl } from 'Lib/urlHelper';
import { SIMPLE, EDITORIAL_WORKFLOW } from 'Constants/publishModes';
import Collection from 'Collection/Collection';
import Workflow from 'Workflow/Workflow';
import Editor from 'Editor/Editor';
import NotFoundPage from './NotFoundPage';
import Header from './Header';
TopBarProgress.config({
barColors: {
/**
* Uses value from CSS --colorActive.
*/
"0": '#3a69c8',
'1.0': '#3a69c8',
},
shadowBlur: 0,
barThickness: 2,
});
class App extends React.Component {
static propTypes = {
auth: ImmutablePropTypes.map,
config: ImmutablePropTypes.map,
collections: ImmutablePropTypes.orderedMap,
logoutUser: PropTypes.func.isRequired,
dispatch: PropTypes.func.isRequired,
user: ImmutablePropTypes.map,
isFetching: PropTypes.bool.isRequired,
publishMode: PropTypes.oneOf([SIMPLE, EDITORIAL_WORKFLOW]),
siteId: PropTypes.string,
};
static configError(config) {
return (<div>
<h1>Error loading the CMS configuration</h1>
<div>
<p>The <code>config.yml</code> file could not be loaded or failed to parse properly.</p>
<p><strong>Error message:</strong> {config.get('error')}</p>
<p>Check your console for details.</p>
</div>
</div>);
}
componentDidMount() {
this.props.dispatch(actionLoadConfig());
}
handleLogin(credentials) {
this.props.dispatch(actionLoginUser(credentials));
}
authenticating() {
const { auth } = this.props;
const backend = currentBackend(this.props.config);
if (backend == null) {
return <div><h1>Waiting for backend...</h1></div>;
}
return (
<div>
<Notifs CustomComponent={Toast} />
{
React.createElement(backend.authComponent(), {
onLogin: this.handleLogin.bind(this),
error: auth && auth.get('error'),
isFetching: auth && auth.get('isFetching'),
siteId: this.props.config.getIn(["backend", "site_domain"]),
base_url: this.props.config.getIn(["backend", "base_url"], null),
authEndpoint: this.props.config.getIn(["backend", "auth_endpoint"]),
config: this.props.config,
clearHash: () => history.replace('/'),
})
}
</div>
);
}
handleLinkClick(event, handler, ...args) {
event.preventDefault();
handler(...args);
}
render() {
const {
user,
config,
collections,
logoutUser,
isFetching,
publishMode,
openMediaLibrary,
} = this.props;
if (config === null) {
return null;
}
if (config.get('error')) {
return App.configError(config);
}
if (config.get('isFetching')) {
return <Loader active>Loading configuration...</Loader>;
}
if (user == null) {
return this.authenticating();
}
const defaultPath = `/collections/${collections.first().get('name')}`;
const hasWorkflow = publishMode === EDITORIAL_WORKFLOW;
return (
<div className="nc-app-container">
<Notifs CustomComponent={Toast} />
<Header
user={user}
collections={collections}
onCreateEntryClick={createNewEntry}
onLogoutClick={logoutUser}
openMediaLibrary={openMediaLibrary}
hasWorkflow={hasWorkflow}
displayUrl={config.get('display_url')}
/>
<div className="nc-app-main">
{ isFetching && <TopBarProgress /> }
<div>
<Switch>
<Redirect exact from="/" to={defaultPath} />
<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/entries/:slug" component={Editor} />
<Route path="/search/:searchTerm" render={props => <Collection {...props} isSearchResults />} />
<Route component={NotFoundPage} />
</Switch>
<MediaLibrary/>
</div>
</div>
</div>
);
}
}
function mapStateToProps(state, ownProps) {
const { auth, config, collections, globalUI } = state;
const user = auth && auth.get('user');
const isFetching = globalUI.get('isFetching');
const publishMode = config && config.get('publish_mode');
return { auth, config, collections, user, isFetching, publishMode };
}
function mapDispatchToProps(dispatch) {
return {
dispatch,
openMediaLibrary: () => {
dispatch(actionOpenMediaLibrary());
},
logoutUser: () => {
dispatch(actionLogoutUser());
},
};
}
export default hot(module)(
connect(mapStateToProps, mapDispatchToProps)(App)
);

View File

@ -0,0 +1,91 @@
.nc-appHeader-container {
z-index: 300;
}
.nc-appHeader-main {
@apply(--dropShadowMain);
position: fixed;
width: 100%;
top: 0;
background-color: var(--colorForeground);
z-index: 300;
height: var(--topBarHeight);
}
.nc-appHeader-content {
display: flex;
justify-content: space-between;
min-width: 800px;
max-width: 1440px;
padding: 0 12px;
margin: 0 auto;
}
.nc-appHeader-button {
background-color: transparent;
color: #7b8290;
font-size: 16px;
font-weight: 500;
display: inline-flex;
padding: 16px 20px;
align-items: center;
& .nc-icon {
margin-right: 4px;
color: #b3b9c4;
}
&:hover,
&:active,
&:focus,
&.nc-appHeader-button-active {
background-color: white;
color: var(--colorActive);
& .nc-icon {
color: var(--colorActive);
}
}
}
.nc-appHeader-actions {
display: inline-flex;
align-items: center;
}
.nc-appHeader-siteLink {
font-size: 14px;
font-weight: 400;
color: #7b8290;
padding: 10px 16px;
}
.nc-appHeader-quickNew {
@apply(--buttonMedium);
@apply(--buttonGray);
margin-right: 8px;
&:after {
top: 11px;
}
}
.nc-appHeader-avatar {
border: 0;
padding: 8px;
cursor: pointer;
color: #1e2532;
background-color: transparent;
}
.nc-appHeader-avatar-image,
.nc-appHeader-avatar-placeholder {
width: 32px;
border-radius: 32px;
}
.nc-appHeader-avatar-placeholder {
height: 32px;
color: #1e2532;
background-color: var(--textFieldBorderColor);
}

View File

@ -0,0 +1,115 @@
import PropTypes from 'prop-types';
import React from "react";
import ImmutablePropTypes from "react-immutable-proptypes";
import { NavLink } from 'react-router-dom';
import { Icon, Dropdown, DropdownItem } from 'netlify-cms-ui-default';
import { stripProtocol } from 'Lib/urlHelper';
export default class Header extends React.Component {
static propTypes = {
user: ImmutablePropTypes.map.isRequired,
collections: ImmutablePropTypes.orderedMap.isRequired,
onCreateEntryClick: PropTypes.func.isRequired,
onLogoutClick: PropTypes.func.isRequired,
displayUrl: PropTypes.string,
};
handleCreatePostClick = (collectionName) => {
const { onCreateEntryClick } = this.props;
if (onCreateEntryClick) {
onCreateEntryClick(collectionName);
}
};
render() {
const {
user,
collections,
toggleDrawer,
onLogoutClick,
openMediaLibrary,
hasWorkflow,
displayUrl,
} = this.props;
const avatarUrl = user.get('avatar_url');
return (
<div className="nc-appHeader-container">
<div className="nc-appHeader-main">
<div className="nc-appHeader-content">
<nav>
<NavLink
to="/"
className="nc-appHeader-button"
activeClassName="nc-appHeader-button-active"
isActive={(match, location) => location.pathname.startsWith('/collections/')}
>
<Icon type="page"/>
Content
</NavLink>
{
hasWorkflow
? <NavLink to="/workflow" className="nc-appHeader-button" activeClassName="nc-appHeader-button-active">
<Icon type="workflow"/>
Workflow
</NavLink>
: null
}
<button onClick={openMediaLibrary} className="nc-appHeader-button">
<Icon type="media-alt"/>
Media
</button>
</nav>
<div className="nc-appHeader-actions">
<Dropdown
classNameButton="nc-appHeader-button nc-appHeader-quickNew"
label="Quick add"
dropdownTopOverlap="30px"
dropdownWidth="160px"
dropdownPosition="left"
>
{
collections.filter(collection => collection.get('create')).toList().map(collection =>
<DropdownItem
key={collection.get("name")}
label={collection.get("label_singular") || collection.get("label")}
onClick={() => this.handleCreatePostClick(collection.get('name'))}
/>
)
}
</Dropdown>
{
displayUrl
? <a
className="nc-appHeader-siteLink"
href={displayUrl}
target="_blank"
>
{stripProtocol(displayUrl)}
</a>
: null
}
<Dropdown
dropdownTopOverlap="50px"
dropdownWidth="100px"
dropdownPosition="right"
button={
<button className="nc-appHeader-avatar">
{
avatarUrl
? <img className="nc-appHeader-avatar-image" src={user.get('avatar_url')}/>
: <Icon className="nc-appHeader-avatar-placeholder" type="user" size="large"/>
}
</button>
}
>
<DropdownItem label="Log Out" onClick={onLogoutClick}/>
</Dropdown>
</div>
</div>
</div>
</div>
);
}
}

View File

@ -0,0 +1,3 @@
.nc-notFound-container {
margin: var(--pageMargin);
}

View File

@ -0,0 +1,7 @@
import React from 'react';
export default () => (
<div className="nc-notFound-container">
<h2>Not Found</h2>
</div>
);

View File

@ -0,0 +1,11 @@
@import "./Sidebar.css";
@import "./CollectionTop.css";
@import "./Entries/Entries.css";
.nc-collectionPage-container {
margin: var(--pageMargin);
}
.nc-collectionPage-main {
padding-left: 280px;
}

View File

@ -0,0 +1,71 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { getNewEntryUrl } from 'Lib/urlHelper';
import Sidebar from './Sidebar';
import CollectionTop from './CollectionTop';
import EntriesCollection from './Entries/EntriesCollection';
import EntriesSearch from './Entries/EntriesSearch';
import { VIEW_STYLE_LIST, VIEW_STYLE_GRID } from 'Constants/collectionViews';
class Collection extends React.Component {
static propTypes = {
collection: ImmutablePropTypes.map.isRequired,
collections: ImmutablePropTypes.orderedMap.isRequired,
};
state = {
viewStyle: VIEW_STYLE_LIST,
};
renderEntriesCollection = () => {
const { name, collection } = this.props;
return <EntriesCollection collection={collection} name={name} viewStyle={this.state.viewStyle}/>
};
renderEntriesSearch = () => {
const { searchTerm, collections } = this.props;
return <EntriesSearch collections={collections} searchTerm={searchTerm} />
};
handleChangeViewStyle = (viewStyle) => {
if (this.state.viewStyle !== viewStyle) {
this.setState({ viewStyle });
}
}
render() {
const { collection, collections, collectionName, isSearchResults, searchTerm } = this.props;
const newEntryUrl = collection.get('create') ? getNewEntryUrl(collectionName) : '';
return (
<div className="nc-collectionPage-container">
<Sidebar collections={collections} searchTerm={searchTerm}/>
<div className="nc-collectionPage-main">
{
isSearchResults
? null
: <CollectionTop
collectionLabel={collection.get('label')}
collectionLabelSingular={collection.get('label_singular')}
collectionDescription={collection.get('description')}
newEntryUrl={newEntryUrl}
viewStyle={this.state.viewStyle}
onChangeViewStyle={this.handleChangeViewStyle}
/>
}
{ isSearchResults ? this.renderEntriesSearch() : this.renderEntriesCollection() }
</div>
</div>
);
}
}
function mapStateToProps(state, ownProps) {
const { collections } = state;
const { isSearchResults, match } = ownProps;
const { name, searchTerm } = match.params;
const collection = name ? collections.get(name) : collections.first();
return { collection, collections, collectionName: name, isSearchResults, searchTerm };
}
export default connect(mapStateToProps)(Collection);

View File

@ -0,0 +1,64 @@
.nc-collectionPage-top {
@apply(--cardTop);
}
.nc-collectionPage-top-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.nc-collectionPage-top-description {
@apply(--cardTopDescription)
}
.nc-collectionPage-topHeading {
@apply(--cardTopHeading)
}
.nc-collectionPage-topNewButton {
@apply(--button);
@apply(--dropShadowDeep);
@apply(--buttonDefault);
@apply(--buttonGray);
padding: 0 30px;
}
.nc-collectionPage-top-viewControls {
display: flex;
align-items: center;
justify-content: flex-end;
margin-top: 14px;
}
.nc-collectionPage-top-viewControls {
margin-top: 24px;
}
.nc-collectionPage-top-viewControls-text {
font-size: 14px;
color: var(--colorText);
margin-right: 12px;
}
.nc-collectionPage-top-viewControls-button {
color: #b3b9c4;
background-color: transparent;
display: block;
padding: 0;
margin: 0 4px;
&:last-child {
margin-right: 0;
}
& .nc-icon {
display: block;
}
}
.nc-collectionPage-top-viewControls-buttonActive {
color: var(--colorActive);
}

View File

@ -0,0 +1,64 @@
import PropTypes from 'prop-types';
import React from 'react';
import c from 'classnames';
import { Link } from 'react-router-dom';
import { Icon } from 'netlify-cms-ui-default';
import { VIEW_STYLE_LIST, VIEW_STYLE_GRID } from 'Constants/collectionViews';
const CollectionTop = ({
collectionLabel,
collectionLabelSingular,
collectionDescription,
viewStyle,
onChangeViewStyle,
newEntryUrl,
}) => {
return (
<div className="nc-collectionPage-top">
<div className="nc-collectionPage-top-row">
<h1 className="nc-collectionPage-topHeading">{collectionLabel}</h1>
{
newEntryUrl
? <Link className="nc-collectionPage-topNewButton" to={newEntryUrl}>
{`New ${collectionLabelSingular || collectionLabel}`}
</Link>
: null
}
</div>
{
collectionDescription
? <p className="nc-collectionPage-top-description">{collectionDescription}</p>
: null
}
<div className={c('nc-collectionPage-top-viewControls', {
'nc-collectionPage-top-viewControls-noDescription': !collectionDescription,
})}>
<span className="nc-collectionPage-top-viewControls-text">View as:</span>
<button
className={c('nc-collectionPage-top-viewControls-button', {
'nc-collectionPage-top-viewControls-buttonActive': viewStyle === VIEW_STYLE_LIST,
})}
onClick={() => onChangeViewStyle(VIEW_STYLE_LIST)}
>
<Icon type="list"/>
</button>
<button
className={c('nc-collectionPage-top-viewControls-button', {
'nc-collectionPage-top-viewControls-buttonActive': viewStyle === VIEW_STYLE_GRID,
})}
onClick={() => onChangeViewStyle(VIEW_STYLE_GRID)}
>
<Icon type="grid"/>
</button>
</div>
</div>
);
};
CollectionTop.propTypes = {
collectionLabel: PropTypes.string.isRequired,
collectionDescription: PropTypes.string,
newEntryUrl: PropTypes.string
};
export default CollectionTop;

View File

@ -0,0 +1,2 @@
@import "./EntryListing.css";
@import "./EntryCard.css";

View File

@ -0,0 +1,55 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { Loader } from 'netlify-cms-ui-default';
import EntryListing from './EntryListing';
const Entries = ({
collections,
entries,
publicFolder,
page,
onPaginate,
isFetching,
viewStyle,
cursor,
handleCursorActions,
}) => {
const loadingMessages = [
'Loading Entries',
'Caching Entries',
'This might take several minutes',
];
if (entries) {
return (
<EntryListing
collections={collections}
entries={entries}
publicFolder={publicFolder}
viewStyle={viewStyle}
cursor={cursor}
handleCursorActions={handleCursorActions}
/>
);
}
if (isFetching) {
return <Loader active>{loadingMessages}</Loader>;
}
return <div className="nc-collectionPage-noEntries">No Entries</div>;
}
Entries.propTypes = {
collections: ImmutablePropTypes.map.isRequired,
entries: ImmutablePropTypes.list,
publicFolder: PropTypes.string.isRequired,
page: PropTypes.number,
isFetching: PropTypes.bool,
viewStyle: PropTypes.string,
cursor: PropTypes.any.isRequired,
handleCursorActions: PropTypes.func.isRequired,
};
export default Entries;

View File

@ -0,0 +1,84 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { partial } from 'lodash';
import {
loadEntries as actionLoadEntries,
traverseCollectionCursor as actionTraverseCollectionCursor,
} from 'Actions/entries';
import { selectEntries } from 'Reducers';
import { selectCollectionEntriesCursor } from 'Reducers/cursors';
import Cursor from 'ValueObjects/Cursor';
import Entries from './Entries';
class EntriesCollection extends React.Component {
static propTypes = {
collection: ImmutablePropTypes.map.isRequired,
publicFolder: PropTypes.string.isRequired,
entries: ImmutablePropTypes.list,
isFetching: PropTypes.bool.isRequired,
viewStyle: PropTypes.string,
cursor: PropTypes.object.isRequired,
loadEntries: PropTypes.func.isRequired,
traverseCollectionCursor: PropTypes.func.isRequired,
};
componentDidMount() {
const { collection, loadEntries } = this.props;
if (collection) {
loadEntries(collection);
}
}
componentWillReceiveProps(nextProps) {
const { collection, loadEntries } = this.props;
if (nextProps.collection !== collection) {
loadEntries(nextProps.collection);
}
}
handleCursorActions = (cursor, action) => {
const { collection, traverseCollectionCursor } = this.props;
traverseCollectionCursor(collection, action);
};
render () {
const { collection, entries, publicFolder, isFetching, viewStyle, cursor } = this.props;
return (
<Entries
collections={collection}
entries={entries}
publicFolder={publicFolder}
isFetching={isFetching}
collectionName={collection.get('label')}
viewStyle={viewStyle}
cursor={cursor}
handleCursorActions={partial(this.handleCursorActions, cursor)}
/>
);
}
}
function mapStateToProps(state, ownProps) {
const { collection, viewStyle } = ownProps;
const { config } = state;
const publicFolder = config.get('public_folder');
const page = state.entries.getIn(['pages', collection.get('name'), 'page']);
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 cursor = Cursor.create(rawCursor).clearData();
return { publicFolder, collection, page, entries, isFetching, viewStyle, cursor };
}
const mapDispatchToProps = {
loadEntries: actionLoadEntries,
traverseCollectionCursor: actionTraverseCollectionCursor,
};
export default connect(mapStateToProps, mapDispatchToProps)(EntriesCollection);

View File

@ -0,0 +1,88 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { selectSearchedEntries } from 'Reducers';
import {
searchEntries as actionSearchEntries,
clearSearch as actionClearSearch
} from 'Actions/search';
import Cursor from 'ValueObjects/Cursor';
import Entries from './Entries';
class EntriesSearch extends React.Component {
static propTypes = {
isFetching: PropTypes.bool,
searchEntries: PropTypes.func.isRequired,
clearSearch: PropTypes.func.isRequired,
searchTerm: PropTypes.string.isRequired,
collections: ImmutablePropTypes.seq,
entries: ImmutablePropTypes.list,
page: PropTypes.number,
publicFolder: PropTypes.string,
};
componentDidMount() {
const { searchTerm, searchEntries } = this.props;
searchEntries(searchTerm);
}
componentWillReceiveProps(nextProps) {
if (this.props.searchTerm === nextProps.searchTerm) return;
const { searchEntries } = this.props;
searchEntries(nextProps.searchTerm);
}
componentWillUnmount() {
this.props.clearSearch();
}
getCursor = () => {
const { page } = this.props;
return Cursor.create({
actions: isNaN(page) ? [] : ["append_next"],
});
};
handleCursorActions = (action) => {
const { page, searchTerm, searchEntries } = this.props;
if (action === "append_next") {
const nextPage = page + 1;
searchEntries(searchTerm, nextPage);
}
};
render () {
const { collections, entries, publicFolder, page, isFetching } = this.props;
return (
<Entries
cursor={this.getCursor()}
handleCursorActions={this.handleCursorActions}
collections={collections}
entries={entries}
publicFolder={publicFolder}
page={page}
onPaginate={this.handleLoadMore}
isFetching={isFetching}
/>
);
}
}
function mapStateToProps(state, ownProps) {
const { searchTerm } = ownProps;
const collections = ownProps.collections.toIndexedSeq();
const isFetching = state.search.get('isFetching');
const page = state.search.get('page');
const entries = selectSearchedEntries(state);
const publicFolder = state.config.get('public_folder');
return { isFetching, page, collections, entries, publicFolder, searchTerm };
}
const mapDispatchToProps = {
searchEntries: actionSearchEntries,
clearSearch: actionClearSearch,
};
export default connect(mapStateToProps, mapDispatchToProps)(EntriesSearch);

View File

@ -0,0 +1,76 @@
.nc-entryListing-gridCard {
@apply(--card);
flex: 0 0 335px;
height: 240px;
background-color: var(--colorForeground);
color: var(--colorText);
overflow: hidden;
margin-bottom: 16px;
margin-left: 12px;
&:hover {
background-color: var(--colorForeground);
color: var(--colorText);
}
}
.nc-entryListing-cardImage {
background-position: center center;
background-size: cover;
background-repeat: no-repeat;
height: 150px;
}
.nc-entryListing-cardBody {
padding: 16px 22px;
height: 90px;
position: relative;
&:after {
content: '';
position: absolute;
display: block;
z-index: 1;
bottom: 0;
left: -20%;
height: 140%;
width: 140%;
box-shadow: inset 0 -15px 24px #fff;
}
}
.nc-entryListing-listCard {
@apply(--card);
width: var(--topCardWidth);
max-width: 100%;
padding: 16px 22px;
margin-left: 12px;
margin-bottom: 16px;
&:hover {
background-color: var(--colorForeground);
}
}
.nc-entryListing-listCard-title {
margin-bottom: 0;
}
.nc-entryListing-listCard-collection-label {
font-size: 12px;
color: var(--colorTextLead);
text-transform: uppercase;
}
.nc-entryListing-cardBody-full {
height: 100%;
}
.nc-entryListing-cardHeading {
margin: 0 0 2px;
}
.nc-entryListing-cardListLabel {
white-space: nowrap;
font-weight: bold;
}

View File

@ -0,0 +1,59 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { Link } from 'react-router-dom';
import c from 'classnames';
import history from 'Routing/history';
import { resolvePath } from 'netlify-cms-lib-util/path';
import { VIEW_STYLE_LIST, VIEW_STYLE_GRID } from 'Constants/collectionViews';
const CollectionLabel = ({ label }) =>
<h2 className="nc-entryListing-listCard-collection-label">{label}</h2>;
const EntryCard = ({
collection,
entry,
inferedFields,
publicFolder,
collectionLabel,
viewStyle = VIEW_STYLE_LIST,
}) => {
const label = entry.get('label');
const title = label || entry.getIn(['data', inferedFields.titleField]);
const path = `/collections/${collection.get('name')}/entries/${entry.get('slug')}`;
let image = entry.getIn(['data', inferedFields.imageField]);
image = resolvePath(image, publicFolder);
if(image) {
image = encodeURI(image);
}
if (viewStyle === VIEW_STYLE_LIST) {
return (
<Link to={path} className="nc-entryListing-listCard">
{ collectionLabel ? <CollectionLabel label={collectionLabel}/> : null }
<h2 className="nc-entryListing-listCard-title">{ title }</h2>
</Link>
);
}
if (viewStyle === VIEW_STYLE_GRID) {
return (
<Link to={path} className="nc-entryListing-gridCard">
<div className={c('nc-entryListing-cardBody', { 'nc-entryListing-cardBody-full': !image })}>
{ collectionLabel ? <CollectionLabel label={collectionLabel}/> : null }
<h2 className="nc-entryListing-cardHeading">{title}</h2>
</div>
{
image
? <div
className="nc-entryListing-cardImage"
style={{ backgroundImage: `url(${ image })` }}
/>
: null
}
</Link>
);
}
}
export default EntryCard;

View File

@ -0,0 +1,9 @@
.nc-entryListing-cardsGrid {
display: flex;
flex-flow: row wrap;
margin-left: -12px;
}
.nc-entryListing-cardsList {
margin-left: -12px;
}

View File

@ -0,0 +1,73 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Waypoint from 'react-waypoint';
import { Map } from 'immutable';
import { selectFields, selectInferedField } from 'Reducers/collections';
import EntryCard from './EntryCard';
import Cursor from 'ValueObjects/Cursor';
export default class EntryListing extends React.Component {
static propTypes = {
publicFolder: PropTypes.string.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");
}
};
inferFields = collection => {
const titleField = selectInferedField(collection, 'title');
const descriptionField = selectInferedField(collection, 'description');
const imageField = selectInferedField(collection, 'image');
const fields = selectFields(collection);
const inferedFields = [titleField, descriptionField, imageField];
const remainingFields = fields && fields.filter(f => inferedFields.indexOf(f.get('name')) === -1);
return { titleField, descriptionField, imageField, remainingFields };
};
renderCardsForSingleCollection = () => {
const { collections, entries, publicFolder, viewStyle } = this.props;
const inferedFields = this.inferFields(collections);
const entryCardProps = { collection: collections, inferedFields, publicFolder, viewStyle };
return entries.map((entry, idx) => <EntryCard {...{ ...entryCardProps, entry, key: idx }} />);
};
renderCardsForMultipleCollections = () => {
const { collections, entries, publicFolder } = this.props;
return entries.map((entry, idx) => {
const collectionName = entry.get('collection');
const collection = collections.find(coll => coll.get('name') === collectionName);
const collectionLabel = collection.get('label');
const inferedFields = this.inferFields(collection);
const entryCardProps = { collection, entry, inferedFields, publicFolder, key: idx, collectionLabel };
return <EntryCard {...entryCardProps} />;
});
};
render() {
const { collections } = this.props;
return (
<div>
<div className="nc-entryListing-cardsGrid">
{
Map.isMap(collections)
? this.renderCardsForSingleCollection()
: this.renderCardsForMultipleCollections()
}
<Waypoint onEnter={this.handleLoadMore} />
</div>
</div>
);
}
}

View File

@ -0,0 +1,74 @@
.nc-collectionPage-sidebar {
@apply(--card);
width: 250px;
padding: 8px 0 12px;
position: fixed;
max-height: calc(100vh - 112px);
overflow: auto;
}
.nc-collectionPage-sidebarHeading {
font-size: 23px;
font-weight: 600;
padding: 0;
margin: 18px 12px 12px;
color: var(--colorTextLead);
}
.nc-collectionPage-sidebarSearch {
display: flex;
align-items: center;
margin: 0 8px;
position: relative;
& input {
background-color: #eff0f4;
border-radius: var(--borderRadius);
font-size: 14px;
padding: 10px 6px 10px 32px;
width: 100%;
position: relative;
z-index: 1;
&:focus {
outline: none;
box-shadow: inset 0 0 0 2px var(--colorBlue);
}
}
& .nc-icon {
position: absolute;
top: 0;
left: 6px;
z-index: 2;
height: 100%;
display: flex;
align-items: center;
pointer-events: none;
}
}
.nc-collectionPage-sidebarLink {
display: flex;
font-size: 14px;
font-weight: 500;
align-items: center;
padding: 8px 12px;
border-left: 2px solid #fff;
& .nc-icon {
margin-right: 8px;
}
&:hover,
&:active,
&.nc-collectionPage-sidebarLink-active {
color: var(--colorActive);
background-color: var(--colorActiveBackground);
border-left-color: #4863c6;
}
&:first-of-type {
margin-top: 16px;
}
}

View File

@ -0,0 +1,53 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { NavLink } from 'react-router-dom';
import { searchCollections } from 'Actions/collections';
import { getCollectionUrl } from 'Lib/urlHelper';
import { Icon } from 'netlify-cms-ui-default';
export default class Collection extends React.Component {
static propTypes = {
collections: ImmutablePropTypes.orderedMap.isRequired,
};
state = { query: this.props.searchTerm || '' };
renderLink = collection => {
const collectionName = collection.get('name');
return (
<NavLink
key={collectionName}
to={`/collections/${collectionName}`}
className="nc-collectionPage-sidebarLink"
activeClassName="nc-collectionPage-sidebarLink-active"
>
<Icon type="write"/>
{collection.get('label')}
</NavLink>
);
};
render() {
const { collections } = this.props;
const { query } = this.state;
return (
<div className="nc-collectionPage-sidebar">
<h1 className="nc-collectionPage-sidebarHeading">Collections</h1>
<div className="nc-collectionPage-sidebarSearch">
<Icon type="search" size="small"/>
<input
onChange={e => this.setState({ query: e.target.value })}
onKeyDown={e => e.key === 'Enter' && searchCollections(query)}
placeholder="Search all"
value={query}
/>
</div>
{collections.toList().map(this.renderLink)}
</div>
);
}
}

View File

@ -0,0 +1,6 @@
@import "./EditorInterface.css";
@import "./EditorToolbar.css";
@import "./EditorToggle.css";
@import "./EditorControlPane/EditorControlPane.css";
@import "./EditorControlPane/EditorControl.css";
@import "./EditorPreviewPane/EditorPreviewPane.css";

View File

@ -0,0 +1,391 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { Map } from 'immutable';
import { get } from 'lodash';
import { connect } from 'react-redux';
import history from 'Routing/history';
import { logoutUser } from 'Actions/auth';
import {
loadEntry,
loadEntries,
createDraftFromEntry,
createEmptyDraft,
discardDraft,
changeDraftField,
changeDraftFieldValidation,
persistEntry,
deleteEntry,
} from 'Actions/entries';
import {
updateUnpublishedEntryStatus,
publishUnpublishedEntry,
deleteUnpublishedEntry
} from 'Actions/editorialWorkflow';
import { deserializeValues } from 'Lib/serializeEntryValues';
import { addAsset } from 'Actions/media';
import { openMediaLibrary, removeInsertedMedia } from 'Actions/mediaLibrary';
import { selectEntry, selectUnpublishedEntry, getAsset } from 'Reducers';
import { selectFields } from 'Reducers/collections';
import { Loader } from 'netlify-cms-ui-default';
import { status } from 'Constants/publishModes';
import { EDITORIAL_WORKFLOW } from 'Constants/publishModes';
import EditorInterface from './EditorInterface';
import withWorkflow from './withWorkflow';
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}`);
class Editor extends React.Component {
static propTypes = {
addAsset: PropTypes.func.isRequired,
boundGetAsset: PropTypes.func.isRequired,
changeDraftField: PropTypes.func.isRequired,
changeDraftFieldValidation: PropTypes.func.isRequired,
collection: ImmutablePropTypes.map.isRequired,
createDraftFromEntry: PropTypes.func.isRequired,
createEmptyDraft: PropTypes.func.isRequired,
discardDraft: PropTypes.func.isRequired,
entry: ImmutablePropTypes.map,
mediaPaths: ImmutablePropTypes.map.isRequired,
entryDraft: ImmutablePropTypes.map.isRequired,
loadEntry: PropTypes.func.isRequired,
persistEntry: PropTypes.func.isRequired,
deleteEntry: PropTypes.func.isRequired,
showDelete: PropTypes.bool.isRequired,
openMediaLibrary: PropTypes.func.isRequired,
removeInsertedMedia: PropTypes.func.isRequired,
fields: ImmutablePropTypes.list.isRequired,
slug: PropTypes.string,
newEntry: PropTypes.bool.isRequired,
displayUrl: PropTypes.string,
hasWorkflow: PropTypes.bool,
unpublishedEntry: PropTypes.bool,
isModification: PropTypes.bool,
collectionEntriesLoaded: PropTypes.bool,
updateUnpublishedEntryStatus: PropTypes.func.isRequired,
publishUnpublishedEntry: PropTypes.func.isRequired,
deleteUnpublishedEntry: PropTypes.func.isRequired,
currentStatus: PropTypes.string,
logoutUser: PropTypes.func.isRequired,
};
componentDidMount() {
const {
entry,
newEntry,
entryDraft,
collection,
slug,
loadEntry,
createEmptyDraft,
loadEntries,
collectionEntriesLoaded,
} = this.props;
if (newEntry) {
createEmptyDraft(collection);
} else {
loadEntry(collection, slug);
}
const leaveMessage = 'Are you sure you want to leave this page?';
this.exitBlocker = (event) => {
if (this.props.entryDraft.get('hasChanged')) {
// This message is ignored in most browsers, but its presence
// triggers the confirmation dialog
event.returnValue = leaveMessage;
return leaveMessage;
}
};
window.addEventListener('beforeunload', this.exitBlocker);
const navigationBlocker = (location, action) => {
/**
* New entry being saved and redirected to it's new slug based url.
*/
const isPersisting = this.props.entryDraft.getIn(['entry', 'isPersisting']);
const newRecord = this.props.entryDraft.getIn(['entry', 'newRecord']);
const newEntryPath = `/collections/${collection.get('name')}/new`;
if (isPersisting && newRecord && this.props.location.pathname === newEntryPath && action === 'PUSH') {
return;
}
if (this.props.hasChanged) {
return leaveMessage;
}
};
const unblock = history.block(navigationBlocker);
/**
* This will run as soon as the location actually changes, unless creating
* a new post. The confirmation above will run first.
*/
this.unlisten = history.listen((location, action) => {
const newEntryPath = `/collections/${collection.get('name')}/new`;
const entriesPath = `/collections/${collection.get('name')}/entries/`;
const { pathname } = location;
if (pathname.startsWith(newEntryPath) || pathname.startsWith(entriesPath) && action === 'PUSH') {
return;
}
unblock();
this.unlisten();
});
if (!collectionEntriesLoaded) {
loadEntries(collection);
}
}
componentWillReceiveProps(nextProps) {
/**
* If the old slug is empty and the new slug is not, a new entry was just
* saved, and we need to update navigation to the correct url using the
* slug.
*/
const newSlug = nextProps.entryDraft && nextProps.entryDraft.getIn(['entry', 'slug']);
if (!this.props.slug && newSlug && nextProps.newEntry) {
navigateToEntry(this.props.collection.get('name'), newSlug);
nextProps.loadEntry(nextProps.collection, newSlug);
}
if (this.props.entry === nextProps.entry) return;
const { entry, newEntry, fields, collection } = nextProps;
if (entry && !entry.get('isFetching') && !entry.get('error')) {
/**
* Deserialize entry values for widgets with registered serializers before
* creating the entry draft.
*/
const values = deserializeValues(entry.get('data'), fields);
const deserializedEntry = entry.set('data', values);
const fieldsMetaData = nextProps.entryDraft && nextProps.entryDraft.get('fieldsMetaData');
this.createDraft(deserializedEntry, fieldsMetaData);
} else if (newEntry) {
this.props.createEmptyDraft(collection);
}
}
componentWillUnmount() {
this.props.discardDraft();
window.removeEventListener('beforeunload', this.exitBlocker);
}
createDraft = (entry, metadata) => {
if (entry) this.props.createDraftFromEntry(entry, metadata);
};
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);
this.props.updateUnpublishedEntryStatus(collection.get('name'), slug, currentStatus, newStatus);
}
handlePersistEntry = async (opts = {}) => {
const { createNew = false } = opts;
const { persistEntry, collection, entryDraft, newEntry, currentStatus, hasWorkflow, loadEntry, slug, createEmptyDraft } = this.props;
await persistEntry(collection)
if (createNew) {
navigateToNewEntry(collection.get('name'));
createEmptyDraft(collection);
}
else if (slug && hasWorkflow && !currentStatus) {
loadEntry(collection, slug);
}
};
handlePublishEntry = async (opts = {}) => {
const { createNew = false } = opts;
const { publishUnpublishedEntry, entryDraft, collection, slug, currentStatus, loadEntry } = this.props;
if (currentStatus !== status.last()) {
window.alert('Please update status to "Ready" before publishing.');
return;
} else if (entryDraft.get('hasChanged')) {
window.alert('You have unsaved changes, please save before publishing.');
return;
} else if (!window.confirm('Are you sure you want to publish this entry?')) {
return;
}
await publishUnpublishedEntry(collection.get('name'), slug);
if (createNew) {
navigateToNewEntry(collection.get('name'));
}
else {
loadEntry(collection, slug);
}
};
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?')) {
return;
}
} else if (!window.confirm('Are you sure you want to delete this published entry?')) {
return;
}
if (newEntry) {
return navigateToCollection(collection.get('name'));
}
setTimeout(async () => {
await deleteEntry(collection, slug);
return navigateToCollection(collection.get('name'));
}, 0);
};
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?')) {
return;
} 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);
if (isModification) {
loadEntry(collection, slug);
} else {
navigateToCollection(collection.get('name'));
}
};
render() {
const {
entry,
entryDraft,
fields,
mediaPaths,
boundGetAsset,
collection,
changeDraftField,
changeDraftFieldValidation,
openMediaLibrary,
addAsset,
removeInsertedMedia,
user,
hasChanged,
displayUrl,
hasWorkflow,
unpublishedEntry,
newEntry,
isModification,
currentStatus,
logoutUser,
} = 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 <Loader active>Loading entry...</Loader>;
}
return (
<EditorInterface
entry={entryDraft.get('entry')}
getAsset={boundGetAsset}
collection={collection}
fields={fields}
fieldsMetaData={entryDraft.get('fieldsMetaData')}
fieldsErrors={entryDraft.get('fieldsErrors')}
mediaPaths={mediaPaths}
onChange={changeDraftField}
onValidate={changeDraftFieldValidation}
onOpenMediaLibrary={openMediaLibrary}
onAddAsset={addAsset}
onRemoveInsertedMedia={removeInsertedMedia}
onPersist={this.handlePersistEntry}
onDelete={this.handleDeleteEntry}
onDeleteUnpublishedChanges={this.handleDeleteUnpublishedChanges}
onChangeStatus={this.handleChangeStatus}
onPublish={this.handlePublishEntry}
showDelete={this.props.showDelete}
enableSave={entryDraft.get('hasChanged')}
user={user}
hasChanged={hasChanged}
displayUrl={displayUrl}
hasWorkflow={hasWorkflow}
hasUnpublishedChanges={unpublishedEntry}
isNewEntry={newEntry}
isModification={isModification}
currentStatus={currentStatus}
onLogoutClick={logoutUser}
/>
);
}
}
function mapStateToProps(state, ownProps) {
const { collections, entryDraft, mediaLibrary, auth, config, entries } = state;
const slug = ownProps.match.params.slug;
const collection = collections.get(ownProps.match.params.name);
const collectionName = collection.get('name');
const newEntry = ownProps.newRecord === true;
const fields = selectFields(collection, slug);
const entry = newEntry ? null : selectEntry(state, collectionName, slug);
const boundGetAsset = getAsset.bind(null, state);
const mediaPaths = mediaLibrary.get('controlMedia');
const user = auth && auth.get('user');
const hasChanged = entryDraft.get('hasChanged');
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 unpublishedEntry = selectUnpublishedEntry(state, collectionName, slug);
const currentStatus = unpublishedEntry && unpublishedEntry.getIn(['metaData', 'status']);
return {
collection,
collections,
newEntry,
entryDraft,
mediaPaths,
boundGetAsset,
fields,
slug,
entry,
user,
hasChanged,
displayUrl,
hasWorkflow,
isModification,
collectionEntriesLoaded,
currentStatus,
};
}
export default connect(
mapStateToProps,
{
changeDraftField,
changeDraftFieldValidation,
openMediaLibrary,
removeInsertedMedia,
addAsset,
loadEntry,
loadEntries,
createDraftFromEntry,
createEmptyDraft,
discardDraft,
persistEntry,
deleteEntry,
updateUnpublishedEntryStatus,
publishUnpublishedEntry,
deleteUnpublishedEntry,
logoutUser,
}
)(withWorkflow(Editor));

View File

@ -0,0 +1,7 @@
.nc-controlPane-control {
margin-top: 16px;
&:first-child {
margin-top: 36px;
}
}

View File

@ -0,0 +1,85 @@
import React from 'react';
import { partial, uniqueId } from 'lodash';
import c from 'classnames';
import { resolveWidget } from 'Lib/registry';
import Widget from './Widget';
export default class EditorControl extends React.Component {
state = {
activeLabel: false,
};
render() {
const {
value,
field,
fieldsMetaData,
fieldsErrors,
mediaPaths,
getAsset,
onChange,
onOpenMediaLibrary,
onAddAsset,
onRemoveInsertedMedia,
onValidate,
processControlRef,
} = this.props;
const widgetName = field.get('widget');
const widget = resolveWidget(widgetName);
const fieldName = field.get('name');
const uniqueFieldId = uniqueId();
const metadata = fieldsMetaData && fieldsMetaData.get(fieldName);
const errors = fieldsErrors && fieldsErrors.get(fieldName);
return (
<div className="nc-controlPane-control">
<ul className="nc-controlPane-errors">
{
errors && errors.map(error =>
error.message &&
typeof error.message === 'string' &&
<li key={error.message.trim().replace(/[^a-z0-9]+/gi, '-')}>{error.message}</li>
)
}
</ul>
<label
className={c({
'nc-controlPane-label': true,
'nc-controlPane-labelActive': this.state.styleActive,
'nc-controlPane-labelWithError': !!errors,
})}
htmlFor={fieldName + uniqueFieldId}
>
{field.get('label')}
</label>
<Widget
classNameWrapper={c({
'nc-controlPane-widget': true,
'nc-controlPane-widgetActive': this.state.styleActive,
'nc-controlPane-widgetError': !!errors,
})}
classNameWidget="nc-controlPane-widget"
classNameWidgetActive="nc-controlPane-widgetNestable"
classNameLabel="nc-controlPane-label"
classNameLabelActive="nc-controlPane-labelActive"
controlComponent={widget.control}
field={field}
uniqueFieldId={uniqueFieldId}
value={value}
mediaPaths={mediaPaths}
metadata={metadata}
onChange={(newValue, newMetadata) => onChange(fieldName, newValue, newMetadata)}
onValidate={onValidate && partial(onValidate, fieldName)}
onOpenMediaLibrary={onOpenMediaLibrary}
onRemoveInsertedMedia={onRemoveInsertedMedia}
onAddAsset={onAddAsset}
getAsset={getAsset}
hasActiveStyle={this.state.styleActive}
setActiveStyle={() => this.setState({ styleActive: true })}
setInactiveStyle={() => this.setState({ styleActive: false })}
ref={processControlRef && partial(processControlRef, fieldName)}
editorControl={EditorControl}
/>
</div>
);
}
}

View File

@ -0,0 +1,107 @@
:root {
--controlPaneLabel: {
display: inline-block;
color: var(--controlLabelColor);
font-size: 12px;
text-transform: uppercase;
font-weight: 600;
background-color: var(--textFieldBorderColor);
border: 0;
border-radius: 3px 3px 0 0;
padding: 3px 6px 2px;
margin: 0;
transition: all var(--transition);
position: relative;
/**
* Faux outside curve into top of input
*/
&:before,
&:after {
content: '';
display: block;
position: absolute;
top: 0;
right: -4px;
height: 100%;
width: 4px;
background-color: inherit;
}
&:after {
border-bottom-left-radius: 3px;
background-color: #fff;
}
}
--controlPaneWidget: {
display: block;
width: 100%;
padding: var(--inputPadding);
margin: 0;
border: var(--textFieldBorder);
border-radius: var(--borderRadius);
border-top-left-radius: 0;
outline: 0;
box-shadow: none;
background-color: var(--colorInputBackground);
color: #444a57;
transition: border-color var(--transition);
position: relative;
font-size: 15px;
line-height: 1.5;
}
}
.nc-controlPane-root {
max-width: 800px;
margin: 0 auto;
padding-bottom: 16px;
& p {
font-size: 16px;
}
}
.nc-controlPane-label {
@apply(--controlPaneLabel);
}
.nc-controlPane-labelActive {
background-color: var(--colorActive);
color: var(--colorTextLight);
}
.nc-controlPane-widget {
@apply(--controlPaneWidget);
&.nc-controlPane-widgetActive {
border-color: var(--colorActive);
}
}
select.nc-controlPane-widget {
text-indent: 14px;
height: 58px;
}
.nc-controlPane-labelWithError {
background-color: var(--colorErrorText);
color: #fff;
}
.nc-controlPane-widgetError {
border-color: var(--colorErrorText);
}
.nc-controlPane-errors {
list-style-type: none;
font-size: 12px;
color: var(--colorErrorText);
margin-bottom: 5px;
text-align: right;
text-transform: uppercase;
position: relative;
font-weight: 600;
top: 20px;
}

View File

@ -0,0 +1,82 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import EditorControl from './EditorControl';
export default class ControlPane extends React.Component {
componentValidate = {};
processControlRef = (fieldName, wrappedControl) => {
if (!wrappedControl) return;
this.componentValidate[fieldName] = wrappedControl.validate;
};
validate = () => {
this.props.fields.forEach((field) => {
if (field.get('widget') === 'hidden') return;
this.componentValidate[field.get("name")]();
});
};
render() {
const {
collection,
fields,
entry,
fieldsMetaData,
fieldsErrors,
mediaPaths,
getAsset,
onChange,
onOpenMediaLibrary,
onAddAsset,
onRemoveInsertedMedia,
onValidate,
} = this.props;
if (!collection || !fields) {
return null;
}
if (entry.size === 0 || entry.get('partial') === true) {
return null;
}
return (
<div className="nc-controlPane-root">
{fields.map((field, i) => field.get('widget') === 'hidden' ? null :
<EditorControl
key={i}
field={field}
value={entry.getIn(['data', field.get('name')])}
fieldsMetaData={fieldsMetaData}
fieldsErrors={fieldsErrors}
mediaPaths={mediaPaths}
getAsset={getAsset}
onChange={onChange}
onOpenMediaLibrary={onOpenMediaLibrary}
onAddAsset={onAddAsset}
onRemoveInsertedMedia={onRemoveInsertedMedia}
onValidate={onValidate}
processControlRef={this.processControlRef}
/>
)}
</div>
);
}
}
ControlPane.propTypes = {
collection: ImmutablePropTypes.map.isRequired,
entry: ImmutablePropTypes.map.isRequired,
fields: ImmutablePropTypes.list.isRequired,
fieldsMetaData: ImmutablePropTypes.map.isRequired,
fieldsErrors: ImmutablePropTypes.map.isRequired,
mediaPaths: ImmutablePropTypes.map.isRequired,
getAsset: PropTypes.func.isRequired,
onOpenMediaLibrary: PropTypes.func.isRequired,
onAddAsset: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onValidate: PropTypes.func.isRequired,
onRemoveInsertedMedia: PropTypes.func.isRequired,
};

View File

@ -0,0 +1,218 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ImmutablePropTypes from "react-immutable-proptypes";
import { Map } from 'immutable';
import ValidationErrorTypes from 'Constants/validationErrorTypes';
const truthy = () => ({ error: false });
const isEmpty = value => (
value === null ||
value === undefined ||
(value.hasOwnProperty('length') && value.length === 0) ||
(value.constructor === Object && Object.keys(value).length === 0)
);
export default class Widget extends Component {
static propTypes = {
controlComponent: PropTypes.func.isRequired,
field: ImmutablePropTypes.map.isRequired,
hasActiveStyle: PropTypes.bool,
setActiveStyle: PropTypes.func.isRequired,
setInactiveStyle: PropTypes.func.isRequired,
classNameWrapper: PropTypes.string.isRequired,
classNameWidget: PropTypes.string.isRequired,
classNameWidgetActive: PropTypes.string.isRequired,
classNameLabel: PropTypes.string.isRequired,
classNameLabelActive: PropTypes.string.isRequired,
value: PropTypes.oneOfType([
PropTypes.node,
PropTypes.object,
PropTypes.string,
PropTypes.bool,
]),
mediaPaths: ImmutablePropTypes.map.isRequired,
metadata: ImmutablePropTypes.map,
onChange: PropTypes.func.isRequired,
onValidate: PropTypes.func,
onOpenMediaLibrary: PropTypes.func.isRequired,
onAddAsset: PropTypes.func.isRequired,
onRemoveInsertedMedia: PropTypes.func.isRequired,
getAsset: PropTypes.func.isRequired,
};
shouldComponentUpdate(nextProps) {
/**
* Allow widgets to provide their own `shouldComponentUpdate` method.
*/
if (this.wrappedControlShouldComponentUpdate) {
return this.wrappedControlShouldComponentUpdate(nextProps);
}
return this.props.value !== nextProps.value
|| this.props.classNameWrapper !== nextProps.classNameWrapper
|| this.props.hasActiveStyle !== nextProps.hasActiveStyle;
}
processInnerControlRef = ref => {
if (!ref) return;
/**
* If the widget is a container that receives state updates from the store,
* we'll need to get the ref of the actual control via the `react-redux`
* `getWrappedInstance` method. Note that connected widgets must pass
* `withRef: true` to `connect` in the options object.
*/
const wrappedControl = ref.getWrappedInstance ? ref.getWrappedInstance() : ref;
this.wrappedControlValid = wrappedControl.isValid || truthy;
/**
* Get the `shouldComponentUpdate` method from the wrapped control, and
* provide the control instance is the `this` binding.
*/
const { shouldComponentUpdate: scu } = wrappedControl;
this.wrappedControlShouldComponentUpdate = scu && scu.bind(wrappedControl);
};
validate = (skipWrapped = false) => {
const { field, value } = this.props;
const errors = [];
const validations = [this.validatePresence, this.validatePattern];
validations.forEach((func) => {
const response = func(field, value);
if (response.error) errors.push(response.error);
});
if (skipWrapped) {
if (skipWrapped.error) errors.push(skipWrapped.error);
} else {
const wrappedError = this.validateWrappedControl(field);
if (wrappedError.error) errors.push(wrappedError.error);
}
this.props.onValidate(errors);
};
validatePresence = (field, value) => {
const isRequired = field.get('required', true);
if (isRequired && isEmpty(value)) {
const error = {
type: ValidationErrorTypes.PRESENCE,
message: `${ field.get('label', field.get('name')) } is required.`,
};
return { error };
}
return { error: false };
};
validatePattern = (field, value) => {
const pattern = field.get('pattern', false);
if (isEmpty(value)) {
return { error: false };
}
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() }`,
};
return { error };
}
return { error: false };
};
validateWrappedControl = (field) => {
const response = this.wrappedControlValid();
if (typeof response === "boolean") {
const isValid = response;
return { error: (!isValid) };
} else if (response.hasOwnProperty('error')) {
return response;
} else if (response instanceof Promise) {
response.then(
() => { this.validate({ error: false }); },
(err) => {
const error = {
type: ValidationErrorTypes.CUSTOM,
message: `${ field.get('label', field.get('name')) } - ${ err }.`,
};
this.validate({ error });
}
);
const error = {
type: ValidationErrorTypes.CUSTOM,
message: `${ field.get('label', field.get('name')) } is processing.`,
};
return { error };
}
return { error: false };
};
/**
* In case the `onChangeObject` function is frozen by a child widget implementation,
* e.g. when debounced, always get the latest object value instead of using
* `this.props.value` directly.
*/
getObjectValue = () => this.props.value || Map();
/**
* Change handler for fields that are nested within another field.
*/
onChangeObject = (fieldName, newValue, newMetadata) => {
const newObjectValue = this.getObjectValue().set(fieldName, newValue);
return this.props.onChange(newObjectValue, newMetadata);
};
render() {
const {
controlComponent,
field,
value,
mediaPaths,
metadata,
onChange,
onOpenMediaLibrary,
onAddAsset,
onRemoveInsertedMedia,
getAsset,
classNameWrapper,
classNameWidget,
classNameWidgetActive,
classNameLabel,
classNameLabelActive,
setActiveStyle,
setInactiveStyle,
hasActiveStyle,
editorControl,
uniqueFieldId
} = this.props;
return React.createElement(controlComponent, {
field,
value,
mediaPaths,
metadata,
onChange,
onChangeObject: this.onChangeObject,
onOpenMediaLibrary,
onAddAsset,
onRemoveInsertedMedia,
getAsset,
forID: field.get('name') + uniqueFieldId,
ref: this.processInnerControlRef,
classNameWrapper,
classNameWidget,
classNameWidgetActive,
classNameLabel,
classNameLabelActive,
setActiveStyle,
setInactiveStyle,
hasActiveStyle,
editorControl,
});
}
}

View File

@ -0,0 +1,78 @@
/**
* React Split Pane
*/
.Resizer.vertical {
width: 21px;
cursor: col-resize;
position: relative;
transition: background-color var(--transition);
&:before {
content: '';
width: 1px;
height: 100%;
position: relative;
left: 10px;
background-color: var(--textFieldBorderColor);
display: block;
}
&:hover,
&:active {
background-color: var(--colorGrayLight);
}
}
/* Quick fix for preview pane not fully displaying in Safari */
.SplitPane .Pane {
height: 100%;
}
.SplitPane,
.nc-entryEditor-noPreviewEditorContainer {
@apply(--card);
border-radius: 0;
height: 100%;
}
.nc-entryEditor-containerOuter {
width: 100%;
min-width: 800px;
height: 100%;
position: absolute;
top: 0;
left: 0;
overflow: hidden;
padding-top: 66px;
background-color: var(--colorBackground);
}
.nc-entryEditor-container {
max-width: 1600px;
height: 100%;
margin: 0 auto;
position: relative;
}
.nc-entryEditor-controlPane,
.nc-entryEditor-previewPane {
height: 100%;
overflow-y: auto;
}
.nc-entryEditor-controlPane {
padding: 0 16px;
position: relative;
overflow-x: hidden;
}
.nc-entryEditor-viewControls {
position: absolute;
top: 10px;
right: 10px;
z-index: 299;
}
.nc-entryEditor-blocker > * {
pointer-events: none;
}

View File

@ -0,0 +1,224 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import SplitPane from 'react-split-pane';
import classnames from 'classnames';
import { ScrollSync, ScrollSyncPane } from './EditorScrollSync';
import { Icon } from 'netlify-cms-ui-default';
import EditorControlPane from './EditorControlPane/EditorControlPane';
import EditorPreviewPane from './EditorPreviewPane/EditorPreviewPane';
import EditorToolbar from './EditorToolbar';
import EditorToggle from './EditorToggle';
const PREVIEW_VISIBLE = 'cms.preview-visible';
const SCROLL_SYNC_ENABLED = 'cms.scroll-sync-enabled';
class EditorInterface extends Component {
state = {
showEventBlocker: false,
previewVisible: localStorage.getItem(PREVIEW_VISIBLE) !== "false",
scrollSyncEnabled: localStorage.getItem(SCROLL_SYNC_ENABLED) !== "false",
};
handleSplitPaneDragStart = () => {
this.setState({ showEventBlocker: true });
};
handleSplitPaneDragFinished = () => {
this.setState({ showEventBlocker: false });
};
handleOnPersist = (opts = {}) => {
const { createNew = false } = opts;
this.controlPaneRef.validate();
this.props.onPersist({ createNew });
};
handleOnPublish = (opts = {}) => {
const { createNew = false } = opts;
this.controlPaneRef.validate();
this.props.onPublish({ createNew });
};
handleTogglePreview = () => {
const newPreviewVisible = !this.state.previewVisible;
this.setState({ previewVisible: newPreviewVisible });
localStorage.setItem(PREVIEW_VISIBLE, newPreviewVisible);
};
handleToggleScrollSync = () => {
const newScrollSyncEnabled = !this.state.scrollSyncEnabled;
this.setState({ scrollSyncEnabled: newScrollSyncEnabled });
localStorage.setItem(SCROLL_SYNC_ENABLED, newScrollSyncEnabled);
};
render() {
const {
collection,
entry,
fields,
fieldsMetaData,
fieldsErrors,
mediaPaths,
getAsset,
onChange,
enableSave,
showDelete,
onDelete,
onDeleteUnpublishedChanges,
onChangeStatus,
onPublish,
onValidate,
onOpenMediaLibrary,
onAddAsset,
onRemoveInsertedMedia,
user,
hasChanged,
displayUrl,
hasWorkflow,
hasUnpublishedChanges,
isNewEntry,
isModification,
currentStatus,
onLogoutClick,
} = this.props;
const { previewVisible, scrollSyncEnabled, showEventBlocker } = this.state;
const collectionPreviewEnabled = collection.getIn(['editor', 'preview'], true);
const editor = (
<div className={classnames('nc-entryEditor-controlPane', { 'nc-entryEditor-blocker': showEventBlocker })}>
<EditorControlPane
collection={collection}
entry={entry}
fields={fields}
fieldsMetaData={fieldsMetaData}
fieldsErrors={fieldsErrors}
mediaPaths={mediaPaths}
getAsset={getAsset}
onChange={onChange}
onValidate={onValidate}
onOpenMediaLibrary={onOpenMediaLibrary}
onAddAsset={onAddAsset}
onRemoveInsertedMedia={onRemoveInsertedMedia}
ref={c => this.controlPaneRef = c} // eslint-disable-line
/>
</div>
);
const editorWithPreview = (
<ScrollSync enabled={this.state.scrollSyncEnabled}>
<div>
<SplitPane
maxSize={-100}
defaultSize="50%"
onDragStarted={this.handleSplitPaneDragStart}
onDragFinished={this.handleSplitPaneDragFinished}
>
<ScrollSyncPane>{editor}</ScrollSyncPane>
<div className={classnames('nc-entryEditor-previewPane', { 'nc-entryEditor-blocker': showEventBlocker })}>
<EditorPreviewPane
collection={collection}
entry={entry}
fields={fields}
fieldsMetaData={fieldsMetaData}
getAsset={getAsset}
/>
</div>
</SplitPane>
</div>
</ScrollSync>
);
const editorWithoutPreview = (
<div className="nc-entryEditor-noPreviewEditorContainer">
{editor}
</div>
);
return (
<div className="nc-entryEditor-containerOuter">
<EditorToolbar
isPersisting={entry.get('isPersisting')}
isPublishing={entry.get('isPublishing')}
isUpdatingStatus={entry.get('isUpdatingStatus')}
isDeleting={entry.get('isDeleting')}
onPersist={this.handleOnPersist}
onPersistAndNew={() => this.handleOnPersist({ createNew: true })}
onDelete={onDelete}
onDeleteUnpublishedChanges={onDeleteUnpublishedChanges}
onChangeStatus={onChangeStatus}
showDelete={showDelete}
onPublish={onPublish}
onPublishAndNew={() => this.handleOnPublish({ createNew: true })}
enableSave={enableSave}
user={user}
hasChanged={hasChanged}
displayUrl={displayUrl}
collection={collection}
hasWorkflow={hasWorkflow}
hasUnpublishedChanges={hasUnpublishedChanges}
isNewEntry={isNewEntry}
isModification={isModification}
currentStatus={currentStatus}
onLogoutClick={onLogoutClick}
/>
<div className="nc-entryEditor-container">
<div className="nc-entryEditor-viewControls">
<EditorToggle
enabled={collectionPreviewEnabled}
active={previewVisible}
onClick={this.handleTogglePreview}
icon="eye"
/>
<EditorToggle
enabled={collectionPreviewEnabled && previewVisible}
active={scrollSyncEnabled}
onClick={this.handleToggleScrollSync}
icon="scroll"
/>
</div>
{
collectionPreviewEnabled && this.state.previewVisible
? editorWithPreview
: editorWithoutPreview
}
</div>
</div>
);
}
}
EditorInterface.propTypes = {
collection: ImmutablePropTypes.map.isRequired,
entry: ImmutablePropTypes.map.isRequired,
fields: ImmutablePropTypes.list.isRequired,
fieldsMetaData: ImmutablePropTypes.map.isRequired,
fieldsErrors: ImmutablePropTypes.map.isRequired,
mediaPaths: ImmutablePropTypes.map.isRequired,
getAsset: PropTypes.func.isRequired,
onOpenMediaLibrary: PropTypes.func.isRequired,
onAddAsset: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onValidate: PropTypes.func.isRequired,
onPersist: PropTypes.func.isRequired,
enableSave: PropTypes.bool.isRequired,
showDelete: PropTypes.bool.isRequired,
onDelete: PropTypes.func.isRequired,
onDeleteUnpublishedChanges: PropTypes.func.isRequired,
onPublish: PropTypes.func.isRequired,
onChangeStatus: PropTypes.func.isRequired,
onRemoveInsertedMedia: PropTypes.func.isRequired,
user: ImmutablePropTypes.map,
hasChanged: PropTypes.bool,
displayUrl: PropTypes.string,
hasWorkflow: PropTypes.bool,
hasUnpublishedChanges: PropTypes.bool,
isNewEntry: PropTypes.bool,
isModification: PropTypes.bool,
currentStatus: PropTypes.string,
onLogoutClick: PropTypes.func.isRequired,
};
export default EditorInterface;

View File

@ -0,0 +1,37 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
function isVisible(field) {
return field.get('widget') !== 'hidden';
}
const style = {
fontFamily: 'Roboto, "Helvetica Neue", HelveticaNeue, Helvetica, Arial, sans-serif',
};
/**
* Use a stateful component so that child components can effectively utilize
* `shouldComponentUpdate`.
*/
export default class Preview extends React.Component {
render() {
const { collection, fields, widgetFor } = this.props;
if (!collection || !fields) {
return null;
}
return (
<div style={style}>
{fields.filter(isVisible).map(field => widgetFor(field.get('name')))}
</div>
);
}
}
Preview.propTypes = {
collection: ImmutablePropTypes.map.isRequired,
entry: ImmutablePropTypes.map.isRequired,
fields: ImmutablePropTypes.list.isRequired,
getAsset: PropTypes.func.isRequired,
widgetFor: PropTypes.func.isRequired,
};

View File

@ -0,0 +1,28 @@
import PropTypes from 'prop-types';
import React from 'react';
import { isElement } from 'react-is';
import { ScrollSyncPane } from 'react-scroll-sync';
/**
* We need to create a lightweight component here so that we can access the
* context within the Frame. This allows us to attach the ScrollSyncPane to the
* body.
*/
class PreviewContent extends React.Component {
render() {
const { previewComponent, previewProps } = this.props;
return (
<ScrollSyncPane attachTo={this.context.document.scrollingElement}>
{isElement(previewComponent)
? React.cloneElement(previewComponent, previewProps)
: React.createElement(previewComponent, previewProps)}
</ScrollSyncPane>
);
}
}
PreviewContent.contextTypes = {
document: PropTypes.any,
};
export default PreviewContent;

View File

@ -0,0 +1,7 @@
.nc-previewPane-frame {
width: 100%;
height: 100%;
border: none;
background: #fff;
border-radius: var(--borderRadius);
}

View File

@ -0,0 +1,176 @@
import PropTypes from 'prop-types';
import React from 'react';
import { List, Map } from 'immutable';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Frame from 'react-frame-component';
import { resolveWidget, getPreviewTemplate, getPreviewStyles } from 'Lib/registry';
import { ErrorBoundary } from 'UI';
import { selectTemplateName, selectInferedField } from 'Reducers/collections';
import { INFERABLE_FIELDS } from 'Constants/fieldInference';
import EditorPreviewContent from './EditorPreviewContent.js';
import PreviewHOC from './PreviewHOC';
import EditorPreview from './EditorPreview';
export default class PreviewPane extends React.Component {
getWidget = (field, value, props) => {
const { fieldsMetaData, getAsset, entry } = props;
const widget = resolveWidget(field.get('widget'));
/**
* Use an HOC to provide conditional updates for all previews.
*/
return !widget.preview ? null : (
<PreviewHOC
previewComponent={widget.preview}
key={field.get('name')}
field={field}
getAsset={getAsset}
value={value && Map.isMap(value) ? value.get(field.get('name')) : value}
metadata={fieldsMetaData && fieldsMetaData.get(field.get('name'))}
entry={entry}
fieldsMetaData={fieldsMetaData}
/>
);
};
inferedFields = {};
inferFields() {
const titleField = selectInferedField(this.props.collection, 'title');
const shortTitleField = selectInferedField(this.props.collection, 'shortTitle');
const authorField = selectInferedField(this.props.collection, 'author');
this.inferedFields = {};
if (titleField) this.inferedFields[titleField] = INFERABLE_FIELDS.title;
if (shortTitleField) this.inferedFields[shortTitleField] = INFERABLE_FIELDS.shortTitle;
if (authorField) this.inferedFields[authorField] = INFERABLE_FIELDS.author;
}
/**
* Returns the widget component for a named field, and makes recursive calls
* to retrieve components for nested and deeply nested fields, which occur in
* object and list type fields. Used internally to retrieve widgets, and also
* exposed for use in custom preview templates.
*/
widgetFor = (name, fields = this.props.fields, values = this.props.entry.get('data')) => {
// We retrieve the field by name so that this function can also be used in
// custom preview templates, where the field object can't be passed in.
let field = fields && fields.find(f => f.get('name') === name);
let value = values && values.get(field.get('name'));
let nestedFields = field.get('fields');
if (nestedFields) {
field = field.set('fields', this.getNestedWidgets(nestedFields, value));
}
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>;
}
return value ? this.getWidget(field, value, this.props) : null;
};
/**
* Retrieves widgets for nested fields (children of object/list fields)
*/
getNestedWidgets = (fields, values) => {
// Fields nested within a list field will be paired with a List of value Maps.
if (List.isList(values)) {
return values.map(value => this.widgetsForNestedFields(fields, value));
}
// Fields nested within an object field will be paired with a single Map of values.
return this.widgetsForNestedFields(fields, values);
};
/**
* Use widgetFor as a mapping function for recursive widget retrieval
*/
widgetsForNestedFields = (fields, values) => {
return fields.map(field => this.widgetFor(field.get('name'), fields, values));
};
/**
* This function exists entirely to expose nested widgets for object and list
* fields to custom preview templates.
*
* TODO: see if widgetFor can now provide this functionality for preview templates
*/
widgetsFor = (name) => {
const { fields, entry } = this.props;
const field = fields.find(f => f.get('name') === name);
const nestedFields = field && field.get('fields');
const value = entry.getIn(['data', field.get('name')]);
if (List.isList(value)) {
return value.map((val, index) => {
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)])),
});
};
render() {
const { entry, collection } = this.props;
if (!entry || !entry.get('data')) {
return null;
}
const previewComponent =
getPreviewTemplate(selectTemplateName(collection, entry.get('slug'))) ||
EditorPreview;
this.inferFields();
const previewProps = {
...this.props,
widgetFor: this.widgetFor,
widgetsFor: this.widgetsFor,
};
const styleEls = getPreviewStyles()
.map((style, i) => {
if (style.raw) {
return <style key={i}>{style.value}</style>
}
return <link key={i} href={style.value} type="text/css" rel="stylesheet" />;
});
if (!collection) {
return <Frame className="nc-previewPane-frame" head={styleEls} />;
}
const initialContent = `
<!DOCTYPE html>
<html>
<head><base target="_blank"/></head>
<body><div></div></body>
</html>
`;
return (
<ErrorBoundary>
<Frame className="nc-previewPane-frame" head={styleEls} initialContent={initialContent}>
<EditorPreviewContent {...{ previewComponent, previewProps }}/>
</Frame>
</ErrorBoundary>
);
}
}
PreviewPane.propTypes = {
collection: ImmutablePropTypes.map.isRequired,
fields: ImmutablePropTypes.list.isRequired,
entry: ImmutablePropTypes.map.isRequired,
fieldsMetaData: ImmutablePropTypes.map.isRequired,
getAsset: PropTypes.func.isRequired,
};

View File

@ -0,0 +1,21 @@
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
* will only be updated on value change.
*/
shouldComponentUpdate(nextProps) {
const isWidgetContainer = ['object', 'list'].includes(nextProps.field.get('widget'));
return isWidgetContainer || this.props.value !== nextProps.value;
}
render() {
const { previewComponent, ...props } = this.props;
return React.createElement(previewComponent, props);
}
}
export default PreviewHOC;

View File

@ -0,0 +1,127 @@
import React, { Component } from 'react'
import PropTypes from 'prop-types'
/**
* ScrollSync provider component
*
*/
export default class ScrollSync extends Component {
static propTypes = {
children: PropTypes.element.isRequired,
proportional: PropTypes.bool,
vertical: PropTypes.bool,
horizontal: PropTypes.bool,
enabled: PropTypes.bool
};
static defaultProps = {
proportional: true,
vertical: true,
horizontal: true,
enabled: true
};
static childContextTypes = {
registerPane: PropTypes.func,
unregisterPane: PropTypes.func
}
getChildContext() {
return {
registerPane: this.registerPane,
unregisterPane: this.unregisterPane
}
}
panes = {}
registerPane = (node, group) => {
if (!this.panes[group]) {
this.panes[group] = []
}
if (!this.findPane(node, group)) {
this.addEvents(node, group)
this.panes[group].push(node)
}
}
unregisterPane = (node, group) => {
if (this.findPane(node, group)) {
this.removeEvents(node)
this.panes[group].splice(this.panes[group].indexOf(node), 1)
}
}
addEvents = (node, group) => {
/* For some reason element.addEventListener doesnt work with document.body */
node.onscroll = this.handlePaneScroll.bind(this, node, group) // eslint-disable-line
}
removeEvents = (node) => {
/* For some reason element.removeEventListener doesnt work with document.body */
node.onscroll = null // eslint-disable-line
}
findPane = (node, group) => {
if (!this.panes[group]) {
return false
}
return this.panes[group].find(pane => pane === node)
}
handlePaneScroll = (node, group) => {
if (!this.props.enabled) {
return;
}
window.requestAnimationFrame(() => {
this.syncScrollPositions(node, group)
})
}
syncScrollPositions = (scrolledPane, group) => {
const {
scrollTop,
scrollHeight,
clientHeight,
scrollLeft,
scrollWidth,
clientWidth
} = scrolledPane
const scrollTopOffset = scrollHeight - clientHeight
const scrollLeftOffset = scrollWidth - clientWidth
const { proportional, vertical, horizontal } = this.props
this.panes[group].forEach((pane) => {
/* For all panes beside the currently scrolling one */
if (scrolledPane !== pane) {
/* Remove event listeners from the node that we'll manipulate */
this.removeEvents(pane, group)
/* Calculate the actual pane height */
const paneHeight = pane.scrollHeight - clientHeight
const paneWidth = pane.scrollWidth - clientWidth
/* Adjust the scrollTop position of it accordingly */
if (vertical && scrollTopOffset > 0) {
pane.scrollTop = proportional ? (paneHeight * scrollTop) / scrollTopOffset : scrollTop // eslint-disable-line
}
if (horizontal && scrollLeftOffset > 0) {
pane.scrollLeft = proportional ? (paneWidth * scrollLeft) / scrollLeftOffset : scrollLeft // eslint-disable-line
}
/* Re-attach event listeners after we're done scrolling */
window.requestAnimationFrame(() => {
this.addEvents(pane, group)
})
}
})
}
render() {
return React.Children.only(this.props.children)
}
}

View File

@ -0,0 +1,50 @@
import { Component } from 'react'
import ReactDOM from 'react-dom'
import PropTypes from 'prop-types'
/**
* ScrollSyncPane Component
*
* Wrap your content in it to keep its scroll position in sync with other panes
*
* @example ./example.md
*/
export default class ScrollSyncPane extends Component {
static propTypes = {
children: PropTypes.node.isRequired,
attachTo: PropTypes.object,
group: PropTypes.string
}
static defaultProps = {
group: 'default'
}
static contextTypes = {
registerPane: PropTypes.func.isRequired,
unregisterPane: PropTypes.func.isRequired
};
componentDidMount() {
this.node = this.props.attachTo || ReactDOM.findDOMNode(this)
this.context.registerPane(this.node, this.props.group)
}
componentWillReceiveProps(nextProps) {
if (this.props.group !== nextProps.group) {
this.context.unregisterPane(this.node, this.props.group)
this.context.registerPane(this.node, nextProps.group)
}
}
componentWillUnmount() {
this.context.unregisterPane(this.node, this.props.group)
}
render() {
return this.props.children
}
}

View File

@ -0,0 +1,2 @@
export { default as ScrollSync } from './ScrollSync';
export { default as ScrollSyncPane } from './ScrollSyncPane';

View File

@ -0,0 +1,23 @@
.nc-editor-toggle {
@apply(--dropShadowMiddle);
background-color: #fff;
color: var(--colorInactive);
border-radius: 32px;
display: block;
width: 40px;
height: 40px;
padding: 0;
margin-bottom: 12px;
& .nc-icon {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
}
.nc-editor-toggleActive {
color: var(--colorActive);
}

View File

@ -0,0 +1,18 @@
import React from 'react';
import PropTypes from 'prop-types';
import c from 'classnames';
import { Icon } from 'netlify-cms-ui-default';
const EditorToggle = ({ enabled, active, onClick, icon }) => !enabled ? null :
<button className={c('nc-editor-toggle', {'nc-editor-toggleActive': active })} onClick={onClick}>
<Icon type={icon} size="large"/>
</button>;
EditorToggle.propTypes = {
enabled: PropTypes.bool,
active: PropTypes.bool,
onClick: PropTypes.func.isRequired,
icon: PropTypes.string.isRequired,
};
export default EditorToggle;

View File

@ -0,0 +1,151 @@
:root {
--editorToolbarButtonMargin: 0 10px;
}
.nc-entryEditor-toolbar {
box-shadow: 0 2px 6px 0 rgba(68, 74, 87, 0.05),
0 1px 3px 0 rgba(68, 74, 87, 0.10),
0 2px 54px rgba(0, 0, 0, 0.1);
position: fixed;
top: 0;
left: 0;
width: 100%;
min-width: 800px;
z-index: 300;
background-color: #fff;
height: 66px;
display: flex;
justify-content: space-between;
}
.nc-entryEditor-toolbar-mainSection,
.nc-entryEditor-toolbar-backSection,
.nc-entryEditor-toolbar-metaSection {
height: 100%;
display: flex;
align-items: center;
}
.nc-entryEditor-toolbar-mainSection {
flex: 10;
display: flex;
justify-content: space-between;
padding: 0 10px;
& .nc-entryEditor-toolbar-mainSection-left {
display: flex;
}
& .nc-entryEditor-toolbar-mainSection-right {
display: flex;
justify-content: flex-end;
}
}
.nc-entryEditor-toolbar-backSection,
.nc-entryEditor-toolbar-metaSection {
border: 0 solid var(--textFieldBorderColor);
}
.nc-entryEditor-toolbar-dropdown {
margin: var(--editorToolbarButtonMargin);
& .nc-icon {
color: var(--colorTeal);
}
}
.nc-entryEditor-toolbar-publishButton {
background-color: var(--colorTeal);
}
.nc-entryEditor-toolbar-statusButton {
background-color: var(--colorTealLight);
color: var(--colorTeal);
}
.nc-entryEditor-toolbar-backSection {
border-right-width: 1px;
font-weight: normal;
padding: 0 20px;
&:hover,
&:focus {
background-color: #F1F2F4;
}
}
.nc-entryEditor-toolbar-metaSection {
border-left-width: 1px;
padding: 0 7px;
}
.nc-entryEditor-toolbar-backArrow {
color: var(--colorTextLead);
font-size: 21px;
font-weight: 600;
margin-right: 16px;
}
.nc-entryEditor-toolbar-backCollection {
color: var(--colorTextLead);
font-size: 14px;
}
.nc-entryEditor-toolbar-backStatus {
@apply(--textBadgeSuccess);
&::after {
height: 12px;
width: 15.5px;
color: #005614;
margin-left: 5px;
position: relative;
top: 1px;
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>");
}
}
.nc-entryEditor-toolbar-backStatus-hasChanged {
@apply(--textBadgeDanger);
}
.nc-entryEditor-toolbar-backStatus,
.nc-entryEditor-toolbar-backStatus-hasChanged {
margin-top: 6px;
}
.nc-entryEditor-toolbar-deleteButton,
.nc-entryEditor-toolbar-saveButton {
@apply(--buttonDefault);
display: block;
margin: var(--editorToolbarButtonMargin);
}
.nc-entryEditor-toolbar-deleteButton {
@apply(--buttonLightRed);
}
.nc-entryEditor-toolbar-saveButton {
@apply(--buttonLightBlue);
}
.nc-entryEditor-toolbar-statusPublished {
margin: var(--editorToolbarButtonMargin);
border: 1px solid var(--textFieldBorderColor);
border-radius: var(--borderRadius);
background-color: var(--colorWhite);
color: var(--colorTeal);
padding: 0 24px;
line-height: 36px;
cursor: default;
font-size: 14px;
font-weight: 500;
}
.nc-entryEditor-toolbar-statusMenu-status .nc-icon {
color: var(--colorInfoText);
}

View File

@ -0,0 +1,248 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import c from 'classnames';
import { Link } from 'react-router-dom';
import { status } from 'Constants/publishModes';
import { Icon, Dropdown, DropdownItem } from 'netlify-cms-ui-default';
import { stripProtocol } from 'Lib/urlHelper';
export default class EditorToolbar extends React.Component {
static propTypes = {
isPersisting: PropTypes.bool,
isPublishing: PropTypes.bool,
isUpdatingStatus: PropTypes.bool,
isDeleting: PropTypes.bool,
onPersist: PropTypes.func.isRequired,
onPersistAndNew: PropTypes.func.isRequired,
enableSave: PropTypes.bool.isRequired,
showDelete: PropTypes.bool.isRequired,
onDelete: PropTypes.func.isRequired,
onDeleteUnpublishedChanges: PropTypes.func.isRequired,
onChangeStatus: PropTypes.func.isRequired,
onPublish: PropTypes.func.isRequired,
onPublishAndNew: PropTypes.func.isRequired,
user: ImmutablePropTypes.map,
hasChanged: PropTypes.bool,
displayUrl: PropTypes.string,
collection: ImmutablePropTypes.map.isRequired,
hasWorkflow: PropTypes.bool,
hasUnpublishedChanges: PropTypes.bool,
isNewEntry: PropTypes.bool,
isModification: PropTypes.bool,
currentStatus: PropTypes.string,
onLogoutClick: PropTypes.func.isRequired,
};
renderSimpleSaveControls = () => {
const { showDelete, onDelete } = this.props;
return (
<div>
{
showDelete
? <button className="nc-entryEditor-toolbar-deleteButton" onClick={onDelete}>
Delete entry
</button>
: null
}
</div>
);
};
renderSimplePublishControls = () => {
const { collection, onPersist, onPersistAndNew, isPersisting, hasChanged, isNewEntry } = this.props;
if (!isNewEntry && !hasChanged) {
return <div className="nc-entryEditor-toolbar-statusPublished">Published</div>;
}
return (
<div>
<Dropdown
className="nc-entryEditor-toolbar-dropdown"
classNameButton="nc-entryEditor-toolbar-publishButton"
dropdownTopOverlap="40px"
dropdownWidth="150px"
label={isPersisting ? 'Publishing...' : 'Publish'}
>
<DropdownItem label="Publish now" icon="arrow" iconDirection="right" onClick={onPersist}/>
{
collection.get('create')
? <DropdownItem label="Publish and create new" icon="add" onClick={onPersistAndNew}/>
: null
}
</Dropdown>
</div>
);
};
renderWorkflowSaveControls = () => {
const {
onPersist,
onDelete,
onDeleteUnpublishedChanges,
hasChanged,
hasUnpublishedChanges,
isPersisting,
isDeleting,
isNewEntry,
isModification,
} = this.props;
const deleteLabel = (hasUnpublishedChanges && isModification && 'Delete unpublished changes')
|| (hasUnpublishedChanges && (isNewEntry || !isModification) && 'Delete unpublished entry')
|| (!hasUnpublishedChanges && !isModification && 'Delete published entry');
return [
<button
key="save-button"
className="nc-entryEditor-toolbar-saveButton"
onClick={() => hasChanged && onPersist()}
>
{isPersisting ? 'Saving...' : 'Save'}
</button>,
isNewEntry || !deleteLabel ? null
: <button
key="delete-button"
className="nc-entryEditor-toolbar-deleteButton"
onClick={hasUnpublishedChanges ? onDeleteUnpublishedChanges : onDelete}
>
{isDeleting ? 'Deleting...' : deleteLabel}
</button>,
];
};
renderWorkflowPublishControls = () => {
const {
collection,
onPersist,
onPersistAndNew,
isUpdatingStatus,
isPublishing,
onChangeStatus,
onPublish,
onPublishAndNew,
currentStatus,
isNewEntry,
} = this.props;
if (currentStatus) {
return [
<Dropdown
className="nc-entryEditor-toolbar-dropdown"
classNameButton="nc-entryEditor-toolbar-statusButton"
dropdownTopOverlap="40px"
dropdownWidth="120px"
label={isUpdatingStatus ? 'Updating...' : 'Set status'}
>
<DropdownItem
className="nc-entryEditor-toolbar-statusMenu-status"
label="Draft"
onClick={() => onChangeStatus('DRAFT')}
icon={currentStatus === status.get('DRAFT') && 'check'}
/>
<DropdownItem
className="nc-entryEditor-toolbar-statusMenu-status"
label="In review"
onClick={() => onChangeStatus('PENDING_REVIEW')}
icon={currentStatus === status.get('PENDING_REVIEW') && 'check'}
/>
<DropdownItem
className="nc-entryEditor-toolbar-statusMenu-status"
label="Ready"
onClick={() => onChangeStatus('PENDING_PUBLISH')}
icon={currentStatus === status.get('PENDING_PUBLISH') && 'check'}
/>
</Dropdown>,
<Dropdown
className="nc-entryEditor-toolbar-dropdown"
classNameButton="nc-entryEditor-toolbar-publishButton"
dropdownTopOverlap="40px"
dropdownWidth="150px"
label={isPublishing ? 'Publishing...' : 'Publish'}
>
<DropdownItem label="Publish now" icon="arrow" iconDirection="right" onClick={onPublish}/>
{
collection.get('create')
? <DropdownItem label="Publish and create new" icon="add" onClick={onPublishAndNew}/>
: null
}
</Dropdown>
];
}
if (!isNewEntry) {
return <div className="nc-entryEditor-toolbar-statusPublished">Published</div>;
}
};
render() {
const {
isPersisting,
onPersist,
onPersistAndNew,
enableSave,
showDelete,
onDelete,
user,
hasChanged,
displayUrl,
collection,
hasWorkflow,
hasUnpublishedChanges,
onLogoutClick,
} = this.props;
const disabled = !enableSave || isPersisting;
const avatarUrl = user.get('avatar_url');
return (
<div className="nc-entryEditor-toolbar">
<Link to={`/collections/${collection.get('name')}`} className="nc-entryEditor-toolbar-backSection">
<div className="nc-entryEditor-toolbar-backArrow"></div>
<div>
<div className="nc-entryEditor-toolbar-backCollection">
Writing in <strong>{collection.get('label')}</strong> collection
</div>
{
hasChanged
? <div className="nc-entryEditor-toolbar-backStatus-hasChanged">Unsaved Changes</div>
: <div className="nc-entryEditor-toolbar-backStatus">Changes saved</div>
}
</div>
</Link>
<div className="nc-entryEditor-toolbar-mainSection">
<div className="nc-entryEditor-toolbar-mainSection-left">
{ hasWorkflow ? this.renderWorkflowSaveControls() : this.renderSimpleSaveControls() }
</div>
<div className="nc-entryEditor-toolbar-mainSection-right">
{ hasWorkflow ? this.renderWorkflowPublishControls() : this.renderSimplePublishControls() }
</div>
</div>
<div className="nc-entryEditor-toolbar-metaSection">
{
displayUrl
? <a className="nc-appHeader-siteLink" href={displayUrl} target="_blank">
{stripProtocol(displayUrl)}
</a>
: null
}
<Dropdown
dropdownTopOverlap="50px"
dropdownWidth="100px"
dropdownPosition="right"
button={
<button className="nc-appHeader-avatar">
{
avatarUrl
? <img className="nc-appHeader-avatar-image" src={user.get('avatar_url')}/>
: <Icon className="nc-appHeader-avatar-placeholder" type="user" size="large"/>
}
</button>
}
>
<DropdownItem label="Log Out" onClick={onLogoutClick}/>
</Dropdown>
</div>
</div>
);
}
};

View File

@ -0,0 +1,58 @@
import React from 'react';
import { connect } from 'react-redux';
import { EDITORIAL_WORKFLOW } from 'Constants/publishModes';
import { selectUnpublishedEntry, selectEntry } from 'Reducers';
import { selectAllowDeletion } from 'Reducers/collections';
import { loadUnpublishedEntry, persistUnpublishedEntry } from 'Actions/editorialWorkflow';
function mapStateToProps(state, ownProps) {
const { collections } = state;
const isEditorialWorkflow = (state.config.get('publish_mode') === EDITORIAL_WORKFLOW);
const collection = collections.get(ownProps.match.params.name);
const returnObj = {
isEditorialWorkflow,
showDelete: !ownProps.newEntry && selectAllowDeletion(collection),
};
if (isEditorialWorkflow) {
const slug = ownProps.match.params.slug;
const unpublishedEntry = selectUnpublishedEntry(state, collection.get('name'), slug);
if (unpublishedEntry) {
returnObj.unpublishedEntry = true;
returnObj.entry = unpublishedEntry;
}
}
return returnObj;
}
function mergeProps(stateProps, dispatchProps, ownProps) {
const { isEditorialWorkflow, unpublishedEntry } = stateProps;
const { dispatch } = dispatchProps;
const returnObj = {};
if (isEditorialWorkflow) {
// Overwrite loadEntry to loadUnpublishedEntry
returnObj.loadEntry = (collection, slug) =>
dispatch(loadUnpublishedEntry(collection, slug));
// Overwrite persistEntry to persistUnpublishedEntry
returnObj.persistEntry = collection =>
dispatch(persistUnpublishedEntry(collection, unpublishedEntry));
}
return {
...ownProps,
...stateProps,
...returnObj,
};
}
export default function withWorkflow(Editor) {
return connect(mapStateToProps, null, mergeProps)(
class extends React.Component {
render() {
return <Editor {...this.props} />;
}
}
);
};

View File

@ -0,0 +1,9 @@
.nc-booleanControl-switch {
& .nc-toggle-background {
background-color: var(--textFieldBorderColor);
}
& .nc-toggle-active .nc-toggle-background {
background-color: var(--colorActive);
}
}

View File

@ -0,0 +1,44 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from "react-immutable-proptypes";
import { isBoolean } from 'lodash';
import { Toggle } from 'netlify-cms-ui-default';
export default class BooleanControl extends React.Component {
render() {
const {
value,
field,
forID,
onChange,
classNameWrapper,
setActiveStyle,
setInactiveStyle
} = this.props;
return (
<div className={`${classNameWrapper} nc-booleanControl-switch`}>
<Toggle
id={forID}
active={isBoolean(value) ? value : field.get('defaultValue', false)}
onChange={onChange}
onFocus={setActiveStyle}
onBlur={setInactiveStyle}
/>
</div>
);
}
}
BooleanControl.propTypes = {
field: ImmutablePropTypes.map.isRequired,
onChange: PropTypes.func.isRequired,
classNameWrapper: PropTypes.string.isRequired,
setActiveStyle: PropTypes.func.isRequired,
setInactiveStyle: PropTypes.func.isRequired,
forID: PropTypes.string,
value: PropTypes.bool,
};
BooleanControl.defaultProps = {
value: false,
};

View File

@ -0,0 +1,93 @@
import React from 'react';
import PropTypes from 'prop-types';
import DateTime from 'react-datetime';
import moment from 'moment';
const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD';
const DEFAULT_DATETIME_FORMAT = moment.defaultFormat;
export default class DateControl extends React.Component {
static propTypes = {
field: PropTypes.object.isRequired,
onChange: PropTypes.func.isRequired,
classNameWrapper: PropTypes.string.isRequired,
setActiveStyle: PropTypes.func.isRequired,
setInactiveStyle: PropTypes.func.isRequired,
value: PropTypes.oneOfType([
PropTypes.object,
PropTypes.string,
]),
includeTime: PropTypes.bool,
};
format = this.props.field.get('format');
componentDidMount() {
const { value } = this.props;
/**
* Set the current date as default value if no default value is provided. An
* empty string means the value is intentionally blank.
*/
if (!value && value !== '') {
this.handleChange(new Date());
}
}
// Date is valid if datetime is a moment or Date object otherwise it's a string.
// Handle the empty case, if the user wants to empty the field.
isValidDate = datetime => (moment.isMoment(datetime) || datetime instanceof Date || datetime === '');
handleChange = datetime => {
const { onChange } = this.props;
/**
* Set the date only if it is valid.
*/
if (!this.isValidDate(datetime)) {
return;
}
/**
* Produce a formatted string only if a format is set in the config.
* Otherwise produce a date object.
*/
if (this.format) {
const formattedValue = moment(datetime).format(this.format);
onChange(formattedValue);
} else {
const value = moment.isMoment(datetime) ? datetime.toDate() : datetime;
onChange(value);
}
};
onBlur = datetime => {
const { setInactiveStyle } = this.props;
if (!this.isValidDate(datetime)) {
const parsedDate = moment(datetime);
if (parsedDate.isValid()) {
this.handleChange(datetime);
} else {
window.alert('The date you entered is invalid.');
}
}
setInactiveStyle();
};
render() {
const { includeTime, value, classNameWrapper, setActiveStyle, setInactiveStyle } = this.props;
return (
<DateTime
timeFormat={!!includeTime}
value={moment(value, this.format)}
onChange={this.handleChange}
onFocus={setActiveStyle}
onBlur={this.onBlur}
inputProps={{ className: classNameWrapper }}
/>
);
}
}

View File

@ -0,0 +1,10 @@
import PropTypes from 'prop-types';
import React from 'react';
export default function DatePreview({ value }) {
return <div className="nc-widgetPreview">{value ? value.toString() : null}</div>;
}
DatePreview.propTypes = {
value: PropTypes.object,
};

View File

@ -0,0 +1 @@
@import "./ReactDatetime.css";

View File

@ -0,0 +1,43 @@
import React from 'react';
import PropTypes from 'prop-types';
import DateControl from 'EditorWidgets/Date/DateControl';
export default class DateTimeControl extends React.Component {
static propTypes = {
field: PropTypes.object.isRequired,
onChange: PropTypes.func.isRequired,
classNameWrapper: PropTypes.string.isRequired,
setActiveStyle: PropTypes.func.isRequired,
setInactiveStyle: PropTypes.func.isRequired,
value: PropTypes.oneOfType([
PropTypes.object,
PropTypes.string,
]),
format: PropTypes.string,
};
render() {
const {
field,
format,
onChange,
value,
classNameWrapper,
setActiveStyle,
setInactiveStyle
} = this.props;
return (
<DateControl
onChange={onChange}
format={format}
value={value}
field={field}
classNameWrapper={classNameWrapper}
setActiveStyle={setActiveStyle}
setInactiveStyle={setInactiveStyle}
includeTime
/>
);
}
}

View File

@ -0,0 +1,10 @@
import PropTypes from 'prop-types';
import React from 'react';
export default function DateTimePreview({ value }) {
return <div className="nc-widgetPreview">{value ? value.toString() : null}</div>;
}
DateTimePreview.propTypes = {
value: PropTypes.object,
};

View File

@ -0,0 +1,210 @@
.rdt {
position: relative;
}
.rdtPicker {
display: none;
position: absolute;
width: 250px;
padding: 4px;
margin-top: 1px;
z-index: 99999 !important;
background: #fff;
border: 2px solid var(--colorGray);
border-radius: 2px;
box-shadow: 0 2px 8px 0 rgba(0, 0, 0, .16);
}
.rdtOpen .rdtPicker {
display: block;
}
.rdtStatic .rdtPicker {
box-shadow: none;
position: static;
}
.rdtPicker .rdtTimeToggle {
text-align: center;
}
.rdtPicker table {
width: 100%;
margin: 0;
}
.rdtPicker td,
.rdtPicker th {
text-align: center;
height: 28px;
}
.rdtPicker td {
cursor: pointer;
}
.rdtPicker td.rdtDay:hover,
.rdtPicker td.rdtHour:hover,
.rdtPicker td.rdtMinute:hover,
.rdtPicker td.rdtSecond:hover,
.rdtPicker .rdtTimeToggle:hover {
background: #eeeeee;
cursor: pointer;
}
.rdtPicker td.rdtOld,
.rdtPicker td.rdtNew {
color: #999999;
}
.rdtPicker td.rdtToday {
position: relative;
}
.rdtPicker td.rdtToday:before {
content: '';
display: inline-block;
border-left: 7px solid transparent;
border-bottom: 7px solid #428bca;
border-top-color: rgba(0, 0, 0, 0.2);
position: absolute;
bottom: 4px;
right: 4px;
}
.rdtPicker td.rdtActive,
.rdtPicker td.rdtActive:hover {
background-color: #428bca;
color: #fff;
text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25);
}
.rdtPicker td.rdtActive.rdtToday:before {
border-bottom-color: #fff;
}
.rdtPicker td.rdtDisabled,
.rdtPicker td.rdtDisabled:hover {
background: none;
color: #999999;
cursor: not-allowed;
}
.rdtPicker td span.rdtOld {
color: #999999;
}
.rdtPicker td span.rdtDisabled,
.rdtPicker td span.rdtDisabled:hover {
background: none;
color: #999999;
cursor: not-allowed;
}
.rdtPicker th {
border-bottom: 1px solid #f9f9f9;
}
.rdtPicker .dow {
width: 14.2857%;
border-bottom: none;
}
.rdtPicker th.rdtSwitch {
width: 100px;
}
.rdtPicker th.rdtNext,
.rdtPicker th.rdtPrev {
font-size: 21px;
vertical-align: top;
}
.rdtPrev span,
.rdtNext span {
display: block;
-webkit-touch-callout: none; /* iOS Safari */
-webkit-user-select: none; /* Chrome/Safari/Opera */
-khtml-user-select: none; /* Konqueror */
-moz-user-select: none; /* Firefox */
-ms-user-select: none; /* Internet Explorer/Edge */
user-select: none;
}
.rdtPicker th.rdtDisabled,
.rdtPicker th.rdtDisabled:hover {
background: none;
color: #999999;
cursor: not-allowed;
}
.rdtPicker thead tr:first-child th {
cursor: pointer;
}
.rdtPicker thead tr:first-child th:hover {
background: #eeeeee;
}
.rdtPicker tfoot {
border-top: 1px solid #f9f9f9;
}
.rdtPicker button {
border: none;
background: none;
cursor: pointer;
}
.rdtPicker button:hover {
background-color: #eee;
}
.rdtPicker thead button {
width: 100%;
height: 100%;
}
td.rdtMonth,
td.rdtYear {
height: 50px;
width: 25%;
cursor: pointer;
}
td.rdtMonth:hover,
td.rdtYear:hover {
background: #eee;
}
.rdtCounters {
display: inline-block;
}
.rdtCounters > div {
float: left;
}
.rdtCounter {
height: 100px;
}
.rdtCounter {
width: 40px;
}
.rdtCounterSeparator {
line-height: 100px;
}
.rdtCounter .rdtBtn {
height: 40%;
line-height: 40px;
cursor: pointer;
display: block;
-webkit-touch-callout: none; /* iOS Safari */
-webkit-user-select: none; /* Chrome/Safari/Opera */
-khtml-user-select: none; /* Konqueror */
-moz-user-select: none; /* Firefox */
-ms-user-select: none; /* Internet Explorer/Edge */
user-select: none;
}
.rdtCounter .rdtBtn:hover {
background: #eee;
}
.rdtCounter .rdtCount {
height: 20%;
font-size: 1.2em;
}
.rdtMilli {
vertical-align: middle;
padding-left: 8px;
width: 48px;
}
.rdtMilli input {
width: 100%;
font-size: 1.2em;
margin-top: 37px;
}

View File

@ -0,0 +1,17 @@
@import "./Object/Object.css";
@import "./List/List.css";
@import "./withMedia/withMedia.css";
@import "./Image/Image.css";
@import "./File/FileControl.css";
@import "./Markdown/Markdown.css";
@import "./Boolean/Boolean.css";
@import "./Relation/Relation.css";
@import "./DateTime/DateTime.css";
:root {
--widgetNestDistance: 14px;
}
.nc-widgetPreview {
margin: 15px 2px;
}

View File

@ -0,0 +1,7 @@
.nc-fileControl-input {
display: none !important;
}
.nc-fileControl-imageUpload {
cursor: pointer;
}

View File

@ -0,0 +1,5 @@
import withMediaControl from 'EditorWidgets/withMedia/withMediaControl';
const FileControl = withMediaControl();
export default FileControl;

View File

@ -0,0 +1,15 @@
import PropTypes from 'prop-types';
import React from 'react';
export default function FilePreview({ value, getAsset }) {
return (<div className="nc-widgetPreview">
{ value ?
<a href={getAsset(value)}>{ value }</a>
: null}
</div>);
}
FilePreview.propTypes = {
getAsset: PropTypes.func.isRequired,
value: PropTypes.node,
};

View File

@ -0,0 +1,4 @@
.nc-imagePreview-image {
max-width: 100%;
height: auto;
}

View File

@ -0,0 +1,5 @@
import withMediaControl from 'EditorWidgets/withMedia/withMediaControl';
const ImageControl = withMediaControl(true);
export default ImageControl;

View File

@ -0,0 +1,19 @@
import PropTypes from 'prop-types';
import React from 'react';
export default function ImagePreview({ value, getAsset }) {
return (<div className='nc-widgetPreview'>
{ value ?
<img
src={getAsset(value)}
className='nc-imageWidget-image'
role="presentation"
/>
: null}
</div>);
}
ImagePreview.propTypes = {
getAsset: PropTypes.func.isRequired,
value: PropTypes.node,
};

View File

@ -0,0 +1,88 @@
.nc-listControl {
padding: 0 14px 14px;
&.nc-listControl-collapsed {
padding-bottom: 0;
}
}
.list-item-dragging {
opacity: 0.5;
}
.nc-listControl-topBar {
display: flex;
justify-content: space-between;
align-items: center;
margin: 0 -14px;
background-color: var(--textFieldBorderColor);
padding: 13px;
}
.nc-listControl-addButton {
display: flex;
justify-content: center;
align-items: center;
padding: 2px 12px;
font-size: 12px;
font-weight: bold;
border-radius: 3px;
& .nc-icon {
padding-left: 6px;
}
}
.nc-listControl-listCollapseToggle {
display: flex;
align-items: center;
font-size: 14px;
font-weight: 500;
line-height: 1;
}
.nc-listControl-listCollapseToggleButton{
padding: 4px;
background-color: transparent;
color: inherit;
&:last-of-type {
margin-right: 4px;
}
}
.nc-listControl-item {
margin-top: 18px;
&:first-of-type {
margin-top: 26px;
}
}
.nc-listControl-itemTopBar {
background-color: var(--textFieldBorderColor);
}
.nc-listControl-objectLabel {
display: none;
border-top: 0;
background-color: var(--textFieldBorderColor);
padding: 13px;
border-radius: 0 0 var(--borderRadius) var(--borderRadius);
}
.nc-listControl-objectControl {
padding: 6px 14px 14px;
border-top: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.nc-listControl-collapsed {
& .nc-listControl-objectLabel {
display: block;
}
& .nc-listControl-objectControl {
display: none;
}
}

View File

@ -0,0 +1,307 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { List, Map } from 'immutable';
import { partial } from 'lodash';
import c from 'classnames';
import { SortableContainer, SortableElement, SortableHandle } from 'react-sortable-hoc';
import { Icon, ListItemTopBar } from 'netlify-cms-ui-default';
import ObjectControl from 'EditorWidgets/Object/ObjectControl';
function ListItem(props) {
return <div {...props} className={`list-item ${ props.className || '' }`}>{props.children}</div>;
}
ListItem.propTypes = {
className: PropTypes.string,
children: PropTypes.node,
};
ListItem.displayName = 'list-item';
function valueToString(value) {
return value ? value.join(',').replace(/,([^\s]|$)/g, ', $1') : '';
}
const SortableListItem = SortableElement(ListItem);
const TopBar = ({ allowAdd, onAdd, listLabel, onCollapseAllToggle, allItemsCollapsed, itemsCount }) => (
<div className="nc-listControl-topBar">
<div className="nc-listControl-listCollapseToggle">
<button className="nc-listControl-listCollapseToggleButton" onClick={onCollapseAllToggle}>
<Icon type="chevron" direction={allItemsCollapsed ? 'right' : 'down'} size="small" />
</button>
{itemsCount} {listLabel}
</div>
{
allowAdd ?
<button className="nc-listControl-addButton" onClick={onAdd}>
Add {listLabel} <Icon type="add" size="xsmall" />
</button>
:
null
}
</div>
);
const SortableList = SortableContainer(({ items, renderItem }) => {
return <div>{items.map(renderItem)}</div>;
});
const valueTypes = {
SINGLE: 'SINGLE',
MULTIPLE: 'MULTIPLE',
};
export default class ListControl extends Component {
static propTypes = {
onChange: PropTypes.func.isRequired,
onChangeObject: PropTypes.func.isRequired,
value: ImmutablePropTypes.list,
field: PropTypes.object,
forID: PropTypes.string,
mediaPaths: ImmutablePropTypes.map.isRequired,
getAsset: PropTypes.func.isRequired,
onOpenMediaLibrary: PropTypes.func.isRequired,
onAddAsset: PropTypes.func.isRequired,
onRemoveInsertedMedia: PropTypes.func.isRequired,
classNameWrapper: PropTypes.string.isRequired,
setActiveStyle: PropTypes.func.isRequired,
setInactiveStyle: PropTypes.func.isRequired,
};
static defaultProps = {
value: List(),
};
constructor(props) {
super(props);
const { field, value } = props;
const allItemsCollapsed = field.get('collapsed', true);
const itemsCollapsed = value && Array(value.size).fill(allItemsCollapsed);
this.state = {
itemsCollapsed: List(itemsCollapsed),
value: valueToString(value),
};
this.valueType = null;
}
/**
* Always update so that each nested widget has the option to update. This is
* required because ControlHOC provides a default `shouldComponentUpdate`
* which only updates if the value changes, but every widget must be allowed
* to override this.
*/
shouldComponentUpdate() {
return true;
}
componentDidMount() {
const { field } = this.props;
if (field.get('fields')) {
this.valueType = valueTypes.MULTIPLE;
} else if (field.get('field')) {
this.valueType = valueTypes.SINGLE;
}
}
componentWillUpdate(nextProps) {
if (this.props.field === nextProps.field) return;
if (nextProps.field.get('fields')) {
this.valueType = valueTypes.MULTIPLE;
} else if (nextProps.field.get('field')) {
this.valueType = valueTypes.SINGLE;
}
}
handleChange = (e) => {
const { onChange } = this.props;
const oldValue = this.state.value;
const newValue = e.target.value;
const listValue = e.target.value.split(',');
if (newValue.match(/,$/) && oldValue.match(/, $/)) {
listValue.pop();
}
const parsedValue = valueToString(listValue);
this.setState({ value: parsedValue });
onChange(listValue.map(val => val.trim()));
};
handleFocus = () => {
this.props.setActiveStyle();
}
handleBlur = (e) => {
const listValue = e.target.value.split(',').map(el => el.trim()).filter(el => el);
this.setState({ value: valueToString(listValue) });
this.props.setInactiveStyle();
}
handleAdd = (e) => {
e.preventDefault();
const { value, onChange } = this.props;
const parsedValue = (this.valueType === valueTypes.SINGLE) ? null : Map();
this.setState({ itemsCollapsed: this.state.itemsCollapsed.push(false) });
onChange((value || List()).push(parsedValue));
}
/**
* In case the `onChangeObject` function is frozen by a child widget implementation,
* e.g. when debounced, always get the latest object value instead of using
* `this.props.value` directly.
*/
getObjectValue = idx => this.props.value.get(idx) || Map();
handleChangeFor(index) {
return (fieldName, newValue, newMetadata) => {
const { value, metadata, onChange, field } = this.props;
const collectionName = field.get('name');
const newObjectValue = this.getObjectValue(index).set(fieldName, newValue);
const parsedValue = (this.valueType === valueTypes.SINGLE) ? newObjectValue.first() : newObjectValue;
const parsedMetadata = {
[collectionName]: Object.assign(metadata ? metadata.toJS() : {}, newMetadata ? newMetadata[collectionName] : {}),
};
onChange(value.set(index, parsedValue), parsedMetadata);
};
}
handleRemove = (index, event) => {
event.preventDefault();
const { itemsCollapsed } = this.state;
const { value, metadata, onChange, field } = this.props;
const collectionName = field.get('name');
const parsedMetadata = metadata && { [collectionName]: metadata.removeIn(value.get(index).valueSeq()) };
this.setState({ itemsCollapsed: itemsCollapsed.delete(index) });
onChange(value.remove(index), parsedMetadata);
};
handleItemCollapseToggle = (index, event) => {
event.preventDefault();
const { itemsCollapsed } = this.state;
const collapsed = itemsCollapsed.get(index);
this.setState({ itemsCollapsed: itemsCollapsed.set(index, !collapsed) });
};
handleCollapseAllToggle = (e) => {
e.preventDefault();
const { value } = this.props;
const { itemsCollapsed } = this.state;
const allItemsCollapsed = itemsCollapsed.every(val => val === true);
this.setState({ itemsCollapsed: List(Array(value.size).fill(!allItemsCollapsed)) });
};
objectLabel(item) {
const { field } = this.props;
const multiFields = field.get('fields');
const singleField = field.get('field');
const labelField = (multiFields && multiFields.first()) || singleField;
const value = multiFields ? item.get(multiFields.first().get('name')) : singleField.get('label');
return (value || `No ${ labelField.get('name') }`).toString();
}
onSortEnd = ({ oldIndex, newIndex }) => {
const { value, onChange } = this.props;
const { itemsCollapsed } = this.state;
// Update value
const item = value.get(oldIndex);
const newValue = value.delete(oldIndex).insert(newIndex, item);
this.props.onChange(newValue);
// Update collapsing
const collapsed = itemsCollapsed.get(oldIndex);
const updatedItemsCollapsed = itemsCollapsed.delete(oldIndex).insert(newIndex, collapsed);
this.setState({ itemsCollapsed: updatedItemsCollapsed });
};
renderItem = (item, index) => {
const {
field,
getAsset,
mediaPaths,
onOpenMediaLibrary,
onAddAsset,
onRemoveInsertedMedia,
classNameWrapper,
} = this.props;
const { itemsCollapsed } = this.state;
const collapsed = itemsCollapsed.get(index);
const classNames = ['nc-listControl-item', collapsed ? 'nc-listControl-collapsed' : ''];
return (<SortableListItem className={classNames.join(' ')} index={index} key={`item-${ index }`}>
<ListItemTopBar
className="nc-listControl-itemTopBar"
collapsed={collapsed}
onCollapseToggle={partial(this.handleItemCollapseToggle, index)}
onRemove={partial(this.handleRemove, index)}
dragHandleHOC={SortableHandle}
/>
<div className="nc-listControl-objectLabel">{this.objectLabel(item)}</div>
<ObjectControl
value={item}
field={field}
onChangeObject={this.handleChangeFor(index)}
getAsset={getAsset}
onOpenMediaLibrary={onOpenMediaLibrary}
mediaPaths={mediaPaths}
onAddAsset={onAddAsset}
onRemoveInsertedMedia={onRemoveInsertedMedia}
classNameWrapper={`${ classNameWrapper } nc-listControl-objectControl`}
forList
/>
</SortableListItem>);
};
renderListControl() {
const { value, forID, field, classNameWrapper } = this.props;
const { itemsCollapsed } = this.state;
const items = value || List();
const label = field.get('label');
const labelSingular = field.get('label_singular') || field.get('label');
return (
<div id={forID} className={c(classNameWrapper, 'nc-listControl')}>
<TopBar
allowAdd={field.get('allow_add', true)}
onAdd={this.handleAdd}
listLabel={items.size === 1 ? labelSingular.toLowerCase() : label.toLowerCase()}
onCollapseAllToggle={this.handleCollapseAllToggle}
allItemsCollapsed={itemsCollapsed.every(val => val === true)}
itemsCount={items.size}
/>
<SortableList
items={items}
renderItem={this.renderItem}
onSortEnd={this.onSortEnd}
useDragHandle
lockAxis="y"
/>
</div>
);
}
render() {
const { field, forID, classNameWrapper } = this.props;
const { value } = this.state;
if (field.get('field') || field.get('fields')) {
return this.renderListControl();
}
return (<input
type="text"
id={forID}
value={value}
onChange={this.handleChange}
onFocus={this.handleFocus}
onBlur={this.handleBlur}
className={classNameWrapper}
/>);
}
};

View File

@ -0,0 +1,11 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ObjectPreview from 'EditorWidgets/Object/ObjectPreview';
const ListPreview = ObjectPreview;
ListPreview.propTypes = {
field: PropTypes.node,
};
export default ListPreview;

View File

@ -0,0 +1,4 @@
@import "./MarkdownControl/RawEditor/index.css";
@import "./MarkdownControl/Toolbar/Toolbar.css";
@import "./MarkdownControl/Toolbar/ToolbarButton.css";
@import "./MarkdownControl/VisualEditor/index.css";

View File

@ -0,0 +1,15 @@
.nc-rawEditor-rawWrapper {
position: relative;
}
.nc-rawEditor-rawEditor {
position: relative;
overflow: hidden;
overflow-x: auto;
min-height: var(--richTextEditorMinHeight);
font-family: var(--fontFamilyMono);
border-top-left-radius: 0;
border-top-right-radius: 0;
border-top: 0;
margin-top: calc(-1 * var(--stickyDistanceBottom));
}

View File

@ -0,0 +1,83 @@
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import React from 'react';
import { Editor as Slate } from 'slate-react';
import Plain from 'slate-plain-serializer';
import { debounce } from 'lodash';
import Toolbar from 'EditorWidgets/Markdown/MarkdownControl/Toolbar/Toolbar';
export default class RawEditor extends React.Component {
constructor(props) {
super(props);
this.state = {
value: Plain.deserialize(this.props.value || ''),
};
}
shouldComponentUpdate(nextProps, nextState) {
return !this.state.value.equals(nextState.value);
}
handleChange = change => {
if (!this.state.value.document.equals(change.value.document)) {
this.handleDocumentChange(change);
}
this.setState({ value: change.value });
};
/**
* When the document value changes, serialize from Slate's AST back to plain
* text (which is Markdown) and pass that up as the new value.
*/
handleDocumentChange = debounce(change => {
const value = Plain.serialize(change.value);
this.props.onChange(value);
}, 150);
/**
* If a paste contains plain text, deserialize it to Slate's AST and insert
* to the document. Selection logic (where to insert, whether to replace) is
* handled by Slate.
*/
handlePaste = (e, data, change) => {
if (data.text) {
const fragment = Plain.deserialize(data.text).document;
return change.insertFragment(fragment);
}
};
handleToggleMode = () => {
this.props.onMode('visual');
};
render() {
const { className, field } = this.props;
return (
<div className="nc-rawEditor-rawWrapper">
<div className="nc-visualEditor-editorControlBar">
<Toolbar
onToggleMode={this.handleToggleMode}
buttons={field.get('buttons')}
className="nc-markdownWidget-toolbarRaw"
disabled
rawMode
/>
</div>
<Slate
className={`${className} nc-rawEditor-rawEditor`}
value={this.state.value}
onChange={this.handleChange}
onPaste={this.handlePaste}
/>
</div>
);
}
}
RawEditor.propTypes = {
onChange: PropTypes.func.isRequired,
onMode: PropTypes.func.isRequired,
className: PropTypes.string.isRequired,
value: PropTypes.string,
field: ImmutablePropTypes.map
};

View File

@ -0,0 +1,48 @@
.nc-toolbar-Toolbar {
background-color: var(--textFieldBorderColor);
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
padding: 11px 14px;
min-height: 58px;
transition: background-color var(--transition), color var(--transition);
}
.nc-markdownWidget-toolbar-toggle {
flex-shrink: 0;
display: flex;
align-items: center;
font-size: 14px;
margin: 0 10px;
}
.nc-markdownWidget-toolbar-toggle-label {
display: inline-block;
text-align: center;
white-space: nowrap;
line-height: 20px;
}
.nc-markdownWidget-toolbar-toggle-label-active {
font-weight: 600;
color: #3a69c7;
}
.nc-toolbar-ToolbarActive {
background-color: var(--colorActive);
color: var(--colorTextLight);
& .nc-markdownWidget-toolbar-toggle-label {
color: var(--colorTextLight);
}
& .nc-markdownWidget-toolbar-toggle-background {
background-color: var(--textFieldBorderColor);
}
}
.nc-toolbar-dropdown {
display: inline-block;
position: relative;
}

View File

@ -0,0 +1,205 @@
import PropTypes from 'prop-types';
import React from 'react';
import { List } from 'immutable';
import ImmutablePropTypes from 'react-immutable-proptypes';
import c from 'classnames';
import { Dropdown, DropdownItem, Toggle, Icon } from 'netlify-cms-ui-default';
import ToolbarButton from './ToolbarButton';
export default class Toolbar extends React.Component {
static propTypes = {
buttons: PropTypes.object,
onToggleMode: PropTypes.func.isRequired,
rawMode: PropTypes.bool,
plugins: ImmutablePropTypes.map,
onSubmit: PropTypes.func,
onAddAsset: PropTypes.func,
getAsset: PropTypes.func,
disabled: PropTypes.bool,
className: PropTypes.string,
buttons: ImmutablePropTypes.list
};
constructor(props) {
super(props);
this.state = {
activePlugin: null,
};
}
isHidden = button => {
const { buttons } = this.props;
return List.isList(buttons) ? !buttons.includes(button) : false;
}
render() {
const {
onMarkClick,
onBlockClick,
onLinkClick,
selectionHasMark,
selectionHasBlock,
selectionHasLink,
onToggleMode,
rawMode,
plugins,
onAddAsset,
getAsset,
disabled,
onSubmit,
className,
} = this.props;
const { activePlugin } = this.state;
/**
* Because the toggle labels change font weight for active/inactive state,
* we need to set estimated widths for them to maintain position without
* moving other inline items on font weight change.
*/
const toggleOffLabel = 'Rich text';
const toggleOffLabelWidth = '62px';
const toggleOnLabel = 'Markdown';
const toggleOnLabelWidth = '70px';
return (
<div className={c(className, 'nc-toolbar-Toolbar')}>
<div>
<ToolbarButton
type="bold"
label="Bold"
icon="bold"
onClick={onMarkClick}
isActive={selectionHasMark}
isHidden={this.isHidden('bold')}
disabled={disabled}
/>
<ToolbarButton
type="italic"
label="Italic"
icon="italic"
onClick={onMarkClick}
isActive={selectionHasMark}
isHidden={this.isHidden('italic')}
disabled={disabled}
/>
<ToolbarButton
type="code"
label="Code"
icon="code"
onClick={onMarkClick}
isActive={selectionHasMark}
isHidden={this.isHidden('code')}
disabled={disabled}
/>
<ToolbarButton
type="link"
label="Link"
icon="link"
onClick={onLinkClick}
isActive={selectionHasLink}
isHidden={this.isHidden('link')}
disabled={disabled}
/>
<ToolbarButton
type="heading-one"
label="Header 1"
icon="h1"
onClick={onBlockClick}
isActive={selectionHasBlock}
isHidden={this.isHidden('heading-one')}
disabled={disabled}
/>
<ToolbarButton
type="heading-two"
label="Header 2"
icon="h2"
onClick={onBlockClick}
isActive={selectionHasBlock}
isHidden={this.isHidden('heading-two')}
disabled={disabled}
/>
<ToolbarButton
type="quote"
label="Quote"
icon="quote"
onClick={onBlockClick}
isActive={selectionHasBlock}
isHidden={this.isHidden('quote')}
disabled={disabled}
/>
<ToolbarButton
type="code"
label="Code Block"
icon="code-block"
onClick={onBlockClick}
isActive={selectionHasBlock}
isHidden={this.isHidden('code-block')}
disabled={disabled}
/>
<ToolbarButton
type="bulleted-list"
label="Bulleted List"
icon="list-bulleted"
onClick={onBlockClick}
isActive={selectionHasBlock}
isHidden={this.isHidden('bulleted-list')}
disabled={disabled}
/>
<ToolbarButton
type="numbered-list"
label="Numbered List"
icon="list-numbered"
onClick={onBlockClick}
isActive={selectionHasBlock}
isHidden={this.isHidden('numbered-list')}
disabled={disabled}
/>
<div className="nc-toolbar-dropdown">
<Dropdown
dropdownTopOverlap="36px"
button={
<ToolbarButton
label="Add Component"
icon="add-with"
onClick={this.handleComponentsMenuToggle}
disabled={disabled}
/>
}
>
{plugins && plugins.toList().map((plugin, idx) => (
<DropdownItem key={idx} label={plugin.get('label')} onClick={() => onSubmit(plugin.get('id'))} />
))}
</Dropdown>
</div>
</div>
<div className="nc-markdownWidget-toolbar-toggle">
<span
style={{ width: toggleOffLabelWidth }}
className={c(
'nc-markdownWidget-toolbar-toggle-label',
{ 'nc-markdownWidget-toolbar-toggle-label-active': !rawMode },
)}
>
{toggleOffLabel}
</span>
<Toggle
active={rawMode}
onChange={onToggleMode}
className="nc-markdownWidget-toolbar-toggle"
classNameBackground="nc-markdownWidget-toolbar-toggle-background"
/>
<span
style={{ width: toggleOnLabelWidth }}
className={c(
'nc-markdownWidget-toolbar-toggle-label',
{ 'nc-markdownWidget-toolbar-toggle-label-active': rawMode },
)}
>
{toggleOnLabel}
</span>
</div>
</div>
);
}
}

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