From 6b45a46a395727d416d4ea5373cf2e526393af15 Mon Sep 17 00:00:00 2001 From: Shawn Erquhart Date: Mon, 14 Aug 2017 09:00:47 -0400 Subject: [PATCH] add media library * rebase editorial workflow pull requests when behind * fix async/await transpilation * add media library pagination * switch media library to grid layout * ensure that only cms branches can be force updated --- .babelrc | 12 +- example/config.yml | 4 +- package.json | 5 +- src/actions/editorialWorkflow.js | 1 + src/actions/mediaLibrary.js | 183 ++++++++++ src/backends/backend.js | 16 + src/backends/git-gateway/API.js | 6 + src/backends/github/API.js | 325 +++++++++++++---- src/backends/github/implementation.js | 22 ++ src/backends/test-repo/implementation.js | 31 +- src/components/AppHeader/AppHeader.css | 17 +- src/components/AppHeader/AppHeader.js | 32 +- src/components/ControlPanel/ControlPane.css | 34 +- src/components/ControlPanel/ControlPane.js | 16 +- src/components/EntryEditor/EntryEditor.css | 4 +- src/components/EntryEditor/EntryEditor.js | 6 + src/components/MediaLibrary/MediaLibrary.css | 98 ++++++ src/components/MediaLibrary/MediaLibrary.js | 332 ++++++++++++++++++ .../MediaLibrary/MediaLibraryFooter.css | 25 ++ .../MediaLibrary/MediaLibraryFooter.js | 64 ++++ src/components/UI/Dialog/Dialog.css | 52 +++ src/components/UI/Dialog/FocusTrap.js | 16 + src/components/UI/Dialog/index.js | 65 ++++ src/components/UI/loader/Loader.js | 8 +- src/components/UI/theme.css | 23 +- src/components/Widgets/ControlHOC.js | 32 +- src/components/Widgets/FileControl.js | 119 +++---- src/components/Widgets/ImageControl.js | 125 +++---- src/components/Widgets/ListControl.js | 17 +- .../Toolbar/ToolbarPluginForm.js | 21 +- .../Toolbar/ToolbarPluginFormControl.js | 7 +- .../MarkdownControl/VisualEditor/index.css | 2 +- src/components/Widgets/ObjectControl.js | 49 ++- src/containers/App.js | 8 + src/containers/EntryPage.js | 12 +- src/index.css | 6 +- .../providers/assetStore/implementation.js | 88 +++-- src/lib/urlHelper.js | 7 + src/reducers/entryDraft.js | 6 +- src/reducers/index.js | 2 + src/reducers/mediaLibrary.js | 93 +++++ src/valueObjects/AssetProxy.js | 5 +- yarn.lock | 20 ++ 43 files changed, 1658 insertions(+), 358 deletions(-) create mode 100644 src/actions/mediaLibrary.js create mode 100644 src/components/MediaLibrary/MediaLibrary.css create mode 100644 src/components/MediaLibrary/MediaLibrary.js create mode 100644 src/components/MediaLibrary/MediaLibraryFooter.css create mode 100644 src/components/MediaLibrary/MediaLibraryFooter.js create mode 100644 src/components/UI/Dialog/Dialog.css create mode 100644 src/components/UI/Dialog/FocusTrap.js create mode 100644 src/components/UI/Dialog/index.js create mode 100644 src/reducers/mediaLibrary.js diff --git a/.babelrc b/.babelrc index 49c40475b1ca..6ec82922c801 100644 --- a/.babelrc +++ b/.babelrc @@ -1,11 +1,21 @@ { - "presets": [["env", { "modules": false }], "stage-1", "react"], + "presets": [ + ["env", { + "modules": false + }], + "stage-1", + "react" + ], "plugins": [ "react-hot-loader/babel", "lodash", ["babel-plugin-transform-builtin-extend", { "globals": ["Error"] }], + ["transform-runtime", { + "useBuiltIns": true, + "useESModules": true + }] ], "env": { "test": { diff --git a/example/config.yml b/example/config.yml index c1866961b0f7..a9d2cff481d6 100644 --- a/example/config.yml +++ b/example/config.yml @@ -1,5 +1,7 @@ backend: - name: test-repo + name: github + repo: netlify/netlify-cms + branch: pr-554-backend media_folder: "assets/uploads" diff --git a/package.json b/package.json index 7dc18c504f50..e50a25ea99b3 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "babel-loader": "^7.0.0", "babel-plugin-lodash": "^3.2.0", "babel-plugin-transform-builtin-extend": "^1.1.0", + "babel-plugin-transform-runtime": "^6.23.0", "babel-preset-env": "^1.6.0", "babel-preset-react": "^6.23.0", "babel-preset-stage-1": "^6.22.0", @@ -108,8 +109,8 @@ "postcss-cssnext": "^3.0.2", "postcss-import": "^11.0.0", "postcss-loader": "^2.0.7", - "react-test-renderer": "^16.0.0", "react-hot-loader": "^3.0.0-beta.7", + "react-test-renderer": "^16.0.0", "style-loader": "^0.18.2", "stylefmt": "^4.3.1", "stylelint": "^7.9.0", @@ -126,6 +127,7 @@ "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.11", "gray-matter": "^3.0.6", @@ -184,6 +186,7 @@ "unified": "^6.1.4", "unist-builder": "^1.0.2", "unist-util-visit-parents": "^1.1.1", + "url": "^0.11.0", "uuid": "^3.1.0" }, "optionalDependencies": { diff --git a/src/actions/editorialWorkflow.js b/src/actions/editorialWorkflow.js index 9b3d0e3210ff..550fc47fdb83 100644 --- a/src/actions/editorialWorkflow.js +++ b/src/actions/editorialWorkflow.js @@ -121,6 +121,7 @@ function unpublishedEntryPersistedFail(error, transactionID) { type: UNPUBLISHED_ENTRY_PERSIST_FAILURE, payload: { error }, optimist: { type: REVERT, id: transactionID }, + error, }; } diff --git a/src/actions/mediaLibrary.js b/src/actions/mediaLibrary.js new file mode 100644 index 000000000000..0045f9abcd76 --- /dev/null +++ b/src/actions/mediaLibrary.js @@ -0,0 +1,183 @@ +import { actions as notifActions } from 'redux-notifications'; +import { currentBackend } from '../backends/backend'; +import { createAssetProxy } from '../valueObjects/AssetProxy'; +import { getAsset, selectIntegration } from '../reducers'; +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_LOAD_REQUEST = 'MEDIA_LOAD_REQUEST'; +export const MEDIA_LOAD_SUCCESS = 'MEDIA_LOAD_SUCCESS'; +export const MEDIA_LOAD_FAILURE = 'MEDIA_LOAD_FAILURE'; +export const MEDIA_PERSIST_REQUEST = 'MEDIA_PERSIST_REQUEST'; +export const MEDIA_PERSIST_SUCCESS = 'MEDIA_PERSIST_SUCCESS'; +export const MEDIA_PERSIST_FAILURE = 'MEDIA_PERSIST_FAILURE'; +export const MEDIA_DELETE_REQUEST = 'MEDIA_DELETE_REQUEST'; +export const MEDIA_DELETE_SUCCESS = 'MEDIA_DELETE_SUCCESS'; +export const MEDIA_DELETE_FAILURE = 'MEDIA_DELETE_FAILURE'; + +export function openMediaLibrary(payload) { + return { type: MEDIA_LIBRARY_OPEN, payload }; +} + +export function closeMediaLibrary() { + return { type: MEDIA_LIBRARY_CLOSE }; +} + +export function insertMedia(mediaPath) { + return { type: MEDIA_INSERT, payload: { mediaPath } }; +} + +export function loadMedia(opts = {}) { + const { delay = 0, query = '', page = 1 } = opts; + return async (dispatch, getState) => { + const state = getState(); + const backend = currentBackend(state.config); + const integration = selectIntegration(state, null, 'assetStore'); + if (integration) { + const provider = getIntegrationProvider(state.integrations, backend.getToken, integration); + dispatch(mediaLoading(page)); + try { + const files = await provider.retrieve(query, page); + const mediaLoadedOpts = { + page, + canPaginate: true, + dynamicSearch: true, + dynamicSearchQuery: query + }; + return dispatch(mediaLoaded(files, mediaLoadedOpts)); + } + catch(error) { + return dispatch(mediaLoadFailed()); + } + } + dispatch(mediaLoading(page)); + return new Promise(resolve => { + setTimeout(() => resolve( + backend.getMedia() + .then(files => dispatch(mediaLoaded(files))) + .catch((error) => dispatch(error.status === 404 ? mediaLoaded() : mediaLoadFailed())) + )); + }, delay); + }; +} + +export function persistMedia(file, privateUpload) { + return async (dispatch, getState) => { + const state = getState(); + const backend = currentBackend(state.config); + const integration = selectIntegration(state, null, 'assetStore'); + + dispatch(mediaPersisting()); + + try { + const assetProxy = await createAssetProxy(file.name.toLowerCase(), file, false, privateUpload); + dispatch(addAsset(assetProxy)); + if (!integration) { + const asset = await backend.persistMedia(assetProxy); + return dispatch(mediaPersisted(asset)); + } + return dispatch(mediaPersisted(assetProxy.asset)); + } + catch(error) { + console.error(error); + dispatch(notifSend({ + message: `Failed to persist media: ${ error }`, + kind: 'danger', + dismissAfter: 8000, + })); + return dispatch(mediaPersistFailed()); + } + }; +} + +export function deleteMedia(file) { + return (dispatch, getState) => { + const state = getState(); + const backend = currentBackend(state.config); + const integration = selectIntegration(state, null, 'assetStore'); + if (integration) { + const provider = getIntegrationProvider(state.integrations, backend.getToken, integration); + dispatch(mediaDeleting()); + return provider.delete(file.id) + .then(() => { + return dispatch(mediaDeleted(file)); + }) + .catch(error => { + console.error(error); + dispatch(notifSend({ + message: `Failed to delete media: ${ error.message }`, + kind: 'danger', + dismissAfter: 8000, + })); + return dispatch(mediaDeleteFailed()); + }); + } + dispatch(mediaDeleting()); + return backend.deleteMedia(file.path) + .then(() => { + return dispatch(mediaDeleted(file)); + }) + .catch(error => { + console.error(error); + dispatch(notifSend({ + message: `Failed to delete media: ${ error.message }`, + kind: 'danger', + dismissAfter: 8000, + })); + return dispatch(mediaDeleteFailed()); + }); + }; +} + +export function mediaLoading(page) { + return { + type: MEDIA_LOAD_REQUEST, + payload: { page }, + } +} + +export function mediaLoaded(files, opts = {}) { + return { + type: MEDIA_LOAD_SUCCESS, + payload: { files, ...opts } + }; +} + +export function mediaLoadFailed(error) { + return { type: MEDIA_LOAD_FAILURE }; +} + +export function mediaPersisting() { + return { type: MEDIA_PERSIST_REQUEST }; +} + +export function mediaPersisted(asset) { + return { + type: MEDIA_PERSIST_SUCCESS, + payload: { file: asset }, + }; +} + +export function mediaPersistFailed(error) { + return { type: MEDIA_PERSIST_FAILURE }; +} + +export function mediaDeleting() { + return { type: MEDIA_DELETE_REQUEST }; +} + +export function mediaDeleted(file) { + return { + type: MEDIA_DELETE_SUCCESS, + payload: { file }, + }; +} + +export function mediaDeleteFailed(error) { + return { type: MEDIA_DELETE_FAILURE }; +} diff --git a/src/backends/backend.js b/src/backends/backend.js index d1f72ada37b6..e08e2af672a0 100644 --- a/src/backends/backend.js +++ b/src/backends/backend.js @@ -148,6 +148,10 @@ class Backend { ); } + getMedia() { + return this.implementation.getMedia(); + } + entryWithFormat(collectionOrEntity) { return (entry) => { const format = resolveFormat(collectionOrEntity, entry); @@ -244,6 +248,13 @@ class Backend { }); } + persistMedia(file) { + const options = { + commitMessage: `Upload ${file.path}`, + }; + return this.implementation.persistMedia(file, options); + } + deleteEntry(config, collection, slug) { const path = selectEntryPath(collection, slug); @@ -255,6 +266,11 @@ class Backend { return this.implementation.deleteFile(path, commitMessage); } + deleteMedia(path) { + const commitMessage = `Delete ${path}`; + return this.implementation.deleteFile(path, commitMessage); + } + persistUnpublishedEntry(config, collection, entryDraft, MediaFiles) { return this.persistEntry(config, collection, entryDraft, MediaFiles, { unpublished: true }); } diff --git a/src/backends/git-gateway/API.js b/src/backends/git-gateway/API.js index d3b9e439d736..3f75c1cc0b46 100644 --- a/src/backends/git-gateway/API.js +++ b/src/backends/git-gateway/API.js @@ -1,4 +1,5 @@ import GithubAPI from "../github/API"; +import { APIError } from "../../valueObjects/errors"; export default class API extends GithubAPI { constructor(config) { @@ -44,15 +45,20 @@ export default class API extends GithubAPI { request(path, options = {}) { const url = this.urlFor(path, options); + let responseStatus; return this.getRequestHeaders(options.headers || {}) .then(headers => fetch(url, { ...options, headers })) .then((response) => { + responseStatus = response.status; const contentType = response.headers.get("Content-Type"); if (contentType && contentType.match(/json/)) { return this.parseJsonResponse(response); } return response.text(); + }) + .catch(error => { + throw new APIError(error.message, responseStatus, 'Git Gateway'); }); } diff --git a/src/backends/github/API.js b/src/backends/github/API.js index b162a4013156..b9563c4898e9 100644 --- a/src/backends/github/API.js +++ b/src/backends/github/API.js @@ -1,11 +1,13 @@ import LocalForage from "localforage"; import { Base64 } from "js-base64"; -import _ from "lodash"; +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"; +const CMS_BRANCH_PREFIX = 'cms/'; + export default class API { constructor(config) { this.api_root = config.api_root || "https://api.github.com"; @@ -83,6 +85,10 @@ export default class API { }); } + generateBranchName(basename) { + return `${CMS_BRANCH_PREFIX}${basename}`; + } + checkMetadataRef() { return this.request(`${ this.repoURL }/git/refs/meta/_netlify_cms?${ Date.now() }`, { cache: "no-store", @@ -249,7 +255,7 @@ export default class API { persistFiles(entry, mediaFiles, options) { const uploadPromises = []; - const files = mediaFiles.concat(entry); + const files = entry ? mediaFiles.concat(entry) : mediaFiles; files.forEach((file) => { if (file.uploaded) { return; } @@ -273,43 +279,51 @@ export default class API { deleteFile(path, message, options={}) { const branch = options.branch || this.branch; + const pathArray = path.split('/'); + const filename = last(pathArray); + const directory = initial(pathArray).join('/'); + const fileDataPath = encodeURIComponent(directory); + const fileDataURL = `${this.repoURL}/git/trees/${branch}:${fileDataPath}`; const fileURL = `${ this.repoURL }/contents/${ path }`; - // We need to request the file first to get the SHA - return this.request(fileURL, { - params: { ref: branch }, - cache: "no-store", - }).then(({ sha }) => this.request(fileURL, { - method: "DELETE", - params: { - sha, - message, - branch, - }, - })); + + /** + * We need to request the tree first to get the SHA. We use extended SHA-1 + * syntax (:) to get a blob from a tree without having to recurse + * through the tree. + */ + return this.request(fileDataURL, { cache: 'no-store' }) + .then(resp => { + const { sha } = resp.tree.find(file => file.path === filename); + const opts = { method: 'DELETE', params: { sha, message, branch } }; + return this.request(fileURL, opts); + }); } editorialWorkflowGit(fileTree, entry, filesList, options) { const contentKey = entry.slug; - const branchName = `cms/${ contentKey }`; + const branchName = this.generateBranchName(contentKey); const unpublished = options.unpublished || false; if (!unpublished) { // Open new editorial review workflow for this entry - Create new metadata and commit to new branch` - const contentKey = entry.slug; - const branchName = `cms/${ contentKey }`; + let prResponse; return this.getBranch() .then(branchData => this.updateTree(branchData.commit.sha, "/", fileTree)) .then(changeTree => this.commit(options.commitMessage, changeTree)) .then(commitResponse => this.createBranch(branchName, commitResponse.sha)) .then(branchResponse => this.createPR(options.commitMessage, branchName)) - .then(prResponse => this.user().then(user => user.name ? user.name : user.login) - .then(username => this.storeMetadata(contentKey, { + .then(pr => { + prResponse = pr; + return this.user(); + }) + .then(user => { + return this.storeMetadata(contentKey, { type: "PR", pr: { number: prResponse.number, head: prResponse.head && prResponse.head.sha, }, - user: username, + user: user.name || user.login, status: status.first(), branch: branchName, collection: options.collectionName, @@ -323,42 +337,174 @@ export default class API { files: filesList, }, timeStamp: new Date().toISOString(), - } - ))); + }); + }); } else { // Entry is already on editorial review workflow - just update metadata and commit to existing branch + let newHead; return this.getBranch(branchName) - .then(branchData => this.updateTree(branchData.commit.sha, "/", fileTree)) - .then(changeTree => this.commit(options.commitMessage, changeTree)) - .then((response) => { - const contentKey = entry.slug; - const branchName = `cms/${ contentKey }`; - return this.user().then(user => user.name ? user.name : user.login) - .then(username => this.retrieveMetadata(contentKey)) - .then((metadata) => { - let files = metadata.objects && metadata.objects.files || []; - files = files.concat(filesList); - const updatedPR = metadata.pr; - updatedPR.head = response.sha; - return { - ...metadata, - pr: updatedPR, - title: options.parsedData && options.parsedData.title, - description: options.parsedData && options.parsedData.description, - objects: { - entry: { - path: entry.path, - sha: entry.sha, - }, - files: _.uniq(files), - }, - timeStamp: new Date().toISOString(), - }; + .then(branchData => this.updateTree(branchData.commit.sha, "/", fileTree)) + .then(changeTree => this.commit(options.commitMessage, changeTree)) + .then(commit => { + newHead = commit; + return this.retrieveMetadata(contentKey); }) - .then(updatedMetadata => this.storeMetadata(contentKey, updatedMetadata)) - .then(this.patchBranch(branchName, response.sha)); + .then(metadata => { + const { title, description } = options.parsedData || {}; + const metadataFiles = get(metadata.objects, 'files', []); + const files = [ ...metadataFiles, ...filesList ]; + const pr = { ...metadata.pr, head: newHead.sha }; + const objects = { + entry: { path: entry.path, sha: entry.sha }, + files: uniq(files), + }; + const updatedMetadata = { ...metadata, pr, title, description, objects }; + return this.rebasePullRequest(pr.number, branchName, contentKey, metadata, newHead); + }); + } + } + + /** + * Rebase a pull request onto the latest HEAD of it's target base branch + * (should generally be the configured backend branch). Only rebases changes + * in the entry file. + */ + async rebasePullRequest(prNumber, branchName, contentKey, metadata, head) { + const { path } = metadata.objects.entry; + + try { + /** + * Get the published branch and create new commits over it. If the pull + * request is up to date, no rebase will occur. + */ + const baseBranch = await this.getBranch(); + const commits = await this.getPullRequestCommits(prNumber, head); + + /** + * Sometimes the list of commits for a pull request isn't updated + * immediately after the PR branch is patched. There's also the possibility + * that the branch has changed unexpectedly. We account for both by adding + * the head if it's missing, or else throwing an error if the PR head is + * neither the head we expect nor its parent. + */ + const finalCommits = this.assertHead(commits, head); + const rebasedHead = await this.rebaseSingleBlobCommits(baseBranch.commit, finalCommits, path); + + /** + * Update metadata, then force update the pull request branch head. + */ + const pr = { ...metadata.pr, head: rebasedHead.sha }; + const timeStamp = new Date().toISOString(); + const updatedMetadata = { ...metadata, pr, timeStamp }; + await this.storeMetadata(contentKey, updatedMetadata); + return this.patchBranch(branchName, rebasedHead.sha, { force: true }); + } + catch(error) { + console.error(error); + throw error; + } + } + + /** + * Rebase an array of commits one-by-one, starting from a given base SHA. Can + * accept an array of commits as received from the GitHub API. All commits are + * expected to change the same, single blob. + */ + rebaseSingleBlobCommits(baseCommit, commits, pathToBlob) { + /** + * If the parent of the first commit already matches the target base, + * return commits as is. + */ + if (commits.length === 0 || commits[0].parents[0].sha === baseCommit.sha) { + return Promise.resolve(last(commits)); + } + + /** + * Re-create each commit over the new base, applying each to the previous, + * changing only the parent SHA and tree for each, but retaining all other + * info, such as the author/committer data. + */ + const newHeadPromise = commits.reduce((lastCommitPromise, commit, idx) => { + return lastCommitPromise.then(newParent => { + /** + * Normalize commit data to ensure it's not nested in `commit.commit`. + */ + const parent = this.normalizeCommit(newParent); + const commitToRebase = this.normalizeCommit(commit); + + return this.rebaseSingleBlobCommit(parent, commitToRebase, pathToBlob); }); + }, Promise.resolve(baseCommit)); + + /** + * Return a promise that resolves when all commits have been created. + */ + return newHeadPromise; + } + + /** + * Rebase a commit that changes a single blob. Also handles updating the tree. + */ + rebaseSingleBlobCommit(baseCommit, commit, pathToBlob) { + /** + * Retain original commit metadata. + */ + const { message, author, committer } = commit; + + /** + * Set the base commit as the parent. + */ + const parent = [ baseCommit.sha ]; + + /** + * Get the blob data by path. + */ + return this.getBlobInTree(commit.tree.sha, pathToBlob) + + /** + * Create a new tree consisting of the base tree and the single updated + * blob. Use the full path to indicate nesting, GitHub will take care of + * subtree creation. + */ + .then(blob => this.createTree(baseCommit.tree.sha, [{ ...blob, path: pathToBlob }])) + + /** + * Create a new commit with the updated tree and original commit metadata. + */ + .then(tree => this.createCommit(message, tree.sha, parent, author, committer)); + } + + + /** + * Get a pull request by PR number. + */ + getPullRequest(prNumber) { + return this.request(`${ this.repoURL }/pulls/${prNumber} }`); + } + + /** + * Get the list of commits for a given pull request. + */ + getPullRequestCommits (prNumber) { + return this.request(`${ this.repoURL }/pulls/${prNumber}/commits`); + } + + /** + * Returns `commits` with `headToAssert` appended if it's the child of the + * last commit in `commits`. Returns `commits` unaltered if `headToAssert` is + * already the last commit in `commits`. Otherwise throws an error. + */ + assertHead(commits, headToAssert) { + const headIsMissing = headToAssert.parents[0].sha === last(commits).sha; + const headIsNotMissing = headToAssert.sha === last(commits).sha; + + if (headIsMissing) { + return commits.concat(headToAssert); + } else if (headIsNotMissing) { + return commits; } + + throw Error('Editorial workflow branch changed unexpectedly.'); } updateUnpublishedEntryStatus(collection, slug, status) { @@ -373,9 +519,10 @@ export default class API { deleteUnpublishedEntry(collection, slug) { const contentKey = slug; + const branchName = this.generateBranchName(contentKey); return this.retrieveMetadata(contentKey) .then(metadata => this.closePR(metadata.pr, metadata.objects)) - .then(() => this.deleteBranch(`cms/${ contentKey }`)) + .then(() => this.deleteBranch(branchName)) // If the PR doesn't exist, then this has already been deleted - // deletion should be idempotent, so we can consider this a // success. @@ -389,10 +536,11 @@ export default class API { publishUnpublishedEntry(collection, slug) { const contentKey = slug; + const branchName = this.generateBranchName(contentKey); let prNumber; return this.retrieveMetadata(contentKey) .then(metadata => this.mergePR(metadata.pr, metadata.objects)) - .then(() => this.deleteBranch(`cms/${ contentKey }`)); + .then(() => this.deleteBranch(branchName)); } @@ -403,10 +551,11 @@ export default class API { }); } - patchRef(type, name, sha) { + patchRef(type, name, sha, opts = {}) { + const force = opts.force || false; return this.request(`${ this.repoURL }/git/refs/${ type }/${ encodeURIComponent(name) }`, { method: "PATCH", - body: JSON.stringify({ sha }), + body: JSON.stringify({ sha, force }), }); } @@ -424,8 +573,16 @@ export default class API { return this.createRef("heads", branchName, sha); } - patchBranch(branchName, sha) { - return this.patchRef("heads", branchName, sha); + assertCmsBranch(branchName) { + return branchName.startsWith(CMS_BRANCH_PREFIX); + } + + patchBranch(branchName, sha, opts = {}) { + const force = opts.force || false; + if (force && !this.assertCmsBranch(branchName)) { + throw Error(`Only CMS branches can be force updated, cannot force update ${branchName}`); + } + return this.patchRef("heads", branchName, sha, { force }); } deleteBranch(branchName) { @@ -487,7 +644,28 @@ export default class API { } getTree(sha) { - return sha ? this.request(`${ this.repoURL }/git/trees/${ sha }`) : Promise.resolve({ tree: [] }); + if (sha) { + return this.request(`${this.repoURL}/git/trees/${sha}`); + } + return Promise.resolve({ tree: [] }); + } + + /** + * Get a blob from a tree. Requests individual subtrees recursively if blob is + * nested within one or more directories. + */ + getBlobInTree(treeSha, pathToBlob) { + const pathSegments = pathToBlob.split('/').filter(val => val); + const directories = pathSegments.slice(0, -1); + const filename = pathSegments.slice(-1)[0]; + const baseTree = this.getTree(treeSha); + const subTreePromise = directories.reduce((treePromise, segment) => { + return treePromise.then(tree => { + const subTreeSha = find(tree.tree, { path: segment }).sha; + return this.getTree(subTreeSha); + }); + }, baseTree); + return subTreePromise.then(subTree => find(subTree.tree, { path: filename })); } toBase64(str) { @@ -542,19 +720,40 @@ export default class API { ); } return Promise.all(updates) - .then(updates => this.request(`${ this.repoURL }/git/trees`, { - method: "POST", - body: JSON.stringify({ base_tree: sha, tree: updates }), - })).then(response => ({ path, mode: "040000", type: "tree", sha: response.sha, parentSha: sha })); + .then(tree => this.createTree(sha, tree)) + .then(response => ({ path, mode: "040000", type: "tree", sha: response.sha, parentSha: sha })); }); } + createTree(baseSha, tree) { + return this.request(`${ this.repoURL }/git/trees`, { + method: "POST", + body: JSON.stringify({ base_tree: baseSha, tree }), + }); + } + + /** + * Some GitHub API calls return commit data in a nested `commit` property, + * with the SHA outside of the nested property, while others return a + * flatter object with no nested `commit` property. This normalizes a commit + * to resemble the latter. + */ + normalizeCommit(commit) { + if (commit.commit) { + return { ...commit.commit, sha: commit.sha }; + } + return commit; + } + commit(message, changeTree) { - const tree = changeTree.sha; const parents = changeTree.parentSha ? [changeTree.parentSha] : []; + return this.createCommit(message, changeTree.sha, parents); + } + + createCommit(message, treeSha, parents, author, committer) { return this.request(`${ this.repoURL }/git/commits`, { method: "POST", - body: JSON.stringify({ message, tree, parents }), + body: JSON.stringify({ message, tree: treeSha, parents, author, committer }), }); } } diff --git a/src/backends/github/implementation.js b/src/backends/github/implementation.js index f3621496ad19..1f82655dc080 100644 --- a/src/backends/github/implementation.js +++ b/src/backends/github/implementation.js @@ -1,3 +1,4 @@ +import trimStart from 'lodash/trimStart'; import semaphore from "semaphore"; import AuthenticationPage from "./AuthenticationPage"; import API from "./API"; @@ -89,10 +90,31 @@ export default class GitHub { })); } + getMedia() { + return this.api.listFiles(this.config.get('media_folder')) + .then(files => files.filter(file => file.type === 'file')) + .then(files => files.map(({ sha, name, size, download_url, path }) => { + return { id: sha, name, size, url: download_url, path }; + })); + } + persistEntry(entry, mediaFiles = [], options = {}) { return this.api.persistFiles(entry, mediaFiles, options); } + async persistMedia(mediaFile, options = {}) { + try { + const response = await this.api.persistFiles(null, [mediaFile], options); + const { value, size, path, fileObj } = mediaFile; + const url = `https://raw.githubusercontent.com/${this.repo}/${this.branch}${path}`; + return { id: response.sha, name: value, size: fileObj.size, url, path: trimStart(path, '/') }; + } + catch(error) { + console.error(error); + throw error; + } + } + deleteFile(path, commitMessage, options) { return this.api.deleteFile(path, commitMessage, options); } diff --git a/src/backends/test-repo/implementation.js b/src/backends/test-repo/implementation.js index c0adc75db058..9e82204e7d49 100644 --- a/src/backends/test-repo/implementation.js +++ b/src/backends/test-repo/implementation.js @@ -1,3 +1,5 @@ +import { remove, attempt, isError } from 'lodash'; +import uuid from 'uuid/v4'; import AuthenticationPage from './AuthenticationPage'; import { fileExtension } from '../../lib/pathHelper' @@ -24,6 +26,7 @@ function nameFromEmail(email) { export default class TestRepo { constructor(config) { this.config = config; + this.assets = []; } authComponent() { @@ -99,10 +102,32 @@ export default class TestRepo { return Promise.resolve(); } + getMedia() { + return Promise.resolve(this.assets); + } + + persistMedia({ fileObj }) { + const { name, size } = fileObj; + const objectUrl = attempt(window.URL.createObjectURL, fileObj); + const url = isError(objectUrl) ? '' : objectUrl; + const normalizedAsset = { id: uuid(), name, size, path: url, url }; + + this.assets.push(normalizedAsset); + return Promise.resolve(normalizedAsset); + } + deleteFile(path, commitMessage) { - const folder = path.substring(0, path.lastIndexOf('/')); - const fileName = path.substring(path.lastIndexOf('/') + 1); - delete window.repoFiles[folder][fileName]; + const assetIndex = this.assets.findIndex(asset => asset.path === path); + if (assetIndex > -1) { + this.assets.splice(assetIndex, 1); + } + + else { + const folder = path.substring(0, path.lastIndexOf('/')); + const fileName = path.substring(path.lastIndexOf('/') + 1); + delete window.repoFiles[folder][fileName]; + } + return Promise.resolve(); } } diff --git a/src/components/AppHeader/AppHeader.css b/src/components/AppHeader/AppHeader.css index 170623556a90..8ee8475dc150 100644 --- a/src/components/AppHeader/AppHeader.css +++ b/src/components/AppHeader/AppHeader.css @@ -7,13 +7,22 @@ /* Gross stuff below, React Toolbox hacks */ -.nc-appHeader-homeLink, +.nc-appHeader-button, .nc-appHeader-iconMenu { - margin-left: 2%; + margin-left: 16px; } -.nc-appHeader-homeLink &icon { - vertical-align: top; +.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, diff --git a/src/components/AppHeader/AppHeader.js b/src/components/AppHeader/AppHeader.js index 92f22edab30c..486b98e77cbc 100644 --- a/src/components/AppHeader/AppHeader.js +++ b/src/components/AppHeader/AppHeader.js @@ -20,11 +20,6 @@ export default class AppHeader extends React.Component { onLogoutClick: PropTypes.func.isRequired, }; - state = { - createMenuActive: false, - userMenuActive: false, - }; - handleCreatePostClick = (collectionName) => { const { onCreateEntryClick } = this.props; if (onCreateEntryClick) { @@ -32,18 +27,6 @@ export default class AppHeader extends React.Component { } }; - handleCreateButtonClick = () => { - this.setState({ - createMenuActive: true, - }); - }; - - handleCreateMenuHide = () => { - this.setState({ - createMenuActive: false, - }); - }; - render() { const { user, @@ -51,6 +34,7 @@ export default class AppHeader extends React.Component { runCommand, toggleDrawer, onLogoutClick, + openMediaLibrary, } = this.props; const avatarStyle = { @@ -59,7 +43,6 @@ export default class AppHeader extends React.Component { const theme = { appBar: 'nc-appHeader-appBar', - homeLink: 'nc-appHeader-homeLink', iconMenu: 'nc-appHeader-iconMenu', icon: 'nc-appHeader-icon', leftIcon: 'nc-appHeader-leftIcon', @@ -76,17 +59,14 @@ export default class AppHeader extends React.Component { theme={theme} leftIcon="menu" onLeftIconClick={toggleDrawer} - onRightIconClick={this.handleRightIconClick} > - + - + + { collections.filter(collection => collection.get('create')).toList().map(collection => onChange(fieldName, newValue, newMetadata)} onValidate={this.props.onValidate.bind(this, fieldName)} + onOpenMediaLibrary={onOpenMediaLibrary} onAddAsset={onAddAsset} onRemoveAsset={onRemoveAsset} getAsset={getAsset} @@ -87,7 +99,9 @@ ControlPane.propTypes = { 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, diff --git a/src/components/EntryEditor/EntryEditor.css b/src/components/EntryEditor/EntryEditor.css index 8256be03e60d..cefd0094af65 100644 --- a/src/components/EntryEditor/EntryEditor.css +++ b/src/components/EntryEditor/EntryEditor.css @@ -7,7 +7,7 @@ position: absolute; top: 8px; right: 20px; - z-index: 1000; + z-index: 299; opacity: 0.8; display: flex; justify-content: flex-end; @@ -41,7 +41,7 @@ height: 55px; padding: 10px 20px; position: absolute; - z-index: 9999; + z-index: 299; left: 0; right: 0; bottom: 0; diff --git a/src/components/EntryEditor/EntryEditor.js b/src/components/EntryEditor/EntryEditor.js index 64a77deabd91..de828ee978dc 100644 --- a/src/components/EntryEditor/EntryEditor.js +++ b/src/components/EntryEditor/EntryEditor.js @@ -52,12 +52,14 @@ class EntryEditor extends Component { fields, fieldsMetaData, fieldsErrors, + mediaPaths, getAsset, onChange, enableSave, showDelete, onDelete, onValidate, + onOpenMediaLibrary, onAddAsset, onRemoveAsset, onCancelEdit, @@ -102,9 +104,11 @@ class EntryEditor extends Component { fields={fields} fieldsMetaData={fieldsMetaData} fieldsErrors={fieldsErrors} + mediaPaths={mediaPaths} getAsset={getAsset} onChange={onChange} onValidate={onValidate} + onOpenMediaLibrary={onOpenMediaLibrary} onAddAsset={onAddAsset} onRemoveAsset={onRemoveAsset} ref={c => this.controlPaneRef = c} // eslint-disable-line @@ -166,7 +170,9 @@ EntryEditor.propTypes = { 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, diff --git a/src/components/MediaLibrary/MediaLibrary.css b/src/components/MediaLibrary/MediaLibrary.css new file mode 100644 index 000000000000..0a175226550c --- /dev/null +++ b/src/components/MediaLibrary/MediaLibrary.css @@ -0,0 +1,98 @@ +@import './MediaLibraryFooter.css'; + +:root { + --mediaLibraryCardWidth: 300px; + --mediaLibraryCardMargin: 10px; + --mediaLibraryCardOutsideWidth: calc(var(--mediaLibraryCardWidth) + var(--mediaLibraryCardMargin) * 2); +} + +.nc-mediaLibrary-dialog { + width: calc(var(--mediaLibraryCardOutsideWidth) + 48px); + + @media (width >= 800px) { + width: calc(var(--mediaLibraryCardOutsideWidth) * 2 + 48px); + } + + @media (width >= 1120px) { + width: calc(var(--mediaLibraryCardOutsideWidth) * 3 + 48px); + } + + @media (width >= 1440px) { + width: calc(var(--mediaLibraryCardOutsideWidth) * 4 + 48px); + } + + @media (width >= 1760px) { + width: calc(var(--mediaLibraryCardOutsideWidth) * 5 + 48px); + } + + @media (width >= 2080px) { + width: calc(var(--mediaLibraryCardOutsideWidth) * 6 + 48px); + } +} + +.nc-mediaLibrary-title { + position: absolute; + margin-top: 20px; +} + +.nc-mediaLibrary-searchInput { + @apply --input; + font-family: var(--fontFamilyPrimary); + width: 50%; + max-width: 800px; + margin: 12px auto; + display: inline-block; + position: relative; + z-index: 1; +} + +.nc-mediaLibrary-emptyMessage { + height: 100%; + width: 100%; + position: absolute; + top: 0; + left: 0; + display: flex; + justify-content: center; + align-items: center; +} + +.nc-mediaLibrary-cardGrid-container { + height: calc(100% - 150px); + margin: 20px auto 0; + overflow-y: auto; +} + +.nc-mediaLibrary-cardGrid { + display: flex; + flex-wrap: wrap; +} + +.nc-mediaLibrary-card { + width: var(--mediaLibraryCardWidth); + height: 240px; + margin: var(--mediaLibraryCardMargin); + border: var(--textFieldBorder); + border-radius: var(--borderRadius); + cursor: pointer; + overflow: hidden; +} + +.nc-mediaLibrary-card-selected { + border-color: var(--primaryColor); +} + +.nc-mediaLibrary-cardImage { + width: 100%; + height: 160px; + object-fit: cover; + border-radius: 2px 2px 0 0; +} + +.nc-mediaLibrary-cardText { + color: var(--textColor); + padding: 8px; + margin-top: 20px; + overflow-wrap: break-word; + line-height: 1.3 !important; +} diff --git a/src/components/MediaLibrary/MediaLibrary.js b/src/components/MediaLibrary/MediaLibrary.js new file mode 100644 index 000000000000..f13440e8e12a --- /dev/null +++ b/src/components/MediaLibrary/MediaLibrary.js @@ -0,0 +1,332 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { orderBy, get, last, isEmpty, map } from 'lodash'; +import c from 'classnames'; +import fuzzy from 'fuzzy'; +import Waypoint from 'react-waypoint'; +import Dialog from '../UI/Dialog'; +import { resolvePath } from '../../lib/pathHelper'; +import { changeDraftField } from '../../actions/entries'; +import { + loadMedia as loadMediaAction, + persistMedia as persistMediaAction, + deleteMedia as deleteMediaAction, + insertMedia as insertMediaAction, + closeMediaLibrary as closeMediaLibraryAction, +} from '../../actions/mediaLibrary'; +import MediaLibraryFooter from './MediaLibraryFooter'; + +/** + * Extensions used to determine which files to show when the media library is + * accessed from an image insertion field. + */ +const IMAGE_EXTENSIONS_VIEWABLE = [ 'jpg', 'jpeg', 'webp', 'gif', 'png', 'bmp', 'tiff' ]; +const IMAGE_EXTENSIONS = [ ...IMAGE_EXTENSIONS_VIEWABLE, 'svg' ]; + +class MediaLibrary extends React.Component { + + /** + * The currently selected file and query are tracked in component state as + * they do not impact the rest of the application. + */ + state = { + selectedFile: {}, + query: '', + }; + + componentDidMount() { + this.props.loadMedia(); + } + + componentWillReceiveProps(nextProps) { + /** + * We clear old state from the media library when it's being re-opened + * because, when doing so on close, the state is cleared while the media + * library is still fading away. + */ + const isOpening = !this.props.isVisible && nextProps.isVisible; + if (isOpening) { + this.setState({ selectedFile: {}, query: '' }); + } + } + + /** + * Filter an array of file data to include only images. + */ + filterImages = files => { + return files ? files.filter(file => IMAGE_EXTENSIONS.includes(last(file.name.split('.')))) : []; + }; + + /** + * Transform file data for table display. + */ + toTableData = files => { + const tableData = files && files.map(({ key, name, size, queryOrder, url, urlIsPublicPath }) => { + const ext = last(name.split('.')); + return { + key, + name, + type: ext.toUpperCase(), + size, + queryOrder, + url, + urlIsPublicPath, + isImage: IMAGE_EXTENSIONS.includes(ext), + isViewableImage: IMAGE_EXTENSIONS_VIEWABLE.includes(ext), + }; + }); + + /** + * Get the sort order for use with `lodash.orderBy`, and always add the + * `queryOrder` sort as the lowest priority sort order. + */ + const { sortFields } = this.state; + const fieldNames = map(sortFields, 'fieldName').concat('queryOrder'); + const directions = map(sortFields, 'direction').concat('asc'); + return orderBy(tableData, fieldNames, directions); + }; + + handleClose = () => { + this.props.closeMediaLibrary(); + }; + + /** + * Toggle asset selection on click. + */ + handleAssetClick = asset => { + const selectedFile = this.state.selectedFile.key === asset.key ? {} : asset; + this.setState({ selectedFile }); + }; + + /** + * Upload a file. + */ + handlePersist = async event => { + /** + * Stop the browser from automatically handling the file input click, and + * get the file for upload. + */ + event.stopPropagation(); + event.preventDefault(); + const { loadMedia, persistMedia, privateUpload } = this.props; + const { files: fileList } = event.dataTransfer || event.target; + const files = [...fileList]; + const file = files[0]; + + /** + * Upload the selected file, then refresh the media library. This should be + * improved in the future, but isn't currently resulting in noticeable + * performance/load time issues. + */ + await persistMedia(file, privateUpload); + this.scrollToTop(); + }; + + /** + * Stores the public path of the file in the application store, where the + * editor field that launched the media library can retrieve it. + */ + handleInsert = () => { + const { selectedFile } = this.state; + const { name, url, urlIsPublicPath } = selectedFile; + const { insertMedia, publicFolder } = this.props; + const publicPath = urlIsPublicPath ? url : resolvePath(name, publicFolder); + insertMedia(publicPath); + this.handleClose(); + }; + + /** + * Removes the selected file from the backend. + */ + handleDelete = () => { + const { selectedFile } = this.state; + const { files, deleteMedia } = this.props; + if (!window.confirm('Are you sure you want to delete selected media?')) { + return; + } + const file = files.find(file => selectedFile.key === file.key); + deleteMedia(file) + .then(() => { + this.setState({ selectedFile: {} }); + }); + }; + + handleLoadMore = () => { + const { loadMedia, dynamicSearchQuery, page } = this.props; + loadMedia({ query: dynamicSearchQuery, page: page + 1 }); + }; + + /** + * Executes media library search for implementations that support dynamic + * search via request. For these implementations, the Enter key must be + * pressed to execute search. If assets are being stored directly through + * the GitHub backend, search is in-memory and occurs as the query is typed, + * so this handler has no impact. + */ + handleSearchKeyDown = async (event) => { + if (event.key === 'Enter' && this.props.dynamicSearch) { + await this.props.loadMedia({ query: this.state.query }) + this.scrollToTop(); + } + }; + + scrollToTop = () => { + this.scrollContainerRef.scrollTop = 0; + } + + /** + * Updates query state as the user types in the search field. + */ + handleSearchChange = event => { + this.setState({ query: event.target.value }); + }; + + /** + * Filters files that do not match the query. Not used for dynamic search. + */ + queryFilter = (query, files) => { + /** + * Because file names don't have spaces, typing a space eliminates all + * potential matches, so we strip them all out internally before running the + * query. + */ + const strippedQuery = query.replace(/ /g, ''); + const matches = fuzzy.filter(strippedQuery, files, { extract: file => file.name }); + const matchFiles = matches.map((match, queryIndex) => { + const file = files[match.index]; + return { ...file, queryIndex }; + }); + return matchFiles; + }; + + render() { + const { + isVisible, + canInsert, + files, + dynamicSearch, + dynamicSearchActive, + forImage, + isLoading, + isPersisting, + isDeleting, + hasNextPage, + page, + isPaginating, + } = this.props; + const { query, selectedFile } = this.state; + const filteredFiles = forImage ? this.filterImages(files) : files; + const queriedFiles = (!dynamicSearch && query) ? this.queryFilter(query, filteredFiles) : filteredFiles; + const tableData = this.toTableData(queriedFiles); + const hasFiles = files && !!files.length; + const hasFilteredFiles = filteredFiles && !!filteredFiles.length; + const hasSearchResults = queriedFiles && !!queriedFiles.length; + const hasMedia = hasSearchResults; + const shouldShowEmptyMessage = !hasMedia; + const emptyMessage = (isLoading && !hasMedia && 'Loading...') + || (dynamicSearchActive && 'No results.') + || (!hasFiles && 'No assets found.') + || (!hasFilteredFiles && 'No images found.') + || (!hasSearchResults && 'No results.'); + + return ( + + } + > +

{forImage ? 'Images' : 'Assets'}

+ this.handleSearchKeyDown(event)} + placeholder="Search..." + disabled={!dynamicSearchActive && !hasFilteredFiles} + autoFocus + /> +
(this.scrollContainerRef = ref)}> +
+ { + tableData.map((file, idx) => +
this.handleAssetClick(file)} + tabIndex="-1" + > +
+ { + file.isViewableImage + ? + :
+ } +
+

{file.name}

+
+ ) + } + { + hasNextPage + ? this.handleLoadMore()}/> + : null + } +
+ { + shouldShowEmptyMessage + ?

{emptyMessage}

+ : null + } + { isPaginating ?

Loading...

: null } +
+
+ ); + } +} + +const mapStateToProps = state => { + const { config, mediaLibrary } = state; + const configProps = { + publicFolder: config.get('public_folder'), + }; + const mediaLibraryProps = { + isVisible: mediaLibrary.get('isVisible'), + canInsert: mediaLibrary.get('canInsert'), + files: mediaLibrary.get('files'), + dynamicSearch: mediaLibrary.get('dynamicSearch'), + dynamicSearchActive: mediaLibrary.get('dynamicSearchActive'), + dynamicSearchQuery: mediaLibrary.get('dynamicSearchQuery'), + forImage: mediaLibrary.get('forImage'), + isLoading: mediaLibrary.get('isLoading'), + isPersisting: mediaLibrary.get('isPersisting'), + isDeleting: mediaLibrary.get('isDeleting'), + privateUpload: mediaLibrary.get('privateUpload'), + page: mediaLibrary.get('page'), + hasNextPage: mediaLibrary.get('hasNextPage'), + isPaginating: mediaLibrary.get('isPaginating'), + }; + return { ...configProps, ...mediaLibraryProps }; +}; + +const mapDispatchToProps = { + loadMedia: loadMediaAction, + persistMedia: persistMediaAction, + deleteMedia: deleteMediaAction, + insertMedia: insertMediaAction, + closeMediaLibrary: closeMediaLibraryAction, +}; + +export default connect(mapStateToProps, mapDispatchToProps)(MediaLibrary); diff --git a/src/components/MediaLibrary/MediaLibraryFooter.css b/src/components/MediaLibrary/MediaLibraryFooter.css new file mode 100644 index 000000000000..4133dd6279c3 --- /dev/null +++ b/src/components/MediaLibrary/MediaLibraryFooter.css @@ -0,0 +1,25 @@ +.nc-mediaLibrary-footer-buttonRight { + float: right; + margin-left: 10px; +} + +.nc-mediaLibrary-footer-buttonLeft { + float: left; + margin-right: 10px; +} + +.nc-mediaLibrary-footer-button-loader { + float: left; + margin: 8px 20px; +} + +.nc-mediaLibrary-footer-button-loaderSpinner { + position: static; +} + +.nc-mediaLibrary-footer-button-loaderText { + position: relative; + top: 2px; + margin-left: 18px; +} + diff --git a/src/components/MediaLibrary/MediaLibraryFooter.js b/src/components/MediaLibrary/MediaLibraryFooter.js new file mode 100644 index 000000000000..47b728bef016 --- /dev/null +++ b/src/components/MediaLibrary/MediaLibraryFooter.js @@ -0,0 +1,64 @@ +import React from 'react'; +import { Button, BrowseButton } from 'react-toolbox/lib/button'; +import Loader from '../UI/loader/Loader'; + +const MediaLibraryFooter = ({ + onDelete, + onPersist, + onClose, + onInsert, + hasSelection, + forImage, + canInsert, + isPersisting, + isDeleting, +}) => { + const shouldShowLoader = isPersisting || isDeleting; + const loaderText = isPersisting ? 'Uploading...' : 'Deleting...'; + const loader = ( +
+ + {loaderText} +
+ ); + return ( +
+
+ ); +}; + +export default MediaLibraryFooter; diff --git a/src/components/UI/Dialog/Dialog.css b/src/components/UI/Dialog/Dialog.css new file mode 100644 index 000000000000..15106bc139fa --- /dev/null +++ b/src/components/UI/Dialog/Dialog.css @@ -0,0 +1,52 @@ +.nc-dialog-wrapper { + z-index: 99999; +} + +.nc-dialog-root { + height: 80%; + text-align: center; + max-width: 2200px; +} + +.nc-dialog-body { + height: 100%; +} + +.nc-dialog-contentWrapper { + height: 100%; +} + +.nc-dialog-footer { + margin: 24px 0; + width: calc(100% - 48px); + position: absolute; + bottom: 0; +} + + +/** + * Progress Bar + */ +.nc-dialog-progressOverlay { + background-color: rgba(255, 255, 255, 0.75); + z-index: 1; + height: 100%; + width: 100%; +} + +.nc-dialog-progressOverlay-active { + opacity: 1 !important; +} + +.nc-dialog-progressBarContainer { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; +} + +.nc-dialog-progressBar-linear { + width: 80%; +} + diff --git a/src/components/UI/Dialog/FocusTrap.js b/src/components/UI/Dialog/FocusTrap.js new file mode 100644 index 000000000000..86c284932619 --- /dev/null +++ b/src/components/UI/Dialog/FocusTrap.js @@ -0,0 +1,16 @@ +import React from 'react'; +import FocusTrapReact from 'focus-trap-react'; + +/** + * A wrapper for focus-trap-react, which we use to completely remove focus traps + * from the DOM rather than using the library's own internal activation/pausing + * mechanisms, which can manifest bugs when nested. + */ +const FocusTrap = props => { + const { active, children, focusTrapOptions, className } = props; + return active + ? {children} + :
{children}
+} + +export default FocusTrap; diff --git a/src/components/UI/Dialog/index.js b/src/components/UI/Dialog/index.js new file mode 100644 index 000000000000..e351f3838616 --- /dev/null +++ b/src/components/UI/Dialog/index.js @@ -0,0 +1,65 @@ +import React from 'react'; +import RTDialog from 'react-toolbox/lib/dialog'; +import Overlay from 'react-toolbox/lib/overlay'; +import ProgressBar from 'react-toolbox/lib/progress_bar'; +import FocusTrap from './FocusTrap'; + +const dialogTheme = { + wrapper: 'nc-dialog-wrapper', + dialog: 'nc-dialog-root', + body: 'nc-dialog-body', +}; + +const progressOverlayTheme = { + overlay: 'nc-dialog-progressOverlay', + active: 'nc-dialog-progressOverlay-active', +}; + +const progressBarTheme = { + linear: 'nc-dialog-progressBar-linear', +}; + +const Dialog = ({ + type, + isVisible, + isLoading, + loadingMessage, + onClose, + footer, + className, + children, +}) => + + + + +

