diff --git a/public/globals.js b/public/globals.js index 8a1058495..19f32e7bb 100644 --- a/public/globals.js +++ b/public/globals.js @@ -230,6 +230,7 @@ window.pkp = { 'common.loading': 'Loading', 'common.me': 'Me', 'common.moreActions': 'More Actions', + 'common.months': 'months', 'common.name': 'Name', 'common.navigation.user': 'User Navigation', 'common.new': 'New', @@ -243,6 +244,8 @@ window.pkp = { 'common.orderDown': 'Decrease position of {$itemTitle}', 'common.orderUp': 'Increase position of {$itemTitle}', 'common.overdue': 'Overdue', + 'common.oneWeek': '1 week', + 'common.oneMonth': '1 month', 'common.pageNumber': 'Page {$pageNumber}', 'common.pagination.goToPage': 'Go to {$page}', 'common.pagination.label': 'View additional pages', @@ -287,6 +290,7 @@ window.pkp = { 'common.inProgress': 'In Progress', 'common.closed': 'Closed', 'common.warning': 'Warning', + 'common.weeks': 'weeks', 'common.confirmUnsavedChanges': 'You have unsaved changes. Are you sure you want to cancel?', 'context.context': 'Journal', @@ -415,7 +419,7 @@ window.pkp = { 'discussion.form.detailsDescription': 'Use this space to share essential information.', 'discussion.form.detailsNameDescription': - 'Please enter the name for this task and discussion.', + 'Please enter the name for the task and discussion.', 'discussion.form.detailsParticipantsDescription': 'You have the option to assign participants or allocate it solely to yourself.', 'discussion.form.discussionDescription': @@ -539,6 +543,7 @@ window.pkp = { 'Are you sure you want to change to {$localeName} to compose this email? Any changes you have made to the subject and body of the email will be lost.', 'email.email': 'Email', 'email.subject': 'Subject', + 'email.body': 'Body', 'email.to': 'To', 'fileManager.copyeditedFiles': 'Copyedited Files', 'fileManager.copyeditedFilesDescription': @@ -823,6 +828,10 @@ window.pkp = { 'reviewerManager.reviewerStatus': 'Reviewer status', 'search.searchResults': 'Search Results', semicolon: '{$label}:', + 'stage.submission': 'Submission Stage', + 'stage.review': 'Review Stage', + 'stage.copyediting': 'Copyediting Stage', + 'stage.production': 'Production Stage', 'stageParticipants.notify.message': 'Message', 'stats.context.downloadReport.description': 'Download a CSV/Excel spreadsheet with usage statistics for this journal matching the following parameters.', @@ -925,7 +934,26 @@ window.pkp = { 'taskTemplate.apply': 'Apply Template', 'taskTemplate.applyConfirmation': "Applying this template will replace data in related fields on the form. These changes won't be saved unless you choose to save. Continue?", - + 'taskTemplates.title': 'Tasks and Discussions Templates', + 'taskTemplates.description': + 'Use this space to create templates for tasks and discussions. These templates automatically fill in the task name, due date, description, and roles, giving you a head start.', + 'taskTemplates.templateName': 'Task and discussion template name', + 'taskTemplates.templateAutoAdd': + 'Automatically add this task and discussion when a submission reaches a specific stage', + 'taskTemplates.templateAutoAddInStage': + 'Automatically add this task and/or discussion when a submission reaches the stage', + 'taskTemplates.add': 'Add template', + 'taskTemplates.addInStage': 'Add Task and Discussion Template in {$stage}', + 'taskTemplates.confirmAutoAdd': 'Confirm Automatic Addition', + 'taskTemplates.confirmAutoAddEnable': + 'Are you sure you want this task/discussion template to be automatically added when a submission reaches the {$stage}?', + 'taskTemplates.confirmAutoAddDisable': + 'Are you sure you want to stop automatically adding this task/discussion template when a submission reaches the {$stage}?', + 'taskTemplates.confirmEmailTemplate': + 'Applying this email template will replace the discussion text in the form. The changes will not be saved unless you choose to save. Do you want to continue?', + 'taskTemplates.edit': 'Edit Task and Discussion Template', + 'taskTemplates.dueDateFromCreationDate': + '{$dueDate} from the creation date', 'task.closeThisTask': 'Close this Task', 'task.startedBy': 'Task started by', 'task.startThisTask': 'Start this task', diff --git a/src/components/Container/SettingsPage.vue b/src/components/Container/SettingsPage.vue index d8b9c3c44..ef4f2c8b6 100644 --- a/src/components/Container/SettingsPage.vue +++ b/src/components/Container/SettingsPage.vue @@ -9,6 +9,7 @@ import DateTimeForm from '@/components/Form/context/DateTimeForm.vue'; import DoiSetupSettingsForm from '@/components/Form/context/DoiSetupSettingsForm.vue'; import DoiRegistrationSettingsForm from '@/components/Form/context/DoiRegistrationSettingsForm.vue'; import ReviewerRecommendationManager from '@/managers/ReviewerRecommendationManager/ReviewerRecommendationManager.vue'; +import TaskTemplateManager from '@/managers/TaskTemplateManager/TaskTemplateManager.vue'; export default { name: 'SettingsPage', @@ -22,6 +23,7 @@ export default { DoiSetupSettingsForm, DoiRegistrationSettingsForm, ReviewerRecommendationManager, + TaskTemplateManager, }, extends: Page, data() { diff --git a/src/components/Table/TableColGroup.vue b/src/components/Table/TableColGroup.vue index a6e7067f8..20bc42e1d 100644 --- a/src/components/Table/TableColGroup.vue +++ b/src/components/Table/TableColGroup.vue @@ -3,9 +3,15 @@ :id="groupId" :colspan="tableContext.columnsCount.value" scope="colgroup" - class="whitespace-nowrap border-b border-light bg-tertiary px-2 py-4 text-start text-lg-medium text-secondary first:border-s first:ps-3 last:border-e last:pe-3" + class="whitespace-nowrap border-b border-light bg-tertiary p-2 text-start text-lg-medium text-secondary first:border-s first:ps-3 last:border-e last:pe-3" > - +
+ + +
+ +
+
diff --git a/src/managers/DiscussionManager/DiscussionManager.vue b/src/managers/DiscussionManager/DiscussionManager.vue index 28fd24786..85df61454 100644 --- a/src/managers/DiscussionManager/DiscussionManager.vue +++ b/src/managers/DiscussionManager/DiscussionManager.vue @@ -44,7 +44,7 @@ " > - + - template.taskDetails?.participantRoles?.includes(p.roleId), - ) + .filter((p) => template.participantRoles?.includes(p.roleId)) .map((p) => p.id) || []; setValue('participants', selectedParticipants); @@ -217,11 +215,8 @@ export function useDiscussionManagerForm( if (isTask.value) { setValue('taskInfoAssignee', selectedParticipants); - if (template.taskDetails.dueDate) { - setValue( - 'taskInfoDueDate', - getRelativeTargetDate(template.taskDetails.dueDate), - ); + if (template.dueDate) { + setValue('taskInfoDueDate', getRelativeTargetDate(template.dueDate)); } } else { setValue('taskInfoDueDate', null); diff --git a/src/managers/TaskTemplateManager/TaskTemplateManager.mdx b/src/managers/TaskTemplateManager/TaskTemplateManager.mdx new file mode 100644 index 000000000..f7baf1eaf --- /dev/null +++ b/src/managers/TaskTemplateManager/TaskTemplateManager.mdx @@ -0,0 +1,17 @@ +import { + Primary, + Controls, + Stories, + Meta, + ArgTypes, +} from '@storybook/addon-docs/blocks'; + +import * as TaskTemplateManager from './TaskTemplateManager.stories.js'; + + + +# Tasks and Discussions Templates + +This component displays the tasks and discussions templates in the settings area. + + diff --git a/src/managers/TaskTemplateManager/TaskTemplateManager.stories.js b/src/managers/TaskTemplateManager/TaskTemplateManager.stories.js new file mode 100644 index 000000000..d3fed6c81 --- /dev/null +++ b/src/managers/TaskTemplateManager/TaskTemplateManager.stories.js @@ -0,0 +1,147 @@ +import {within, userEvent} from 'storybook/test'; +import {http, HttpResponse} from 'msw'; +import TaskTemplateManager from './TaskTemplateManager.vue'; +import { + TemplatesDataMock, + getTemplate, +} from '@/mockFactories/taskDiscussionTemplates'; +import {emailTemplateMock} from '@/mockFactories/emailTemplateMock'; + +export default { + title: 'Managers/TaskTemplateManager', + component: TaskTemplateManager, +}; + +const baseArgs = { + templates: [ + ...TemplatesDataMock, + getTemplate({ + id: 4, + name: 'Ethical Approval', + stageId: 'Submission', + participantRoles: [65536], + dueDate: 'P3M', + }), + getTemplate({ + id: 5, + name: 'Adherence to Policy and Guidelines', + stageId: 'Submission', + autoAdd: false, + }), + getTemplate({id: 6, name: 'Language Review', stageId: 'Submission'}), + getTemplate({ + id: 7, + name: 'Analysis of the Method', + stageId: 'Submission', + autoAdd: false, + }), + getTemplate({id: 8, name: 'Lorem ipsum dolor sit amet', stageId: 'Review'}), + getTemplate({ + id: 9, + name: 'Consectetur adipiscing elit', + stageId: 'Review', + autoAdd: false, + }), + getTemplate({ + id: 10, + name: 'Sed do eiusmod tempor incididunt ut', + stageId: 'Copyediting', + autoAdd: false, + }), + getTemplate({ + id: 11, + name: 'labore et dolore magna aliqua', + stageId: 'Production', + }), + getTemplate({ + id: 12, + name: 'Ut enim ad minim veniam', + stageId: 'Production', + autoAdd: false, + }), + getTemplate({ + id: 13, + name: 'Quis nostrud exercitation ullamco', + stageId: 'Production', + autoAdd: false, + }), + ], +}; + +const renderComponent = (args) => ({ + components: {TaskTemplateManager}, + setup() { + return {args}; + }, + template: ``, +}); + +const mswHandlers = [ + http.get('https://mock/index.php/publicknowledge/api/v1/templates', () => { + return HttpResponse.json(baseArgs.templates); + }), + http.get( + 'https://mock/index.php/publicknowledge/api/v1/mailables/DISCUSSION_NOTIFICATION_SUBMISSION', + () => { + return HttpResponse.json( + emailTemplateMock['DISCUSSION_NOTIFICATION_SUBMISSION'], + ); + }, + ), + http.get( + 'https://mock/index.php/publicknowledge/api/v1/mailables/DISCUSSION_NOTIFICATION_REVIEW', + () => { + return HttpResponse.json( + emailTemplateMock['DISCUSSION_NOTIFICATION_REVIEW'], + ); + }, + ), + http.get( + 'https://mock/index.php/publicknowledge/api/v1/mailables/DISCUSSION_NOTIFICATION_COPYEDITING', + () => { + return HttpResponse.json( + emailTemplateMock['DISCUSSION_NOTIFICATION_COPYEDITING'], + ); + }, + ), + + http.get( + 'https://mock/index.php/publicknowledge/api/v1/mailables/DISCUSSION_NOTIFICATION_PRODUCTION', + () => { + return HttpResponse.json( + emailTemplateMock['DISCUSSION_NOTIFICATION_PRODUCTION'], + ); + }, + ), +]; + +export const Default = { + render: renderComponent, + args: baseArgs, + parameters: { + msw: { + handlers: mswHandlers, + }, + }, +}; + +export const AddNewTemplate = { + render: renderComponent, + args: baseArgs, + parameters: { + msw: { + handlers: mswHandlers, + }, + }, + play: async ({canvasElement}) => { + // Assigns canvas to the component root element + const canvas = within(canvasElement); + const user = userEvent.setup(); + + await user.click( + within(canvas.getByText('Submission Stage').closest('th')).getByText( + 'Add template', + ), + ); + }, +}; diff --git a/src/managers/TaskTemplateManager/TaskTemplateManager.vue b/src/managers/TaskTemplateManager/TaskTemplateManager.vue new file mode 100644 index 000000000..257f6ef86 --- /dev/null +++ b/src/managers/TaskTemplateManager/TaskTemplateManager.vue @@ -0,0 +1,85 @@ + + diff --git a/src/managers/TaskTemplateManager/TaskTemplateManagerCellActions.vue b/src/managers/TaskTemplateManager/TaskTemplateManagerCellActions.vue new file mode 100644 index 000000000..c668ce3fa --- /dev/null +++ b/src/managers/TaskTemplateManager/TaskTemplateManagerCellActions.vue @@ -0,0 +1,30 @@ + + diff --git a/src/managers/TaskTemplateManager/TaskTemplateManagerCellAutoAdd.vue b/src/managers/TaskTemplateManager/TaskTemplateManagerCellAutoAdd.vue new file mode 100644 index 000000000..7175c137b --- /dev/null +++ b/src/managers/TaskTemplateManager/TaskTemplateManagerCellAutoAdd.vue @@ -0,0 +1,36 @@ + + + diff --git a/src/managers/TaskTemplateManager/TaskTemplateManagerCellName.vue b/src/managers/TaskTemplateManager/TaskTemplateManagerCellName.vue new file mode 100644 index 000000000..e4fc5159f --- /dev/null +++ b/src/managers/TaskTemplateManager/TaskTemplateManagerCellName.vue @@ -0,0 +1,15 @@ + + + diff --git a/src/managers/TaskTemplateManager/TaskTemplateManagerEmails.vue b/src/managers/TaskTemplateManager/TaskTemplateManagerEmails.vue new file mode 100644 index 000000000..133e57614 --- /dev/null +++ b/src/managers/TaskTemplateManager/TaskTemplateManagerEmails.vue @@ -0,0 +1,75 @@ + + + diff --git a/src/managers/TaskTemplateManager/TaskTemplateManagerForm.vue b/src/managers/TaskTemplateManager/TaskTemplateManagerForm.vue new file mode 100644 index 000000000..7c674d1c3 --- /dev/null +++ b/src/managers/TaskTemplateManager/TaskTemplateManagerForm.vue @@ -0,0 +1,43 @@ + + + diff --git a/src/managers/TaskTemplateManager/taskTemplateManagerStore.js b/src/managers/TaskTemplateManager/taskTemplateManagerStore.js new file mode 100644 index 000000000..fe123c9d1 --- /dev/null +++ b/src/managers/TaskTemplateManager/taskTemplateManagerStore.js @@ -0,0 +1,152 @@ +import {defineComponentStore} from '@/utils/defineComponentStore'; + +import {computed, watch} from 'vue'; + +import {t} from '@/utils/i18n'; +import {useFetch} from '@/composables/useFetch'; +import {useUrl} from '@/composables/useUrl'; +import {useDataChanged} from '@/composables/useDataChanged'; +import {useExtender} from '@/composables/useExtender'; + +import {useTaskTemplateManagerConfig} from './useTaskTemplateManagerConfig'; +import {useTaskTemplateActions} from './useTaskTemplateManagerActions'; + +export const useTaskTemplateManagerStore = defineComponentStore( + 'taskTemplateManager', + () => { + const extender = useExtender(); + + const relativeUrl = computed(() => { + return `templates`; + }); + + const {apiUrl: templatesApiUrl} = useUrl(relativeUrl); + + const {data: templatesList, fetch: fetchTemplates} = + useFetch(templatesApiUrl); + + watch(relativeUrl, () => { + fetchTemplates({clearData: true}); + }); + + fetchTemplates(); + + const {triggerDataChange} = useDataChanged(() => fetchTemplates()); + + function triggerDataChangeCallback() { + triggerDataChange(); + } + + function getTemplatesByStage(stageId) { + return computed( + () => + templatesList.value?.filter((data) => data.stageId === stageId) || [], + ); + } + + const stagedTemplates = [ + { + name: t('stage.submission'), + key: 'Submission', + templates: getTemplatesByStage('Submission'), + }, + { + name: t('stage.review'), + key: 'Review', + templates: getTemplatesByStage('Review'), + }, + { + name: t('stage.copyediting'), + key: 'Copyediting', + templates: getTemplatesByStage('Copyediting'), + }, + { + name: t('stage.production'), + key: 'Production', + templates: getTemplatesByStage('Production'), + }, + ]; + + /** + * Actions + */ + const taskTemplateActions = useTaskTemplateActions(); + + function getActionArgs() { + return { + config: discussionConfig.value, + }; + } + + function templateAdd({stage}) { + taskTemplateActions.templateAdd( + { + stage, + }, + triggerDataChangeCallback, + ); + } + + function templateEdit({taskTemplate}) { + taskTemplateActions.templateEdit( + {taskTemplate}, + triggerDataChangeCallback, + ); + } + + function templateDelete({taskTemplate}) { + taskTemplateActions.templateDelete( + {taskTemplate}, + triggerDataChangeCallback, + ); + } + + function templateUpdateAutoAdd({taskTemplate}) { + taskTemplateActions.templateUpdateAutoAdd( + { + taskTemplate, + }, + triggerDataChangeCallback, + ); + } + + const discussionConfig = computed(() => + taskTemplateManagerConfig.getManagerConfig(), + ); + + function getItemActions({taskTemplate}) { + return taskTemplateManagerConfig.getItemActions({ + taskTemplate, + ...getActionArgs(), + }); + } + + /** Columns */ + const taskTemplateManagerConfig = extender.addFns( + useTaskTemplateManagerConfig(), + ); + const columns = computed(() => taskTemplateManagerConfig.getColumns()); + + return { + templatesList, + stagedTemplates, + triggerDataChangeCallback, + + /** + * Config + * */ + columns, + getItemActions, + + /** + * Actions + */ + templateAdd, + templateEdit, + templateDelete, + templateUpdateAutoAdd, + + extender, + }; + }, +); diff --git a/src/managers/TaskTemplateManager/useTaskTemplateManagerActions.js b/src/managers/TaskTemplateManager/useTaskTemplateManagerActions.js new file mode 100644 index 000000000..b7bb3a337 --- /dev/null +++ b/src/managers/TaskTemplateManager/useTaskTemplateManagerActions.js @@ -0,0 +1,88 @@ +import {useLocalize} from '@/composables/useLocalize'; +import {useModal} from '@/composables/useModal'; + +import TaskTemplateManagerForm from './TaskTemplateManagerForm.vue'; + +export const Actions = { + TASK_TEMPLATES_LIST: 'templateList', + TASK_TEMPLATES_ADD: 'templateAdd', + TASK_TEMPLATES_EDIT: 'templateEdit', + TASK_TEMPLATES_DELETE: 'templateDelete', +}; + +export function useTaskTemplateActions() { + const {t} = useLocalize(); + + function templateAdd({stage}, finishedCallback) { + const {openSideModal, closeSideModal} = useModal(); + + function onCloseFn() { + closeSideModal(TaskTemplateManagerForm); + } + + openSideModal( + TaskTemplateManagerForm, + { + stage, + onCloseFn, + }, + { + onClose: finishedCallback, + }, + ); + } + + function templateEdit({taskTemplate}, finishedCallback) { + const {openSideModal, closeSideModal} = useModal(); + + function onCloseFn() { + closeSideModal(TaskTemplateManagerForm); + } + + openSideModal( + TaskTemplateManagerForm, + { + taskTemplate, + onCloseFn, + }, + { + onClose: finishedCallback, + }, + ); + } + + function templateDelete({taskTemplate}, finishedCallback) { + const {openDialog} = useModal(); + openDialog({ + actions: [ + { + label: t('common.ok'), + isWarnable: true, + callback: (close) => { + close(); + }, + }, + { + label: t('common.cancel'), + callback: (close) => { + close(); + }, + }, + ], + title: t('common.delete'), + message: t('common.confirmDelete'), + modalStyle: 'negative', + }); + } + + function templateUpdateAutoAdd({taskTemplate}, finishedCallback) { + // TODO: update status + } + + return { + templateAdd, + templateEdit, + templateDelete, + templateUpdateAutoAdd, + }; +} diff --git a/src/managers/TaskTemplateManager/useTaskTemplateManagerConfig.js b/src/managers/TaskTemplateManager/useTaskTemplateManagerConfig.js new file mode 100644 index 000000000..037ceb754 --- /dev/null +++ b/src/managers/TaskTemplateManager/useTaskTemplateManagerConfig.js @@ -0,0 +1,95 @@ +import {useLocalize} from '@/composables/useLocalize'; +import {Actions} from './useTaskTemplateManagerActions'; + +export const TaskTemplateManagerConfigurations = { + permissions: [ + { + roles: [pkp.const.ROLE_ID_AUTHOR], + actions: [Actions.TASK_TEMPLATES_LIST], + }, + { + roles: [ + pkp.const.ROLE_ID_SUB_EDITOR, + pkp.const.ROLE_ID_MANAGER, + pkp.const.ROLE_ID_SITE_ADMIN, + pkp.const.ROLE_ID_ASSISTANT, + ], + actions: [ + Actions.TASK_TEMPLATES_LIST, + Actions.TASK_TEMPLATES_ADD, + Actions.TASK_TEMPLATES_EDIT, + Actions.TASK_TEMPLATES_DELETE, + ], + }, + ], + actions: [ + Actions.TASK_TEMPLATES_LIST, + Actions.TASK_TEMPLATES_ADD, + Actions.TASK_TEMPLATES_EDIT, + Actions.TASK_TEMPLATES_DELETE, + ], +}; + +export function useTaskTemplateManagerConfig() { + const {t} = useLocalize(); + + function getColumns() { + const columns = []; + + columns.push({ + header: t('taskTemplates.templateName'), + component: 'TaskTemplateManagerCellName', + }); + + columns.push({ + header: t('taskTemplates.templateAutoAdd'), + component: 'TaskTemplateManagerCellAutoAdd', + }); + + columns.push({ + header: t('common.moreActions'), + headerSrOnly: true, + component: 'TaskTemplateManagerCellActions', + }); + + return columns; + } + + function getManagerConfig() { + const permittedActions = TaskTemplateManagerConfigurations.actions.filter( + (action) => { + return TaskTemplateManagerConfigurations.permissions.some((perm) => { + return perm.actions.includes(action); + }); + }, + ); + return {permittedActions}; + } + + function getItemActions({config, workItem}) { + const actions = []; + if (config.permittedActions.includes(Actions.TASK_TEMPLATES_EDIT)) { + actions.push({ + label: t('common.edit'), + name: Actions.TASK_TEMPLATES_EDIT, + icon: 'Edit', + }); + } + + if (config.permittedActions.includes(Actions.TASK_TEMPLATES_DELETE)) { + actions.push({ + label: t('common.delete'), + name: Actions.TASK_TEMPLATES_DELETE, + icon: 'Cancel', + isWarnable: true, + }); + } + return actions; + } + + return { + getColumns, + getItemActions, + getManagerConfig, + }; +} diff --git a/src/managers/TaskTemplateManager/useTaskTemplateManagerEmails.js b/src/managers/TaskTemplateManager/useTaskTemplateManagerEmails.js new file mode 100644 index 000000000..f9f13ac16 --- /dev/null +++ b/src/managers/TaskTemplateManager/useTaskTemplateManagerEmails.js @@ -0,0 +1,39 @@ +import {useFetch} from '@/composables/useFetch'; +import {useUrl} from '@/composables/useUrl'; + +const mailableKeys = { + Submission: 'DISCUSSION_NOTIFICATION_SUBMISSION', + Review: 'DISCUSSION_NOTIFICATION_REVIEW', + Copyediting: 'DISCUSSION_NOTIFICATION_COPYEDITING', + Production: 'DISCUSSION_NOTIFICATION_PRODUCTION', +}; + +export function useTaskTemplateManagerEmails({ + stage = null, + taskTemplate = null, +} = {}) { + const stageId = taskTemplate?.stageId || stage?.key; + if (!stageId) { + throw new Error('Stage ID is required to fetch the email templates.'); + } + + const mailableKey = mailableKeys[stageId]; + if (!mailableKey) { + throw new Error(`No mailable key found for stageId: ${stageId}`); + } + + const {apiUrl: taskApiUrl} = useUrl(`mailables/${mailableKey}`); + + const { + data: emailTemplatesData, + fetch: fetchTaskData, + isLoading: isLoadingEmailTemplates, + } = useFetch(taskApiUrl); + + fetchTaskData(); + + return { + emailTemplatesData, + isLoadingEmailTemplates, + }; +} diff --git a/src/managers/TaskTemplateManager/useTaskTemplateManagerForm.js b/src/managers/TaskTemplateManager/useTaskTemplateManagerForm.js new file mode 100644 index 000000000..764647d54 --- /dev/null +++ b/src/managers/TaskTemplateManager/useTaskTemplateManagerForm.js @@ -0,0 +1,306 @@ +import {ref, computed} from 'vue'; +import {localize} from '@/utils/i18n'; +import {useForm} from '@/composables/useForm'; +import {useFormChanged} from '@/composables/useFormChanged'; +import {useLocalize} from '@/composables/useLocalize'; +import {useModal} from '@/composables/useModal'; +import {useTaskTemplateManagerEmails} from './useTaskTemplateManagerEmails'; + +import FileAttacherModal from '@/components/Composer/FileAttacherModal.vue'; +import FieldPreparedContentInsertModal from '@/components/Form/fields/FieldPreparedContentInsertModal.vue'; + +export function useTaskTemplateManagerForm({ + taskTemplate = null, + stage = null, + onCloseFn = () => {}, +} = {}) { + const {t} = useLocalize(); + const isTask = ref(taskTemplate?.type === 'Task'); + let isTemplateOverrideConfirmed = false; + const {emailTemplatesData} = useTaskTemplateManagerEmails({ + stage, + taskTemplate, + }); + + const { + form, + initEmptyForm, + addPage, + addGroup, + set, + setValue, + addFieldText, + addFieldOptions, + addFieldRichTextArea, + addFieldSelect, + addFieldCheckbox, + } = useForm({}, {customSubmit: handleFormSubmission}); + + async function handleFormSubmission(formData) { + // return result to Form component handler + return { + data: {}, + validationError: {}, + }; + } + + function getParticipantOptions() { + return [ + {label: 'Journal Manager', value: pkp.const.ROLE_ID_MANAGER}, + {label: 'Editor', value: pkp.const.ROLE_ID_MANAGER}, + {label: 'Author', value: pkp.const.ROLE_ID_AUTHOR}, + {label: 'Section Editor', value: pkp.const.ROLE_ID_SUB_EDITOR}, + ]; + } + + function getDueDateOptions() { + return [ + { + value: 'P1W', + label: t('taskTemplates.dueDateFromCreationDate', { + dueDate: t('common.oneWeek'), + }), + }, + { + value: 'P2W', + label: t('taskTemplates.dueDateFromCreationDate', { + dueDate: `2 ${t('common.weeks')}`, + }), + }, + { + value: 'P3W', + label: t('taskTemplates.dueDateFromCreationDate', { + dueDate: `3 ${t('common.weeks')}`, + }), + }, + { + value: 'P4W', + label: t('taskTemplates.dueDateFromCreationDate', { + dueDate: `4 ${t('common.weeks')}`, + }), + }, + { + value: 'P1M', + label: t('taskTemplates.dueDateFromCreationDate', { + dueDate: t('common.oneMonth'), + }), + }, + { + value: 'P1M15D', + label: t('taskTemplates.dueDateFromCreationDate', { + dueDate: `1.5 ${t('common.months')}`, + }), + }, + { + value: 'P2M', + label: t('taskTemplates.dueDateFromCreationDate', { + dueDate: `2 ${t('common.months')}`, + }), + }, + { + value: 'P2M15D', + label: t('taskTemplates.dueDateFromCreationDate', { + dueDate: `2.5 ${t('common.months')}`, + }), + }, + { + value: 'P3M', + label: t('taskTemplates.dueDateFromCreationDate', { + dueDate: `3 ${t('common.months')}`, + }), + }, + ]; + } + + // eslint-disable-next-line no-unused-vars + function onSelectEmailTemplate(emailTemplate) { + const content = localize(emailTemplate?.body); + if (!content) return; + + if (!taskTemplate || isTemplateOverrideConfirmed) { + setValue('discussionText', content); + return; + } + + // When editing, confirm overriding the discussion text with the selected email template + const {openDialog} = useModal(); + openDialog({ + name: 'selectTemplate', + title: t('taskTemplate.apply'), + message: t('taskTemplates.confirmEmailTemplate'), + actions: [ + { + label: t('common.yes', {}), + isWarnable: true, + callback: async (close) => { + setValue('discussionText', content); + isTemplateOverrideConfirmed = true; + close(); + }, + }, + { + label: t('common.no', {}), + callback: (close) => { + close(); + }, + }, + ], + close: () => {}, + modalStyle: 'negative', + }); + } + + function initDiscussionText() { + return { + setup: (editor) => { + editor.ui.registry.addButton('pkpAttachFiles', { + icon: 'upload', + text: t('common.attachFiles'), + onAction() { + const {openSideModal} = useModal(); + + openSideModal(FileAttacherModal, { + title: t('common.attachFiles'), + attachers: [], + onAddAttachments: [], + }); + }, + }); + + editor.ui.registry.addButton('pkpInsert', { + icon: 'plus', + text: t('common.insertContent'), + onAction() { + const {openSideModal, closeSideModal} = useModal( + FieldPreparedContentInsertModal, + ); + openSideModal(FieldPreparedContentInsertModal, { + title: t('common.insertContent'), + insertLabel: t('common.insert'), + preparedContent, + preparedContentLabel: 'Label', + onInsert: (text) => { + editor.insertContent(text); + closeSideModal(FieldPreparedContentInsertModal); + }, + }); + }, + }); + }, + }; + } + + const preparedContent = computed(() => { + const dataDescriptions = emailTemplatesData.value?.dataDescriptions; + if (!dataDescriptions) { + return []; + } + + const items = []; + + Object.keys(dataDescriptions).forEach((key) => { + items.push({ + key, + value: `{$${key}}`, + description: dataDescriptions[key], + }); + }); + + return items; + }); + + initEmptyForm('taskTemplate', { + showErrorFooter: false, + }); + + addPage('default', { + submitButton: {label: t('common.save')}, + cancelButton: {label: t('common.cancel')}, + }); + + addGroup('details', { + label: t('common.details'), + description: t('discussion.form.detailsDescription'), + }); + + addFieldText('detailsName', { + groupId: 'details', + label: t('common.name'), + description: t('discussion.form.detailsNameDescription'), + size: 'large', + value: taskTemplate?.name, + hideOnDisplay: true, + }); + + addFieldOptions('detailsParticipants', 'checkbox', { + groupId: 'details', + label: t('editor.submission.stageParticipants'), + description: t('discussion.form.detailsParticipantsDescription'), + name: 'detailsParticipants', + options: getParticipantOptions(), + value: taskTemplate?.participantRoles || [], + }); + + addGroup('taskInformation', { + label: t('discussion.form.taskInformation'), + description: t('discussion.form.taskInfoDescription'), + }); + + addFieldCheckbox('taskInfoAdd', { + groupId: 'taskInformation', + label: t('discussion.form.taskInfoLabel'), + value: isTask.value, + hideOnDisplay: true, + disabled: + taskTemplate?.type === 'Discussion' && taskTemplate?.status === 'Closed', + }); + + addFieldSelect('taskInfoDueDate', { + groupId: 'taskInformation', + label: t('common.dueDate'), + name: 'taskInfoDueDate', + showWhen: 'taskInfoAdd', + value: isTask.value ? taskTemplate?.dueDate : null, + options: getDueDateOptions(), + size: 'large', + }); + + addGroup('discussion', { + label: t('discussion.name'), + description: t('discussion.form.discussionDescription'), + }); + + addFieldText('discussionSubject', { + groupId: 'discussion', + label: t('email.subject'), + size: 'large', + value: taskTemplate?.subject, + }); + + addFieldRichTextArea('discussionText', { + groupId: 'discussion', + label: t('email.body'), + toolbar: 'bold italic underline bullist | pkpAttachFiles | pkpInsert', + plugins: ['lists'], + size: 'large', + init: initDiscussionText(), + value: taskTemplate?.content || '', + }); + + addGroup('autoAddTemplate'); + + addFieldCheckbox('autoAddTemplate', { + groupId: 'autoAddTemplate', + label: t('taskTemplates.templateAutoAddInStage'), + value: taskTemplate?.autoAdd || false, + }); + + useFormChanged(form, [], onCloseFn, { + warnOnClose: true, + }); + + return { + form, + set, + }; +} diff --git a/src/mockFactories/emailTemplateMock.js b/src/mockFactories/emailTemplateMock.js new file mode 100644 index 000000000..e456f9cbe --- /dev/null +++ b/src/mockFactories/emailTemplateMock.js @@ -0,0 +1,257 @@ +import {deepMerge} from './mockHelpers'; + +const CommonDefaults = { + _href: + 'https://mock/index.php/publicknowledge/api/v1/emailTemplates/DISCUSSION_NOTIFICATION_SUBMISSION', + alternateTo: null, + assignedUserGroupIds: [], + body: { + en: 'Please enter your message.', + fr_CA: 'Prière de saisir votre message.', + }, + contextId: 1, + id: 1, + isUnrestricted: true, + key: 'DISCUSSION_NOTIFICATION_SUBMISSION', + name: { + en: 'Discussion (Submission)', + fr_CA: 'Discussion (soumission)', + }, + subject: { + en: 'A message regarding {$journalName}', + fr_CA: 'Un message à propos de la revue {$journalName}', + }, +}; + +const dataDescriptions = { + authorSubmissionUrl: "The author's URL to the submission", + authors: 'The full names of the authors', + authorsShort: + 'The names of the authors in a short string, like "Barnes, et al"', + contactEmail: "The email address of the journal's primary contact", + contactName: "The name of the journal's primary contact", + contextAcronym: "The journal's initials", + journalName: "The journal's name", + journalSignature: "The journal's email signature for automated emails", + journalUrl: "The URL to the journal's homepage", + mailingAddress: 'The mailing address of the journal', + passwordLostUrl: + 'The URL to a page where the user can recover a lost password', + recipientName: 'The full name of the recipient or all recipients', + recipientUsername: 'The username of the recipient or all recipients', + senderEmail: 'The email address of the sender', + senderName: 'The full name of the sender', + signature: 'The email signature of the sender', + submissionAbstract: "The submission's abstract", + submissionId: "The submission's unique ID number", + submissionPublishedUrl: 'The URL to the published submission', + submissionTitle: "The submission's title", + submissionUrl: 'The URL to the submission in the editorial backend', + submissionWizardUrl: 'The URL to the submission in the submission wizard', + submissionsUrl: "The URL to view all of a user's assigned submissions", + userProfileUrl: 'The URL for a user to view and edit their profile', +}; + +export const emailTemplateMock = { + DISCUSSION_NOTIFICATION_SUBMISSION: { + emailTemplates: [ + {...CommonDefaults}, + deepMerge( + {...CommonDefaults}, + { + key: 'EDITOR_ASSIGN_SUBMISSION', + name: { + en: 'Assign Editor', + fr_CA: 'Assigner un-e rédacteur-trice', + }, + body: { + en: '

Dear {$recipientName},

The following submission has been assigned to you to see through the editorial process.

{$submissionTitle}
{$authors}

Abstract

{$submissionAbstract}

If you find the submission to be relevant for {$journalName}, please forward the submission to the review stage by selecting "Send to Review" and then assign reviewers by clicking "Add Reviewer".

If the submission is not appropriate for this journal, please decline the submission.

Thank you in advance.

Kind regards,

{$journalSignature}', + fr_CA: + '

{$recipientName},

La soumission suivante vous a été assignée pour le suivi du processus éditorial.

{$submissionTitle}
{$authors}

Résumé

{$submissionAbstract}

Si vous jugez la soumission pertinente pour la revue {$journalName}, veuillez la transmettre à l\'étape d\'évaluation en sélectionnant "Envoyer en évaluation" et en désignant des évaluateur.trice.s en cliquant sur « Ajouter un.e évaluateur.trice ».

Si la soumission n\'est pas appropriée pour cette revue, veuillez la décliner.

Je vous remercie d\'avance.

Cordialement,

{$journalSignature}', + }, + subject: { + en: 'You have been assigned as an editor on a submission to {$journalName}', + fr_CA: + "Vous avez été assigné.e en tant que rédacteur.trice d'une soumission de la revue {$journalName}", + }, + }, + ), + ], + dataDescriptions, + }, + DISCUSSION_NOTIFICATION_REVIEW: { + emailTemplates: [ + deepMerge( + {...CommonDefaults}, + { + id: 3, + key: 'DISCUSSION_NOTIFICATION_REVIEW', + name: { + en: 'Discussion (Review)', + fr_CA: 'Discussion (évaluation)', + }, + body: { + en: 'Please enter your message.', + fr_CA: 'Prière de saisir votre message.', + }, + subject: { + en: 'A message regarding {$journalName}', + fr_CA: 'Un message à propos de la revue {$journalName}', + }, + }, + ), + deepMerge( + {...CommonDefaults}, + { + id: 4, + key: 'EDITOR_ASSIGN_REVIEW', + name: { + en: 'Assign Editor', + fr_CA: 'Assigner un-e rédacteur-trice', + }, + body: { + en: '

Dear {$recipientName},

The following submission has been assigned to you to see through the peer review process.

{$submissionTitle}
{$authors}

Abstract

{$submissionAbstract}

Please login to view the submission and assign qualified reviewers. You can assign a reviewer by clicking "Add Reviewer".

Thank you in advance.

Kind regards,

{$signature}', + fr_CA: + '

{$recipientName},

La soumission suivante vous a été assignée pour le processus d\'évaluation par les pairs.

{$submissionTitle}
{$authors}

Résumé

{$submissionAbstract}

Veuillez vous connecter pour voir la soumission et désigner des évaluateur.trice.s qualifié.es. Vous pouvez désigner un.e évaluateur.trice en cliquant sur « Ajouter un.e évaluateur.trice ».

Je vous remercie d\'avance.

Cordialement,

{$signature}', + }, + subject: { + en: 'You have been assigned as an editor on a submission to {$journalName}', + fr_CA: + "Vous avez été assigné.e en tant que rédacteur.trice d'une soumission de la revue {$journalName}", + }, + }, + ), + ], + dataDescriptions, + }, + DISCUSSION_NOTIFICATION_COPYEDITING: { + emailTemplates: [ + deepMerge( + {...CommonDefaults}, + { + id: 5, + key: 'DISCUSSION_NOTIFICATION_COPYEDITING', + name: { + en: 'Discussion (Copyediting)', + fr_CA: 'Discussion (révision)', + }, + body: { + en: 'Please enter your message.', + fr_CA: 'Prière de saisir votre message.', + }, + subject: { + en: 'A message regarding {$journalName}', + fr_CA: 'Un message à propos de la revue {$journalName}', + }, + }, + ), + deepMerge( + {...CommonDefaults}, + { + id: 6, + key: 'COPYEDIT_REQUEST', + name: { + en: 'Request Copyedit', + fr_CA: 'Demande de révision', + }, + body: { + en: '

Dear {$recipientName},

A new submission is ready to be copyedited:

{$submissionId} — "{$submissionTitle}"
{$journalName}

Please follow these steps to complete this task:

  1. Click on the Submission URL below.
  2. Open any files available under Draft Files and edit the files. Use the Copyediting Discussions area if you need to contact the editor(s) or author(s).
  3. Save the copyedited file(s) and upload them to the Copyedited panel.
  4. Use the Copyediting Discussions to notify the editor(s) that all files have been prepared, and that the Production process may begin.

If you are unable to undertake this work at this time or have any questions, please contact me. Thank you for your contribution to {$journalName}.

Kind regards,

{$signature}', + fr_CA: + "{$recipientName},
\n
\nJ'aimerais que vous effectuiez la révision du manuscrit intitulé « {$submissionTitle} » pour la revue {$journalName} à l'aide des étapes suivantes.
\n1. Cliquer sur l'URL de la soumission ci-dessous.
\n2. Ouvrir le(s) fichier(s) disponible(s) sous Fichiers des ébauches finales et effectuer votre révision, tout en ajoutant des discussions sur la révision, le cas échéant.
\n3. Enregistrer le(s) fichier(s) révisé(s) et le(s) téléverser dans la section Version(s) révisée(s).
\n4. Informer le,la rédacteur-trice que tous les fichiers ont été révisés et que l'étape de production peut débuter.
\n
\nURL de la revue {$journalName} : {$journalUrl}
\nURL de la soumission : {$submissionUrl}
\nNom d'utilisateur-trice : {$recipientUsername}", + }, + subject: { + en: 'Submission {$submissionId} is ready to be copyedited for {$contextAcronym}', + fr_CA: "Demande de révision d'une soumission", + }, + }, + ), + ], + dataDescriptions, + }, + DISCUSSION_NOTIFICATION_PRODUCTION: { + emailTemplates: [ + deepMerge( + {...CommonDefaults}, + { + id: 7, + key: 'DISCUSSION_NOTIFICATION_PRODUCTION', + name: { + en: 'Discussion (Production)', + fr_CA: 'Discussion (production)', + }, + body: { + en: 'Please enter your message.', + fr_CA: 'Prière de saisir votre message.', + }, + subject: { + en: 'A message regarding {$journalName}', + fr_CA: 'Un message à propos de la revue {$journalName}', + }, + }, + ), + deepMerge( + {...CommonDefaults}, + { + id: 8, + key: 'EDITOR_ASSIGN_PRODUCTION', + name: { + en: 'Assign Editor', + fr_CA: 'Assigner un-e rédacteur-trice', + }, + body: { + en: '

Dear {$recipientName},

The following submission has been assigned to you to see through the production stage.

{$submissionTitle}
{$authors}

Abstract

{$submissionAbstract}

Please login to view the submission. Once production-ready files are available, upload them under the Publication > Galleys section. Then schedule the work for publication by clicking the Schedule for Publication button.

Thank you in advance.

Kind regards,

{$signature}', + fr_CA: + '

{$recipientName},

La soumission suivante vous a été assignée pour suivre le processus de production.

{$submissionTitle}
{$authors}

Résumé

{$submissionAbstract}

Veuillez vous connecter pour afficher la soumission. Une fois les fichiers prêts pour la production disponibles, les téléverser sous la section Publication > Épreuves. Ensuite, planifier la publication en cliquant sur le bouton Planifier la publication.

Merci d\'avance.

Cordialement,

{$signature}', + }, + subject: { + en: 'You have been assigned as an editor on a submission to {$journalName}', + fr_CA: + "Vous avez été assigné.e en tant que rédacteur.trice d'une soumission de la revue {$journalName}", + }, + }, + ), + deepMerge( + {...CommonDefaults}, + { + id: 9, + key: 'LAYOUT_COMPLETE', + name: { + en: 'Galleys Complete', + fr_CA: 'Épreuves complétées', + }, + body: { + en: '

Dear {$recipientName},

Galleys have now been prepared for the following submission and are ready for final review.

{$submissionTitle}
{$journalName}

If you have any questions, please contact me.

Kind regards,

{$signature}

', + fr_CA: + '

Bonjour {$recipientName},

Les épreuves du manuscrit intitulé « {$submissionTitle} » pour la revue {$journalName} sont maintenant prêtes pour la relecture.

Si vous avez des questions, n\'hésitez pas à communiquer avec moi.

{$signature}

', + }, + subject: { + en: 'Galleys Complete', + fr_CA: 'Mise en page des épreuves terminée', + }, + }, + ), + deepMerge( + {...CommonDefaults}, + { + id: 10, + key: 'LAYOUT_REQUEST', + name: { + en: 'Ready for Production', + fr_CA: 'Prêt pour production', + }, + body: { + en: '

Dear {$recipientName},

A new submission is ready for layout editing:

{$submissionId} — {$submissionTitle}
{$journalName}

  1. Click on the Submission URL above.
  2. Download the Production Ready files and use them to create the galleys according to the journal\'s standards.
  3. Upload the galleys to the Publication section of the submission.
  4. Use the Production Discussions to notify the editor that the galleys are ready.

If you are unable to undertake this work at this time or have any questions, please contact me. Thank you for your contribution to this journal.

Kind regards,

{$signature}', + fr_CA: + "

Bonjour {$recipientName},

J'aimerais que vous prépariez les épreuves du manuscrit intitulé « {$submissionTitle} » pour la revue {$journalName} à l'aide des étapes suivantes.

\n
  1. Cliquer sur l'URL de la soumission ci-dessous.
  2. Se connecter au site Web de la revue et utiliser les fichiers disponibles sous Fichiers prêts pour la production pour créer les épreuves en fonction des normes de la revue.
  3. Téléverser les épreuves dans la section Épreuves.
  4. Informer le-la rédacteur-trice, via une discussion sur la production, que les épreuves ont été téléversées et qu'elles sont prêtes.

URL de la revue {$journalName} : {$journalUrl}

URL du manuscrit : {$submissionUrl}

Nom d'utilisateur-trice : {$recipientUsername}

Si vous ne pouvez pas effectuer ce travail pour le moment ou si vous avez des questions, veuillez communiquer avec moi. Je vous remercie de votre collaboration.

{$signature}", + }, + subject: { + en: 'Submission {$submissionId} is ready for production at {$contextAcronym}', + fr_CA: + 'La soumission {$submissionId} est prête pour production à la revue {$contextAcronym}', + }, + }, + ), + ], + dataDescriptions, + }, +}; diff --git a/src/mockFactories/taskDiscussionTemplates.js b/src/mockFactories/taskDiscussionTemplates.js index 9552358a3..1802bf7ce 100644 --- a/src/mockFactories/taskDiscussionTemplates.js +++ b/src/mockFactories/taskDiscussionTemplates.js @@ -37,10 +37,10 @@ const CommonDefaults = { `, type: 'Task', - taskDetails: { - participantRoles: [16, 1], - dueDate: 'P1W', - }, + participantRoles: [16, 1], + dueDate: 'P1W', + stageId: 'Submission', + autoAdd: true, }; export function getTemplate(overrides = {}) { @@ -72,6 +72,8 @@ export const TemplatesDataMock = [ Please share your thoughts by July 15. Any concerns about authorship should be raised before we finalize the submission.

`, type: 'Discussion', + stageId: 'Submission', + participantRoles: [65536], }, { id: 3, @@ -95,9 +97,7 @@ export const TemplatesDataMock = [ Ensure all documents are consistent and up to date. Reach out to the ethics coordinator if clarification is needed.

`, type: 'Task', - taskDetails: { - participantRoles: [65536], - dueDate: 'P3M', - }, + participantRoles: [65536], + dueDate: 'P3M', }, ];