diff --git a/public/globals.js b/public/globals.js index 0b1c9cd61..4b31c4587 100644 --- a/public/globals.js +++ b/public/globals.js @@ -433,6 +433,13 @@ window.pkp = { 'metadata.property.displayName.doi': 'DOI', 'navigation.backTo': '\u27f5 Back to {$page}', 'publication.contributors': 'Contributors', + 'orcid.field.verification.request': 'Request verification', + 'orcid.field.verification.requested': 'Verification requested!', + 'orcid.field.authorEmailModal.title': 'Request ORCID verification', + 'orcid.field.authorEmailModal.message': 'Would you like to send an email to this author requesting they verify their ORCID?', + 'orcid.field.deleteOrcidModal.title': 'Delete ORCID', + 'orcid.field.deleteOrcidModal.message': 'Are you sure you want to remove this ORCID?', + 'orcid.field.unverified.shouldRequest': 'This ORCID has not been verified. Please remove this unverified ORCID and request verification from the user/author directly.', 'publication.jats.autoCreatedMessage': 'This JATS file is generated automatically by the submission metadata', 'publication.jats.confirmDeleteFileButton': 'Delete JATS File', diff --git a/src/components/Form/Form.vue b/src/components/Form/Form.vue index 5e711a144..a5d218c69 100644 --- a/src/components/Form/Form.vue +++ b/src/components/Form/Form.vue @@ -211,7 +211,7 @@ export default { submitValues() { let values = {}; this.fields.forEach((field) => { - if (field.component === 'field-html') { + if (field.isInert) { return; } if (!field.isMultilingual) { diff --git a/src/components/Form/FormGroup.vue b/src/components/Form/FormGroup.vue index 5e3b31974..ce7b6087c 100644 --- a/src/components/Form/FormGroup.vue +++ b/src/components/Form/FormGroup.vue @@ -62,6 +62,7 @@ import FieldPubId from './fields/FieldPubId.vue'; import FieldHtml from './fields/FieldHtml.vue'; import FieldMetadataSetting from './fields/FieldMetadataSetting.vue'; import FieldOptions from './fields/FieldOptions.vue'; +import FieldOrcid from './fields/FieldOrcid.vue'; import FieldPreparedContent from './fields/FieldPreparedContent.vue'; import FieldRadioInput from './fields/FieldRadioInput.vue'; import FieldRichTextarea from './fields/FieldRichTextarea.vue'; @@ -92,6 +93,7 @@ export default { FieldHtml, FieldMetadataSetting, FieldOptions, + FieldOrcid, FieldPreparedContent, FieldRadioInput, FieldRichTextarea, diff --git a/src/components/Form/fields/FieldBase.vue b/src/components/Form/fields/FieldBase.vue index e7714ea37..82529f34d 100644 --- a/src/components/Form/fields/FieldBase.vue +++ b/src/components/Form/fields/FieldBase.vue @@ -33,6 +33,13 @@ export default { groupId: String, /** The ID of the form this field should appear in. This is passed down from the `Form`. */ formId: String, + /** Whether the field should be ignored when a form is submitted (e.g. purely informational field). */ + isInert: { + type: Boolean, + default() { + return false; + }, + }, /** Whether or not this field should be presented for each supported language. */ isMultilingual: Boolean, /** Whether or not a value for this field should be required. */ diff --git a/src/components/Form/fields/FieldOrcid.mdx b/src/components/Form/fields/FieldOrcid.mdx new file mode 100644 index 000000000..56c39e316 --- /dev/null +++ b/src/components/Form/fields/FieldOrcid.mdx @@ -0,0 +1,15 @@ +import {Primary, Controls, Meta, Stories} from '@storybook/blocks'; + +import * as FieldOrcidStories from './FieldOrcid.stories.js'; + +<Meta of={FieldOrcidStories} />{' '} + +# FieldOrcid + +## Usage + +Field used for managing a linked user/author's ORCID + +<Primary /> +<Controls /> +<Stories /> diff --git a/src/components/Form/fields/FieldOrcid.stories.js b/src/components/Form/fields/FieldOrcid.stories.js new file mode 100644 index 000000000..2bca8bd31 --- /dev/null +++ b/src/components/Form/fields/FieldOrcid.stories.js @@ -0,0 +1,65 @@ +import FieldOrcid from '@/components/Form/fields/FieldOrcid.vue'; +import FieldBaseMock from '../mocks/field-base'; +import FieldOrcidMock from '../mocks/field-orcid'; +import {http, HttpResponse} from 'msw'; + +export default { + title: 'Forms/FieldOrcid', + component: FieldOrcid, + render: (args) => ({ + components: {FieldOrcid}, + setup() { + function change(name, prop, newValue, localeKey) { + if (localeKey) { + args[prop][localeKey] = newValue; + } else { + args[prop] = newValue; + } + } + + return {args, change}; + }, + template: ` + <FieldOrcid v-bind="args" @change="change" /> + `, + }), + parameters: { + msw: { + handlers: [ + http.post( + 'https://mock/index.php/publicknowledge/api/v1/orcid/requestAuthorVerification/1', + async () => { + return HttpResponse.json(); + }, + ), + http.post( + 'https://mock/index.php/publicknowledge/api/v1/orcid/deleteForAuthor/1', + async () => { + return HttpResponse.json(); + }, + ), + ], + }, + }, +}; + +export const Base = { + args: {...FieldBaseMock, ...FieldOrcidMock}, +}; + +export const WithOrcid = { + args: { + ...FieldBaseMock, + ...FieldOrcidMock, + orcid: 'https://sandbox.orcid.org/0009-0009-3222-5777', + isVerified: true, + }, +}; + +export const WithUnverifiedOrcid = { + args: { + ...FieldBaseMock, + ...FieldOrcidMock, + orcid: 'https://sandbox.orcid.org/0009-0009-3222-5777', + }, +}; diff --git a/src/components/Form/fields/FieldOrcid.vue b/src/components/Form/fields/FieldOrcid.vue new file mode 100644 index 000000000..73c0f7eaa --- /dev/null +++ b/src/components/Form/fields/FieldOrcid.vue @@ -0,0 +1,251 @@ +<template> + <div class="pkpFormField pkpFormField--html"> + <div class="pkpFormField__heading"> + <span class="pkpFormFieldLabel"> + {{ label }} + </span> + <tooltip v-if="tooltip" aria-hidden="true" :tooltip="tooltip" label="" /> + <span v-if="tooltip" class="-screenReader" v-html="tooltip" /> + <help-button + v-if="helpTopic" + :topic="helpTopic" + :section="helpSection" + :label="t('help.help')" + /> + </div> + <div + v-if="!isVerified && hasOrcid" + class="pkpFormField__description" + v-html="t('orcid.field.unverified.shouldRequest')" + /> + <div> + <!-- When ORCID is present --> + <Icon + v-if="isVerified && hasOrcid" + :class="'mr-2'" + :icon="'orcid'" + :inline="true" + /> + <div + v-if="hasOrcid" + class="pkpFormField__control pkpFormField__control--html" + v-html="orcidDisplayValue" + /> + <pkp-button + v-if="hasOrcid" + class="pkpFormField__control--html__button" + :is-warnable="true" + :is-disabled="isButtonDisabled" + @click="openDeleteDialog" + > + {{ t('common.delete') }} + </pkp-button> + <!-- When ORCID is absent --> + <pkp-button + v-if="!hasOrcid" + :disabled="verificationRequested || isButtonDisabled" + :icon="verificationRequested ? 'Complete' : null" + @click="openSendAuthorEmailDialog" + > + {{ + verificationRequested + ? t('orcid.field.verification.requested') + : t('orcid.field.verification.request') + }} + </pkp-button> + </div> + </div> +</template> + +<script> +import FieldBase from '@/components/Form/fields/FieldBase.vue'; +import {useApiUrl} from '@/composables/useApiUrl'; +import {useFetch} from '@/composables/useFetch'; +import {useModal} from '@/composables/useModal'; + +export default { + name: 'FieldOrcid', + extends: FieldBase, + props: { + /** ORCID URL that has been verified */ + orcid: { + type: String, + required: true, + default: '', + }, + /** Author ID used in ORCID related actions */ + authorId: { + type: Number, + required: true, + default: 0, + }, + /** Whether ORCID ID has been verified and authenticated by the owner */ + isVerified: { + type: Boolean, + required: true, + default: false, + }, + }, + data() { + return { + /** Internal value used for displaying ORCID in component. Takes initial value from `orcid` prop */ + orcidValue: '', + /** Whether an email requesting users verify their ORCID has been sent or not */ + verificationRequested: false, + /** Whether request verification/delete ORCID button should be disabled or not */ + isButtonDisabled: false, + }; + }, + computed: { + /** + * Helper to see if an ORCID value is present + * @returns {boolean} + */ + hasOrcid: function () { + return this.orcidValue.length !== 0; + }, + /** + * Wraps ORCID in <a> tag for HTML display + * @returns {string} + */ + orcidDisplayValue: function () { + if (this.hasOrcid) { + return `<a target="_blank" class="underline" href="${this.orcidValue}">${this.orcidValue}</a>`; + } else { + return this.orcidValue; + } + }, + }, + created() { + this.orcidValue = this.orcid; + }, + methods: { + /** + * Triggers author email request via API + * + * @returns {Promise<void>} + */ + sendAuthorEmail: async function () { + this.isButtonDisabled = true; + + const {apiUrl} = useApiUrl( + `orcid/requestAuthorVerification/${this.authorId}`, + ); + + const {isSuccess, fetch} = useFetch(apiUrl, { + method: 'POST', + expectValidationError: true, + }); + await fetch(); + + if (isSuccess) { + this.verificationRequested = true; + } + + this.isButtonDisabled = false; + }, + /** + * Open confirmation dialog for requesting author ORCID verification + */ + openSendAuthorEmailDialog: function () { + const {openDialog} = useModal(); + openDialog({ + name: 'sendAuthorEmail', + title: this.t('orcid.field.authorEmailModal.title'), + message: this.t('orcid.field.authorEmailModal.message'), + actions: [ + { + label: this.t('common.yes'), + isPrimary: true, + callback: async (close) => { + await this.sendAuthorEmail(); + close(); + }, + }, + { + label: this.t('common.no'), + isWarnable: true, + callback: (close) => { + close(); + }, + }, + ], + close: () => {}, + }); + }, + /** + * Trigger API request to remove ORCID and access tokens from author/user + * + * @returns {Promise<void>} + */ + deleteOrcid: async function () { + this.isButtonDisabled = true; + + const {apiUrl} = useApiUrl(`orcid/deleteForAuthor/${this.authorId}`); + const {isSuccess, fetch} = useFetch(apiUrl, { + method: 'POST', + expectValidationError: true, + }); + + await fetch(); + + if (isSuccess) { + this.orcidValue = ''; + } + + this.isButtonDisabled = false; + }, + /** + * Opens dialog to confirm deletion of ORCID from author/user + */ + openDeleteDialog: function () { + const {openDialog} = useModal(); + openDialog({ + name: 'deleteOrcid', + title: this.t('orcid.field.deleteOrcidModal.title'), + message: this.t('orcid.field.deleteOrcidModal.message'), + actions: [ + { + label: this.t('common.yes'), + isPrimary: true, + callback: async (close) => { + await this.deleteOrcid(); + close(); + }, + }, + { + label: this.t('common.no'), + isWarnable: true, + callback: (close) => { + close(); + }, + }, + ], + close: () => {}, + }); + }, + }, +}; +</script> + +<style lang="less"> +@import '../../../styles/_import'; + +.pkpFormField__control--html { + font-size: @font-sml; + line-height: 1.8em; + display: inline-block; + + p:first-child { + margin-top: 0; + } + + p:last-child { + margin-bottom: 0; + } +} + +.pkpFormField__control--html__button { + margin-inline-start: 0.25rem; +} +</style> diff --git a/src/components/Form/mocks/field-orcid.js b/src/components/Form/mocks/field-orcid.js new file mode 100644 index 000000000..a08ab562b --- /dev/null +++ b/src/components/Form/mocks/field-orcid.js @@ -0,0 +1,9 @@ +export default { + name: 'orcid', + component: 'field-orcid', + label: 'ORCID', + orcid: '', + authorId: 1, + tooltip: + 'ORCID is an independent non-profit organization that provides a persistent identifier – an ORCID iD – that distinguishes you from other researchers and a mechanism for linking your research outputs and activities to your iD. ORCID is integrated into many systems used by publishers, funders, institutions, and other research-related services. Learn more at https://orcid.org.', +}; diff --git a/src/components/ListPanel/contributors/ContributorsListPanel.vue b/src/components/ListPanel/contributors/ContributorsListPanel.vue index 32cbe1ab2..782c8159b 100644 --- a/src/components/ListPanel/contributors/ContributorsListPanel.vue +++ b/src/components/ListPanel/contributors/ContributorsListPanel.vue @@ -436,7 +436,7 @@ export default { }, ); this.$emit('updated:contributors', newContributors); - + this.getAndUpdatePublication(); }, complete(r) { @@ -475,7 +475,11 @@ export default { activeForm.action = apiUrl; activeForm.method = 'PUT'; activeForm.fields = activeForm.fields.map((field) => { - if (Object.keys(author).includes(field.name)) { + if (field.name === 'orcid') { + field.orcid = author['orcid'] ?? ''; + field.authorId = author['id']; + field.isVerified = author['orcidIsVerified'] ?? false; + } else if (Object.keys(author).includes(field.name)) { field.value = author[field.name]; } return field; @@ -654,7 +658,6 @@ export default { }, }); }, - }, }; </script> diff --git a/src/styles/_global.less b/src/styles/_global.less index abf2cb657..cb3cbf18a 100644 --- a/src/styles/_global.less +++ b/src/styles/_global.less @@ -2,8 +2,8 @@ @tailwind base; -/* - Apply some styles for backward-compatibility as tailwind by +/* + Apply some styles for backward-compatibility as tailwind by default resets styling of headers This is just temporary until styling is migrated to taildwind classes */