diff --git a/.babelrc b/.babelrc index 6ec82922c801..825ea6b87287 100644 --- a/.babelrc +++ b/.babelrc @@ -7,7 +7,6 @@ "react" ], "plugins": [ - "react-hot-loader/babel", "lodash", ["babel-plugin-transform-builtin-extend", { "globals": ["Error"] diff --git a/.storybook/config.js b/.storybook/config.js deleted file mode 100644 index 1977c69a672e..000000000000 --- a/.storybook/config.js +++ /dev/null @@ -1,8 +0,0 @@ -import { configure } from '@kadira/storybook'; -import '../src/index.css'; - -function loadStories() { - require('../src/components/stories/'); -} - -configure(loadStories, module); diff --git a/.storybook/webpack.config.js b/.storybook/webpack.config.js deleted file mode 100644 index e62e19d4dd04..000000000000 --- a/.storybook/webpack.config.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = require('../webpack.base.js'); diff --git a/example/config.yml b/example/config.yml index c1866961b0f7..3a2a746fca0d 100644 --- a/example/config.yml +++ b/example/config.yml @@ -1,11 +1,15 @@ backend: name: test-repo +display_url: https://example.com media_folder: "assets/uploads" collections: # A list of collections the CMS should be able to edit - name: "posts" # Used in routes, ie.: /admin/collections/:slug/edit label: "Post" # Used in the UI, ie.: "New Post" + description: > + The description is a great place for tone setting, high level information, and editing + guidelines that are specific to a collection. folder: "_posts" slug: "{{year}}-{{month}}-{{day}}-{{slug}}" create: true # Allow users to create new documents in this collection diff --git a/example/moby-dick.jpg b/example/moby-dick.jpg index cbbbffbd753c..0eb9c8b59581 100644 Binary files a/example/moby-dick.jpg and b/example/moby-dick.jpg differ diff --git a/package.json b/package.json index 84abe699b1c9..d2956cdbc653 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,6 @@ "build:scripts": "cross-env NODE_ENV=production webpack --config webpack.cli.js", "add-contributor": "all-contributors add", "generate-contributors": "all-contributors generate", - "storybook": "start-storybook -p 9001", - "storybook-build": "build-storybook -o dist", "lint": "npm run lint:js & npm run lint:css", "lint:js": "eslint .", "lint:js:fix": "npm run lint:js -- --fix", @@ -73,7 +71,6 @@ "last 2 ChromeAndroid versions" ], "devDependencies": { - "@kadira/storybook": "^1.36.0", "all-contributors-cli": "^4.4.0", "babel": "^6.5.2", "babel-cli": "^6.18.0", @@ -87,7 +84,6 @@ "babel-preset-react": "^6.23.0", "babel-preset-stage-1": "^6.22.0", "babel-runtime": "^6.23.0", - "caniuse-lite": "^1.0.30000745", "cross-env": "^5.0.2", "css-loader": "^0.28.7", "cssnano": "^v4.0.0-rc.2", @@ -110,7 +106,6 @@ "postcss-import": "^11.0.0", "postcss-loader": "^2.0.7", "raf": "^3.4.0", - "react-hot-loader": "^3.0.0-beta.7", "react-test-renderer": "^16.0.0", "style-loader": "^0.18.2", "stylefmt": "^4.3.1", @@ -119,8 +114,8 @@ "stylelint-config-standard": "^13.0.2", "stylelint-declaration-block-order": "^0.1.0", "stylelint-declaration-use-variable": "^1.6.0", + "svg-inline-loader": "^0.8.0", "uglifyjs-webpack-plugin": "^1.0.1", - "url-loader": "^0.5.9", "webpack": "^3.6.0", "webpack-dev-server": "^2.9.1", "webpack-merge": "^4.1.0", @@ -129,7 +124,6 @@ "dependencies": { "classnames": "^2.2.5", "create-react-class": "^15.6.0", - "focus-trap-react": "^3.0.3", "fuzzy": "^0.1.1", "gotrue-js": "^0.9.15", "gray-matter": "^3.0.6", @@ -140,27 +134,28 @@ "jwt-decode": "^2.1.0", "localforage": "^1.4.2", "lodash": "^4.13.1", - "material-design-icons": "^3.0.1", "mdast-util-definitions": "^1.2.2", "mdast-util-to-string": "^1.0.4", "moment": "^2.11.2", - "normalize.css": "^4.2.0", "prop-types": "^15.5.10", "react": "^16.0.0", + "react-aria-menubutton": "^5.1.0", "react-autosuggest": "^9.3.2", - "react-datetime": "^2.6.0", + "react-datetime": "^2.11.0", "react-dnd": "^2.5.4", "react-dnd-html5-backend": "^2.5.4", "react-dom": "^16.0.0", "react-frame-component": "^2.0.0", "react-immutable-proptypes": "^2.1.0", + "react-modal": "^3.1.5", "react-redux": "^4.4.0", "react-router-dom": "^4.2.2", "react-router-redux": "^5.0.0-alpha.8", - "react-sidebar": "^2.2.1", + "react-scroll-sync": "^0.4.0", "react-sortable-hoc": "^0.6.8", "react-split-pane": "^0.1.66", - "react-toolbox": "^2.0.0-beta.12", + "react-textarea-autosize": "^5.2.0", + "react-toggled": "^1.1.2", "react-topbar-progress-indicator": "^2.0.0", "react-transition-group": "^2.2.1", "react-waypoint": "^7.1.0", @@ -189,7 +184,8 @@ "unist-builder": "^1.0.2", "unist-util-visit-parents": "^1.1.1", "url": "^0.11.0", - "uuid": "^3.1.0" + "uuid": "^3.1.0", + "what-input": "^5.0.3" }, "optionalDependencies": { "fsevents": "^1.0.14" diff --git a/postcss.config.js b/postcss.config.js index 377a7b27b61a..4668f798aa7c 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -3,17 +3,7 @@ const webpack = require('webpack'); module.exports = { plugins: [ require('postcss-import')({ addDependencyTo: webpack }), - require('postcss-cssnext')({ - features: { - customProperties: { - variables: { - "preferred-font": 'inherit', // Override react-toolbox font setting - }, - }, - }, - }), - require('cssnano')({ - preset: 'default', - }), + require('postcss-cssnext')(), + require('cssnano')({ preset: 'default' }), ], }; diff --git a/src/actions/auth.js b/src/actions/auth.js index a0e3ee840403..8b19aa9ba4bf 100644 --- a/src/actions/auth.js +++ b/src/actions/auth.js @@ -1,5 +1,5 @@ -import { currentBackend } from '../backends/backend'; import { actions as notifActions } from 'redux-notifications'; +import { currentBackend } from 'Backends/backend'; const { notifSend } = notifActions; diff --git a/src/actions/collections.js b/src/actions/collections.js new file mode 100644 index 000000000000..70cf1b3c161f --- /dev/null +++ b/src/actions/collections.js @@ -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)); +} diff --git a/src/actions/config.js b/src/actions/config.js index 5f1c3f2dcf63..3dd7bde851d0 100644 --- a/src/actions/config.js +++ b/src/actions/config.js @@ -1,7 +1,7 @@ import yaml from "js-yaml"; import { set, defaultsDeep, get } from "lodash"; -import { authenticateUser } from "../actions/auth"; -import * as publishModes from "../constants/publishModes"; +import { authenticateUser } from "Actions/auth"; +import * as publishModes from "Constants/publishModes"; export const CONFIG_REQUEST = "CONFIG_REQUEST"; export const CONFIG_SUCCESS = "CONFIG_SUCCESS"; diff --git a/src/actions/editor.js b/src/actions/editor.js deleted file mode 100644 index c7a98c5f2874..000000000000 --- a/src/actions/editor.js +++ /dev/null @@ -1,22 +0,0 @@ -import history from '../routing/history'; - -export const SWITCH_VISUAL_MODE = 'SWITCH_VISUAL_MODE'; -export const CLOSED_ENTRY = 'CLOSED_ENTRY'; - -export function switchVisualMode(useVisualMode) { - return { - type: SWITCH_VISUAL_MODE, - payload: useVisualMode, - }; -} - -export function closeEntry(collection) { - return (dispatch) => { - if (collection && collection.get('name', false)) { - history.push(`collections/${ collection.get('name') }`); - } else { - history.goBack(); - } - dispatch({ type: CLOSED_ENTRY }); - }; -} diff --git a/src/actions/editorialWorkflow.js b/src/actions/editorialWorkflow.js index 9255cf1408bc..92b9f57c7f33 100644 --- a/src/actions/editorialWorkflow.js +++ b/src/actions/editorialWorkflow.js @@ -1,14 +1,13 @@ import uuid from 'uuid/v4'; import { actions as notifActions } from 'redux-notifications'; -import { serializeValues } from '../lib/serializeEntryValues'; -import { closeEntry } from './editor'; import { BEGIN, COMMIT, REVERT } from 'redux-optimist'; -import { currentBackend } from '../backends/backend'; -import { getAsset } from '../reducers'; -import { selectFields } from '../reducers/collections'; +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 { status, EDITORIAL_WORKFLOW } from '../constants/publishModes'; -import { EditorialWorkflowError } from "../valueObjects/errors"; const { notifSend } = notifActions; @@ -35,6 +34,10 @@ export const UNPUBLISHED_ENTRY_PUBLISH_REQUEST = 'UNPUBLISHED_ENTRY_PUBLISH_REQU 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) */ @@ -105,12 +108,13 @@ function unpublishedEntryPersisting(collection, entry, transactionID) { }; } -function unpublishedEntryPersisted(collection, entry, 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 }, }; @@ -183,6 +187,30 @@ function unpublishedEntryPublishError(collection, slug, 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 */ @@ -223,7 +251,7 @@ export function loadUnpublishedEntries(collections) { } export function persistUnpublishedEntry(collection, existingUnpublishedEntry) { - return (dispatch, getState) => { + return async (dispatch, getState) => { const state = getState(); const entryDraft = state.entryDraft; @@ -246,23 +274,32 @@ export function persistUnpublishedEntry(collection, existingUnpublishedEntry) { dispatch(unpublishedEntryPersisting(collection, serializedEntry, transactionID)); const persistAction = existingUnpublishedEntry ? backend.persistUnpublishedEntry : backend.persistEntry; - return persistAction.call(backend, state.config, collection, serializedEntryDraft, assetProxies.toJS(), state.integrations) - .then(() => { + 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, })); - return dispatch(unpublishedEntryPersisted(collection, serializedEntry, transactionID)); - }) - .catch((error) => { + 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))); - }); + } }; } @@ -274,9 +311,19 @@ export function updateUnpublishedEntryStatus(collection, slug, oldStatus, newSta 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(() => { + dispatch(notifSend({ + message: `Failed to update status: ${ error }`, + kind: 'danger', + dismissAfter: 8000, + })); dispatch(unpublishedEntryStatusChangeError(collection, slug, transactionID)); }); }; @@ -287,18 +334,23 @@ export function deleteUnpublishedEntry(collection, slug) { const state = getState(); const backend = currentBackend(state.config); const transactionID = uuid(); - dispatch(unpublishedEntryPublishRequest(collection, slug, transactionID)); - backend.deleteUnpublishedEntry(collection, slug) + dispatch(unpublishedEntryDeleteRequest(collection, slug, transactionID)); + return backend.deleteUnpublishedEntry(collection, slug) .then(() => { - dispatch(unpublishedEntryPublished(collection, slug, transactionID)); + dispatch(notifSend({ + message: 'Unpublished changes deleted', + kind: 'success', + dismissAfter: 4000, + })); + dispatch(unpublishedEntryDeleted(collection, slug, transactionID)); }) .catch((error) => { dispatch(notifSend({ - message: `Failed to close PR: ${ error }`, + message: `Failed to delete unpublished changes: ${ error }`, kind: 'danger', dismissAfter: 8000, })); - dispatch(unpublishedEntryPublishError(collection, slug, transactionID)); + dispatch(unpublishedEntryDeleteError(collection, slug, transactionID)); }); }; } @@ -309,13 +361,18 @@ export function publishUnpublishedEntry(collection, slug) { const backend = currentBackend(state.config); const transactionID = uuid(); dispatch(unpublishedEntryPublishRequest(collection, slug, transactionID)); - backend.publishUnpublishedEntry(collection, slug) + 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 merge: ${ error }`, + message: `Failed to publish: ${ error }`, kind: 'danger', dismissAfter: 8000, })); diff --git a/src/actions/entries.js b/src/actions/entries.js index 72df4a40707b..4797f4797eee 100644 --- a/src/actions/entries.js +++ b/src/actions/entries.js @@ -1,13 +1,12 @@ import { List } from 'immutable'; import { actions as notifActions } from 'redux-notifications'; -import { serializeValues } from '../lib/serializeEntryValues'; -import { closeEntry } from './editor'; -import { currentBackend } from '../backends/backend'; -import { getIntegrationProvider } from '../integrations'; -import { getAsset, selectIntegration } from '../reducers'; -import { selectFields } from '../reducers/collections'; -import { createEntry } from '../valueObjects/Entry'; -import ValidationErrorTypes from '../constants/validationErrorTypes'; +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 { createEntry } from 'ValueObjects/Entry'; +import ValidationErrorTypes from 'Constants/validationErrorTypes'; const { notifSend } = notifActions; @@ -111,12 +110,17 @@ export function entryPersisting(collection, entry) { }; } -export function entryPersisted(collection, entry) { +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, }, }; } @@ -299,13 +303,13 @@ export function persistEntry(collection) { dispatch(entryPersisting(collection, serializedEntry)); return backend .persistEntry(state.config, collection, serializedEntryDraft, assetProxies.toJS()) - .then(() => { + .then(slug => { dispatch(notifSend({ message: 'Entry saved', kind: 'success', dismissAfter: 4000, })); - return dispatch(entryPersisted(collection, serializedEntry)); + dispatch(entryPersisted(collection, serializedEntry, slug)) }) .catch((error) => { console.error(error); diff --git a/src/actions/findbar.js b/src/actions/findbar.js deleted file mode 100644 index d257abc286e2..000000000000 --- a/src/actions/findbar.js +++ /dev/null @@ -1,38 +0,0 @@ -import history from '../routing/history'; -import { SEARCH } from '../components/FindBar/FindBar'; -import { getCollectionUrl, getNewEntryUrl } from '../lib/urlHelper'; - -export const RUN_COMMAND = 'RUN_COMMAND'; -export const SHOW_COLLECTION = 'SHOW_COLLECTION'; -export const CREATE_COLLECTION = 'CREATE_COLLECTION'; -export const HELP = 'HELP'; - -export function runCommand(command, payload) { - return (dispatch) => { - switch (command) { - case SHOW_COLLECTION: - history.push(getCollectionUrl(payload.collectionName)); - break; - case CREATE_COLLECTION: - history.push(getNewEntryUrl(payload.collectionName)); - break; - case HELP: - window.alert('Find Bar Help (PLACEHOLDER)\n\nLorem ipsum dolor sit amet, consectetur adipiscing elit.'); - break; - case SEARCH: - history.push(`/search/${ payload.searchTerm }`); - break; - default: - break; - } - dispatch({ type: RUN_COMMAND, command, payload }); - }; -} - -export function navigateToCollection(collectionName) { - return runCommand(SHOW_COLLECTION, { collectionName }); -} - -export function createNewEntryInCollection(collectionName) { - return runCommand(CREATE_COLLECTION, { collectionName }); -} diff --git a/src/actions/globalUI.js b/src/actions/globalUI.js deleted file mode 100644 index ca9e4088cfbf..000000000000 --- a/src/actions/globalUI.js +++ /dev/null @@ -1,13 +0,0 @@ -export const TOGGLE_SIDEBAR = 'TOGGLE_SIDEBAR'; -export const OPEN_SIDEBAR = 'OPEN_SIDEBAR'; - -export function toggleSidebar() { - return { type: TOGGLE_SIDEBAR }; -} - -export function openSidebar(open = false) { - return { - type: OPEN_SIDEBAR, - payload: { open }, - }; -} diff --git a/src/actions/mediaLibrary.js b/src/actions/mediaLibrary.js index 805065e34d3e..a58616644b69 100644 --- a/src/actions/mediaLibrary.js +++ b/src/actions/mediaLibrary.js @@ -1,15 +1,16 @@ import { actions as notifActions } from 'redux-notifications'; -import { currentBackend } from '../backends/backend'; -import { createAssetProxy } from '../valueObjects/AssetProxy'; -import { getAsset, selectIntegration } from '../reducers'; +import { currentBackend } from 'Backends/backend'; +import { createAssetProxy } from 'ValueObjects/AssetProxy'; +import { getAsset, selectIntegration } from 'Reducers'; +import { getIntegrationProvider } from 'Integrations'; import { addAsset } from './media'; -import { getIntegrationProvider } from '../integrations'; 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'; @@ -32,6 +33,10 @@ 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) => { diff --git a/src/actions/search.js b/src/actions/search.js index 81e96ce74e26..dfc22b99348c 100644 --- a/src/actions/search.js +++ b/src/actions/search.js @@ -1,9 +1,9 @@ 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 { 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'; /* diff --git a/src/backends/backend.js b/src/backends/backend.js index 4b73307dbabf..4f2771aff30d 100644 --- a/src/backends/backend.js +++ b/src/backends/backend.js @@ -1,12 +1,19 @@ import { attempt, isError } from 'lodash'; +import { resolveFormat } from "Formats/formats"; +import { selectIntegration } from 'Reducers/integrations'; +import { + selectListMethod, + selectEntrySlug, + selectEntryPath, + selectAllowNewEntries, + selectAllowDeletion, + selectFolderEntryExtension +} 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 GitGatewayBackend from "./git-gateway/implementation"; -import { resolveFormat } from "../formats/formats"; -import { selectIntegration } from '../reducers/integrations'; -import { selectListMethod, selectEntrySlug, selectEntryPath, selectAllowNewEntries, selectAllowDeletion, selectFolderEntryExtension } from "../reducers/collections"; -import { createEntry } from "../valueObjects/Entry"; -import { sanitizeSlug } from "../lib/urlHelper"; class LocalStorageAuthStore { storageKey = "netlify-cms-user"; @@ -252,10 +259,10 @@ class Backend { */ 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, { - newEntry, parsedData, commitMessage, collectionName, mode, ...updatedOptions, - }); + return this.implementation.persistEntry(entryObj, MediaFiles, opts) + .then(() => entryObj.slug); } persistMedia(file) { diff --git a/src/backends/git-gateway/API.js b/src/backends/git-gateway/API.js index 3f75c1cc0b46..10737bb35a61 100644 --- a/src/backends/git-gateway/API.js +++ b/src/backends/git-gateway/API.js @@ -1,5 +1,5 @@ -import GithubAPI from "../github/API"; -import { APIError } from "../../valueObjects/errors"; +import GithubAPI from "Backends/github/API"; +import { APIError } from "ValueObjects/errors"; export default class API extends GithubAPI { constructor(config) { diff --git a/src/backends/git-gateway/AuthenticationPage.css b/src/backends/git-gateway/AuthenticationPage.css index d71790be879d..0a51b8fa7a6a 100644 --- a/src/backends/git-gateway/AuthenticationPage.css +++ b/src/backends/git-gateway/AuthenticationPage.css @@ -1,31 +1,45 @@ .nc-gitGatewayAuthenticationPage-root { display: flex; + flex-flow: column nowrap; align-items: center; justify-content: center; height: 100vh; } -.nc-gitGatewayAuthenticationPage-card { +.nc-gitGatewayAuthenticationPage-form { width: 350px; - padding: 10px; -} + margin-top: -80px; + & input { + background-color: #fff; + border-radius: var(--borderRadius); -.nc-gitGatewayAuthenticationPage-card img { - display: block; - margin: auto; - padding-bottom: 5px; -} + font-size: 14px; + padding: 10px 10px; + margin-bottom: 15px; + margin-top: 6px; + width: 100%; + position: relative; + z-index: 1; -.nc-gitGatewayAuthenticationPage-errorMsg { - color: #dd0000; + &:focus { + outline: none; + box-shadow: inset 0 0 0 2px var(--colorBlue); + } + } } -.nc-gitGatewayAuthenticationPage-message { - font-size: 1.1em; - margin: 20px 10px; +.nc-gitGatewayAuthenticationPage-button { + @apply(--button); + @apply(--dropShadowDeep); + @apply(--buttonDefault); + @apply(--buttonGray); + + padding: 0 30px; + display: block; + margin-top: 20px; + margin-left: auto; } -.nc-gitGatewayAuthenticationPage-button { - padding: .25em 1em; - height: auto; +.nc-gitGatewayAuthenticationPage-errorMsg { + color: var(--colorErrorText); } diff --git a/src/backends/git-gateway/AuthenticationPage.js b/src/backends/git-gateway/AuthenticationPage.js index 058030b30ba4..820a13546b7f 100644 --- a/src/backends/git-gateway/AuthenticationPage.js +++ b/src/backends/git-gateway/AuthenticationPage.js @@ -1,11 +1,8 @@ import PropTypes from 'prop-types'; import React from "react"; -import Input from "react-toolbox/lib/input"; -import Button from "react-toolbox/lib/button"; +import { partial } from 'lodash'; import { Notifs } from 'redux-notifications'; -import { Toast } from '../../components/UI/index'; -import { Card, Icon } from "../../components/UI"; -import logo from "./netlify_logo.svg"; +import { Toast, Icon } from 'UI'; let component = null; @@ -59,8 +56,8 @@ export default class AuthenticationPage extends React.Component { state = { email: "", password: "", errors: {} }; - handleChange = (name, value) => { - this.setState({ ...this.state, [name]: value }); + handleChange = (name, e) => { + this.setState({ ...this.state, [name]: e.target.value }); }; handleLogin = (e) => { @@ -96,48 +93,42 @@ export default class AuthenticationPage extends React.Component { if (window.netlifyIdentity) { return
- +
} return (
- -
- - {error &&

- {error} -

} - {errors.server &&

- {errors.server} -

} - - - -
-
+ +
+ {!error &&

+ {error} +

} + {!errors.server &&

+ {errors.server} +

} +
{ errors.email || null }
+ +
{ errors.password || null }
+ + +
); } diff --git a/src/backends/git-gateway/implementation.js b/src/backends/git-gateway/implementation.js index cdc3ebe496cc..cd8f029cc17d 100644 --- a/src/backends/git-gateway/implementation.js +++ b/src/backends/git-gateway/implementation.js @@ -2,7 +2,7 @@ import GoTrue from "gotrue-js"; import jwtDecode from 'jwt-decode'; import {List} from 'immutable'; import { get, pick, intersection } from "lodash"; -import GitHubBackend from "../github/implementation"; +import GitHubBackend from "Backends/github/implementation"; import API from "./API"; import AuthenticationPage from "./AuthenticationPage"; diff --git a/src/backends/git-gateway/netlify_logo.svg b/src/backends/git-gateway/netlify_logo.svg deleted file mode 100644 index 7e42689990a3..000000000000 --- a/src/backends/git-gateway/netlify_logo.svg +++ /dev/null @@ -1,23 +0,0 @@ - - - Netlify - - - - - - - - - - - - - - - - - - - - diff --git a/src/backends/github/API.js b/src/backends/github/API.js index 7e3eda86071e..60152b2f6b7f 100644 --- a/src/backends/github/API.js +++ b/src/backends/github/API.js @@ -1,10 +1,10 @@ import LocalForage from "localforage"; import { Base64 } from "js-base64"; import { uniq, initial, last, get, find } from "lodash"; -import { filterPromises, resolvePromiseProperties } from "../../lib/promiseHelper"; -import AssetProxy from "../../valueObjects/AssetProxy"; -import { SIMPLE, EDITORIAL_WORKFLOW, status } from "../../constants/publishModes"; -import { APIError, EditorialWorkflowError } from "../../valueObjects/errors"; +import { filterPromises, resolvePromiseProperties } from "Lib/promiseHelper"; +import AssetProxy from "ValueObjects/AssetProxy"; +import { SIMPLE, EDITORIAL_WORKFLOW, status } from "Constants/publishModes"; +import { APIError, EditorialWorkflowError } from "ValueObjects/errors"; const CMS_BRANCH_PREFIX = 'cms/'; @@ -270,6 +270,7 @@ export default class API { .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); diff --git a/src/backends/github/AuthenticationPage.css b/src/backends/github/AuthenticationPage.css index 1465a12e4bcf..124099ae52b2 100644 --- a/src/backends/github/AuthenticationPage.css +++ b/src/backends/github/AuthenticationPage.css @@ -6,7 +6,24 @@ height: 100vh; } +.nc-githubAuthenticationPage-logo { + color: #c4c6d2; + margin-top: -300px; +} + .nc-githubAuthenticationPage-button { - padding: .25em 1em; - height: auto; + @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; + } } diff --git a/src/backends/github/AuthenticationPage.js b/src/backends/github/AuthenticationPage.js index 71ec7a06ea9c..319309d04613 100644 --- a/src/backends/github/AuthenticationPage.js +++ b/src/backends/github/AuthenticationPage.js @@ -1,10 +1,8 @@ import PropTypes from 'prop-types'; import React from 'react'; -import Button from 'react-toolbox/lib/button'; -import Authenticator from '../../lib/netlify-auth'; -import { Icon } from '../../components/UI'; +import Authenticator from 'Lib/netlify-auth'; import { Notifs } from 'redux-notifications'; -import { Toast } from '../../components/UI/index'; +import { Icon, Toast } from 'UI'; export default class AuthenticationPage extends React.Component { static propTypes = { @@ -37,16 +35,16 @@ export default class AuthenticationPage extends React.Component { return (
+ {loginError &&

{loginError}

} - +
); } diff --git a/src/backends/github/__tests__/API.spec.js b/src/backends/github/__tests__/API.spec.js index ba6659617b8d..de2a74bb08fd 100644 --- a/src/backends/github/__tests__/API.spec.js +++ b/src/backends/github/__tests__/API.spec.js @@ -1,4 +1,4 @@ -import AssetProxy from "../../../valueObjects/AssetProxy"; +import AssetProxy from "ValueObjects/AssetProxy"; import API from "../API"; describe('github API', () => { diff --git a/src/backends/github/implementation.js b/src/backends/github/implementation.js index bba53bdaf3ba..eb3b865d62df 100644 --- a/src/backends/github/implementation.js +++ b/src/backends/github/implementation.js @@ -1,8 +1,8 @@ import trimStart from 'lodash/trimStart'; import semaphore from "semaphore"; +import { fileExtension } from 'Lib/pathHelper' import AuthenticationPage from "./AuthenticationPage"; import API from "./API"; -import { fileExtension } from '../../lib/pathHelper' const MAX_CONCURRENT_DOWNLOADS = 10; @@ -15,7 +15,7 @@ export default class GitHub { } this.repo = config.getIn(["backend", "repo"], ""); - this.branch = config.getIn(["backend", "branch"], "master"); + this.branch = config.getIn(["backend", "branch"], "master").trim(); this.api_root = config.getIn(["backend", "api_root"], "https://api.github.com"); this.token = ''; } diff --git a/src/backends/test-repo/AuthenticationPage.js b/src/backends/test-repo/AuthenticationPage.js index 17c5d77ff37b..ca38d68b80dc 100644 --- a/src/backends/test-repo/AuthenticationPage.js +++ b/src/backends/test-repo/AuthenticationPage.js @@ -1,9 +1,6 @@ import PropTypes from 'prop-types'; import React from 'react'; -import Input from "react-toolbox/lib/input"; -import Button from "react-toolbox/lib/button"; -import { Card, Icon } from "../../components/UI"; -import logo from "../git-gateway/netlify_logo.svg"; +import { Icon } from 'UI'; export default class AuthenticationPage extends React.Component { static propTypes = { @@ -11,40 +8,25 @@ export default class AuthenticationPage extends React.Component { inProgress: PropTypes.bool.isRequired, }; - state = { email: '' }; - handleLogin = (e) => { e.preventDefault(); this.props.onLogin(this.state); }; - handleEmailChange = (value) => { - this.setState({ email: value }); - }; - render() { const { inProgress } = this.props; - return (
- - -

This is a demo, enter your email to start

- - -
-
); + {inProgress ? "Logging in..." : "Login"} + + + ); } } diff --git a/src/backends/test-repo/implementation.js b/src/backends/test-repo/implementation.js index 9e82204e7d49..55a723ded3ef 100644 --- a/src/backends/test-repo/implementation.js +++ b/src/backends/test-repo/implementation.js @@ -1,7 +1,7 @@ import { remove, attempt, isError } from 'lodash'; import uuid from 'uuid/v4'; +import { fileExtension } from 'Lib/pathHelper' import AuthenticationPage from './AuthenticationPage'; -import { fileExtension } from '../../lib/pathHelper' window.repoFiles = window.repoFiles || {}; @@ -14,15 +14,6 @@ function getFile(path) { return obj || {}; } -function nameFromEmail(email) { - return email - .split('@').shift().replace(/[.-_]/g, ' ') - .split(' ') - .filter(f => f) - .map(s => s.substr(0, 1).toUpperCase() + (s.substr(1) || '')) - .join(' '); -} - export default class TestRepo { constructor(config) { this.config = config; @@ -37,8 +28,8 @@ export default class TestRepo { return this.authenticate(user); } - authenticate(state) { - return Promise.resolve({ email: state.email, name: nameFromEmail(state.email) }); + authenticate() { + return Promise.resolve(); } logout() { diff --git a/src/components/App/App.css b/src/components/App/App.css new file mode 100644 index 000000000000..0f7e895e24ca --- /dev/null +++ b/src/components/App/App.css @@ -0,0 +1,8 @@ +@import "./NotFoundPage.css"; +@import "./Header.css"; + +.nc-app-main { + min-width: 800px; + max-width: 1440px; + margin: 0 auto; +} diff --git a/src/components/App/App.js b/src/components/App/App.js new file mode 100644 index 000000000000..4b575ae5aa36 --- /dev/null +++ b/src/components/App/App.js @@ -0,0 +1,180 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +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 { Loader, Toast } from 'UI'; +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 (
+

Error loading the CMS configuration

+ +
+

The config.yml file could not be loaded or failed to parse properly.

+

Error message: {config.get('error')}

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

Waiting for backend...

; + } + + return ( +
+ { + 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) + }) + } +
+ ); + } + + 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 Loading configuration...; + } + + if (user == null) { + return this.authenticating(); + } + + const defaultPath = `/collections/${collections.first().get('name')}`; + const hasWorkflow = publishMode === EDITORIAL_WORKFLOW; + + return ( +
+ +
+
+ { isFetching && } +
+ + + + { hasWorkflow ? : null } + + } /> + + } /> + + + +
+
+
+ ); + } +} + +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 connect(mapStateToProps, mapDispatchToProps)(App); diff --git a/src/components/App/Header.css b/src/components/App/Header.css new file mode 100644 index 000000000000..f8d3f70aa1fc --- /dev/null +++ b/src/components/App/Header.css @@ -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); +} diff --git a/src/components/App/Header.js b/src/components/App/Header.js new file mode 100644 index 000000000000..1d770fab4fde --- /dev/null +++ b/src/components/App/Header.js @@ -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 'UI'; +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 ( +
+
+
+ +
+ + { + collections.filter(collection => collection.get('create')).toList().map(collection => + this.handleCreatePostClick(collection.get('name'))} + /> + ) + } + + { + displayUrl + ? + {stripProtocol(displayUrl)} + + : null + } + + { + avatarUrl + ? + : + } + + } + > + + +
+
+
+
+ ); + } +} diff --git a/src/components/App/NotFoundPage.css b/src/components/App/NotFoundPage.css new file mode 100644 index 000000000000..9022a94b2eb7 --- /dev/null +++ b/src/components/App/NotFoundPage.css @@ -0,0 +1,3 @@ +.nc-notFound-container { + margin: var(--pageMargin); +} diff --git a/src/components/App/NotFoundPage.js b/src/components/App/NotFoundPage.js new file mode 100644 index 000000000000..5115a424845b --- /dev/null +++ b/src/components/App/NotFoundPage.js @@ -0,0 +1,7 @@ +import React from 'react'; + +export default () => ( +
+

Not Found

+
+); diff --git a/src/components/AppHeader/AppHeader.css b/src/components/AppHeader/AppHeader.css deleted file mode 100644 index 8ee8475dc150..000000000000 --- a/src/components/AppHeader/AppHeader.css +++ /dev/null @@ -1,37 +0,0 @@ -.nc-appHeader-appBar { - padding: 8px 24px; - height: auto; - background-color: var(--backgroundAltColor); - color: var(--defaultColorLight); -} - -/* Gross stuff below, React Toolbox hacks */ - -.nc-appHeader-button, -.nc-appHeader-iconMenu { - margin-left: 16px; -} - -.nc-appHeader-button { - cursor: pointer; - border: 0; - background-color: transparent; - width: 36px; - padding: 6px 0; - text-align: center; - - & .nc-appHeader-icon { - vertical-align: top; - } -} - -.nc-appHeader-icon, -.nc-appHeader-icon span, -.nc-appHeader-leftIcon span { - /* stylelint-disable */ - - color: var(--defaultColorLight) !important; - font-size: 24px !important; - - /* stylelint-enable */ -} diff --git a/src/components/AppHeader/AppHeader.js b/src/components/AppHeader/AppHeader.js deleted file mode 100644 index 486b98e77cbc..000000000000 --- a/src/components/AppHeader/AppHeader.js +++ /dev/null @@ -1,89 +0,0 @@ -import PropTypes from 'prop-types'; -import React from "react"; -import ImmutablePropTypes from "react-immutable-proptypes"; -import { Link } from 'react-router-dom'; -import { IconMenu, Menu, MenuItem } from "react-toolbox/lib/menu"; -import Avatar from "react-toolbox/lib/avatar"; -import AppBar from "react-toolbox/lib/app_bar"; -import FontIcon from "react-toolbox/lib/font_icon"; -import FindBar from "../FindBar/FindBar"; -import { stringToRGB } from "../../lib/textHelper"; - -export default class AppHeader extends React.Component { - - static propTypes = { - user: ImmutablePropTypes.map.isRequired, - collections: ImmutablePropTypes.orderedMap.isRequired, - runCommand: PropTypes.func.isRequired, - toggleDrawer: PropTypes.func.isRequired, - onCreateEntryClick: PropTypes.func.isRequired, - onLogoutClick: PropTypes.func.isRequired, - }; - - handleCreatePostClick = (collectionName) => { - const { onCreateEntryClick } = this.props; - if (onCreateEntryClick) { - onCreateEntryClick(collectionName); - } - }; - - render() { - const { - user, - collections, - runCommand, - toggleDrawer, - onLogoutClick, - openMediaLibrary, - } = this.props; - - const avatarStyle = { - backgroundColor: `#${ stringToRGB(user.get("name")) }`, - }; - - const theme = { - appBar: 'nc-appHeader-appBar', - iconMenu: 'nc-appHeader-iconMenu', - icon: 'nc-appHeader-icon', - leftIcon: 'nc-appHeader-leftIcon', - base: 'nc-theme-base', - container: 'nc-theme-container', - rounded: 'nc-theme-rounded', - depth: 'nc-theme-depth', - clearfix: 'nc-theme-clearfix', - }; - - return ( - - - - - - - { - collections.filter(collection => collection.get('create')).toList().map(collection => - - ) - } - - - - - - - - ); - } -} diff --git a/src/components/Collection/Collection.css b/src/components/Collection/Collection.css new file mode 100644 index 000000000000..ca7ec0ea5a14 --- /dev/null +++ b/src/components/Collection/Collection.css @@ -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; +} diff --git a/src/components/Collection/Collection.js b/src/components/Collection/Collection.js new file mode 100644 index 000000000000..f954e736a797 --- /dev/null +++ b/src/components/Collection/Collection.js @@ -0,0 +1,70 @@ +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 + }; + + renderEntriesSearch = () => { + const { searchTerm, collections } = this.props; + return + }; + + 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 ( +
+ +
+ { + isSearchResults + ? null + : + } + { isSearchResults ? this.renderEntriesSearch() : this.renderEntriesCollection() } +
+
+ ); + } +} + +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); diff --git a/src/components/Collection/CollectionTop.css b/src/components/Collection/CollectionTop.css new file mode 100644 index 000000000000..095a2d49db16 --- /dev/null +++ b/src/components/Collection/CollectionTop.css @@ -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); +} diff --git a/src/components/Collection/CollectionTop.js b/src/components/Collection/CollectionTop.js new file mode 100644 index 000000000000..28c38adbecdf --- /dev/null +++ b/src/components/Collection/CollectionTop.js @@ -0,0 +1,63 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import c from 'classnames'; +import { Link } from 'react-router-dom'; +import { Icon } from 'UI'; +import { VIEW_STYLE_LIST, VIEW_STYLE_GRID } from 'Constants/collectionViews'; + +const CollectionTop = ({ + collectionLabel, + collectionDescription, + viewStyle, + onChangeViewStyle, + newEntryUrl, +}) => { + return ( +
+
+

{collectionLabel}

+ { + newEntryUrl + ? + {`New ${collectionLabel}`} + + : null + } +
+ { + collectionDescription + ?

{collectionDescription}

+ : null + } +
+ View as: + + +
+
+ ); +}; + +CollectionTop.propTypes = { + collectionLabel: PropTypes.string.isRequired, + collectionDescription: PropTypes.string, + newEntryUrl: PropTypes.string +}; + +export default CollectionTop; diff --git a/src/components/Collection/Entries/Entries.css b/src/components/Collection/Entries/Entries.css new file mode 100644 index 000000000000..8f62e0a25a43 --- /dev/null +++ b/src/components/Collection/Entries/Entries.css @@ -0,0 +1,2 @@ +@import "./EntryListing.css"; +@import "./EntryCard.css"; diff --git a/src/components/Collection/Entries/Entries.js b/src/components/Collection/Entries/Entries.js new file mode 100644 index 000000000000..bc9974b2e33d --- /dev/null +++ b/src/components/Collection/Entries/Entries.js @@ -0,0 +1,51 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { Loader } from 'UI'; +import EntryListing from './EntryListing'; + +const Entries = ({ + collections, + entries, + publicFolder, + page, + onPaginate, + isFetching, + viewStyle +}) => { + const loadingMessages = [ + 'Loading Entries', + 'Caching Entries', + 'This might take several minutes', + ]; + + if (entries) { + return ( + + ); + } + + if (isFetching) { + return {loadingMessages}; + } + + return
No Entries
; +} + +Entries.propTypes = { + collections: ImmutablePropTypes.map.isRequired, + entries: ImmutablePropTypes.list, + publicFolder: PropTypes.string.isRequired, + page: PropTypes.number, + isFetching: PropTypes.bool, + viewStyle: PropTypes.string, +}; + +export default Entries; diff --git a/src/components/Collection/Entries/EntriesCollection.js b/src/components/Collection/Entries/EntriesCollection.js new file mode 100644 index 000000000000..37b727471795 --- /dev/null +++ b/src/components/Collection/Entries/EntriesCollection.js @@ -0,0 +1,64 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; +import { loadEntries } from 'Actions/entries'; +import { selectEntries } from 'Reducers'; +import Entries from './Entries'; + +class EntriesCollection extends React.Component { + static propTypes = { + collection: ImmutablePropTypes.map.isRequired, + publicFolder: PropTypes.string.isRequired, + dispatch: PropTypes.func.isRequired, + page: PropTypes.number, + entries: ImmutablePropTypes.list, + isFetching: PropTypes.bool.isRequired, + viewStyle: PropTypes.string, + }; + + componentDidMount() { + const { collection, dispatch } = this.props; + if (collection) { + dispatch(loadEntries(collection)); + } + } + + componentWillReceiveProps(nextProps) { + const { collection, dispatch } = this.props; + if (nextProps.collection !== collection) { + dispatch(loadEntries(nextProps.collection)); + } + } + + render () { + const { dispatch, collection, entries, publicFolder, page, isFetching, viewStyle } = this.props; + + return ( + dispatch(loadEntries(collection, page))} + isFetching={isFetching} + collectionName={collection.get('label')} + viewStyle={viewStyle} + /> + ); + } +} + +function mapStateToProps(state, ownProps) { + const { name, 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); + + return { publicFolder, collection, page, entries, isFetching, viewStyle }; +} + +export default connect(mapStateToProps)(EntriesCollection); diff --git a/src/containers/SearchPage.js b/src/components/Collection/Entries/EntriesSearch.js similarity index 54% rename from src/containers/SearchPage.js rename to src/components/Collection/Entries/EntriesSearch.js index e27d5721c38b..02c66ffa3b3a 100644 --- a/src/containers/SearchPage.js +++ b/src/components/Collection/Entries/EntriesSearch.js @@ -1,14 +1,15 @@ -import PropTypes from 'prop-types'; 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 { Loader } from '../components/UI'; -import EntryListing from '../components/EntryListing/EntryListing'; - -class SearchPage extends React.Component { +import { selectSearchedEntries } from 'Reducers'; +import { + searchEntries as actionSearchEntries, + clearSearch as actionClearSearch +} from 'Actions/search'; +import Entries from './Entries'; +class EntriesSearch extends React.Component { static propTypes = { isFetching: PropTypes.bool, searchEntries: PropTypes.func.isRequired, @@ -40,43 +41,35 @@ class SearchPage extends React.Component { if (!isNaN(page)) searchEntries(searchTerm, page); }; - render() { - const { collections, searchTerm, entries, isFetching, page, publicFolder } = this.props; - return (
- {(isFetching === true || !entries) ? - {['Loading Entries', 'Caching Entries', 'This might take several minutes']} - : - - Results for “{searchTerm}” - - } -
); + render () { + const { dispatch, collections, entries, publicFolder, page, isFetching } = this.props; + return ( + + ); } } - function mapStateToProps(state, ownProps) { + const { searchTerm } = ownProps; + const collections = ownProps.collections.toIndexedSeq(); const isFetching = state.entries.getIn(['search', 'isFetching']); const page = state.entries.getIn(['search', 'page']); const entries = selectSearchedEntries(state); - const collections = state.collections.toIndexedSeq(); const publicFolder = state.config.get('public_folder'); - const { searchTerm } = ownProps.match.params; return { isFetching, page, collections, entries, publicFolder, searchTerm }; } +const mapDispatchToProps = { + searchEntries: actionSearchEntries, + clearSearch: actionClearSearch, +}; -export default connect( - mapStateToProps, - { - searchEntries: actionSearchEntries, - clearSearch: actionClearSearch, - } -)(SearchPage); +export default connect(mapStateToProps, mapDispatchToProps)(EntriesSearch); diff --git a/src/components/Collection/Entries/EntryCard.css b/src/components/Collection/Entries/EntryCard.css new file mode 100644 index 000000000000..8dfc18766ce0 --- /dev/null +++ b/src/components/Collection/Entries/EntryCard.css @@ -0,0 +1,70 @@ +.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-cardBody-full { + height: 100%; +} + +.nc-entryListing-cardHeading { + margin: 0 0 2px; +} + +.nc-entryListing-cardListLabel { + white-space: nowrap; + font-weight: bold; +} diff --git a/src/components/Collection/Entries/EntryCard.js b/src/components/Collection/Entries/EntryCard.js new file mode 100644 index 000000000000..e9f3f9fcd030 --- /dev/null +++ b/src/components/Collection/Entries/EntryCard.js @@ -0,0 +1,53 @@ +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 'Lib/pathHelper'; +import { VIEW_STYLE_LIST, VIEW_STYLE_GRID } from 'Constants/collectionViews'; + +const EntryCard = ({ + collection, + entry, + inferedFields, + publicFolder, + 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 ( + +

{title}

+ + ); + } + + if (viewStyle === VIEW_STYLE_GRID) { + return ( + +
+

{title}

+
+ { + image + ?
+ : null + } + + ); + } +} + +export default EntryCard; diff --git a/src/components/Collection/Entries/EntryListing.css b/src/components/Collection/Entries/EntryListing.css new file mode 100644 index 000000000000..4980857feadb --- /dev/null +++ b/src/components/Collection/Entries/EntryListing.css @@ -0,0 +1,9 @@ +.nc-entryListing-cardsGrid { + display: flex; + flex-flow: row wrap; + margin-left: -12px; +} + +.nc-entryListing-cardsList { + margin-left: -12px; +} diff --git a/src/components/Collection/Entries/EntryListing.js b/src/components/Collection/Entries/EntryListing.js new file mode 100644 index 000000000000..dff560bbad07 --- /dev/null +++ b/src/components/Collection/Entries/EntryListing.js @@ -0,0 +1,70 @@ +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'; + +export default class EntryListing extends React.Component { + static propTypes = { + publicFolder: PropTypes.string.isRequired, + collections: PropTypes.oneOfType([ + ImmutablePropTypes.map, + ImmutablePropTypes.iterable, + ]).isRequired, + entries: ImmutablePropTypes.list, + onPaginate: PropTypes.func.isRequired, + page: PropTypes.number, + viewStyle: PropTypes.string, + }; + + handleLoadMore = () => { + this.props.onPaginate(this.props.page + 1); + }; + + 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) => ); + }; + + 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 inferedFields = this.inferFields(collection); + const entryCardProps = { collection, entry, inferedFields, publicFolder, key: idx }; + return ; + }); + }; + + render() { + const { collections, entries, publicFolder } = this.props; + + return ( +
+
+ { + Map.isMap(collections) + ? this.renderCardsForSingleCollection() + : this.renderCardsForMultipleCollections() + } + +
+
+ ); + } +} diff --git a/src/components/Collection/Sidebar.css b/src/components/Collection/Sidebar.css new file mode 100644 index 000000000000..ace061b0f0c2 --- /dev/null +++ b/src/components/Collection/Sidebar.css @@ -0,0 +1,68 @@ +.nc-collectionPage-sidebar { + @apply(--card); + width: 250px; + padding: 8px 0 12px; + position: fixed; +} + +.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; + left: 6px; + z-index: 2; + } +} + +.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; + } +} diff --git a/src/components/Collection/Sidebar.js b/src/components/Collection/Sidebar.js new file mode 100644 index 000000000000..671a8bcfa139 --- /dev/null +++ b/src/components/Collection/Sidebar.js @@ -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 'UI'; + +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 ( + + + {collection.get('label')} + + ); + }; + + + render() { + const { collections } = this.props; + const { query } = this.state; + + return ( +
+

Collections

+
+ + this.setState({ query: e.target.value })} + onKeyDown={e => e.key === 'Enter' && searchCollections(query)} + placeholder="Search all" + value={query} + /> +
+ {collections.toList().map(this.renderLink)} +
+ ); + } +} diff --git a/src/components/ControlPanel/ControlPane.css b/src/components/ControlPanel/ControlPane.css deleted file mode 100644 index 7d4967765c68..000000000000 --- a/src/components/ControlPanel/ControlPane.css +++ /dev/null @@ -1,35 +0,0 @@ -.nc-controlPane-root p { - font-size: 16px; -} - -.nc-controlPane-control { - color: var(--textColor); - position: relative; - padding: 20px 0 10px 0; - margin-top: 16px; - - & input, - & textarea, - & select { - @apply --input; - } -} - -.nc-controlPane-label { - display: block; - color: var(--controlLabelColor); - font-size: 12px; - text-transform: uppercase; - font-weight: 600; -} - -.nc-controlPane-labelWithError { - color: #FF706F; -} - -.nc-controlPane-errors { - list-style-type: none; - font-size: 10px; - color: #FF706F; - margin-bottom: 5px; -} diff --git a/src/components/ControlPanel/ControlPane.js b/src/components/ControlPanel/ControlPane.js deleted file mode 100644 index 1d7525d6b2ee..000000000000 --- a/src/components/ControlPanel/ControlPane.js +++ /dev/null @@ -1,109 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { Map, fromJS } from 'immutable'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { resolveWidget } from '../Widgets'; -import ControlHOC from '../Widgets/ControlHOC'; - -function isHidden(field) { - return field.get('widget') === 'hidden'; -} - -export default class ControlPane extends Component { - componentValidate = {}; - processControlRef(fieldName, wrappedControl) { - if (!wrappedControl) return; - this.componentValidate[fieldName] = wrappedControl.validate; - } - - validate = () => { - this.props.fields.forEach((field) => { - if (isHidden(field)) return; - this.componentValidate[field.get("name")](); - }); - }; - - controlFor(field) { - const { - entry, - fieldsMetaData, - fieldsErrors, - mediaPaths, - getAsset, - onChange, - onOpenMediaLibrary, - onAddAsset, - onRemoveAsset - } = this.props; - const widget = resolveWidget(field.get('widget')); - const fieldName = field.get('name'); - const value = entry.getIn(['data', fieldName]); - const metadata = fieldsMetaData.get(fieldName); - const errors = fieldsErrors.get(fieldName); - const labelClass = errors ? 'nc-controlPane-label nc-controlPane-labelWithError' : 'nc-controlPane-label'; - if (entry.size === 0 || entry.get('partial') === true) return null; - return ( -
- -
    - { - errors && errors.map(error => - error.message && - typeof error.message === 'string' && -
  • {error.message}
  • - ) - } -
- onChange(fieldName, newValue, newMetadata)} - onValidate={this.props.onValidate.bind(this, fieldName)} - onOpenMediaLibrary={onOpenMediaLibrary} - onAddAsset={onAddAsset} - onRemoveAsset={onRemoveAsset} - getAsset={getAsset} - ref={this.processControlRef.bind(this, fieldName)} - /> -
- ); - } - - render() { - const { collection, fields } = this.props; - if (!collection || !fields) { - return null; - } - - return ( -
- { - fields.map((field, i) => { - if (isHidden(field)) { - return null; - } - return
{this.controlFor(field)}
; - }) - } -
- ); - } -} - -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, - onRemoveAsset: PropTypes.func.isRequired, -}; diff --git a/src/components/Editor/Editor.css b/src/components/Editor/Editor.css new file mode 100644 index 000000000000..edcd4e7b3eae --- /dev/null +++ b/src/components/Editor/Editor.css @@ -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"; diff --git a/src/components/Editor/Editor.js b/src/components/Editor/Editor.js new file mode 100644 index 000000000000..b0bb7ec5bd74 --- /dev/null +++ b/src/components/Editor/Editor.js @@ -0,0 +1,390 @@ +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 'UI'; +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, + closeEntry: 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); + this.createDraft(deserializedEntry); + } else if (newEntry) { + this.props.createEmptyDraft(collection); + } + } + + componentWillUnmount() { + this.props.discardDraft(); + window.removeEventListener('beforeunload', this.exitBlocker); + } + + createDraft = (entry) => { + if (entry) this.props.createDraftFromEntry(entry); + }; + + handleChangeStatus = (newStatusName) => { + const { updateUnpublishedEntryStatus, collection, slug, currentStatus } = this.props; + 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 (!window.confirm('Are you sure you want to publish this entry?')) { + return; + } else if (entryDraft.get('hasChanged')) { + if (window.confirm('Your unsaved changes will be saved before publishing. Are you sure you want to publish?')) { + await persistEntry(collection); + } else { + 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

