diff --git a/package-lock.json b/package-lock.json index d42101a6df..f58d07e913 100644 --- a/package-lock.json +++ b/package-lock.json @@ -709,8 +709,8 @@ }, "@types/moment": { "version": "2.13.0", - "resolved": false, - "integrity": "sha1-YE69GJvDvDShVIaJQE5hoqSqyJY=", + "resolved": "https://registry.npmjs.org/@types/moment/-/moment-2.13.0.tgz", + "integrity": "sha512-DyuyYGpV6r+4Z1bUznLi/Y7HpGn4iQ4IVcGn8zrr1P4KotKLdH0sbK1TFR6RGyX6B+G8u83wCzL+bpawKU/hdQ==", "dev": true, "requires": { "moment": "*" @@ -6876,8 +6876,8 @@ }, "grunt-karma": { "version": "2.0.0", - "resolved": false, - "integrity": "sha1-dTWD0RXf3AVf5X5Y+W1rPH5hIRg=", + "resolved": "https://registry.npmjs.org/grunt-karma/-/grunt-karma-2.0.0.tgz", + "integrity": "sha512-/5plsdrES8dWrGhg33Q7AiYU1PUHXtMcZLP2pAppUJJKNmCpiGZXpVfHZ7KO19buVxb555UFbfhhbY7FccXH4g==", "dev": true, "requires": { "lodash": "^3.10.1" @@ -6886,7 +6886,7 @@ "lodash": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz", - "integrity": "sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y=", + "integrity": "sha512-9mDDwqVIma6OZX79ZlDACZl8sBm0TEnkf99zV3iMA4GzkIT/9hiqP5mY0HoT1iNLCrKc/R1HByV+yJfRWVJryQ==", "dev": true } } @@ -8967,8 +8967,8 @@ }, "jasmine-core": { "version": "2.99.1", - "resolved": false, - "integrity": "sha1-5kAN8ea1bhMLYcS80JPap/boyhU=", + "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-2.99.1.tgz", + "integrity": "sha512-ra97U4qu3OCcIxvN6eg3kyy8bLrID/TgxafSGMMICg3SFx5C/sUfDPpiOh7yoIsHdtjrOVdtT9rieYhqOsh9Ww==", "dev": true }, "jit-grunt": { @@ -9563,14 +9563,14 @@ }, "karma-jasmine": { "version": "1.1.2", - "resolved": false, - "integrity": "sha1-OU8rJf+0pkS5rabyLUQ+L9CIhsM=", + "resolved": "https://registry.npmjs.org/karma-jasmine/-/karma-jasmine-1.1.2.tgz", + "integrity": "sha512-SENGE9DhlIIFTSZWiNq4eGeXL8G6z9cqHIOdkx9jh1qhhQqwEy3tAoLRyER0vOcHqdOlKmGpOuXk+HOipIy7sg==", "dev": true }, "karma-ng-html2js-preprocessor": { "version": "1.0.0", - "resolved": false, - "integrity": "sha1-ENjIz6pBNvHIp22RpMvO7evsSjE=", + "resolved": "https://registry.npmjs.org/karma-ng-html2js-preprocessor/-/karma-ng-html2js-preprocessor-1.0.0.tgz", + "integrity": "sha512-Ho7VxRglFeIsRDimaC0ZFdwb4P5FS3o36o4PpuEAkIdhb6BrucGGpUuVWEB4KQIOtZISJaN6eTcXTfCX3kbEHw==", "dev": true }, "karma-sourcemap-loader": { @@ -13320,8 +13320,8 @@ }, "react-addons-test-utils": { "version": "15.6.2", - "resolved": false, - "integrity": "sha1-wStu/cIkfBDae4dw0YUICnsEcVY=", + "resolved": "https://registry.npmjs.org/react-addons-test-utils/-/react-addons-test-utils-15.6.2.tgz", + "integrity": "sha512-6IUCnLp7jQRBftm2anf8rP8W+8M2PsC7GPyMFe2Wef3Wfml7j2KybVL//Ty7bRDBqLh8AG4m/zNZbFlwulldFw==", "dev": true }, "react-autocomplete": { @@ -16867,7 +16867,7 @@ "core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==" } } }, diff --git a/scripts/api/templates.ts b/scripts/api/templates.ts index bbc588bd21..4d57bb6cda 100644 --- a/scripts/api/templates.ts +++ b/scripts/api/templates.ts @@ -1,10 +1,109 @@ +import { + applyMiddleware, + canEdit, + cleanData, + prepareData, + willCreateNew, +} from 'apps/authoring-react/toolbar/template-helpers'; +import {httpRequestJsonLocal} from 'core/helpers/network'; import ng from 'core/services/ng'; -import {ITemplate} from 'superdesk-api'; +import {clone} from 'lodash'; +import {IArticle, IDesk, ITemplate} from 'superdesk-api'; function getById(id: ITemplate['_id']): Promise<ITemplate> { - return ng.get('templates').find(id); + return httpRequestJsonLocal<ITemplate>({ + method: 'GET', + path: `/content_templates/${id}`, + }); +} + +function createTemplate(payload) { + return httpRequestJsonLocal<ITemplate>({ + method: 'POST', + path: '/content_templates', + payload, + }); +} + +function updateTemplate(payload, template: ITemplate) { + return httpRequestJsonLocal<ITemplate>({ + method: 'PATCH', + path: `/content_templates/${template._id}`, + payload, + headers: {'If-Match': template._etag}, + }); +} + +/** + * Creates or updates a template. If the article has an existing template it will be updated. + * + * @templateName - template name from the form input + * @selectedDeskId - deskId selected in the form + */ +function createTemplateFromArticle( + // The new template will be based on this article + sourceArticle: IArticle, + templateName: string, + selectedDeskId: IDesk['_id'] | null, +): Promise<ITemplate> { + return getById(sourceArticle.template).then((resultTemplate) => { + const data = prepareData(resultTemplate); + const deskId = selectedDeskId || data.desk; + const templateArticle = data.template; + const item: IArticle = clone(ng.get('templates').pickItemData(sourceArticle)); + const userId = ng.get('session').identity._id; + + return applyMiddleware(item).then((itemAfterMiddleware) => { + const newTemplate: Partial<ITemplate> = { + template_name: templateName, + template_type: 'create', + template_desks: selectedDeskId != null ? [deskId] : null, + is_public: templateArticle.is_public, + data: itemAfterMiddleware, + }; + + let templateTemp: Partial<ITemplate> = templateArticle != null ? templateArticle : newTemplate; + let diff = templateArticle != null ? newTemplate : null; + + if (willCreateNew(templateArticle, templateName, selectedDeskId != null)) { + templateTemp = newTemplate; + diff = null; + + if (!canEdit(templateArticle, selectedDeskId != null)) { + templateTemp.is_public = false; + templateTemp.user = userId; + templateTemp.template_desks = null; + } + } + + const hasLinks = templateTemp._links != null; + const payload: Partial<ITemplate> = diff != null ? cleanData(diff) : cleanData(templateTemp); + + // if the template is made private, set the current user as template owner + if (templateArticle.is_public && (diff?.is_public === false || templateTemp.is_public === false)) { + payload.user = userId; + } + + const requestPayload: Partial<ITemplate> = { + ...payload, + data: cleanData<IArticle>(sourceArticle), + }; + + return (hasLinks + ? updateTemplate(requestPayload, resultTemplate) + : createTemplate(requestPayload) + ) + .then((_data) => { + return _data; + }, (response) => { + return Promise.reject(response); + }); + }); + }); } export const templates = { getById, + createTemplateFromArticle, + prepareData, }; diff --git a/scripts/apps/authoring-react/authoring-integration-wrapper.tsx b/scripts/apps/authoring-react/authoring-integration-wrapper.tsx index bea368ab5b..1c47c1d1d3 100644 --- a/scripts/apps/authoring-react/authoring-integration-wrapper.tsx +++ b/scripts/apps/authoring-react/authoring-integration-wrapper.tsx @@ -33,6 +33,7 @@ import {CreatedModifiedInfo} from './subcomponents/created-modified-info'; import {dispatchInternalEvent} from 'core/internal-events'; import {IArticleActionInteractive} from 'core/interactive-article-actions-panel/interfaces'; import {ARTICLE_RELATED_RESOURCE_NAMES} from 'core/constants'; +import {TemplateModal} from './toolbar/template-modal'; import {IProps} from './authoring-angular-integration'; import {showModal} from '@superdesk/common'; import ExportModal from './toolbar/export-modal'; @@ -222,6 +223,20 @@ export class AuthoringIntegrationWrapper extends React.PureComponent<IPropsWrapp (Component) => (props: {item: IArticle}) => <Component article={props.item} />, ); + const saveAsTemplate = (item: IArticle): IAuthoringAction => ({ + label: gettext('Save as template'), + onTrigger: () => ( + showModal(({closeModal}) => { + return ( + <TemplateModal + closeModal={closeModal} + item={item} + /> + ); + }) + ), + }); + return ( <WithInteractiveArticleActionsPanel location="authoring"> {(panelState, panelActions) => { @@ -269,6 +284,7 @@ export class AuthoringIntegrationWrapper extends React.PureComponent<IPropsWrapp const [authoringActionsFromExtensions, articleActionsFromExtensions] = res; return [ + saveAsTemplate(item), getExportModal(getLatestItem, handleUnsavedChanges, hasUnsavedChanges), ...authoringActionsFromExtensions, ...articleActionsFromExtensions, diff --git a/scripts/apps/authoring-react/toolbar/template-helpers.ts b/scripts/apps/authoring-react/toolbar/template-helpers.ts new file mode 100644 index 0000000000..b4800a4f5a --- /dev/null +++ b/scripts/apps/authoring-react/toolbar/template-helpers.ts @@ -0,0 +1,119 @@ +import ng from 'core/services/ng'; +import {extensions} from 'appConfig'; +import {IArticle, ICustomFieldType, ITemplate, IVocabulary} from 'superdesk-api'; + +export function applyMiddleware(_item: IArticle): Promise<IArticle> { + // Custom field types with `onTemplateCreate` defined. From all extensions. + const fieldTypes: {[id: string]: ICustomFieldType<any, any, any, any>} = {}; + + Object.values(extensions).forEach((ext) => { + ext?.activationResult?.contributions?.customFieldTypes?.forEach( + (customField: ICustomFieldType<any, any, any, any>) => { + if (customField.onTemplateCreate != null) { + fieldTypes[customField.id] = customField; + } + }, + ); + }); + + return ng.get('vocabularies').getVocabularies().then((_vocabularies: Array<IVocabulary>) => { + const fakeScope: any = {}; + + return ng.get('content').setupAuthoring(_item.profile, fakeScope, _item).then(() => { + let itemNext: IArticle = {..._item}; + + for (const fieldId of Object.keys(fakeScope.editor)) { + const vocabulary = _vocabularies.find(({_id}) => _id === fieldId); + + if (vocabulary != null && fieldTypes[vocabulary.custom_field_type] != null) { + const config = vocabulary.custom_field_config ?? {}; + const customField = fieldTypes[vocabulary.custom_field_type]; + + itemNext = { + ...itemNext, + extra: { + ...itemNext.extra, + [fieldId]: customField.onTemplateCreate( + itemNext?.extra?.[fieldId], + config, + ), + }, + }; + } + } + + return itemNext; + }); + }); +} + +export function prepareData(template: ITemplate) { + return { + name: template.template_name, + desk: template.template_desks != null ? template.template_desks[0] : null, + template, + }; +} + +export function cleanData<T>(data: Partial<T>): Partial<T> { + [ + '_type', + '_status', + '_updated', + '_created', + '_etag', + '_links', + '_id', + '_current_version', + '_etag', + '_links', + 'expiry', + 'lock_user', + 'original_id', + 'schedule_settings', + 'semantics', + '_autosave', + '_editable', + '_latest_version', + '_locked', + 'time_zone', + ].forEach((field) => { + delete data[field]; + }); + + return data; +} + +/** + * Determines whether the template will be overwritten or a new one will be created. + * The is_public parameter is needed because we get it from user input and not from the fetched template. + * is_public is set from the user - if the template is made as a desk template then is_public is true. + */ +export function canEdit(template: ITemplate, isPublic: boolean): boolean { + const privileges = ng.get('privileges'); + + if (template == null) { + return false; + } else if (template?.is_public && !isPublic) { + // if template is changed from public to private, always + // create a copy of a template and don't modify the original one. + return false; + } else if (isPublic) { + return privileges.userHasPrivileges({content_templates: 1}); + } else if (template?.user === ng.get('session').identity._id) { + return true; + } else { + return privileges.userHasPrivileges({personal_template: 1}); // can edit templates of other users + } +} + +export const wasRenamed = ( + template: ITemplate, + templateName: string, +) => template != null && templateName !== template.template_name; + +export const willCreateNew = ( + template: ITemplate, + templateName: string, + isPublic: boolean, +) => template == null || wasRenamed(template, templateName) || canEdit(template, isPublic) !== true; diff --git a/scripts/apps/authoring-react/toolbar/template-modal.tsx b/scripts/apps/authoring-react/toolbar/template-modal.tsx new file mode 100644 index 0000000000..c34191c292 --- /dev/null +++ b/scripts/apps/authoring-react/toolbar/template-modal.tsx @@ -0,0 +1,184 @@ +import {sdApi} from 'api'; +import {Spacer} from 'core/ui/components/Spacer'; +import {gettext} from 'core/utils'; +import React from 'react'; +import {IArticle, ITemplate} from 'superdesk-api'; +import {Alert, Button, Checkbox, Input, Modal, Option, Select} from 'superdesk-ui-framework/react'; +import {canEdit, wasRenamed} from './template-helpers'; + +interface IProps { + item: IArticle; + closeModal: () => void; +} + +interface IStateLoading { + initialized: false; +} + +interface IStateLoaded { + initialized: true; + templateName: string | null; + isDeskTemplate: boolean; + responseError: string | null; + deskId: string | null; + template: ITemplate | null; +} + +type IState = IStateLoaded | IStateLoading; + +export class TemplateModal extends React.PureComponent<IProps, IState> { + constructor(props: IProps) { + super(props); + + this.state = { + initialized: false, + }; + } + + componentDidMount(): void { + sdApi.templates.getById(this.props.item.template).then((res) => { + this.setState({ + initialized: true, + template: res, + templateName: res.template_name, + isDeskTemplate: true, + responseError: null, + deskId: res.template_desks[0], + }); + }); + } + + render(): JSX.Element { + const availableDesks = sdApi.desks.getAllDesks().toArray(); + + if (!this.state.initialized) { + return null; + } + + const state = this.state; + + return ( + <Modal + visible + onHide={() => this.props.closeModal()} + size="medium" + zIndex={1050} + headerTemplate={gettext('Save as template')} + > + <Spacer v gap="16"> + <Input + type="text" + label={gettext('Template name')} + value={state.templateName} + onChange={(value) => this.setState({ + ...state, + templateName: value, + })} + /> + { + state.responseError != null && ( + <Alert + margin="none" + size="small" + type="alert" + > + {state.responseError} + </Alert> + ) + } + { + /** + * A new template will be created: + * - if the input template name differs from the initially fetched template name + * - if the initially fetched template from the article is null + * - if the initially fetched template from the article can't be edited + * + * Else the existing template will be updated + */ + wasRenamed(state.template, state.templateName) + || state.template == null + || canEdit(state.template, state.deskId != null) !== true + ? ( + <Alert + margin="none" + size="small" + type="warning" + style="hollow" + > + {gettext('A new template will be created')} + </Alert> + ) + : ( + <Alert + margin="none" + size="small" + type="warning" + style="hollow" + > + {gettext('Template will be updated')} + </Alert> + ) + } + { + availableDesks != null && state.template.is_public && + ( + <> + <Checkbox + label={{text: gettext('Desk template')}} + checked={state.isDeskTemplate} + onChange={() => this.setState({ + ...state, + deskId: state.isDeskTemplate ? null : state.deskId, + isDeskTemplate: !state.isDeskTemplate, + })} + /> + { + state.isDeskTemplate && ( + <Select + label={gettext('Desks')} + value={state.deskId} + onChange={(value) => { + this.setState({...state, deskId: value}); + }} + > + <Option /> + { + availableDesks.map(({_id, name}) => ( + <Option key={_id} value={_id}>{name}</Option> + )) + } + </Select> + ) + } + </> + ) + } + <Spacer h gap="8" justifyContent="end" noGrow> + <Button + text={gettext('Cancel')} + onClick={() => this.props.closeModal()} + /> + <Button + type="primary" + text={gettext('Save')} + onClick={() => { + sdApi.templates.createTemplateFromArticle( + this.props.item, + state.templateName, + state.deskId, + ) + .then(() => { + this.props.closeModal(); + }) + .catch((error) => { + this.setState({...state, responseError: error._issues.is_public}); + }); + }} + /> + </Spacer> + + </Spacer> + </Modal> + ); + } +} diff --git a/scripts/apps/templates/controllers/CreateTemplateController.ts b/scripts/apps/templates/controllers/CreateTemplateController.ts index 2fab89ac37..3be60e02ce 100644 --- a/scripts/apps/templates/controllers/CreateTemplateController.ts +++ b/scripts/apps/templates/controllers/CreateTemplateController.ts @@ -1,57 +1,6 @@ import notifySaveError from '../helpers'; -import {extensions} from 'appConfig'; -import {IArticle, ICustomFieldType, IVocabulary} from 'superdesk-api'; - -/** - * Iterate content profile fields. - * Check if field is a custom field from extension. - * If it is, run `onTemplateCreate` middleware on it - * and update the value. - */ -function applyMiddleware(_item: IArticle, content, vocabularies): Promise<IArticle> { - // Custom field types with `onTemplateCreate` defined. From all extensions. - const fieldTypes: {[id: string]: ICustomFieldType<any, any, any, any>} = {}; - - Object.values(extensions).forEach((ext) => { - ext?.activationResult?.contributions?.customFieldTypes?.forEach( - (customField: ICustomFieldType<any, any, any, any>) => { - if (customField.onTemplateCreate != null) { - fieldTypes[customField.id] = customField; - } - }, - ); - }); - - return vocabularies.getVocabularies().then((_vocabularies: Array<IVocabulary>) => { - const fakeScope: any = {}; - - return content.setupAuthoring(_item.profile, fakeScope, _item).then(() => { - let itemNext: IArticle = {..._item}; - - for (const fieldId of Object.keys(fakeScope.editor)) { - const vocabulary = _vocabularies.find(({_id}) => _id === fieldId); - - if (vocabulary != null && fieldTypes[vocabulary.custom_field_type] != null) { - const config = vocabulary.custom_field_config ?? {}; - const customField = fieldTypes[vocabulary.custom_field_type]; - - itemNext = { - ...itemNext, - extra: { - ...itemNext.extra, - [fieldId]: customField.onTemplateCreate( - itemNext?.extra?.[fieldId], - config, - ), - }, - }; - } - } - - return itemNext; - }); - }); -} +import {sdApi} from 'api'; +import {willCreateNew} from 'apps/authoring-react/toolbar/template-helpers'; CreateTemplateController.$inject = [ 'item', @@ -74,10 +23,6 @@ export function CreateTemplateController( $q, notify, _, - privileges, - session, - content, - vocabularies, ) { var self = this; @@ -95,10 +40,12 @@ export function CreateTemplateController( function activate() { if (item.template) { api.find('content_templates', item.template).then((template) => { - self.name = template.template_name; - self.desk = !_.isNil(template.template_desks) ? template.template_desks[0] : null; - self.is_public = template.is_public !== false; - self.template = template; + const data = sdApi.templates.prepareData(template); + + self.name = data.template.template_name; + self.desk = data.desk; + self.is_public = data.template.is_public; + self.template = data.template; }); } @@ -107,74 +54,18 @@ export function CreateTemplateController( }); } - self.canEdit = () => { - if (self.template == null) { - return false; // no template exists yet - } else if (self.template?.is_public === true && self.is_public === false) { - // if template is changed from public to private, always create a copy of a template - // and don't modify the original one. - return false; - } else if (self.is_public === true) { - return privileges.userHasPrivileges({content_templates: 1}); - } else if (self.template?.user === session.identity._id) { - return true; // can always edit own templates - } else { - return privileges.userHasPrivileges({personal_template: 1}); // can edit templates of other users - } - }; - - self.wasRenamed = () => { - return self.template != null && self.name !== self.template.template_name; - }; - - self.willCreateNew = () => - self.template == null // no template exists yet - || self.wasRenamed() - || self.canEdit() !== true; + self.willCreateNew = () => willCreateNew(self.template, self.name, self.is_public); function save() { - const _item: IArticle = JSON.parse(JSON.stringify(templates.pickItemData(item))); - const sessionId = session.identity._id; - - return applyMiddleware(_item, content, vocabularies).then((itemAfterMiddleware) => { - var data = { - template_name: self.name, - template_type: self.type, - template_desks: self.is_public ? [self.desk] : null, - is_public: self.is_public, - data: itemAfterMiddleware, - }; - - var template = self.template ? self.template : data; - var diff: any = self.template ? data : null; - - // in case there is old template but user renames it - // or user is not allowed to edit it - create a new one - if (self.willCreateNew()) { - template = data; - diff = null; - - if (self.canEdit() !== true) { - template.is_public = false; - template.user = sessionId; - template.template_desks = null; - } - } - - // if template is made private, set current user as template owner - if (template.is_public === true && diff?.is_public === false) { - diff.user = sessionId; - } - - return api.save('content_templates', template, diff) - .then((_data) => { - self._issues = null; - return _data; - }, (response) => { - notifySaveError(response, notify); - self._issues = response.data._issues; - return $q.reject(self._issues); - }); - }); + return sdApi.templates.createTemplateFromArticle(item, self.name, self.is_public ? self.desk : null) + .then((data) => { + self._issues = null; + return data; + }) + .catch((error) => { + notifySaveError({data: error}, notify); + self._issues = error._issues; + return $q.reject(self._issues); + }); } } diff --git a/scripts/core/superdesk-api.d.ts b/scripts/core/superdesk-api.d.ts index a9f8ffe572..a139af30fc 100644 --- a/scripts/core/superdesk-api.d.ts +++ b/scripts/core/superdesk-api.d.ts @@ -1041,7 +1041,7 @@ declare module 'superdesk-api' { } }; version: any; - template: any; + template: ITemplate['_id']; original_creator: string; unique_id: any; operation: any; @@ -3061,14 +3061,14 @@ declare module 'superdesk-api' { } export interface ITemplate extends IBaseRestApiResponse { - data: IArticle, - is_public: boolean, + data: Partial<IArticle>; + is_public: boolean; next_run?: any; schedule?: any; - template_desks: Array<IDesk['_id']>, - template_name: string, - template_type: 'create' | 'kill' | string, - user: IUser['_id'] + template_desks: Array<IDesk['_id']>; + template_name: string; + template_type: 'create' | 'kill' | string; + user: IUser['_id']; }