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

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

View File

@ -0,0 +1,22 @@
.nc-toolbarButton-button {
display: inline-block;
padding: 6px;
border: none;
background-color: transparent;
font-size: 16px;
color: inherit;
cursor: pointer;
&:disabled {
cursor: auto;
opacity: 0.5;
}
& .nc-icon {
display: block;
}
}
.nc-toolbarButton-active {
color: #1e2532;
}

View File

@ -0,0 +1,34 @@
import PropTypes from 'prop-types';
import React from 'react';
import c from 'classnames';
import { Icon } from 'netlify-cms-ui-default';
const ToolbarButton = ({ type, label, icon, onClick, isActive, isHidden, disabled }) => {
const active = isActive && type && isActive(type);
if (isHidden) {
return null;
}
return (
<button
className={c('nc-toolbarButton-button', { ['nc-toolbarButton-active']: active })}
onClick={e => onClick && onClick(e, type)}
title={label}
disabled={disabled}
>
{ icon ? <Icon type={icon}/> : label }
</button>
);
};
ToolbarButton.propTypes = {
type: PropTypes.string,
label: PropTypes.string.isRequired,
icon: PropTypes.string,
onClick: PropTypes.func,
isActive: PropTypes.func,
disabled: PropTypes.bool,
};
export default ToolbarButton;

View File

@ -0,0 +1,22 @@
.nc-visualEditor-shortcode {
border-radius: var(--borderRadius);
border: 2px solid var(--textFieldBorderColor);
margin: 12px 0;
padding: 14px;
}
.nc-visualEditor-shortcode-topBar {
background-color: var(--textFieldBorderColor);
margin: calc(-1 * var(--widgetNestDistance)) calc(-1 * var(--widgetNestDistance)) 0;
border-radius: 0;
}
.nc-visualEditor-shortcode-collapsed {
background-color: var(--textFieldBorderColor);
cursor: pointer;
}
.nc-visualEditor-shortcode-collapsedTitle {
padding: 8px;
color: var(--controlLabelColor);
}

View File

@ -0,0 +1,137 @@
import React from 'react';
import c from 'classnames';
import { Map } from 'immutable';
import { connect } from 'react-redux';
import { partial, capitalize } from 'lodash';
import { resolveWidget, getEditorComponents } from 'Lib/registry';
import { openMediaLibrary, removeInsertedMedia } from 'Actions/mediaLibrary';
import { addAsset } from 'Actions/media';
import { getAsset } from 'Reducers';
import { ListItemTopBar } from 'netlify-cms-ui-default';
import { getEditorControl } from '../index';
class Shortcode extends React.Component {
constructor(props) {
super(props);
this.state = {
/**
* The `shortcodeNew` prop is set to `true` when creating a new Shortcode,
* so that the form is immediately open for editing. Otherwise all
* shortcodes are collapsed by default.
*/
collapsed: !props.node.data.get('shortcodeNew'),
};
}
handleChange = (fieldName, value) => {
const { editor, node } = this.props;
const shortcodeData = Map(node.data.get('shortcodeData')).set(fieldName, value);
const data = node.data.set('shortcodeData', shortcodeData);
editor.change(c => c.setNodeByKey(node.key, { data }));
};
handleCollapseToggle = () => {
this.setState({ collapsed: !this.state.collapsed });
}
handleRemove = () => {
const { editor, node } = this.props;
editor.change(change => {
change
.removeNodeByKey(node.key)
.focus();
});
}
handleClick = event => {
/**
* Stop click from propagating to editor, otherwise focus will be passed
* to the editor.
*/
event.stopPropagation();
/**
* If collapsed, any click should open the form.
*/
if (this.state.collapsed) {
this.handleCollapseToggle();
}
}
renderControl = (shortcodeData, field, index) => {
const {
onAddAsset,
boundGetAsset,
mediaPaths,
onOpenMediaLibrary,
onRemoveInsertedMedia,
} = this.props;
if (field.get('widget') === 'hidden') return null;
const value = shortcodeData.get(field.get('name'));
const key = `field-${ field.get('name') }`;
const Control = getEditorControl();
const controlProps = {
field,
value,
onAddAsset,
getAsset: boundGetAsset,
onChange: this.handleChange,
mediaPaths,
onOpenMediaLibrary,
onRemoveInsertedMedia,
};
return (
<div key={key}>
<Control {...controlProps}/>
</div>
);
};
render() {
const { attributes, node, editor } = this.props;
const { collapsed } = this.state;
const pluginId = node.data.get('shortcode');
const shortcodeData = Map(this.props.node.data.get('shortcodeData'));
const plugin = getEditorComponents().get(pluginId);
const className = c(
'nc-objectControl-root',
'nc-visualEditor-shortcode',
{ 'nc-visualEditor-shortcode-collapsed': collapsed },
);
return (
<div {...attributes} className={className} onClick={this.handleClick}>
<ListItemTopBar
className="nc-visualEditor-shortcode-topBar"
collapsed={collapsed}
onCollapseToggle={this.handleCollapseToggle}
onRemove={this.handleRemove}
/>
{
collapsed
? <div className="nc-visualEditor-shortcode-collapsedTitle">{capitalize(pluginId)}</div>
: plugin.get('fields').map(partial(this.renderControl, shortcodeData))
}
</div>
);
}
}
const mapStateToProps = (state, ownProps) => {
const { attributes, node, editor } = ownProps;
return {
mediaPaths: state.mediaLibrary.get('controlMedia'),
boundGetAsset: getAsset.bind(null, state),
attributes,
node,
editor,
};
};
const mapDispatchToProps = {
onAddAsset: addAsset,
onOpenMediaLibrary: openMediaLibrary,
onRemoveInsertedMedia: removeInsertedMedia,
};
export default connect(mapStateToProps, mapDispatchToProps)(Shortcode);