{ entry.get('error') }

; + } else if (entryDraft == null + || entryDraft.get('entry') === undefined + || (entry && entry.get('isFetching'))) { + return Loading entry...; + } + + return ( + + ); + } +} + +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)); diff --git a/src/components/Editor/EditorControlPane/EditorControl.css b/src/components/Editor/EditorControlPane/EditorControl.css new file mode 100644 index 000000000000..432eb70b227e --- /dev/null +++ b/src/components/Editor/EditorControlPane/EditorControl.css @@ -0,0 +1,7 @@ +.nc-controlPane-control { + margin-top: 16px; + + &:first-child { + margin-top: 36px; + } +} diff --git a/src/components/Editor/EditorControlPane/EditorControl.js b/src/components/Editor/EditorControlPane/EditorControl.js new file mode 100644 index 000000000000..4b8370b169b6 --- /dev/null +++ b/src/components/Editor/EditorControlPane/EditorControl.js @@ -0,0 +1,83 @@ +import React from 'react'; +import { partial } 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 metadata = fieldsMetaData && fieldsMetaData.get(fieldName); + const errors = fieldsErrors && fieldsErrors.get(fieldName); + return ( +
+
    + { + errors && errors.map(error => + error.message && + typeof error.message === 'string' && +
  • {error.message}
  • + ) + } +
+ + 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} + /> +
+ ); + } +} diff --git a/src/components/Editor/EditorControlPane/EditorControlPane.css b/src/components/Editor/EditorControlPane/EditorControlPane.css new file mode 100644 index 000000000000..1c773401f343 --- /dev/null +++ b/src/components/Editor/EditorControlPane/EditorControlPane.css @@ -0,0 +1,106 @@ +: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; + + & 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; +} diff --git a/src/components/Editor/EditorControlPane/EditorControlPane.js b/src/components/Editor/EditorControlPane/EditorControlPane.js new file mode 100644 index 000000000000..41f0cf983f52 --- /dev/null +++ b/src/components/Editor/EditorControlPane/EditorControlPane.js @@ -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 ( +
+ {fields.map((field, i) => field.get('widget') === 'hidden' ? null : + + )} +
+ ); + } +} + +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, +}; diff --git a/src/components/Widgets/ControlHOC.js b/src/components/Editor/EditorControlPane/Widget.js similarity index 70% rename from src/components/Widgets/ControlHOC.js rename to src/components/Editor/EditorControlPane/Widget.js index 5016cb39e7ec..75c18bbb836d 100644 --- a/src/components/Widgets/ControlHOC.js +++ b/src/components/Editor/EditorControlPane/Widget.js @@ -1,15 +1,24 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import ImmutablePropTypes from "react-immutable-proptypes"; -import ValidationErrorTypes from '../../constants/validationErrorTypes'; +import { Map } from 'immutable'; +import ValidationErrorTypes from 'Constants/validationErrorTypes'; const truthy = () => ({ error: false }); -class ControlHOC extends Component { - +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, + className: PropTypes.string.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, @@ -22,7 +31,7 @@ class ControlHOC extends Component { onValidate: PropTypes.func, onOpenMediaLibrary: PropTypes.func.isRequired, onAddAsset: PropTypes.func.isRequired, - onRemoveAsset: PropTypes.func.isRequired, + onRemoveInsertedMedia: PropTypes.func.isRequired, getAsset: PropTypes.func.isRequired, }; @@ -33,7 +42,9 @@ class ControlHOC extends Component { if (this.wrappedControlShouldComponentUpdate) { return this.wrappedControlShouldComponentUpdate(nextProps); } - return this.props.value !== nextProps.value; + return this.props.value !== nextProps.value + || this.props.classNameWrapper !== nextProps.classNameWrapper + || this.props.hasActiveStyle !== nextProps.hasActiveStyle; } processInnerControlRef = ref => { @@ -136,6 +147,21 @@ class ControlHOC extends Component { 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, @@ -146,8 +172,17 @@ class ControlHOC extends Component { onChange, onOpenMediaLibrary, onAddAsset, - onRemoveAsset, - getAsset + onRemoveInsertedMedia, + getAsset, + classNameWrapper, + classNameWidget, + classNameWidgetActive, + classNameLabel, + classNameLabelActive, + setActiveStyle, + setInactiveStyle, + hasActiveStyle, + editorControl, } = this.props; return React.createElement(controlComponent, { field, @@ -155,14 +190,22 @@ class ControlHOC extends Component { mediaPaths, metadata, onChange, + onChangeObject: this.onChangeObject, onOpenMediaLibrary, onAddAsset, - onRemoveAsset, + onRemoveInsertedMedia, getAsset, forID: field.get('name'), ref: this.processInnerControlRef, + classNameWrapper, + classNameWidget, + classNameWidgetActive, + classNameLabel, + classNameLabelActive, + setActiveStyle, + setInactiveStyle, + hasActiveStyle, + editorControl, }); } } - -export default ControlHOC; diff --git a/src/components/Editor/EditorInterface.css b/src/components/Editor/EditorInterface.css new file mode 100644 index 000000000000..c8a5d2a3c260 --- /dev/null +++ b/src/components/Editor/EditorInterface.css @@ -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 16px; + position: relative; + overflow-x: hidden; +} + +.nc-entryEditor-viewControls { + position: absolute; + top: 10px; + right: -10px; + z-index: 299; +} + +.nc-entryEditor-blocker > * { + pointer-events: none; +} diff --git a/src/components/Editor/EditorInterface.js b/src/components/Editor/EditorInterface.js new file mode 100644 index 000000000000..7afe6572a3f7 --- /dev/null +++ b/src/components/Editor/EditorInterface.js @@ -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 'UI' +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 = ( +
+ this.controlPaneRef = c} // eslint-disable-line + /> +
+ ); + + const editorWithPreview = ( + +
+ + {editor} +
+ +
+
+
+
+ ); + + const editorWithoutPreview = ( +
+ {editor} +
+ ); + + return ( +
+ 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} + /> +
+
+ + +
+ { + collectionPreviewEnabled && this.state.previewVisible + ? editorWithPreview + : editorWithoutPreview + } +
+
+ ); + } +} + +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; diff --git a/src/components/PreviewPane/Preview.js b/src/components/Editor/EditorPreviewPane/EditorPreview.js similarity index 100% rename from src/components/PreviewPane/Preview.js rename to src/components/Editor/EditorPreviewPane/EditorPreview.js diff --git a/src/components/PreviewPane/PreviewContent.js b/src/components/Editor/EditorPreviewPane/EditorPreviewContent.js similarity index 92% rename from src/components/PreviewPane/PreviewContent.js rename to src/components/Editor/EditorPreviewPane/EditorPreviewContent.js index a8bae522289c..c7a466bad308 100644 --- a/src/components/PreviewPane/PreviewContent.js +++ b/src/components/Editor/EditorPreviewPane/EditorPreviewContent.js @@ -1,6 +1,6 @@ import PropTypes from 'prop-types'; import React from 'react'; -import { ScrollSyncPane } from '../ScrollSync'; +import { ScrollSyncPane } from 'react-scroll-sync'; /** * We need to create a lightweight component here so that we can access the diff --git a/src/components/PreviewPane/PreviewPane.css b/src/components/Editor/EditorPreviewPane/EditorPreviewPane.css similarity index 70% rename from src/components/PreviewPane/PreviewPane.css rename to src/components/Editor/EditorPreviewPane/EditorPreviewPane.css index e91d1e105254..9b92a1f7adeb 100644 --- a/src/components/PreviewPane/PreviewPane.css +++ b/src/components/Editor/EditorPreviewPane/EditorPreviewPane.css @@ -3,4 +3,5 @@ height: 100%; border: none; background: #fff; + border-radius: var(--borderRadius); } diff --git a/src/components/PreviewPane/PreviewPane.js b/src/components/Editor/EditorPreviewPane/EditorPreviewPane.js similarity index 89% rename from src/components/PreviewPane/PreviewPane.js rename to src/components/Editor/EditorPreviewPane/EditorPreviewPane.js index 0f13ddb1f239..fd7654be7e14 100644 --- a/src/components/PreviewPane/PreviewPane.js +++ b/src/components/Editor/EditorPreviewPane/EditorPreviewPane.js @@ -3,14 +3,13 @@ import React from 'react'; import { List, Map } from 'immutable'; import ImmutablePropTypes from 'react-immutable-proptypes'; import Frame from 'react-frame-component'; -import registry from '../../lib/registry'; -import ErrorBoundary from '../UI/ErrorBoundary/ErrorBoundary'; -import { resolveWidget } from '../Widgets'; -import { selectTemplateName, selectInferedField } from '../../reducers/collections'; -import { INFERABLE_FIELDS } from '../../constants/fieldInference'; -import PreviewContent from './PreviewContent.js'; -import PreviewHOC from '../Widgets/PreviewHOC'; -import Preview from './Preview'; +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 { @@ -127,8 +126,8 @@ export default class PreviewPane extends React.Component { } const previewComponent = - registry.getPreviewTemplate(selectTemplateName(collection, entry.get('slug'))) || - Preview; + getPreviewTemplate(selectTemplateName(collection, entry.get('slug'))) || + EditorPreview; this.inferFields(); @@ -138,7 +137,7 @@ export default class PreviewPane extends React.Component { widgetsFor: this.widgetsFor, }; - const styleEls = registry.getPreviewStyles() + const styleEls = getPreviewStyles() .map((style, i) => ); if (!collection) { @@ -152,10 +151,11 @@ export default class PreviewPane extends React.Component {
`; + return ( - + ); diff --git a/src/components/Widgets/PreviewHOC.js b/src/components/Editor/EditorPreviewPane/PreviewHOC.js similarity index 100% rename from src/components/Widgets/PreviewHOC.js rename to src/components/Editor/EditorPreviewPane/PreviewHOC.js diff --git a/src/components/Editor/EditorScrollSync/ScrollSync.js b/src/components/Editor/EditorScrollSync/ScrollSync.js new file mode 100644 index 000000000000..a5ce3a624f02 --- /dev/null +++ b/src/components/Editor/EditorScrollSync/ScrollSync.js @@ -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) + } +} diff --git a/src/components/Editor/EditorScrollSync/ScrollSyncPane.js b/src/components/Editor/EditorScrollSync/ScrollSyncPane.js new file mode 100644 index 000000000000..b3a22c5f5113 --- /dev/null +++ b/src/components/Editor/EditorScrollSync/ScrollSyncPane.js @@ -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 + } +} diff --git a/src/components/ScrollSync/index.js b/src/components/Editor/EditorScrollSync/index.js similarity index 100% rename from src/components/ScrollSync/index.js rename to src/components/Editor/EditorScrollSync/index.js diff --git a/src/components/Editor/EditorToggle.css b/src/components/Editor/EditorToggle.css new file mode 100644 index 000000000000..8a4a7859f6d4 --- /dev/null +++ b/src/components/Editor/EditorToggle.css @@ -0,0 +1,18 @@ +.nc-editor-toggle { + @apply(--dropShadowMiddle); + background-color: #fff; + color: var(--colorInactive); + border-radius: 32px; + display: block; + width: 40px; + height: 40px; + padding: 0; + display: flex; + justify-content: center; + align-items: center; + margin-bottom: 12px; +} + +.nc-editor-toggleActive { + color: var(--colorActive); +} diff --git a/src/components/Editor/EditorToggle.js b/src/components/Editor/EditorToggle.js new file mode 100644 index 000000000000..13c674fd7771 --- /dev/null +++ b/src/components/Editor/EditorToggle.js @@ -0,0 +1,18 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import c from 'classnames'; +import { Icon } from 'UI'; + +const EditorToggle = ({ enabled, active, onClick, icon }) => !enabled ? null : + ; + +EditorToggle.propTypes = { + enabled: PropTypes.bool, + active: PropTypes.bool, + onClick: PropTypes.func.isRequired, + icon: PropTypes.string.isRequired, +}; + +export default EditorToggle; diff --git a/src/components/Editor/EditorToolbar.css b/src/components/Editor/EditorToolbar.css new file mode 100644 index 000000000000..45b6f4948534 --- /dev/null +++ b/src/components/Editor/EditorToolbar.css @@ -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, "); + } + +} + +.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); +} diff --git a/src/components/Editor/EditorToolbar.js b/src/components/Editor/EditorToolbar.js new file mode 100644 index 000000000000..8f4b4ede9f1d --- /dev/null +++ b/src/components/Editor/EditorToolbar.js @@ -0,0 +1,237 @@ +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 'UI'; +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 ( +
+ { + showDelete + ? + : null + } +
+ ); + }; + + renderSimplePublishControls = () => { + const { onPersist, onPersistAndNew, isPersisting, hasChanged, isNewEntry } = this.props; + if (!isNewEntry && !hasChanged) { + return
Published
; + } + return ( +
+ + + + +
+ ); + }; + + 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 [ + , + isNewEntry || !deleteLabel ? null + : , + ]; + }; + + renderWorkflowPublishControls = () => { + const { + onPersist, + onPersistAndNew, + isUpdatingStatus, + isPublishing, + onChangeStatus, + onPublish, + onPublishAndNew, + currentStatus, + isNewEntry, + } = this.props; + if (currentStatus) { + return [ + + onChangeStatus('DRAFT')} + icon={currentStatus === status.get('DRAFT') && 'check'} + /> + onChangeStatus('PENDING_REVIEW')} + icon={currentStatus === status.get('PENDING_REVIEW') && 'check'} + /> + onChangeStatus('PENDING_PUBLISH')} + icon={currentStatus === status.get('PENDING_PUBLISH') && 'check'} + /> + , + + + + + ]; + } + + if (!isNewEntry) { + return
Published
; + } + }; + + + + 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 ( +
+ +
+
+
+ Writing in {collection.get('label')} collection +
+ { + hasChanged + ?
Unsaved Changes
+ :
Changes saved
+ } +
+ +
+
+ { hasWorkflow ? this.renderWorkflowSaveControls() : this.renderSimpleSaveControls() } +
+
+ { hasWorkflow ? this.renderWorkflowPublishControls() : this.renderSimplePublishControls() } +
+
+
+ { + displayUrl + ? + {stripProtocol(displayUrl)} + + : null + } + + { + avatarUrl + ? + : + } + + } + > + + +
+
+ ); + } +}; diff --git a/src/components/EntryEditor/__tests__/EntryEditorToolbar.spec.js b/src/components/Editor/__tests__/EntryEditorToolbar.spec.js similarity index 94% rename from src/components/EntryEditor/__tests__/EntryEditorToolbar.spec.js rename to src/components/Editor/__tests__/EntryEditorToolbar.spec.js index c8e28d654ab1..bd763cd24c7f 100644 --- a/src/components/EntryEditor/__tests__/EntryEditorToolbar.spec.js +++ b/src/components/Editor/__tests__/EntryEditorToolbar.spec.js @@ -1,6 +1,6 @@ import React from 'react'; import { shallow } from 'enzyme'; -import EntryEditorToolbar from '../EntryEditorToolbar'; +import EditorToolbar from '../EditorInterface/EditorToolbar'; describe('EntryEditorToolbar', () => { it('should have the Save button disabled initally, and the Cancel button enabled', () => { diff --git a/src/components/EntryEditor/__tests__/__snapshots__/EntryEditorToolbar.spec.js.snap b/src/components/Editor/__tests__/__snapshots__/EntryEditorToolbar.spec.js.snap similarity index 100% rename from src/components/EntryEditor/__tests__/__snapshots__/EntryEditorToolbar.spec.js.snap rename to src/components/Editor/__tests__/__snapshots__/EntryEditorToolbar.spec.js.snap diff --git a/src/components/Editor/withWorkflow.js b/src/components/Editor/withWorkflow.js new file mode 100644 index 000000000000..c8678258bb10 --- /dev/null +++ b/src/components/Editor/withWorkflow.js @@ -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 ; + } + } + ); +}; + diff --git a/src/components/EditorWidgets/Boolean/Boolean.css b/src/components/EditorWidgets/Boolean/Boolean.css new file mode 100644 index 000000000000..b56b85458650 --- /dev/null +++ b/src/components/EditorWidgets/Boolean/Boolean.css @@ -0,0 +1,9 @@ +.nc-booleanControl-switch { + & .nc-toggle-background { + background-color: var(--textFieldBorderColor); + } + + & .nc-toggle-active .nc-toggle-background { + background-color: var(--colorActive); + } +} diff --git a/src/components/EditorWidgets/Boolean/BooleanControl.js b/src/components/EditorWidgets/Boolean/BooleanControl.js new file mode 100644 index 000000000000..0612a9cc2a98 --- /dev/null +++ b/src/components/EditorWidgets/Boolean/BooleanControl.js @@ -0,0 +1,40 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import ImmutablePropTypes from "react-immutable-proptypes"; +import { isBoolean } from 'lodash'; +import { Toggle } from 'UI'; + +export default class BooleanControl extends React.Component { + render() { + const { + value, + field, + forID, + onChange, + classNameWrapper, + setActiveStyle, + setInactiveStyle + } = this.props; + return ( +
+ +
+ ); + } +} + +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, +}; diff --git a/src/components/Widgets/DateControl.js b/src/components/EditorWidgets/Date/DateControl.js similarity index 53% rename from src/components/Widgets/DateControl.js rename to src/components/EditorWidgets/Date/DateControl.js index fca1653aec54..dc064ccd2f02 100644 --- a/src/components/Widgets/DateControl.js +++ b/src/components/EditorWidgets/Date/DateControl.js @@ -1,9 +1,22 @@ -import PropTypes from 'prop-types'; import React from 'react'; +import PropTypes from 'prop-types'; import DateTime from 'react-datetime'; import moment from 'moment'; 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, + }; + componentDidMount() { const { value, field, onChange } = this.props; this.format = field.get('format'); @@ -28,22 +41,17 @@ export default class DateControl extends React.Component { }; render() { - const { includeTime, value } = this.props; + const { includeTime, value, classNameWrapper, setActiveStyle, setInactiveStyle } = this.props; const format = this.format || moment.defaultFormat; - return (); + return ( + + ); } } - -DateControl.propTypes = { - onChange: PropTypes.func.isRequired, - value: PropTypes.oneOfType([ - PropTypes.object, - PropTypes.string, - ]), - includeTime: PropTypes.bool, - field: PropTypes.object, -}; diff --git a/src/components/Widgets/DatePreview.js b/src/components/EditorWidgets/Date/DatePreview.js similarity index 57% rename from src/components/Widgets/DatePreview.js rename to src/components/EditorWidgets/Date/DatePreview.js index c31e95f5837d..945a8f2b25c3 100644 --- a/src/components/Widgets/DatePreview.js +++ b/src/components/EditorWidgets/Date/DatePreview.js @@ -1,9 +1,8 @@ import PropTypes from 'prop-types'; import React from 'react'; -import previewStyle from './defaultPreviewStyle'; export default function DatePreview({ value }) { - return
{value ? value.toString() : null}
; + return
{value ? value.toString() : null}
; } DatePreview.propTypes = { diff --git a/src/components/EditorWidgets/DateTime/DateTime.css b/src/components/EditorWidgets/DateTime/DateTime.css new file mode 100644 index 000000000000..41b83e5c917a --- /dev/null +++ b/src/components/EditorWidgets/DateTime/DateTime.css @@ -0,0 +1 @@ +@import "./ReactDatetime.css"; diff --git a/src/components/EditorWidgets/DateTime/DateTimeControl.js b/src/components/EditorWidgets/DateTime/DateTimeControl.js new file mode 100644 index 000000000000..dc97a680cc5e --- /dev/null +++ b/src/components/EditorWidgets/DateTime/DateTimeControl.js @@ -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 ( + + ); + } +} diff --git a/src/components/Widgets/DateTimePreview.js b/src/components/EditorWidgets/DateTime/DateTimePreview.js similarity index 58% rename from src/components/Widgets/DateTimePreview.js rename to src/components/EditorWidgets/DateTime/DateTimePreview.js index c6cc85892144..a6d89bdd5f93 100644 --- a/src/components/Widgets/DateTimePreview.js +++ b/src/components/EditorWidgets/DateTime/DateTimePreview.js @@ -1,9 +1,8 @@ import PropTypes from 'prop-types'; import React from 'react'; -import previewStyle from './defaultPreviewStyle'; export default function DateTimePreview({ value }) { - return
{value ? value.toString() : null}
; + return
{value ? value.toString() : null}
; } DateTimePreview.propTypes = { diff --git a/src/components/EditorWidgets/DateTime/ReactDatetime.css b/src/components/EditorWidgets/DateTime/ReactDatetime.css new file mode 100644 index 000000000000..4dc0b791cb50 --- /dev/null +++ b/src/components/EditorWidgets/DateTime/ReactDatetime.css @@ -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; +} diff --git a/src/components/EditorWidgets/EditorWidgets.css b/src/components/EditorWidgets/EditorWidgets.css new file mode 100644 index 000000000000..1c7eec76c7ba --- /dev/null +++ b/src/components/EditorWidgets/EditorWidgets.css @@ -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; +} diff --git a/src/components/EditorWidgets/File/FileControl.css b/src/components/EditorWidgets/File/FileControl.css new file mode 100644 index 000000000000..bd5217475dbd --- /dev/null +++ b/src/components/EditorWidgets/File/FileControl.css @@ -0,0 +1,7 @@ +.nc-fileControl-input { + display: none !important; +} + +.nc-fileControl-imageUpload { + cursor: pointer; +} diff --git a/src/components/EditorWidgets/File/FileControl.js b/src/components/EditorWidgets/File/FileControl.js new file mode 100644 index 000000000000..db2fdeb05382 --- /dev/null +++ b/src/components/EditorWidgets/File/FileControl.js @@ -0,0 +1,5 @@ +import withMediaControl from 'EditorWidgets/withMedia/withMediaControl'; + +const FileControl = withMediaControl(); + +export default FileControl; diff --git a/src/components/Widgets/FilePreview.js b/src/components/EditorWidgets/File/FilePreview.js similarity index 77% rename from src/components/Widgets/FilePreview.js rename to src/components/EditorWidgets/File/FilePreview.js index bea801d334dd..716bba42b9eb 100644 --- a/src/components/Widgets/FilePreview.js +++ b/src/components/EditorWidgets/File/FilePreview.js @@ -1,9 +1,8 @@ import PropTypes from 'prop-types'; import React from 'react'; -import previewStyle from './defaultPreviewStyle'; export default function FilePreview({ value, getAsset }) { - return (
+ return (
{ value ? { value } : null} diff --git a/src/components/EditorWidgets/Image/Image.css b/src/components/EditorWidgets/Image/Image.css new file mode 100644 index 000000000000..e3351ec8044e --- /dev/null +++ b/src/components/EditorWidgets/Image/Image.css @@ -0,0 +1,4 @@ +.nc-imagePreview-image { + max-width: 100%; + height: auto; +} diff --git a/src/components/EditorWidgets/Image/ImageControl.js b/src/components/EditorWidgets/Image/ImageControl.js new file mode 100644 index 000000000000..801a59bc3967 --- /dev/null +++ b/src/components/EditorWidgets/Image/ImageControl.js @@ -0,0 +1,5 @@ +import withMediaControl from 'EditorWidgets/withMedia/withMediaControl'; + +const ImageControl = withMediaControl(true); + +export default ImageControl; diff --git a/src/components/Widgets/ImagePreview.js b/src/components/EditorWidgets/Image/ImagePreview.js similarity index 70% rename from src/components/Widgets/ImagePreview.js rename to src/components/EditorWidgets/Image/ImagePreview.js index 55ffa1a42464..b18c96d536c8 100644 --- a/src/components/Widgets/ImagePreview.js +++ b/src/components/EditorWidgets/Image/ImagePreview.js @@ -1,13 +1,12 @@ import PropTypes from 'prop-types'; import React from 'react'; -import previewStyle, { imagePreviewStyle } from './defaultPreviewStyle'; export default function ImagePreview({ value, getAsset }) { - return (
+ return (
{ value ? : null} diff --git a/src/components/EditorWidgets/List/List.css b/src/components/EditorWidgets/List/List.css new file mode 100644 index 000000000000..441299cc5f70 --- /dev/null +++ b/src/components/EditorWidgets/List/List.css @@ -0,0 +1,83 @@ +.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; + cursor: pointer; + line-height: 1; + + & .nc-icon { + padding-right: 8px; + } +} + +.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; + } +} diff --git a/src/components/Widgets/ListControl.js b/src/components/EditorWidgets/List/ListControl.js similarity index 57% rename from src/components/Widgets/ListControl.js rename to src/components/EditorWidgets/List/ListControl.js index 1050fff5bbdf..335ac80853d7 100644 --- a/src/components/Widgets/ListControl.js +++ b/src/components/EditorWidgets/List/ListControl.js @@ -2,9 +2,11 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { List, Map, fromJS } from 'immutable'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import { partial } from 'lodash'; +import c from 'classnames'; import { SortableContainer, SortableElement, SortableHandle } from 'react-sortable-hoc'; -import FontIcon from 'react-toolbox/lib/font_icon'; -import ObjectControl from './ObjectControl'; +import { Icon, ListItemTopBar } from 'UI'; +import ObjectControl from 'EditorWidgets/Object/ObjectControl'; function ListItem(props) { return
{props.children}
; @@ -20,11 +22,22 @@ function valueToString(value) { } const SortableListItem = SortableElement(ListItem); -const DragHandle = SortableHandle( - () => + +const TopBar = ({ onAdd, listLabel, collapsed, onCollapseToggle, itemsCount }) => ( +
+
+ + {itemsCount} {listLabel} +
+ +
); -const SortableList = SortableContainer(({ items, renderItem }) => - (
{items.map(renderItem)}
)); + +const SortableList = SortableContainer(({ items, renderItem }) => { + return
{items.map(renderItem)}
; +}); const valueTypes = { SINGLE: 'SINGLE', @@ -34,6 +47,7 @@ const valueTypes = { export default class ListControl extends Component { static propTypes = { onChange: PropTypes.func.isRequired, + onChangeObject: PropTypes.func.isRequired, value: PropTypes.node, field: PropTypes.node, forID: PropTypes.string, @@ -41,12 +55,19 @@ export default class ListControl extends Component { getAsset: PropTypes.func.isRequired, onOpenMediaLibrary: PropTypes.func.isRequired, onAddAsset: PropTypes.func.isRequired, - onRemoveAsset: PropTypes.func.isRequired, + onRemoveInsertedMedia: PropTypes.func.isRequired, + classNameWrapper: PropTypes.string.isRequired, + setActiveStyle: PropTypes.func.isRequired, + setInactiveStyle: PropTypes.func.isRequired, }; constructor(props) { super(props); - this.state = { itemsCollapsed: List(), value: valueToString(props.value) }; + this.state = { + collapsed: false, + itemsCollapsed: List(), + value: valueToString(props.value), + }; this.valueType = null; } @@ -87,28 +108,42 @@ export default class ListControl extends Component { if (newValue.match(/,$/) && oldValue.match(/, $/)) { listValue.pop(); } - + const parsedValue = valueToString(listValue); this.setState({ value: parsedValue }); onChange(listValue.map(val => val.trim())); }; - handleCleanup = (e) => { + 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({ collapsed: 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 (newValue, newMetadata) => { + return (fieldName, newValue, newMetadata) => { const { value, metadata, onChange, forID } = this.props; - const parsedValue = (this.valueType === valueTypes.SINGLE) ? newValue.first() : newValue; + const newObjectValue = this.getObjectValue(index).set(fieldName, newValue); + const parsedValue = (this.valueType === valueTypes.SINGLE) ? newObjectValue.first() : newObjectValue; const parsedMetadata = { [forID]: Object.assign(metadata ? metadata.toJS() : {}, newMetadata ? newMetadata[forID] : {}), }; @@ -116,23 +151,23 @@ export default class ListControl extends Component { }; } - handleRemove(index) { - return (e) => { - e.preventDefault(); - const { value, metadata, onChange, forID } = this.props; - const parsedMetadata = metadata && { [forID]: metadata.removeIn(value.get(index).valueSeq()) }; - onChange(value.remove(index), parsedMetadata); - }; + handleRemove = (index, event) => { + event.preventDefault(); + const { value, metadata, onChange, forID } = this.props; + const parsedMetadata = metadata && { [forID]: metadata.removeIn(value.get(index).valueSeq()) }; + onChange(value.remove(index), parsedMetadata); } - handleToggle(index) { - return (e) => { - e.preventDefault(); - const { itemsCollapsed } = this.state; - this.setState({ - itemsCollapsed: itemsCollapsed.set(index, !itemsCollapsed.get(index, false)), - }); - }; + handleCollapseToggle = () => { + this.setState({ collapsed: !this.state.collapsed }); + } + + handleItemCollapseToggle = (index, event) => { + event.preventDefault(); + const { itemsCollapsed } = this.state; + this.setState({ + itemsCollapsed: itemsCollapsed.set(index, !itemsCollapsed.get(index, false)), + }); } objectLabel(item) { @@ -160,55 +195,75 @@ export default class ListControl extends Component { }; renderItem = (item, index) => { - const { field, getAsset, mediaPaths, onOpenMediaLibrary, onAddAsset, onRemoveAsset } = this.props; + 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 ( - - - +
{this.objectLabel(item)}
); }; renderListControl() { - const { value, forID, field } = this.props; - const listLabel = field.get('label'); - - return (
- - -
); + const { value, forID, field, classNameWrapper } = this.props; + const { collapsed } = this.state; + const items = value || List(); + const className = c(classNameWrapper, 'nc-listControl', { + 'nc-listControl-collapsed' : collapsed, + }); + + return ( +
+ + { + collapsed ? null : + + } +
+ ); } render() { - const { field, forID } = this.props; + const { field, forID, classNameWrapper } = this.props; const { value } = this.state; if (field.get('field') || field.get('fields')) { @@ -220,7 +275,9 @@ export default class ListControl extends Component { id={forID} value={value} onChange={this.handleChange} - onBlur={this.handleCleanup} + onFocus={this.handleFocus} + onBlur={this.handleBlur} + className={classNameWrapper} />); } }; diff --git a/src/components/Widgets/ListPreview.js b/src/components/EditorWidgets/List/ListPreview.js similarity index 58% rename from src/components/Widgets/ListPreview.js rename to src/components/EditorWidgets/List/ListPreview.js index c2bc596c0687..825621008e27 100644 --- a/src/components/Widgets/ListPreview.js +++ b/src/components/EditorWidgets/List/ListPreview.js @@ -1,8 +1,6 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import { resolveWidget } from '../Widgets'; -import previewStyle from './defaultPreviewStyle'; -import ObjectPreview from './ObjectPreview'; +import ObjectPreview from 'EditorWidgets/Object/ObjectPreview'; const ListPreview = ObjectPreview; diff --git a/src/components/EditorWidgets/Markdown/Markdown.css b/src/components/EditorWidgets/Markdown/Markdown.css new file mode 100644 index 000000000000..6aef23444f0c --- /dev/null +++ b/src/components/EditorWidgets/Markdown/Markdown.css @@ -0,0 +1,4 @@ +@import "./MarkdownControl/RawEditor/index.css"; +@import "./MarkdownControl/Toolbar/Toolbar.css"; +@import "./MarkdownControl/Toolbar/ToolbarButton.css"; +@import "./MarkdownControl/VisualEditor/index.css"; diff --git a/src/components/Widgets/Markdown/MarkdownControl/RawEditor/index.css b/src/components/EditorWidgets/Markdown/MarkdownControl/RawEditor/index.css similarity index 65% rename from src/components/Widgets/Markdown/MarkdownControl/RawEditor/index.css rename to src/components/EditorWidgets/Markdown/MarkdownControl/RawEditor/index.css index 028771691c03..65f58565f95c 100644 --- a/src/components/Widgets/Markdown/MarkdownControl/RawEditor/index.css +++ b/src/components/EditorWidgets/Markdown/MarkdownControl/RawEditor/index.css @@ -3,10 +3,13 @@ } .nc-rawEditor-rawEditor { - @apply(--input); 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: -var(--stickyDistanceBottom); } diff --git a/src/components/Widgets/Markdown/MarkdownControl/RawEditor/index.js b/src/components/EditorWidgets/Markdown/MarkdownControl/RawEditor/index.js similarity index 79% rename from src/components/Widgets/Markdown/MarkdownControl/RawEditor/index.js rename to src/components/EditorWidgets/Markdown/MarkdownControl/RawEditor/index.js index c0494f224b1b..cd15da30fb50 100644 --- a/src/components/Widgets/Markdown/MarkdownControl/RawEditor/index.js +++ b/src/components/EditorWidgets/Markdown/MarkdownControl/RawEditor/index.js @@ -3,8 +3,7 @@ import React from 'react'; import { Editor as Slate } from 'slate-react'; import Plain from 'slate-plain-serializer'; import { debounce } from 'lodash'; -import Toolbar from '../Toolbar/Toolbar'; -import { Sticky } from '../../../../UI/Sticky/Sticky'; +import Toolbar from 'EditorWidgets/Markdown/MarkdownControl/Toolbar/Toolbar'; export default class RawEditor extends React.Component { constructor(props) { @@ -51,17 +50,20 @@ export default class RawEditor extends React.Component { }; render() { + const { className } = this.props; + return (
- - - +
+ +
+
+ + + + + + + + + + +
+ + } + > + {plugins && plugins.toList().map(plugin => ( + onSubmit(plugin.get('id'))} /> + ))} + +
+
+
+ + {toggleOffLabel} + + + + {toggleOnLabel} + +
+
+ ); + } +} diff --git a/src/components/EditorWidgets/Markdown/MarkdownControl/Toolbar/ToolbarButton.css b/src/components/EditorWidgets/Markdown/MarkdownControl/Toolbar/ToolbarButton.css new file mode 100644 index 000000000000..89b49470882e --- /dev/null +++ b/src/components/EditorWidgets/Markdown/MarkdownControl/Toolbar/ToolbarButton.css @@ -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; +} diff --git a/src/components/EditorWidgets/Markdown/MarkdownControl/Toolbar/ToolbarButton.js b/src/components/EditorWidgets/Markdown/MarkdownControl/Toolbar/ToolbarButton.js new file mode 100644 index 000000000000..026e67502496 --- /dev/null +++ b/src/components/EditorWidgets/Markdown/MarkdownControl/Toolbar/ToolbarButton.js @@ -0,0 +1,30 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import c from 'classnames'; +import { Icon } from 'UI'; + +const ToolbarButton = ({ type, label, icon, onClick, isActive, disabled }) => { + const active = isActive && type && isActive(type); + + return ( + + ); +}; + +ToolbarButton.propTypes = { + type: PropTypes.string, + label: PropTypes.string.isRequired, + icon: PropTypes.string, + onClick: PropTypes.func.isRequired, + isActive: PropTypes.func, + disabled: PropTypes.bool, +}; + +export default ToolbarButton; diff --git a/src/components/EditorWidgets/Markdown/MarkdownControl/VisualEditor/Shortcode.css b/src/components/EditorWidgets/Markdown/MarkdownControl/VisualEditor/Shortcode.css new file mode 100644 index 000000000000..fb3b9a300185 --- /dev/null +++ b/src/components/EditorWidgets/Markdown/MarkdownControl/VisualEditor/Shortcode.css @@ -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: -var(--widgetNestDistance) -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); +} diff --git a/src/components/EditorWidgets/Markdown/MarkdownControl/VisualEditor/Shortcode.js b/src/components/EditorWidgets/Markdown/MarkdownControl/VisualEditor/Shortcode.js new file mode 100644 index 000000000000..346ea3e2e1ba --- /dev/null +++ b/src/components/EditorWidgets/Markdown/MarkdownControl/VisualEditor/Shortcode.js @@ -0,0 +1,136 @@ +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 'UI'; +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; + 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 ( +
+ +
+ ); + }; + + 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 ( +
+ + { + collapsed + ?
{capitalize(pluginId)}
+ : plugin.get('fields').map(partial(this.renderControl, shortcodeData)) + } +
+ ); + } +} + +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); diff --git a/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/__tests__/__snapshots__/parser.spec.js.snap b/src/components/EditorWidgets/Markdown/MarkdownControl/VisualEditor/__tests__/__snapshots__/parser.spec.js.snap similarity index 100% rename from src/components/Widgets/Markdown/MarkdownControl/VisualEditor/__tests__/__snapshots__/parser.spec.js.snap rename to src/components/EditorWidgets/Markdown/MarkdownControl/VisualEditor/__tests__/__snapshots__/parser.spec.js.snap diff --git a/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/__tests__/parser.spec.js b/src/components/EditorWidgets/Markdown/MarkdownControl/VisualEditor/__tests__/parser.spec.js similarity index 98% rename from src/components/Widgets/Markdown/MarkdownControl/VisualEditor/__tests__/parser.spec.js rename to src/components/EditorWidgets/Markdown/MarkdownControl/VisualEditor/__tests__/parser.spec.js index f549b3669a4e..c77d2a0049f5 100644 --- a/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/__tests__/parser.spec.js +++ b/src/components/EditorWidgets/Markdown/MarkdownControl/VisualEditor/__tests__/parser.spec.js @@ -1,5 +1,5 @@ import { fromJS } from 'immutable'; -import { markdownToSlate } from '../../../serializers'; +import { markdownToSlate } from 'EditorWidgets/Markdown/serializers'; const parser = markdownToSlate; diff --git a/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/index.css b/src/components/EditorWidgets/Markdown/MarkdownControl/VisualEditor/index.css similarity index 79% rename from src/components/Widgets/Markdown/MarkdownControl/VisualEditor/index.css rename to src/components/EditorWidgets/Markdown/MarkdownControl/VisualEditor/index.css index 384334aba9a0..e7e1aaa3bd1a 100644 --- a/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/index.css +++ b/src/components/EditorWidgets/Markdown/MarkdownControl/VisualEditor/index.css @@ -1,12 +1,14 @@ -.nc-visualEditor-editorControlBar { - z-index: 1; - border: 2px solid transparent; - border-top: 0; - background-color: var(--controlBGColor); +@import './Shortcode.css'; + +:root { + --stickyDistanceBottom: 100px; } -.nc-visualEditor-editorControlBarSticky { - border-color: var(--textFieldBorderColor); +.nc-visualEditor-editorControlBar { + z-index: 1; + position: sticky; + top: 0; + margin-bottom: var(--stickyDistanceBottom); } .nc-visualEditor-wrapper { @@ -14,12 +16,15 @@ } .nc-visualEditor-editor { - @apply(--input); 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: -var(--stickyDistanceBottom); } .nc-visualEditor-editor h1 { @@ -100,7 +105,7 @@ } .nc-visualEditor-editor code { - background-color: var(--backgroundColorShaded); + background-color: var(--colorBackground); border-radius: var(--borderRadius); padding: 0 2px; font-size: 85%; @@ -108,7 +113,7 @@ .nc-visualEditor-editor blockquote { padding-left: 16px; - border-left: 3px solid var(--backgroundColorShaded); + border-left: 3px solid var(--colorBackground); margin-left: 0; margin-right: 0; } @@ -123,15 +128,3 @@ padding: 8px; text-align: left; } - -.nc-visualEditor-shortcode { - border: 2px solid black; - padding: 8px; - margin: 2px 0; - cursor: pointer; -} - -.nc-visualEditor-shortcodeSelected { - border-color: var(--primaryColor); - color: var(--primaryColor); -} diff --git a/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/index.js b/src/components/EditorWidgets/Markdown/MarkdownControl/VisualEditor/index.js similarity index 61% rename from src/components/Widgets/Markdown/MarkdownControl/VisualEditor/index.js rename to src/components/EditorWidgets/Markdown/MarkdownControl/VisualEditor/index.js index 253c4a17bd52..ff475f78790e 100644 --- a/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/index.js +++ b/src/components/EditorWidgets/Markdown/MarkdownControl/VisualEditor/index.js @@ -1,35 +1,60 @@ import PropTypes from 'prop-types'; 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 '../../serializers'; -import registry from '../../../../../lib/registry'; -import Toolbar from '../Toolbar/Toolbar'; -import { Sticky } from '../../../../UI/Sticky/Sticky'; +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, + }; + constructor(props) { super(props); - const emptyText = Text.create(''); - const emptyBlock = Block.create({ kind: 'block', type: 'paragraph', nodes: [ emptyText ] }); - const emptyRawDoc = { nodes: [emptyBlock] }; - const rawDoc = this.props.value && markdownToSlate(this.props.value); - const rawDocHasNodes = !isEmpty(get(rawDoc, 'nodes')) - const document = Document.fromJSON(rawDocHasNodes ? rawDoc : emptyRawDoc); - const value = Value.create({ document }); this.state = { - value, - shortcodePlugins: registry.getEditorComponents(), + value: createSlateValue(props.value), + shortcodePlugins: getEditorComponents(), }; } shouldComponentUpdate(nextProps, nextState) { - return !this.state.value.equals(nextState.value); + return (this.props.value !== null && nextProps.value === null) + || (this.props.value === null && nextProps.value !== null) + || !this.state.value.equals(nextState.value); + } + + componentWillUpdate(nextProps) { + const shouldResetState = (this.props.value !== null && nextProps.value === null) + || (this.props.value === null && nextProps.value !== null) + if (shouldResetState) { + this.setState({ value: createSlateValue(nextProps.value) }); + } } handlePaste = (e, data, change) => { @@ -41,8 +66,8 @@ export default class Editor extends Component { return change.insertFragment(doc); } - hasMark = type => this.state.value.activeMarks.some(mark => mark.type === type); - hasBlock = type => this.state.value.blocks.some(node => node.type === type); + 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(); @@ -60,7 +85,7 @@ export default class Editor extends Component { // Handle everything except list buttons. if (!['bulleted-list', 'numbered-list'].includes(type)) { - const isActive = this.hasBlock(type); + const isActive = this.selectionHasBlock(type); change = change.setBlock(isActive ? 'paragraph' : type); } @@ -121,14 +146,20 @@ export default class Editor extends Component { this.setState({ value: change.value }); }; - handlePluginSubmit = (plugin, shortcodeData) => { + handlePluginAdd = pluginId => { const { value } = this.state; - const data = { - shortcode: plugin.id, - shortcodeData, - }; const nodes = [Text.create('')]; - const block = { kind: 'block', type: 'shortcode', data, isVoid: true, nodes }; + const block = { + kind: 'block', + type: 'shortcode', + data: { + shortcode: pluginId, + shortcodeNew: true, + shortcodeData: Map(), + }, + isVoid: true, + nodes + }; let change = value.change(); const { focusBlock } = change.value; @@ -148,12 +179,6 @@ export default class Editor extends Component { this.props.onMode('raw'); }; - getButtonProps = (type, opts = {}) => { - const { isBlock } = opts; - const handler = opts.handler || (isBlock ? this.handleBlockClick: this.handleMarkClick); - const isActive = opts.isActive || (isBlock ? this.hasBlock : this.hasMark); - return { onAction: e => handler(e, type), active: isActive(type) }; - }; handleDocumentChange = debounce(change => { const raw = change.value.document.toJSON(); @@ -169,39 +194,32 @@ export default class Editor extends Component { this.setState({ value: change.value }); }; + processRef = ref => { + this.ref = ref; + } + render() { - const { onAddAsset, onRemoveAsset, getAsset } = this.props; + const { onAddAsset, getAsset, className } = this.props; return (
- +
- +
this.ref = ref} + ref={this.processRef} spellCheck />
); } } - -Editor.propTypes = { - onAddAsset: PropTypes.func.isRequired, - onRemoveAsset: PropTypes.func.isRequired, - getAsset: PropTypes.func.isRequired, - onChange: PropTypes.func.isRequired, - onMode: PropTypes.func.isRequired, - value: PropTypes.string, -}; diff --git a/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/keys.js b/src/components/EditorWidgets/Markdown/MarkdownControl/VisualEditor/keys.js similarity index 100% rename from src/components/Widgets/Markdown/MarkdownControl/VisualEditor/keys.js rename to src/components/EditorWidgets/Markdown/MarkdownControl/VisualEditor/keys.js diff --git a/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/plugins.js b/src/components/EditorWidgets/Markdown/MarkdownControl/VisualEditor/plugins.js similarity index 100% rename from src/components/Widgets/Markdown/MarkdownControl/VisualEditor/plugins.js rename to src/components/EditorWidgets/Markdown/MarkdownControl/VisualEditor/plugins.js diff --git a/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/renderers.js b/src/components/EditorWidgets/Markdown/MarkdownControl/VisualEditor/renderers.js similarity index 92% rename from src/components/Widgets/Markdown/MarkdownControl/VisualEditor/renderers.js rename to src/components/EditorWidgets/Markdown/MarkdownControl/VisualEditor/renderers.js index b1788463c71c..c24acbc5c60e 100644 --- a/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/renderers.js +++ b/src/components/EditorWidgets/Markdown/MarkdownControl/VisualEditor/renderers.js @@ -1,6 +1,7 @@ import React from 'react'; import { List } from 'immutable'; import cn from 'classnames'; +import Shortcode from './Shortcode'; /** * Slate uses React components to render each type of node that it receives. @@ -60,12 +61,6 @@ const Image = props => { }, image); return result; }; -const Shortcode = props => { - const { attributes, node, editor } = props; - const isSelected = editor.value.selection.hasFocusIn(node); - const className = cn('nc-visualEditor-shortcode', { ['nc-visualEditor-shortcodeSelected']: isSelected }); - return
{node.data.get('shortcode')}
; -}; export const renderMark = props => { switch (props.mark.type) { diff --git a/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/validators.js b/src/components/EditorWidgets/Markdown/MarkdownControl/VisualEditor/validators.js similarity index 100% rename from src/components/Widgets/Markdown/MarkdownControl/VisualEditor/validators.js rename to src/components/EditorWidgets/Markdown/MarkdownControl/VisualEditor/validators.js diff --git a/src/components/Widgets/Markdown/MarkdownControl/index.js b/src/components/EditorWidgets/Markdown/MarkdownControl/index.js similarity index 61% rename from src/components/Widgets/Markdown/MarkdownControl/index.js rename to src/components/EditorWidgets/Markdown/MarkdownControl/index.js index 78de7ef29fbf..367858a82a99 100644 --- a/src/components/Widgets/Markdown/MarkdownControl/index.js +++ b/src/components/EditorWidgets/Markdown/MarkdownControl/index.js @@ -1,24 +1,29 @@ import PropTypes from 'prop-types'; import React from 'react'; -import registry from '../../../../lib/registry'; -import { markdownToRemark, remarkToMarkdown } from '../serializers' +import c from 'classnames'; +import { markdownToRemark, remarkToMarkdown } from 'EditorWidgets/Markdown/serializers' import RawEditor from './RawEditor'; import VisualEditor from './VisualEditor'; -import { StickyContainer } from '../../../UI/Sticky/Sticky'; const MODE_STORAGE_KEY = 'cms.md-mode'; +let editorControl; + +export const getEditorControl = () => editorControl; + export default class MarkdownControl extends React.Component { static propTypes = { onChange: PropTypes.func.isRequired, onAddAsset: PropTypes.func.isRequired, - onRemoveAsset: PropTypes.func.isRequired, getAsset: PropTypes.func.isRequired, + classNameWrapper: PropTypes.string.isRequired, + editorControl: PropTypes.node.isRequired, value: PropTypes.string, }; constructor(props) { super(props); + editorControl = props.editorControl; this.state = { mode: localStorage.getItem(MODE_STORAGE_KEY) || 'visual' }; } @@ -27,33 +32,42 @@ export default class MarkdownControl extends React.Component { localStorage.setItem(MODE_STORAGE_KEY, mode); }; + processRef = ref => this.ref = ref; + render() { - const { onChange, onAddAsset, onRemoveAsset, getAsset, value } = this.props; + const { + onChange, + onAddAsset, + getAsset, + value, + classNameWrapper, + } = this.props; + const { mode } = this.state; const visualEditor = ( -
+
); const rawEditor = ( -
+
); - return { mode === 'visual' ? visualEditor : rawEditor }; + return mode === 'visual' ? visualEditor : rawEditor; } } diff --git a/src/components/Widgets/Markdown/MarkdownControl/plugins.js b/src/components/EditorWidgets/Markdown/MarkdownControl/plugins.js similarity index 78% rename from src/components/Widgets/Markdown/MarkdownControl/plugins.js rename to src/components/EditorWidgets/Markdown/MarkdownControl/plugins.js index dc39d20c0aa2..5a403ae77e54 100644 --- a/src/components/Widgets/Markdown/MarkdownControl/plugins.js +++ b/src/components/EditorWidgets/Markdown/MarkdownControl/plugins.js @@ -1,7 +1,7 @@ import PropTypes from 'prop-types'; import { Component, Children } from 'react'; import { List, Record, fromJS } from 'immutable'; -import _ from 'lodash'; +import { isFunction } from 'lodash'; const plugins = { editor: List() }; @@ -43,9 +43,9 @@ export function newEditorPlugin(config) { icon: config.icon, fields: fromJS(config.fields), pattern: config.pattern, - fromBlock: _.isFunction(config.fromBlock) ? config.fromBlock.bind(null) : null, - toBlock: _.isFunction(config.toBlock) ? config.toBlock.bind(null) : null, - toPreview: _.isFunction(config.toPreview) ? config.toPreview.bind(null) : config.toBlock.bind(null), + fromBlock: isFunction(config.fromBlock) ? config.fromBlock.bind(null) : null, + toBlock: isFunction(config.toBlock) ? config.toBlock.bind(null) : null, + toPreview: isFunction(config.toPreview) ? config.toPreview.bind(null) : config.toBlock.bind(null), }); diff --git a/src/components/Widgets/Markdown/MarkdownPreview/__tests__/__snapshots__/renderer.spec.js.snap b/src/components/EditorWidgets/Markdown/MarkdownPreview/__tests__/__snapshots__/renderer.spec.js.snap similarity index 100% rename from src/components/Widgets/Markdown/MarkdownPreview/__tests__/__snapshots__/renderer.spec.js.snap rename to src/components/EditorWidgets/Markdown/MarkdownPreview/__tests__/__snapshots__/renderer.spec.js.snap diff --git a/src/components/Widgets/Markdown/MarkdownPreview/__tests__/renderer.spec.js b/src/components/EditorWidgets/Markdown/MarkdownPreview/__tests__/renderer.spec.js similarity index 98% rename from src/components/Widgets/Markdown/MarkdownPreview/__tests__/renderer.spec.js rename to src/components/EditorWidgets/Markdown/MarkdownPreview/__tests__/renderer.spec.js index b4d7c0bfd5d9..dcb8ef856966 100644 --- a/src/components/Widgets/Markdown/MarkdownPreview/__tests__/renderer.spec.js +++ b/src/components/EditorWidgets/Markdown/MarkdownPreview/__tests__/renderer.spec.js @@ -4,7 +4,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { padStart } from 'lodash'; import MarkdownPreview from '../index'; -import { markdownToHtml } from '../../serializers'; +import { markdownToHtml } from 'EditorWidgets/Markdown/serializers'; const parser = markdownToHtml; diff --git a/src/components/Widgets/Markdown/MarkdownPreview/index.js b/src/components/EditorWidgets/Markdown/MarkdownPreview/index.js similarity index 64% rename from src/components/Widgets/Markdown/MarkdownPreview/index.js rename to src/components/EditorWidgets/Markdown/MarkdownPreview/index.js index cfa5b04b01ae..14f9dc1c7e69 100644 --- a/src/components/Widgets/Markdown/MarkdownPreview/index.js +++ b/src/components/EditorWidgets/Markdown/MarkdownPreview/index.js @@ -1,14 +1,13 @@ import PropTypes from 'prop-types'; import React from 'react'; -import { markdownToHtml } from '../serializers'; -import previewStyle from '../../defaultPreviewStyle'; +import { markdownToHtml } from 'EditorWidgets/Markdown/serializers'; const MarkdownPreview = ({ value, getAsset }) => { if (value === null) { return null; } const html = markdownToHtml(value, getAsset); - return
; + return
; }; MarkdownPreview.propTypes = { diff --git a/src/components/Widgets/Markdown/serializers/__tests__/remarkAllowHtmlEntities.spec.js b/src/components/EditorWidgets/Markdown/serializers/__tests__/remarkAllowHtmlEntities.spec.js similarity index 100% rename from src/components/Widgets/Markdown/serializers/__tests__/remarkAllowHtmlEntities.spec.js rename to src/components/EditorWidgets/Markdown/serializers/__tests__/remarkAllowHtmlEntities.spec.js diff --git a/src/components/Widgets/Markdown/serializers/__tests__/remarkAssertParents.spec.js b/src/components/EditorWidgets/Markdown/serializers/__tests__/remarkAssertParents.spec.js similarity index 100% rename from src/components/Widgets/Markdown/serializers/__tests__/remarkAssertParents.spec.js rename to src/components/EditorWidgets/Markdown/serializers/__tests__/remarkAssertParents.spec.js diff --git a/src/components/Widgets/Markdown/serializers/__tests__/remarkEscapeMarkdownEntities.spec.js b/src/components/EditorWidgets/Markdown/serializers/__tests__/remarkEscapeMarkdownEntities.spec.js similarity index 100% rename from src/components/Widgets/Markdown/serializers/__tests__/remarkEscapeMarkdownEntities.spec.js rename to src/components/EditorWidgets/Markdown/serializers/__tests__/remarkEscapeMarkdownEntities.spec.js diff --git a/src/components/Widgets/Markdown/serializers/__tests__/remarkPaddedLinks.spec.js b/src/components/EditorWidgets/Markdown/serializers/__tests__/remarkPaddedLinks.spec.js similarity index 100% rename from src/components/Widgets/Markdown/serializers/__tests__/remarkPaddedLinks.spec.js rename to src/components/EditorWidgets/Markdown/serializers/__tests__/remarkPaddedLinks.spec.js diff --git a/src/components/Widgets/Markdown/serializers/__tests__/remarkStripTrailingBreaks.spec.js b/src/components/EditorWidgets/Markdown/serializers/__tests__/remarkStripTrailingBreaks.spec.js similarity index 100% rename from src/components/Widgets/Markdown/serializers/__tests__/remarkStripTrailingBreaks.spec.js rename to src/components/EditorWidgets/Markdown/serializers/__tests__/remarkStripTrailingBreaks.spec.js diff --git a/src/components/Widgets/Markdown/serializers/__tests__/slate.spec.js b/src/components/EditorWidgets/Markdown/serializers/__tests__/slate.spec.js similarity index 100% rename from src/components/Widgets/Markdown/serializers/__tests__/slate.spec.js rename to src/components/EditorWidgets/Markdown/serializers/__tests__/slate.spec.js diff --git a/src/components/Widgets/Markdown/serializers/index.js b/src/components/EditorWidgets/Markdown/serializers/index.js similarity index 94% rename from src/components/Widgets/Markdown/serializers/index.js rename to src/components/EditorWidgets/Markdown/serializers/index.js index bc36b11ac550..728d567f91ef 100644 --- a/src/components/Widgets/Markdown/serializers/index.js +++ b/src/components/EditorWidgets/Markdown/serializers/index.js @@ -7,6 +7,7 @@ import remarkToRehype from 'remark-rehype'; import rehypeToHtml from 'rehype-stringify'; import htmlToRehype from 'rehype-parse'; import rehypeToRemark from 'rehype-remark'; +import { getEditorComponents } from 'Lib/registry'; import remarkToRehypeShortcodes from './remarkRehypeShortcodes'; import rehypePaperEmoji from './rehypePaperEmoji'; import remarkAssertParents from './remarkAssertParents'; @@ -20,7 +21,6 @@ import remarkEscapeMarkdownEntities from './remarkEscapeMarkdownEntities'; import remarkStripTrailingBreaks from './remarkStripTrailingBreaks'; import remarkAllowHtmlEntities from './remarkAllowHtmlEntities'; import slateToRemark from './slateRemark'; -import registry from '../../../../lib/registry'; /** * This module contains all serializers for the Markdown widget. @@ -76,7 +76,7 @@ export const markdownToRemark = markdown => { const result = unified() .use(remarkSquashReferences) .use(remarkImagesToText) - .use(remarkShortcodes, { plugins: registry.getEditorComponents() }) + .use(remarkShortcodes, { plugins: getEditorComponents() }) .runSync(parsed); return result; @@ -154,7 +154,7 @@ export const markdownToHtml = (markdown, getAsset) => { const mdast = markdownToRemark(markdown); const hast = unified() - .use(remarkToRehypeShortcodes, { plugins: registry.getEditorComponents(), getAsset }) + .use(remarkToRehypeShortcodes, { plugins: getEditorComponents(), getAsset }) .use(remarkToRehype, { allowDangerousHTML: true }) .runSync(mdast); @@ -184,7 +184,7 @@ export const htmlToSlate = html => { .use(remarkAssertParents) .use(remarkPaddedLinks) .use(remarkImagesToText) - .use(remarkShortcodes, { plugins: registry.getEditorComponents() }) + .use(remarkShortcodes, { plugins: getEditorComponents() }) .use(remarkWrapHtml) .use(remarkToSlate) .runSync(mdast); @@ -218,7 +218,7 @@ export const markdownToSlate = markdown => { * trees. */ export const slateToMarkdown = raw => { - const mdast = slateToRemark(raw, { shortcodePlugins: registry.getEditorComponents() }); + const mdast = slateToRemark(raw, { shortcodePlugins: getEditorComponents() }); const markdown = remarkToMarkdown(mdast); return markdown; }; diff --git a/src/components/Widgets/Markdown/serializers/rehypePaperEmoji.js b/src/components/EditorWidgets/Markdown/serializers/rehypePaperEmoji.js similarity index 100% rename from src/components/Widgets/Markdown/serializers/rehypePaperEmoji.js rename to src/components/EditorWidgets/Markdown/serializers/rehypePaperEmoji.js diff --git a/src/components/Widgets/Markdown/serializers/remarkAllowHtmlEntities.js b/src/components/EditorWidgets/Markdown/serializers/remarkAllowHtmlEntities.js similarity index 100% rename from src/components/Widgets/Markdown/serializers/remarkAllowHtmlEntities.js rename to src/components/EditorWidgets/Markdown/serializers/remarkAllowHtmlEntities.js diff --git a/src/components/Widgets/Markdown/serializers/remarkAssertParents.js b/src/components/EditorWidgets/Markdown/serializers/remarkAssertParents.js similarity index 100% rename from src/components/Widgets/Markdown/serializers/remarkAssertParents.js rename to src/components/EditorWidgets/Markdown/serializers/remarkAssertParents.js diff --git a/src/components/Widgets/Markdown/serializers/remarkEscapeMarkdownEntities.js b/src/components/EditorWidgets/Markdown/serializers/remarkEscapeMarkdownEntities.js similarity index 99% rename from src/components/Widgets/Markdown/serializers/remarkEscapeMarkdownEntities.js rename to src/components/EditorWidgets/Markdown/serializers/remarkEscapeMarkdownEntities.js index 344f0f609633..6a40c617e527 100644 --- a/src/components/Widgets/Markdown/serializers/remarkEscapeMarkdownEntities.js +++ b/src/components/EditorWidgets/Markdown/serializers/remarkEscapeMarkdownEntities.js @@ -1,5 +1,5 @@ import { has, flow, partial, flatMap, flatten, map } from 'lodash'; -import { joinPatternSegments, combinePatterns, replaceWhen } from '../../../../lib/regexHelper'; +import { joinPatternSegments, combinePatterns, replaceWhen } from 'Lib/regexHelper'; /** * Reusable regular expressions segments. diff --git a/src/components/Widgets/Markdown/serializers/remarkImagesToText.js b/src/components/EditorWidgets/Markdown/serializers/remarkImagesToText.js similarity index 100% rename from src/components/Widgets/Markdown/serializers/remarkImagesToText.js rename to src/components/EditorWidgets/Markdown/serializers/remarkImagesToText.js diff --git a/src/components/Widgets/Markdown/serializers/remarkPaddedLinks.js b/src/components/EditorWidgets/Markdown/serializers/remarkPaddedLinks.js similarity index 100% rename from src/components/Widgets/Markdown/serializers/remarkPaddedLinks.js rename to src/components/EditorWidgets/Markdown/serializers/remarkPaddedLinks.js diff --git a/src/components/Widgets/Markdown/serializers/remarkRehypeShortcodes.js b/src/components/EditorWidgets/Markdown/serializers/remarkRehypeShortcodes.js similarity index 100% rename from src/components/Widgets/Markdown/serializers/remarkRehypeShortcodes.js rename to src/components/EditorWidgets/Markdown/serializers/remarkRehypeShortcodes.js diff --git a/src/components/Widgets/Markdown/serializers/remarkShortcodes.js b/src/components/EditorWidgets/Markdown/serializers/remarkShortcodes.js similarity index 100% rename from src/components/Widgets/Markdown/serializers/remarkShortcodes.js rename to src/components/EditorWidgets/Markdown/serializers/remarkShortcodes.js diff --git a/src/components/Widgets/Markdown/serializers/remarkSlate.js b/src/components/EditorWidgets/Markdown/serializers/remarkSlate.js similarity index 100% rename from src/components/Widgets/Markdown/serializers/remarkSlate.js rename to src/components/EditorWidgets/Markdown/serializers/remarkSlate.js diff --git a/src/components/Widgets/Markdown/serializers/remarkSquashReferences.js b/src/components/EditorWidgets/Markdown/serializers/remarkSquashReferences.js similarity index 100% rename from src/components/Widgets/Markdown/serializers/remarkSquashReferences.js rename to src/components/EditorWidgets/Markdown/serializers/remarkSquashReferences.js diff --git a/src/components/Widgets/Markdown/serializers/remarkStripTrailingBreaks.js b/src/components/EditorWidgets/Markdown/serializers/remarkStripTrailingBreaks.js similarity index 100% rename from src/components/Widgets/Markdown/serializers/remarkStripTrailingBreaks.js rename to src/components/EditorWidgets/Markdown/serializers/remarkStripTrailingBreaks.js diff --git a/src/components/Widgets/Markdown/serializers/remarkWrapHtml.js b/src/components/EditorWidgets/Markdown/serializers/remarkWrapHtml.js similarity index 100% rename from src/components/Widgets/Markdown/serializers/remarkWrapHtml.js rename to src/components/EditorWidgets/Markdown/serializers/remarkWrapHtml.js diff --git a/src/components/Widgets/Markdown/serializers/slateRemark.js b/src/components/EditorWidgets/Markdown/serializers/slateRemark.js similarity index 99% rename from src/components/Widgets/Markdown/serializers/slateRemark.js rename to src/components/EditorWidgets/Markdown/serializers/slateRemark.js index cd6b83c9bcbe..d836b7cde4b9 100644 --- a/src/components/Widgets/Markdown/serializers/slateRemark.js +++ b/src/components/EditorWidgets/Markdown/serializers/slateRemark.js @@ -1,4 +1,4 @@ -import { get, isEmpty, concat, without, flatten, flatMap, initial, last, difference, reverse, sortBy } from 'lodash'; +import { get, isEmpty, without, flatMap, last, sortBy } from 'lodash'; import u from 'unist-builder'; /** diff --git a/src/components/Widgets/NumberControl.js b/src/components/EditorWidgets/Number/NumberControl.js similarity index 58% rename from src/components/Widgets/NumberControl.js rename to src/components/EditorWidgets/Number/NumberControl.js index d8c2fa282fe6..c47938f7ff77 100644 --- a/src/components/Widgets/NumberControl.js +++ b/src/components/EditorWidgets/Number/NumberControl.js @@ -1,7 +1,20 @@ -import PropTypes from 'prop-types'; import React from 'react'; +import PropTypes from 'prop-types'; export default class NumberControl extends React.Component { + static propTypes = { + onChange: PropTypes.func.isRequired, + classNameWrapper: PropTypes.string.isRequired, + setActiveStyle: PropTypes.func.isRequired, + setInactiveStyle: PropTypes.func.isRequired, + value: PropTypes.node, + forID: PropTypes.string, + valueType: PropTypes.string, + step: PropTypes.number, + min: PropTypes.number, + max: PropTypes.number, + }; + handleChange = (e) => { const valueType = this.props.field.get('valueType'); const { onChange } = this.props; @@ -15,13 +28,16 @@ export default class NumberControl extends React.Component { }; render() { - const { field, value, forID } = this.props; + const { field, value, classNameWrapper, forID, setActiveStyle, setInactiveStyle } = this.props; const min = field.get('min', ''); const max = field.get('max', ''); const step = field.get('step', field.get('valueType') === 'int' ? 1 : ''); return ; } } - -NumberControl.propTypes = { - onChange: PropTypes.func.isRequired, - value: PropTypes.node, - forID: PropTypes.string, - valueType: PropTypes.string, - step: PropTypes.number, - min: PropTypes.number, - max: PropTypes.number, -}; diff --git a/src/components/Widgets/NumberPreview.js b/src/components/EditorWidgets/Number/NumberPreview.js similarity index 63% rename from src/components/Widgets/NumberPreview.js rename to src/components/EditorWidgets/Number/NumberPreview.js index b90e3fbb5608..e954d3ff722e 100644 --- a/src/components/Widgets/NumberPreview.js +++ b/src/components/EditorWidgets/Number/NumberPreview.js @@ -1,9 +1,8 @@ import PropTypes from 'prop-types'; import React from 'react'; -import previewStyle from './defaultPreviewStyle'; export default function NumberPreview({ value }) { - return
{value}
; + return
{value}
; } NumberPreview.propTypes = { diff --git a/src/components/EditorWidgets/Object/Object.css b/src/components/EditorWidgets/Object/Object.css new file mode 100644 index 000000000000..3adba5d27da7 --- /dev/null +++ b/src/components/EditorWidgets/Object/Object.css @@ -0,0 +1,3 @@ +.nc-objectControl-root { + padding: 14px; +} diff --git a/src/components/EditorWidgets/Object/ObjectControl.js b/src/components/EditorWidgets/Object/ObjectControl.js new file mode 100644 index 000000000000..140fafd7d113 --- /dev/null +++ b/src/components/EditorWidgets/Object/ObjectControl.js @@ -0,0 +1,102 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { Map } from 'immutable'; +import { partial } from 'lodash'; +import c from 'classnames'; +import { resolveWidget } from 'Lib/registry'; +import EditorControl from 'Editor/EditorControlPane/EditorControl'; + +const TopBar = ({ collapsed, onCollapseToggle }) => +
+
+ + {itemsCount} {listLabel} +
+
; + + +export default class ObjectControl extends Component { + static propTypes = { + onChangeObject: PropTypes.func.isRequired, + onOpenMediaLibrary: PropTypes.func.isRequired, + mediaPaths: ImmutablePropTypes.map.isRequired, + onAddAsset: PropTypes.func.isRequired, + onRemoveInsertedMedia: PropTypes.func.isRequired, + getAsset: PropTypes.func.isRequired, + value: PropTypes.oneOfType([ + PropTypes.node, + PropTypes.object, + PropTypes.bool, + ]), + field: PropTypes.object, + forID: PropTypes.string, + classNameWrapper: PropTypes.string.isRequired, + }; + + constructor(props) { + super(props); + this.state = { + collapsed: false, + }; + } + + /* + * 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; + } + + controlFor(field) { + const { + onAddAsset, + onOpenMediaLibrary, + mediaPaths, + onRemoveInsertedMedia, + getAsset, + value, + onChangeObject, + } = this.props; + + if (field.get('widget') === 'hidden') { + return null; + } + const widgetName = field.get('widget') || 'string'; + const widget = resolveWidget(widgetName); + const fieldName = field.get('name'); + const fieldValue = value && Map.isMap(value) ? value.get(fieldName) : value; + + return ( + + ); + } + + render() { + const { field, forID, classNameWrapper } = this.props; + const multiFields = field.get('fields'); + const singleField = field.get('field'); + + if (multiFields) { + return (
+ {multiFields.map(f => this.controlFor(f))} +
); + } else if (singleField) { + return this.controlFor(singleField); + } + + return

No field(s) defined for this widget

; + } +} diff --git a/src/components/Widgets/ObjectPreview.js b/src/components/EditorWidgets/Object/ObjectPreview.js similarity index 55% rename from src/components/Widgets/ObjectPreview.js rename to src/components/EditorWidgets/Object/ObjectPreview.js index bd713072d701..c849d428934f 100644 --- a/src/components/Widgets/ObjectPreview.js +++ b/src/components/EditorWidgets/Object/ObjectPreview.js @@ -1,10 +1,8 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import { resolveWidget } from '../Widgets'; -import previewStyle from './defaultPreviewStyle'; const ObjectPreview = ({ field }) => ( -
{(field && field.get('fields')) || null}
+
{(field && field.get('fields')) || null}
); ObjectPreview.propTypes = { diff --git a/src/components/EditorWidgets/Relation/ReactAutosuggest.css b/src/components/EditorWidgets/Relation/ReactAutosuggest.css new file mode 100644 index 000000000000..9bed23f760dd --- /dev/null +++ b/src/components/EditorWidgets/Relation/ReactAutosuggest.css @@ -0,0 +1,35 @@ +.react-autosuggest__container { + position: relative; +} + +.react-autosuggest__suggestions-container { + display: none; +} + +.react-autosuggest__container--open .react-autosuggest__suggestions-container { + @apply(--dropdownList); + position: absolute; + display: block; + top: 51px; + width: 100%; + z-index: 2; +} + +.react-autosuggest__suggestion { + @apply(--dropdownItem); +} + +.react-autosuggest__suggestions-list { + margin: 0; + padding: 0; + list-style-type: none; +} + +.react-autosuggest__suggestion { + cursor: pointer; + padding: 10px 20px; +} + +.react-autosuggest__suggestion--focused { + background-color: #ddd; +} diff --git a/src/components/EditorWidgets/Relation/Relation.css b/src/components/EditorWidgets/Relation/Relation.css new file mode 100644 index 000000000000..2556f6737ffa --- /dev/null +++ b/src/components/EditorWidgets/Relation/Relation.css @@ -0,0 +1 @@ +@import "./ReactAutosuggest.css"; diff --git a/src/components/Widgets/RelationControl.js b/src/components/EditorWidgets/Relation/RelationControl.js similarity index 85% rename from src/components/Widgets/RelationControl.js rename to src/components/EditorWidgets/Relation/RelationControl.js index 0b7a15c99181..589892b7fd6c 100644 --- a/src/components/Widgets/RelationControl.js +++ b/src/components/EditorWidgets/Relation/RelationControl.js @@ -5,9 +5,8 @@ import uuid from 'uuid/v4'; import { Map } from 'immutable'; import { connect } from 'react-redux'; import { debounce } from 'lodash'; -import { Loader } from '../../components/UI/index'; -import { query, clearSearch } from '../../actions/search'; - +import { query, clearSearch } from 'Actions/search'; +import { Loader } from 'UI'; function escapeRegexCharacters(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -26,6 +25,9 @@ class RelationControl extends Component { PropTypes.array, PropTypes.object, ]), + classNameWrapper: PropTypes.string.isRequired, + setActiveStyle: PropTypes.func.isRequired, + setInactiveStyle: PropTypes.func.isRequired, }; constructor(props, ctx) { @@ -89,13 +91,24 @@ class RelationControl extends Component { }; render() { - const { value, isFetching, forID, queryHits } = this.props; + const { + value, + isFetching, + forID, + queryHits, + classNameWrapper, + setActiveStyle, + setInactiveStyle + } = this.props; const inputProps = { placeholder: '', value: value || '', onChange: this.onChange, id: forID, + className: classNameWrapper, + onFocus: setActiveStyle, + onBlur: setInactiveStyle, }; const suggestions = (queryHits.get) ? queryHits.get(this.controlID, []) : []; @@ -110,6 +123,7 @@ class RelationControl extends Component { getSuggestionValue={this.getSuggestionValue} renderSuggestion={this.renderSuggestion} inputProps={inputProps} + focusInputOnSuggestionClick={false} />
@@ -117,10 +131,11 @@ class RelationControl extends Component { } } -function mapStateToProps(state) { +function mapStateToProps(state, ownProps) { + const { className } = ownProps; const isFetching = state.search.get('isFetching'); const queryHits = state.search.get('queryHits'); - return { isFetching, queryHits }; + return { isFetching, queryHits, className }; } export default connect( diff --git a/src/components/Widgets/RelationPreview.js b/src/components/EditorWidgets/Relation/RelationPreview.js similarity index 63% rename from src/components/Widgets/RelationPreview.js rename to src/components/EditorWidgets/Relation/RelationPreview.js index 1924708dc48a..7ee2fcac906e 100644 --- a/src/components/Widgets/RelationPreview.js +++ b/src/components/EditorWidgets/Relation/RelationPreview.js @@ -1,9 +1,8 @@ import PropTypes from 'prop-types'; import React from 'react'; -import previewStyle from './defaultPreviewStyle'; export default function RelationPreview({ value }) { - return
{ value }
; + return
{ value }
; } RelationPreview.propTypes = { diff --git a/src/components/EditorWidgets/Select/SelectControl.js b/src/components/EditorWidgets/Select/SelectControl.js new file mode 100644 index 000000000000..04a2aafbbad9 --- /dev/null +++ b/src/components/EditorWidgets/Select/SelectControl.js @@ -0,0 +1,64 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { isMap } from 'immutable'; + +export default class SelectControl extends React.Component { + static propTypes = { + onChange: PropTypes.func.isRequired, + value: PropTypes.node, + forID: PropTypes.string.isRequired, + classNameWrapper: PropTypes.string.isRequired, + setActiveStyle: PropTypes.func.isRequired, + setInactiveStyle: PropTypes.func.isRequired, + field: ImmutablePropTypes.contains({ + options: ImmutablePropTypes.listOf(PropTypes.oneOfType([ + PropTypes.string, + ImmutablePropTypes.contains({ + label: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + }), + ])).isRequired, + }), + }; + + handleChange = (e) => { + this.props.onChange(e.target.value); + }; + + render() { + const { field, value, forID, classNameWrapper, setActiveStyle, setInactiveStyle } = this.props; + const fieldOptions = field.get('options'); + + if (!fieldOptions) { + return
Error rendering select control for {field.get('name')}: No options
; + } + + const options = [ + ...(field.get('default', false) ? [] : [{ label: '', value: '' }]), + ...fieldOptions.map((option) => { + if (typeof option === 'string') { + return { label: option, value: option }; + } + return isMap(option) ? option.toJS() : option; + }), + ]; + + return ( + + ); + } +} diff --git a/src/components/Widgets/SelectPreview.js b/src/components/EditorWidgets/Select/SelectPreview.js similarity index 58% rename from src/components/Widgets/SelectPreview.js rename to src/components/EditorWidgets/Select/SelectPreview.js index 1eb2a59cd0bf..9517e240ec07 100644 --- a/src/components/Widgets/SelectPreview.js +++ b/src/components/EditorWidgets/Select/SelectPreview.js @@ -1,9 +1,8 @@ import PropTypes from 'prop-types'; import React from 'react'; -import previewStyle from './defaultPreviewStyle'; export default function SelectPreview({ value }) { - return
{value ? value.toString() : null}
; + return
{value ? value.toString() : null}
; } SelectPreview.propTypes = { diff --git a/src/components/EditorWidgets/String/StringControl.js b/src/components/EditorWidgets/String/StringControl.js new file mode 100644 index 000000000000..3c13a4cda571 --- /dev/null +++ b/src/components/EditorWidgets/String/StringControl.js @@ -0,0 +1,36 @@ +import PropTypes from 'prop-types'; +import React from 'react'; + +export default class StringControl extends React.Component { + static propTypes = { + onChange: PropTypes.func.isRequired, + forID: PropTypes.string, + value: PropTypes.node, + classNameWrapper: PropTypes.string.isRequired, + setActiveStyle: PropTypes.func.isRequired, + setInactiveStyle: PropTypes.func.isRequired, + }; + + render() { + const { + forID, + value, + onChange, + classNameWrapper, + setActiveStyle, + setInactiveStyle + } = this.props; + + return ( + onChange(e.target.value)} + onFocus={setActiveStyle} + onBlur={setInactiveStyle} + /> + ); + } +} diff --git a/src/components/Widgets/StringPreview.js b/src/components/EditorWidgets/String/StringPreview.js similarity index 63% rename from src/components/Widgets/StringPreview.js rename to src/components/EditorWidgets/String/StringPreview.js index b1d5817e9e01..9f9e0ea48297 100644 --- a/src/components/Widgets/StringPreview.js +++ b/src/components/EditorWidgets/String/StringPreview.js @@ -1,9 +1,8 @@ import PropTypes from 'prop-types'; import React from 'react'; -import previewStyle from './defaultPreviewStyle'; export default function StringPreview({ value }) { - return
{ value }
; + return
{ value }
; } StringPreview.propTypes = { diff --git a/src/components/EditorWidgets/Text/TextControl.js b/src/components/EditorWidgets/Text/TextControl.js new file mode 100644 index 000000000000..e09ca5cb6bff --- /dev/null +++ b/src/components/EditorWidgets/Text/TextControl.js @@ -0,0 +1,48 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Textarea from 'react-textarea-autosize'; + +export default class TextControl extends React.Component { + static propTypes = { + onChange: PropTypes.func.isRequired, + forID: PropTypes.string, + value: PropTypes.node, + classNameWrapper: PropTypes.string.isRequired, + setActiveStyle: PropTypes.func.isRequired, + setInactiveStyle: PropTypes.func.isRequired, + }; + + /** + * Always update to ensure `react-textarea-autosize` properly calculates + * height. Certain situations, such as this widget being nested in a list + * item that gets rearranged, can leave the textarea in a minimal height + * state. Always updating should generally be low cost, but this should be + * optimized in the future. + */ + shouldComponentUpdate(nextProps) { + return true; + } + + render() { + const { + forID, + value = '', + onChange, + classNameWrapper, + setActiveStyle, + setInactiveStyle, + } = this.props; + + return ( +