diff --git a/public/globals.js b/public/globals.js index 6a685d1f3..eee6b9066 100644 --- a/public/globals.js +++ b/public/globals.js @@ -378,6 +378,21 @@ window.pkp = { 'submission.upload.percentComplete': 'Uploading {$percent}% complete', 'submissions.incomplete': 'Incomplete', 'validator.required': 'This field is required.', + 'invitation.notification.title': 'Invitation sent', + 'invitation.wizard.success': "{$email} has been invited to a new role in OJS. You can be updated about the user's decision on the User & Role page, your OJS notification and/or your email", + 'user.email': 'Email', + 'user.username': 'Username', + 'user.orcid': 'ORCID iD', + 'invitation.notification.closeBtn':'View all users', + 'user.password': 'Password', + 'invitation.role.selectRole':'Select a new role', + 'invitation.role.dateEnd' : 'End Date', + 'invitation.role.dateStart' : 'Start Date', + 'invitation.role.masthead' : 'Journal Masthead', + 'invitation.role.removeRole.button' : 'Remove Role', + 'invitation.role.addRole.button':'Add Another Role', + 'invitation.orcid.message':'Add Another Role', + }, tinyMCE: { diff --git a/src/components/Container/PageOJS.vue b/src/components/Container/PageOJS.vue index 2b034b21e..dd798b54e 100644 --- a/src/components/Container/PageOJS.vue +++ b/src/components/Container/PageOJS.vue @@ -1,10 +1,12 @@ + diff --git a/src/pages/userInvitation/UserInvitationEmailComposerStep.vue b/src/pages/userInvitation/UserInvitationEmailComposerStep.vue new file mode 100644 index 000000000..148b44123 --- /dev/null +++ b/src/pages/userInvitation/UserInvitationEmailComposerStep.vue @@ -0,0 +1,89 @@ + + + diff --git a/src/pages/userInvitation/UserInvitationHeader.vue b/src/pages/userInvitation/UserInvitationHeader.vue new file mode 100644 index 000000000..f67629358 --- /dev/null +++ b/src/pages/userInvitation/UserInvitationHeader.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/pages/userInvitation/UserInvitationPage.mdx b/src/pages/userInvitation/UserInvitationPage.mdx new file mode 100644 index 000000000..67e6c1131 --- /dev/null +++ b/src/pages/userInvitation/UserInvitationPage.mdx @@ -0,0 +1,9 @@ +import {Primary, Controls, Stories, Meta, ArgTypes} from '@storybook/blocks'; + +import * as UserInvitationPage from './UserInvitationPage.stories.js'; + + + +# User Invitation page + + diff --git a/src/pages/userInvitation/UserInvitationPage.stories.js b/src/pages/userInvitation/UserInvitationPage.stories.js new file mode 100644 index 000000000..c5d3927dc --- /dev/null +++ b/src/pages/userInvitation/UserInvitationPage.stories.js @@ -0,0 +1,115 @@ +import UserInvitationPage from './UserInvitationPage.vue'; +import {http, HttpResponse} from 'msw'; +import userMock from './mocks/userMock.js'; +import PageInitConfigMock from './mocks/pageInitConfig'; + +export default {title: 'Pages/UserInvitation', component: UserInvitationPage}; + +export const Init = { + render: (args) => ({ + components: {UserInvitationPage}, + setup() { + return {args}; + }, + template: '', + }), + parameters: { + msw: { + handlers: [ + http.get( + 'https://mock/index.php/publicknowledge/api/v1/_user', + ({request}) => { + const url = new URL(request.url); + let params = new URLSearchParams(url.search); + + // To store the parameters in a simple object + let allParams = {}; + for (let param of params) { + allParams[param[0]] = param[1]; + } + if ( + allParams.searchPhrase.replaceAll(/\s/g, '') === + 'carlo@mailinator.com' + ) { + return HttpResponse.json(userMock); + } else { + return HttpResponse.json({ + itemsMax: 0, + items: [], + }); + } + }, + ), + http.post( + 'https://mock/index.php/publicknowledge/api/v1/invitations', + () => { + return HttpResponse.json({invitationId: 15}); + }, + ), + http.post( + 'https://mock/index.php/publicknowledge/api/v1/invitations/15', + async ({request}) => { + const data = await request.json(); + let errors = {}; + + data.userGroupsToAdd.forEach((element, index) => { + let objectErrors = {}; + Object.keys(element).forEach((key) => { + if (element[key] === null) { + objectErrors[key] = ['This field is required']; + } + }); + if (Object.keys(objectErrors).length > 0) { + errors['userGroupsToAdd'] = { + ...errors['userGroupsToAdd'], + [index]: objectErrors, + }; + } + }); + + if (data.email === '') { + errors['email'] = ['This field is required']; + } + if (data.orcid === '') { + errors['orcid'] = ['This field is required']; + } + if (data.familyName === '') { + errors['familyName'] = ['This field is required']; + } + if (data.givenName === '') { + errors['givenName'] = ['This field is required']; + } + + Object.keys(data.emailComposer).forEach((element) => { + if (data.emailComposer[element] === '') { + errors['emailComposer'] = { + ...errors['emailComposer'], + [element]: ['This field is required'], + }; + } + }); + + if (Object.keys(errors).length > 0) { + return HttpResponse.json(errors, {status: 422}); + } + + return HttpResponse.json({status: 201}); + }, + ), + http.post( + 'https://mock/index.php/publicknowledge/api/v1/invitations/15/submit', + () => { + return HttpResponse.json({}); + }, + ), + http.post( + 'https://mock/index.php/publicknowledge/api/v1/user/_invite', + () => { + return HttpResponse.json('invitation send successfully'); + }, + ), + ], + }, + }, + args: PageInitConfigMock, +}; diff --git a/src/pages/userInvitation/UserInvitationPage.vue b/src/pages/userInvitation/UserInvitationPage.vue new file mode 100644 index 000000000..8a0aa1124 --- /dev/null +++ b/src/pages/userInvitation/UserInvitationPage.vue @@ -0,0 +1,376 @@ + + + + + diff --git a/src/pages/userInvitation/UserInvitationPageStore.js b/src/pages/userInvitation/UserInvitationPageStore.js new file mode 100644 index 000000000..eb5317507 --- /dev/null +++ b/src/pages/userInvitation/UserInvitationPageStore.js @@ -0,0 +1,309 @@ +import {defineComponentStore} from '@/utils/defineComponentStore'; +import {useFetch} from '@/composables/useFetch'; +import {useUrl} from '@/composables/useUrl'; +import {computed, onMounted, ref, watch} from 'vue'; +import {useModal} from '@/composables/useModal'; +export const useUserInvitationPageStore = defineComponentStore( + 'userInvitationPage', + (pageInitConfig) => { + const {openDialog} = useModal(); + + /** Invitation payload, initial value */ + const invitationPayload = ref({...pageInitConfig.invitationPayload}); + + function updatePayload(fieldName, value) { + invitationPayload.value[fieldName] = value; + } + + /** Steps */ + const currentStepId = ref(pageInitConfig.steps[0].id); + const steps = ref(pageInitConfig.steps); + const startedSteps = ref([]); + + /** + * The currently active step + */ + const currentStep = computed(() => { + return steps.value.find((step) => step.id === currentStepId.value); + }); + /** + * The index of the currently active step + * in the steps array + */ + const currentStepIndex = computed(() => { + return steps.value.findIndex((step) => step.id === currentStepId.value); + }); + /** + * Is the current step the first step? + */ + const isOnFirstStep = computed(() => { + return !currentStepIndex.value; + }); + + /** + * Is the current step the last step? + */ + const isOnLastStep = computed(() => { + return currentStepIndex.value === steps.value.length - 1; + }); + + /** + * Add a step change to the browser history so the + * user can use the browser's back button + * + * @param {Object} step The step to add + */ + function addHistory(step) { + window.history.pushState({}, step.name, '#' + step.id); + } + + /** + * Go to the next step or submit if this is the last step + */ + async function nextStep() { + if (registeredActionsForSteps[currentStep.value.id]) { + let shouldContinue = true; + shouldContinue = + await registeredActionsForSteps[currentStep.value.id](); + if (!shouldContinue) { + return; + } + } + if (isOnLastStep.value) { + submitInvitation(); + } else { + if (!currentStep.value?.skipInvitationUpdate) { + await updateInvitation(); + // this needs to check only relevant errors for given step using the step.validateFields + if (currentStepErrorsPerSection.value.length === 0) { + openStep(steps.value[1 + currentStepIndex.value].id); + } + } else { + openStep(steps.value[1 + currentStepIndex.value].id); + } + } + } + + /** + * Go to a step in the wizard + * + * @param {String} stepId + */ + function openStep(stepId) { + const newStep = steps.value.find((step) => step.id === stepId); + if (!newStep) { + return; + } + errors.value = []; + currentStepId.value = stepId; + } + + /** + * Go to the previous step in the wizard + */ + function previousStep() { + const previousIndex = currentStepIndex.value - 1; + if (previousIndex >= 0) { + openStep(steps.value[previousIndex].id); + } + } + /** + * Update when the step changes + */ + watch(currentStepIndex, async (newVal, oldVal) => { + if (newVal === oldVal) { + return; + } + + // Update the list of steps that have been started + steps.value.forEach((step, i) => { + if ( + !startedSteps.value.includes(step.id) && + i <= currentStepIndex.value + ) { + startedSteps.value.push(step.id); + } + }); + + // Track step changes in the title and browser history + const step = steps.value[newVal]; + // document.title = this.getPageTitle(step); + if (step.id !== window.location.hash.replace('#', '')) { + addHistory(step); + } + + // Trigger validation on the review step + if (newVal === steps.value.length - 1) { + // validate(); + } + }); + + /** Page titles */ + const pageTitleDescription = ref(pageInitConfig.pageTitleDescription); + const primaryLocale = ref(pageInitConfig.primaryLocale); + /** + * The title to show at the top of the page + */ + const pageTitle = computed(() => { + if (!currentStep.value) { + return ''; + } + return currentStep.value.name.replace('{$step}', currentStep.value); + }); + + /** + * The step title to show at the top of the step + */ + const stepTitle = computed(() => { + if (!currentStep.value) { + return ''; + } + return currentStep.value.reviewName.replace( + '{$step}', + 'STEP -' + (1 + currentStepIndex.value), + ); + }); + + /** All Erros */ + const errors = ref({}); + + /** + * Are there any validation errors? + */ + const isValid = computed(() => { + return !Object.keys(errors.value).length; + }); + + /** + * set current step section errors + */ + const currentStepErrorsPerSection = computed(() => { + let error = []; + if (Object.keys(errors.value).length != 0) { + currentStep.value.sections.forEach((element, index) => { + if (element.props.validateFields.length > 0) { + let sectionErr = {}; + element.props.validateFields.forEach((field) => { + if (Object.keys(errors.value).includes(field)) { + sectionErr[field] = errors.value[field]; + } + }); + if (Object.keys(sectionErr).length != 0) { + error[index] = sectionErr; + } + } + }); + } + + return error; + }); + + /** Handling invitation */ + const invitationId = ref(null); + /** + * Create invitation + */ + async function createInvitation() { + const {apiUrl} = useUrl('invitations'); + + const {data, fetch: createInvitation} = useFetch(apiUrl, { + method: 'POST', + body: {type: pageInitConfig.invitationType}, + }); + await createInvitation(); + invitationId.value = data.value.invitationId; + } + + /** Update Invitation */ + async function updateInvitation() { + if (!invitationId.value) { + await createInvitation(); + } + + const {apiUrl} = useUrl(`invitations/${invitationId.value}`); + + const {fetch, validationError} = useFetch(apiUrl, { + method: 'POST', + body: invitationPayload.value, + expectValidationError: true, + }); + await fetch(); + if (validationError.value) { + errors.value = validationError.value; + } else { + errors.value = []; + } + } + + /** Submit invitation */ + async function submitInvitation() { + await updateInvitation(); + if (isValid.value) { + const {apiUrl} = useUrl(`invitations/${invitationId.value}/submit`); + + const {data, fetch} = useFetch(apiUrl, { + method: 'POST', + body: {}, + }); + + await fetch(); + if (data.value) { + openDialog({ + title: 'Invitation sent', + actions: [ + { + label: 'Ok', + callback: (close) => { + close(); + }, + }, + ], + }); + } + } + } + + const registeredActionsForSteps = {}; + function registerActionForStepId(stepId, callback) { + registeredActionsForSteps[stepId] = callback; + } + + onMounted(() => { + /** + * Open the correct step when the page is loaded + */ + if (!window.location.hash) { + openStep(steps.value[0].id); + } + }); + + return { + invitationPayload, + updatePayload, + registerActionForStepId, + //computed + currentStep, + currentStepIndex, + isOnFirstStep, + isOnLastStep, + isValid, + pageTitle, + startedSteps, + stepTitle, + openStep, + currentStepErrorsPerSection, + + //page values + steps, + pageTitleDescription, + errors, + + //form feilds + primaryLocale, + + //methods + nextStep, + previousStep, + }; + }, +); diff --git a/src/pages/userInvitation/UserInvitationSearchFormStep.vue b/src/pages/userInvitation/UserInvitationSearchFormStep.vue new file mode 100644 index 000000000..bc055b37b --- /dev/null +++ b/src/pages/userInvitation/UserInvitationSearchFormStep.vue @@ -0,0 +1,105 @@ + + + diff --git a/src/pages/userInvitation/UserInvitationUserGroupsTable.vue b/src/pages/userInvitation/UserInvitationUserGroupsTable.vue new file mode 100644 index 000000000..6140a63a1 --- /dev/null +++ b/src/pages/userInvitation/UserInvitationUserGroupsTable.vue @@ -0,0 +1,176 @@ + + + + diff --git a/src/pages/userInvitation/mocks/pageInitConfig.js b/src/pages/userInvitation/mocks/pageInitConfig.js new file mode 100644 index 000000000..922d36679 --- /dev/null +++ b/src/pages/userInvitation/mocks/pageInitConfig.js @@ -0,0 +1,345 @@ +import InsertContentMock from '@/mocks/insertContent'; +import emailTemplate from '@/mocks/emailTemplate'; + +const recipientOptions = [ + { + value: 2, + label: { + en: 'Carlo Corino', + fr_CA: 'Carlo Fr Corino', + }, + }, + { + value: 3, + label: { + en: 'Daniel Barnes', + fr_CA: 'Daniel Fr Barnes', + }, + }, + { + value: 4, + label: { + en: 'Stephanie Minoue', + fr_CA: 'Stephanie Fr Minoue', + }, + }, + { + value: 5, + label: { + en: 'Paul Hudson', + fr_CA: 'Paul Fr Hudson', + }, + }, +]; + +export default { + emailTemplatesApiUrl: + 'https://mock/index.php/publicknowledge/api/v1/EmailTemplateMocks', + pageTitle: 'Invite user to take a role', + pageTitleDescription: + 'You are inviting a user to take a role in OJS along with appearing in the journal masthead', + primaryLocale: 'en', + invitationType: 'RoleUpdateForNewUser', + invitationPayload: { + userId: null, + email: '', + orcid: '', + givenName: '', + familyName: '', + affiliation: '', + country: '', + orcidValidation: false, + userGroupsToAdd: [ + {userGroup: null, dateStart: null, dateEnd: null, masthead: null}, + ], + currentUserGroups: [], + userGroupsToRemove: [], + emailComposer: { + body: '', + subject: '', + }, + }, + steps: [ + { + id: 'searchUser', + name: 'Search User', + reviewName: '{$step} - Search User', + description: + 'Search for the user using their email address, username or ORCID ID. Enter at least one details to get started. If user does not exist, ypu can invite them to take up roles and be a part of your journal. If the user already exist in the system, you can view user information and invite to take a additional roles.', + nextButtonLabel: 'Search user (t)', + skipInvitationUpdate: true, + sections: [ + { + id: 'searchUserForm', + sectionComponent: 'UserInvitationSearchFormStep', + props: { + validateFields: [], + }, + }, + ], + }, + { + id: 'userDetails', + name: 'Enter details', + reviewName: '{$step} - Enter details and invite for roles', + type: 'form', + description: 'You can invite them to take up a role in OJS', + nextButtonLabel: 'Save And Continue (t)', + skipInvitationUpdate: false, + sections: [ + { + id: 'userDetailsForm', + type: 'form', + description: + '

Please provide the following details to help us manage your submission in our system.

', + sectionComponent: 'UserInvitationDetailsFormStep', + props: { + userGroups: [ + { + value: 2, + label: 'Journal manager', + disabled: false, + }, + { + value: 3, + label: 'Journal editor', + disabled: false, + }, + { + value: 4, + label: 'Production editor', + disabled: false, + }, + { + value: 5, + label: 'Section editor', + disabled: false, + }, + { + value: 6, + label: 'Guest editor', + disabled: false, + }, + { + value: 7, + label: 'Copyeditor', + disabled: false, + }, + { + value: 8, + label: 'Designer', + disabled: false, + }, + { + value: 9, + label: 'Funding coordinator', + disabled: false, + }, + { + value: 10, + label: 'Indexer', + disabled: false, + }, + { + value: 11, + label: 'Layout Editor', + disabled: false, + }, + { + value: 12, + label: 'Marketing and sales coordinator', + disabled: false, + }, + { + value: 13, + label: 'Proofreader', + disabled: false, + }, + { + value: 14, + label: 'Author', + disabled: false, + }, + { + value: 15, + label: 'Translator', + disabled: false, + }, + { + value: 16, + label: 'Reviewer', + disabled: false, + }, + { + value: 17, + label: 'Reader', + disabled: false, + }, + { + value: 18, + label: 'Subscription Manager', + disabled: false, + }, + ], + form: { + id: 'userDetails', + method: 'POST', + action: + 'http://localhost/ojs/index.php/publicknowledge/api/v1/users', + fields: [ + { + name: 'email', + component: 'field-text', + label: 'Email address', + groupId: 'default', + isRequired: true, + isMultilingual: false, + value: 'null', + inputType: 'text', + optIntoEdit: false, + optIntoEditLabel: '', + size: 'large', + prefix: '', + }, + { + name: 'orcid', + component: 'field-text', + label: 'ORCID iD', + groupId: 'default', + isRequired: false, + isMultilingual: false, + value: null, + inputType: 'text', + optIntoEdit: false, + optIntoEditLabel: '', + size: 'large', + prefix: '', + }, + { + name: 'givenName', + component: 'field-text', + label: 'Given Name', + groupId: 'default', + isRequired: false, + isMultilingual: false, + value: null, + inputType: 'text', + optIntoEdit: false, + optIntoEditLabel: '', + size: 'large', + prefix: '', + }, + { + name: 'familyName', + component: 'field-text', + label: 'Family Name', + groupId: 'default', + isRequired: false, + isMultilingual: false, + value: null, + inputType: 'text', + optIntoEdit: false, + optIntoEditLabel: '', + size: 'large', + prefix: '', + }, + ], + groups: [ + { + id: 'default', + pageId: 'default', + }, + ], + hiddenFields: {}, + pages: [ + { + id: 'default', + submitButton: { + label: 'Save', + }, + }, + ], + primaryLocale: 'en', + visibleLocales: ['en'], + supportedFormLocales: [ + { + key: 'en', + label: 'English', + }, + { + key: 'fr_CA', + label: 'French', + }, + ], + errors: {}, + }, + validateFields: [ + 'orcid', + 'email', + 'givenName', + 'familyName', + 'userGroupsToAdd', + ], + }, + }, + ], + }, + { + id: 'userInvitedEmail', + name: 'Review & invite for roles', + reviewName: '{$step} - Modify email shared with the user', + type: 'email', + description: + 'Send the user an email to let them know about the invitation, next steps, journal GDPR polices and ORCiD verification', + nextButtonLabel: 'Invite user to the role (t)', + sections: [ + { + id: 'userInvited', + type: 'email', + description: + '

Please provide the following details to help us manage your submission in our system.

', + sectionComponent: 'UserInvitationEmailComposerStep', + props: { + email: { + id: 'userInvited', + type: 'email', + name: 'Invite Users', + attachers: [], + canChangeRecipients: false, + canSkip: true, + description: + 'Send an email to the authors to let them know that this submission will be sent for peer review. If possible, give the authors some indication of how long the peer review process might take and when they should expect to hear from the editors again. This email will not be sent until the decision is recorded.', + errors: {}, + initialTemplateKey: 'EDITOR_DECISION_ACCEPT', + anonymousRecipients: false, + locale: 'en', + body: "{$recipientName}
\\n\n
\\n\nYou have now been registered as a user with Journal of Public Knowledge. We have included your username and password in this email, which are needed for all work with this journal through its website. At any point, you can ask to be removed from the journal's list of users by contacting me.
\\n\n
\\n\naccept url: {$acceptUrl}
\\n\ndecline url: {$declineUrl}
\\n\n
\\n\nThank you,
\\n\n

admin admin

\n", + subject: 'User invited', + emailTemplates: [emailTemplate], + emailTemplatesApiUrl: + 'https://httbin.org/publicknowledge/api/v1/emailTemplates', + locales: [ + { + locale: 'en', + name: 'English', + }, + { + locale: 'fr_CA', + name: 'French', + }, + ], + to: [2, 3], + recipientOptions: recipientOptions, + recipients: recipientOptions.map((r) => r.value), + variables: { + en: [...InsertContentMock], + fr_CA: [...InsertContentMock], + }, + }, + validateFields: ['emailComposer'], + }, + }, + ], + }, + ], +}; diff --git a/src/pages/userInvitation/mocks/userMock.js b/src/pages/userInvitation/mocks/userMock.js new file mode 100644 index 000000000..27e6fbd34 --- /dev/null +++ b/src/pages/userInvitation/mocks/userMock.js @@ -0,0 +1,67 @@ +export default { + itemsMax: 1, + items: [ + { + _href: 'http://localhost/ojs/index.php/publicknowledge/api/v1/users/35', + affiliation: { + en: 'CUNY', + fr_CA: '', + }, + biography: { + en: '', + fr_CA: '', + }, + disabled: false, + email: 'carlo@mailinator.com', + fullName: 'Carlo Corino', + groups: [ + { + id: 17, + name: { + en: 'Reader', + fr_CA: 'Lecteur-trice', + }, + abbrev: { + en: 'Read', + fr_CA: 'Lect', + }, + roleId: 1048576, + showTitle: true, + permitSelfRegistration: true, + permitMetadataEdit: false, + recommendOnly: false, + }, + { + id: 14, + name: { + en: 'Author', + fr_CA: 'Auteur-e', + }, + abbrev: { + en: 'AU', + fr_CA: 'AU', + }, + roleId: 65536, + showTitle: true, + permitSelfRegistration: true, + permitMetadataEdit: false, + recommendOnly: false, + }, + ], + id: 35, + interests: [], + orcid: null, + orcidAccessDenied: null, + orcidAccessExpiresOn: null, + orcidAccessScope: null, + orcidAccessToken: null, + orcidRefreshToken: null, + orcidReviewPutCode: null, + preferredPublicName: { + en: '', + fr_CA: '', + }, + userName: 'zwoods', + }, + ], +};