diff --git a/src/features/orcidlink/HomeLinked/LinkInfo.tsx b/src/features/orcidlink/HomeLinked/LinkInfo.tsx index 377f6351..710db12b 100644 --- a/src/features/orcidlink/HomeLinked/LinkInfo.tsx +++ b/src/features/orcidlink/HomeLinked/LinkInfo.tsx @@ -2,11 +2,9 @@ import { LinkRecordPublic, ORCIDProfile, } from '../../../common/api/orcidLinkCommon'; -import { - renderCreditName, - renderORCIDId, - renderRealname, -} from '../common/misc'; +import CreditName from '../common/CreditName'; +import { ORCIDIdLink } from '../common/ORCIDIdLink'; +import RealName from '../common/RealName'; import Scopes from '../common/Scopes'; import styles from '../orcidlink.module.scss'; @@ -26,15 +24,24 @@ export default function LinkInfo({
ORCID iD
-
{renderORCIDId(orcidSiteURL, linkRecord.orcid_auth.orcid)}
+
+ +
Name on Account
-
{renderRealname(profile)}
+
+ +
Published Name
-
{renderCreditName(profile)}
+
+ +
Created on
diff --git a/src/features/orcidlink/HomeLinked/index.tsx b/src/features/orcidlink/HomeLinked/index.tsx index de595c2b..95d34cac 100644 --- a/src/features/orcidlink/HomeLinked/index.tsx +++ b/src/features/orcidlink/HomeLinked/index.tsx @@ -2,7 +2,7 @@ import { InfoResult, orcidlinkAPI } from '../../../common/api/orcidlinkAPI'; import { useAppSelector } from '../../../common/hooks'; import { authUsername } from '../../auth/authSlice'; import ErrorMessage from '../common/ErrorMessage'; -import { renderLoadingOverlay } from '../common/misc'; +import LoadingOverlay from '../common/LoadingOverlay'; import HomeLinked from './view'; export interface HomeLinkedControllerProps { @@ -35,7 +35,7 @@ export default function HomeLinkedController({ return ( <> - {renderLoadingOverlay(isFetching)} + {renderState()} ); diff --git a/src/features/orcidlink/common/CreditName.test.tsx b/src/features/orcidlink/common/CreditName.test.tsx new file mode 100644 index 00000000..5f36f895 --- /dev/null +++ b/src/features/orcidlink/common/CreditName.test.tsx @@ -0,0 +1,36 @@ +import { render } from '@testing-library/react'; +import 'core-js/stable/structured-clone'; +import { ORCIDProfile } from '../../../common/api/orcidLinkCommon'; +import { PROFILE_1 } from '../test/data'; +import CreditName from './CreditName'; + +describe('The renderCreditName render function', () => { + it('renders correctly if not private', () => { + const profile = structuredClone(PROFILE_1); + + const { container } = render(); + + expect(container).toHaveTextContent('Foo B. Bar'); + }); + + it('renders a special string if it is private', () => { + const profile = structuredClone(PROFILE_1); + + profile.nameGroup.private = true; + + const { container } = render(); + + expect(container).toHaveTextContent('private'); + }); + + it('renders a "not available" string if it is absent', () => { + const profile = structuredClone(PROFILE_1); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + profile.nameGroup.fields!.creditName = null; + + const { container } = render(); + + expect(container).toHaveTextContent('n/a'); + }); +}); diff --git a/src/features/orcidlink/common/CreditName.tsx b/src/features/orcidlink/common/CreditName.tsx new file mode 100644 index 00000000..fbb420d6 --- /dev/null +++ b/src/features/orcidlink/common/CreditName.tsx @@ -0,0 +1,23 @@ +import { Typography } from '@mui/material'; +import { ORCIDProfile } from '../../../common/api/orcidLinkCommon'; +import NA from './NA'; +import PrivateField from './PrivateField'; + +export interface CreditNameProps { + profile: ORCIDProfile; +} + +/** + * Displays an ORCID user profile "published name" - an optional name a user may + * specify in their ORCID profile to be used in publication credit, instead of + * their given (required) and family names (optiona). + */ +export default function CreditName({ profile }: CreditNameProps) { + if (profile.nameGroup.private) { + return ; + } + if (!profile.nameGroup.fields.creditName) { + return ; + } + return {profile.nameGroup.fields.creditName}; +} diff --git a/src/features/orcidlink/common/ErrorMessage.tsx b/src/features/orcidlink/common/ErrorMessage.tsx index f133457c..938958c7 100644 --- a/src/features/orcidlink/common/ErrorMessage.tsx +++ b/src/features/orcidlink/common/ErrorMessage.tsx @@ -1,8 +1,8 @@ /** * Displays an error message as may be retured by an RTK query. * - * Currently very basci, just displaying the message in an Alert. However, some - * errors can clearly use a more specialized display. + * Currently very basic, just displaying the message in an Alert. However, some + * errors would benefit from a more specialized display. */ import Alert from '@mui/material/Alert'; import { SerializedError } from '@reduxjs/toolkit'; diff --git a/src/features/orcidlink/common/misc.module.scss b/src/features/orcidlink/common/LoadingOverlay.module.scss similarity index 79% rename from src/features/orcidlink/common/misc.module.scss rename to src/features/orcidlink/common/LoadingOverlay.module.scss index 69a5a2f6..6958b70b 100644 --- a/src/features/orcidlink/common/misc.module.scss +++ b/src/features/orcidlink/common/LoadingOverlay.module.scss @@ -1,7 +1,3 @@ -.orcid-icon { - height: 24px; - margin-right: 0.25em; -} /* A wrapper around a loading indicator when presented standalone */ .loading { @@ -11,7 +7,7 @@ justify-content: center; margin-top: 8rem; } - + .loading-title { margin-left: 0.5rem; } diff --git a/src/features/orcidlink/common/LoadingOverlay.tsx b/src/features/orcidlink/common/LoadingOverlay.tsx new file mode 100644 index 00000000..1b436c12 --- /dev/null +++ b/src/features/orcidlink/common/LoadingOverlay.tsx @@ -0,0 +1,40 @@ +import { Alert, AlertTitle, CircularProgress, Modal } from '@mui/material'; +import styles from './LoadingOverlay.module.scss'; + +export interface LoadingAlertProps { + title: string; + description: string; +} + +/** + * A wrapper around MUI Alert to show a loading indicator (spinner) and message, + * with a description. + */ +export function LoadingAlert({ title, description }: LoadingAlertProps) { + return ( +
+ }> + + {title} + +

{description}

+
+
+ ); +} + +export interface LoadingOverlayProps { + open: boolean; +} + +/** + * Displays a model containing a loading alert as defined above, for usage in + * covering and blocking the screen while a process is in progress. + */ +export default function LoadingOverlay({ open }: LoadingOverlayProps) { + return ( + + + + ); +} diff --git a/src/features/orcidlink/common/NA.tsx b/src/features/orcidlink/common/NA.tsx new file mode 100644 index 00000000..3a2430fc --- /dev/null +++ b/src/features/orcidlink/common/NA.tsx @@ -0,0 +1,13 @@ +import { Typography } from '@mui/material'; + +/** + * Simply a common component to use in place of an empty space for a field which + * is absent or empty. + */ +export default function NA() { + return ( + + n/a + + ); +} diff --git a/src/features/orcidlink/common/ORCIDIdLink.module.scss b/src/features/orcidlink/common/ORCIDIdLink.module.scss new file mode 100644 index 00000000..fd260763 --- /dev/null +++ b/src/features/orcidlink/common/ORCIDIdLink.module.scss @@ -0,0 +1,10 @@ +.main { + align-items: center; + display: flex; + flex-direction: row; +} + +.icon { + height: 24px; + margin-right: 0.25em; +} diff --git a/src/features/orcidlink/common/ORCIDIdLink.test.tsx b/src/features/orcidlink/common/ORCIDIdLink.test.tsx new file mode 100644 index 00000000..00eec477 --- /dev/null +++ b/src/features/orcidlink/common/ORCIDIdLink.test.tsx @@ -0,0 +1,24 @@ +import { render, screen } from '@testing-library/react'; +import 'core-js/stable/structured-clone'; +import { ORCIDIdLink } from './ORCIDIdLink'; + +describe('The renderORCIDId render function', () => { + it('renders an orcid id link', async () => { + const baseURL = 'http://example.com'; + const orcidId = 'abc123'; + const expectedURL = `${baseURL}/${orcidId}`; + + const { container } = render( + + ); + + expect(container).toHaveTextContent(orcidId); + + const link = await screen.findByText(expectedURL); + expect(link).toHaveAttribute('href', expectedURL); + + const image = await screen.findByAltText('ORCID Icon'); + expect(image).toBeVisible(); + expect(image.getAttribute('src')).toContain('ORCID-iD_icon-vector.svg'); + }); +}); diff --git a/src/features/orcidlink/common/ORCIDIdLink.tsx b/src/features/orcidlink/common/ORCIDIdLink.tsx new file mode 100644 index 00000000..59e9cfb0 --- /dev/null +++ b/src/features/orcidlink/common/ORCIDIdLink.tsx @@ -0,0 +1,25 @@ +import { image } from '../images'; +import styles from './ORCIDIdLink.module.scss'; + +export interface ORCIDIdLinkProps { + url: string; + orcidId: string; +} + +/** + * Renders an anchor link to an ORCID profile in the form recommended by ORCID. + * + */ +export function ORCIDIdLink({ url, orcidId }: ORCIDIdLinkProps) { + return ( + + ORCID Icon + {url}/{orcidId} + + ); +} diff --git a/src/features/orcidlink/common/PrivateField.test.tsx b/src/features/orcidlink/common/PrivateField.test.tsx new file mode 100644 index 00000000..365425c8 --- /dev/null +++ b/src/features/orcidlink/common/PrivateField.test.tsx @@ -0,0 +1,9 @@ +import { render } from '@testing-library/react'; +import PrivateField from './PrivateField'; + +describe('The PrivateField component', () => { + it('renders the expected text', () => { + const { container } = render(); + expect(container).toHaveTextContent('private'); + }); +}); diff --git a/src/features/orcidlink/common/PrivateField.tsx b/src/features/orcidlink/common/PrivateField.tsx new file mode 100644 index 00000000..9511bee9 --- /dev/null +++ b/src/features/orcidlink/common/PrivateField.tsx @@ -0,0 +1,10 @@ +import { Typography } from '@mui/material'; + +/** + * Should be used to indicate that an ORCID profile field has been made private + * by the owner, and may not be viewed by anyone else. + * + */ +export default function PrivateField() { + return private; +} diff --git a/src/features/orcidlink/common/RealName.test.tsx b/src/features/orcidlink/common/RealName.test.tsx new file mode 100644 index 00000000..c5d95fcc --- /dev/null +++ b/src/features/orcidlink/common/RealName.test.tsx @@ -0,0 +1,34 @@ +import { render } from '@testing-library/react'; +import 'core-js/stable/structured-clone'; +import { ORCIDProfile } from '../../../common/api/orcidLinkCommon'; +import { PROFILE_1 } from '../test/data'; +import RealName from './RealName'; + +describe('the renderRealName render function ', () => { + it('renders correctly if not private', () => { + const profile = structuredClone(PROFILE_1); + + const { container } = render(); + + expect(container).toHaveTextContent('Foo Bar'); + }); + + it('renders just the first name if no last name', () => { + const profile = structuredClone(PROFILE_1); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + profile.nameGroup.fields!.lastName = null; + + const { container } = render(); + + expect(container).toHaveTextContent('Foo'); + }); + + it('renders a special string if it is private', () => { + const profile = structuredClone(PROFILE_1); + profile.nameGroup.private = true; + + const { container } = render(); + + expect(container).toHaveTextContent('private'); + }); +}); diff --git a/src/features/orcidlink/common/RealName.tsx b/src/features/orcidlink/common/RealName.tsx new file mode 100644 index 00000000..cc376ffb --- /dev/null +++ b/src/features/orcidlink/common/RealName.tsx @@ -0,0 +1,26 @@ +import { Typography } from '@mui/material'; +import { ORCIDProfile } from '../../../common/api/orcidLinkCommon'; +import PrivateField from './PrivateField'; + +export interface RealNameProps { + profile: ORCIDProfile; +} + +/** + * Renders user's name from their ORCID profile. + */ +export default function RealName({ profile }: RealNameProps) { + if (profile.nameGroup.private) { + return ; + } + + const { firstName, lastName } = profile.nameGroup.fields; + if (lastName) { + return ( + + {firstName} {lastName} + + ); + } + return {firstName}; +} diff --git a/src/features/orcidlink/common/Scopes.tsx b/src/features/orcidlink/common/Scopes.tsx index 41f401f9..2116b275 100644 --- a/src/features/orcidlink/common/Scopes.tsx +++ b/src/features/orcidlink/common/Scopes.tsx @@ -30,6 +30,13 @@ function isScope(possibleScope: string): possibleScope is ORCIDScope { return ['/read-limited', '/activities/update'].includes(possibleScope); } +/** + * Renders the scopes as provided by an ORCID profile - a string containing + * space-separated scope identifiers. + * + * The scopes are displayed in a collapsed "accordion" component, the detail + * area of which contains the description of the associated scope. + */ export default function Scopes({ scopes }: ScopesProps) { const rows = scopes.split(/\s+/).map((scope: string, index) => { if (!isScope(scope)) { diff --git a/src/features/orcidlink/common/misc.test.tsx b/src/features/orcidlink/common/misc.test.tsx deleted file mode 100644 index f958edc4..00000000 --- a/src/features/orcidlink/common/misc.test.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import 'core-js/stable/structured-clone'; -import { ORCIDProfile } from '../../../common/api/orcidLinkCommon'; -import { PROFILE_1 } from '../test/data'; -import { renderCreditName, renderORCIDId, renderRealname } from './misc'; - -describe('The miscellaneous shared components module', () => { - describe('the renderRealName render function ', () => { - it('renders correctly if not private', () => { - const profile = structuredClone(PROFILE_1); - - const { container } = render(renderRealname(profile)); - - expect(container).toHaveTextContent('Foo Bar'); - }); - - it('renders just the first name if no last name', () => { - const profile = structuredClone(PROFILE_1); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - profile.nameGroup.fields!.lastName = null; - - const { container } = render(renderRealname(profile)); - - expect(container).toHaveTextContent('Foo'); - }); - - it('renders a special string if it is private', () => { - const profile = structuredClone(PROFILE_1); - profile.nameGroup.private = true; - - const { container } = render(renderRealname(profile)); - - expect(container).toHaveTextContent('private'); - }); - }); - - describe('The renderCreditName render function', () => { - it('renders correctly if not private', () => { - const profile = structuredClone(PROFILE_1); - - const { container } = render(renderCreditName(profile)); - - expect(container).toHaveTextContent('Foo B. Bar'); - }); - - it('renders a special string if it is private', () => { - const profile = structuredClone(PROFILE_1); - - profile.nameGroup.private = true; - - const { container } = render(renderCreditName(profile)); - - expect(container).toHaveTextContent('private'); - }); - - it('renders a "not available" string if it is absent', () => { - const profile = structuredClone(PROFILE_1); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - profile.nameGroup.fields!.creditName = null; - - const { container } = render(renderCreditName(profile)); - - expect(container).toHaveTextContent('n/a'); - }); - }); - - describe('The renderORCIDId render function', () => { - it('renders an orcid id link', async () => { - const baseURL = 'http://example.com'; - const orcidId = 'abc123'; - const expectedURL = `${baseURL}/${orcidId}`; - - const { container } = render(renderORCIDId(baseURL, orcidId)); - - expect(container).toHaveTextContent(orcidId); - - const link = await screen.findByText(expectedURL); - expect(link).toHaveAttribute('href', expectedURL); - - const image = await screen.findByAltText('ORCID Icon'); - expect(image).toBeVisible(); - expect(image.getAttribute('src')).toContain('ORCID-iD_icon-vector.svg'); - }); - }); -}); diff --git a/src/features/orcidlink/common/misc.tsx b/src/features/orcidlink/common/misc.tsx deleted file mode 100644 index ec12615e..00000000 --- a/src/features/orcidlink/common/misc.tsx +++ /dev/null @@ -1,120 +0,0 @@ -/** - * Miscellaneous commmonly used rendering functions - * - * If a bit of rendered content is used more than once, it should be wrapped in - * a function and moved here. - * - */ -import { - Alert, - AlertTitle, - CircularProgress, - Modal, - Typography, -} from '@mui/material'; -import { ORCIDProfile } from '../../../common/api/orcidLinkCommon'; -import { image } from '../images'; -import styles from './misc.module.scss'; - -export function renderORCIDIcon() { - return ( - ORCID Icon - ); -} - -function renderPrivateField() { - return private; -} - -function renderNA() { - return ( - - n/a - - ); -} - -export function renderRealname(profile: ORCIDProfile) { - // Name is the one stored from the original linking, may have changed. - if (profile.nameGroup.private) { - return renderPrivateField(); - } - - const { firstName, lastName } = profile.nameGroup.fields; - if (lastName) { - return ( - - {firstName} {lastName} - - ); - } - return {firstName}; -} - -/** - * Renders the "credit name" from an ORCID User Profile. - * - * This is an optional field, so we are repared to render as n/a. - * - * @param profile An ORCID User Profile - * @returns - */ -export function renderCreditName(profile: ORCIDProfile) { - if (profile.nameGroup.private) { - return renderPrivateField(); - } - if (!profile.nameGroup.fields.creditName) { - return renderNA(); - } - return {profile.nameGroup.fields.creditName}; -} - -function renderLoading(title: string, description: string) { - return ( -
- }> - - {title} - -

{description}

-
-
- ); -} - -export function renderLoadingOverlay(open: boolean) { - return ( - - {renderLoading('Loading...', 'Loading ORCID Link')} - - ); -} - -/** - * Creates a link to an ORCID profile in the form recommended by ORCID. - * - * @param orcidSiteURL A URL to an ORCID public site that hosts orcid profiles - * @param orcidId An ORCID iD - * @returns An anchor component linking to the given ORCID iD - */ -export function renderORCIDId(orcidSiteURL: string, orcidId: string) { - return ( - - {renderORCIDIcon()} - {orcidSiteURL}/{orcidId} - - ); -}