{ loadingMessage }

+ +
+
+ +
+ {children} +
+ + { footer ?
{footer}
: null } + +
+
; + +export default Dialog; diff --git a/src/components/UI/loader/Loader.js b/src/components/UI/loader/Loader.js index b0542d511a7a..8475bd392219 100644 --- a/src/components/UI/loader/Loader.js +++ b/src/components/UI/loader/Loader.js @@ -1,6 +1,6 @@ import React from 'react'; import CSSTransition from 'react-transition-group/CSSTransition'; -import classnames from 'classnames'; +import c from 'classnames'; export default class Loader extends React.Component { @@ -50,8 +50,8 @@ export default class Loader extends React.Component { }; render() { - const { active } = this.props; - const className = classnames('nc-loader-root', { 'nc-loader-active': active }); - return
{this.renderChild()}
; + const { active, className } = this.props; + const combinedClassName = c('nc-loader-root', { 'nc-loader-active': active }, className); + return
{this.renderChild()}
; } } diff --git a/src/components/UI/theme.css b/src/components/UI/theme.css index fdb922075c6d..29b8d5f31a9b 100644 --- a/src/components/UI/theme.css +++ b/src/components/UI/theme.css @@ -1,5 +1,5 @@ :root { - --fontFamily: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + --fontFamilyPrimary: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; --fontFamilyMono: 'SFMono-Regular', Consolas, "Liberation Mono", Menlo, Courier, monospace; --defaultColor: #333; --defaultColorLight: #fff; @@ -32,6 +32,27 @@ --borderWidth: 2px; --border: solid var(--borderWidth) var(--secondaryColor); --textFieldBorder: var(--border); + + --input: { + font-family: 'SFMono-Regular', Consolas, "Liberation Mono", Menlo, Courier, monospace; + display: block; + width: 100%; + padding: 12px; + margin: 0; + border: var(--textFieldBorder); + border-radius: var(--borderRadius); + outline: 0; + box-shadow: none; + background-color: var(--controlBGColor); + font-size: 16px; + color: var(--textColor); + transition: border-color .3s ease; + + &:focus, + &:active { + border-color: var(--primaryColor); + } + } } .nc-theme-base { diff --git a/src/components/Widgets/ControlHOC.js b/src/components/Widgets/ControlHOC.js index e6f440b9fc62..d9d6fbba8bdf 100644 --- a/src/components/Widgets/ControlHOC.js +++ b/src/components/Widgets/ControlHOC.js @@ -16,21 +16,36 @@ class ControlHOC extends Component { PropTypes.string, PropTypes.bool, ]), + mediaPaths: ImmutablePropTypes.map.isRequired, metadata: ImmutablePropTypes.map, onChange: PropTypes.func.isRequired, - onValidate: PropTypes.func.isRequired, + onValidate: PropTypes.func, + onOpenMediaLibrary: PropTypes.func.isRequired, onAddAsset: PropTypes.func.isRequired, onRemoveAsset: PropTypes.func.isRequired, getAsset: PropTypes.func.isRequired, }; shouldComponentUpdate(nextProps) { + /** + * Allow widgets to provide their own `shouldComponentUpdate` method. + */ + if (this.wrappedControlShouldComponentUpdate) { + return this.wrappedControlShouldComponentUpdate(nextProps); + } return this.props.value !== nextProps.value; } processInnerControlRef = (wrappedControl) => { if (!wrappedControl) return; this.wrappedControlValid = wrappedControl.isValid || truthy; + + /** + * Get the `shouldComponentUpdate` method from the wrapped control, and + * provide the control instance is the `this` binding. + */ + const { shouldComponentUpdate: scu } = wrappedControl; + this.wrappedControlShouldComponentUpdate = scu && scu.bind(wrappedControl); }; validate = (skipWrapped = false) => { @@ -113,12 +128,25 @@ class ControlHOC extends Component { }; render() { - const { controlComponent, field, value, metadata, onChange, onAddAsset, onRemoveAsset, getAsset } = this.props; + const { + controlComponent, + field, + value, + mediaPaths, + metadata, + onChange, + onOpenMediaLibrary, + onAddAsset, + onRemoveAsset, + getAsset + } = this.props; return React.createElement(controlComponent, { field, value, + mediaPaths, metadata, onChange, + onOpenMediaLibrary, onAddAsset, onRemoveAsset, getAsset, diff --git a/src/components/Widgets/FileControl.js b/src/components/Widgets/FileControl.js index 1ac38c2bda0f..3aef0dd00a71 100644 --- a/src/components/Widgets/FileControl.js +++ b/src/components/Widgets/FileControl.js @@ -1,115 +1,76 @@ import PropTypes from 'prop-types'; import React from 'react'; +import ImmutablePropTypes from "react-immutable-proptypes"; +import { get } from 'lodash'; +import uuid from 'uuid/v4'; import { truncateMiddle } from '../../lib/textHelper'; -import { Loader } from '../UI'; import AssetProxy, { createAssetProxy } from '../../valueObjects/AssetProxy'; const MAX_DISPLAY_LENGTH = 50; export default class FileControl extends React.Component { - state = { - processing: false, - }; + constructor(props) { + super(props); + this.controlID = uuid(); + } - promise = null; - isValid = () => { - if (this.promise) { - return this.promise; + shouldComponentUpdate(nextProps) { + /** + * Always update if the value changes. + */ + if (this.props.value !== nextProps.value) { + return true; } - return { error: false }; - }; - - - handleFileInputRef = (el) => { - this._fileInput = el; - }; - - handleClick = (e) => { - this._fileInput.click(); - }; - - handleDragEnter = (e) => { - e.stopPropagation(); - e.preventDefault(); - }; - handleDragOver = (e) => { - e.stopPropagation(); - e.preventDefault(); - }; - - handleChange = (e) => { - e.stopPropagation(); - e.preventDefault(); - - const fileList = e.dataTransfer ? e.dataTransfer.files : e.target.files; - const files = [...fileList]; - const imageType = /^image\//; + /** + * If there is a media path for this control in the state object, and that + * path is different than the value in `nextProps`, update. + */ + const mediaPath = nextProps.mediaPaths.get(this.controlID); + if (mediaPath && (nextProps.value !== mediaPath)) { + return true; + } - // Return the first file on the list - const file = files[0]; + return false; + } - this.props.onRemoveAsset(this.props.value); - if (file) { - this.setState({ processing: true }); - this.promise = createAssetProxy(file.name, file, false, this.props.field.get('private', false)) - .then((assetProxy) => { - this.setState({ processing: false }); - this.props.onAddAsset(assetProxy); - this.props.onChange(assetProxy.public_path); - }); - } else { - this.props.onChange(null); + componentWillReceiveProps(nextProps) { + const { mediaPaths, value } = nextProps; + const mediaPath = mediaPaths.get(this.controlID); + if (mediaPath && mediaPath !== value) { + this.props.onChange(mediaPath); } + } + + handleClick = (e) => { + const { field, onOpenMediaLibrary } = this.props; + return onOpenMediaLibrary({ controlID: this.controlID, privateUpload: field.private }); }; renderFileName = () => { - if (!this.props.value) return null; - if (this.value instanceof AssetProxy) { - return truncateMiddle(this.props.value.path, MAX_DISPLAY_LENGTH); - } else { - return truncateMiddle(this.props.value, MAX_DISPLAY_LENGTH); - } + const { value } = this.props; + return value ? truncateMiddle(value, MAX_DISPLAY_LENGTH) : null; }; render() { - const { processing } = this.state; const fileName = this.renderFileName(); - if (processing) { - return ( -
- - - -
- ); - } return ( -
+
- {fileName ? fileName : 'Click here to upload a file from your computer, or drag and drop a file directly into this box'} + {fileName ? fileName : 'Click here to select an asset from the asset library'} -
); } } FileControl.propTypes = { + field: PropTypes.object.isRequired, + mediaPaths: ImmutablePropTypes.map.isRequired, onAddAsset: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired, onRemoveAsset: PropTypes.func.isRequired, + onOpenMediaLibrary: PropTypes.func.isRequired, value: PropTypes.node, - field: PropTypes.object, }; diff --git a/src/components/Widgets/ImageControl.js b/src/components/Widgets/ImageControl.js index 19457c38f764..a8406ce06181 100644 --- a/src/components/Widgets/ImageControl.js +++ b/src/components/Widgets/ImageControl.js @@ -1,119 +1,74 @@ import PropTypes from 'prop-types'; import React from 'react'; +import ImmutablePropTypes from "react-immutable-proptypes"; +import uuid from 'uuid/v4'; import { truncateMiddle } from '../../lib/textHelper'; -import { Loader } from '../UI'; import AssetProxy, { createAssetProxy } from '../../valueObjects/AssetProxy'; const MAX_DISPLAY_LENGTH = 50; export default class ImageControl extends React.Component { - state = { - processing: false, - }; + constructor(props) { + super(props); + this.controlID = uuid(); + } - promise = null; + shouldComponentUpdate(nextProps) { + /** + * Always update if the value changes. + */ + if (this.props.value !== nextProps.value) { + return true; + } - isValid = () => { - if (this.promise) { - return this.promise; + /** + * If there is a media path for this control in the state object, and that + * path is different than the value in `nextProps`, update. + */ + const mediaPath = nextProps.mediaPaths.get(this.controlID); + if (mediaPath && (nextProps.value !== mediaPath)) { + return true; } - return { error: false }; - }; + return false; + } - handleFileInputRef = (el) => { - this._fileInput = el; - }; + componentWillReceiveProps(nextProps) { + const { mediaPaths, value } = nextProps; + const mediaPath = mediaPaths.get(this.controlID); + if (mediaPath && mediaPath !== value) { + this.props.onChange(mediaPath); + } + } handleClick = (e) => { - this._fileInput.click(); - }; - - handleDragEnter = (e) => { - e.stopPropagation(); - e.preventDefault(); + const { field, onOpenMediaLibrary } = this.props; + return onOpenMediaLibrary({ controlID: this.controlID, forImage: true, privateUpload: field.private }); }; - handleDragOver = (e) => { - e.stopPropagation(); - e.preventDefault(); - }; - - handleChange = (e) => { - e.stopPropagation(); - e.preventDefault(); - - const fileList = e.dataTransfer ? e.dataTransfer.files : e.target.files; - const files = [...fileList]; - const imageType = /^image\//; - - // Iterate through the list of files and return the first image on the list - const file = files.find((currentFile) => { - if (imageType.test(currentFile.type)) { - return currentFile; - } - }); - - this.props.onRemoveAsset(this.props.value); - if (file) { - this.setState({ processing: true }); - this.promise = createAssetProxy(file.name, file) - .then((assetProxy) => { - this.setState({ processing: false }); - this.props.onAddAsset(assetProxy); - this.props.onChange(assetProxy.public_path); - }); - } else { - this.props.onChange(null); - } - }; - - renderImageName = () => { - if (!this.props.value) return null; - if (this.value instanceof AssetProxy) { - return truncateMiddle(this.props.value.path, MAX_DISPLAY_LENGTH); - } else { - return truncateMiddle(this.props.value, MAX_DISPLAY_LENGTH); - } + renderFileName = () => { + const { value } = this.props; + return value ? truncateMiddle(value, MAX_DISPLAY_LENGTH) : null; }; render() { - const { processing } = this.state; - const imageName = this.renderImageName(); - if (processing) { - return ( -
- - - -
- ); - } + const fileName = this.renderFileName(); return ( -
+
- {imageName ? imageName : 'Click here to upload an image from your computer, or drag and drop a file directly into this box'} + {fileName ? fileName : 'Click here to select an image from the image library'} -
); } } ImageControl.propTypes = { + field: PropTypes.object.isRequired, + mediaPaths: ImmutablePropTypes.map.isRequired, onAddAsset: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired, onRemoveAsset: PropTypes.func.isRequired, + onOpenMediaLibrary: PropTypes.func.isRequired, value: PropTypes.node, }; diff --git a/src/components/Widgets/ListControl.js b/src/components/Widgets/ListControl.js index 33e2f7cd19d6..1050fff5bbdf 100644 --- a/src/components/Widgets/ListControl.js +++ b/src/components/Widgets/ListControl.js @@ -1,6 +1,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { List, Map, fromJS } from 'immutable'; +import ImmutablePropTypes from 'react-immutable-proptypes'; import { SortableContainer, SortableElement, SortableHandle } from 'react-sortable-hoc'; import FontIcon from 'react-toolbox/lib/font_icon'; import ObjectControl from './ObjectControl'; @@ -36,7 +37,9 @@ export default class ListControl extends Component { value: PropTypes.node, field: PropTypes.node, forID: PropTypes.string, + mediaPaths: ImmutablePropTypes.map.isRequired, getAsset: PropTypes.func.isRequired, + onOpenMediaLibrary: PropTypes.func.isRequired, onAddAsset: PropTypes.func.isRequired, onRemoveAsset: PropTypes.func.isRequired, }; @@ -47,6 +50,16 @@ export default class ListControl extends Component { this.valueType = null; } + /** + * Always update so that each nested widget has the option to update. This is + * required because ControlHOC provides a default `shouldComponentUpdate` + * which only updates if the value changes, but every widget must be allowed + * to override this. + */ + shouldComponentUpdate() { + return true; + } + componentDidMount() { const { field } = this.props; if (field.get('fields')) { @@ -147,7 +160,7 @@ export default class ListControl extends Component { }; renderItem = (item, index) => { - const { field, getAsset, onAddAsset, onRemoveAsset } = this.props; + const { field, getAsset, mediaPaths, onOpenMediaLibrary, onAddAsset, onRemoveAsset } = this.props; const { itemsCollapsed } = this.state; const collapsed = itemsCollapsed.get(index); const classNames = ['nc-listControl-item', collapsed ? 'nc-listControl-collapsed' : '']; @@ -167,6 +180,8 @@ export default class ListControl extends Component { className="nc-listControl-objectControl" onChange={this.handleChangeFor(index)} getAsset={getAsset} + onOpenMediaLibrary={onOpenMediaLibrary} + mediaPaths={mediaPaths} onAddAsset={onAddAsset} onRemoveAsset={onRemoveAsset} /> diff --git a/src/components/Widgets/Markdown/MarkdownControl/Toolbar/ToolbarPluginForm.js b/src/components/Widgets/Markdown/MarkdownControl/Toolbar/ToolbarPluginForm.js index cb356a3a696f..ee7d641b6f82 100644 --- a/src/components/Widgets/Markdown/MarkdownControl/Toolbar/ToolbarPluginForm.js +++ b/src/components/Widgets/Markdown/MarkdownControl/Toolbar/ToolbarPluginForm.js @@ -1,10 +1,13 @@ import PropTypes from 'prop-types'; import React from 'react'; +import ImmutablePropTypes from "react-immutable-proptypes"; +import { connect } from 'react-redux'; import { Map } from 'immutable'; import { Button } from 'react-toolbox/lib/button'; +import { openMediaLibrary } from '../../../../../actions/mediaLibrary'; import ToolbarPluginFormControl from './ToolbarPluginFormControl'; -export default class ToolbarPluginForm extends React.Component { +class ToolbarPluginForm extends React.Component { static propTypes = { plugin: PropTypes.object.isRequired, onSubmit: PropTypes.func.isRequired, @@ -12,6 +15,8 @@ export default class ToolbarPluginForm extends React.Component { onAddAsset: PropTypes.func.isRequired, onRemoveAsset: PropTypes.func.isRequired, getAsset: PropTypes.func.isRequired, + mediaPaths: ImmutablePropTypes.map.isRequired, + onOpenMediaLibrary: PropTypes.func.isRequired, }; constructor(props) { @@ -37,6 +42,8 @@ export default class ToolbarPluginForm extends React.Component { onRemoveAsset, getAsset, onChange, + onOpenMediaLibrary, + mediaPaths, } = this.props; return ( @@ -54,6 +61,8 @@ export default class ToolbarPluginForm extends React.Component { onChange={(val) => { this.setState({ data: this.state.data.set(field.get('name'), val) }); }} + mediaPaths={mediaPaths} + onOpenMediaLibrary={onOpenMediaLibrary} /> ))}
@@ -66,3 +75,13 @@ export default class ToolbarPluginForm extends React.Component { ); } } + +const mapStateToProps = state => ({ + mediaPaths: state.mediaLibrary.get('controlMedia'), +}); + +const mapDispatchToProps = { + onOpenMediaLibrary: openMediaLibrary, +}; + +export default connect(mapStateToProps, mapDispatchToProps)(ToolbarPluginForm); diff --git a/src/components/Widgets/Markdown/MarkdownControl/Toolbar/ToolbarPluginFormControl.js b/src/components/Widgets/Markdown/MarkdownControl/Toolbar/ToolbarPluginFormControl.js index 082ea7e479cf..b3af850b2927 100644 --- a/src/components/Widgets/Markdown/MarkdownControl/Toolbar/ToolbarPluginFormControl.js +++ b/src/components/Widgets/Markdown/MarkdownControl/Toolbar/ToolbarPluginFormControl.js @@ -1,5 +1,6 @@ import PropTypes from 'prop-types'; import React from 'react'; +import ImmutablePropTypes from "react-immutable-proptypes"; import { resolveWidget } from '../../../../Widgets'; const ToolbarPluginFormControl = ({ @@ -10,11 +11,13 @@ const ToolbarPluginFormControl = ({ onRemoveAsset, getAsset, onChange, + mediaPaths, + onOpenMediaLibrary, }) => { const widget = resolveWidget(field.get('widget') || 'string'); const key = `field-${ field.get('name') }`; const Control = widget.control; - const controlProps = { field, value, onAddAsset, onRemoveAsset, getAsset, onChange }; + const controlProps = { field, value, onAddAsset, onRemoveAsset, getAsset, onChange, mediaPaths, onOpenMediaLibrary }; return (
@@ -34,6 +37,8 @@ ToolbarPluginFormControl.propTypes = { onRemoveAsset: PropTypes.func.isRequired, getAsset: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired, + mediaPaths: ImmutablePropTypes.map.isRequired, + onOpenMediaLibrary: PropTypes.func.isRequired, }; export default ToolbarPluginFormControl; diff --git a/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/index.css b/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/index.css index c0e4ccf84c51..ac2ce2ca835a 100644 --- a/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/index.css +++ b/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/index.css @@ -18,7 +18,7 @@ overflow: hidden; overflow-x: auto; min-height: var(--richTextEditorMinHeight); - font-family: var(--fontFamily); + font-family: var(--fontFamilyPrimary); } .nc-visualEditor-editor h1 { diff --git a/src/components/Widgets/ObjectControl.js b/src/components/Widgets/ObjectControl.js index ca1fffc2496f..c4eb8705b65e 100644 --- a/src/components/Widgets/ObjectControl.js +++ b/src/components/Widgets/ObjectControl.js @@ -1,11 +1,15 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { Map } from 'immutable'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ControlHOC from './ControlHOC'; import { resolveWidget } from '../Widgets'; export default class ObjectControl extends Component { static propTypes = { onChange: PropTypes.func.isRequired, + onOpenMediaLibrary: PropTypes.func.isRequired, + mediaPaths: ImmutablePropTypes.map.isRequired, onAddAsset: PropTypes.func.isRequired, onRemoveAsset: PropTypes.func.isRequired, getAsset: PropTypes.func.isRequired, @@ -24,10 +28,25 @@ export default class ObjectControl extends Component { * e.g. when debounced, always get the latest object value instead of usin * `this.props.value` directly. */ - getObjectValue = () => this.props.value; + getObjectValue = () => this.props.value || Map(); + + /* + * 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; + } + + onChange = (fieldName, newValue, newMetadata) => { + const newObjectValue = this.getObjectValue().set(fieldName, newValue); + return this.props.onChange(newObjectValue, newMetadata); + }; controlFor(field) { - const { onAddAsset, onRemoveAsset, getAsset, value, onChange } = this.props; + const { onAddAsset, onOpenMediaLibrary, mediaPaths, onRemoveAsset, getAsset, value, onChange } = this.props; if (field.get('widget') === 'hidden') { return null; } @@ -37,20 +56,18 @@ export default class ObjectControl extends Component { return (
- { - React.createElement(widget.control, { - id: field.get('name'), - field, - value: fieldValue, - onChange: (val, metadata) => { - onChange((this.getObjectValue() || Map()).set(field.get('name'), val), metadata); - }, - onAddAsset, - onRemoveAsset, - getAsset, - forID: field.get('name'), - }) - } +
); } diff --git a/src/containers/App.js b/src/containers/App.js index fff36c141661..b7414aadb4a7 100644 --- a/src/containers/App.js +++ b/src/containers/App.js @@ -17,7 +17,9 @@ import { navigateToCollection as actionNavigateToCollection, createNewEntryInCollection as actionCreateNewEntryInCollection, } from '../actions/findbar'; +import { openMediaLibrary as actionOpenMediaLibrary } from '../actions/mediaLibrary'; import AppHeader from '../components/AppHeader/AppHeader'; +import MediaLibrary from '../components/MediaLibrary/MediaLibrary'; import { Loader, Toast } from '../components/UI/index'; import { getCollectionUrl, getNewEntryUrl } from '../lib/urlHelper'; import { SIMPLE, EDITORIAL_WORKFLOW } from '../constants/publishModes'; @@ -114,6 +116,7 @@ class App extends React.Component { logoutUser, isFetching, publishMode, + openMediaLibrary, } = this.props; @@ -190,6 +193,7 @@ class App extends React.Component { onCreateEntryClick={createNewEntryInCollection} onLogoutClick={logoutUser} toggleDrawer={toggleSidebar} + openMediaLibrary={openMediaLibrary} />
{ isFetching && } @@ -202,6 +206,7 @@ class App extends React.Component { +
@@ -231,6 +236,9 @@ function mapDispatchToProps(dispatch) { createNewEntryInCollection: (collectionName) => { dispatch(actionCreateNewEntryInCollection(collectionName)); }, + openMediaLibrary: () => { + dispatch(actionOpenMediaLibrary()); + }, logoutUser: () => { dispatch(actionLogoutUser()); }, diff --git a/src/containers/EntryPage.js b/src/containers/EntryPage.js index c0a2d805b4f8..8ff0814431f9 100644 --- a/src/containers/EntryPage.js +++ b/src/containers/EntryPage.js @@ -16,6 +16,7 @@ import { import { closeEntry } from '../actions/editor'; import { deserializeValues } from '../lib/serializeEntryValues'; import { addAsset, removeAsset } from '../actions/media'; +import { openMediaLibrary } from '../actions/mediaLibrary'; import { openSidebar } from '../actions/globalUI'; import { selectEntry, getAsset } from '../reducers'; import { selectFields } from '../reducers/collections'; @@ -34,11 +35,13 @@ class EntryPage extends React.Component { 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, removeAsset: PropTypes.func.isRequired, closeEntry: PropTypes.func.isRequired, openSidebar: PropTypes.func.isRequired, @@ -125,10 +128,12 @@ class EntryPage extends React.Component { entry, entryDraft, fields, + mediaPaths, boundGetAsset, collection, changeDraftField, changeDraftFieldValidation, + openMediaLibrary, addAsset, removeAsset, closeEntry, @@ -150,8 +155,10 @@ class EntryPage extends React.Component { fields={fields} fieldsMetaData={entryDraft.get('fieldsMetaData')} fieldsErrors={entryDraft.get('fieldsErrors')} + mediaPaths={mediaPaths} onChange={changeDraftField} onValidate={changeDraftFieldValidation} + onOpenMediaLibrary={openMediaLibrary} onAddAsset={addAsset} onRemoveAsset={removeAsset} onPersist={this.handlePersistEntry} @@ -165,18 +172,20 @@ class EntryPage extends React.Component { } function mapStateToProps(state, ownProps) { - const { collections, entryDraft } = state; + const { collections, entryDraft, mediaLibrary } = state; const slug = ownProps.match.params.slug; const collection = collections.get(ownProps.match.params.name); const newEntry = ownProps.newRecord === true; const fields = selectFields(collection, slug); const entry = newEntry ? null : selectEntry(state, collection.get('name'), slug); const boundGetAsset = getAsset.bind(null, state); + const mediaPaths = mediaLibrary.get('controlMedia'); return { collection, collections, newEntry, entryDraft, + mediaPaths, boundGetAsset, fields, slug, @@ -189,6 +198,7 @@ export default connect( { changeDraftField, changeDraftFieldValidation, + openMediaLibrary, addAsset, removeAsset, loadEntry, diff --git a/src/index.css b/src/index.css index 7e1a31eba060..f43a4f0581a0 100644 --- a/src/index.css +++ b/src/index.css @@ -16,6 +16,7 @@ @import "./components/UI/icon/Icon.css"; @import "./components/UI/loader/Loader.css"; @import "./components/UI/toast/Toast.css"; +@import "./components/UI/Dialog/Dialog.css"; @import "./components/UnpublishedListing/UnpublishedListing.css"; @import "./components/UnpublishedListing/UnpublishedListingCardMeta.css"; @import "./components/Widgets/BooleanControl.css"; @@ -32,6 +33,7 @@ @import "./containers/App.css"; @import "./containers/CollectionPage.css"; @import "./containers/Sidebar.css"; +@import "./components/MediaLibrary/MediaLibrary.css"; html { box-sizing: border-box; @@ -45,7 +47,7 @@ html { } body { - font-family: var(--fontFamily); + font-family: var(--fontFamilyPrimary); height: 100%; background-color: #fff; color: #7c8382; @@ -54,7 +56,7 @@ body { h1, h2, h3, h4, h5, h6, p { margin: 0; - font-family: var(--fontFamily); + font-family: var(--fontFamilyPrimary); } h1 { diff --git a/src/integrations/providers/assetStore/implementation.js b/src/integrations/providers/assetStore/implementation.js index a0dac10dacd5..b2399aa3eb19 100644 --- a/src/integrations/providers/assetStore/implementation.js +++ b/src/integrations/providers/assetStore/implementation.js @@ -1,3 +1,6 @@ +import { pickBy } from 'lodash'; +import { addParams } from '../../../lib/urlHelper'; + export default class AssetStore { constructor(config, getToken) { this.config = config; @@ -52,21 +55,44 @@ export default class AssetStore { })); } - - request(path, options = {}) { + async request(path, options = {}) { const headers = this.requestHeaders(options.headers || {}); const url = this.urlFor(path, options); - return fetch(url, { ...options, headers }).then((response) => { - const contentType = response.headers.get('Content-Type'); - if (contentType && contentType.match(/json/)) { - return this.parseJsonResponse(response); - } + const response = await fetch(url, { ...options, headers }); + const contentType = response.headers.get('Content-Type'); + const isJson = contentType && contentType.match(/json/); + const content = isJson ? await this.parseJsonResponse(response) : response.text(); + return content; + } - return response.text(); + async retrieve(query, page) { + const params = pickBy({ search: query, page }, val => !!val); + const url = addParams(this.getSignedFormURL, params); + const token = await this.getToken(); + const headers = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${ token }`, + }; + const response = await this.request(url, { headers }); + const files = response.map(({ id, name, size, url }) => { + return { id, name, size, url, urlIsPublicPath: true }; }); + return files; + } + + delete(assetID) { + const url = `${ this.getSignedFormURL }/${ assetID }` + return this.getToken() + .then(token => this.request(url, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${ token }`, + }, + })); } - upload(file, privateUpload = false) { + async upload(file, privateUpload = false) { const fileData = { name: file.name, size: file.size @@ -79,33 +105,35 @@ export default class AssetStore { fileData.visibility = 'private'; } - return this.getToken() - .then(token => this.request(this.getSignedFormURL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${ token }`, - }, - body: JSON.stringify(fileData), - })) - .then((response) => { + try { + const token = await this.getToken(); + const response = await this.request(this.getSignedFormURL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${ token }`, + }, + body: JSON.stringify(fileData), + }); const formURL = response.form.url; const formFields = response.form.fields; - const assetID = response.asset.id; - const assetURL = response.asset.url; + const { id, name, size, url } = response.asset; const formData = new FormData(); Object.keys(formFields).forEach(key => formData.append(key, formFields[key])); formData.append('file', file, file.name); - return this.request(formURL, { - method: 'POST', - body: formData, - }) - .then(() => { - if (this.shouldConfirmUpload) this.confirmRequest(assetID); - return { success: true, assetURL }; - }); - }); + await this.request(formURL, { method: 'POST', body: formData }); + + if (this.shouldConfirmUpload) { + await this.confirmRequest(id); + } + + const asset = { id, name, size, url, urlIsPublicPath: true }; + return { success: true, url, asset }; + } + catch(error) { + console.error(error); + } } } diff --git a/src/lib/urlHelper.js b/src/lib/urlHelper.js index 6aed98dd202f..28b01f350025 100644 --- a/src/lib/urlHelper.js +++ b/src/lib/urlHelper.js @@ -1,3 +1,4 @@ +import url from 'url'; import sanitizeFilename from 'sanitize-filename'; import { isString, escapeRegExp, flow, partialRight } from 'lodash'; @@ -13,6 +14,12 @@ export function getNewEntryUrl(collectionName, direct) { return getUrl(`/collections/${ collectionName }/new`, direct); } +export function addParams(urlString, params) { + const parsedUrl = url.parse(urlString, true); + parsedUrl.query = { ...parsedUrl.query, ...params }; + return url.format(parsedUrl); +} + /* See https://www.w3.org/International/articles/idn-and-iri/#path. * According to the new IRI (Internationalized Resource Identifier) spec, RFC 3987, * ASCII chars should be kept the same way as in standard URIs (letters digits _ - . ~). diff --git a/src/reducers/entryDraft.js b/src/reducers/entryDraft.js index 02ac895b6c35..148c0f1b4c3e 100644 --- a/src/reducers/entryDraft.js +++ b/src/reducers/entryDraft.js @@ -85,7 +85,11 @@ const entryDraftReducer = (state = Map(), action) => { }); case ADD_ASSET: - return state.update('mediaFiles', list => list.push(action.payload.public_path)); + if (state.has('mediaFiles')) { + return state.update('mediaFiles', list => list.push(action.payload.public_path)); + } + return state; + case REMOVE_ASSET: return state.update('mediaFiles', list => list.filterNot(path => path === action.payload)); diff --git a/src/reducers/index.js b/src/reducers/index.js index 78dfc39eb5c8..5d830e38b763 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -7,6 +7,7 @@ import editorialWorkflow, * as fromEditorialWorkflow from './editorialWorkflow'; import entryDraft from './entryDraft'; import collections from './collections'; import search from './search'; +import mediaLibrary from './mediaLibrary'; import medias, * as fromMedias from './medias'; import globalUI from './globalUI'; @@ -20,6 +21,7 @@ const reducers = { entries, editorialWorkflow, entryDraft, + mediaLibrary, medias, globalUI, }; diff --git a/src/reducers/mediaLibrary.js b/src/reducers/mediaLibrary.js new file mode 100644 index 000000000000..fee749193ea5 --- /dev/null +++ b/src/reducers/mediaLibrary.js @@ -0,0 +1,93 @@ +import { get } from 'lodash'; +import { Map } from 'immutable'; +import uuid from 'uuid/v4'; +import { + MEDIA_LIBRARY_OPEN, + MEDIA_LIBRARY_CLOSE, + MEDIA_INSERT, + MEDIA_LOAD_REQUEST, + MEDIA_LOAD_SUCCESS, + MEDIA_LOAD_FAILURE, + MEDIA_PERSIST_REQUEST, + MEDIA_PERSIST_SUCCESS, + MEDIA_PERSIST_FAILURE, + MEDIA_DELETE_REQUEST, + MEDIA_DELETE_SUCCESS, + MEDIA_DELETE_FAILURE, +} from '../actions/mediaLibrary'; + +const mediaLibrary = (state = Map({ isVisible: false, controlMedia: Map() }), action) => { + switch (action.type) { + case MEDIA_LIBRARY_OPEN: { + const { controlID, forImage } = action.payload || {}; + return state.withMutations(map => { + map.set('isVisible', true); + map.set('forImage', forImage); + map.set('controlID', controlID); + map.set('canInsert', !!controlID); + }); + } + case MEDIA_LIBRARY_CLOSE: + return state.set('isVisible', false); + case MEDIA_INSERT: { + const controlID = state.get('controlID'); + const mediaPath = get(action, ['payload', 'mediaPath']); + return state.setIn(['controlMedia', controlID], mediaPath); + } + case MEDIA_LOAD_REQUEST: + return state.withMutations(map => { + map.set('isLoading', true); + map.set('isPaginating', action.payload.page > 1); + }); + case MEDIA_LOAD_SUCCESS: { + const { files, page, canPaginate, dynamicSearch, dynamicSearchQuery } = action.payload; + const filesWithKeys = files.map(file => ({ ...file, key: uuid() })); + return state.withMutations(map => { + map.set('isLoading', false); + map.set('isPaginating', false); + map.set('page', page); + map.set('hasNextPage', canPaginate && files && files.length > 0); + map.set('dynamicSearch', dynamicSearch); + map.set('dynamicSearchQuery', dynamicSearchQuery); + map.set('dynamicSearchActive', !!dynamicSearchQuery); + if (page && page > 1) { + const updatedFiles = map.get('files').concat(filesWithKeys); + map.set('files', updatedFiles); + } else { + map.set('files', filesWithKeys); + } + }); + } + case MEDIA_LOAD_FAILURE: + return state.set('isLoading', false); + case MEDIA_PERSIST_REQUEST: + return state.set('isPersisting', true); + case MEDIA_PERSIST_SUCCESS: { + const { file } = action.payload; + return state.withMutations(map => { + const fileWithKey = { ...file, key: uuid() }; + const updatedFiles = [fileWithKey, ...map.get('files')]; + map.set('files', updatedFiles); + map.set('isPersisting', false); + }); + } + case MEDIA_PERSIST_FAILURE: + return state.set('isPersisting', false); + case MEDIA_DELETE_REQUEST: + return state.set('isDeleting', true); + case MEDIA_DELETE_SUCCESS: { + const { key } = action.payload.file; + return state.withMutations(map => { + const updatedFiles = map.get('files').filter(file => file.key !== key); + map.set('files', updatedFiles); + map.set('isDeleting', false); + }); + } + case MEDIA_DELETE_FAILURE: + return state.set('isDeleting', false); + default: + return state; + } +}; + +export default mediaLibrary; diff --git a/src/valueObjects/AssetProxy.js b/src/valueObjects/AssetProxy.js index e88b2c1b0f36..d231672c835b 100644 --- a/src/valueObjects/AssetProxy.js +++ b/src/valueObjects/AssetProxy.js @@ -8,7 +8,7 @@ export const setStore = (storeObj) => { store = storeObj; }; -export default function AssetProxy(value, fileObj, uploaded = false) { +export default function AssetProxy(value, fileObj, uploaded = false, asset) { const config = store.getState().config; this.value = value; this.fileObj = fileObj; @@ -16,6 +16,7 @@ export default function AssetProxy(value, fileObj, uploaded = false) { this.sha = null; this.path = config.get('media_folder') && !uploaded ? resolvePath(value, config.get('media_folder')) : value; this.public_path = !uploaded ? resolvePath(value, config.get('public_folder')) : value; + this.asset = asset; } AssetProxy.prototype.toString = function () { @@ -46,7 +47,7 @@ export function createAssetProxy(value, fileObj, uploaded = false, privateUpload const provider = integration && getIntegrationProvider(state.integrations, currentBackend(state.config).getToken, integration); return provider.upload(fileObj, privateUpload).then( response => ( - new AssetProxy(response.assetURL.replace(/^(https?):/, ''), null, true) + new AssetProxy(response.asset.url.replace(/^(https?):/, ''), null, true, response.asset) ), error => new AssetProxy(value, fileObj, false) ); diff --git a/yarn.lock b/yarn.lock index 6966738ebd61..5033f2f6d353 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1476,6 +1476,10 @@ bytes@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048" +bytes@^2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-2.5.0.tgz#4c9423ea2d252c270c41b2bdefeff9bb6b62c06a" + caller-path@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-0.1.0.tgz#94085ef63581ecd3daa92444a8fe94e82577751f" @@ -3379,6 +3383,18 @@ flatten@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.2.tgz#dae46a9d78fbe25292258cc1e780a41d95c03782" +focus-trap-react@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/focus-trap-react/-/focus-trap-react-3.0.3.tgz#96f934fba39dae9b80a5d2b4839a919c0bc4fa7e" + dependencies: + focus-trap "^2.0.1" + +focus-trap@^2.0.1: + version "2.3.0" + resolved "https://registry.yarnpkg.com/focus-trap/-/focus-trap-2.3.0.tgz#07c91964867d346315f4f5f8df88bf96455316e2" + dependencies: + tabbable "^1.0.3" + for-each@~0.3.2: version "0.3.2" resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.2.tgz#2c40450b9348e97f281322593ba96704b9abd4d4" @@ -8975,6 +8991,10 @@ synesthesia@^1.0.1: dependencies: css-color-names "0.0.3" +tabbable@^1.0.3: + version "1.0.6" + resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-1.0.6.tgz#7c26a87ea6f4a25edf5edb619745a0ae740724fc" + table@^3.7.8: version "3.8.3" resolved "https://registry.yarnpkg.com/table/-/table-3.8.3.tgz#2bbc542f0fda9861a755d3947fefd8b3f513855f"