diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..1fe32959 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,20 @@ +# http://editorconfig.org +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.js] +quote_type = single +spaces_around_operators = true + +[*.css] +quote_type = single + +[*.md] +trim_trailing_whitespace = false diff --git a/.eslintrc b/.eslintrc index 7857e238..153d7d64 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,114 +1,12 @@ -env: - browser: true - es6: true - jest: true - -parser: babel-eslint -plugins: [ - "react", - "class-property" -] - -rules: - # Possible Errors - # https://github.com/eslint/eslint/tree/master/docs/rules#possible-errors - no-control-regex: 2 - no-debugger: 2 - no-dupe-args: 2 - no-dupe-keys: 2 - no-duplicate-case: 2 - no-empty-character-class: 2 - no-ex-assign: 2 - no-extra-boolean-cast : 2 - no-extra-semi: 2 - no-invalid-regexp: 2 - no-irregular-whitespace: 2 - no-proto: 2 - no-unexpected-multiline: 2 - no-unreachable: 2 - valid-typeof: 2 - - # Best Practices - # https://github.com/eslint/eslint/tree/master/docs/rules#best-practices - no-fallthrough: 2 - no-redeclare: 2 - no-constant-condition: 2 - - # Stylistic Issues - # https://github.com/eslint/eslint/tree/master/docs/rules#stylistic-issues - no-alert: 2 - no-console: [2, { allow: ["warn", "error"] }] - comma-spacing: 2 - eol-last: 2 - indent: [2, 2, {SwitchCase: 1}] - max-len: [2, 160, 2] - new-parens: 2 - no-mixed-spaces-and-tabs: 2 - no-multiple-empty-lines: [2, {max: 2}] - no-trailing-spaces: 2 - object-curly-spacing: [1, "always"] - quotes: [2, "single", "avoid-escape"] - semi: 2 - keyword-spacing: 2 - space-before-blocks: [2, "always"] - space-before-function-paren: [2, "never"] - space-in-parens: [2, "never"] - space-infix-ops: 2 - space-unary-ops: 2 - - # ECMAScript 6 - # http://eslint.org/docs/rules/#ecmascript-6 - arrow-spacing: [2, {"before": true, "after": true}] - no-confusing-arrow: 2 - prefer-const: 2 - - # Strict Mode - # https://github.com/eslint/eslint/tree/master/docs/rules#strict-mode - strict: [2, "global"] - - # Variables - # https://github.com/eslint/eslint/tree/master/docs/rules#variables - no-undef: 2 - no-unused-vars: [2, {"args": "none"}] - - - react/prop-types: 1 - react/forbid-prop-types: 1 - react/jsx-boolean-value: 1 - react/jsx-closing-bracket-location: 1 - react/jsx-curly-spacing: 1 - react/jsx-equals-spacing: 1 - react/jsx-handler-names: 1 - react/jsx-indent-props: [2, 2] - react/jsx-indent: [2, 2] - react/jsx-no-bind: 1 - react/jsx-no-duplicate-props: 1 - react/jsx-no-undef: 1 - react/jsx-pascal-case: 1 - react/jsx-uses-react: 1 - react/jsx-uses-vars: 1 - react/no-danger: 1 - react/no-deprecated: 1 - react/no-did-mount-set-state: 1 - react/no-did-update-set-state: 1 - react/no-direct-mutation-state: 1 - react/no-is-mounted: 1 - react/no-multi-comp: 1 - react/no-string-refs: 1 - react/no-unknown-property: 1 - react/prefer-es6-class: 1 - react/prefer-stateless-function: 1 - react/react-in-jsx-scope: 1 - react/require-extension: 1 - react/self-closing-comp: 1 - react/sort-comp: 1 - - class-property/class-property-semicolon: 2 - -# Global scoped method and vars -globals: - netlify: true - require: true - process: true - module: true - CMS_ENV: true +{ + "extends": [ + "eslint-config-netlify" + ], + "settings": { + "import/resolver": { + "webpack": { + "config": "webpack.dev.js" + } + } + } +} diff --git a/.stylelintrc b/.stylelintrc new file mode 100644 index 00000000..e014f049 --- /dev/null +++ b/.stylelintrc @@ -0,0 +1,270 @@ +{ + "extends": [ + "stylelint-config-standard", + "stylelint-config-css-modules" + ], + "plugins": [ + "stylelint-declaration-block-order", + "stylelint-declaration-use-variable" + ], + "rules": { + "at-rule-no-vendor-prefix": true, + "comment-empty-line-before": "never", + "declaration-no-important": true, + "function-url-no-scheme-relative": true, + "function-url-quotes": "always", + "max-nesting-depth": 1, + "media-feature-name-no-vendor-prefix": true, + "number-leading-zero": "never", + "number-max-precision": 3, + "property-no-vendor-prefix": true, + "selector-attribute-quotes": "always", + "selector-no-attribute": true, + "selector-no-qualifying-type": true, + "selector-no-id": true, + "selector-no-type": true, + "selector-no-universal": true, + "selector-no-vendor-prefix": true, + "selector-pseudo-element-colon-notation": "double", + "selector-pseudo-element-no-unknown": true, + "selector-root-no-composition": true, + "selector-type-no-unknown": true, + "string-quotes": "single", + "value-no-vendor-prefix": true, + "stylelint-disable-reason": "always-after", + + "declaration-block-properties-order": [ + [ + "composes", + "position", + "top", + "right", + "bottom", + "left", + "z-index", + "display", + "visibility", + "flex", + "flex-grow", + "flex-shrink", + "flex-basis", + "flex-direction", + "flex-flow", + "flex-wrap", + "align-content", + "align-items", + "align-self", + "justify-content", + "order", + "float", + "clear", + "overflow", + "overflow-x", + "overflow-y", + "-webkit-overflow-scrolling", + "clip", + "box-sizing", + "margin", + "margin-top", + "margin-right", + "margin-bottom", + "margin-left", + "padding", + "padding-top", + "padding-right", + "padding-bottom", + "padding-left", + "min-width", + "min-height", + "max-width", + "max-height", + "width", + "height", + "outline", + "outline-width", + "outline-style", + "outline-color", + "outline-offset", + "border", + "border-spacing", + "border-collapse", + "border-width", + "border-style", + "border-color", + "border-top", + "border-top-width", + "border-top-style", + "border-top-color", + "border-right", + "border-right-width", + "border-right-style", + "border-right-color", + "border-bottom", + "border-bottom-width", + "border-bottom-style", + "border-bottom-color", + "border-left", + "border-left-width", + "border-left-style", + "border-left-color", + "border-radius", + "border-top-left-radius", + "border-top-right-radius", + "border-bottom-right-radius", + "border-bottom-left-radius", + "border-image", + "border-image-source", + "border-image-slice", + "border-image-width", + "border-image-outset", + "border-image-repeat", + "border-top-image", + "border-right-image", + "border-bottom-image", + "border-left-image", + "border-corner-image", + "border-top-left-image", + "border-top-right-image", + "border-bottom-right-image", + "border-bottom-left-image", + "background", + "background-color", + "background-image", + "background-attachment", + "background-position", + "background-position-x", + "background-position-y", + "background-clip", + "background-origin", + "background-size", + "background-repeat", + "box-decoration-break", + "box-shadow", + "color", + "table-layout", + "caption-side", + "empty-cells", + "list-style", + "list-style-position", + "list-style-type", + "list-style-image", + "quotes", + "content", + "counter-increment", + "counter-reset", + "-ms-writing-mode", + "vertical-align", + "text-align", + "text-align-last", + "text-decoration", + "text-emphasis", + "text-emphasis-position", + "text-emphasis-style", + "text-emphasis-color", + "text-indent", + "text-justify", + "text-outline", + "text-transform", + "text-wrap", + "text-overflow", + "text-overflow-ellipsis", + "text-overflow-mode", + "text-shadow", + "text-rendering", + "white-space", + "word-spacing", + "word-wrap", + "word-break", + "tab-size", + "hyphens", + "letter-spacing", + "font", + "font-weight", + "font-style", + "font-variant", + "font-size-adjust", + "font-stretch", + "font-size", + "font-family", + "font-feature-settings", + "-webkit-font-smoothing", + "-moz-osx-font-smoothing", + "src", + "line-height", + "opacity", + "filter", + "resize", + "cursor", + "nav-index", + "nav-up", + "nav-right", + "nav-down", + "nav-left", + "transition", + "transition-delay", + "transition-timing-function", + "transition-duration", + "transition-property", + "transform", + "transform-origin", + "animation", + "animation-name", + "animation-duration", + "animation-play-state", + "animation-timing-function", + "animation-delay", + "animation-iteration-count", + "animation-direction", + "animation-fill-mode", + "pointer-events", + "unicode-bidi", + "direction", + "columns", + "column-span", + "column-width", + "column-count", + "column-fill", + "column-gap", + "column-rule", + "column-rule-width", + "column-rule-style", + "column-rule-color", + "break-before", + "break-inside", + "break-after", + "page-break-before", + "page-break-inside", + "page-break-after", + "orphans", + "widows", + "zoom", + "max-zoom", + "min-zoom", + "user-zoom", + "orientation", + "user-select", + "fill", + "stroke" + ], + { "unspecified": "bottomAlphabetical" } + ], + + "plugin/declaration-block-order": [ + "custom-properties", + "dollar-variables", + "declarations", + "rules", + "at-rules" + ], + + "sh-waqar/declaration-use-variable": [ + [ + "/color/", + "z-index", + "font-size", + "border-radius", + "/background/" + ] + ] + } +} diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..b60c18b6 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,3 @@ +language: node_js +node_js: + - "6.0" \ No newline at end of file diff --git a/package.json b/package.json index 30ad7bea..534e51ba 100644 --- a/package.json +++ b/package.json @@ -5,19 +5,29 @@ "main": "index.js", "scripts": { "start": "webpack-dev-server --config webpack.dev.js", - "test": "NODE_ENV=test jest", - "test:watch": "npm test -- --watch", + "test": "NODE_ENV=test npm run lint && jest", + "test:watch": "NODE_ENV=test jest --watch", "build": "webpack --config webpack.config.js", "storybook": "start-storybook -p 9001", "storybook-build": "build-storybook -o dist", - "lint": "eslint .", - "lint:fix": "npm run lint -- --fix", - "lint:staged": "lint-staged" + "lint": "npm run lint:js & npm run lint:css", + "lint:js": "eslint .", + "lint:js:fix": "npm run lint:js -- --fix", + "lint:css": "stylelint 'src/**/*.css'", + "lint:css:fix": "stylefmt --recursive src/", + "lint:staged": "lint-staged", + "deps": "npm-check -s", + "deps:update": "npm-check -u" }, "lint-staged": { - "*.@(js|jsx)": [ + "*.js": [ "eslint --fix", "git add" + ], + "*.css": [ + "stylefmt", + "stylelint", + "git add" ] }, "pre-commit": "lint:staged", @@ -29,9 +39,7 @@ "license": "MIT", "devDependencies": { "@kadira/storybook": "^1.36.0", - "autoprefixer": "^6.3.3", "babel-core": "^6.5.1", - "babel-eslint": "^6.1.2", "babel-jest": "^15.0.0", "babel-loader": "^6.2.2", "babel-plugin-lodash": "^3.2.0", @@ -43,28 +51,61 @@ "babel-runtime": "^6.5.0", "css-loader": "^0.23.1", "enzyme": "^2.4.1", - "eslint": "^3.5.0", - "eslint-plugin-class-property": "^1.0.1", - "eslint-plugin-react": "^5.1.1", + "eslint": "^3.7.1", + "eslint-config-netlify": "github:netlify/eslint-config-netlify", "expect": "^1.20.2", "exports-loader": "^0.6.3", "file-loader": "^0.8.5", - "immutable": "^3.7.6", "imports-loader": "^0.6.5", "jest-cli": "^15.1.1", - "js-yaml": "^3.5.3", "lint-staged": "^3.0.3", - "moment": "^2.11.2", "node-sass": "^3.10.0", - "normalizr": "^2.0.0", + "npm-check": "^5.2.3", "postcss-cssnext": "^2.7.0", "postcss-import": "^8.1.2", "postcss-loader": "^0.9.1", "pre-commit": "^1.1.3", + "sass-loader": "^4.0.2", + "style-loader": "^0.13.0", + "stylefmt": "^4.3.1", + "stylelint": "^7.3.1", + "stylelint-config-css-modules": "^0.1.0", + "stylelint-config-standard": "^13.0.2", + "stylelint-declaration-block-order": "^0.1.0", + "stylelint-declaration-use-variable": "^1.6.0", + "url-loader": "^0.5.7", + "webpack": "^1.13.2", + "webpack-dev-server": "^1.15.1", + "webpack-merge": "^0.14.1", + "webpack-postcss-tools": "^1.1.1" + }, + "dependencies": { + "autoprefixer": "^6.3.3", + "bricks.js": "^1.7.0", + "dateformat": "^1.0.12", + "fuzzy": "^0.1.1", + "immutable": "^3.7.6", + "immutability-helper": "^2.0.0", + "js-base64": "^2.1.9", + "js-yaml": "^3.5.3", + "json-loader": "^0.5.4", + "localforage": "^1.4.2", + "lodash": "^4.13.1", + "markup-it": "git+https://github.com/cassiozen/markup-it.git", + "material-design-icons": "^3.0.1", + "moment": "^2.11.2", + "normalize.css": "^4.2.0", + "pluralize": "^3.0.0", + "prismjs": "^1.5.1", "react": "^15.1.0", - "react-addons-test-utils": "^15.3.2", "react-dom": "^15.1.0", "react-hot-loader": "^3.0.0-beta.2", + "react-addons-css-transition-group": "^15.3.1", + "react-datetime": "^2.6.0", + "react-portal": "^2.2.1", + "react-simple-dnd": "^0.1.2", + "react-toolbox": "^1.2.1", + "react-waypoint": "^3.1.3", "react-immutable-proptypes": "^1.6.0", "react-lazy-load": "^3.0.3", "react-pure-render": "^1.0.2", @@ -73,37 +114,10 @@ "react-router-redux": "^4.0.5", "redux": "^3.3.1", "redux-thunk": "^1.0.3", - "sass-loader": "^4.0.2", - "style-loader": "^0.13.0", - "url-loader": "^0.5.7", - "webpack": "^1.13.2", - "webpack-dev-server": "^1.15.1", - "webpack-merge": "^0.14.1", - "webpack-postcss-tools": "^1.1.1", - "whatwg-fetch": "^1.0.0" - }, - "dependencies": { - "bricks.js": "^1.7.0", - "dateformat": "^1.0.12", - "fuzzy": "^0.1.1", - "immutability-helper": "^2.0.0", - "js-base64": "^2.1.9", - "json-loader": "^0.5.4", - "localforage": "^1.4.2", - "lodash": "^4.13.1", - "markup-it": "git+https://github.com/cassiozen/markup-it.git", - "material-design-icons": "^3.0.1", - "normalize.css": "^4.2.0", - "pluralize": "^3.0.0", - "prismjs": "^1.5.1", - "react-addons-css-transition-group": "^15.3.1", - "react-datetime": "^2.6.0", - "react-portal": "^2.2.1", - "react-simple-dnd": "^0.1.2", - "react-toolbox": "^1.2.1", "selection-position": "^1.0.0", "semaphore": "^1.0.5", "slate": "^0.14.14", - "slate-drop-or-paste-images": "^0.2.0" + "slate-drop-or-paste-images": "^0.2.0", + "whatwg-fetch": "^1.0.0" } } diff --git a/src/actions/config.js b/src/actions/config.js index 8b286142..9500df2f 100644 --- a/src/actions/config.js +++ b/src/actions/config.js @@ -1,8 +1,6 @@ import yaml from 'js-yaml'; -import _ from 'lodash'; import { currentBackend } from '../backends/backend'; import { authenticate } from '../actions/auth'; -import * as publishModes from '../constants/publishModes'; import * as MediaProxy from '../valueObjects/MediaProxy'; export const CONFIG_REQUEST = 'CONFIG_REQUEST'; @@ -72,19 +70,5 @@ function parseConfig(data) { } } - if (!('publish_mode' in config) || _.values(publishModes).indexOf(config.publish_mode) === -1) { - // Make sure there is a publish workflow mode set - config.publish_mode = publishModes.SIMPLE; - } - - if (!('public_folder' in config)) { - // Make sure there is a public folder - config.public_folder = config.media_folder; - } - - if (config.public_folder.charAt(0) !== '/') { - config.public_folder = '/' + config.public_folder; - } - return config; } diff --git a/src/actions/entries.js b/src/actions/entries.js index 83cb5c65..bf5d5b46 100644 --- a/src/actions/entries.js +++ b/src/actions/entries.js @@ -1,5 +1,6 @@ import { currentBackend } from '../backends/backend'; -import { getMedia } from '../reducers'; +import { getIntegrationProvider } from '../integrations'; +import { getMedia, selectIntegration } from '../reducers'; /* * Contant Declarations @@ -21,6 +22,9 @@ export const ENTRY_PERSIST_REQUEST = 'ENTRY_PERSIST_REQUEST'; export const ENTRY_PERSIST_SUCCESS = 'ENTRY_PERSIST_SUCCESS'; export const ENTRY_PERSIST_FAILURE = 'ENTRY_PERSIST_FAILURE'; +export const SEARCH_ENTRIES_REQUEST = 'SEARCH_ENTRIES_REQUEST'; +export const SEARCH_ENTRIES_SUCCESS = 'SEARCH_ENTRIES_SUCCESS'; +export const SEARCH_ENTRIES_FAILURE = 'SEARCH_ENTRIES_FAILURE'; /* * Simple Action Creators (Internal) @@ -61,7 +65,7 @@ export function entriesLoaded(collection, entries, pagination) { payload: { collection: collection.get('name'), entries: entries, - pages: pagination + page: pagination } }; } @@ -110,6 +114,34 @@ export function emmptyDraftCreated(entry) { }; } +export function searchingEntries(searchTerm) { + return { + type: SEARCH_ENTRIES_REQUEST, + payload: { searchTerm } + }; +} + +export function SearchSuccess(searchTerm, entries, page) { + return { + type: SEARCH_ENTRIES_SUCCESS, + payload: { + searchTerm, + entries, + page + } + }; +} + +export function SearchFailure(searchTerm, error) { + return { + type: SEARCH_ENTRIES_FAILURE, + payload: { + searchTerm, + error + } + }; +} + /* * Exported simple Action Creators */ @@ -136,25 +168,30 @@ export function changeDraft(entry) { /* * Exported Thunk Action Creators */ -export function loadEntry(collection, slug) { + +export function loadEntry(entry, collection, slug) { return (dispatch, getState) => { const state = getState(); const backend = currentBackend(state.config); - dispatch(entryLoading(collection, slug)); - backend.entry(collection, slug) - .then((entry) => dispatch(entryLoaded(collection, entry))); + let getPromise; + if (entry && entry.get('path')) { + getPromise = backend.getEntry(entry.get('collection'), entry.get('slug'), entry.get('path')); + } else { + getPromise = backend.lookupEntry(collection, slug); + } + return getPromise.then((loadedEntry) => dispatch(entryLoaded(collection, loadedEntry))); }; } -export function loadEntries(collection) { +export function loadEntries(collection, page = 0) { return (dispatch, getState) => { if (collection.get('isFetching')) { return; } const state = getState(); - const backend = currentBackend(state.config); - + const integration = selectIntegration(state, collection.get('name'), 'listEntries'); + const provider = integration ? getIntegrationProvider(state.integrations, integration) : currentBackend(state.config); dispatch(entriesLoading(collection)); - backend.entries(collection).then( + provider.listEntries(collection, page).then( (response) => dispatch(entriesLoaded(collection, response.entries, response.pagination)), (error) => dispatch(entriesFailed(collection, error)) ); @@ -184,3 +221,19 @@ export function persistEntry(collection, entry) { ); }; } + +export function searchEntries(searchTerm, page = 0) { + return (dispatch, getState) => { + const state = getState(); + let collections = state.collections.keySeq().toArray(); + collections = collections.filter(collection => selectIntegration(state, collection, 'search')); + const integration = selectIntegration(state, collections[0], 'search'); + if (!integration) console.warn('There isn\'t a search integration configured.'); + const provider = integration ? getIntegrationProvider(state.integrations, integration) : currentBackend(state.config); + dispatch(searchingEntries(searchTerm)); + provider.search(collections, searchTerm, page).then( + (response) => dispatch(SearchSuccess(searchTerm, response.entries, response.pagination)), + (error) => dispatch(SearchFailure(searchTerm, error)) + ); + }; +} diff --git a/src/actions/findbar.js b/src/actions/findbar.js index b726ec58..4abfd3db 100644 --- a/src/actions/findbar.js +++ b/src/actions/findbar.js @@ -31,7 +31,7 @@ export function runCommand(commandName, payload) { window.alert('Find Bar Help (PLACEHOLDER)\n\nLorem ipsum dolor sit amet, consectetur adipiscing elit.'); break; case SEARCH: - history.push('/search'); + history.push(`/search/${payload.searchTerm}`); break; } dispatch(run(commandName, payload)); diff --git a/src/backends/backend.js b/src/backends/backend.js index 1cb9bc42..6c6bb778 100644 --- a/src/backends/backend.js +++ b/src/backends/backend.js @@ -17,6 +17,25 @@ class LocalStorageAuthStore { } } +const slugFormatter = (template, entryData) => { + var date = new Date(); + return template.replace(/\{\{([^\}]+)\}\}/g, function(_, name) { + switch (name) { + case 'year': + return date.getFullYear(); + case 'month': + return ('0' + (date.getMonth() + 1)).slice(-2); + case 'day': + return ('0' + date.getDate()).slice(-2); + case 'slug': + const identifier = entryData.get('title', entryData.get('path')); + return identifier.trim().toLowerCase().replace(/[^a-z0-9\.\-\_]+/gi, '-'); + default: + return entryData.get(name); + } + }); +}; + class Backend { constructor(implementation, authStore = null) { this.implementation = implementation; @@ -46,7 +65,7 @@ class Backend { }); } - entries(collection, page, perPage) { + listEntries(collection, page, perPage) { return this.implementation.entries(collection, page, perPage).then((response) => { return { pagination: response.pagination, @@ -55,8 +74,15 @@ class Backend { }); } - entry(collection, slug) { - return this.implementation.entry(collection, slug).then(this.entryWithFormat(collection)); + // We have the file path. Fetch and parse the file. + getEntry(collection, slug, path) { + return this.implementation.getEntry(collection, slug, path).then(this.entryWithFormat(collection)); + } + + // Will fetch the whole list of files from GitHub and load each file, then looks up for entry. + // (Files are persisted in local storage - only expensive on the first run for each file). + lookupEntry(collection, slug) { + return this.implementation.lookupEntry(collection, slug).then(this.entryWithFormat(collection)); } newEntry(collection) { @@ -87,24 +113,6 @@ class Backend { return this.implementation.unpublishedEntry(collection, slug).then(this.entryWithFormat(collection)); } - slugFormatter(template, entry) { - var date = new Date(); - return template.replace(/\{\{([^\}]+)\}\}/g, function(_, name) { - switch (name) { - case 'year': - return date.getFullYear(); - case 'month': - return ('0' + (date.getMonth() + 1)).slice(-2); - case 'day': - return ('0' + date.getDate()).slice(-2); - case 'slug': - return entry.getIn(['data', 'title']).trim().toLowerCase().replace(/[^a-z0-9\.\-\_]+/gi, '-'); - default: - return entry.getIn(['data', name]); - } - }); - } - persistEntry(config, collection, entryDraft, MediaFiles, options) { const newEntry = entryDraft.getIn(['entry', 'newRecord']) || false; @@ -116,7 +124,7 @@ class Backend { const entryData = entryDraft.getIn(['entry', 'data']).toJS(); let entryObj; if (newEntry) { - const slug = this.slugFormatter(collection.get('slug'), entryDraft.get('entry')); + const slug = slugFormatter(collection.get('slug'), entryDraft.getIn(['entry', 'data'])); entryObj = { path: `${collection.get('folder')}/${slug}.md`, slug: slug, @@ -172,11 +180,11 @@ export function resolveBackend(config) { switch (name) { case 'test-repo': - return new Backend(new TestRepoBackend(config), authStore); + return new Backend(new TestRepoBackend(config, slugFormatter), authStore); case 'github': - return new Backend(new GitHubBackend(config), authStore); + return new Backend(new GitHubBackend(config, slugFormatter), authStore); case 'netlify-git': - return new Backend(new NetlifyGitBackend(config), authStore); + return new Backend(new NetlifyGitBackend(config, slugFormatter), authStore); default: throw `Backend not found: ${name}`; } diff --git a/src/backends/github/API.js b/src/backends/github/API.js index 70770aed..71818899 100644 --- a/src/backends/github/API.js +++ b/src/backends/github/API.js @@ -11,7 +11,7 @@ export default class API { this.token = token; this.repo = repo; this.branch = branch; - this.repoURL = `/repos/${this.repo}`; + this.repoURL = `/repos/${ this.repo }`; } user() { @@ -20,9 +20,9 @@ export default class API { requestHeaders(headers = {}) { return { - Authorization: `token ${this.token}`, + Authorization: `token ${ this.token }`, 'Content-Type': 'application/json', - ...headers + ...headers, }; } @@ -40,11 +40,11 @@ export default class API { const params = []; if (options.params) { for (const key in options.params) { - params.push(`${key}=${encodeURIComponent(options.params[key])}`); + params.push(`${ key }=${ encodeURIComponent(options.params[key]) }`); } } if (params.length) { - path += `?${params.join('&')}`; + path += `?${ params.join('&') }`; } return API_ROOT + path; } @@ -52,7 +52,7 @@ export default class API { request(path, options = {}) { const headers = this.requestHeaders(options.headers || {}); const url = this.urlFor(path, options); - return fetch(url, { ...options, headers: headers }).then((response) => { + return fetch(url, { ...options, headers }).then((response) => { const contentType = response.headers.get('Content-Type'); if (contentType && contentType.match(/json/)) { return this.parseJsonResponse(response); @@ -63,20 +63,20 @@ export default class API { } checkMetadataRef() { - return this.request(`${this.repoURL}/git/refs/meta/_netlify_cms?${Date.now()}`, { + return this.request(`${ this.repoURL }/git/refs/meta/_netlify_cms?${ Date.now() }`, { cache: 'no-store', }) .then(response => response.object) - .catch(error => { + .catch((error) => { // Meta ref doesn't exist const readme = { - raw: '# Netlify CMS\n\nThis tree is used by the Netlify CMS to store metadata information for specific files and branches.' + raw: '# Netlify CMS\n\nThis tree is used by the Netlify CMS to store metadata information for specific files and branches.', }; return this.uploadBlob(readme) - .then(item => this.request(`${this.repoURL}/git/trees`, { + .then(item => this.request(`${ this.repoURL }/git/trees`, { method: 'POST', - body: JSON.stringify({ tree: [{ path: 'README.md', mode: '100644', type: 'blob', sha: item.sha }] }) + body: JSON.stringify({ tree: [{ path: 'README.md', mode: '100644', type: 'blob', sha: item.sha }] }), })) .then(tree => this.commit('First Commit', tree)) .then(response => this.createRef('meta', '_netlify_cms', response.sha)) @@ -88,32 +88,32 @@ export default class API { return this.checkMetadataRef() .then((branchData) => { const fileTree = { - [`${key}.json`]: { - path: `${key}.json`, + [`${ key }.json`]: { + path: `${ key }.json`, raw: JSON.stringify(data), - file: true - } + file: true, + }, }; - return this.uploadBlob(fileTree[`${key}.json`]) + return this.uploadBlob(fileTree[`${ key }.json`]) .then(item => this.updateTree(branchData.sha, '/', fileTree)) - .then(changeTree => this.commit(`Updating “${key}” metadata`, changeTree)) + .then(changeTree => this.commit(`Updating “${ key }” metadata`, changeTree)) .then(response => this.patchRef('meta', '_netlify_cms', response.sha)) .then(() => { - LocalForage.setItem(`gh.meta.${key}`, { + LocalForage.setItem(`gh.meta.${ key }`, { expires: Date.now() + 300000, // In 5 minutes - data + data, }); }); }); } retrieveMetadata(key) { - const cache = LocalForage.getItem(`gh.meta.${key}`); + const cache = LocalForage.getItem(`gh.meta.${ key }`); return cache.then((cached) => { if (cached && cached.expires > Date.now()) { return cached.data; } - return this.request(`${this.repoURL}/contents/${key}.json`, { + return this.request(`${ this.repoURL }/contents/${ key }.json`, { params: { ref: 'refs/meta/_netlify_cms' }, headers: { Accept: 'application/vnd.github.VERSION.raw' }, cache: 'no-store', @@ -123,17 +123,17 @@ export default class API { } readFile(path, sha, branch = this.branch) { - const cache = sha ? LocalForage.getItem(`gh.${sha}`) : Promise.resolve(null); + const cache = sha ? LocalForage.getItem(`gh.${ sha }`) : Promise.resolve(null); return cache.then((cached) => { if (cached) { return cached; } - return this.request(`${this.repoURL}/contents/${path}`, { + return this.request(`${ this.repoURL }/contents/${ path }`, { headers: { Accept: 'application/vnd.github.VERSION.raw' }, params: { ref: branch }, - cache: false + cache: false, }).then((result) => { if (sha) { - LocalForage.setItem(`gh.${sha}`, result); + LocalForage.setItem(`gh.${ sha }`, result); } return result; }); @@ -141,25 +141,27 @@ export default class API { } listFiles(path) { - return this.request(`${this.repoURL}/contents/${path}`, { - params: { ref: this.branch } + return this.request(`${ this.repoURL }/contents/${ path }`, { + params: { ref: this.branch }, }); } readUnpublishedBranchFile(contentKey) { let metaData; - return this.retrieveMetadata(contentKey) - .then(data => { + const unpublishedPromise = this.retrieveMetadata(contentKey) + .then((data) => { metaData = data; return this.readFile(data.objects.entry, null, data.branch); }) - .then(file => { - return { metaData, file }; + .then(file => ({ metaData, file })) + .catch((error) => { + return null; }); + return unpublishedPromise; } listUnpublishedBranches() { - return this.request(`${this.repoURL}/git/refs/heads/cms`); + return this.request(`${ this.repoURL }/git/refs/heads/cms`); } persistFiles(entry, mediaFiles, options) { @@ -172,7 +174,7 @@ export default class API { files.forEach((file) => { if (file.uploaded) { return; } uploadPromises.push(this.uploadBlob(file)); - parts = file.path.split('/').filter((part) => part); + parts = file.path.split('/').filter(part => part); filename = parts.pop(); subtree = fileTree; while (part = parts.shift()) { @@ -196,14 +198,14 @@ export default class API { } editorialWorkflowGit(fileTree, entry, filesList, options) { - const contentKey = options.collectionName ? `${options.collectionName}-${entry.slug}` : entry.slug; - const branchName = `cms/${contentKey}`; + const contentKey = options.collectionName ? `${ options.collectionName }-${ entry.slug }` : entry.slug; + const branchName = `cms/${ 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 = options.collectionName ? `${options.collectionName}-${entry.slug}` : entry.slug; - const branchName = `cms/${contentKey}`; + const contentKey = options.collectionName ? `${ options.collectionName }-${ entry.slug }` : entry.slug; + const branchName = `cms/${ contentKey }`; return this.getBranch() .then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree)) @@ -211,14 +213,14 @@ export default class API { .then(commitResponse => this.createBranch(branchName, commitResponse.sha)) .then(branchResponse => this.createPR(options.commitMessage, branchName)) .then((prResponse) => { - return this.user().then(user => { + return this.user().then((user) => { return user.name ? user.name : user.login; }) .then(username => this.storeMetadata(contentKey, { type: 'PR', pr: { number: prResponse.number, - head: prResponse.head && prResponse.head.sha + head: prResponse.head && prResponse.head.sha, }, user: username, status: status.first(), @@ -228,9 +230,9 @@ export default class API { description: options.parsedData && options.parsedData.description, objects: { entry: entry.path, - files: filesList + files: filesList, }, - timeStamp: new Date().toISOString() + timeStamp: new Date().toISOString(), })); }); } else { @@ -239,13 +241,13 @@ export default class API { .then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree)) .then(changeTree => this.commit(options.commitMessage, changeTree)) .then((response) => { - const contentKey = options.collectionName ? `${options.collectionName}-${entry.slug}` : entry.slug; - const branchName = `cms/${contentKey}`; - return this.user().then(user => { + const contentKey = options.collectionName ? `${ options.collectionName }-${ entry.slug }` : entry.slug; + const branchName = `cms/${ contentKey }`; + return this.user().then((user) => { return user.name ? user.name : user.login; }) .then(username => this.retrieveMetadata(contentKey)) - .then(metadata => { + .then((metadata) => { let files = metadata.objects && metadata.objects.files || []; files = files.concat(filesList); @@ -255,9 +257,9 @@ export default class API { description: options.parsedData && options.parsedData.description, objects: { entry: entry.path, - files: _.uniq(files) + files: _.uniq(files), }, - timeStamp: new Date().toISOString() + timeStamp: new Date().toISOString(), }; }) .then(updatedMetadata => this.storeMetadata(contentKey, updatedMetadata)) @@ -267,50 +269,50 @@ export default class API { } updateUnpublishedEntryStatus(collection, slug, status) { - const contentKey = collection ? `${collection}-${slug}` : slug; + const contentKey = collection ? `${ collection }-${ slug }` : slug; return this.retrieveMetadata(contentKey) - .then(metadata => { + .then((metadata) => { return { ...metadata, - status + status, }; }) .then(updatedMetadata => this.storeMetadata(contentKey, updatedMetadata)); } publishUnpublishedEntry(collection, slug, status) { - const contentKey = collection ? `${collection}-${slug}` : slug; + const contentKey = collection ? `${ collection }-${ slug }` : slug; return this.retrieveMetadata(contentKey) - .then(metadata => { + .then((metadata) => { const headSha = metadata.pr && metadata.pr.head; const number = metadata.pr && metadata.pr.number; return this.mergePR(headSha, number); }) - .then(() => this.deleteBranch(`cms/${contentKey}`)); + .then(() => this.deleteBranch(`cms/${ contentKey }`)); } createRef(type, name, sha) { - return this.request(`${this.repoURL}/git/refs`, { + return this.request(`${ this.repoURL }/git/refs`, { method: 'POST', - body: JSON.stringify({ ref: `refs/${type}/${name}`, sha }), + body: JSON.stringify({ ref: `refs/${ type }/${ name }`, sha }), }); } patchRef(type, name, sha) { - return this.request(`${this.repoURL}/git/refs/${type}/${name}`, { + return this.request(`${ this.repoURL }/git/refs/${ type }/${ name }`, { method: 'PATCH', - body: JSON.stringify({ sha }) + body: JSON.stringify({ sha }), }); } deleteRef(type, name, sha) { - return this.request(`${this.repoURL}/git/refs/${type}/${name}`, { + return this.request(`${ this.repoURL }/git/refs/${ type }/${ name }`, { method: 'DELETE', }); } getBranch(branch = this.branch) { - return this.request(`${this.repoURL}/branches/${branch}`); + return this.request(`${ this.repoURL }/branches/${ branch }`); } createBranch(branchName, sha) { @@ -327,24 +329,24 @@ export default class API { createPR(title, head, base = 'master') { const body = 'Automatically generated by Netlify CMS'; - return this.request(`${this.repoURL}/pulls`, { + return this.request(`${ this.repoURL }/pulls`, { method: 'POST', body: JSON.stringify({ title, body, head, base }), }); } mergePR(headSha, number) { - return this.request(`${this.repoURL}/pulls/${number}/merge`, { + return this.request(`${ this.repoURL }/pulls/${ number }/merge`, { method: 'PUT', body: JSON.stringify({ commit_message: 'Automatically generated. Merged on Netlify CMS.', - sha: headSha + sha: headSha, }), }); } getTree(sha) { - return sha ? this.request(`${this.repoURL}/git/trees/${sha}`) : Promise.resolve({ tree: [] }); + return sha ? this.request(`${ this.repoURL }/git/trees/${ sha }`) : Promise.resolve({ tree: [] }); } toBase64(str) { @@ -357,12 +359,12 @@ export default class API { const content = item instanceof MediaProxy ? item.toBase64() : this.toBase64(item.raw); return content.then((contentBase64) => { - return this.request(`${this.repoURL}/git/blobs`, { + return this.request(`${ this.repoURL }/git/blobs`, { method: 'POST', body: JSON.stringify({ content: contentBase64, - encoding: 'base64' - }) + encoding: 'base64', + }), }).then((response) => { item.sha = response.sha; item.uploaded = true; @@ -374,11 +376,11 @@ export default class API { updateTree(sha, path, fileTree) { return this.getTree(sha) .then((tree) => { - var obj, filename, fileOrDir; - var updates = []; - var added = {}; + let obj, filename, fileOrDir; + const updates = []; + const added = {}; - for (var i = 0, len = tree.tree.length; i < len; i++) { + for (let i = 0, len = tree.tree.length; i < len; i++) { obj = tree.tree[i]; if (fileOrDir = fileTree[obj.path]) { added[obj.path] = true; @@ -400,12 +402,12 @@ export default class API { } return Promise.all(updates) .then((updates) => { - return this.request(`${this.repoURL}/git/trees`, { + return this.request(`${ this.repoURL }/git/trees`, { method: 'POST', - body: JSON.stringify({ base_tree: sha, tree: updates }) + body: JSON.stringify({ base_tree: sha, tree: updates }), }); }).then((response) => { - return { path: path, mode: '040000', type: 'tree', sha: response.sha, parentSha: sha }; + return { path, mode: '040000', type: 'tree', sha: response.sha, parentSha: sha }; }); }); } @@ -413,9 +415,9 @@ export default class API { commit(message, changeTree) { const tree = changeTree.sha; const parents = changeTree.parentSha ? [changeTree.parentSha] : []; - return this.request(`${this.repoURL}/git/commits`, { + return this.request(`${ this.repoURL }/git/commits`, { method: 'POST', - body: JSON.stringify({ message, tree, parents }) + body: JSON.stringify({ message, tree, parents }), }); } diff --git a/src/backends/github/implementation.js b/src/backends/github/implementation.js index 2d270261..5168edd1 100644 --- a/src/backends/github/implementation.js +++ b/src/backends/github/implementation.js @@ -38,7 +38,7 @@ export default class GitHub { files.map((file) => { promises.push(new Promise((resolve, reject) => { return sem.take(() => this.api.readFile(file.path, file.sha).then((data) => { - resolve(createEntry(file.path, file.path.split('/').pop().replace(/\.[^\.]+$/, ''), data)); + resolve(createEntry(collection.get('name'), file.path.split('/').pop().replace(/\.[^\.]+$/, ''), file.path, { raw: data })); sem.leave(); }).catch((err) => { sem.leave(); @@ -47,18 +47,25 @@ export default class GitHub { })); }); return Promise.all(promises); - }).then((entries) => ({ - pagination: {}, - entries + }).then(entries => ({ + pagination: 0, + entries, })); } - entry(collection, slug) { - return this.entries(collection).then((response) => ( - response.entries.filter((entry) => entry.slug === slug)[0] + + // Will fetch the entire list of entries from github. + lookupEntry(collection, slug) { + return this.entries(collection).then(response => ( + response.entries.filter(entry => entry.slug === slug)[0] )); } + // Fetches a single entry. + getEntry(collection, slug, path) { + return this.api.readFile(path).then(data => createEntry(collection, slug, path, { raw: data })); + } + persistEntry(entry, mediaFiles = [], options = {}) { return this.api.persistFiles(entry, mediaFiles, options); } @@ -71,11 +78,16 @@ export default class GitHub { promises.push(new Promise((resolve, reject) => { const contentKey = branch.ref.split('refs/heads/cms/').pop(); return sem.take(() => this.api.readUnpublishedBranchFile(contentKey).then((data) => { - const entryPath = data.metaData.objects.entry; - const entry = createEntry(entryPath, entryPath.split('/').pop().replace(/\.[^\.]+$/, ''), data.file); - entry.metaData = data.metaData; - resolve(entry); - sem.leave(); + if (data === null || data === undefined) { + resolve(null); + sem.leave(); + } else { + const entryPath = data.metaData.objects.entry; + const entry = createEntry('draft', entryPath.split('/').pop().replace(/\.[^\.]+$/, ''), entryPath, { raw: data.file }); + entry.metaData = data.metaData; + resolve(entry); + sem.leave(); + } }).catch((err) => { sem.leave(); reject(err); @@ -84,16 +96,17 @@ export default class GitHub { }); return Promise.all(promises); }).then((entries) => { + const filteredEntries = entries.filter(entry => entry !== null); return { - pagination: {}, - entries + pagination: 0, + entries: filteredEntries, }; }); } unpublishedEntry(collection, slug) { - return this.unpublishedEntries().then((response) => ( - response.entries.filter((entry) => ( + return this.unpublishedEntries().then(response => ( + response.entries.filter(entry => ( entry.metaData && entry.metaData.collection === collection.get('name') && entry.slug === slug ))[0] )); diff --git a/src/backends/netlify-git/AuthenticationPage.js b/src/backends/netlify-git/AuthenticationPage.js index 28333d33..0840a332 100644 --- a/src/backends/netlify-git/AuthenticationPage.js +++ b/src/backends/netlify-git/AuthenticationPage.js @@ -19,7 +19,6 @@ export default class AuthenticationPage extends React.Component { 'Authorization': 'Basic ' + btoa(`${email}:${password}`) } }).then((response) => { - console.log(response); if (response.ok) { return response.json().then((data) => { this.props.onLogin(Object.assign({ email }, data)); diff --git a/src/backends/netlify-git/implementation.js b/src/backends/netlify-git/implementation.js index cf7f21ab..589b1b83 100644 --- a/src/backends/netlify-git/implementation.js +++ b/src/backends/netlify-git/implementation.js @@ -35,7 +35,7 @@ export default class NetlifyGit { files.map((file) => { promises.push(new Promise((resolve, reject) => { return sem.take(() => this.api.readFile(file.path, file.sha).then((data) => { - resolve(createEntry(file.path, file.path.split('/').pop().replace(/\.[^\.]+$/, ''), data)); + resolve(createEntry(collection.get('name'), file.path.split('/').pop().replace(/\.[^\.]+$/, ''), file.path, { raw: data })); sem.leave(); }).catch((err) => { sem.leave(); @@ -50,7 +50,7 @@ export default class NetlifyGit { })); } - entry(collection, slug) { + lookupEntry(collection, slug) { return this.entries(collection).then((response) => ( response.entries.filter((entry) => entry.slug === slug)[0] )); diff --git a/src/backends/test-repo/implementation.js b/src/backends/test-repo/implementation.js index b8cf56cc..fc3462d7 100644 --- a/src/backends/test-repo/implementation.js +++ b/src/backends/test-repo/implementation.js @@ -29,7 +29,7 @@ export default class TestRepo { const folder = collection.get('folder'); if (folder) { for (var path in window.repoFiles[folder]) { - entries.push(createEntry(folder + '/' + path, getSlug(path), window.repoFiles[folder][path].content)); + entries.push(createEntry(collection.get('name'), getSlug(path), folder + '/' + path, { raw: window.repoFiles[folder][path].content })); } } @@ -39,7 +39,7 @@ export default class TestRepo { }); } - entry(collection, slug) { + lookupEntry(collection, slug) { return this.entries(collection).then((response) => ( response.entries.filter((entry) => entry.slug === slug)[0] )); diff --git a/src/components/ControlPanel/ControlPane.js b/src/components/ControlPanel/ControlPane.js index 939dbb24..a450a054 100644 --- a/src/components/ControlPanel/ControlPane.js +++ b/src/components/ControlPanel/ControlPane.js @@ -8,17 +8,20 @@ export default class ControlPane extends Component { controlFor(field) { const { entry, getMedia, onChange, onAddMedia, onRemoveMedia } = this.props; const widget = resolveWidget(field.get('widget')); + const fieldName = field.get('name'); + const value = entry.getIn(['data', fieldName]); + if (!value) return null; return (
Last updated: {timeStamp} by {entry.getIn(['metaData', 'user'])}
{(ownStatus === status.last()) && - + }