├── .circleci └── config.yml ├── .editorconfig ├── .eslintrc ├── .github └── stale.yml ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── badges ├── npm-audit-badge.png └── npm-audit-badge.svg ├── index.js ├── lib ├── api.js ├── browser.js ├── callAll.js ├── helpers.js ├── implementation.js ├── middleware.js ├── modules │ ├── apostrophe-workflow-admin-bar │ │ └── index.js │ ├── apostrophe-workflow-areas │ │ └── index.js │ ├── apostrophe-workflow-assets │ │ └── index.js │ ├── apostrophe-workflow-docs │ │ ├── index.js │ │ └── lib │ │ │ └── cursor.js │ ├── apostrophe-workflow-global │ │ ├── index.js │ │ └── public │ │ │ └── js │ │ │ └── user.js │ ├── apostrophe-workflow-groups │ │ └── index.js │ ├── apostrophe-workflow-images │ │ ├── index.js │ │ └── views │ │ │ └── workflowPreview.html │ ├── apostrophe-workflow-modified-documents │ │ ├── index.js │ │ ├── lib │ │ │ └── cursor.js │ │ ├── public │ │ │ ├── css │ │ │ │ └── manager.less │ │ │ └── js │ │ │ │ └── manager-modal.js │ │ └── views │ │ │ ├── manageHeadings.html │ │ │ ├── manageListPage.html │ │ │ └── managerModal.html │ ├── apostrophe-workflow-pages │ │ ├── index.js │ │ └── public │ │ │ └── js │ │ │ ├── editor.js │ │ │ └── reorganize.js │ ├── apostrophe-workflow-permissions │ │ └── index.js │ ├── apostrophe-workflow-pieces │ │ ├── index.js │ │ ├── public │ │ │ └── js │ │ │ │ ├── editor-modal.js │ │ │ │ └── manager-modal.js │ │ └── views │ │ │ └── workflowLastCommitted.html │ ├── apostrophe-workflow-schemas │ │ ├── index.js │ │ ├── public │ │ │ └── js │ │ │ │ └── user.js │ │ └── views │ │ │ └── workflow-permissions-schema-field.html │ ├── apostrophe-workflow-tasks │ │ └── index.js │ └── apostrophe-workflow-templates │ │ └── index.js ├── removeDotPathViaSplice.js ├── routes.js └── tasks.js ├── locales └── en.json ├── package.json ├── public ├── css │ ├── expand.less │ ├── menu.less │ ├── schemas.less │ └── user.less └── js │ ├── ._committable-modal.js │ ├── batch-export-modal.js │ ├── batch-force-export-modal.js │ ├── commit-modal.js │ ├── export-modal.js │ ├── force-export-modal.js │ ├── force-export-related-modal.js │ ├── force-export-widget-modal.js │ ├── history-modal.js │ ├── lean.js │ ├── locale-picker-modal.js │ ├── locale-unavailable-modal.js │ ├── manage-modal.js │ ├── review-modal.js │ └── user.js ├── test ├── .gitignore ├── lib │ └── modules │ │ ├── apostrophe-custom-pages │ │ └── index.js │ │ ├── apostrophe-pages │ │ └── views │ │ │ └── pages │ │ │ └── home.html │ │ └── products │ │ └── index.js ├── missing-prefix-redirect-status-code.js ├── package.json ├── parkedPages.js ├── reorganize.js ├── reorganizeReplicateFalse.js ├── test1.js ├── test2-disable-anon-session.js ├── test2.js ├── test3.js ├── testApi.js ├── testDereplicate.js ├── testGlobalDef.js ├── testOverrideOptions.js ├── testReplicateFalse.js ├── testReplicateRelated.js └── testResolveRelationships.js └── views ├── batch-export-modal.html ├── batch-force-export-modal.html ├── commit-modal.html ├── export-modal.html ├── force-export-modal.html ├── force-export-related-modal.html ├── force-export-widget-modal.html ├── history-modal.html ├── locale-picker-modal.html ├── locale-picker.html ├── locale-tree.html ├── locale-unavailable-modal.html ├── manage-modal.html ├── menu.html ├── relatedByType.html └── review-modal.html /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build-node14-mongo44: 4 | docker: 5 | - image: circleci/node:14-browsers 6 | - image: mongo:4.4 7 | steps: 8 | - checkout 9 | - run: 10 | name: update-npm 11 | command: 'sudo npm install -g npm@7' 12 | - restore_cache: 13 | key: dependency-cache-{{ checksum "package.json" }} 14 | - run: 15 | name: install-npm-wee 16 | command: npm install 17 | - save_cache: 18 | key: dependency-cache-{{ checksum "package.json" }} 19 | paths: 20 | - ./node_modules 21 | - run: 22 | name: test 23 | command: npm test 24 | build-node14-mongo42: 25 | docker: 26 | - image: circleci/node:14-browsers 27 | - image: mongo:4.2 28 | steps: 29 | - checkout 30 | - run: 31 | name: update-npm 32 | command: 'sudo npm install -g npm@7' 33 | - restore_cache: 34 | key: dependency-cache-{{ checksum "package.json" }} 35 | - run: 36 | name: install-npm-wee 37 | command: npm install 38 | - save_cache: 39 | key: dependency-cache-{{ checksum "package.json" }} 40 | paths: 41 | - ./node_modules 42 | - run: 43 | name: test 44 | command: npm test 45 | build-node12: 46 | docker: 47 | - image: circleci/node:12-browsers 48 | - image: mongo:3.6.11 49 | steps: 50 | - checkout 51 | - run: 52 | name: update-npm 53 | command: 'sudo npm install -g npm@6' 54 | - restore_cache: 55 | key: dependency-cache-{{ checksum "package.json" }} 56 | - run: 57 | name: install-npm-wee 58 | command: npm install 59 | - save_cache: 60 | key: dependency-cache-{{ checksum "package.json" }} 61 | paths: 62 | - ./node_modules 63 | - run: 64 | name: test 65 | command: npm test 66 | build-node10: 67 | docker: 68 | - image: circleci/node:10-browsers 69 | - image: mongo:3.6.11 70 | steps: 71 | - checkout 72 | - run: 73 | name: update-npm 74 | command: 'sudo npm install -g npm@6' 75 | - restore_cache: 76 | key: dependency-cache-{{ checksum "package.json" }} 77 | - run: 78 | name: install-npm-wee 79 | command: npm install 80 | - save_cache: 81 | key: dependency-cache-{{ checksum "package.json" }} 82 | paths: 83 | - ./node_modules 84 | - run: 85 | name: test 86 | command: npm test 87 | workflows: 88 | version: 2 89 | build: 90 | jobs: 91 | - build-node14-mongo44 92 | - build-node14-mongo42 93 | - build-node12 94 | - build-node10 95 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | indent_size = 2 9 | indent_style = space 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "apostrophe" 3 | } 4 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 14 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | - documentation 10 | - bug 11 | - "3.0" 12 | # Label to use when marking an issue as stale 13 | staleLabel: stale 14 | # Comment to post when marking an issue as stale. Set to `false` to disable 15 | markComment: > 16 | This issue has been automatically marked as stale because it has not had 17 | recent activity. It will be closed if no further activity occurs. Thank you 18 | for your contributions. 19 | # Comment to post when closing a stale issue. Set to `false` to disable 20 | closeComment: false 21 | 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | test/locales 2 | package-lock.json 3 | npm-debug.log 4 | *.DS_Store 5 | node_modules 6 | # We do not commit CSS, only LESS 7 | public/css/*.css 8 | # No Vim swap files 9 | *.sw* 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "stable" 4 | - "lts/*" 5 | sudo: false 6 | services: 7 | - docker 8 | - mongodb 9 | 10 | # We need to download MongoDB 2.6.10 11 | env: 12 | global: 13 | - MONGODB_VERSION=2.6.10 14 | before_install: 15 | - wget http://fastdl.mongodb.org/linux/mongodb-linux-x86_64-$MONGODB_VERSION.tgz 16 | - tar xfz mongodb-linux-x86_64-$MONGODB_VERSION.tgz 17 | - export PATH=`pwd`/mongodb-linux-x86_64-$MONGODB_VERSION/bin:$PATH 18 | - mkdir -p data/db 19 | - mongod --dbpath=data/db > /dev/null 2>&1 & 20 | - sleep 3 21 | 22 | # whitelist 23 | branches: 24 | only: 25 | - master 26 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, 2017 P'unk Avenue LLC 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /badges/npm-audit-badge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apostrophecms-legacy/apostrophe-workflow/8ff55dd9083824b4d10db7775dda250d4a5bf4a1/badges/npm-audit-badge.png -------------------------------------------------------------------------------- /badges/npm-audit-badge.svg: -------------------------------------------------------------------------------- 1 | vulnerabilitiesvulnerabilities5050 -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var async = require('async'); 2 | var Promise = require('bluebird'); 3 | 4 | var modules = [ 5 | 'apostrophe-workflow-areas', 6 | 'apostrophe-workflow-docs', 7 | 'apostrophe-workflow-global', 8 | 'apostrophe-workflow-groups', 9 | 'apostrophe-workflow-pages', 10 | 'apostrophe-workflow-permissions', 11 | 'apostrophe-workflow-pieces', 12 | 'apostrophe-workflow-schemas', 13 | 'apostrophe-workflow-images', 14 | 'apostrophe-workflow-admin-bar', 15 | 'apostrophe-workflow-tasks', 16 | 'apostrophe-workflow-assets', 17 | 'apostrophe-workflow-modified-documents', 18 | 'apostrophe-workflow-templates' 19 | ]; 20 | 21 | // ## Options 22 | // 23 | // `includeTypes: [ 'my-blog-post', 'my-event' ]` 24 | // 25 | // Apply workflow only to docs of the specified types. IF WORKFLOW IS ENABLED FOR ANY PAGE TYPE, 26 | // AS OPPOSED TO A PIECE, IT MUST BE ENABLED FOR *ALL* PAGE TYPES. 27 | // 28 | // `excludeTypes: [ 'my-personal-profile' ]` 29 | // 30 | // Apply workflow to everything EXCEPT the specified types. IF WORKFLOW IS ENABLED FOR ANY PAGE TYPE, 31 | // AS OPPOSED TO A PIECE, IT MUST BE ENABLED FOR *ALL* PAGE TYPES. 32 | // 33 | // If both options are present, a type must appear in `includeTypes` 34 | // and NOT appear in `excludeTypes`. 35 | // 36 | // `baseExcludeTypes: [ 'apostrophe-user', 'apostrophe-group' ]` 37 | // 38 | // **Typically not changed.** A short list of types that should never be subject to workflow, 39 | // no matter what the other options say. For security reasons this list contains users and groups 40 | // by default. You will usually leave this alone. 41 | // 42 | // `excludeProperties: [ 'hitCounter' ]` 43 | // 44 | // A list of properties that should not be subject to workflow, but rather should be allowed to 45 | // vary for each locale and never be copied. These are typically properties 46 | // that don't make sense to edit as a "draft" and then submit as the new live version. For 47 | // instance, you wouldn't want to overwrite a page view counter field. 48 | // 49 | // There is no `includeProperties` option. In Apostrophe 2.x workflow applies to properties by default, 50 | // and excluded properties are unique to the locale (that is, either draft or live version of the doc). 51 | // 52 | // `baseExcludeProperties` 53 | // 54 | // Like `baseExcludeTypes`, this overrides a short list of properties that must not be modified 55 | // by workflow. You don't want to change this. 56 | 57 | module.exports = { 58 | 59 | moogBundle: { 60 | modules: modules, 61 | directory: 'lib/modules' 62 | }, 63 | 64 | beforeConstruct: function(self, options) { 65 | if (options.replicateAcrossLocales === undefined) { 66 | options.replicateAcrossLocales = true; 67 | } 68 | }, 69 | 70 | afterConstruct: function(self, callback) { 71 | self.composeLocales(); 72 | self.composeOptions(); 73 | self.enableAddMissingLocalesTask(); 74 | self.enableAddLocalePrefixesTask(); 75 | self.enableRemoveNumberedParkedPagesTask(); 76 | self.enableResolveJoinIdsTask(); 77 | self.enableHarmonizeWorkflowGuidsByParkedIdTask(); 78 | self.enableDiffDraftAndLiveTask(); 79 | self.enableReplicateLocaleTask(); 80 | self.enableSyncPagesTreeTask(); 81 | self.pushAssets(); 82 | self.addToAdminBar(); 83 | self.apos.pages.addAfterContextMenu(self.menu); 84 | self.enableHelpers(); 85 | self.enableCrossDomainSessionCache(); 86 | self.refineOptimizeKey(); 87 | self.composeApiCalls(); 88 | self.addWorkflowModifiedMigration(); 89 | self.addWorkflowLastCommittedMigration(); 90 | 91 | if (self.options.autoCommitPageMoves) { 92 | self.addPagesTreeCleaningMigration(); 93 | } 94 | 95 | self.on('apostrophe-pages:beforeParkAll', 'updateHistoricalPrefixesPromisified', function() { 96 | return Promise.promisify(self.updateHistoricalPrefixes)(); 97 | }); 98 | self.addRoutes(); 99 | return async.series([ 100 | self.enableCollection, 101 | self.enableFacts 102 | ], callback); 103 | }, 104 | 105 | construct: function(self, options) { 106 | require('./lib/implementation.js')(self, options); 107 | require('./lib/api.js')(self, options); 108 | require('./lib/callAll.js')(self, options); 109 | require('./lib/browser.js')(self, options); 110 | require('./lib/middleware.js')(self, options); 111 | require('./lib/routes.js')(self, options); 112 | require('./lib/tasks.js')(self, options); 113 | require('./lib/helpers.js')(self, options); 114 | } 115 | 116 | }; 117 | -------------------------------------------------------------------------------- /lib/browser.js: -------------------------------------------------------------------------------- 1 | module.exports = function(self, options) { 2 | self.pushAssets = function() { 3 | self.pushAsset('script', 'user', { when: 'user' }); 4 | self.pushAsset('script', 'manage-modal', { when: 'user' }); 5 | self.pushAsset('script', 'commit-modal', { when: 'user' }); 6 | self.pushAsset('script', 'export-modal', { when: 'user' }); 7 | self.pushAsset('script', 'review-modal', { when: 'user' }); 8 | self.pushAsset('script', 'history-modal', { when: 'user' }); 9 | self.pushAsset('script', 'locale-picker-modal', { when: 'user' }); 10 | self.pushAsset('script', 'force-export-widget-modal', { when: 'user' }); 11 | self.pushAsset('script', 'force-export-modal', { when: 'user' }); 12 | self.pushAsset('script', 'force-export-related-modal', { when: 'user' }); 13 | self.pushAsset('script', 'batch-export-modal', { when: 'user' }); 14 | self.pushAsset('script', 'batch-force-export-modal', { when: 'user' }); 15 | self.pushAsset('script', 'locale-unavailable-modal', { when: 'user' }); 16 | self.pushAsset('stylesheet', 'user', { when: 'user' }); 17 | self.pushAsset('script', 'lean', { when: 'lean' }); 18 | }; 19 | }; 20 | -------------------------------------------------------------------------------- /lib/helpers.js: -------------------------------------------------------------------------------- 1 | var _ = require('@sailshq/lodash'); 2 | 3 | module.exports = function(self, options) { 4 | self.enableHelpers = function() { 5 | self.addHelpers({ 6 | localizations: function() { 7 | var localizations = []; 8 | _.each((self.apos.templates.contextReq.data.workflow && self.apos.templates.contextReq.data.workflow.localizations) || [], function(localization, locale) { 9 | if (!self.locales[locale].private) { 10 | localizations.push(localization); 11 | } 12 | }); 13 | return localizations; 14 | }, 15 | lang: function() { 16 | var locale = self.apos.templates.contextReq.locale || self.defaultLocale; 17 | locale = self.liveify(locale); 18 | if (self.locales[locale] && self.locales[locale].lang) { 19 | return self.locales[locale].lang; 20 | } 21 | return locale.replace(/[-_]\w+$/, ''); 22 | }, 23 | committable: function(draft) { 24 | return self.filterCommittableDrafts(self.apos.templates.contextReq, [ draft ]).length > 0; 25 | } 26 | }); 27 | }; 28 | }; 29 | -------------------------------------------------------------------------------- /lib/middleware.js: -------------------------------------------------------------------------------- 1 | var _ = require('@sailshq/lodash'); 2 | 3 | module.exports = function(self, options) { 4 | 5 | // Set `req.locale` based on `req.query.workflowLocale`, `req.session.locale`, 6 | // the hostname and/or the URL prefix. 7 | // 8 | // If the locale is not present or is not valid, set `req.locale` to the 9 | // default locale by calling `self.guessLocale`. 10 | // 11 | // Store the locale in `req.session.locale` as well unless disabled. 12 | 13 | self.expressMiddleware = { 14 | before: 'apostrophe-global', 15 | middleware: function(req, res, next) { 16 | var to, host, matches, locale, prefix, hostname, subdomain, domain, candidates, setByUs; 17 | 18 | function setLocale(locale) { 19 | req.locale = locale; 20 | if (locale) { 21 | setByUs = true; 22 | } 23 | if (self.localeInSession(req)) { 24 | req.session.locale = locale; 25 | } 26 | if (self.apos.hasOwnProperty('i18n')) { 27 | self.apos.i18n.setLocale(req, locale); 28 | } 29 | } 30 | 31 | req.data = req.data || {}; 32 | req.data.workflow = req.data.workflow || {}; 33 | _.assign(req.data.workflow, _.pick(self, 'locales', 'nestedLocales')); 34 | 35 | // Hack to implement bc with the subdomains option 36 | // by populating self.hostnames for each prefix, as soon as we 37 | // actually know what the domain name is 38 | if (self.options.subdomains && (!self.hostnames)) { 39 | host = self.getHost(req); 40 | self.hostnames = {}; 41 | matches = host.match(/^([^:.]+)(\.([^:]+))?:?/); 42 | subdomain = matches[1]; 43 | domain = matches[3]; 44 | if (!_.has(self.locales, subdomain)) { 45 | domain = host; 46 | if (domain.match(/(:80|:443)$/)) { 47 | domain = domain.replace(/:\d+$/, ''); 48 | } 49 | } 50 | _.each(self.locales, function(locale, name) { 51 | if (!name.match(/-draft$/)) { 52 | if (!locale.private) { 53 | self.hostnames[name] = name + '.' + domain; 54 | } else { 55 | self.hostnames[name] = domain; 56 | } 57 | } 58 | }); 59 | } 60 | 61 | if (req.query.workflowLocale) { 62 | // Switch locale choice in session via query string, then redirect 63 | locale = self.apos.launder.string(req.query.workflowLocale); 64 | 65 | if (_.has(self.locales, locale)) { 66 | setLocale(locale); 67 | } 68 | if (self.hostnames) { 69 | // Don't let a stale hostname just switch it back 70 | to = req.absoluteUrl.replace(/\??workflowLocale=[^&]+&?/, ''); 71 | matches = to.match(/^(https?:)?\/\/([^/:]+)/); 72 | hostname = matches[2]; 73 | if (hostname) { 74 | to = to.replace('//' + hostname, '//' + self.hostnames[self.liveify(req.query.workflowLocale)]); 75 | } 76 | return res.redirect(to); 77 | } else { 78 | return res.redirect(req.url.replace(/\??workflowLocale=[^&]+&?/, '')); 79 | } 80 | } 81 | 82 | // test reqs might not have a headers object 83 | if (req.headers && req.headers['apostrophe-locale']) { 84 | // API calls use apostrophe-locale headers 85 | if (_.has(self.locales, req.headers['apostrophe-locale'])) { 86 | // To avoid race conditions that can break locale switching, 87 | // we don't modify the session based on the header 88 | if (self.apos.hasOwnProperty('i18n')) { 89 | // Careful, i18n.setLocale does not grasp draft locales 90 | self.apos.i18n.setLocale(req, self.liveify(req.headers['apostrophe-locale'])); 91 | } 92 | req.locale = req.headers['apostrophe-locale']; 93 | return next(); 94 | } 95 | } 96 | 97 | // Start with all of the non-draft locales as candidates 98 | // (we implement draft vs. live at the end) 99 | 100 | candidates = _.filter(_.keys(self.locales), function(locale) { 101 | return locale === self.liveify(locale); 102 | }); 103 | 104 | // Winnow it down by hostname 105 | 106 | if (self.hostnames) { 107 | host = self.getHost(req); 108 | matches = host.match(/^[^:]+/); 109 | if (matches) { 110 | hostname = matches[0]; 111 | candidates = _.filter(candidates, function(candidate) { 112 | if (self.locales[candidate] && self.locales[candidate].private) { 113 | if (!self.apos.permissions.can(req, 'private-locales')) { 114 | return false; 115 | } 116 | } 117 | return self.hostnames[candidate] === hostname; 118 | }); 119 | } 120 | } 121 | 122 | function extractRootLevelLocale(candidates) { 123 | return _.filter(candidates, function (candidate) { 124 | const locale = self.locales[candidate]; 125 | return (!locale.private) && (!_.has(self.prefixes, candidate)); 126 | }); 127 | } 128 | 129 | // If we're not down to one yet, winnow it down by prefix 130 | if ((candidates.length > 1) && self.prefixes) { 131 | // If we're dealing with a locale which shares the hostname 132 | // with other locales, but doesn't have a prefix, (i.e. it 133 | // exists at the root level of the hostname), figure out 134 | // that locale and set that as the current session's locale. 135 | if (req.url.match(/^\/(\?|$)/)) { 136 | // a locale which resides at the root level of a shared 137 | // hostname won't have a prefix. So we select the candidate 138 | // which doesn't have a prefix in this case. 139 | candidates = extractRootLevelLocale(candidates); 140 | } else { 141 | matches = req.url.match(/^\/[^/]+/); 142 | if (matches) { 143 | prefix = matches[0]; 144 | 145 | var filteredCandidates = _.filter(candidates, function (candidate) { 146 | return self.prefixes[candidate] === prefix; 147 | }); 148 | 149 | // At this stage, if the list of filtered candidates is 150 | // empty, it means the first fragment of the URI didn't 151 | // match any of the candidates' locale prefixes. 152 | // 153 | // We're here because the hostname filter did not narrow 154 | // the candidates to one, so this is likely to be a URI 155 | // for a root-level locale among candidates filtered by 156 | // hostname. 157 | // 158 | // However, it could also be an API URL which doesn't care 159 | // about the locale or is counting on the existing value 160 | // of req.locale. To resolve this ambiguity we check for 161 | // a standardized list of URI prefixes that do not 162 | // imply a locale. This list can be extended. 163 | 164 | if (filteredCandidates.length === 0) { 165 | if (!self.isApiCall(req)) { 166 | candidates = extractRootLevelLocale(candidates); 167 | } 168 | } else { 169 | // if the candidates list isn't empty, the URI's first fragment 170 | // matched the locale prefix for one of the candidates, in which 171 | // case it's fairly clear how to proceed. 172 | candidates = filteredCandidates; 173 | } 174 | } 175 | } 176 | } 177 | 178 | // If we made it down to one locale, that's the winner 179 | if (candidates.length === 1) { 180 | var privateLocale = self.locales[candidates[0]] && self.locales[candidates[0]].private; 181 | if (privateLocale) { 182 | if (self.apos.permissions.can(req, 'private-locales')) { 183 | setLocale(candidates[0]); 184 | } 185 | } else { 186 | setLocale(candidates[0]); 187 | } 188 | } 189 | const hostnameDefaultLocale = self.getLocaleViaHostnameDefault(req); 190 | if (hostnameDefaultLocale && (!setByUs)) { 191 | if (self.locales[hostnameDefaultLocale] && self.locales[hostnameDefaultLocale].private) { 192 | if (self.apos.permissions.can(req, 'private-locales')) { 193 | setLocale(hostnameDefaultLocale); 194 | } 195 | } else { 196 | setLocale(hostnameDefaultLocale); 197 | } 198 | } 199 | 200 | req.locale = self.localeInSession(req) ? req.session.locale : req.locale; 201 | // Resort to the default locale if (1) there is no indication of what locale to use, 202 | // (2) the locale isn't configured, or (3) the locale is private and we don't have 203 | // permission to view private locales. 204 | 205 | if ((!req.locale) || (!_.has(self.locales, req.locale)) || 206 | (self.locales[req.locale].private && (!self.apos.permissions.can(req, 'private-locales')))) { 207 | self.guessLocale(req); 208 | setLocale(req.locale); 209 | } 210 | 211 | if (req.user) { 212 | // Handle preview mode first 213 | if (!req.session.workflowMode && self.defaultMode === 'preview') { 214 | req.session.workflowPreview = true; 215 | } 216 | 217 | req.session.workflowMode = req.session.workflowMode || self.defaultMode; 218 | if (req.session.workflowMode === 'live') { 219 | req.locale = self.liveify(req.locale); 220 | req.session.workflowMode = 'live'; 221 | } else { 222 | req.locale = self.draftify(req.locale); 223 | req.session.workflowMode = 'draft'; 224 | } 225 | } 226 | 227 | if (self.prefixes && self.prefixes[self.liveify(req.locale)] && req.url.match(/^\/(\?|$)/)) { 228 | // Redirect to home page of appropriate locale 229 | let newUrl = self.prefixes[self.liveify(req.locale)] + '/'; 230 | const matches = req.url.match(/^\/(\?.*)$/); 231 | if (matches) { 232 | newUrl += matches[1]; 233 | } 234 | return res.redirect(self.options.missingPrefixRedirectStatusCode || 302, newUrl); 235 | } 236 | 237 | return next(); 238 | } 239 | }; 240 | 241 | }; 242 | -------------------------------------------------------------------------------- /lib/modules/apostrophe-workflow-admin-bar/index.js: -------------------------------------------------------------------------------- 1 | var _ = require('@sailshq/lodash'); 2 | 3 | module.exports = { 4 | improve: 'apostrophe-admin-bar', 5 | beforeConstruct: function(self, options) { 6 | options.removeWhenLive = [ 'apostrophe-pages', 'apostrophe-tags', 'apostrophe-workflow-manage-modal' ].concat(options.removeWhenLive || []); 7 | }, 8 | construct: function(self, options) { 9 | // Hide most admin bar buttons in draft mode, specifically 10 | // those that manage pieces; you must be in draft mode to 11 | // do most things. Later perhaps we'll introduce a manage modal 12 | // for live mode that lets you preview things 13 | var superItemIsVisible = self.itemIsVisible; 14 | self.itemIsVisible = function(req, item) { 15 | var result = superItemIsVisible(req, item); 16 | if (!result) { 17 | return result; 18 | } 19 | if (req.locale && req.locale.match(/-draft$/)) { 20 | return result; 21 | } 22 | 23 | if (_.contains(self.options.removeWhenLive, item.name)) { 24 | return false; 25 | } 26 | // In addition, pieces manage buttons are not safe live 27 | // if the type is included in workflow; look 28 | // for subclasses 29 | var manager = self.apos.modules[item.name]; 30 | if (!manager) { 31 | return result; 32 | } 33 | if (!(self.apos.synth.instanceOf(manager, 'apostrophe-pieces'))) { 34 | return result; 35 | } 36 | var workflow = self.apos.modules['apostrophe-workflow']; 37 | if (!workflow.includeType(manager.name)) { 38 | return result; 39 | } 40 | return false; 41 | }; 42 | } 43 | }; 44 | -------------------------------------------------------------------------------- /lib/modules/apostrophe-workflow-areas/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | improve: 'apostrophe-areas', 3 | construct: function(self, options) { 4 | var superWidgetControlGroups = self.widgetControlGroups; 5 | self.widgetControlGroups = function(req, widget, options) { 6 | var controlGroups = superWidgetControlGroups(req, widget, options); 7 | var workflow = self.apos.modules['apostrophe-workflow']; 8 | if (!workflow.localized) { 9 | return controlGroups; 10 | } 11 | return controlGroups.concat([ 12 | { 13 | controls: [ 14 | { 15 | tooltip: 'Force Export', 16 | icon: 'sign-out', 17 | action: 'workflow-force-export-widget' 18 | } 19 | ] 20 | } 21 | ]); 22 | }; 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /lib/modules/apostrophe-workflow-assets/index.js: -------------------------------------------------------------------------------- 1 | var _ = require('@sailshq/lodash'); 2 | var fs = require('fs'); 3 | 4 | module.exports = { 5 | improve: 'apostrophe-assets', 6 | afterConstruct: function(self) { 7 | self.workflowAddStylesheetRoutes(); 8 | }, 9 | construct: function(self, options) { 10 | var superStylesheetsHelper = self.stylesheetsHelper; 11 | self.workflowStylesheetCache = {}; 12 | self.stylesheetsHelper = function(scene) { 13 | var result = ''; 14 | var workflow = self.apos.modules['apostrophe-workflow']; 15 | var locale = self.apos.templates.contextReq.locale; 16 | locale = workflow.liveify(locale); 17 | var prefix = self.apos.prefix + self.action; 18 | if (workflow.locales[locale].stylesheet) { 19 | result = ''; 20 | } else if (workflow.options.defaultStylesheet) { 21 | result = ''; 22 | } 23 | return self.apos.templates.safe(result + superStylesheetsHelper(scene).toString()); 24 | }; 25 | 26 | self.workflowAddStylesheetRoutes = function() { 27 | // For performance reasons we need to run these "routes" before the middleware, 28 | // so push middleware of our own that implements them. The apostrophe-assets 29 | // module already sets up an `expressMiddleware` property scheduled to run 30 | // before `apostrophe-global`, which is what we want. -Tom 31 | self.expressMiddleware.middleware.push(function(req, res, next) { 32 | if (req.path === self.action + '/workflow-stylesheet') { 33 | return self.workflowStylesheet(req, res); 34 | } else if (req.path === self.action + '/workflow-default-stylesheet') { 35 | return self.workflowDefaultStylesheet(req, res); 36 | } 37 | return next(); 38 | }); 39 | }; 40 | 41 | self.workflowStylesheet = function(req, res) { 42 | var workflow = self.apos.modules['apostrophe-workflow']; 43 | var locale = req.query.locale; 44 | if (!locale) { 45 | return req.res.status(404).send('not found'); 46 | } 47 | locale = workflow.liveify(locale); 48 | var stylesheet = workflow.locales[locale] && workflow.locales[locale].stylesheet; 49 | return self.workflowSendStylesheet(req, stylesheet); 50 | }; 51 | 52 | self.workflowDefaultStylesheet = function(req, res) { 53 | var workflow = self.apos.modules['apostrophe-workflow']; 54 | var stylesheet = workflow.options.defaultStylesheet; 55 | return self.workflowSendStylesheet(req, stylesheet); 56 | }; 57 | 58 | self.workflowSendStylesheet = function(req, stylesheet) { 59 | if (!stylesheet) { 60 | return req.res.status(404).send('not found'); 61 | } 62 | var css = self.workflowStylesheetCache[stylesheet]; 63 | if (!css) { 64 | var path = self.workflowGetStylesheetPath(stylesheet); 65 | if (!path) { 66 | self.apos.utils.error('stylesheet ' + stylesheet + ' was configured for workflow but does not exist in apostrophe-workflow project level'); 67 | return req.res.status(404).send('not found'); 68 | } 69 | 70 | css = fs.readFileSync(path, 'utf8'); 71 | css = self.prefixCssUrlsWith(css, self.assetUrl('')); 72 | self.workflowStylesheetCache[stylesheet] = css; 73 | } 74 | req.res.set('Content-Type', 'text/css'); 75 | // Cache for one year, this is safe because the 76 | // asset generation ID is included in the query string 77 | // of the URL, busting the cache automatically on new deploys 78 | req.res.set('Cache-Control', 'public, max-age=31536000'); 79 | return req.res.send(css); 80 | }; 81 | 82 | self.workflowGetStylesheetPath = function(stylesheet) { 83 | var workflow = self.apos.modules['apostrophe-workflow']; 84 | var chain = workflow.__meta.chain; 85 | var path; 86 | _.each(chain, function(entry) { 87 | var _path = entry.dirname + '/public/css/' + stylesheet; 88 | if (!_path.match(/\.\w+$/)) { 89 | _path += '.css'; 90 | } 91 | if (fs.existsSync(_path)) { 92 | path = _path; 93 | return false; 94 | } 95 | }); 96 | return path; 97 | }; 98 | 99 | } 100 | }; 101 | -------------------------------------------------------------------------------- /lib/modules/apostrophe-workflow-docs/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | improve: 'apostrophe-docs', 4 | 5 | trashInSchema: true, 6 | 7 | construct: function(self, options) { 8 | 9 | self.apos.define('apostrophe-cursor', require('./lib/cursor.js')); 10 | 11 | var superIsUniqueError = self.isUniqueError; 12 | self.isUniqueError = function(err) { 13 | var result = superIsUniqueError(err); 14 | if (!result) { 15 | return result; 16 | } 17 | if (err && err.message && err.message.match(/workflowGuid/)) { 18 | return false; 19 | } 20 | return result; 21 | }; 22 | 23 | var superGetSlugIndexParams = self.getSlugIndexParams; 24 | self.getSlugIndexParams = function() { 25 | var params = superGetSlugIndexParams(); 26 | params.workflowLocale = 1; 27 | return params; 28 | }; 29 | 30 | var superGetPathLevelIndexParams = self.getPathLevelIndexParams; 31 | self.getPathLevelIndexParams = function() { 32 | var params = superGetPathLevelIndexParams(); 33 | params.workflowLocale = 1; 34 | return params; 35 | }; 36 | 37 | // Solve chicken and egg problem by making sure we have a 38 | // workflow locale before we test insert permissions 39 | 40 | var superTestInsertPermissions = self.testInsertPermissions; 41 | self.testInsertPermissions = function(req, doc, options) { 42 | var workflow = self.apos.modules['apostrophe-workflow']; 43 | // If not enabled yet, this will be a startup task 44 | if (workflow) { 45 | self.ensureSlug(doc); 46 | workflow.ensureWorkflowLocale(req, doc); 47 | } 48 | return superTestInsertPermissions(req, doc, options); 49 | }; 50 | 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /lib/modules/apostrophe-workflow-docs/lib/cursor.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | construct: function(self, options) { 3 | // npm modules should play nice and not use `alias` 4 | var workflow = self.apos.modules['apostrophe-workflow']; 5 | 6 | // Apostrophe will fetch only documents for the locale of req unless 7 | // explicitly asked to do otherwise via .workflowLocale(null). 8 | // The filter can also be called with a specific locale string. 9 | 10 | self.addFilter('workflowLocale', { 11 | def: true, 12 | finalize: function() { 13 | var setting = self.get('workflowLocale'); 14 | if (setting === null) { 15 | return; 16 | } 17 | if (setting === true) { 18 | setting = self.get('req').locale; 19 | if (!setting) { 20 | setting = workflow.defaultLocale; 21 | } 22 | } else { 23 | // It's an explicit locale string 24 | } 25 | if (self.get('type')) { 26 | if (workflow.includeType(self.get('type'))) { 27 | // query is restricted by type to a type that definitely involves workflow 28 | self.and({ workflowLocale: setting }); 29 | } else { 30 | // Restricted by type to a type that definitely does not involve workflow, 31 | // no criteria needed 32 | } 33 | } else { 34 | // Content not participating in localization will have no locale at all, 35 | // take care not to block access to that. However we can use `$in`, 36 | // which works just like the equality filter and will accept `null` as 37 | // a match for "property does not exist at all". This avoids a slow 38 | // $or query 39 | self.and({ 40 | workflowLocale: { 41 | $in: [ setting, null ] 42 | } 43 | }); 44 | } 45 | } 46 | }); 47 | 48 | } 49 | }; 50 | -------------------------------------------------------------------------------- /lib/modules/apostrophe-workflow-global/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | improve: 'apostrophe-global' 3 | }; 4 | -------------------------------------------------------------------------------- /lib/modules/apostrophe-workflow-global/public/js/user.js: -------------------------------------------------------------------------------- 1 | apos.define('apostrophe-global', { 2 | alwaysShowEditor: true 3 | }); 4 | -------------------------------------------------------------------------------- /lib/modules/apostrophe-workflow-groups/index.js: -------------------------------------------------------------------------------- 1 | var _ = require('@sailshq/lodash'); 2 | 3 | module.exports = { 4 | 5 | improve: 'apostrophe-groups', 6 | 7 | construct: function(self, options) { 8 | 9 | var superModulesReady = self.modulesReady; 10 | self.modulesReady = function() { 11 | // Can't be done sooner because the workflow module has to exist first 12 | superModulesReady(); 13 | self.workflowModifyPermissionsField(); 14 | }; 15 | 16 | self.workflowModifyPermissionsField = function() { 17 | var workflow = self.apos.modules['apostrophe-workflow']; 18 | var permissions = _.find(self.schema, { name: 'permissions' }); 19 | if (!permissions) { 20 | return; 21 | } 22 | permissions.type = 'apostrophe-workflow-permissions'; 23 | permissions.nestedLocales = workflow.nestedLocales; 24 | permissions.locales = {}; 25 | permissions.excludeActions = workflow.excludeActions; 26 | _.each(workflow.locales, function(locale, name) { 27 | permissions.locales[name] = _.pick(locale, 'label'); 28 | }); 29 | }; 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /lib/modules/apostrophe-workflow-images/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | improve: 'apostrophe-images', 3 | construct: function(self, options) { 4 | self.workflowPreview = function(req, before, after) { 5 | return self.render(req, 'workflowPreview', { image: after }); 6 | }; 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /lib/modules/apostrophe-workflow-images/views/workflowPreview.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/modules/apostrophe-workflow-modified-documents/index.js: -------------------------------------------------------------------------------- 1 | var _ = require('@sailshq/lodash'); 2 | 3 | module.exports = { 4 | // A virtual "piece type" only used to display a universal manage modal 5 | // for documents that are commit-ready. No "modified-document" piece is ever 6 | // really inserted into the database. -Tom 7 | extend: 'apostrophe-pieces', 8 | name: 'workflow-document', 9 | label: 'Document', 10 | pluralLabel: 'Documents', 11 | forbiddenFields: [], 12 | sort: { 13 | updatedAt: -1 14 | }, 15 | beforeConstruct: function(self, options) { 16 | options.addFilters = [ 17 | { 18 | name: 'modified' 19 | }, 20 | { 21 | name: 'submitted' 22 | }, 23 | { 24 | name: 'type' 25 | } 26 | ].concat(options.addFilters || []); 27 | options.addFields = [ 28 | { 29 | name: 'type', 30 | type: 'select', 31 | // Patched after all types are known 32 | choices: [] 33 | } 34 | ].concat(options.addFields || []); 35 | // To prevent warnings about unarranged fields. We do not actually 36 | // edit this virtual, read-only piece type 37 | options.arrangeFields = [ 38 | { 39 | name: 'type', 40 | fields: [ 'type' ] 41 | } 42 | ].concat(options.arrangeFields || []); 43 | // We do not have fully configurable columns for this custom type, 44 | // but we configure these two so the sorts work 45 | options.defaultColumns = [ 46 | { 47 | name: 'updatedAt', 48 | label: 'Last Edit', 49 | sort: { 50 | 'updatedAt': -1 51 | } 52 | }, 53 | { 54 | name: 'workflowLastCommitted', 55 | label: 'Last Commit', 56 | sort: { 57 | 'workflowLastCommitted.at': -1 58 | } 59 | } 60 | ]; 61 | }, 62 | construct: function(self, options) { 63 | var superComposeFilters = self.composeFilters; 64 | self.options.batchOperations = _.filter(self.options.batchOperations, function(operation) { 65 | // Only operations that make sense here should come through 66 | return _.includes([ 'submit', 'commit', 'force-export', 'revert-to-live' ], operation.name); 67 | }); 68 | self.composeFilters = function() { 69 | // Move trash filter to end of list 70 | self.options.addFilters = _.filter(self.options.addFilters || [], function(filter) { 71 | return (filter.name !== 'trash'); 72 | }).concat(_.filter(self.options.addFilters || [], { name: 'trash' })); 73 | superComposeFilters(); 74 | }; 75 | self.getManagerControls = function(req) { 76 | // Committables are not real things and cannot be "added" 77 | return [ 78 | { 79 | type: 'minor', 80 | label: 'Finished', 81 | action: 'cancel' 82 | } 83 | ]; 84 | }; 85 | // Clarify that this is not a real piece type to be written to the db, it is 86 | // an interface to "manage" all piece types with just workflow operations 87 | self.addGenerateTask = function() {}; 88 | self.insert = function() { 89 | throw 'This is a virtual piece type never stored to the db'; 90 | }; 91 | self.update = function() { 92 | throw 'This is a virtual piece type never stored to the db'; 93 | }; 94 | self.on('apostrophe:modulesReady', 'populateTypes', function() { 95 | const field = _.find(self.schema, { name: 'type' }); 96 | const types = Object.keys(self.apos.docs.managers); 97 | field.choices = types.map(function(type) { 98 | const manager = self.apos.docs.getManager(type); 99 | let label; 100 | if (self.apos.instanceOf(manager, 'apostrophe-custom-pages')) { 101 | const def = _.find(self.apos.pages.options.types, { name: type }); 102 | if (def) { 103 | label = def.label; 104 | } 105 | } 106 | return { 107 | value: type, 108 | label: label || manager.pluralLabel || manager.label || type 109 | }; 110 | }); 111 | }); 112 | self.addToAdminBar = function() { 113 | self.apos.adminBar.add(self.__meta.name, 'Manage'); 114 | const group = _.find(self.apos.adminBar.groups, { label: 'Workflow' }); 115 | if (group) { 116 | group.items.push(self.__meta.name); 117 | } 118 | }; 119 | // Since this is an overview of many doc types, all that is required 120 | // is that you be able to potentially edit at least one doc type that 121 | // is subject to workflow 122 | self.requireEditor = function(req, res, next) { 123 | if (!_.find(Object.keys(self.apos.docs.managers), function(name) { 124 | return self.apos.modules['apostrophe-workflow'].includeType(name) && self.apos.permissions.can(req, 'edit-' + name); 125 | })) { 126 | return self.apiResponse(res, 'forbidden'); 127 | } 128 | return next(); 129 | }; 130 | var superGetListProjection = self.getListProjection; 131 | self.getListProjection = function(req) { 132 | var projection = superGetListProjection(req); 133 | projection = Object.assign( 134 | { 135 | workflowLocale: 1, 136 | workflowModified: 1, 137 | workflowSubmitted: 1, 138 | workflowLastCommitted: 1 139 | }, 140 | projection 141 | ); 142 | return projection; 143 | }; 144 | } 145 | }; 146 | -------------------------------------------------------------------------------- /lib/modules/apostrophe-workflow-modified-documents/lib/cursor.js: -------------------------------------------------------------------------------- 1 | const _ = require('@sailshq/lodash'); 2 | 3 | module.exports = { 4 | afterConstruct: function(self) { 5 | 6 | const req = self.get('req'); 7 | 8 | // We do not care what the document type is, 9 | // by default (although the type filter may be 10 | // used as an actual ui filter) 11 | self.type(null); 12 | 13 | // OK I lied, we care a lot, but specifically it must be a type 14 | // to which workflow applies 15 | 16 | const workflow = self.apos.modules['apostrophe-workflow']; 17 | 18 | const types = Object.keys(self.apos.docs.managers).filter(function(type) { 19 | // Type must be subject to workflow, and user must be able to 20 | // commit (edit the live locale). The trash type we're ignoring 21 | // is the legacy trashcan, not your trashed docs 22 | return (type !== 'trash') && workflow.includeType(type) && self.apos.permissions.can(Object.assign({}, req, { workflowLocale: workflow.liveify(req.locale) }), 'edit-' + type); 23 | }); 24 | if (!types.length) { 25 | self.and({ _id: '__iNeverMatch' }); 26 | return; 27 | } 28 | self.and({ 29 | workflowLocale: { $exists: 1 }, 30 | type: { $in: types } 31 | }); 32 | self.permission('edit'); 33 | }, 34 | construct: function(self, options) { 35 | self.apos.schemas.addFilters(_.filter(self.options.module.schema, { name: 'type' }), { 36 | override: [ 'type' ] 37 | }, self); 38 | self.addFilter('modified', { 39 | def: null, 40 | finalize: function() { 41 | const modified = self.get('modified'); 42 | if (modified === null) { 43 | return; 44 | } 45 | if (modified) { 46 | self.and({ 47 | workflowModified: true 48 | }); 49 | } else { 50 | self.and({ 51 | workflowModified: { $ne: true } 52 | }); 53 | } 54 | }, 55 | safeFor: 'manage', 56 | launder: function(s) { 57 | return self.apos.launder.booleanOrNull(s); 58 | }, 59 | choices: function(callback) { 60 | var choices = [ 61 | { 62 | value: '0', 63 | label: 'No' 64 | }, 65 | { 66 | value: '1', 67 | label: 'Yes' 68 | } 69 | ]; 70 | return setImmediate(function() { 71 | return callback(null, choices); 72 | }); 73 | } 74 | }); 75 | self.addFilter('submitted', { 76 | def: null, 77 | finalize: function() { 78 | const submitted = self.get('submitted'); 79 | if (submitted === null) { 80 | return; 81 | } 82 | if (submitted) { 83 | self.and({ 84 | workflowSubmitted: { $exists: 1 } 85 | }); 86 | return; 87 | } 88 | self.and({ 89 | workflowSubmitted: { $exists: 0 } 90 | }); 91 | }, 92 | safeFor: 'manage', 93 | launder: function(s) { 94 | return self.apos.launder.booleanOrNull(s); 95 | }, 96 | choices: function(callback) { 97 | var choices = [ 98 | { 99 | value: '0', 100 | label: 'No' 101 | }, 102 | { 103 | value: '1', 104 | label: 'Yes' 105 | } 106 | ]; 107 | return setImmediate(function() { 108 | return callback(null, choices); 109 | }); 110 | } 111 | }); 112 | } 113 | }; 114 | -------------------------------------------------------------------------------- /lib/modules/apostrophe-workflow-modified-documents/public/css/manager.less: -------------------------------------------------------------------------------- 1 | .apos-ui.apos-modal.workflow-document-manager .apos-modal-header .apos-modal-filters .apos-modal-filter { 2 | margin-right: 20px; 3 | } 4 | 5 | -------------------------------------------------------------------------------- /lib/modules/apostrophe-workflow-modified-documents/public/js/manager-modal.js: -------------------------------------------------------------------------------- 1 | apos.define('apostrophe-workflow-modified-documents-manager-modal', { 2 | extend: 'apostrophe-pieces-manager-modal', 3 | construct: function(self, option) { 4 | self.onChange = function() { 5 | // We refresh list view on *all* doc changes, not just one piece type 6 | self.refresh(); 7 | }; 8 | var superBeforeShow = self.beforeShow; 9 | self.beforeShow = function(callback) { 10 | // Watch for more types of changes that should refresh the list 11 | apos.on('workflowSubmitted', self.onChange); 12 | apos.on('workflowCommitted', self.onChange); 13 | apos.on('workflowRevertedToLive', self.onChange); 14 | return superBeforeShow(callback); 15 | }; 16 | var superAfterHide = self.afterHide; 17 | self.afterHide = function() { 18 | superAfterHide(); 19 | // So we don't leak memory and keep refreshing 20 | // after we're gone 21 | apos.off('workflowSubmitted', self.onChange); 22 | apos.off('workflowCommitted', self.onChange); 23 | apos.off('workflowRevertedToLive', self.onChange); 24 | }; 25 | } 26 | }); 27 | -------------------------------------------------------------------------------- /lib/modules/apostrophe-workflow-modified-documents/views/manageHeadings.html: -------------------------------------------------------------------------------- 1 | {%- import "apostrophe-ui:components/fields.html" as fields -%} 2 | {%- macro renderColumn(column) -%} 3 | {{ __ns('apostrophe', column.label) }} 4 | {%- endmacro -%} 5 | 6 | {{fields.checkbox('select-all')}} 7 | {{ __ns('apostrophe', 'Document') }} 8 | {{ __ns('apostrophe', 'Type') }} 9 | {{ renderColumn({ name: 'updatedAt', label: 'Last Edit', sort: { updatedAt: -1 }, defaultSortDirection: '-1' }) }} 10 | {{ __ns('apostrophe', 'Submitted?') }} 11 | {{ renderColumn({ name: 'workflowLastCommitted', label: 'Last Commit', sort: { workflowLastCommitted: -1 }, defaultSortDirection: '-1' }) }} 12 | {{ __ns('apostrophe', 'Actions') }} 13 | 14 | -------------------------------------------------------------------------------- /lib/modules/apostrophe-workflow-modified-documents/views/manageListPage.html: -------------------------------------------------------------------------------- 1 | {%- import "apostrophe-ui:components/fields.html" as fields -%} 2 | {% for piece in data.pieces %} 3 | 4 | {{fields.checkbox(data.options.name + '-select')}} 5 | {% if apos.utils.beginsWith(piece.slug, '/') %} 6 | {{ piece.title or piece.slug }} 7 | {% else %} 8 | {{ piece.title or piece.slug }} 9 | {% endif %} 10 | {{ piece.type }} 11 | {{ piece.updatedAt | date('YYYY-MM-DD') }}
{{ piece.workflowLastEditor }} 12 | {% if piece.workflowSubmitted %}{{ __ns('apostrophe', 'Yes') }}{% else %}{{ __ns('apostrophe', 'No') }}{% endif %} 13 | {{ piece.workflowLastCommitted.at | date('YYYY-MM-DD') }}
{{ piece.workflowLastCommitted.user.title }} 14 | 15 | {% if piece._edit %} 16 | {% if apos.utils.beginsWith(piece.slug, '/') %} 17 | {{ __ns('apostrophe', 'Edit') }} 18 | {% else %} 19 | {{ __ns('apostrophe', 'Edit') }} 20 | {% endif %} 21 | {% if apos.modules['apostrophe-workflow'].committable(piece) %} 22 | {{ __ns('apostrophe', 'Commit') }} 23 | {{ __ns('apostrophe', 'Revert') }} 24 | {% endif %} 25 | {% endif %} 26 | 27 | 28 | {% endfor %} 29 | -------------------------------------------------------------------------------- /lib/modules/apostrophe-workflow-modified-documents/views/managerModal.html: -------------------------------------------------------------------------------- 1 | {% extends "managerModalBase.html" %} 2 | {% block label %} 3 | Manage Workflow 4 | {% endblock %} 5 | {% block instructions %} 6 | Tip: looking for documents that need to be committed? 7 | Use the "Modified" filter. 8 | {% endblock %} 9 | 10 | -------------------------------------------------------------------------------- /lib/modules/apostrophe-workflow-pages/index.js: -------------------------------------------------------------------------------- 1 | var _ = require('@sailshq/lodash'); 2 | var async = require('async'); 3 | 4 | module.exports = { 5 | 6 | improve: 'apostrophe-pages', 7 | 8 | beforeConstruct: function(self, options) { 9 | 10 | options.addBatchOperations = [ 11 | { 12 | name: 'submit', 13 | route: 'apostrophe-workflow:submit', 14 | label: 'Submit', 15 | buttonLabel: 'Submit' 16 | }, 17 | { 18 | name: 'commit', 19 | route: 'apostrophe-workflow:batch-commit', 20 | label: 'Commit', 21 | buttonLabel: 'Commit' 22 | }, 23 | { 24 | name: 'force-export', 25 | route: 'apostrophe-workflow:batch-force-export', 26 | label: 'Force Export', 27 | buttonLabel: 'Force Export' 28 | } 29 | ].concat(options.addBatchOperations || []); 30 | 31 | }, 32 | 33 | construct: function(self, options) { 34 | self.on('apostrophe:modulesReady', 'setAutoCommitPageMoves', function() { 35 | self.autoCommitPageMoves = self.apos.modules['apostrophe-workflow'] 36 | .options.autoCommitPageMoves || false; 37 | }); 38 | 39 | var superGetPathIndexParams = self.getPathIndexParams; 40 | self.getPathIndexParams = function() { 41 | var params = superGetPathIndexParams(); 42 | params.workflowLocaleForPathIndex = 1; 43 | return params; 44 | }; 45 | 46 | var superRemoveTrailingSlugSlashes = self.removeTrailingSlugSlashes; 47 | self.removeTrailingSlugSlashes = function(req, slug) { 48 | if (arguments.length === 1) { 49 | // bc workaround 50 | slug = req; 51 | req = self.apos.tasks.getAnonReq(); 52 | } 53 | var workflow = self.apos.modules['apostrophe-workflow']; 54 | if (!workflow.prefixes) { 55 | return superRemoveTrailingSlugSlashes(slug); 56 | } 57 | var locale = workflow.liveify(req.locale); 58 | if (_.has(workflow.prefixes, locale)) { 59 | var matches = slug.match(/^(\/[^/]+)(\/?)$/); 60 | if (matches && (workflow.prefixes[locale] === matches[1])) { 61 | // Something like /en/, leave it alone, 62 | // it's a localized homepage. However if the 63 | // trailing slash *after* the locale is missing, 64 | // add it and redirect 65 | if (matches[2] === '') { 66 | return slug + '/'; 67 | } else { 68 | return slug; 69 | } 70 | } 71 | } 72 | return superRemoveTrailingSlugSlashes(slug); 73 | }; 74 | 75 | var superPruneCurrentPageForBrowser = self.pruneCurrentPageForBrowser; 76 | self.pruneCurrentPageForBrowser = function(page) { 77 | var pruned = superPruneCurrentPageForBrowser(page); 78 | pruned.workflowLocale = page.workflowLocale; 79 | pruned.workflowGuid = page.workflowGuid; 80 | return pruned; 81 | }; 82 | 83 | var superGetEditControls = self.getEditControls; 84 | self.getEditControls = function(req) { 85 | return upgradeControls(req, superGetEditControls(req), 'edit'); 86 | }; 87 | 88 | var superGetCreateControls = self.getCreateControls; 89 | self.getCreateControls = function(req) { 90 | return upgradeControls(req, superGetCreateControls(req), 'create'); 91 | }; 92 | 93 | function upgradeControls(req, controls, verb) { 94 | var workflow = self.apos.modules['apostrophe-workflow']; 95 | if (!workflow.includeType(self.name)) { 96 | // Not subject to workflow 97 | return controls; 98 | } 99 | var save = _.find(controls, { action: 'save' }); 100 | if (save) { 101 | save.label = 'Save Draft'; 102 | } 103 | controls.push({ 104 | type: 'dropdown', 105 | label: 'Workflow', 106 | name: 'workflow', 107 | dropdownOptions: { 108 | direction: 'down' 109 | }, 110 | items: [ 111 | { 112 | label: 'Submit', 113 | action: 'workflow-submit' 114 | }, 115 | { 116 | label: 'Commit', 117 | action: 'workflow-commit' 118 | } 119 | ].concat( 120 | (workflow.localized && (verb === 'edit')) 121 | ? [ 122 | { 123 | label: 'History and Export', 124 | action: 'workflow-history' 125 | } 126 | ] : [ 127 | { 128 | label: 'History', 129 | action: 'workflow-history' 130 | } 131 | ] 132 | ).concat(workflow.localized 133 | ? [ 134 | { 135 | label: 'Force Export', 136 | action: 'workflow-force-export' 137 | }, 138 | { 139 | label: 'Force Export Related', 140 | action: 'workflow-force-export-related' 141 | } 142 | ] : [] 143 | ) 144 | }); 145 | return controls; 146 | } 147 | 148 | // On invocation of `apos.pages.move`, modify the criteria and filters 149 | // to ensure only the relevant locale is in play 150 | 151 | var superBeforeMove = self.beforeMove; 152 | self.beforeMove = function(req, moved, target, position, options, callback) { 153 | return superBeforeMove(req, moved, target, position, options, function(err) { 154 | if (err) { 155 | return callback(err); 156 | } 157 | if (moved.workflowLocale) { 158 | options.criteria = _.assign({}, options.criteria || {}, { workflowLocale: moved.workflowLocale }); 159 | options.filters = _.assign({}, options.filters || {}, { workflowLocale: moved.workflowLocale }); 160 | } 161 | return callback(null); 162 | }); 163 | }; 164 | 165 | // After a page is moved in one locale, record the action that 166 | // was taken so it can be repeated on a commit or export 167 | // without attempting to reconcile differences in where the 168 | // destination parent page happens to be at the start 169 | // of the operation. 170 | // If the autoCommitPageMoves option has been set, we move the live version 171 | // when we move the draft one, in order to avoid any page tree conflicts. 172 | 173 | var superAfterMove = self.afterMove; 174 | self.afterMove = function(req, moved, info, callback) { 175 | return superAfterMove(req, moved, info, function(err) { 176 | const isDraft = moved.workflowLocale.includes('-draft'); 177 | 178 | if (err) { 179 | return callback(err); 180 | } 181 | return self.apos.docs.db.update({ 182 | _id: moved._id 183 | }, { 184 | $set: { 185 | workflowModified: true, 186 | workflowMoved: { 187 | target: info.target.workflowGuid, 188 | position: info.position 189 | }, 190 | ...isDraft && { workflowMovedIsNew: true } 191 | } 192 | }, (err) => { 193 | if (err || !isDraft || !self.autoCommitPageMoves) { 194 | return callback(err || null); 195 | } 196 | 197 | self.apos.workflow.getDraftAndLive(req, moved._id, {}, (err, draft, live) => { 198 | if (err) { 199 | return callback(err); 200 | } 201 | 202 | return self.apos.workflow.repeatMove(req, draft, live, callback); 203 | }); 204 | }); 205 | }); 206 | }; 207 | 208 | // `implementParkAll` must be reimplemented to cover all of 209 | // the locales, and to first invoke the add-missing-locales and 210 | // add-locale-prefixes logic, if needed 211 | 212 | self.implementParkAll = function(callback) { 213 | if ( 214 | (self.apos.argv._[0] === 'apostrophe-workflow:add-missing-locales') || 215 | (self.apos.argv._[0] === 'apostrophe-workflow:add-locale-prefixes') || 216 | (self.apos.argv._[0] === 'apostrophe-workflow:remove-numbered-parked-pages') || 217 | (self.apos.argv._[0] === 'apostrophe-workflow:harmonize-workflow-guids-by-parked-id')) { 218 | // If we park pages in this scenario, we'll wind up with 219 | // duplicate pages in the new locales after the task 220 | // fills them in 221 | return setImmediate(callback); 222 | } 223 | 224 | var workflow = self.apos.modules['apostrophe-workflow']; 225 | return async.series([ 226 | workflow.updatePerConfiguration, 227 | parkBody 228 | ], function(err) { 229 | return callback(err); 230 | }); 231 | 232 | function parkBody(callback) { 233 | var workflow = self.apos.modules['apostrophe-workflow']; 234 | var locales = _.keys(workflow.locales); 235 | return async.eachSeries(locales, function(locale, callback) { 236 | var parked = _.cloneDeep(self.parked); 237 | if (workflow.prefixes) { 238 | fixPrefixes(parked, locale); 239 | } 240 | 241 | var req = self.apos.tasks.getReq(); 242 | req.locale = locale; 243 | 244 | return async.eachSeries(parked, function(item, callback) { 245 | convertToLocalizedSlug(item, locale); 246 | return self.implementParkOne(req, item, callback); 247 | }, callback); 248 | 249 | function convertToLocalizedSlug (item, locale) { 250 | if (locale) { 251 | var liveLocale = workflow.liveify(locale); 252 | if (typeof item.slug === 'object') { 253 | if (!item.slug[liveLocale] && !item.slug._default) { 254 | throw new Error('apostrophe-workflow: mising _default value in localized parked page'); 255 | } 256 | item.slug = item.slug[liveLocale] || item.slug._default; 257 | if (item.parkedId && item.parkedId === 'home') { 258 | throw new Error('apostrophe-workflow: the homepage cannot have a localized slug'); 259 | } 260 | } 261 | 262 | if (item._children) { 263 | _.each(item._children, function(child) { 264 | convertToLocalizedSlug(child, liveLocale); 265 | }); 266 | } 267 | } 268 | } 269 | 270 | function fixPrefixes(parked, locale) { 271 | var prefix = workflow.prefixes[workflow.liveify(locale)]; 272 | if (!prefix) { 273 | return; 274 | } 275 | _.each(parked, function(item) { 276 | if (item.slug === '/') { 277 | // Hint to the implementParkOne implementation that 278 | // this is still a homepage even if its slug is 279 | // no longer / due to locale prefixes 280 | item.level = 0; 281 | } 282 | if (item.parent) { 283 | item.parent = prefix + item.parent; 284 | } else if (item.level !== 0) { 285 | item.parent = prefix + '/'; 286 | } 287 | convertToLocalizedSlug(item, locale); 288 | item.slug = prefix + item.slug; 289 | fixPrefixes(item._children || [], locale); 290 | }); 291 | } 292 | }, callback); 293 | } 294 | }; 295 | 296 | var superGetBaseUrl = self.getBaseUrl; 297 | // Return the appropriate base URL for constructing absolute 298 | // URLs in the relevant locale, if the `hostnames` option is 299 | // in play for this locale, otherwise fall back to the 300 | // standard behavior 301 | self.getBaseUrl = function(req) { 302 | var workflow = self.apos.modules['apostrophe-workflow']; 303 | var live = workflow.liveify(req.locale || workflow.defaultLocale); 304 | if (!(workflow.hostnames && workflow.hostnames[live])) { 305 | return superGetBaseUrl(req); 306 | } 307 | var url = req.protocol + '://' + workflow.hostnames[live]; 308 | // If the request URL was on a particular port, the 309 | // new URL should be on that port too. Helpful for 310 | // debugging locally 311 | var matches = (req.get('host') || '').match(/:(\d+$)/); 312 | var port; 313 | if (matches) { 314 | port = matches[1]; 315 | if (((req.protocol === 'http') && (port !== '80')) || 316 | ((req.protocol === 'https') && (port !== '443'))) { 317 | url += ':' + port; 318 | } 319 | } 320 | return url; 321 | }; 322 | 323 | var superGetInfoProjection = self.getInfoProjection; 324 | self.getInfoProjection = function(req, cursor) { 325 | var projection = superGetInfoProjection(req); 326 | projection = Object.assign( 327 | { 328 | workflowLocale: 1, 329 | workflowModified: 1, 330 | workflowSubmitted: 1, 331 | workflowLastCommitted: 1 332 | }, 333 | projection 334 | ); 335 | return projection; 336 | }; 337 | 338 | } 339 | 340 | }; 341 | -------------------------------------------------------------------------------- /lib/modules/apostrophe-workflow-pages/public/js/editor.js: -------------------------------------------------------------------------------- 1 | // Extend the pieces editor modal to implement workflow 2 | 3 | apos.define('apostrophe-pages-editor', { 4 | construct: function(self, options) { 5 | var superBeforeShow = self.beforeShow; 6 | self.beforeShow = function(callback) { 7 | self.link('apos-workflow-submit', function() { 8 | self.submitting = true; 9 | return self.save(function(err) { 10 | if (err) { 11 | self.submitting = false; 12 | 13 | } 14 | // Never reached due to redirect 15 | }); 16 | }); 17 | self.link('apos-workflow-commit', function() { 18 | self.committing = true; 19 | apos.notify('The page has been created and saved.', { type: 'success', dismiss: true }); 20 | return self.save(function(err) { 21 | if (err) { 22 | self.committing = false; 23 | 24 | } 25 | // Never reached due to redirect 26 | }); 27 | }); 28 | self.link('apos-workflow-force-export', function() { 29 | self.forceExporting = true; 30 | return self.save(function(err) { 31 | if (err) { 32 | self.forceExporting = false; 33 | 34 | } 35 | // Never reached due to redirect 36 | }); 37 | }); 38 | self.link('apos-workflow-force-export-related', function() { 39 | self.forceExportingRelated = true; 40 | return self.save(function(err) { 41 | if (err) { 42 | self.forceExportingRelated = false; 43 | } 44 | // Never reached due to redirect 45 | }); 46 | }); 47 | self.link('apos-workflow-history', function() { 48 | return apos.modules['apostrophe-workflow'].history(self.page._id, callback); 49 | }); 50 | self.workflowControlsVisibility(); 51 | return superBeforeShow(callback); 52 | }; 53 | 54 | self.workflowControlsVisibility = function() { 55 | var workflow = apos.modules['apostrophe-workflow']; 56 | var args = { 57 | type: self.page.type 58 | }; 59 | if (self.verb === 'update') { 60 | args.id = self.page._id; 61 | } 62 | return workflow.api('committable', args, function(results) { 63 | if (results.status === 'ok') { 64 | self.$controls.addClass('apos-workflow-committable'); 65 | } 66 | }); 67 | }; 68 | 69 | self.afterSave = function(callback) { 70 | if (self.submitting) { 71 | return apos.modules['apostrophe-workflow'].submit([ self.savedPage._id ], callback); 72 | } 73 | if (self.committing) { 74 | return apos.modules['apostrophe-workflow'].commit([ self.savedPage._id ], callback); 75 | } 76 | if (self.forceExporting) { 77 | return apos.modules['apostrophe-workflow'].forceExport(self.savedPage._id, callback); 78 | } 79 | if (self.forceExportingRelated) { 80 | return apos.modules['apostrophe-workflow'].forceExportRelated(self.savedPage._id, callback); 81 | } 82 | if (self.accessingHistory) { 83 | return apos.modules['apostrophe-workflow'].history(self.page._id, callback); 84 | } 85 | return setImmediate(callback); 86 | }; 87 | } 88 | }); 89 | -------------------------------------------------------------------------------- /lib/modules/apostrophe-workflow-pages/public/js/reorganize.js: -------------------------------------------------------------------------------- 1 | apos.define('apostrophe-pages-reorganize', { 2 | 3 | construct: function(self, options) { 4 | 5 | var workflow = apos.modules['apostrophe-workflow']; 6 | 7 | self.batchSubmit = function() { 8 | return self.batchSimple( 9 | 'submit', 10 | "Are you sure you want to submit " + self.choices.length + " page(s)?", 11 | {} 12 | ); 13 | }; 14 | 15 | self.batchCommit = function() { 16 | return self.batchSimple( 17 | 'commit', 18 | "Are you sure you want to commit " + self.choices.length + " page(s)?", 19 | { 20 | success: function(results, callback) { 21 | return workflow.batchExport(_.values(results), callback); 22 | } 23 | } 24 | ); 25 | }; 26 | 27 | self.batchForceExport = function() { 28 | return self.batchSimple( 29 | 'force-export', 30 | "Are you sure you want to force export " + self.choices.length + " page(s)?", 31 | { 32 | dataSource: workflow.batchForceExportGetLocales, 33 | success: function(result, callback) { 34 | workflow.presentBatchExportResult(result); 35 | return callback(null); 36 | } 37 | } 38 | ); 39 | }; 40 | } 41 | }); 42 | -------------------------------------------------------------------------------- /lib/modules/apostrophe-workflow-permissions/index.js: -------------------------------------------------------------------------------- 1 | var _ = require('@sailshq/lodash'); 2 | 3 | module.exports = { 4 | 5 | improve: 'apostrophe-permissions', 6 | 7 | afterConstruct: function(self) { 8 | self.workflowAddPermissions(); 9 | self.apos.on('can', _.partial(self.workflowOnPermissions, 'can')); 10 | self.apos.on('criteria', _.partial(self.workflowOnPermissions, 'criteria')); 11 | }, 12 | 13 | construct: function(self, options) { 14 | 15 | self.workflowAddPermissions = function() { 16 | self.add({ 17 | value: 'private-locales', 18 | label: 'View Private Locales' 19 | }); 20 | }; 21 | 22 | self.workflowOnPermissions = function(event, req, action, object, info) { 23 | 24 | var workflow; 25 | workflow = self.apos.modules['apostrophe-workflow']; 26 | 27 | if (!workflow) { 28 | // Workflow module not initialized yet 29 | return; 30 | } 31 | 32 | // Types excluded from workflow should not be subject to any of this 33 | if (!workflow.includeType(info.type)) { 34 | return; 35 | } 36 | 37 | // Flunk any access to a nonexistent locale or, if 38 | // we don't have the private-locale permission, 39 | // any access to a private locale 40 | var locale = workflow.locales[req.locale || workflow.defaultLocale]; 41 | if ( 42 | (!locale) || 43 | (locale.private && 44 | ((!req.user) || ( 45 | (!req.user._permissions['private-locales']) && 46 | (!req.user._permissions['admin']) 47 | )) 48 | ) 49 | ) { 50 | info.response = info._false; 51 | return; 52 | } 53 | 54 | if (_.contains(workflow.excludeActions, action)) { 55 | return; 56 | } 57 | if (!info.type) { 58 | return; 59 | } 60 | var manager = self.apos.docs.getManager(info.type); 61 | if (!manager) { 62 | return; 63 | } 64 | var verb = info.verb; 65 | // publish is not a separate verb in workflow since we already control whether you can edit 66 | // in draft vs. live locales 67 | if (verb === 'publish') { 68 | verb = 'edit'; 69 | action = action.replace(/^publish-/, 'edit-'); 70 | } 71 | if (!_.contains(workflow.includeVerbs, verb)) { 72 | return; 73 | } 74 | if (req.user && req.user._permissions.admin) { 75 | // Sitewide admins aren't restricted by locale because they can edit 76 | // groups, which would allow them to defeat that anyway 77 | return; 78 | } 79 | if (manager.isAdminOnly && manager.isAdminOnly()) { 80 | info.response = info._false; 81 | return; 82 | } 83 | 84 | // OK, now we know this is something we're entitled to an opinion about 85 | 86 | // Rebuild the action string using the effective verb and type name 87 | action = info.verb + '-' + info.type; 88 | 89 | // publish is not a separate verb in workflow since we already control whether you can edit 90 | // in draft vs. live locales 91 | if (info.verb === 'publish') { 92 | action = action.replace(/^publish-/, 'edit-'); 93 | } 94 | 95 | if (!(req.user && req.user._permissionsLocales)) { 96 | info.response = info._false; 97 | return; 98 | } 99 | 100 | var adminAction = 'admin-' + info.type; 101 | 102 | // 'VERB', 'VERB-this-type' or 'admin-this-type' is acceptable 103 | var permissionsLocales = _.assign({}, 104 | req.user._permissionsLocales[action] || {}, 105 | req.user._permissionsLocales[adminAction] || {}, 106 | req.user._permissionsLocales[verb] || {} 107 | ); 108 | 109 | if (_.isEmpty(permissionsLocales)) { 110 | info.response = info._false; 111 | return; 112 | } 113 | 114 | if (event === 'criteria') { 115 | info.response = { $and: [ info.response, { workflowLocale: { $in: _.keys(permissionsLocales) } } ] }; 116 | } else { 117 | object = info.object || info.newObject; 118 | if (object) { 119 | if (!permissionsLocales[object.workflowLocale]) { 120 | info.response = info._false; 121 | } 122 | } else if (!permissionsLocales[req.locale]) { 123 | info.response = info._false; 124 | } 125 | } 126 | 127 | }; 128 | 129 | } 130 | }; 131 | -------------------------------------------------------------------------------- /lib/modules/apostrophe-workflow-pieces/index.js: -------------------------------------------------------------------------------- 1 | var _ = require('@sailshq/lodash'); 2 | 3 | module.exports = { 4 | 5 | improve: 'apostrophe-pieces', 6 | 7 | canEditTrash: true, 8 | 9 | beforeConstruct: function(self, options) { 10 | 11 | function onlyIf(type) { 12 | return self.apos.modules['apostrophe-workflow'].includeType(type); 13 | } 14 | 15 | options.addBatchOperations = [ 16 | { 17 | name: 'submit', 18 | route: 'apostrophe-workflow:submit', 19 | label: 'Submit', 20 | buttonLabel: 'Submit', 21 | onlyIf: onlyIf 22 | }, 23 | { 24 | name: 'commit', 25 | route: 'apostrophe-workflow:batch-commit', 26 | label: 'Commit', 27 | buttonLabel: 'Commit', 28 | onlyIf: onlyIf 29 | }, 30 | { 31 | name: 'force-export', 32 | route: 'apostrophe-workflow:batch-force-export', 33 | label: 'Force Export', 34 | buttonLabel: 'Force Export', 35 | onlyIf: onlyIf 36 | }, 37 | { 38 | name: 'revert-to-live', 39 | route: 'apostrophe-workflow:batch-revert-to-live', 40 | label: 'Revert', 41 | buttonLabel: 'Revert to Live', 42 | onlyIf: onlyIf 43 | } 44 | ].concat(options.addBatchOperations || []); 45 | 46 | options.addColumns = [ 47 | { 48 | name: 'workflowLastCommitted', 49 | label: 'Last Commit', 50 | sort: { 51 | 'workflowLastCommitted.at': -1 52 | }, 53 | partial: function(value) { 54 | return self.partial('workflowLastCommitted', { value: value }); 55 | } 56 | } 57 | ].concat(options.addColumns || []); 58 | 59 | }, 60 | 61 | construct: function(self, options) { 62 | 63 | var superGetEditControls = self.getEditControls; 64 | self.getEditControls = function(req) { 65 | return upgradeControls(req, superGetEditControls(req), 'edit'); 66 | }; 67 | 68 | var superGetCreateControls = self.getCreateControls; 69 | self.getCreateControls = function(req) { 70 | return upgradeControls(req, superGetCreateControls(req), 'create'); 71 | }; 72 | 73 | function upgradeControls(req, controls, verb) { 74 | var workflow = self.apos.modules['apostrophe-workflow']; 75 | if (!workflow.includeType(self.name)) { 76 | // Not subject to workflow 77 | return controls; 78 | } 79 | var save = _.find(controls, { action: 'save' }); 80 | if (save) { 81 | save.label = 'Save Draft'; 82 | } 83 | 84 | controls.push({ 85 | type: 'dropdown', 86 | label: 'Workflow', 87 | name: 'workflow', 88 | dropdownOptions: { 89 | direction: 'down' 90 | }, 91 | // Frontend takes care of visibility decisions for these 92 | items: [ 93 | { 94 | label: 'Submit', 95 | action: 'workflow-submit' 96 | }, 97 | { 98 | label: 'Commit', 99 | action: 'workflow-commit' 100 | } 101 | ].concat((verb === 'edit') 102 | ? [ 103 | { 104 | label: 'History', 105 | action: 'workflow-history' 106 | } 107 | ] : [] 108 | ).concat(workflow.localized 109 | ? [ 110 | { 111 | label: 'Force Export', 112 | action: 'workflow-force-export' 113 | }, 114 | { 115 | label: 'Force Export Related', 116 | action: 'workflow-force-export-related' 117 | } 118 | ] : [] 119 | ) 120 | }); 121 | return controls; 122 | } 123 | } 124 | }; 125 | -------------------------------------------------------------------------------- /lib/modules/apostrophe-workflow-pieces/public/js/editor-modal.js: -------------------------------------------------------------------------------- 1 | // Extend the pieces editor modal to implement workflow 2 | 3 | apos.define('apostrophe-pieces-editor-modal', { 4 | construct: function(self, options) { 5 | 6 | var superBeforeShow = self.beforeShow; 7 | self.beforeShow = function(callback) { 8 | self.link('apos-workflow-submit', function() { 9 | return self.workflowSaveThen(function(callback) { 10 | return apos.modules['apostrophe-workflow'].submit([ self.savedPiece._id ], callback); 11 | }); 12 | }); 13 | self.link('apos-workflow-commit', function() { 14 | var workflow = apos.modules['apostrophe-workflow']; 15 | return self.workflowSaveThen(function(callback) { 16 | apos.ui.globalBusy(true); 17 | return workflow.getEditable({ ids: [ self.savedPiece._id ], related: true }, function(err, result) { 18 | apos.ui.globalBusy(false); 19 | if (!err) { 20 | workflow.commit(_.intersection(result.committable, result.modified), { leadId: self.savedPiece._id }, callback); 21 | } 22 | }); 23 | }); 24 | }); 25 | self.link('apos-workflow-force-export', function() { 26 | return self.workflowSaveThen(function(callback) { 27 | return apos.modules['apostrophe-workflow'].forceExport(self.savedPiece._id, callback); 28 | }); 29 | }); 30 | self.link('apos-workflow-force-export-related', function() { 31 | return self.workflowSaveThen(function(callback) { 32 | return apos.modules['apostrophe-workflow'].forceExportRelated(self.savedPiece._id, callback); 33 | }); 34 | }); 35 | self.link('apos-workflow-history', function() { 36 | if (!self._id) { 37 | return; 38 | } 39 | return apos.modules['apostrophe-workflow'].history(self._id); 40 | }); 41 | self.workflowControlsVisibility(); 42 | return superBeforeShow(callback); 43 | }; 44 | 45 | // Save the modal normally, then invoke the given callback. 46 | // The callback is invoked only if the modal is saved successfully 47 | 48 | self.workflowSaveThen = function(callback) { 49 | self.workflowBeforeDisplayResponse = callback; 50 | return self.save(function(err) { 51 | if (err) { 52 | self.apos.utils.error(err); 53 | } 54 | self.workflowBeforeDisplayResponse = null; 55 | }); 56 | }; 57 | 58 | var superDisplayResponse = self.displayResponse; 59 | 60 | // Invoke `workflowBeforeDisplayResponse` if present, then 61 | // respond to the saving of the piece in the normal way 62 | self.displayResponse = function(result, callback) { 63 | if (!self.workflowBeforeDisplayResponse) { 64 | return superDisplayResponse(result, callback); 65 | } 66 | return self.workflowBeforeDisplayResponse(function(err) { 67 | if (err) { 68 | self.apos.utils.error(err); 69 | return callback(err); 70 | } 71 | return superDisplayResponse(result, callback); 72 | }); 73 | }; 74 | 75 | self.workflowControlsVisibility = function() { 76 | var workflow = apos.modules['apostrophe-workflow']; 77 | return workflow.api('committable', { type: self.name, id: self._id }, function(results) { 78 | if (results.status === 'ok') { 79 | self.$controls.addClass('apos-workflow-committable'); 80 | } 81 | }); 82 | }; 83 | } 84 | }); 85 | -------------------------------------------------------------------------------- /lib/modules/apostrophe-workflow-pieces/public/js/manager-modal.js: -------------------------------------------------------------------------------- 1 | apos.define('apostrophe-pieces-manager-modal', { 2 | 3 | construct: function(self, options) { 4 | 5 | var workflow = apos.modules['apostrophe-workflow']; 6 | 7 | self.batchSubmit = function() { 8 | return self.batchSimple( 9 | 'submit', 10 | "Are you sure you want to submit " + self.choices.length + " item(s)?", 11 | {} 12 | ); 13 | }; 14 | 15 | self.batchCommit = function() { 16 | return self.batchSimple( 17 | 'commit', 18 | "Are you sure you want to commit " + self.choices.length + " item(s)?", 19 | { 20 | success: function(results, callback) { 21 | if (workflow.options.exportAfterCommit !== false) { 22 | return workflow.batchExport(_.values(results), callback); 23 | } else { 24 | return callback(null); 25 | } 26 | } 27 | } 28 | ); 29 | }; 30 | 31 | self.batchForceExport = function() { 32 | return self.batchSimple( 33 | 'force-export', 34 | "Are you sure you want to force export " + self.choices.length + " item(s)?", 35 | { 36 | dataSource: workflow.batchForceExportGetLocales, 37 | success: function(result, callback) { 38 | workflow.presentBatchExportResult(result); 39 | return callback(null); 40 | } 41 | } 42 | ); 43 | }; 44 | 45 | self.batchRevertToLive = function() { 46 | return self.batchSimple( 47 | 'revert-to-live', 48 | "Are you sure you want to revert " + self.choices.length + " item(s) to their live content?", 49 | {} 50 | ); 51 | }; 52 | 53 | } 54 | }); 55 | -------------------------------------------------------------------------------- /lib/modules/apostrophe-workflow-pieces/views/workflowLastCommitted.html: -------------------------------------------------------------------------------- 1 | {{ data.value.user.title or data.value.user.username }} {{ data.value.at | date('YYYY-MM-DD') }} 2 | -------------------------------------------------------------------------------- /lib/modules/apostrophe-workflow-schemas/index.js: -------------------------------------------------------------------------------- 1 | var _ = require('@sailshq/lodash'); 2 | 3 | module.exports = { 4 | 5 | improve: 'apostrophe-schemas', 6 | 7 | afterConstruct: function(self) { 8 | self.workflowAddPermissionsFieldType(); 9 | }, 10 | 11 | construct: function(self, options) { 12 | self.workflowAddPermissionsFieldType = function() { 13 | self.apos.schemas.addFieldType({ 14 | name: 'apostrophe-workflow-permissions', 15 | partial: self.workflowPermissionsPartial, 16 | converters: self.workflowPermissionsConverters 17 | }); 18 | }; 19 | 20 | self.workflowPermissionsPartial = function(data) { 21 | var workflow = self.apos.modules['apostrophe-workflow']; 22 | _.each(data.choices, function(choice) { 23 | if (_.contains(workflow.excludeActions, choice.value)) { 24 | choice.exempt = true; 25 | } 26 | var matches = choice.value.match(self.apos.permissions.permissionPattern); 27 | if (matches) { 28 | var verb = matches[1]; 29 | if (!_.contains(workflow.includeVerbs, verb)) { 30 | choice.exempt = true; 31 | } 32 | } 33 | }); 34 | data.choices = _.filter(data.choices, function(choice) { 35 | // Exclude submit- actions from the UI as we're replacing that with edit permissions on the 36 | // draft locales 37 | var matches = choice.value.match(self.apos.permissions.permissionPattern); 38 | if (!matches) { 39 | return true; 40 | } 41 | if (matches[1] === 'submit') { 42 | if (workflow.includeType(matches[2])) { 43 | return false; 44 | } 45 | } 46 | return true; 47 | }); 48 | return self.partial('workflow-permissions-schema-field', data); 49 | }; 50 | 51 | self.workflowPermissionsConverters = { 52 | string: function(req, data, name, object, field, callback) { 53 | // For now importing permissions is not a concern 54 | return setImmediate(callback); 55 | }, 56 | form: function(req, data, name, object, field, callback) { 57 | if (!Array.isArray(data[name])) { 58 | object[name] = []; 59 | return setImmediate(callback); 60 | } 61 | 62 | object[name] = _.filter(data[name], function(choice) { 63 | return _.contains(_.pluck(field.choices, 'value'), choice); 64 | }); 65 | 66 | var permissionsLocales = {}; 67 | var raw = data[name + 'Locales']; 68 | if ((!raw) || (typeof (raw) !== 'object')) { 69 | return setImmediate(callback); 70 | } 71 | _.each(raw, function(locales, permission) { 72 | permissionsLocales[permission] = {}; 73 | _.each(field.locales, function(locale, name) { 74 | if (locales[name]) { 75 | permissionsLocales[permission][name] = true; 76 | } 77 | }); 78 | }); 79 | 80 | // For bc, this schema field uses a separate property for the extended 81 | // information about the locales for which the user has the permission 82 | object[name + 'Locales'] = permissionsLocales; 83 | 84 | return setImmediate(callback); 85 | } 86 | }; 87 | } 88 | }; 89 | -------------------------------------------------------------------------------- /lib/modules/apostrophe-workflow-schemas/views/workflow-permissions-schema-field.html: -------------------------------------------------------------------------------- 1 | {%- import "apostrophe-schemas:macros.html" as schemas -%} 2 | {%- import "apostrophe-workflow:locale-tree.html" as localeTree -%} 3 | 4 | {%- macro body(field) -%} 5 | {%- for choice in field.choices -%} 6 |
7 | {# This can't be label because there are nested checkboxes #} 8 |
9 | 10 | 11 | 12 | {% if not choice.exempt %} 13 | {{ localeTree.tree( 14 | field.name + 'Locales' + '[' + choice.value + ']', [ 15 | { 16 | type: 'select', 17 | choices: [ 18 | { 19 | label: 'None', 20 | value: '' 21 | }, 22 | { 23 | label: 'Edit', 24 | value: 'edit' 25 | }, 26 | { 27 | label: 'Commit', 28 | value: 'commit' 29 | } 30 | ] 31 | } ], 32 | field.nestedLocales) 33 | }} 34 | {% endif %} 35 |
36 |
37 | {%- endfor -%} 38 | {%- endmacro -%} 39 | 40 | {{ schemas.fieldset(data, body) }} 41 | -------------------------------------------------------------------------------- /lib/modules/apostrophe-workflow-tasks/index.js: -------------------------------------------------------------------------------- 1 | var _ = require('@sailshq/lodash'); 2 | 3 | module.exports = { 4 | improve: 'apostrophe-tasks', 5 | construct: function(self, options) { 6 | var superGetReq = self.getReq; 7 | self.getReq = function(properties) { 8 | var workflow = self.apos.modules['apostrophe-workflow']; 9 | return superGetReq(_.assign({ locale: self.apos.argv['workflow-locale'] || (workflow && workflow.defaultLocale) }, properties)); 10 | }; 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /lib/modules/apostrophe-workflow-templates/index.js: -------------------------------------------------------------------------------- 1 | var _ = require('@sailshq/lodash'); 2 | 3 | module.exports = { 4 | improve: 'apostrophe-templates', 5 | construct: function(self, options) { 6 | var superShowContextMenu = self.showContextMenu; 7 | self.showContextMenu = function(req) { 8 | var already = superShowContextMenu(req); 9 | if (already) { 10 | return already; 11 | } 12 | // Because the draft/live switch is in this area, 13 | // it doesn't make sense to hide the context menu 14 | // in the presence of workflow, unless they have 15 | // no editing privileges 16 | return _.find(Object.keys(self.apos.docs.managers), function(name) { 17 | return self.apos.permissions.can(req, 'edit-' + name); 18 | }); 19 | }; 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /lib/removeDotPathViaSplice.js: -------------------------------------------------------------------------------- 1 | // Remove the property at the given dot path. if it is a property of an array, 2 | // remove it with `splice` so there are no holes. If not, remove it 3 | // with `delete`. 4 | // 5 | // If the property does not exist in the first place, or any part of the path 6 | // leading to it does not exist, do nothing. 7 | // 8 | // Returns true if `splice` was used. 9 | 10 | module.exports = function(o, dotPath) { 11 | var elements = dotPath.split(/\./); 12 | var i; 13 | var lastIndex = elements.length - 1; 14 | for (i = 0; (i < lastIndex); i++) { 15 | if (!o) { 16 | return false; 17 | } 18 | o = o[elements[i]]; 19 | } 20 | if (!o) { 21 | return false; 22 | } 23 | var last = elements[lastIndex]; 24 | if (Array.isArray(o)) { 25 | o.splice(last, 1); 26 | return true; 27 | } else { 28 | delete o[last]; 29 | return false; 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /locales/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "Server error, please try again.": "Server error, please try again." 3 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.40.3", 3 | "scripts": { 4 | "test": "eslint . && mocha" 5 | }, 6 | "license": "MIT", 7 | "main": "index.js", 8 | "name": "apostrophe-workflow", 9 | "author": { 10 | "name": "Apostrophe Technologies LLC" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/apostrophecms/apostrophe-workflow.git" 15 | }, 16 | "homepage": "https://github.com/apostrophecms/apostrophe-workflow#readme", 17 | "keywords": [ 18 | "apostrophe", 19 | "apostrophecms", 20 | "apostrophe-cms", 21 | "content management", 22 | "content management system", 23 | "cms", 24 | "workflow", 25 | "localization", 26 | "editorial" 27 | ], 28 | "bugs": { 29 | "url": "https://github.com/apostrophecms/apostrophe-workflow/issues" 30 | }, 31 | "deprecated": false, 32 | "description": "Workflow, approvals, localization and internationalization for ApostropheCMS", 33 | "bundleDependencies": false, 34 | "dependencies": { 35 | "@sailshq/lodash": "^3.10.3", 36 | "async": "^1.5.2", 37 | "bluebird": "^3.7.2", 38 | "eslint-config-apostrophe": "^2.0.2", 39 | "express-session": "^1.17.1", 40 | "json-diff": "^0.5.3", 41 | "jsondiffpatch": "^0.2.5", 42 | "minimatch": "^3.0.4", 43 | "qs": "^6.9.4", 44 | "request-promise": "^4.2.6" 45 | }, 46 | "devDependencies": { 47 | "apostrophe": "^2.220.7", 48 | "apostrophe-override-options": "^2.2.1", 49 | "eslint": "^4.15.0", 50 | "eslint-config-standard": "^11.0.0-beta.0", 51 | "eslint-plugin-import": "^2.25.2", 52 | "eslint-plugin-node": "^5.2.1", 53 | "eslint-plugin-promise": "^3.6.0", 54 | "eslint-plugin-standard": "^3.0.1", 55 | "mocha": "^8.2.1" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /public/css/expand.less: -------------------------------------------------------------------------------- 1 | .apos-ui .apos-expand-list 2 | { 3 | max-height: 0px; 4 | overflow: hidden; 5 | transition: all 0.4s ease; 6 | font-size: 14px; 7 | padding-left: 50px; 8 | 9 | background-color: lighten(@apos-mid, 5%); 10 | li { 11 | margin-bottom: 5px; 12 | } 13 | } 14 | 15 | .apos-expand-list-container--open .apos-expand-list 16 | { 17 | max-height: 900px; 18 | } 19 | 20 | .apos-ui .apos-expand-info { 21 | padding: 20px 20px 10px 50px; 22 | &:hover { cursor: pointer; background-color: darken(@apos-mid, 5%)} 23 | } 24 | 25 | .apos-ui .apos-expand-list-container { 26 | position: relative; 27 | background-color: @apos-mid; 28 | transition: all 0.3s ease; 29 | p 30 | { 31 | font-size: 12px; 32 | font-style: italic; 33 | } 34 | a { color: @apos-primary; } 35 | } 36 | 37 | .apos-ui .apos-expand-list-container--open .apos-expand-list { 38 | padding: 20px 10px 20px 50px; 39 | } 40 | 41 | .apos-expand-trigger { 42 | &::before { 43 | position: absolute; 44 | left: 21px; 45 | top: 21px; 46 | font-size: 12px; 47 | font-family: "FontAwesome"; 48 | content: ""; 49 | margin-right: 0.5em; 50 | } 51 | } 52 | 53 | .apos-expand-list-container--open .apos-expand-trigger { 54 | &::before { 55 | content: "" 56 | } 57 | } -------------------------------------------------------------------------------- /public/css/menu.less: -------------------------------------------------------------------------------- 1 | .apos-workflow-menu 2 | { 3 | display: inline-block; 4 | margin-left: 20px; 5 | .apos-button--group 6 | { 7 | border: 0px; 8 | } 9 | .apos-button 10 | { 11 | margin-left: 5px; 12 | } 13 | // .apos-workflow-state is visible by default because we need 14 | // to get to draft mode to access admin bar functionality in 15 | // a way that isn't extremely difficult to debug 16 | [data-apos-workflow-submit], [data-apos-workflow-commit], [data-apos-workflow-submitted] 17 | { 18 | display: none; 19 | } 20 | &.apos-workflow-editable 21 | { 22 | .apos-workflow-state 23 | { 24 | display: inline-block; 25 | } 26 | } 27 | &.apos-workflow-uncommitted 28 | { 29 | .apos-button--group 30 | { 31 | border: 2px solid; 32 | } 33 | [data-apos-workflow-commit] { 34 | display: inline-block; 35 | } 36 | } 37 | &.apos-workflow-submitted 38 | { 39 | .apos-button--group 40 | { 41 | border: 2px solid; 42 | } 43 | [data-apos-workflow-submitted] { 44 | display: inline-block; 45 | } 46 | } 47 | &.apos-workflow-unsubmitted 48 | { 49 | .apos-button--group 50 | { 51 | border: 2px solid; 52 | } 53 | [data-apos-workflow-submit] { 54 | display: inline-block; 55 | } 56 | &.apos-workflow-submitted { 57 | [data-apos-workflow-submitted] { 58 | display: none; 59 | } 60 | } 61 | } 62 | } 63 | 64 | .apos-ui .apos-workflow-state 65 | { 66 | margin-left: 5px; 67 | box-shadow: none; 68 | .apos-workflow-state-toggle { 69 | margin-left: 0; 70 | } 71 | } 72 | 73 | // Modal case: page settings or pieces editor modal, where 74 | // we already know we can edit the draft, and the only question 75 | // is whether we can perform commit-related actions 76 | 77 | .apos-modal-controls { 78 | [data-apos-workflow-commit], 79 | [data-apos-workflow-history], 80 | [data-apos-workflow-force-export], 81 | [data-apos-workflow-force-export-related] { 82 | display: none; 83 | } 84 | &.apos-workflow-committable { 85 | [data-apos-workflow-commit], 86 | [data-apos-workflow-history], 87 | [data-apos-workflow-force-export], 88 | [data-apos-workflow-force-export-related] { 89 | display: list-item; 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /public/css/schemas.less: -------------------------------------------------------------------------------- 1 | .apos-ui .apos-workflow-field-controls 2 | { 3 | position: relative; 4 | float: right; 5 | font-size: 14px; 6 | text-transform: uppercase; 7 | letter-spacing: 1px; 8 | a 9 | { 10 | color: @apos-primary; 11 | margin-right: 10px; 12 | } 13 | 14 | .apos-workflow-field-current 15 | { 16 | color: @apos-mid; 17 | } 18 | 19 | .apos-workflow-revert-field 20 | { 21 | font-family: 'FontAwesome'; 22 | position: absolute; 23 | font-size: 18px; 24 | color: @apos-primary; 25 | right: -28px; 26 | top: 50px; 27 | margin-right: 0; 28 | } 29 | } 30 | 31 | .apos-ui .apos-workflow-field-state-control 32 | { 33 | // -webkit-appearance:none; 34 | outline: none; 35 | border-radius: 0; 36 | border: 1px solid @apos-mid; 37 | padding: 2px; 38 | text-transform: uppercase; 39 | font-size: 14px; 40 | letter-spacing: 1px; 41 | } 42 | 43 | .apos-ui .apos-field-input:disabled 44 | { 45 | opacity: 0.5; 46 | } 47 | 48 | .apos-workflow-live-field 49 | { 50 | position: relative; 51 | .apos-workflow-live-field-mask 52 | { 53 | position: absolute; 54 | z-index: 100; 55 | top: 0; 56 | left: 0; 57 | right: 0; 58 | bottom: 0; 59 | } 60 | // There is also jQuery to prevent keyboard events. 61 | pointer-events: none; 62 | label:first-child { 63 | // Allow clicks on the live/draft toggle 64 | pointer-events: auto; 65 | } 66 | .apos-field:first-child { 67 | display: none; 68 | } 69 | .apos-form-checkbox { 70 | // disabled style (not a native control) 71 | opacity: 0.5; 72 | } 73 | } 74 | 75 | 76 | .apos-ui .apos-field.apos-workflow-field-changed .apos-field-input 77 | { 78 | border: 1px solid purple; 79 | } -------------------------------------------------------------------------------- /public/css/user.less: -------------------------------------------------------------------------------- 1 | @addColor: #9fecb3; 2 | @removeColor: #f44336; 3 | @movedColor: #ff9800; 4 | 5 | 6 | @import 'schemas.less'; 7 | @import 'menu.less'; 8 | @import 'expand.less'; 9 | 10 | .apos-ui .apos-workflow-preview 11 | { 12 | width: 100%; 13 | height: 100%; 14 | position: relative; 15 | box-sizing: border-box; 16 | border: 0; 17 | margin: 0; 18 | padding: 0; 19 | transform: scale(0.8); 20 | border-bottom-left-radius: 10px; 21 | border-bottom-right-radius: 10px; 22 | box-shadow: 0 10px 190px 0 rgba(0,0,0,.25); 23 | &::before { 24 | content: "● ● ●"; 25 | position: absolute; 26 | top: -59px; 27 | left: 0; 28 | width: 100%; 29 | color: #ffffff; 30 | text-align: left; 31 | font-size: 1.5em; 32 | background: #272727; 33 | display: block; 34 | padding: 6px 8px 11px 13px; 35 | border-top-left-radius: 10px; 36 | border-top-right-radius: 10px; 37 | } 38 | } 39 | 40 | .apos-workflow-preview-iframe 41 | { 42 | width: 100%; 43 | height: 100%; 44 | box-sizing: border-box; 45 | border: 0; 46 | margin: 0; 47 | padding: 0; 48 | overflow: scroll; 49 | } 50 | 51 | .apos-workflow-preview-page 52 | { 53 | .apos-ui { 54 | display: none; 55 | } 56 | } 57 | 58 | .apos-workflow-widget-diff 59 | { 60 | position: relative; 61 | &::before { 62 | position: absolute; 63 | height: 100%; 64 | width: 60px; 65 | z-index: 1000; 66 | left: -1px; 67 | top: -3px; 68 | top: 0px; 69 | display: flex; 70 | justify-content: center; 71 | align-items: center; 72 | font-size: 24px; 73 | color: white; 74 | opacity: 0.8; 75 | font-family: 'FontAwesome'; 76 | } 77 | &::after { 78 | content: ""; 79 | position: absolute; 80 | left: 0; 81 | top: 0; 82 | width: 100%; 83 | height: 100%; 84 | opacity: 0.2; 85 | } 86 | } 87 | 88 | .apos-workflow-widget-diff--new { border: 2px solid @addColor; } 89 | .apos-workflow-widget-diff--new::before { content: ""} 90 | .apos-workflow-widget-diff--new::before, 91 | .apos-workflow-widget-diff--new::after { background-color: @addColor; } 92 | 93 | .apos-workflow-widget-diff--deleted { border: 2px solid @removeColor; } 94 | .apos-workflow-widget-diff--deleted::before { content: ""; } 95 | .apos-workflow-widget-diff--deleted::before, 96 | .apos-workflow-widget-diff--deleted::after { background-color: @removeColor; } 97 | 98 | .apos-workflow-widget-diff--moved { border: 2px solid @movedColor; } 99 | .apos-workflow-widget-diff--moved::before { content: " "; } 100 | .apos-workflow-widget-diff--moved::before, 101 | .apos-workflow-widget-diff--moved::after { background-color: @movedColor; } 102 | 103 | .apos-workflow-widget-diff--changed { border: 2px solid @movedColor; } 104 | .apos-workflow-widget-diff--changed::before { content: ""; } 105 | .apos-workflow-widget-diff--changed::before, 106 | .apos-workflow-widget-diff--changed::after { background-color: @movedColor; } 107 | 108 | .apos-workflow-no-preview 109 | { 110 | display: flex; 111 | justify-content: center; 112 | height: 100%; 113 | align-items: center; 114 | font-size: 28px; 115 | } 116 | 117 | .apos-workflow-live-page { 118 | // Everything except the live/draft toggle should be unavailable in live mode 119 | .apos-ui .apos-context-menu-container .apos-context-menu { 120 | display: none; 121 | } 122 | } 123 | 124 | .apos-ui .apos-workflow-locale-tree { 125 | margin-left: 1em; 126 | li { 127 | margin-top: 4px; 128 | .apos-workflow-locale-input--disabled > label, 129 | .apos-workflow-locale-input--disabled > .apos-workflow-locale-label { 130 | opacity: 0.7; 131 | } 132 | .apos-workflow-locale-control-label--committed { 133 | font-weight: bold; 134 | small { 135 | color: @apos-mid; 136 | font-size: 0.8em; 137 | font-weight: normal; 138 | } 139 | } 140 | } 141 | } 142 | 143 | .apos-ui .apos-workflow-locale-label { 144 | margin-right: 12px; 145 | } 146 | 147 | .apos-ui .apos-workflow-locale-checkbox { 148 | font-size: 80%; 149 | } 150 | 151 | .apos-ui .apos-workflow-locale-checkbox-label { 152 | font-size: 80%; 153 | } 154 | 155 | .apos-ui .apos-workflow-permissions { 156 | margin-bottom: 12px; 157 | .apos-workflow-permissions-label { 158 | margin-top: 12px; 159 | } 160 | .apos-workflow-locale-tree { 161 | display: none; 162 | &.apos-workflow-locale-tree--active { 163 | display: block; 164 | } 165 | } 166 | } 167 | 168 | .apos-ui .apos-workflow-export-option { 169 | margin-top: 6px; 170 | max-width: 740px; 171 | padding-top: 12px; 172 | margin-left: 220px; 173 | padding-left: 24px; 174 | label { 175 | display: block; 176 | } 177 | input { 178 | margin-right: 12px; 179 | } 180 | background-color: @apos-white; 181 | } 182 | 183 | .apos-ui .apos-workflow-locale-picker, 184 | .apos-ui .apos-workflow-export-locales { 185 | max-width: 740px; 186 | margin-left: 220px; 187 | padding-top: @apos-padding-2; 188 | background-color: @apos-white; 189 | } 190 | 191 | .apos-ui .apos-workflow-locale-picker-items, 192 | .apos-ui .apos-workflow-locale-tree { 193 | padding-left: 1em; 194 | margin-left: 7px; 195 | 196 | a { color: #000; } 197 | 198 | ul { 199 | border-left: 2px dashed @apos-primary; 200 | } 201 | } 202 | 203 | .apos-ui.apos-workflow-locale-picker-modal .apos-workflow-locale-unavailable { 204 | text-decoration: line-through; 205 | } 206 | 207 | .apos-ui.apos-workflow-history-modal { 208 | .apos-workflow-history-created-at { 209 | width: 200px; 210 | } 211 | 212 | .apos-table td a { 213 | display: inline; 214 | padding-right: 24px; 215 | } 216 | } 217 | 218 | .apos-ui .apos-workflow-modified-fields, .apos-ui .apos-workflow-related { 219 | // To position/size same as the preview 220 | width: 64%; 221 | margin: 24px 0 12px 18%; 222 | .apos-workflow-hint { 223 | font-weight: normal; 224 | } 225 | } 226 | 227 | .apos-ui .apos-workflow-related { 228 | h3 { 229 | font-size: 125%; 230 | } 231 | p { 232 | font-weight: normal; 233 | } 234 | } 235 | 236 | .apos-ui .apos-workflow-preview-image { 237 | display: block; 238 | margin: auto; 239 | margin-top: 24px; 240 | } 241 | 242 | .apos-area [data-apos-workflow-force-export-widget] { 243 | display: none; 244 | } 245 | 246 | .apos-area.apos-workflow-committable [data-apos-workflow-force-export-widget] { 247 | display: inline-block; 248 | } 249 | 250 | .apos-ui .apos-workflow-shortcut { 251 | display: inline-block; 252 | padding: 8px 16px 0 0; 253 | } 254 | 255 | .apos-ui .apos-workflow-locale-unavailable-body { 256 | padding-top: 24px; 257 | margin: auto; 258 | max-width: 800px; 259 | p { 260 | margin-bottom: 24px; 261 | } 262 | } 263 | 264 | .apos-field-apply-to-subpages .apos-workflow-field-controls { 265 | display: none; 266 | } 267 | 268 | .apos-ui .apos-workflow-export-related-types { 269 | margin: 16px 0; 270 | li { 271 | margin-left: 24px; 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /public/js/._committable-modal.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apostrophecms-legacy/apostrophe-workflow/8ff55dd9083824b4d10db7775dda250d4a5bf4a1/public/js/._committable-modal.js -------------------------------------------------------------------------------- /public/js/batch-export-modal.js: -------------------------------------------------------------------------------- 1 | // A modal for exporting the changes in a given commit to other locales 2 | 3 | apos.define('apostrophe-workflow-batch-export-modal', { 4 | 5 | extend: 'apostrophe-workflow-export-modal', 6 | 7 | source: 'batch-export-modal', 8 | 9 | verb: 'batch-export', 10 | 11 | construct: function(self, options) { 12 | 13 | self.saveContent = function(callback) { 14 | var locales = self.getLocales(); 15 | 16 | if (!locales.length) { 17 | apos.notify('Select at least one locale to export to.', { type: 'error' }); 18 | return callback('user'); 19 | } 20 | 21 | var data = _.assign({ 22 | locales: locales, 23 | job: true 24 | }, options.body); 25 | 26 | return self.api(self.options.verb, data, function(result) { 27 | if (result.status !== 'ok') { 28 | apos.notify('An error occurred.', { type: 'error' }); 29 | return callback(result.status); 30 | } 31 | apos.modules['apostrophe-jobs'].progress(result.jobId); 32 | return callback(null); 33 | }, function(err) { 34 | return callback(err); 35 | }); 36 | }; 37 | 38 | self.presentResult = function(result) { 39 | var workflow = apos.modules['apostrophe-workflow']; 40 | workflow.presentBatchExportResult(result); 41 | }; 42 | 43 | } 44 | }); 45 | -------------------------------------------------------------------------------- /public/js/batch-force-export-modal.js: -------------------------------------------------------------------------------- 1 | // A modal for force-exporting a group of docs 2 | // to other locales. Acts as a dataSource for 3 | // the force-export batch operation, does not 4 | // invoke the force export API on its own 5 | 6 | apos.define('apostrophe-workflow-batch-force-export-modal', { 7 | 8 | extend: 'apostrophe-workflow-export-modal', 9 | 10 | source: 'batch-force-export-modal', 11 | 12 | construct: function(self, options) { 13 | 14 | var superBeforeShow = self.beforeShow; 15 | 16 | self.beforeShow = function(callback) { 17 | return superBeforeShow(function(err) { 18 | if (err) { 19 | return callback(err); 20 | } 21 | self.$el.find('[for="relatedExisting"]').hide(); 22 | self.$el.on('change', '[name="related"]', function() { 23 | var value = $(this).prop('checked'); 24 | if (!value) { 25 | self.$el.find('[for="relatedExisting"]').hide(); 26 | self.$el.find('[data-related-types]').hide(); 27 | return; 28 | } 29 | self.$el.find('[for="relatedExisting"]').show(); 30 | self.$el.find('[data-related-types]').show(); 31 | self.fetchRelatedByType(); 32 | }); 33 | return callback(null); 34 | }); 35 | }; 36 | 37 | self.fetchRelatedByType = function() { 38 | apos.ui.globalBusy(true); 39 | self.api('count-related-by-type', { 40 | ids: self.options.body.ids 41 | }, function(data) { 42 | apos.ui.globalBusy(false); 43 | if (data.status !== 'ok') { 44 | apos.utils.error(data.status); 45 | } 46 | self.$el.find('[data-related-types]').html(data.html); 47 | }, function(err) { 48 | apos.ui.globalBusy(false); 49 | apos.utils.error(err); 50 | }); 51 | }; 52 | 53 | self.saveContent = function(callback) { 54 | var locales = self.getLocales(); 55 | if (!locales.length) { 56 | apos.notify('Select at least one locale to export to.', { type: 'error' }); 57 | return callback('user'); 58 | } 59 | var related = self.$el.findByName('related').prop('checked'); 60 | // Modifying the `body` object passed to us 61 | // by batchForceExportGetLocales allows that 62 | // method to see the locales that were chosen 63 | self.options.body.locales = locales; 64 | self.options.body.related = related; 65 | self.options.body.relatedTypes = []; 66 | self.$el.find('[name="relatedTypes"]:checked').each(function() { 67 | self.options.body.relatedTypes.push($(this).attr('value')); 68 | }); 69 | self.options.body.relatedExisting = self.$el.find('[name="relatedExisting"]').prop('checked'); 70 | return callback(null); 71 | }; 72 | 73 | } 74 | }); 75 | -------------------------------------------------------------------------------- /public/js/commit-modal.js: -------------------------------------------------------------------------------- 1 | // A modal for committing draft changes to a live locale. 2 | 3 | apos.define('apostrophe-workflow-commit-modal', { 4 | 5 | extend: 'apostrophe-modal', 6 | 7 | source: 'commit-modal', 8 | 9 | construct: function(self, options) { 10 | self.manager = options.manager; 11 | self.beforeShow = function(callback) { 12 | return apos.areas.saveAllIfNeeded(callback); 13 | }; 14 | self.saveContent = function(callback) { 15 | return self.api('commit', { id: options.body.id }, function(result) { 16 | if (result.status !== 'ok') { 17 | apos.notify('An error occurred.', { type: 'error' }); 18 | return callback(result.status); 19 | } 20 | if (result.title) { 21 | apos.notify('%s was committed successfully.', result.title, { type: 'success', dismiss: true }); 22 | } else { 23 | apos.notify('The document was committed successfully.', { type: 'success', dismiss: true }); 24 | } 25 | var commitId = result.commitId; 26 | return self.api('editable-locales', { 27 | id: options.body.id 28 | }, function(result) { 29 | if (result.status !== 'ok') { 30 | return callback(result.status); 31 | } 32 | if ((apos.modules['apostrophe-workflow'].options.exportAfterCommit !== false) && (result.locales.length > 1)) { 33 | return self.manager.export(commitId, callback); 34 | } 35 | return callback(null); 36 | }, function(err) { 37 | return callback(err); 38 | }); 39 | }, function(err) { 40 | return callback(err); 41 | }); 42 | }; 43 | // Let the manager know we're done, so the manager can step through these modals 44 | // for several docs in series if needed 45 | self.afterHide = function() { 46 | return options.after(); 47 | }; 48 | } 49 | }); 50 | -------------------------------------------------------------------------------- /public/js/export-modal.js: -------------------------------------------------------------------------------- 1 | // A modal for exporting the changes in a given commit to other locales 2 | 3 | apos.define('apostrophe-workflow-export-modal', { 4 | 5 | extend: 'apostrophe-modal', 6 | 7 | source: 'export-modal', 8 | 9 | verb: 'export', 10 | 11 | construct: function(self, options) { 12 | 13 | self.manager = options.manager; 14 | 15 | self.beforeShow = function(callback) { 16 | var workflow = apos.modules['apostrophe-workflow']; 17 | _.each(workflow.nextExportHint || [], function(locale) { 18 | self.$el.find('input[type="checkbox"][name="locales[' + locale + ']"]').prop('checked', true); 19 | }); 20 | self.$el.on('change', 'input[type="checkbox"]', function() { 21 | var checked = $(this).prop('checked'); 22 | $(this).closest('li').find('input[type="checkbox"]').prop('checked', checked); 23 | }); 24 | return callback(null); 25 | }; 26 | 27 | self.getLocales = function() { 28 | var locales = []; 29 | var $checkboxes = self.$el.find('input[type="checkbox"]:checked'); 30 | $checkboxes.each(function() { 31 | var name = $(this).attr('name'); 32 | var matches = name.match(/^locales\[(.*?)\]$/); 33 | if (matches) { 34 | locales.push(matches[1]); 35 | } 36 | }); 37 | return locales; 38 | }; 39 | 40 | self.saveContent = function(callback) { 41 | var locales = self.getLocales(); 42 | 43 | if (!locales.length) { 44 | apos.notify('Select at least one locale to export to.', { type: 'error' }); 45 | return callback('user'); 46 | } 47 | 48 | var workflow = apos.modules['apostrophe-workflow']; 49 | workflow.nextExportHint = locales; 50 | 51 | return self.exportRelatedUnexported(locales, function(err) { 52 | if (err) { 53 | return callback(err); 54 | } 55 | var data = _.assign({ 56 | locales: locales 57 | }, options.body); 58 | 59 | return self.api(self.options.verb, data, function(result) { 60 | if (result.status !== 'ok') { 61 | apos.notify('An error occurred.', { type: 'error' }); 62 | return callback(result.status); 63 | } 64 | self.presentResult(result); 65 | return callback(null); 66 | }, function(err) { 67 | return callback(err); 68 | }); 69 | }); 70 | }; 71 | 72 | self.presentResult = function(result) { 73 | _.each(result.errors, function(error) { 74 | apos.notify('%s: ' + error.message, error.locale, { type: 'error' }); 75 | }); 76 | if (result.success.length) { 77 | apos.notify('Successfully exported to: %s', result.success.join(', '), { type: 'success', dismiss: true }); 78 | } 79 | }; 80 | 81 | self.exportRelatedUnexported = function(locales, callback) { 82 | return self.manager.getRelatedUnexported({ id: options.body.id, exportLocales: locales }, function(err, result) { 83 | if (err) { 84 | return callback(err); 85 | } 86 | var ids = result.ids; 87 | return async.eachSeries(ids, function(id, callback) { 88 | return self.manager.launchExportModal({ id: id }, callback); 89 | }, function(err) { 90 | return callback && callback(err); 91 | }); 92 | }); 93 | }; 94 | 95 | // Let the manager know we're done, so the manager can step through these modals 96 | // for several docs in series if needed 97 | self.afterHide = function() { 98 | return options.after && options.after(); 99 | }; 100 | } 101 | }); 102 | -------------------------------------------------------------------------------- /public/js/force-export-modal.js: -------------------------------------------------------------------------------- 1 | // A modal for forcing export of a single widget to other locales 2 | 3 | apos.define('apostrophe-workflow-force-export-modal', { 4 | 5 | extend: 'apostrophe-workflow-export-modal', 6 | 7 | source: 'force-export-modal', 8 | 9 | verb: 'force-export', 10 | 11 | construct: function(self, options) { 12 | var superBeforeShow = self.beforeShow; 13 | self.beforeShow = function(callback) { 14 | superBeforeShow(function(err) { 15 | if (err) { 16 | return callback(err); 17 | } 18 | self.$el.find('[for="relatedExisting"]').hide(); 19 | self.$el.on('change', '[name="related"]', function() { 20 | var value = $(this).prop('checked'); 21 | if (!value) { 22 | self.$el.find('[for="relatedExisting"]').hide(); 23 | return; 24 | } 25 | self.$el.find('[for="relatedExisting"]').show(); 26 | }); 27 | return callback(null); 28 | }); 29 | }; 30 | self.exportRelatedUnexported = function(locales, callback) { 31 | if (!options.body.lead) { 32 | // Don't recurse through the entire site 33 | return callback(null); 34 | } 35 | var related = self.$el.find('[name="related"]').prop('checked'); 36 | var relatedExisting = self.$el.find('[name="relatedExisting"]').prop('checked'); 37 | if (!related) { 38 | return callback(null); 39 | } 40 | 41 | var params = { ids: [ options.body.id ], related: true }; 42 | if (!relatedExisting) { 43 | params.onlyIfNewIn = locales; 44 | } 45 | return self.manager.getEditable(params, function(err, result) { 46 | if (err) { 47 | return; 48 | } 49 | var all = result.modified.concat(result.unmodified).filter(function(id) { 50 | return id !== options.body.id; 51 | }); 52 | return async.eachSeries(all, function(id, callback) { 53 | if (self.manager.commitAllRelated) { 54 | return self.api('force-export', { 55 | id: id, 56 | locales: locales, 57 | existing: relatedExisting 58 | }, function(info) { 59 | if (info.status !== 'ok') { 60 | return callback(info.status); 61 | } 62 | return callback(null); 63 | }, callback); 64 | } else if (self.manager.skipAllRelated) { 65 | return setImmediate(callback); 66 | } else { 67 | return apos.create('apostrophe-workflow-force-export-modal', 68 | _.assign( 69 | {}, 70 | _.omit(options, [ 'source', 'verb' ]), 71 | { 72 | body: { 73 | id: id, 74 | lead: false, 75 | existing: relatedExisting 76 | }, 77 | // This is throwing Types do not match in indicateCurrentModal 78 | after: callback 79 | } 80 | ) 81 | ); 82 | } 83 | }, function(err) { 84 | return callback && callback(err); 85 | }); 86 | }); 87 | }; 88 | } 89 | 90 | // The base class already sends everything in options.body so no 91 | // further modifications are needed on the front end so far 92 | 93 | }); 94 | -------------------------------------------------------------------------------- /public/js/force-export-related-modal.js: -------------------------------------------------------------------------------- 1 | // A modal for exporting the changes in a given commit to other locales 2 | 3 | apos.define('apostrophe-workflow-force-export-related-modal', { 4 | 5 | extend: 'apostrophe-workflow-force-export-modal', 6 | 7 | source: 'force-export-related-modal', 8 | 9 | verb: 'force-export', 10 | 11 | construct: function(self, options) { 12 | 13 | var superBeforeShow = self.beforeShow; 14 | self.beforeShow = function(callback) { 15 | return superBeforeShow(function(err) { 16 | if (err) { 17 | return callback(err); 18 | } 19 | self.$el.find('[name="related"]').prop('checked', true); 20 | self.$el.find('[for="related"]').hide(); 21 | self.$el.find('[for="relatedExisting"]').show(); 22 | return callback(null); 23 | }); 24 | }; 25 | 26 | self.saveContent = function(callback) { 27 | var locales = self.getLocales(); 28 | 29 | if (!locales.length) { 30 | apos.notify('Select at least one locale to export to.', { type: 'error' }); 31 | return callback('user'); 32 | } 33 | 34 | var workflow = apos.modules['apostrophe-workflow']; 35 | workflow.nextExportHint = locales; 36 | 37 | return self.exportRelatedUnexported(locales, callback); 38 | }; 39 | 40 | } 41 | }); 42 | -------------------------------------------------------------------------------- /public/js/force-export-widget-modal.js: -------------------------------------------------------------------------------- 1 | // A modal for forcing export of a single widget to other locales 2 | 3 | apos.define('apostrophe-workflow-force-export-widget-modal', { 4 | 5 | extend: 'apostrophe-workflow-export-modal', 6 | 7 | source: 'force-export-widget-modal', 8 | 9 | verb: 'force-export-widget', 10 | 11 | construct: function(self, options) { 12 | // Can't be done in the same way because we don't send a commit id 13 | self.exportRelatedUnexported = function(locales, callback) { 14 | return setImmediate(callback); 15 | }; 16 | } 17 | 18 | // The base class already sends everything in options.body so no 19 | // further modifications are needed on the front end so far 20 | 21 | }); 22 | -------------------------------------------------------------------------------- /public/js/history-modal.js: -------------------------------------------------------------------------------- 1 | // A modal for browsing past commits of a doc. 2 | 3 | apos.define('apostrophe-workflow-history-modal', { 4 | 5 | extend: 'apostrophe-modal', 6 | 7 | source: 'history-modal', 8 | 9 | construct: function(self, options) { 10 | self.manager = options.manager; 11 | self.beforeShow = function(callback) { 12 | // various event handlers will go here 13 | return callback(null); 14 | }; 15 | } 16 | }); 17 | -------------------------------------------------------------------------------- /public/js/lean.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | document.body.addEventListener('apos-before-get', addLocaleHeader); 3 | document.body.addEventListener('apos-before-post', addLocaleHeader); 4 | function addLocaleHeader(event) { 5 | // Easy check for same origin 6 | var link = document.createElement('a'); 7 | link.href = event.uri; 8 | if (link.host !== location.host) { 9 | return; 10 | } 11 | event.request.setRequestHeader('Apostrophe-Locale', document.body.getAttribute('data-locale')); 12 | }; 13 | })(); 14 | -------------------------------------------------------------------------------- /public/js/locale-picker-modal.js: -------------------------------------------------------------------------------- 1 | // A very simple modal for switching locales. Used as admin UI, for the public 2 | // one more typically builds a custom locale picker that suits the site design 3 | 4 | apos.define('apostrophe-workflow-locale-picker-modal', { 5 | extend: 'apostrophe-modal', 6 | source: 'locale-picker-modal' 7 | // All the business logic is in the route and template, which contains 8 | // ordinary links that switch locales by navigating 9 | }); 10 | -------------------------------------------------------------------------------- /public/js/locale-unavailable-modal.js: -------------------------------------------------------------------------------- 1 | // A modal for displaying information about an unavailable locale. 2 | 3 | apos.define('apostrophe-workflow-locale-unavailable-modal', { 4 | 5 | extend: 'apostrophe-modal', 6 | 7 | source: 'locale-unavailable-modal', 8 | 9 | construct: function(self, options) { 10 | self.manager = options.manager; 11 | self.beforeShow = function(callback) { 12 | // various event handlers could go here 13 | return callback(null); 14 | }; 15 | self.saveContent = function(callback) { 16 | return self.api('activate', { 17 | locale: self.options.body.locale, 18 | workflowGuid: self.options.body.workflowGuid 19 | }, function(result) { 20 | if (result.status === 'ok') { 21 | window.location.href = result.url; 22 | } else { 23 | error(); 24 | } 25 | return callback(null); 26 | }, function(err) { 27 | apos.utils.error(err); 28 | error(); 29 | return callback(null); 30 | }); 31 | function error() { 32 | apos.notify('An error occurred activating the document. It may not be available or you may not have permission.', { type: 'error' }); 33 | } 34 | }; 35 | } 36 | }); 37 | -------------------------------------------------------------------------------- /public/js/manage-modal.js: -------------------------------------------------------------------------------- 1 | // A modal for reviewing workflow submissions. 2 | 3 | apos.define('apostrophe-workflow-manage-modal', { 4 | 5 | extend: 'apostrophe-modal', 6 | 7 | source: 'manage-modal', 8 | 9 | construct: function(self, options) { 10 | self.manager = options.manager; 11 | // The route already rendered the content with normal links, so there's nothing more to do here, 12 | // unless we add pagination 13 | } 14 | }); 15 | -------------------------------------------------------------------------------- /public/js/review-modal.js: -------------------------------------------------------------------------------- 1 | // A modal for reviewing the changes in a past commit. 2 | // The template does a lot of the work via the preview iframe. 3 | 4 | apos.define('apostrophe-workflow-review-modal', { 5 | 6 | extend: 'apostrophe-modal', 7 | 8 | source: 'review-modal', 9 | 10 | construct: function(self, options) { 11 | self.manager = options.manager; 12 | self.beforeShow = function(callback) { 13 | return apos.areas.saveAllIfNeeded(callback); 14 | }; 15 | self.saveContent = function(callback) { 16 | return apos.create('apostrophe-workflow-export-modal', 17 | _.assign({ 18 | manager: self.manager, 19 | body: { id: options.body.id }, 20 | after: callback 21 | }, self.manager.options) 22 | ); 23 | }; 24 | } 25 | }); 26 | -------------------------------------------------------------------------------- /test/.gitignore: -------------------------------------------------------------------------------- 1 | public -------------------------------------------------------------------------------- /test/lib/modules/apostrophe-custom-pages/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | beforeConstruct: function(self, options) { 3 | options.addFields = [ 4 | { 5 | name: '_related', 6 | type: 'joinByOne', 7 | withType: 'product' 8 | }, 9 | { 10 | name: '_coolPages', 11 | type: 'joinByArray', 12 | withType: 'apostrophe-page' 13 | } 14 | ].concat(options.addFields || []); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /test/lib/modules/apostrophe-pages/views/pages/home.html: -------------------------------------------------------------------------------- 1 | {% extends data.outerLayout %} 2 |

Home Page

3 | -------------------------------------------------------------------------------- /test/lib/modules/products/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extend: 'apostrophe-pieces', 3 | name: 'product', 4 | alias: 'products', 5 | addFields: [ 6 | { 7 | name: '_related', 8 | type: 'joinByOne', 9 | withType: 'product' 10 | } 11 | ], 12 | construct: function(self, options) { 13 | self.afterInsert = function(req, piece, options, callback) { 14 | piece.afterInsertRan = true; 15 | return self.update(req, piece, options, callback); 16 | }; 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /test/missing-prefix-redirect-status-code.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | 3 | describe('Missing prefix redirect status code', function() { 4 | 5 | var apos; 6 | 7 | this.timeout(20000); 8 | 9 | after(function(done) { 10 | require('apostrophe/test-lib/util').destroy(apos, done); 11 | }); 12 | 13 | /// /// 14 | // EXISTENCE 15 | /// /// 16 | 17 | it('should be a property of the apos object', function(done) { 18 | apos = require('apostrophe')({ 19 | testModule: true, 20 | 21 | modules: { 22 | 'products': {}, 23 | 'apostrophe-pages': { 24 | park: [], 25 | types: [ 26 | { 27 | name: 'home', 28 | label: 'Home' 29 | }, 30 | { 31 | name: 'testPage', 32 | label: 'Test Page' 33 | } 34 | ] 35 | }, 36 | 'apostrophe-workflow': { 37 | missingPrefixRedirectStatusCode: 301, 38 | hostnames: { 39 | 'private': 'private.com', 40 | 'fr': 'exemple.fr', 41 | 'default': 'example.com', 42 | 'us': 'example.com', 43 | 'us-en': 'example.com', 44 | 'us-es': 'example.com', 45 | 'us-de': 'example.com', 46 | 'es': 'example.es', 47 | 'es-CO': 'example.es', 48 | 'es-MX': 'example.es', 49 | 'de': 'example.de', 50 | 'de-de': 'example.de', 51 | 'tt-one': 'tt.com', 52 | 'tt-two': 'tt.com', 53 | 'tt-three': 'tt.com' 54 | }, 55 | prefixes: { 56 | // Even private locales must be distinguishable by hostname and/or prefix 57 | 'default': '/default', 58 | 'us': '/us-private', 59 | // we don't add a prefix for us-en since that locale 60 | // will reside at the root level and share the hostname 61 | // with us-es and us-de. 62 | 'us-es': '/es', 63 | 'us-de': '/de', 64 | 'es-CO': '/co', 65 | 'es-MX': '/mx', 66 | 'de-de': '/de', 67 | 'tt-one': '/one', 68 | 'tt-two': '/two', 69 | 'tt-three': '/three' 70 | // We don't need prefixes for fr because 71 | // that hostname is not shared with other 72 | // locales 73 | }, 74 | locales: [ 75 | { 76 | name: 'default', 77 | label: 'Default', 78 | private: true, 79 | children: [ 80 | { 81 | name: 'fr' 82 | }, 83 | { 84 | name: 'us', 85 | private: true, 86 | children: [ 87 | { 88 | name: 'us-en' 89 | }, 90 | { 91 | name: 'us-es' 92 | }, 93 | { 94 | name: 'us-de' 95 | } 96 | ] 97 | }, 98 | { 99 | name: 'es', 100 | children: [ 101 | { 102 | name: 'es-CO' 103 | }, 104 | { 105 | name: 'es-MX' 106 | } 107 | ] 108 | }, 109 | { 110 | name: 'de', 111 | children: [ 112 | { 113 | name: 'de-de' 114 | } 115 | ] 116 | }, 117 | { 118 | name: 'tt-one' 119 | }, 120 | { 121 | name: 'tt-two' 122 | }, 123 | { 124 | name: 'tt-three' 125 | }, 126 | { 127 | name: 'private', 128 | private: true 129 | }, 130 | { 131 | name: 'private2', 132 | private: true 133 | } 134 | ] 135 | } 136 | ], 137 | defaultLocale: 'default', 138 | defaultLocalesByHostname: { 139 | 'tt.com': 'tt-one', 140 | 'private2.com': 'private2' 141 | } 142 | } 143 | }, 144 | afterInit: function(callback) { 145 | assert(apos.modules['apostrophe-workflow']); 146 | // Should NOT have an alias! 147 | assert(!apos.workflow); 148 | return callback(null); 149 | }, 150 | afterListen: function(err) { 151 | assert(!err); 152 | done(); 153 | } 154 | }); 155 | }); 156 | 157 | function tryMiddleware(url, after) { 158 | return tryMiddlewareBody(url, {}, after); 159 | } 160 | 161 | function tryMiddlewareBody(url, options, after) { 162 | var req; 163 | if (options.admin) { 164 | req = apos.tasks.getReq(); 165 | } else { 166 | req = apos.tasks.getAnonReq(); 167 | } 168 | req.absoluteUrl = url; 169 | var parsed = require('url').parse(req.absoluteUrl); 170 | req.url = parsed.path; 171 | req.session = {}; 172 | req.get = function(propName) { 173 | return { 174 | Host: parsed.host 175 | }[propName]; 176 | }; 177 | req.res.redirect = function(status, url) { 178 | if (!url) { 179 | url = status; 180 | status = 302; 181 | } 182 | req.res.status = status; 183 | req.url = url; 184 | after(req); 185 | }; 186 | 187 | var workflow = apos.modules['apostrophe-workflow']; 188 | assert(workflow); 189 | var middleware = workflow.expressMiddleware.middleware; 190 | 191 | middleware(req, req.res, function() { 192 | after(req); 193 | }); 194 | } 195 | 196 | it('can find a defaultLocaleByHostname-determined locale via middleware with a custom status code', function(done) { 197 | tryMiddleware('http://tt.com', function(req) { 198 | assert(req.locale === 'tt-one'); 199 | assert(req.url === '/one/'); 200 | assert(req.res.status === 301); 201 | done(); 202 | }); 203 | }); 204 | 205 | }); 206 | -------------------------------------------------------------------------------- /test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "//": "Must exist to satisfy checks, not really used", 3 | "dependencies": { 4 | "apostrophe": "*", 5 | "apostrophe-workflow": "*", 6 | "@sailshq/lodash": "*", 7 | "bluebird": "*", 8 | "async": "*", 9 | "request-promise": "*", 10 | "apostrophe-override-options": "*" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/parkedPages.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | 3 | describe('Workflow Core', function() { 4 | 5 | var apos; 6 | 7 | this.timeout(20000); 8 | 9 | after(function(done) { 10 | require('apostrophe/test-lib/util').destroy(apos, done); 11 | }); 12 | 13 | /// /// 14 | // EXISTENCE 15 | /// /// 16 | 17 | it('should be a property of the apos object', function(done) { 18 | apos = require('apostrophe')({ 19 | testModule: true, 20 | 21 | modules: { 22 | 'apostrophe-pages': { 23 | park: [ 24 | { 25 | slug: '/', 26 | published: true, 27 | _defaults: { 28 | title: 'Home', 29 | type: 'home' 30 | }, 31 | _children: [ 32 | { 33 | slug: { 34 | 'us-en': '/products-en', 35 | 'fr': '/produits', 36 | '_default': '/products' 37 | }, 38 | _defaults: { 39 | type: 'product-page', 40 | title: 'Product' 41 | }, 42 | published: true, 43 | parkedId: 'products' 44 | }, { 45 | slug: '/test-page', 46 | type: 'product-page', 47 | published: true, 48 | parkedId: 'test-product-page' 49 | } 50 | ] 51 | } 52 | ], 53 | types: [ 54 | { 55 | name: 'home', 56 | label: 'Home' 57 | }, 58 | { 59 | name: 'product-page', 60 | label: 'Product' 61 | } 62 | ] 63 | }, 64 | 'products': {}, 65 | 'product-pages': {}, 66 | 'apostrophe-workflow': { 67 | replicateAcrossLocales: false, 68 | prefixes: { 69 | 'fr': '/fr', 70 | 'us-es': '/es', 71 | 'us-de': '/de' 72 | }, 73 | locales: [ 74 | { 75 | name: 'default', 76 | label: 'Default', 77 | private: true, 78 | children: [ 79 | { 80 | name: 'fr' 81 | }, 82 | { 83 | name: 'us', 84 | private: true, 85 | children: [ 86 | { 87 | name: 'us-en' 88 | }, 89 | { 90 | name: 'us-es' 91 | }, 92 | { 93 | name: 'us-de' 94 | } 95 | ] 96 | } 97 | ] 98 | } 99 | ], 100 | defaultLocale: 'default' 101 | } 102 | }, 103 | afterInit: function(callback) { 104 | assert(apos.modules['apostrophe-workflow']); 105 | // Should NOT have an alias! 106 | assert(!apos.workflow); 107 | return callback(null); 108 | }, 109 | afterListen: function(err) { 110 | assert(!err); 111 | done(); 112 | } 113 | }); 114 | }); 115 | 116 | it('should create parked pages with prefixes', function() { 117 | return apos.docs.db.find({ type: 'product-page', workflowLocale: 'fr' }).toArray().then(function(pages) { 118 | assert(pages && pages[0] && pages[1]); 119 | assert(pages[0].slug === '/fr/produits'); // from slug object 120 | assert(pages[1].slug === '/fr/test-page'); // from traditional slug string 121 | }); 122 | }); 123 | }); 124 | -------------------------------------------------------------------------------- /test/reorganize.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | var Promise = require('bluebird'); 3 | 4 | describe('Workflow Reorganize', function() { 5 | 6 | let apos; 7 | 8 | this.timeout(20000); 9 | 10 | after(function(done) { 11 | require('apostrophe/test-lib/util').destroy(apos, done); 12 | }); 13 | 14 | /// /// 15 | // EXISTENCE 16 | /// /// 17 | 18 | it('should be a property of the apos object', function(done) { 19 | apos = require('apostrophe')({ 20 | testModule: true, 21 | 22 | modules: { 23 | 'apostrophe-pages': { 24 | types: [ 25 | { 26 | name: 'home', 27 | label: 'Home' 28 | }, 29 | { 30 | name: 'testPage', 31 | label: 'Test Page' 32 | } 33 | ] 34 | }, 35 | products: {}, 36 | 'apostrophe-workflow': { 37 | locales: [ 38 | { 39 | name: 'default', 40 | children: [ 41 | { 42 | name: 'en' 43 | }, 44 | { 45 | name: 'fr' 46 | } 47 | ] 48 | } 49 | ] 50 | } 51 | }, 52 | afterInit: function(callback) { 53 | assert(apos.modules['apostrophe-workflow']); 54 | return callback(null); 55 | }, 56 | afterListen: function(err) { 57 | assert(!err); 58 | done(); 59 | } 60 | }); 61 | }); 62 | 63 | it('insert page1 and page2 as peers initially', function() { 64 | const req = apos.tasks.getReq({ locale: 'default-draft' }); 65 | return Promise.try(function() { 66 | return apos.pages.find(req, { slug: '/' }).children({ depth: 2 }).toObject(); 67 | }).then(function(home) { 68 | return apos.pages.insert(req, home, { 69 | title: 'page1', 70 | slug: '/page1', 71 | type: 'testPage', 72 | published: true, 73 | trash: false 74 | }).then(function() { 75 | return home; 76 | }); 77 | }).then(function(home) { 78 | return apos.pages.insert(req, home, { 79 | title: 'page2', 80 | slug: '/page2', 81 | type: 'testPage', 82 | published: true, 83 | trash: false 84 | }); 85 | }); 86 | }); 87 | 88 | it('page1 and page2 are peers in default-draft locale', function() { 89 | return page1AndPage2ArePeers('default-draft'); 90 | }); 91 | 92 | it('page1 and page2 are peers in default locale', function() { 93 | return page1AndPage2ArePeers('default'); 94 | }); 95 | 96 | it('should be able to move page2 under page1 in default-draft', function() { 97 | const req = apos.tasks.getReq({ locale: 'default-draft' }); 98 | return Promise.try(function() { 99 | return apos.pages.find(req, { slug: '/' }).children({ depth: 2 }).toObject(); 100 | }).then(function(home) { 101 | const page1 = home._children[0]; 102 | const page2 = home._children[1]; 103 | // req, moved, target, relationship 104 | return apos.pages.move(req, page2._id, page1._id, 'inside'); 105 | }).then(function() { 106 | return page2IsNestedUnderPage1('default-draft'); 107 | }); 108 | }); 109 | 110 | it('meanwhile in live locale, page2 should still be a peer', function() { 111 | return page1AndPage2ArePeers('default'); 112 | }); 113 | 114 | it('should be able to commit page1', function() { 115 | const req = apos.tasks.getReq({ locale: 'default-draft' }); 116 | const workflow = apos.modules['apostrophe-workflow']; 117 | return Promise.try(function() { 118 | return apos.pages.find(req, { slug: '/' }).children({ depth: 2 }).toObject(); 119 | }).then(function(home) { 120 | const page1 = home._children[0]; 121 | return Promise.promisify(workflow.commitLatest)(req, page1._id); 122 | }); 123 | }); 124 | 125 | it('should be able to commit page2', function() { 126 | const req = apos.tasks.getReq({ locale: 'default-draft' }); 127 | const workflow = apos.modules['apostrophe-workflow']; 128 | return Promise.try(function() { 129 | return apos.pages.find(req, { slug: '/' }).children({ depth: 2 }).toObject(); 130 | }).then(function(home) { 131 | const page2 = home._children[0]._children[0]; 132 | return Promise.promisify(workflow.commitLatest)(req, page2._id); 133 | }); 134 | }); 135 | 136 | it('now in live locale page2 should be nested under page1', function() { 137 | return page2IsNestedUnderPage1('default'); 138 | }); 139 | 140 | it('meanwhile in fr-draft page2 is still a peer of page1', function() { 141 | return page1AndPage2ArePeers('fr-draft'); 142 | }); 143 | 144 | it('export both commits to fr-draft locale', function() { 145 | const workflow = apos.modules['apostrophe-workflow']; 146 | const exporter = Promise.promisify(workflow.export); 147 | let commits; 148 | const req = apos.tasks.getReq({ locale: 'default-draft' }); 149 | return Promise.try(function() { 150 | return workflow.db.find().sort({ createdAt: 1 }).toArray(); 151 | }).then(function(_commits) { 152 | commits = _commits; 153 | return exporter(req, commits[0]._id, [ 'fr' ]); 154 | }).then(function() { 155 | return exporter(req, commits[1]._id, [ 'fr' ]); 156 | }); 157 | }); 158 | 159 | it('after exports page2 is a child of page1 in fr-draft', function() { 160 | return page2IsNestedUnderPage1('fr-draft'); 161 | }); 162 | 163 | it('... but still a peer in fr (live)', function() { 164 | return page1AndPage2ArePeers('fr'); 165 | }); 166 | 167 | it('... and still a peer in the unrelated en-draft', function() { 168 | return page1AndPage2ArePeers('en-draft'); 169 | }); 170 | 171 | it('can force export page1 and page2 to en-draft', function() { 172 | const req = apos.tasks.getReq({ locale: 'default-draft' }); 173 | const workflow = apos.modules['apostrophe-workflow']; 174 | const forceExport = Promise.promisify(workflow.forceExport); 175 | let home; 176 | return Promise.try(function() { 177 | return apos.pages.find(req, { slug: '/' }).children({ depth: 2 }).toObject(); 178 | }).then(function(_home) { 179 | home = _home; 180 | return forceExport(req, home._children[0]._id, [ 'en' ]); 181 | }).then(function() { 182 | return forceExport(req, home._children[0]._children[0]._id, [ 'en' ]); 183 | }); 184 | }); 185 | 186 | it('after force exports page2 is a child of page1 in en-draft', function() { 187 | return page2IsNestedUnderPage1('en-draft'); 188 | }); 189 | 190 | function page1AndPage2ArePeers(locale) { 191 | return Promise.try(function() { 192 | return apos.docs.db.find({ workflowLocale: locale }).toArray(); 193 | }).then(function(docs) { 194 | return apos.pages.find(apos.tasks.getReq({ locale: locale }), { slug: '/' }).children({ depth: 2, trash: null }).toObject(); 195 | }).then(function(home) { 196 | assert(home); 197 | const page1 = home._children[0]; 198 | const page2 = home._children[1]; 199 | assert(page1.title === 'page1'); 200 | assert(page1.path === '/page1'); 201 | assert(page1.level === 1); 202 | assert(page2.title === 'page2'); 203 | assert(page2.path === '/page2'); 204 | assert(page2.level === 1); 205 | }); 206 | } 207 | 208 | function page2IsNestedUnderPage1(locale) { 209 | return Promise.try(function() { 210 | return apos.pages.find(apos.tasks.getReq({ locale: locale }), { slug: '/' }).children({ depth: 2, trash: null }).toObject(); 211 | }).then(function(home) { 212 | const page1 = home._children[0]; 213 | assert(page1.title === 'page1'); 214 | assert(page1.path === '/page1'); 215 | assert(page1.level === 1); 216 | const page2 = page1._children[0]; 217 | assert(page2.title === 'page2'); 218 | assert(page2.path === '/page1/page2'); 219 | assert(page2.level === 2); 220 | }); 221 | } 222 | 223 | }); 224 | -------------------------------------------------------------------------------- /test/reorganizeReplicateFalse.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | var Promise = require('bluebird'); 3 | 4 | describe('Workflow Reorganize with replicate:false', function() { 5 | 6 | let apos; 7 | 8 | this.timeout(20000); 9 | 10 | after(function(done) { 11 | require('apostrophe/test-lib/util').destroy(apos, done); 12 | }); 13 | 14 | /// /// 15 | // EXISTENCE 16 | /// /// 17 | 18 | it('should be a property of the apos object', function(done) { 19 | apos = require('apostrophe')({ 20 | testModule: true, 21 | 22 | modules: { 23 | 'apostrophe-pages': { 24 | types: [ 25 | { 26 | name: 'home', 27 | label: 'Home' 28 | }, 29 | { 30 | name: 'testPage', 31 | label: 'Test Page' 32 | } 33 | ] 34 | }, 35 | products: {}, 36 | 'apostrophe-workflow': { 37 | locales: [ 38 | { 39 | name: 'default', 40 | children: [ 41 | { 42 | name: 'en' 43 | }, 44 | { 45 | name: 'fr' 46 | }, 47 | { 48 | name: 'es' 49 | } 50 | ] 51 | } 52 | ], 53 | replicateAcrossLocales: false 54 | } 55 | }, 56 | afterInit: function(callback) { 57 | assert(apos.modules['apostrophe-workflow']); 58 | return callback(null); 59 | }, 60 | afterListen: function(err) { 61 | assert(!err); 62 | done(); 63 | } 64 | }); 65 | }); 66 | 67 | it('insert page1 and page2 as peers initially', function() { 68 | const req = apos.tasks.getReq({ locale: 'default-draft' }); 69 | return Promise.try(function() { 70 | return apos.pages.find(req, { slug: '/' }).children({ depth: 2 }).toObject(); 71 | }).then(function(home) { 72 | return apos.pages.insert(req, home, { 73 | title: 'page1', 74 | slug: '/page1', 75 | type: 'testPage', 76 | published: true, 77 | trash: false 78 | }).then(function() { 79 | return home; 80 | }); 81 | }).then(function(home) { 82 | return apos.pages.insert(req, home, { 83 | title: 'page2', 84 | slug: '/page2', 85 | type: 'testPage', 86 | published: true, 87 | trash: false 88 | }); 89 | }); 90 | }); 91 | 92 | it('page1 and page2 are peers in default-draft locale', function() { 93 | return page1AndPage2ArePeers('default-draft'); 94 | }); 95 | 96 | it('page1 and page2 are peers in default locale', function() { 97 | return page1AndPage2ArePeers('default'); 98 | }); 99 | 100 | it('should be able to move page2 under page1 in default-draft', function() { 101 | const req = apos.tasks.getReq({ locale: 'default-draft' }); 102 | return Promise.try(function() { 103 | return apos.pages.find(req, { slug: '/' }).children({ depth: 2 }).toObject(); 104 | }).then(function(home) { 105 | const page1 = home._children[0]; 106 | const page2 = home._children[1]; 107 | // req, moved, target, relationship 108 | return apos.pages.move(req, page2._id, page1._id, 'inside'); 109 | }).then(function() { 110 | return page2IsNestedUnderPage1('default-draft'); 111 | }); 112 | }); 113 | 114 | it('meanwhile in live locale, page2 should still be a peer', function() { 115 | return page1AndPage2ArePeers('default'); 116 | }); 117 | 118 | it('should be able to commit page1', function() { 119 | const req = apos.tasks.getReq({ locale: 'default-draft' }); 120 | const workflow = apos.modules['apostrophe-workflow']; 121 | return Promise.try(function() { 122 | return apos.pages.find(req, { slug: '/' }).children({ depth: 2 }).toObject(); 123 | }).then(function(home) { 124 | const page1 = home._children[0]; 125 | return Promise.promisify(workflow.commitLatest)(req, page1._id); 126 | }); 127 | }); 128 | 129 | it('should be able to commit page2', function() { 130 | const req = apos.tasks.getReq({ locale: 'default-draft' }); 131 | const workflow = apos.modules['apostrophe-workflow']; 132 | return Promise.try(function() { 133 | return apos.pages.find(req, { slug: '/' }).children({ depth: 2 }).toObject(); 134 | }).then(function(home) { 135 | const page2 = home._children[0]._children[0]; 136 | return Promise.promisify(workflow.commitLatest)(req, page2._id); 137 | }); 138 | }); 139 | 140 | it('now in live locale page2 should be nested under page1', function() { 141 | return page2IsNestedUnderPage1('default'); 142 | }); 143 | 144 | it('meanwhile in fr-draft page2 does not exist yet', function() { 145 | return page2DoesNotExist('fr-draft'); 146 | }); 147 | 148 | it('export both commits to fr-draft locale', function() { 149 | const workflow = apos.modules['apostrophe-workflow']; 150 | const exporter = Promise.promisify(workflow.export); 151 | let commits; 152 | const req = apos.tasks.getReq({ locale: 'default-draft' }); 153 | return Promise.try(function() { 154 | return workflow.db.find().sort({ createdAt: 1 }).toArray(); 155 | }).then(function(_commits) { 156 | commits = _commits; 157 | return exporter(req, commits[0]._id, [ 'fr' ]); 158 | }).then(function() { 159 | return exporter(req, commits[1]._id, [ 'fr' ]); 160 | }); 161 | }); 162 | 163 | it('after exports page2 is a child of page1 in fr-draft', function() { 164 | return page2IsNestedUnderPage1('fr-draft'); 165 | }); 166 | 167 | it('... and in fr (live)', function() { 168 | return page2IsNestedUnderPage1('fr'); 169 | }); 170 | 171 | it('... and still nonexistent in the unrelated en-draft', function() { 172 | return page2DoesNotExist('en-draft'); 173 | }); 174 | 175 | it('can force export page1 and page2 to en-draft', function() { 176 | const req = apos.tasks.getReq({ locale: 'default-draft' }); 177 | const workflow = apos.modules['apostrophe-workflow']; 178 | const forceExport = Promise.promisify(workflow.forceExport); 179 | let home; 180 | return Promise.try(function() { 181 | return apos.pages.find(req, { slug: '/' }).children({ depth: 2 }).toObject(); 182 | }).then(function(_home) { 183 | home = _home; 184 | return forceExport(req, home._children[0]._id, [ 'en' ]); 185 | }).then(function() { 186 | return forceExport(req, home._children[0]._children[0]._id, [ 'en' ]); 187 | }); 188 | }); 189 | 190 | it('after force exports page2 is a child of page1 in en-draft', function() { 191 | return page2IsNestedUnderPage1('en-draft'); 192 | }); 193 | 194 | it('... and still nonexistent in the unrelated es-draft', function() { 195 | return page2DoesNotExist('es-draft'); 196 | }); 197 | 198 | it('can force export page2 and page1 to es-draft, in that order (reversed)', function() { 199 | const req = apos.tasks.getReq({ locale: 'default-draft' }); 200 | const workflow = apos.modules['apostrophe-workflow']; 201 | const forceExport = Promise.promisify(workflow.forceExport); 202 | let home; 203 | return Promise.try(function() { 204 | return apos.pages.find(req, { slug: '/' }).children({ depth: 2 }).toObject(); 205 | }).then(function(_home) { 206 | home = _home; 207 | return forceExport(req, home._children[0]._children[0]._id, [ 'es' ]); 208 | }).then(function() { 209 | return forceExport(req, home._children[0]._id, [ 'es' ]); 210 | }); 211 | }); 212 | 213 | it('after force exports page1 and page2 are peers, in reverse order, in es-draft because the parent was not available when the child first arrived', function() { 214 | return page1AndPage2ArePeersReversed('es-draft'); 215 | }); 216 | 217 | function page1AndPage2ArePeers(locale) { 218 | return Promise.try(function() { 219 | return apos.docs.db.find({ workflowLocale: locale }).toArray(); 220 | }).then(function(docs) { 221 | return apos.pages.find(apos.tasks.getReq({ locale: locale }), { slug: '/' }).children({ depth: 2, trash: null }).toObject(); 222 | }).then(function(home) { 223 | assert(home); 224 | const page1 = home._children[0]; 225 | const page2 = home._children[1]; 226 | assert(page1.title === 'page1'); 227 | assert(page1.path === '/page1'); 228 | assert(page1.level === 1); 229 | assert(page2.title === 'page2'); 230 | assert(page2.path === '/page2'); 231 | assert(page2.level === 1); 232 | }); 233 | } 234 | 235 | function page1AndPage2ArePeersReversed(locale) { 236 | return Promise.try(function() { 237 | return apos.docs.db.find({ workflowLocale: locale }).toArray(); 238 | }).then(function(docs) { 239 | return apos.pages.find(apos.tasks.getReq({ locale: locale }), { slug: '/' }).children({ depth: 2, trash: null }).toObject(); 240 | }).then(function(home) { 241 | assert(home); 242 | const page2 = home._children[0]; 243 | const page1 = home._children[1]; 244 | assert(page1.title === 'page1'); 245 | assert(page1.path === '/page1'); 246 | assert(page1.level === 1); 247 | assert(page2.title === 'page2'); 248 | assert(page2.path === '/page2'); 249 | assert(page2.level === 1); 250 | }); 251 | } 252 | 253 | function page2IsNestedUnderPage1(locale) { 254 | return Promise.try(function() { 255 | return apos.pages.find(apos.tasks.getReq({ locale: locale }), { slug: '/' }).children({ depth: 2, trash: null }).toObject(); 256 | }).then(function(home) { 257 | const page1 = home._children[0]; 258 | assert(page1.title === 'page1'); 259 | assert(page1.path === '/page1'); 260 | assert(page1.level === 1); 261 | const page2 = page1._children[0]; 262 | assert(page2.title === 'page2'); 263 | assert(page2.path === '/page1/page2'); 264 | assert(page2.level === 2); 265 | }); 266 | } 267 | 268 | function page2DoesNotExist(locale) { 269 | return Promise.try(function() { 270 | return apos.pages.find(apos.tasks.getReq({ locale: locale }), { title: 'page2' }).toObject(); 271 | }).then(function(page) { 272 | if (page) { 273 | throw new Error('should not exist in the ' + locale + ' locale'); 274 | } 275 | }); 276 | } 277 | }); 278 | -------------------------------------------------------------------------------- /test/testApi.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | var async = require('async'); 3 | var revertId; 4 | var _ = require('@sailshq/lodash'); 5 | 6 | describe('Workflow API', function() { 7 | this.timeout(20000); 8 | var apos; 9 | 10 | after(function(done) { 11 | require('apostrophe/test-lib/util').destroy(apos, done); 12 | }); 13 | 14 | it('should be a property of the apos object', function(done) { 15 | apos = require('apostrophe')({ 16 | testModule: true, 17 | 18 | modules: { 19 | 'apostrophe-pages': { 20 | park: [], 21 | types: [ 22 | { 23 | name: 'home', 24 | label: 'Home' 25 | }, 26 | { 27 | name: 'testPage', 28 | label: 'Test Page' 29 | } 30 | ] 31 | }, 32 | 'apostrophe-workflow': { 33 | alias: 'workflow' // for testing only! 34 | }, 35 | 'products': { 36 | extend: 'apostrophe-pieces', 37 | name: 'product', 38 | alias: 'products' 39 | } 40 | }, 41 | afterInit: function(callback) { 42 | assert(apos.modules['apostrophe-workflow']); 43 | return callback(null); 44 | }, 45 | afterListen: function(err) { 46 | assert(!err); 47 | done(); 48 | } 49 | }); 50 | }); 51 | 52 | it('Test add draft product to db as draft', () => { 53 | var req = apos.tasks.getReq(); 54 | var product = apos.products.newInstance(); 55 | product.title = 'initial title'; 56 | product.tags = []; 57 | return apos.products.insert(req, product) 58 | .then(doc => { 59 | assert(doc.type === 'product'); 60 | assert(doc.workflowGuid); 61 | assert(doc.workflowLocale === 'default-draft'); 62 | }); 63 | }); 64 | 65 | // block repeats 66 | it('Commit a change', (done) => { 67 | var req = apos.tasks.getReq({locale: 'default-draft'}); 68 | 69 | async.waterfall([getProductDraft, updateProductDraft, commitUpdate], (err, res) => { 70 | assert(!err); 71 | assert(typeof res === 'string', 'response should be an id'); 72 | done(); 73 | }); 74 | 75 | function getProductDraft(callback) { 76 | apos.products.find(req).toArray().then(docs => { 77 | assert(docs[0]); 78 | return callback(null, docs[0]); 79 | }) 80 | .catch(e => { 81 | return callback(e); 82 | }); 83 | } 84 | 85 | function updateProductDraft(product, callback) { 86 | product.title = 'new title'; 87 | apos.products.update(req, product, (err, res) => { 88 | return callback(err, res); 89 | }); 90 | } 91 | 92 | function commitUpdate(product, callback) { 93 | apos.workflow.commitLatest(req, product._id, (err, res) => { 94 | revertId = res; 95 | return callback(err, res); 96 | }); 97 | } 98 | }); 99 | 100 | it('Check for live document after commit', done => { 101 | const req = apos.tasks.getReq(); 102 | apos.products.find(req).toArray().then(docs => { 103 | assert(docs[0].title === 'new title'); 104 | assert(!docs[0].trash); 105 | done(); 106 | }); 107 | }); 108 | // end block repeats 109 | 110 | // block repeats 111 | it('Commmit a change', (done) => { 112 | var req = apos.tasks.getReq({locale: 'default-draft'}); 113 | 114 | async.waterfall([getProductDraft, updateProductDraft, commitUpdate], (err, res) => { 115 | 116 | assert(!err); 117 | assert(typeof res === 'string', 'response should be an id'); 118 | done(); 119 | }); 120 | 121 | function getProductDraft(callback) { 122 | apos.products.find(req).toArray().then(docs => { 123 | assert(docs[0]); 124 | return callback(null, docs[0]); 125 | }) 126 | .catch(e => { 127 | return callback(e); 128 | }); 129 | } 130 | 131 | function updateProductDraft(product, callback) { 132 | product.title = 'new title 2'; 133 | apos.products.update(req, product, (err, res) => { 134 | return callback(err, res); 135 | }); 136 | } 137 | 138 | function commitUpdate(product, callback) { 139 | apos.workflow.commitLatest(req, product._id, (err, res) => { 140 | return callback(err, res); 141 | }); 142 | } 143 | // end block repeats 144 | }); 145 | 146 | it('Check for live document after commit', done => { 147 | const req = apos.tasks.getReq(); 148 | apos.products.find(req).toArray().then(docs => { 149 | assert(docs[0].title === 'new title 2'); 150 | assert(!docs[0].trash); 151 | // Verifies base case for the next group of tests. -Tom 152 | assert(Array.isArray(docs[0].tags)); 153 | done(); 154 | }); 155 | }); 156 | 157 | it('Commit a change that deletes a property', (done) => { 158 | var req = apos.tasks.getReq({locale: 'default-draft'}); 159 | 160 | async.waterfall([getProductDraft, updateProductDraft, commitUpdate], (err, res) => { 161 | 162 | assert(!err); 163 | assert(typeof res === 'string', 'response should be an id'); 164 | done(); 165 | }); 166 | 167 | function getProductDraft(callback) { 168 | apos.products.find(req).toArray().then(docs => { 169 | assert(docs[0]); 170 | return callback(null, docs[0]); 171 | }) 172 | .catch(e => { 173 | return callback(e); 174 | }); 175 | } 176 | 177 | function updateProductDraft(product, callback) { 178 | delete product.tags; 179 | apos.products.update(req, product, (err, res) => { 180 | return callback(err, res); 181 | }); 182 | } 183 | 184 | function commitUpdate(product, callback) { 185 | apos.workflow.commitLatest(req, product._id, (err, res) => { 186 | return callback(err, res); 187 | }); 188 | } 189 | // end block repeats 190 | }); 191 | 192 | it('Check for live document after commit: no more tags property', done => { 193 | const req = apos.tasks.getReq(); 194 | apos.products.find(req).toArray().then(docs => { 195 | assert(docs[0].title === 'new title 2'); 196 | assert(!docs[0].trash); 197 | assert(!_.has(docs[0], 'tags')); 198 | done(); 199 | }); 200 | }); 201 | 202 | it('Test revert', done => { 203 | const req = apos.tasks.getReq(); 204 | assert(revertId); 205 | 206 | async.waterfall([revert, check], (err, docs) => { 207 | assert(!err); 208 | assert(docs[0].title === 'new title'); 209 | assert(!docs[0].trash); 210 | done(); 211 | }); 212 | 213 | function revert (callback) { 214 | apos.workflow.revert(req, revertId, (err, res) => { 215 | assert(!err); 216 | callback(err); 217 | }); 218 | } 219 | 220 | function check (callback) { 221 | var req = apos.tasks.getReq({locale: 'default-draft'}); 222 | apos.products.find(req).toArray().then(docs => { 223 | callback(null, docs); 224 | }).catch(callback); 225 | } 226 | }); 227 | 228 | it('Test revert to live', done => { 229 | const req = apos.tasks.getReq({ locale: 'default-draft' }); 230 | 231 | async.waterfall([ getProduct, revertToLive, check ], (err, docs) => { 232 | assert(!err); 233 | assert(docs[0].title === 'new title 2'); 234 | assert(!docs[0].trash); 235 | done(); 236 | }); 237 | 238 | function getProduct (callback) { 239 | apos.products.find(req).toObject(callback); 240 | } 241 | 242 | function revertToLive (product, callback) { 243 | apos.workflow.revertToLive(req, product._id, (err, res) => { 244 | assert(!err); 245 | callback(err); 246 | }); 247 | } 248 | 249 | function check (callback) { 250 | var req = apos.tasks.getReq({locale: 'default-draft'}); 251 | apos.products.find(req).toArray(callback); 252 | } 253 | }); 254 | 255 | it('1 doc committable after a modification to product, 0 after commit', done => { 256 | const req = apos.tasks.getReq({ locale: 'default-draft' }); 257 | var product; 258 | async.series([ getProductDraft, updateProductDraft, _.partial(checkCommittable, 1, 'new title 3'), commit, _.partial(checkCommittable, 0, false) ], function(err) { 259 | assert(!err); 260 | done(); 261 | }); 262 | 263 | function getProductDraft(callback) { 264 | apos.products.find(req).toObject(function(err, _product) { 265 | product = _product; 266 | return callback(err); 267 | }); 268 | } 269 | 270 | function updateProductDraft(callback) { 271 | product.title = 'new title 3'; 272 | apos.products.update(req, product, callback); 273 | } 274 | 275 | function commit(callback) { 276 | apos.workflow.commitLatest(req, product._id, callback); 277 | } 278 | 279 | function checkCommittable(n, title0, callback) { 280 | apos.products.find(req, { workflowModified: true }).toArray(function(err, docs) { 281 | assert(!err); 282 | assert(docs.length === n); 283 | if ((n > 0) && title0) { 284 | assert(docs[0].title === title0); 285 | } 286 | return callback(null); 287 | }); 288 | } 289 | 290 | }); 291 | 292 | }); 293 | -------------------------------------------------------------------------------- /test/testDereplicate.js: -------------------------------------------------------------------------------- 1 | let assert = require('assert'); 2 | let _ = require('@sailshq/lodash'); 3 | 4 | describe('Workflow dereplicate task', function() { 5 | 6 | let apos; 7 | 8 | this.timeout(20000); 9 | 10 | after(function(done) { 11 | require('apostrophe/test-lib/util').destroy(apos, done); 12 | }); 13 | 14 | /// /// 15 | // EXISTENCE 16 | /// /// 17 | 18 | it('should be a property of the apos object', function(done) { 19 | apos = require('apostrophe')({ 20 | testModule: true, 21 | 22 | modules: { 23 | 'apostrophe-pages': { 24 | park: [], 25 | types: [ 26 | { 27 | name: 'home', 28 | label: 'Home' 29 | }, 30 | { 31 | name: 'testPage', 32 | label: 'Test Page' 33 | } 34 | ] 35 | }, 36 | 'products': {}, 37 | 'apostrophe-workflow': { 38 | alias: 'workflow', 39 | locales: [ 40 | { 41 | name: 'default', 42 | label: 'Default', 43 | private: true, 44 | children: [ 45 | { 46 | name: 'fr' 47 | }, 48 | { 49 | name: 'us' 50 | }, 51 | { 52 | name: 'es' 53 | } 54 | ] 55 | } 56 | ] 57 | // Do replicate, to create the test case 58 | } 59 | }, 60 | afterInit: function(callback) { 61 | assert(apos.workflow); 62 | return callback(null); 63 | }, 64 | afterListen: function(err) { 65 | assert(!err); 66 | done(); 67 | } 68 | }); 69 | }); 70 | 71 | it('newly inserted subpage gets replicated initially', function() { 72 | // Use a child locale so it is trash in all other locales at first 73 | let req = apos.tasks.getReq({ locale: 'es-draft' }); 74 | return apos.pages.find(req, { slug: '/' }).toObject().then(function(home) { 75 | assert(home); 76 | return apos.pages.insert(req, home._id, { title: 'About', slug: '/about', type: 'testPage', published: true }); 77 | }).then(function(subpage) { 78 | assert(subpage); 79 | assert(subpage.slug === '/about'); 80 | assert(subpage.workflowGuid); 81 | return apos.docs.db.find({ workflowGuid: subpage.workflowGuid }).toArray(); 82 | }).then(function(peers) { 83 | assert(peers.length === 8); 84 | }); 85 | }); 86 | 87 | it('run dereplication task without crashing', function() { 88 | return apos.tasks.invoke('apostrophe-workflow:dereplicate', [], {}); 89 | }); 90 | 91 | it('newly inserted subpage is no longer replicated to extra locales', function() { 92 | return apos.docs.db.findOne({ slug: '/about' }).then(function(subpage) { 93 | assert(subpage); 94 | assert(subpage.slug === '/about'); 95 | assert(subpage.workflowGuid); 96 | return apos.docs.db.find({ workflowGuid: subpage.workflowGuid }).toArray(); 97 | }).then(function(peers) { 98 | assert.equal(peers.length, 2); 99 | assert(peers.find(function(peer) { 100 | return peer.workflowLocale === 'es'; 101 | })); 102 | assert(peers.find(function(peer) { 103 | return peer.workflowLocale === 'es-draft'; 104 | })); 105 | }); 106 | }); 107 | 108 | it('home page should still be replicated', function() { 109 | return apos.docs.db.find({ level: 0, slug: '/' }).toArray().then(function(homes) { 110 | assert(homes); 111 | assert(homes.length === 8); 112 | const homeLocales = _.pluck(homes, 'workflowLocale'); 113 | assert(!Object.keys(apos.workflow.locales).find(function(locale) { 114 | return (!(homeLocales.indexOf(locale) !== -1)); 115 | })); 116 | }); 117 | }); 118 | 119 | it('global doc page should still be replicated', function() { 120 | return apos.docs.db.find({ type: 'apostrophe-global' }).toArray().then(function(globals) { 121 | assert(globals); 122 | assert(globals.length === 8); 123 | const globalLocales = _.pluck(globals, 'workflowLocale'); 124 | assert(!Object.keys(apos.workflow.locales).find(function(locale) { 125 | return (!(globalLocales.indexOf(locale) !== -1)); 126 | })); 127 | }); 128 | }); 129 | 130 | }); 131 | -------------------------------------------------------------------------------- /test/testGlobalDef.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | var apos, apos2; 3 | var _ = require('@sailshq/lodash'); 4 | 5 | describe('test global def', function() { 6 | this.timeout(20000); 7 | after(function(done) { 8 | require('apostrophe/test-lib/util').destroy(apos, function() { 9 | require('apostrophe/test-lib/util').destroy(apos2, done); 10 | }); 11 | }); 12 | it('global should exist on the apos object', function(done) { 13 | apos = require('apostrophe')({ 14 | testModule: true, 15 | shortName: 'test', 16 | modules: { 17 | 'apostrophe-express': { 18 | secret: 'xxx', 19 | port: 7900 20 | }, 21 | 'products': {}, 22 | 'apostrophe-global': { 23 | addFields: [ 24 | { 25 | name: 'testString', 26 | type: 'string', 27 | def: 'populated def' 28 | } 29 | ] 30 | }, 31 | 'apostrophe-workflow': { 32 | locales: [ 33 | { 34 | name: 'en', 35 | children: [ 36 | { 37 | name: 'fr' 38 | }, 39 | { 40 | name: 'de' 41 | } 42 | ] 43 | } 44 | ], 45 | defaultLocale: 'en', 46 | alias: 'workflow' // for testing only! 47 | } 48 | }, 49 | afterInit: function(callback) { 50 | assert(apos.global); 51 | // In tests this will be the name of the test file, 52 | // so override that in order to get apostrophe to 53 | // listen normally and not try to run a task. -Tom 54 | apos.argv._ = []; 55 | return callback(null); 56 | }, 57 | afterListen: function(err) { 58 | assert(!err); 59 | done(); 60 | } 61 | }); 62 | }); 63 | 64 | it('should populate def values of schema properties across locales at insert time', function(done) { 65 | return apos.docs.db.find({ slug: 'global' }).toArray(function(err, docs) { 66 | assert(!err); 67 | assert(docs.length === 6); 68 | assert(!_.find(docs, function(doc) { 69 | return doc.testString !== 'populated def'; 70 | })); 71 | done(); 72 | }); 73 | }); 74 | 75 | it('global should exist on the second apos object', function(done) { 76 | apos2 = require('apostrophe')({ 77 | testModule: true, 78 | // intentionally the same as previous 79 | shortName: 'test', 80 | modules: { 81 | 'apostrophe-express': { 82 | secret: 'xxx', 83 | port: 7901 84 | }, 85 | 'products': {}, 86 | 'apostrophe-global': { 87 | addFields: [ 88 | { 89 | name: 'anotherString', 90 | type: 'string', 91 | def: 'populated anotherString def' 92 | } 93 | ] 94 | }, 95 | 'apostrophe-workflow': { 96 | locales: [ 97 | { 98 | name: 'en', 99 | children: [ 100 | { 101 | name: 'fr' 102 | }, 103 | { 104 | name: 'de' 105 | } 106 | ] 107 | } 108 | ], 109 | defaultLocale: 'en', 110 | alias: 'workflow' // for testing only! 111 | } 112 | }, 113 | afterInit: function(callback) { 114 | assert(apos2.global); 115 | // In tests this will be the name of the test file, 116 | // so override that in order to get apostrophe to 117 | // listen normally and not try to run a task. -Tom 118 | apos2.argv._ = []; 119 | return callback(null); 120 | }, 121 | afterListen: function(err) { 122 | assert(!err); 123 | done(); 124 | } 125 | }); 126 | }); 127 | 128 | it('should populate def values of schema properties at update time', function(done) { 129 | return apos.docs.db.find({ slug: 'global' }).toArray(function(err, docs) { 130 | assert(!err); 131 | assert(docs.length === 6); 132 | assert(!_.find(docs, function(doc) { 133 | return (doc.testString !== 'populated def') || (doc.anotherString !== 'populated anotherString def'); 134 | })); 135 | done(); 136 | }); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /test/testOverrideOptions.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const request = require('request-promise'); 3 | const _ = require('@sailshq/lodash'); 4 | 5 | describe('Override Options', function() { 6 | this.timeout(20000); 7 | let apos; 8 | 9 | after(function(done) { 10 | require('apostrophe/test-lib/util').destroy(apos, done); 11 | }); 12 | 13 | it('should be a property of the apos object', function(done) { 14 | apos = require('apostrophe')({ 15 | testModule: true, 16 | 17 | modules: { 18 | 'apostrophe-express': { 19 | port: 7900 20 | }, 21 | 'products': {}, 22 | 'apostrophe-pages': { 23 | park: [], 24 | types: [ 25 | { 26 | name: 'home', 27 | label: 'Home' 28 | }, 29 | { 30 | name: 'testPage', 31 | label: 'Test Page' 32 | } 33 | ] 34 | }, 35 | 'apostrophe-workflow': { 36 | locales: [ 37 | { 38 | name: 'en', 39 | children: [ 40 | { 41 | name: 'fr' 42 | }, 43 | { 44 | name: 'de' 45 | } 46 | ] 47 | } 48 | ], 49 | defaultLocale: 'en', 50 | alias: 'workflow' // for testing only! 51 | }, 52 | 'apostrophe-global': { 53 | addFields: [ 54 | { 55 | type: 'boolean', 56 | name: 'disableExportAfterCommit', 57 | label: 'Disable Export After Commit', 58 | def: true 59 | } 60 | ], 61 | overrideOptions: { 62 | editable: { 63 | 'apos.apostrophe-workflow.disableExportAfterCommit': 'disableExportAfterCommit' 64 | } 65 | } 66 | }, 67 | 'apostrophe-override-options': {}, 68 | // For every request act as if an admin were logged in already 69 | 'always-admin': { 70 | construct: function(self, options) { 71 | self.expressMiddleware = function(req, res, next) { 72 | const adminReq = self.apos.tasks.getReq(); 73 | req.user = adminReq.user; 74 | return next(); 75 | }; 76 | } 77 | } 78 | }, 79 | afterInit: function(callback) { 80 | assert(apos.modules['apostrophe-workflow']); 81 | return callback(null); 82 | }, 83 | afterListen: function(err) { 84 | assert(!err); 85 | done(); 86 | } 87 | }); 88 | }); 89 | 90 | it('verify disableExportAfterCommit === true for all global docs', function() { 91 | return apos.docs.db.find({ type: 'apostrophe-global' }).toArray().then(function(docs) { 92 | return (docs.length === 6) && (!_.find(docs, function(doc) { return doc.disableExportAfterCommit !== true; })); 93 | }); 94 | }); 95 | 96 | it('verify simulated admin login and that "exportAfterCommit":false comes through', () => { 97 | return request('http://localhost:7900').then((html) => { 98 | assert(html.match(/logout/)); 99 | assert(html.match(/"exportAfterCommit":false/)); 100 | }); 101 | }); 102 | 103 | }); 104 | -------------------------------------------------------------------------------- /test/testReplicateFalse.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const _ = require('@sailshq/lodash'); 3 | const Promise = require('bluebird'); 4 | 5 | describe('Workflow with replicateAcrossLocales set to false: initial locales', function() { 6 | 7 | let apos; 8 | 9 | this.timeout(20000); 10 | 11 | after(function(done) { 12 | // Do not destroy database yet, second block explores what happens 13 | // when we add locales to it 14 | apos.destroy(function(err) { 15 | assert(!err); 16 | done(); 17 | }); 18 | }); 19 | 20 | /// /// 21 | // EXISTENCE 22 | /// /// 23 | 24 | it('should be a property of the apos object', function(done) { 25 | var locales = [ 26 | { 27 | name: 'default', 28 | label: 'Default', 29 | private: true, 30 | children: [ 31 | { 32 | name: 'fr' 33 | }, 34 | { 35 | name: 'us' 36 | }, 37 | { 38 | name: 'es' 39 | } 40 | ] 41 | } 42 | ]; 43 | return instantiate(locales, function(err, _apos) { 44 | assert(!err); 45 | apos = _apos; 46 | done(); 47 | }); 48 | }); 49 | 50 | it('home page should be replicated', function() { 51 | return apos.docs.db.find({ level: 0, slug: '/' }).toArray().then(function(homes) { 52 | assert(homes); 53 | assert(homes.length === 8); 54 | const homeLocales = _.pluck(homes, 'workflowLocale'); 55 | assert(!Object.keys(apos.workflow.locales).find(function(locale) { 56 | return (!(homeLocales.indexOf(locale) !== -1)); 57 | })); 58 | }); 59 | }); 60 | 61 | it('global doc page should be replicated', function() { 62 | return apos.docs.db.find({ type: 'apostrophe-global' }).toArray().then(function(globals) { 63 | assert(globals); 64 | assert(globals.length === 8); 65 | const globalLocales = _.pluck(globals, 'workflowLocale'); 66 | assert(!Object.keys(apos.workflow.locales).find(function(locale) { 67 | return (!(globalLocales.indexOf(locale) !== -1)); 68 | })); 69 | }); 70 | }); 71 | 72 | it('parked test page should be replicated', function() { 73 | return apos.docs.db.find({ slug: '/parked-test-page' }).toArray().then(function(test) { 74 | assert(test); 75 | assert(test.length === 8); 76 | const testLocales = _.pluck(test, 'workflowLocale'); 77 | assert(!Object.keys(apos.workflow.locales).find(function(locale) { 78 | return (!(testLocales.indexOf(locale) !== -1)); 79 | })); 80 | }); 81 | }); 82 | 83 | it('newly inserted subpage is only replicated draft/live', function() { 84 | let req = apos.tasks.getReq(); 85 | return apos.pages.find(req, { slug: '/' }).toObject().then(function(home) { 86 | assert(home); 87 | return apos.pages.insert(req, home._id, { title: 'About', slug: '/about', type: 'testPage', published: true }); 88 | }).then(function(subpage) { 89 | assert(subpage); 90 | assert(subpage.slug === '/about'); 91 | assert(subpage.workflowGuid); 92 | return apos.docs.db.find({ workflowGuid: subpage.workflowGuid }).toArray(); 93 | }).then(function(peers) { 94 | assert(peers.length === 2); 95 | assert(peers.find(function(peer) { 96 | return peer.workflowLocale === apos.workflow.liveify(req.locale); 97 | })); 98 | assert(peers.find(function(peer) { 99 | return peer.workflowLocale === apos.workflow.draftify(req.locale); 100 | })); 101 | }); 102 | }); 103 | 104 | it('make sure locale of all docs can be distinguished easily for testing who replicated from whom', function() { 105 | return apos.docs.db.find({}).toArray().then(function(docs) { 106 | return Promise.mapSeries(docs, function(doc) { 107 | doc.title = doc.slug + ': original locale: ' + doc.workflowLocale; 108 | return apos.docs.db.update({ _id: doc._id }, doc); 109 | }); 110 | }); 111 | }); 112 | 113 | it('insert test products and establish relationships', function() { 114 | const req = apos.tasks.getReq(); 115 | const products = _.range(0, 10).map(function(n) { 116 | return { 117 | title: 'product ' + n 118 | }; 119 | }); 120 | let last = null; 121 | return Promise.mapSeries(products, function(product, i) { 122 | if (i < 5) { 123 | product.relatedId = last && last._id; 124 | } 125 | return apos.products.insert(req, product); 126 | }).then(function() { 127 | return apos.docs.db.find({ level: 0 }).toArray(); 128 | }).then(function(homes) { 129 | return Promise.mapSeries(homes, function(home) { 130 | return Promise.try(function() { 131 | return apos.docs.db.findOne({ title: 'product 0' }, { workflowLocale: home.workflowLocale }); 132 | }).then(function(product) { 133 | return apos.docs.db.update({ 134 | _id: home._id 135 | }, { 136 | $set: { 137 | relatedId: product._id 138 | } 139 | }); 140 | }); 141 | }); 142 | }); 143 | }); 144 | }); 145 | 146 | describe('Workflow with replicateAcrossLocales set to false: expanded locales', function() { 147 | 148 | let apos; 149 | 150 | this.timeout(20000); 151 | 152 | after(function(done) { 153 | // This time destroy database 154 | require('apostrophe/test-lib/util').destroy(apos, done); 155 | }); 156 | 157 | /// /// 158 | // EXISTENCE 159 | /// /// 160 | 161 | it('should be a property of the apos object', function(done) { 162 | var locales = [ 163 | { 164 | name: 'default', 165 | label: 'Default', 166 | private: true, 167 | children: [ 168 | { 169 | name: 'fr' 170 | }, 171 | { 172 | name: 'us' 173 | }, 174 | { 175 | name: 'es', 176 | children: [ 177 | { 178 | name: 'es-mx' 179 | } 180 | ] 181 | } 182 | ] 183 | } 184 | ]; 185 | return instantiate(locales, function(err, _apos) { 186 | assert(!err); 187 | apos = _apos; 188 | done(); 189 | }); 190 | }); 191 | 192 | it('home page should be replicated', function() { 193 | return apos.docs.db.find({ level: 0, slug: '/' }).toArray().then(function(homes) { 194 | assert(homes); 195 | assert(homes.length === 10); 196 | const homeLocales = _.pluck(homes, 'workflowLocale'); 197 | assert(!Object.keys(apos.workflow.locales).find(function(locale) { 198 | return (!(homeLocales.indexOf(locale) !== -1)); 199 | })); 200 | }); 201 | }); 202 | 203 | it('global doc page should be replicated', function() { 204 | return apos.docs.db.find({ type: 'apostrophe-global' }).toArray().then(function(globals) { 205 | assert(globals); 206 | assert(globals.length === 10); 207 | const globalLocales = _.pluck(globals, 'workflowLocale'); 208 | assert(!Object.keys(apos.workflow.locales).find(function(locale) { 209 | return (!(globalLocales.indexOf(locale) !== -1)); 210 | })); 211 | }); 212 | }); 213 | 214 | it('parked test page should be replicated', function() { 215 | return apos.docs.db.find({ slug: '/parked-test-page' }).toArray().then(function(test) { 216 | assert(test); 217 | assert(test.length === 10); 218 | const testLocales = _.pluck(test, 'workflowLocale'); 219 | assert(!Object.keys(apos.workflow.locales).find(function(locale) { 220 | return (!(testLocales.indexOf(locale) !== -1)); 221 | })); 222 | }); 223 | }); 224 | 225 | it('es-mx-draft parked page should get content of es-draft, not default', function() { 226 | return apos.docs.db.find({ slug: '/parked-test-page', workflowLocale: 'es-mx-draft' }).toArray().then(function(pages) { 227 | assert(pages && pages[0]); 228 | assert(pages[0].title === '/parked-test-page: original locale: es-draft'); 229 | }); 230 | }); 231 | 232 | it('Normally inserted subpage exists but was not replicated to new locale', function() { 233 | return apos.docs.db.find({ slug: '/about' }).toArray().then(function(docs) { 234 | // Only default and default-draft 235 | assert(docs.length === 2); 236 | }); 237 | }); 238 | 239 | }); 240 | 241 | function instantiate(locales, callback) { 242 | var apos = require('apostrophe')({ 243 | testModule: true, 244 | 245 | modules: { 246 | 'apostrophe-custom-pages': { 247 | addFields: [ 248 | { 249 | name: '_featured', 250 | type: 'joinByOne', 251 | withType: 'product' 252 | } 253 | ] 254 | }, 255 | 'apostrophe-pages': { 256 | park: [ 257 | { 258 | type: 'testPage', 259 | slug: '/parked-test-page', 260 | parkedId: 'parked-test-page' 261 | } 262 | ], 263 | types: [ 264 | { 265 | name: 'home', 266 | label: 'Home' 267 | }, 268 | { 269 | name: 'testPage', 270 | label: 'Test Page' 271 | } 272 | ] 273 | }, 274 | 'products': {}, 275 | 'apostrophe-workflow': { 276 | alias: 'workflow', 277 | locales: locales, 278 | replicateAcrossLocales: false 279 | } 280 | }, 281 | afterInit: function(callback) { 282 | assert(apos.workflow); 283 | return callback(null); 284 | }, 285 | afterListen: function(err) { 286 | assert(!err); 287 | return callback(null, apos); 288 | } 289 | }); 290 | } 291 | -------------------------------------------------------------------------------- /test/testResolveRelationships.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | 3 | describe('Resolve Relationships', function() { 4 | this.timeout(20000); 5 | var apos; 6 | 7 | after(function(done) { 8 | require('apostrophe/test-lib/util').destroy(apos, done); 9 | }); 10 | 11 | it('should be a property of the apos object', function(done) { 12 | apos = require('apostrophe')({ 13 | testModule: true, 14 | 15 | modules: { 16 | 'apostrophe-pages': { 17 | park: [], 18 | types: [ 19 | { 20 | name: 'home', 21 | label: 'Home' 22 | }, 23 | { 24 | name: 'testPage', 25 | label: 'Test Page' 26 | } 27 | ] 28 | }, 29 | 'apostrophe-workflow': { 30 | locales: [ 31 | { 32 | name: 'en', 33 | children: [ 34 | { 35 | name: 'fr' 36 | }, 37 | { 38 | name: 'de' 39 | } 40 | ] 41 | } 42 | ], 43 | defaultLocale: 'en', 44 | alias: 'workflow' // for testing only! 45 | }, 46 | 'products': { 47 | extend: 'apostrophe-pieces', 48 | name: 'product', 49 | alias: 'products', 50 | addFields: [ 51 | { 52 | name: '_specialists', 53 | type: 'joinByArray' 54 | }, 55 | { 56 | name: '_expert', 57 | withType: 'specialist', 58 | type: 'joinByOne' 59 | } 60 | ] 61 | }, 62 | 'specialists': { 63 | extend: 'apostrophe-pieces', 64 | name: 'specialist', 65 | alias: 'specialists' 66 | } 67 | }, 68 | afterInit: function(callback) { 69 | assert(apos.modules['apostrophe-workflow']); 70 | return callback(null); 71 | }, 72 | afterListen: function(err) { 73 | assert(!err); 74 | done(); 75 | } 76 | }); 77 | }); 78 | 79 | it('Add products and specialists as drafts, with joins; confirm relationships mapped correctly in new locale', () => { 80 | var req = apos.tasks.getReq(); 81 | var specialist1 = apos.specialists.newInstance(); 82 | var specialist2 = apos.specialists.newInstance(); 83 | var product1 = apos.products.newInstance(); 84 | var product2 = apos.products.newInstance(); 85 | specialist1.title = 'specialist 1'; 86 | specialist2.title = 'specialist 2'; 87 | return apos.specialists.insert(req, specialist1).then(function() { 88 | return apos.specialists.insert(req, specialist2); 89 | }).then(function() { 90 | product1.title = 'product 1'; 91 | product1.specialistsIds = [ specialist1._id ]; 92 | product2.title = 'product 2'; 93 | product2.specialistsIds = [ specialist2._id ]; 94 | product1.expertId = specialist1._id; 95 | return apos.products.insert(req, product1); 96 | }).then(function(product1) { 97 | assert(product1.workflowLocale === 'en-draft'); 98 | return apos.products.insert(req, product2); 99 | }).then(function(product2) { 100 | assert(product2.workflowLocale === 'en-draft'); 101 | var frReq = apos.tasks.getReq({ locale: 'fr-draft' }); 102 | return apos.products.find(frReq, { title: 'product 1' }).toObject(); 103 | }).then(function(product1) { 104 | var frReq = apos.tasks.getReq({ locale: 'fr-draft' }); 105 | assert(product1.workflowLocale === 'fr-draft'); 106 | assert(product1._specialists[0].title === 'specialist 1'); 107 | assert(product1._specialists[0].workflowLocale === 'fr-draft'); 108 | assert(product1._expert.title === 'specialist 1'); 109 | assert(product1._expert.workflowLocale === 'fr-draft'); 110 | return apos.products.find(frReq, { title: 'product 2' }).toObject(); 111 | }).then(function(product2) { 112 | assert(product2.workflowLocale === 'fr-draft'); 113 | assert(product2._specialists[0].title === 'specialist 2'); 114 | assert(product2._specialists[0].workflowLocale === 'fr-draft'); 115 | assert(!product2._expert); 116 | }); 117 | }); 118 | 119 | }); 120 | -------------------------------------------------------------------------------- /views/batch-export-modal.html: -------------------------------------------------------------------------------- 1 | {%- extends "apostrophe-modal:base.html" -%} 2 | {%- import "apostrophe-modal:macros.html" as modals -%} 3 | {%- import "apostrophe-ui:components/buttons.html" as buttons with context -%} 4 | {%- import "locale-tree.html" as localeTree -%} 5 | 6 | {%- block modalClass -%} 7 | apos-workflow-export-modal apos-ui-modal-no-sidebar 8 | {%- endblock -%} 9 | 10 | {%- block controls -%} 11 | {{ buttons.minor('Skip', { action: 'cancel' }) }} 12 | {{ buttons.major('Batch Export', { action: 'save' }) }} 13 | {%- endblock -%} 14 | 15 | {%- block label -%} 16 | {{ __ns('apostrophe', 'Exporting changes for %s item(s) (%s)', data.ids.length, data.locale) }} 17 | {%- endblock -%} 18 | 19 | {% block instructions %} 20 |

21 | {{ __ns('apostrophe', 'This change is already committed for the %s locale. To push this change to additional locales, select them below, then click Export. Selecting the %s locale may still be helpful to select sub-locales.', data.locale, data.locale) }} 22 |

23 | {% endblock %} 24 | 25 | {%- block body -%} 26 |
27 | {{ localeTree.tree( 28 | 'locales', 29 | [ 30 | { 31 | name: 'locale', 32 | commitLocale: data.locale 33 | } 34 | ], 35 | data.nestedLocales) 36 | }} 37 |
38 | {%- endblock -%} 39 | 40 | {%- block footerContainer -%}{%- endblock -%} 41 | -------------------------------------------------------------------------------- /views/batch-force-export-modal.html: -------------------------------------------------------------------------------- 1 | {%- extends "apostrophe-modal:base.html" -%} 2 | {%- import "apostrophe-modal:macros.html" as modals -%} 3 | {%- import "apostrophe-ui:components/buttons.html" as buttons with context -%} 4 | {%- import "locale-tree.html" as localeTree -%} 5 | 6 | {%- block modalClass -%} 7 | apos-workflow-batch-export-modal apos-workflow-export-modal apos-ui-modal-no-sidebar 8 | {%- endblock -%} 9 | 10 | {%- block controls -%} 11 | {{ buttons.minor('Skip', { action: 'cancel' }) }} 12 | {{ buttons.major('Export', { action: 'save' }) }} 13 | {%- endblock -%} 14 | 15 | {%- block label -%} 16 | {{ __ns('apostrophe', 'Forcing export of %s item(s) (%s)', data.ids.length, data.locale) }} 17 | {%- endblock -%} 18 | 19 | {% block instructions %} 20 |

21 | {{ __ns('apostrophe', 'You are forcing an export, which will copy the current draft(s) verbatim to other locales. Select them below, then click Export. Selecting the %s locale may still be helpful to select sub-locales.', data.locale, data.locale) }} 22 |

23 | {% endblock %} 24 | 25 | {%- block body -%} 26 |
27 | 28 | {% if data.offerRelatedExisting %} 29 | 30 | {% endif %} 31 | 32 |
33 | {# ajax populates me #} 34 |
35 | {{ localeTree.tree( 36 | 'locales', 37 | [ 38 | { 39 | name: 'locale' 40 | } 41 | ], 42 | data.nestedLocales) 43 | }} 44 |
45 | {%- endblock -%} 46 | 47 | {%- block footerContainer -%}{%- endblock -%} 48 | -------------------------------------------------------------------------------- /views/commit-modal.html: -------------------------------------------------------------------------------- 1 | {%- extends "apostrophe-modal:base.html" -%} 2 | {%- import "apostrophe-modal:macros.html" as modals -%} 3 | {%- import "apostrophe-ui:components/buttons.html" as buttons with context -%} 4 | {%- import "apostrophe-ui:components/dropdowns.html" as dropdowns with context -%} 5 | 6 | {%- block modalClass -%} 7 | apos-workflow-commit-modal apos-ui-modal-no-sidebar 8 | {%- endblock -%} 9 | 10 | {%- block controls -%} 11 | {{ buttons.minor('Cancel' if (data.total == 1) else 'Skip', { action: 'cancel' }) }} 12 | {{ buttons.major('Commit', { action: 'save' }) }} 13 | {%- endblock -%} 14 | 15 | {%- block label -%} 16 | {{ __ns('apostrophe', '[%s of %s] Committing %s', data.index, data.total, data.doc.title or data.doc.slug) }} 17 | {%- endblock -%} 18 | 19 | {% block instructions %} 20 | {% if not data.lead %} 21 |

22 | {{ __ns('apostrophe', 'This related item may also be visible on the page. Please consider it first so that your final commit is more complete.') }} 23 |

24 |

25 | {{ __ns('apostrophe', 'Commit all like this') }} 26 | {{ __ns('apostrophe', 'Skip all like this') }} 27 |

28 | {% else %} 29 |

30 | {{ __ns('apostrophe', 'Click Commit to make these changes live.') }} 31 |

32 | {% endif %} 33 | {% endblock %} 34 | 35 | {%- block body -%} 36 | {% if data.modifiedFields.length %} 37 |
38 |

{{ __ns('apostrophe', 'Modified fields: %s', data.modifiedFields | join(', ')) }}

39 | {% if data.doc.workflowMovedIsNew %} 40 |

The page was moved to a new location.

41 | {% endif %} 42 |

Also see below for content edited in context.

43 |
44 | {% endif %} 45 |
46 | {% if data.preview %} 47 | {{ data.preview }} 48 | {% elseif data.doc._url %} 49 | 50 | {% else %} 51 |

No preview available.

52 | {% endif %} 53 |
54 | {%- endblock -%} 55 | 56 | {%- block footerContainer -%}{%- endblock -%} 57 | -------------------------------------------------------------------------------- /views/export-modal.html: -------------------------------------------------------------------------------- 1 | {%- extends "apostrophe-modal:base.html" -%} 2 | {%- import "apostrophe-modal:macros.html" as modals -%} 3 | {%- import "apostrophe-ui:components/buttons.html" as buttons with context -%} 4 | {%- import "locale-tree.html" as localeTree -%} 5 | 6 | {%- block modalClass -%} 7 | apos-workflow-export-modal apos-ui-modal-no-sidebar 8 | {%- endblock -%} 9 | 10 | {%- block controls -%} 11 | {{ buttons.minor('Skip', { action: 'cancel' }) }} 12 | {{ buttons.major('Export', { action: 'save' }) }} 13 | {%- endblock -%} 14 | 15 | {%- block label -%} 16 | {{ __ns('apostrophe', 'Exporting changes from %s (%s)', data.commit.to.title or data.commit.to.slug, data.commit.locale) }} 17 | {%- endblock -%} 18 | 19 | {% block instructions %} 20 |

21 | {{ __ns('apostrophe', 'This change is already committed for the %s locale. To push this change to additional locales, select them below, then click Export. Selecting the %s locale may still be helpful to select sub-locales.', data.commit.locale, data.commit.locale) }} 22 |

23 | {% endblock %} 24 | 25 | {%- block body -%} 26 |
27 | {{ localeTree.tree( 28 | 'locales', 29 | [ 30 | { 31 | name: 'locale', 32 | commitLocale: data.commit.locale 33 | } 34 | ], 35 | data.nestedLocales) 36 | }} 37 |
38 | {%- endblock -%} 39 | 40 | {%- block footerContainer -%}{%- endblock -%} 41 | -------------------------------------------------------------------------------- /views/force-export-modal.html: -------------------------------------------------------------------------------- 1 | {%- extends "apostrophe-modal:base.html" -%} 2 | {%- import "apostrophe-modal:macros.html" as modals -%} 3 | {%- import "apostrophe-ui:components/buttons.html" as buttons with context -%} 4 | {%- import "locale-tree.html" as localeTree -%} 5 | 6 | {%- block modalClass -%} 7 | apos-workflow-export-modal apos-ui-modal-no-sidebar 8 | {%- endblock -%} 9 | 10 | {%- block controls -%} 11 | {{ buttons.minor('Skip', { action: 'cancel' }) }} 12 | {{ buttons.major('Force Export', { action: 'save' }) }} 13 | {%- endblock -%} 14 | 15 | {%- block label -%} 16 | {{ __ns('apostrophe', 'Forcing export of %s (%s)', data.doc.title, data.doc.workflowLocale) }} 17 | {%- endblock -%} 18 | 19 | {% block instructions %} 20 |

21 | {{ __ns('apostrophe', 'You are forcing an export, which will copy this draft verbatim to other locales. Select them below, then click Export. Selecting the %s locale may still be helpful to select sub-locales.', data.doc.workflowLocale, data.doc.workflowLocale) }} 22 |

23 | {% if not data.lead %} 24 |

25 | {{ __ns('apostrophe', 'This related item may also be visible. Please consider exporting it too for a more complete result.') }} 26 |

27 |

28 | {# Despite the data attribute name this will do the right operation here #} 29 | {{ __ns('apostrophe', 'Force Export all like this') }} 30 | {{ __ns('apostrophe', 'Skip all like this') }} 31 |

32 | {% endif %} 33 | {% endblock %} 34 | 35 | {%- block body -%} 36 |
37 | 38 | {% if data.offerRelatedExisting %} 39 | 40 | {% endif %} 41 |
42 |
43 | {{ localeTree.tree( 44 | 'locales', 45 | [ 46 | { 47 | name: 'locale' 48 | } 49 | ], 50 | data.nestedLocales) 51 | }} 52 |
53 | {%- endblock -%} 54 | 55 | {%- block footerContainer -%}{%- endblock -%} 56 | -------------------------------------------------------------------------------- /views/force-export-related-modal.html: -------------------------------------------------------------------------------- 1 | {%- extends "apostrophe-modal:base.html" -%} 2 | {%- import "apostrophe-modal:macros.html" as modals -%} 3 | {%- import "apostrophe-ui:components/buttons.html" as buttons with context -%} 4 | {%- import "locale-tree.html" as localeTree -%} 5 | 6 | {%- block modalClass -%} 7 | apos-workflow-export-modal apos-ui-modal-no-sidebar 8 | {%- endblock -%} 9 | 10 | {%- block controls -%} 11 | {{ buttons.minor('Skip', { action: 'cancel' }) }} 12 | {{ buttons.major('Force Export Related', { action: 'save' }) }} 13 | {%- endblock -%} 14 | 15 | {%- block label -%} 16 | {{ __ns('apostrophe', 'Forcing export of documents related to %s (%s)', data.doc.title, data.doc.workflowLocale) }} 17 | {%- endblock -%} 18 | 19 | {% block instructions %} 20 |

21 | {{ __ns('apostrophe', 'You are forcing an export of documents related to this document, which will copy their drafts verbatim to other locales. Select locales below, and check the box if you wish to force export documents even if they already exist in the other locales.') }} 22 |

23 |
24 | {# Here for compatibility with the inherited logic, will be hidden by JS and always checked #} 25 | 26 | {# Really in play #} 27 | {% if data.offerRelatedExisting %} 28 | 29 | {% endif %} 30 |
31 | {% endblock %} 32 | 33 | {%- block body -%} 34 |
35 | {{ localeTree.tree( 36 | 'locales', 37 | [ 38 | { 39 | name: 'locale' 40 | } 41 | ], 42 | data.nestedLocales) 43 | }} 44 |
45 | {%- endblock -%} 46 | 47 | {%- block footerContainer -%}{%- endblock -%} 48 | -------------------------------------------------------------------------------- /views/force-export-widget-modal.html: -------------------------------------------------------------------------------- 1 | {%- extends "apostrophe-modal:base.html" -%} 2 | {%- import "apostrophe-modal:macros.html" as modals -%} 3 | {%- import "apostrophe-ui:components/buttons.html" as buttons with context -%} 4 | {%- import "locale-tree.html" as localeTree -%} 5 | 6 | {%- block modalClass -%} 7 | apos-workflow-export-modal apos-ui-modal-no-sidebar 8 | {%- endblock -%} 9 | 10 | {%- block controls -%} 11 | {{ buttons.minor('Skip', { action: 'cancel' }) }} 12 | {{ buttons.major('Export', { action: 'save' }) }} 13 | {%- endblock -%} 14 | 15 | {%- block label -%} 16 | {{ __ns('apostrophe', 'Exporting a single widget from %s (%s)', data.doc.title, data.doc.workflowLocale) }} 17 | {%- endblock -%} 18 | 19 | {% block instructions %} 20 |

21 | {{ __ns('apostrophe', 'To force an export of this widget to additional locales, select them below, then click Export. Selecting the %s locale may still be helpful to select sub-locales.', data.doc.workflowLocale, data.doc.workflowLocale) }} 22 |

23 | {% endblock %} 24 | 25 | {%- block body -%} 26 |
27 | {{ localeTree.tree( 28 | 'locales', 29 | [ 30 | { 31 | name: 'locale' 32 | } 33 | ], 34 | data.nestedLocales) 35 | }} 36 |
37 | {%- endblock -%} 38 | 39 | {%- block footerContainer -%}{%- endblock -%} 40 | -------------------------------------------------------------------------------- /views/history-modal.html: -------------------------------------------------------------------------------- 1 | {%- extends "apostrophe-modal:base.html" -%} 2 | {%- import "apostrophe-modal:macros.html" as modals -%} 3 | {%- import "apostrophe-ui:components/buttons.html" as buttons with context -%} 4 | 5 | {%- block modalClass -%} 6 | apos-workflow-history-modal apos-ui-modal-no-sidebar 7 | {%- endblock -%} 8 | 9 | {%- block controls -%} 10 | {{ buttons.major('Done', { action: 'cancel' }) }} 11 | {%- endblock -%} 12 | 13 | {%- block label -%} 14 | {{ __ns('apostrophe', 'Commit history of %s', data.doc.title) }} 15 | {%- endblock -%} 16 | 17 | {% block instructions %} 18 |

19 | {{ __ns('apostrophe', 'Click on a past commit to review it, revert to it or export it to more locales.' if data.localized else 'Click on a past commit to review it or revert to it.') }} 20 |

21 | {% endblock %} 22 | 23 | {%- block body -%} 24 | 25 | {# Markup follows the pattern of the manage modal markup #} 26 |
27 |
28 | {# May need to add pagination here at some point 29 |
30 |
__ns('apostrophe', 'Showing %s - %s of %s results', from, to, total)
31 |
32 | #} 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | {% for commit in data.commits %} 41 | 42 | 43 | 44 | 48 | 49 | {% endfor %} 50 | 51 |
DateAuthorActions
{{ commit.createdAt | date(__ns('apostrophe', 'MM/DD/YY[ at ]h:mma')) }}{{ commit.user.title }} 45 | {{ __ns('apostrophe', 'Review and Export' if data.localized else 'Review') }} 46 | {{ __ns('apostrophe', 'Revert Draft') }} 47 |
52 |
53 |
54 | 55 | {%- endblock -%} 56 | -------------------------------------------------------------------------------- /views/locale-picker-modal.html: -------------------------------------------------------------------------------- 1 | {%- macro picker(localizations, nestedLocales, crossDomainSessionToken) -%} 2 | 33 | {%- endmacro -%} 34 | 35 | {%- extends "apostrophe-modal:base.html" -%} 36 | {%- import "apostrophe-modal:macros.html" as modals -%} 37 | {%- import "apostrophe-ui:components/buttons.html" as buttons with context -%} 38 | 39 | {%- block modalClass -%} 40 | apos-workflow-locale-picker-modal apos-ui-modal-no-sidebar 41 | {%- endblock -%} 42 | 43 | {%- block controls -%} 44 | {{ buttons.minor('Cancel', { action: 'cancel' }) }} 45 | {%- endblock -%} 46 | 47 | {%- block label -%} 48 | {{ __ns('apostrophe', 'Switch Locales') }} 49 | {%- endblock -%} 50 | 51 | {% block instructions %} 52 |

53 | {{ __ns('apostrophe', 'Click on any locale to switch to viewing it.') }} 54 |

55 | {% endblock %} 56 | 57 | {%- block body -%} 58 |
59 | {{ picker(data.localizations, data.nestedLocales, data.crossDomainSessionToken) }} 60 |
61 | {%- endblock -%} 62 | 63 | {%- block footerContainer -%}{%- endblock -%} 64 | -------------------------------------------------------------------------------- /views/locale-picker.html: -------------------------------------------------------------------------------- 1 | {# This is an example of a public locale picker. It displays only public locales, #} 2 | {# in a flat list based on the original nested tree intended for editors. #} 3 | {# It is expected that you will override this template or write your own. #} 4 | 5 |