View File

@ -0,0 +1,269 @@
import React from 'react';
import { fromJS } from 'immutable';
import { markdownToSlate } from 'EditorWidgets/Markdown/serializers';
const parser = markdownToSlate;
// Temporary plugins test
const testPlugins = fromJS([
{
label: 'Image',
id: 'image',
fromBlock: match => match && {
image: match[2],
alt: match[1],
},
toBlock: data => `![${ data.alt }](${ data.image })`,
toPreview: data => <img src={data.image} alt={data.alt} />,
pattern: /^!\[([^\]]+)]\(([^)]+)\)$/,
fields: [{
label: 'Image',
name: 'image',
widget: 'image',
}, {
label: 'Alt Text',
name: 'alt',
}],
},
{
id: "youtube",
label: "Youtube",
fields: [{name: 'id', label: 'Youtube Video ID'}],
pattern: /^{{<\s?youtube (\S+)\s?>}}/,
fromBlock: function(match) {
return {
id: match[1]
};
},
toBlock: function(obj) {
return '{{< youtube ' + obj.id + ' >}}';
},
toPreview: function(obj) {
return (
'<img src="http://img.youtube.com/vi/' + obj.id + '/maxresdefault.jpg" alt="Youtube Video"/>'
);
}
},
]);
describe("Compile markdown to Slate Raw AST", () => {
it("should compile simple markdown", () => {
const value = `
# H1
sweet body
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile a markdown ordered list", () => {
const value = `
# H1
1. yo
2. bro
3. fro
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile bulleted lists", () => {
const value = `
# H1
* yo
* bro
* fro
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile multiple header levels", () => {
const value = `
# H1
## H2
### H3
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile horizontal rules", () => {
const value = `
# H1
---
blue moon
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile horizontal rules", () => {
const value = `
# H1
---
blue moon
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile soft breaks (double space)", () => {
const value = `
blue moon
footballs
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile images", () => {
const value = `
![super](duper.jpg)
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile code blocks", () => {
const value = `
\`\`\`javascript
var a = 1;
\`\`\`
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile nested inline markup", () => {
const value = `
# Word
This is **some *hot* content**
perhaps **scalding** even
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile inline code", () => {
const value = `
# Word
This is some sweet \`inline code\` yo!
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile links", () => {
const value = `
# Word
How far is it to [Google](https://google.com) land?
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile plugins", () => {
const value = `
![test](test.png)
{{< test >}}
`;
expect(parser(value)).toMatchSnapshot();
});
it("should compile kitchen sink example", () => {
const value = `
# An exhibit of Markdown
This note demonstrates some of what Markdown is capable of doing.
*Note: Feel free to play with this page. Unlike regular notes, this doesn't
automatically save itself.*
## Basic formatting
Paragraphs can be written like so. A paragraph is the basic block of Markdown.
A paragraph is what text will turn into when there is no reason it should
become anything else.
Paragraphs must be separated by a blank line. Basic formatting of *italics* and
**bold** is supported. This *can be **nested** like* so.
## Lists
### Ordered list
1. Item 1 2. A second item 3. Number 3 4. Ⅳ
*Note: the fourth item uses the Unicode character for Roman numeral four.*
### Unordered list
* An item Another item Yet another item And there's more...
## Paragraph modifiers
### Code block
Code blocks are very useful for developers and other people who look at
code or other things that are written in plain text. As you can see, it
uses a fixed-width font.
You can also make \`inline code\` to add code into other things.
### Quote
> Here is a quote. What this is should be self explanatory. Quotes are
automatically indented when they are used.
## Headings
There are six levels of headings. They correspond with the six levels of HTML
headings. You've probably noticed them already in the page. Each level down
uses one more hash character.
### Headings *can* also contain **formatting**
### They can even contain \`inline code\`
Of course, demonstrating what headings look like messes up the structure of the
page.
I don't recommend using more than three or four levels of headings here,
because, when you're smallest heading isn't too small, and you're largest
heading isn't too big, and you want each size up to look noticeably larger and
more important, there there are only so many sizes that you can use.
## URLs
URLs can be made in a handful of ways:
* A named link to MarkItDown. The easiest way to do these is to select what you
* want to make a link and hit \`Ctrl+L\`. Another named link to
* [MarkItDown](http://www.markitdown.net/) Sometimes you just want a URL like
* <http://www.markitdown.net/>.
## Horizontal rule
A horizontal rule is a line that goes across the middle of the page.
---
It's sometimes handy for breaking things up.
## Images
Markdown can also contain images. I'll need to add something here sometime.
## Finally
There's actually a lot more to Markdown than this. See the official
introduction and syntax for more information. However, be aware that this is
not using the official implementation, and this might work subtly differently
in some of the little things.
`;
expect(parser(value)).toMatchSnapshot();
});
});

View File

@ -0,0 +1,130 @@
@import './Shortcode.css';
:root {
--stickyDistanceBottom: 100px;
}
.nc-visualEditor-editorControlBar {
z-index: 1;
position: sticky;
top: 0;
margin-bottom: var(--stickyDistanceBottom);
}
.nc-visualEditor-wrapper {
position: relative;
}
.nc-visualEditor-editor {
position: relative;
overflow: hidden;
overflow-x: auto;
min-height: var(--richTextEditorMinHeight);
font-family: var(--fontFamilyPrimary);
border-top-left-radius: 0;
border-top-right-radius: 0;
border-top: 0;
margin-top: calc(-1 * var(--stickyDistanceBottom));
}
.nc-visualEditor-editor h1 {
font-size: 32px;
margin-top: 16px;
}
.nc-visualEditor-editor h2 {
font-size: 24px;
margin-top: 12px;
}
.nc-visualEditor-editor h3 {
font-size: 20px;
margin-top: 8px;
}
.nc-visualEditor-editor h4 {
font-size: 18px;
margin-top: 8px;
}
.nc-visualEditor-editor h5,
.nc-visualEditor-editor h6 {
font-size: 16px;
margin-top: 8px;
}
.nc-visualEditor-editor h1,
.nc-visualEditor-editor h2,
.nc-visualEditor-editor h3,
.nc-visualEditor-editor h4,
.nc-visualEditor-editor h5,
.nc-visualEditor-editor h6 {
font-weight: 700;
line-height: 1;
}
.nc-visualEditor-editor p,
.nc-visualEditor-editor pre,
.nc-visualEditor-editor blockquote,
.nc-visualEditor-editor ul,
.nc-visualEditor-editor ol {
margin-top: 16px;
margin-bottom: 16px;
}
.nc-visualEditor-editor a {
text-decoration: underline;
}
.nc-visualEditor-editor hr {
border: 1px solid;
margin-bottom: 16px;
}
.nc-visualEditor-editor li > p {
margin: 0;
}
.nc-visualEditor-editor ul,
.nc-visualEditor-editor ol {
padding-left: 30px;
}
.nc-visualEditor-editor pre {
white-space: pre-wrap;
}
.nc-visualEditor-editor pre > code {
display: block;
width: 100%;
overflow-y: auto;
background-color: #000;
color: #ccc;
border-radius: var(--borderRadius);
padding: 10px;
}
.nc-visualEditor-editor code {
background-color: var(--colorBackground);
border-radius: var(--borderRadius);
padding: 0 2px;
font-size: 85%;
}
.nc-visualEditor-editor blockquote {
padding-left: 16px;
border-left: 3px solid var(--colorBackground);
margin-left: 0;
margin-right: 0;
}
.nc-visualEditor-editor table {
border-collapse: collapse;
}
.nc-visualEditor-editor td,
.nc-visualEditor-editor th {
border: 2px solid black;
padding: 8px;
text-align: left;
}

View File

@ -0,0 +1,230 @@
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import React, { Component } from 'react';
import { get, isEmpty, debounce } from 'lodash';
import { Map } from 'immutable';
import { Value, Document, Block, Text } from 'slate';
import { Editor as Slate } from 'slate-react';
import { slateToMarkdown, markdownToSlate, htmlToSlate } from 'EditorWidgets/Markdown/serializers';
import { getEditorComponents } from 'Lib/registry';
import Toolbar from 'EditorWidgets/Markdown/MarkdownControl/Toolbar/Toolbar';
import { renderNode, renderMark } from './renderers';
import { validateNode } from './validators';
import plugins, { EditListConfigured } from './plugins';
import onKeyDown from './keys';
const createEmptyRawDoc = () => {
const emptyText = Text.create('');
const emptyBlock = Block.create({ kind: 'block', type: 'paragraph', nodes: [ emptyText ] });
return { nodes: [emptyBlock] };
};
const createSlateValue = (rawValue) => {
const rawDoc = rawValue && markdownToSlate(rawValue);
const rawDocHasNodes = !isEmpty(get(rawDoc, 'nodes'))
const document = Document.fromJSON(rawDocHasNodes ? rawDoc : createEmptyRawDoc());
return Value.create({ document });
}
export default class Editor extends Component {
static propTypes = {
onAddAsset: PropTypes.func.isRequired,
getAsset: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onMode: PropTypes.func.isRequired,
className: PropTypes.string.isRequired,
value: PropTypes.string,
field: ImmutablePropTypes.map
};
constructor(props) {
super(props);
this.state = {
value: createSlateValue(props.value),
shortcodePlugins: getEditorComponents(),
};
}
shouldComponentUpdate(nextProps, nextState) {
return !this.state.value.equals(nextState.value);
}
handlePaste = (e, data, change) => {
if (data.type !== 'html' || data.isShift) {
return;
}
const ast = htmlToSlate(data.html);
const doc = Document.fromJSON(ast);
return change.insertFragment(doc);
}
selectionHasMark = type => this.state.value.activeMarks.some(mark => mark.type === type);
selectionHasBlock = type => this.state.value.blocks.some(node => node.type === type);
handleMarkClick = (event, type) => {
event.preventDefault();
const resolvedChange = this.state.value.change().focus().toggleMark(type);
this.ref.onChange(resolvedChange);
this.setState({ value: resolvedChange.value });
};
handleBlockClick = (event, type) => {
event.preventDefault();
let { value } = this.state;
const { document: doc, selection } = value;
const { unwrapList, wrapInList } = EditListConfigured.changes;
let change = value.change();
// Handle everything except list buttons.
if (!['bulleted-list', 'numbered-list'].includes(type)) {
const isActive = this.selectionHasBlock(type);
change = change.setBlock(isActive ? 'paragraph' : type);
}
// Handle the extra wrapping required for list buttons.
else {
const isSameListType = value.blocks.some(block => {
return !!doc.getClosest(block.key, parent => parent.type === type);
});
const isInList = EditListConfigured.utils.isSelectionInList(value);
if (isInList && isSameListType) {
change = change.call(unwrapList, type);
} else if (isInList) {
const currentListType = type === 'bulleted-list' ? 'numbered-list' : 'bulleted-list';
change = change.call(unwrapList, currentListType).call(wrapInList, type);
} else {
change = change.call(wrapInList, type);
}
}
const resolvedChange = change.focus();
this.ref.onChange(resolvedChange);
this.setState({ value: resolvedChange.value });
};
hasLinks = () => {
return this.state.value.inlines.some(inline => inline.type === 'link');
};
handleLink = () => {
let change = this.state.value.change();
// If the current selection contains links, clicking the "link" button
// should simply unlink them.
if (this.hasLinks()) {
change = change.unwrapInline('link');
}
else {
const url = window.prompt('Enter the URL of the link');
// If nothing is entered in the URL prompt, do nothing.
if (!url) return;
// If no text is selected, use the entered URL as text.
if (change.value.isCollapsed) {
change = change
.insertText(url)
.extend(0 - url.length);
}
change = change
.wrapInline({ type: 'link', data: { url } })
.collapseToEnd();
}
this.ref.onChange(change);
this.setState({ value: change.value });
};
handlePluginAdd = pluginId => {
const { value } = this.state;
const nodes = [Text.create('')];
const block = {
kind: 'block',
type: 'shortcode',
data: {
shortcode: pluginId,
shortcodeNew: true,
shortcodeData: Map(),
},
isVoid: true,
nodes
};
let change = value.change();
const { focusBlock } = change.value;
if (focusBlock.text === '') {
change = change.setNodeByKey(focusBlock.key, block);
} else {
change = change.insertBlock(block);
}
change = change.focus();
this.ref.onChange(change);
this.setState({ value: change.value });
};
handleToggle = () => {
this.props.onMode('raw');
};
handleDocumentChange = debounce(change => {
const raw = change.value.document.toJSON();
const plugins = this.state.shortcodePlugins;
const markdown = slateToMarkdown(raw, plugins);
this.props.onChange(markdown);
}, 150);
handleChange = change => {
if (!this.state.value.document.equals(change.value.document)) {
this.handleDocumentChange(change);
}
this.setState({ value: change.value });
};
processRef = ref => {
this.ref = ref;
}
render() {
const { onAddAsset, getAsset, className, field } = this.props;
return (
<div className="nc-visualEditor-wrapper">
<div className="nc-visualEditor-editorControlBar">
<Toolbar
onMarkClick={this.handleMarkClick}
onBlockClick={this.handleBlockClick}
onLinkClick={this.handleLink}
selectionHasMark={this.selectionHasMark}
selectionHasBlock={this.selectionHasBlock}
selectionHasLink={this.hasLinks}
onToggleMode={this.handleToggle}
plugins={this.state.shortcodePlugins}
onSubmit={this.handlePluginAdd}
onAddAsset={onAddAsset}
getAsset={getAsset}
buttons={field.get('buttons')}
/>
</div>
<Slate
className={`${className} nc-visualEditor-editor`}
value={this.state.value}
renderNode={renderNode}
renderMark={renderMark}
validateNode={validateNode}
plugins={plugins}
onChange={this.handleChange}
onKeyDown={onKeyDown}
onPaste={this.handlePaste}
ref={this.processRef}
spellCheck
/>
</div>
);
}
}

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