diff --git a/package-lock.json b/package-lock.json index 67c2dff1..0a2df255 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1872,9 +1872,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.5.tgz", - "integrity": "sha512-NdUTHcPe4C99WxPub+K9l9tK5/lV4UXIoaHSYgzco9BCyjKAAwzdBI+wWtYqHt7LJdbo74ZjRPJgzVweq1sz0w==", + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.5.tgz", + "integrity": "sha512-Nms86NXrsaeU9vbBJKni6gXiEXZ4CVpYVzEjDH9Sb8vmZ3UljyA1GSOJl/6LGPO8EHLuSF9H+IxNXHPX8QHJ4g==", "dependencies": { "regenerator-runtime": "^0.14.0" }, diff --git a/public/assets/images/ORCID-iD_icon-vector.svg b/public/assets/images/ORCID-iD_icon-vector.svg new file mode 100644 index 00000000..c1500de6 --- /dev/null +++ b/public/assets/images/ORCID-iD_icon-vector.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/common/api/orcidLinkCommon.ts b/src/common/api/orcidLinkCommon.ts new file mode 100644 index 00000000..e815d7d7 --- /dev/null +++ b/src/common/api/orcidLinkCommon.ts @@ -0,0 +1,73 @@ +export interface ORCIDAuthPublic { + expires_in: number; + name: string; + orcid: string; + scope: string; +} + +export interface LinkRecordPublic { + created_at: number; + expires_at: number; + retires_at: number; + username: string; + orcid_auth: ORCIDAuthPublic; +} + +export interface ORCIDAuthPublicNonOwner { + orcid: string; + name: string; +} + +export interface LinkRecordPublicNonOwner { + username: string; + orcid_auth: ORCIDAuthPublicNonOwner; +} + +// ORCID User Profile (our version) + +export interface Affiliation { + name: string; + role: string; + startYear: string; + endYear: string | null; +} + +export interface ORCIDFieldGroupBase { + private: boolean; +} + +export interface ORCIDFieldGroupPrivate extends ORCIDFieldGroupBase { + private: true; + fields: null; +} + +export interface ORCIDFieldGroupAccessible extends ORCIDFieldGroupBase { + private: false; + fields: T; +} + +export type ORCIDFieldGroup = + | ORCIDFieldGroupPrivate + | ORCIDFieldGroupAccessible; + +export interface ORCIDNameFieldGroup { + firstName: string; + lastName: string | null; + creditName: string | null; +} + +export interface ORCIDBiographyFieldGroup { + bio: string; +} + +export interface ORCIDEmailFieldGroup { + emailAddresses: Array; +} + +export interface ORCIDProfile { + orcidId: string; + nameGroup: ORCIDFieldGroup; + biographyGroup: ORCIDFieldGroup; + emailGroup: ORCIDFieldGroup; + employment: Array; +} diff --git a/src/common/api/orcidlinkAPI.ts b/src/common/api/orcidlinkAPI.ts index 3bf4bc14..2ddab6a0 100644 --- a/src/common/api/orcidlinkAPI.ts +++ b/src/common/api/orcidlinkAPI.ts @@ -1,53 +1,64 @@ import { baseApi } from '.'; +import { LinkRecordPublic, ORCIDProfile } from './orcidLinkCommon'; import { jsonRpcService } from './utils/serviceHelpers'; -// orcidlink system types +// system info -export interface ORCIDAuthPublic { - expires_in: number; +export interface ServiceDescription { name: string; - orcid: string; - scope: string; + title: string; + version: string; + language: string; + description: string; + repoURL: string; } -export interface LinkRecordPublic { - created_at: number; - expires_at: number; - retires_at: number; - username: string; - orcid_auth: ORCIDAuthPublic; +export interface GitInfo { + commit_hash: string; + commit_hash_abbreviated: string; + author_name: string; + committer_name: string; + committer_date: number; + url: string; + branch: string; + tag: string | null; } -// Method types - -export interface StatusResult { - status: string; +export interface RuntimeInfo { current_time: number; - start_time: number; + orcid_api_url: string; + orcid_oauth_url: string; + orcid_site_url: string; } +// TODO: normalize to either kebab or underscore. Pref underscore. export interface InfoResult { - 'service-description': { - name: string; - title: string; - version: string; - }; + 'service-description': ServiceDescription; + 'git-info': GitInfo; + runtime_info: RuntimeInfo; } -// is-linked +// combined api calls for initial view + +export interface ORCIDLinkInitialStateResult { + isLinked: boolean; + info: InfoResult; +} -export interface IsLinkedParams { +export interface ORCIDLinkInitialStateParams { username: string; } -export type IsLinkedResult = boolean; +// combined api call for linked user info -// owner-link -export interface OwnerLinkParams { - username: string; +export interface ORCIDLinkLinkedUserInfoResult { + linkRecord: LinkRecordPublic; + profile: ORCIDProfile; } -export type OwnerLinkResult = LinkRecordPublic; +export interface ORCIDLinkLinkedUserInfoParams { + username: string; +} // It is mostly a JSONRPC 2.0 service, although the oauth flow is rest-ish. const orcidlinkService = jsonRpcService({ @@ -62,31 +73,76 @@ export const orcidlinkAPI = baseApi .enhanceEndpoints({ addTagTypes: ['ORCIDLink'] }) .injectEndpoints({ endpoints: ({ query }) => ({ - orcidlinkStatus: query({ - query: () => { - return orcidlinkService({ - method: 'status', - }); - }, - }), - orcidlinkIsLinked: query({ - query: ({ username }) => { - return orcidlinkService({ - method: 'is-linked', - params: { - username, + orcidlinkInitialState: query< + ORCIDLinkInitialStateResult, + ORCIDLinkInitialStateParams + >({ + async queryFn({ username }, _queryApi, _extraOptions, fetchWithBQ) { + const [isLinked, info] = await Promise.all([ + fetchWithBQ( + orcidlinkService({ + method: 'is-linked', + params: { + username, + }, + }) + ), + fetchWithBQ( + orcidlinkService({ + method: 'info', + }) + ), + ]); + if (isLinked.error) { + return { error: isLinked.error }; + } + if (info.error) { + return { error: info.error }; + } + return { + data: { + isLinked: isLinked.data as boolean, + info: info.data as InfoResult, }, - }); + }; }, }), - orcidlinkOwnerLink: query({ - query: ({ username }) => { - return orcidlinkService({ - method: 'owner-link', + orcidlinkLinkedUserInfo: query< + ORCIDLinkLinkedUserInfoResult, + ORCIDLinkLinkedUserInfoParams + >({ + async queryFn({ username }, _queryApi, _extraOptions, fetchWithBQ) { + const profileQuery = orcidlinkService({ + method: 'get-orcid-profile', params: { username, }, }); + + const [linkRecord, profile] = await Promise.all([ + fetchWithBQ( + orcidlinkService({ + method: 'owner-link', + params: { + username, + }, + }) + ), + fetchWithBQ(profileQuery), + ]); + if (linkRecord.error) { + return { error: linkRecord.error }; + } + + if (profile.error) { + return { error: profile.error }; + } + return { + data: { + linkRecord: linkRecord.data as LinkRecordPublic, + profile: profile.data as ORCIDProfile, + }, + }; }, }), }), diff --git a/src/common/api/utils/common.ts b/src/common/api/utils/common.ts index 860baef2..8139fd03 100644 --- a/src/common/api/utils/common.ts +++ b/src/common/api/utils/common.ts @@ -49,6 +49,27 @@ export const isJsonRpcError = (obj: unknown): obj is JsonRpcError => { return false; }; +export const isJsonRpc20Error = (obj: unknown): obj is JsonRpcError => { + if ( + typeof obj === 'object' && + obj !== null && + ['jsonrpc', 'error', 'id'].every((k) => k in obj) + ) { + const { jsonrpc, error } = obj as { jsonrpc: string; error: unknown }; + if (jsonrpc !== '2.0') { + return false; + } + if ( + typeof error === 'object' && + error !== null && + ['code', 'message'].every((k) => k in error) + ) { + return true; + } + } + return false; +}; + /** * Type predicate to narrow an unknown error to `FetchBaseQueryError` */ diff --git a/src/common/api/utils/kbaseBaseQuery.ts b/src/common/api/utils/kbaseBaseQuery.ts index 0d4347ed..eff973d4 100644 --- a/src/common/api/utils/kbaseBaseQuery.ts +++ b/src/common/api/utils/kbaseBaseQuery.ts @@ -7,7 +7,11 @@ import { } from '@reduxjs/toolkit/query/react'; import { RootState } from '../../../app/store'; import { serviceWizardApi } from '../serviceWizardApi'; -import { isJsonRpcError, KBaseBaseQueryError } from './common'; +import { + isJsonRpc20Error, + isJsonRpcError, + KBaseBaseQueryError, +} from './common'; export interface DynamicService { name: string; @@ -41,6 +45,12 @@ export interface JSONRPC20Body { params?: unknown; } +export interface JSONRPC20Error { + code: number; + message: string; + data: unknown; +} + export type JSONRPCBody = JSONRPC11Body | JSONRPC20Body; export interface HttpQueryArgs extends FetchArgs { @@ -226,6 +236,7 @@ export const kbaseBaseQuery: ( const response = await request; // identify and better differentiate jsonRpc errors + // This is for KBase's version of JSON-RPC 1.1 if (response.error && response.error.status === 500) { if (isJsonRpcError(response.error.data)) { if (response.error.data.id && response.error.data.id !== reqId) { @@ -245,6 +256,23 @@ export const kbaseBaseQuery: ( }; } } + if (isJsonRpc20Error(response.data)) { + if (response.data.id && response.data.id !== reqId) { + return { + error: { + status: 'CUSTOM_ERROR', + error: 'JsonRpcProtocolError', + data: `Response ID "${response.data.id}" !== Request ID "${reqId}"`, + }, + }; + } + return { + error: { + status: 'JSONRPC_ERROR', + data: response.data, + }, + }; + } // If another error has occurred preventing a response, return default rtk-query response. // This appropriately handles rtk-query internal errors diff --git a/src/common/api/utils/serviceHelpers.ts b/src/common/api/utils/serviceHelpers.ts index 139f2afd..25119cd8 100644 --- a/src/common/api/utils/serviceHelpers.ts +++ b/src/common/api/utils/serviceHelpers.ts @@ -1,4 +1,4 @@ -import { JsonRpcQueryArgs, HttpQueryArgs } from './kbaseBaseQuery'; +import { HttpQueryArgs, JsonRpcQueryArgs } from './kbaseBaseQuery'; // Helpers for adding service info to each query, export const jsonRpcService = ( diff --git a/src/features/orcidlink/Home.tsx b/src/features/orcidlink/Home.tsx deleted file mode 100644 index 233bcf74..00000000 --- a/src/features/orcidlink/Home.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { Alert, AlertTitle, CircularProgress } from '@mui/material'; -import { SerializedError } from '@reduxjs/toolkit'; -import { orcidlinkAPI } from '../../common/api/orcidlinkAPI'; -import { KBaseBaseQueryError } from '../../common/api/utils/common'; -import { useAppSelector } from '../../common/hooks'; -import { authUsername } from '../auth/authSlice'; -import { usePageTitle } from '../layout/layoutSlice'; -import ErrorMessage from './ErrorMessage'; -import Linked from './Linked'; -import styles from './orcidlink.module.scss'; -import Unlinked from './Unlinked'; - -export default function Home() { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const username = useAppSelector(authUsername)!; - - function renderLoading(title: string, description: string) { - return ( -
- }> - - {title} - -

{description}

-
-
- ); - } - - function renderError(error: KBaseBaseQueryError | SerializedError) { - return ; - } - - usePageTitle('KBase ORCID Link'); - - const { - data: isLInked, - error, - isLoading, - isError, - isFetching, - isSuccess, - isUninitialized, - } = orcidlinkAPI.useOrcidlinkIsLinkedQuery({ username }); - - if (isUninitialized) { - return renderLoading('Uninitialized...', 'Loading the ORCID Link App...'); - } else if (isLoading) { - return renderLoading('Loading...', 'Loading the ORCID Link App...'); - } else if (isFetching) { - return renderLoading('Fetching...', 'Loading the ORCID Link App...'); - } else if (isError) { - return renderError(error); - } else if (isSuccess) { - if (isLInked) { - return ( -
- -
- ); - } - return ( -
- -
- ); - } else { - return renderError({ - status: 'CUSTOM_ERROR', - error: 'Unknown State', - }); - } -} diff --git a/src/features/orcidlink/Home/Home.test.tsx b/src/features/orcidlink/Home/Home.test.tsx new file mode 100644 index 00000000..3a3bc776 --- /dev/null +++ b/src/features/orcidlink/Home/Home.test.tsx @@ -0,0 +1,41 @@ +import { render } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { SERVICE_INFO_1 } from '../test/data'; +import Home from './Home'; + +// We are not testing the HomeLinked component; we just want to be sure that it +// is rendered. +jest.mock('../HomeLinked', () => { + return { + __esModule: true, + default: () => { + return
Mocked Linked Component
; + }, + }; +}); + +describe('The Home Component', () => { + it('renders correctly for unlinked', () => { + const { container } = render( + + + + ); + + expect(container).not.toBeNull(); + expect(container).toHaveTextContent( + 'You do not currently have a link from your KBase account' + ); + }); + + it('renders correctly for linked', () => { + const { container } = render( + + + + ); + + expect(container).not.toBeNull(); + expect(container).toHaveTextContent('Mocked Linked Component'); + }); +}); diff --git a/src/features/orcidlink/Home/Home.tsx b/src/features/orcidlink/Home/Home.tsx new file mode 100644 index 00000000..1e0a77b5 --- /dev/null +++ b/src/features/orcidlink/Home/Home.tsx @@ -0,0 +1,21 @@ +/** + * The entrypoint to the root of the ORCID Link UI. + * + * Its primary responsibility is to branch to a view for a linked user or an + * unlinked user. + */ +import { InfoResult } from '../../../common/api/orcidlinkAPI'; +import HomeLinked from '../HomeLinked'; +import HomeUnlinked from '../HomeUnlinked'; + +export interface HomeProps { + isLinked: boolean; + info: InfoResult; +} + +export default function Home({ isLinked, info }: HomeProps) { + if (isLinked) { + return ; + } + return ; +} diff --git a/src/features/orcidlink/Home/index.test.tsx b/src/features/orcidlink/Home/index.test.tsx new file mode 100644 index 00000000..11f7c91f --- /dev/null +++ b/src/features/orcidlink/Home/index.test.tsx @@ -0,0 +1,237 @@ +import { act, render, waitFor } from '@testing-library/react'; +import fetchMock, { MockResponseInit } from 'jest-fetch-mock'; +import { ErrorBoundary } from 'react-error-boundary'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import { createTestStore } from '../../../app/store'; +import { setAuth } from '../../auth/authSlice'; +import { + INITIAL_STORE_STATE, + INITIAL_UNAUTHENTICATED_STORE_STATE, +} from '../test/data'; +import { mockIsLinked, mockIsLinked_not } from '../test/mocks'; +import HomeController from './index'; + +jest.mock('../HomeLinked', () => { + return { + __esModule: true, + default: () => { + return
Mocked Linked Component
; + }, + }; +}); + +describe('The HomeController Component', () => { + beforeEach(() => { + fetchMock.resetMocks(); + fetchMock.enableMocks(); + }); + + it('renders mocked "Linked" component if user is linked', async () => { + fetchMock.mockResponseOnce( + async (request): Promise => { + const { pathname } = new URL(request.url); + switch (pathname) { + case '/services/orcidlink/api/v1': { + if (request.method !== 'POST') { + return ''; + } + const body = await request.json(); + switch (body['method']) { + case 'is-linked': { + return mockIsLinked(body); + } + default: + return ''; + } + } + default: + return ''; + } + } + ); + + const { container } = render( + + + + + + ); + + await waitFor(() => { + expect(container).toHaveTextContent('Mocked Linked Component'); + }); + }); + + it('renders "Unlinked" component if user is not linked', async () => { + fetchMock.mockResponseOnce( + async (request): Promise => { + if (request.method !== 'POST') { + return ''; + } + const { pathname } = new URL(request.url); + switch (pathname) { + case '/services/orcidlink/api/v1': { + const body = await request.json(); + switch (body['method']) { + case 'is-linked': { + return mockIsLinked_not(body); + } + default: + return ''; + } + } + default: + return ''; + } + } + ); + + const { container } = render( + + + + + + ); + + await waitFor(() => { + expect(container).toHaveTextContent( + 'You do not currently have a link from your KBase account to an ORCID® account.' + ); + }); + }); + + it('re-renders correctly', async () => { + fetchMock.mockResponse( + async (request): Promise => { + if (request.method !== 'POST') { + return ''; + } + const { pathname } = new URL(request.url); + switch (pathname) { + // MOcks for the orcidlink api + case '/services/orcidlink/api/v1': { + const body = await request.json(); + switch (body['method']) { + case 'is-linked': { + // In this mock, user "foo" is linked, user "bar" is not. + return mockIsLinked(body); + } + default: + return ''; + } + } + default: + return ''; + } + } + ); + + const testStore = createTestStore(INITIAL_STORE_STATE); + + const { container } = render( + + + + + + ); + + await waitFor(() => { + expect(container).toHaveTextContent('Mocked Linked Component'); + }); + + act(() => { + testStore.dispatch( + setAuth({ + token: 'xyz123', + username: 'bar', + tokenInfo: { + created: 123, + expires: 456, + id: 'xyz123', + name: 'Bar Baz', + type: 'Login', + user: 'bar', + cachefor: 890, + }, + }) + ); + }); + + await waitFor(() => { + expect(container).toHaveTextContent( + 'You do not currently have a link from your KBase account to an ORCID® account.' + ); + }); + }); + + it('renders a parse error correctly', async () => { + fetchMock.mockResponseOnce( + async (request): Promise => { + if (request.method !== 'POST') { + return ''; + } + const { pathname } = new URL(request.url); + switch (pathname) { + case '/services/orcidlink/api/v1': { + const body = await request.json(); + switch (body['method']) { + case 'is-linked': { + return { + body: 'bad', + status: 200, + headers: { + 'content-type': 'application/json', + }, + }; + } + default: + return ''; + } + } + default: + return ''; + } + } + ); + + const { container } = render( + + + + + + ); + + await waitFor(() => { + expect(container).toHaveTextContent( + `SyntaxError: Unexpected token 'b', "bad" is not valid JSON` + ); + }); + }); + + it('throws an impossible error if called without authentication', async () => { + const { container } = render( + { + return
{error.message}
; + }} + > + + + + + +
+ ); + + await waitFor(() => { + expect(container).toHaveTextContent( + 'Impossible - username is not defined' + ); + }); + }); +}); diff --git a/src/features/orcidlink/Home/index.tsx b/src/features/orcidlink/Home/index.tsx new file mode 100644 index 00000000..71417558 --- /dev/null +++ b/src/features/orcidlink/Home/index.tsx @@ -0,0 +1,26 @@ +import { orcidlinkAPI } from '../../../common/api/orcidlinkAPI'; +import { useAppSelector } from '../../../common/hooks'; +import { authUsername } from '../../auth/authSlice'; +import { usePageTitle } from '../../layout/layoutSlice'; +import ErrorMessage from '../common/ErrorMessage'; +import Home from './Home'; + +export default function HomeController() { + const username = useAppSelector(authUsername); + + if (typeof username === 'undefined') { + throw new Error('Impossible - username is not defined'); + } + + usePageTitle('KBase ORCID Link'); + + const { data, error, isError, isSuccess } = + orcidlinkAPI.useOrcidlinkInitialStateQuery({ username }); + + if (isError) { + return ; + } else if (isSuccess) { + return ; + } + return <>; +} diff --git a/src/features/orcidlink/HomeLinked/LinkInfo.test.tsx b/src/features/orcidlink/HomeLinked/LinkInfo.test.tsx new file mode 100644 index 00000000..bba096a0 --- /dev/null +++ b/src/features/orcidlink/HomeLinked/LinkInfo.test.tsx @@ -0,0 +1,23 @@ +import { render } from '@testing-library/react'; +import { LINK_RECORD_1, PROFILE_1 } from '../test/data'; +import LinkInfo from './LinkInfo'; + +describe('The LinkInfo component', () => { + it('renders normally', () => { + const linkRecord = LINK_RECORD_1; + + const profile = PROFILE_1; + const orcidSiteURL = 'foo'; + + const { container } = render( + + ); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(container).toHaveTextContent(profile.nameGroup.fields!.creditName!); + }); +}); diff --git a/src/features/orcidlink/HomeLinked/LinkInfo.tsx b/src/features/orcidlink/HomeLinked/LinkInfo.tsx new file mode 100644 index 00000000..710db12b --- /dev/null +++ b/src/features/orcidlink/HomeLinked/LinkInfo.tsx @@ -0,0 +1,71 @@ +import { + LinkRecordPublic, + ORCIDProfile, +} from '../../../common/api/orcidLinkCommon'; +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'; + +export interface LinkInfoProps { + linkRecord: LinkRecordPublic; + profile: ORCIDProfile; + orcidSiteURL: string; +} + +export default function LinkInfo({ + linkRecord, + profile, + orcidSiteURL, +}: LinkInfoProps) { + return ( +
+
+
+
ORCID iD
+
+ +
+
+
+
Name on Account
+
+ +
+
+
+
Published Name
+
+ +
+
+
+
Created on
+
+ {Intl.DateTimeFormat('en-US', { dateStyle: 'short' }).format( + linkRecord.created_at + )} +
+
+
+
Expires on
+
+ {Intl.DateTimeFormat('en-US', { dateStyle: 'short' }).format( + linkRecord.expires_at + )} +
+
+
+
Permissions Granted
+
+ +
+
+
+
+ ); +} diff --git a/src/features/orcidlink/HomeLinked/OverviewTab.test.tsx b/src/features/orcidlink/HomeLinked/OverviewTab.test.tsx new file mode 100644 index 00000000..cbb8091e --- /dev/null +++ b/src/features/orcidlink/HomeLinked/OverviewTab.test.tsx @@ -0,0 +1,21 @@ +import { render, screen } from '@testing-library/react'; +import { LINK_RECORD_1, PROFILE_1, SERVICE_INFO_1 } from '../test/data'; +import OverviewTab from './OverviewTab'; + +describe('The OverviewTab component', () => { + it('renders appropriately for a regular user', async () => { + const info = SERVICE_INFO_1; + const linkRecord = LINK_RECORD_1; + const profile = PROFILE_1; + render( + + ); + + const creditName = 'Foo B. Bar'; + const realName = 'Foo Bar'; + + // Part of the profile should be available + expect(await screen.findByText(creditName)).toBeVisible(); + expect(await screen.findByText(realName)).toBeVisible(); + }); +}); diff --git a/src/features/orcidlink/HomeLinked/OverviewTab.tsx b/src/features/orcidlink/HomeLinked/OverviewTab.tsx new file mode 100644 index 00000000..7a892a03 --- /dev/null +++ b/src/features/orcidlink/HomeLinked/OverviewTab.tsx @@ -0,0 +1,57 @@ +import { + Card, + CardContent, + CardHeader, + Typography, + Unstable_Grid2 as Grid, +} from '@mui/material'; +import { InfoResult } from '../../../common/api/orcidlinkAPI'; +import { + LinkRecordPublic, + ORCIDProfile, +} from '../../../common/api/orcidLinkCommon'; +import LinkInfo from './LinkInfo'; + +export interface OverviewTabProps { + info: InfoResult; + linkRecord: LinkRecordPublic; + profile: ORCIDProfile; +} + +export default function OverviewTab({ + info, + linkRecord, + profile, +}: OverviewTabProps) { + return ( + + + + + + + + + + + + + + NOTES HERE + + + + + + + LINKS TO MORE INFO HERE + + + + + ); +} diff --git a/src/features/orcidlink/HomeLinked/index.test.tsx b/src/features/orcidlink/HomeLinked/index.test.tsx new file mode 100644 index 00000000..7e4edbc9 --- /dev/null +++ b/src/features/orcidlink/HomeLinked/index.test.tsx @@ -0,0 +1,168 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import fetchMock, { MockResponseInit } from 'jest-fetch-mock'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import { createTestStore } from '../../../app/store'; +import { + INITIAL_STORE_STATE, + LINK_RECORD_1, + ORCIDLINK_IS_LINKED_AUTHORIZATION_REQUIRED, + PROFILE_1, + SERVICE_INFO_1, +} from '../test/data'; +import { + jsonRPC20_ErrorResponse, + jsonRPC20_ResultResponse, + mockIsLinked, +} from '../test/mocks'; +import HomeLinkedController from './index'; + +function setupMockRegularUser() { + fetchMock.mockResponse( + async (request): Promise => { + const { pathname } = new URL(request.url); + // put a little delay in here so that we have a better + // chance of catching temporary conditions, like loading. + await new Promise((resolve) => { + setTimeout(() => { + resolve(null); + }, 300); + }); + switch (pathname) { + // Mocks for the orcidlink api + case '/services/orcidlink/api/v1': { + if (request.method !== 'POST') { + return ''; + } + const body = await request.json(); + const id = body['id']; + switch (body['method']) { + case 'is-linked': + // In this mock, user "foo" is linked, user "bar" is not. + return jsonRPC20_ResultResponse(id, mockIsLinked(body)); + case 'get-orcid-profile': + // simulate fetching an orcid profile + return jsonRPC20_ResultResponse(id, PROFILE_1); + case 'owner-link': + // simulate fetching the link record for a user + return jsonRPC20_ResultResponse(id, LINK_RECORD_1); + case 'info': + // simulate getting service info. + return jsonRPC20_ResultResponse(id, SERVICE_INFO_1); + default: + return ''; + } + } + default: + return ''; + } + } + ); +} + +function setupMockRegularUserWithError() { + fetchMock.mockResponse( + async (request): Promise => { + const { pathname } = new URL(request.url); + // put a little delay in here so that we have a better + // chance of catching temporary conditions, like loading. + await new Promise((resolve) => { + setTimeout(() => { + resolve(null); + }, 300); + }); + switch (pathname) { + // MOcks for the orcidlink api + case '/services/orcidlink/api/v1': { + if (request.method !== 'POST') { + return ''; + } + const body = await request.json(); + const id = body['id'] as string; + switch (body['method']) { + case 'is-linked': + return jsonRPC20_ErrorResponse( + id, + ORCIDLINK_IS_LINKED_AUTHORIZATION_REQUIRED + ); + case 'get-orcid-profile': { + return jsonRPC20_ErrorResponse( + id, + ORCIDLINK_IS_LINKED_AUTHORIZATION_REQUIRED + ); + } + case 'owner-link': + // simulate fetching the link record for a user + return jsonRPC20_ResultResponse(id, LINK_RECORD_1); + + case 'info': + // simulate getting service info + return jsonRPC20_ResultResponse(id, SERVICE_INFO_1); + + default: + return ''; + } + } + default: + return ''; + } + } + ); +} + +describe('The HomeLinkedController component', () => { + beforeEach(() => { + fetchMock.resetMocks(); + fetchMock.enableMocks(); + }); + + it('renders normally for a normal user', async () => { + setupMockRegularUser(); + + const info = SERVICE_INFO_1; + + render( + + + + + + ); + + // Now poke around and make sure things are there. + await waitFor(async () => { + expect(screen.queryByText('Loading ORCID Link')).toBeVisible(); + }); + + screen.queryByText('5/1/24'); + await waitFor(async () => { + // Ensure some expected fields are rendered. + expect( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + screen.queryByText(PROFILE_1.nameGroup.fields!.creditName!) + ).toBeVisible(); + expect(screen.queryByText('5/1/24')).toBeVisible(); + }); + }); + + it('renders an error if something goes wrong', async () => { + /** + We arrange for something to go wrong. How about ... token doesn't exist. + */ + setupMockRegularUserWithError(); + + const info = SERVICE_INFO_1; + + const { container } = render( + + + + + + ); + + await waitFor(async () => { + await expect(container).toHaveTextContent('Authorization Required'); + }); + }); +}); diff --git a/src/features/orcidlink/HomeLinked/index.tsx b/src/features/orcidlink/HomeLinked/index.tsx new file mode 100644 index 00000000..95d34cac --- /dev/null +++ b/src/features/orcidlink/HomeLinked/index.tsx @@ -0,0 +1,42 @@ +import { InfoResult, orcidlinkAPI } from '../../../common/api/orcidlinkAPI'; +import { useAppSelector } from '../../../common/hooks'; +import { authUsername } from '../../auth/authSlice'; +import ErrorMessage from '../common/ErrorMessage'; +import LoadingOverlay from '../common/LoadingOverlay'; +import HomeLinked from './view'; + +export interface HomeLinkedControllerProps { + info: InfoResult; +} + +export default function HomeLinkedController({ + info, +}: HomeLinkedControllerProps) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const username = useAppSelector(authUsername)!; + + const { data, error, isError, isFetching, isSuccess } = + orcidlinkAPI.useOrcidlinkLinkedUserInfoQuery( + { username }, + { refetchOnMountOrArgChange: true } + ); + + // Renderers + function renderState() { + if (isError) { + return ; + } else if (isSuccess) { + const { linkRecord, profile } = data; + return ( + + ); + } + } + + return ( + <> + + {renderState()} + + ); +} diff --git a/src/features/orcidlink/HomeLinked/view.test.tsx b/src/features/orcidlink/HomeLinked/view.test.tsx new file mode 100644 index 00000000..4f1b0801 --- /dev/null +++ b/src/features/orcidlink/HomeLinked/view.test.tsx @@ -0,0 +1,67 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { LINK_RECORD_1, PROFILE_1, SERVICE_INFO_1 } from '../test/data'; +import HomeLinked from './view'; + +describe('The HomeLinked Component', () => { + it('renders with minimal props', async () => { + const info = SERVICE_INFO_1; + const linkRecord = LINK_RECORD_1; + const profile = PROFILE_1; + + render( + + ); + + const creditName = 'Foo B. Bar'; + const realName = 'Foo Bar'; + + // Part of the profile should be available + expect(await screen.findByText(creditName)).toBeVisible(); + expect(await screen.findByText(realName)).toBeVisible(); + }); + + it('tab content switches when tabs are selected', async () => { + const info = SERVICE_INFO_1; + const linkRecord = LINK_RECORD_1; + const profile = PROFILE_1; + + render( + + + + ); + + const creditName = 'Foo B. Bar'; + const realName = 'Foo Bar'; + + const overviewAreaTitle = 'Your KBase ORCID Link'; + const removeAreaTitle = 'Remove your KBase ORCID® Link'; + + const overviewTabLabel = 'Overview'; + const manageTabLabel = 'Manage Your Link'; + + const overviewTab = await screen.findByText(overviewTabLabel); + const manageTab = await screen.findByText(manageTabLabel); + + // Part of the profile should be available on initial tab. + expect(await screen.findByText(creditName)).toBeVisible(); + expect(await screen.findByText(realName)).toBeVisible(); + + act(() => { + manageTab.click(); + }); + + waitFor(async () => { + expect(await screen.findByText(removeAreaTitle)).toBeVisible(); + }); + + act(() => { + overviewTab.click(); + }); + + waitFor(async () => { + expect(await screen.findByText(overviewAreaTitle)).toBeVisible(); + }); + }); +}); diff --git a/src/features/orcidlink/HomeLinked/view.tsx b/src/features/orcidlink/HomeLinked/view.tsx new file mode 100644 index 00000000..afe9b16e --- /dev/null +++ b/src/features/orcidlink/HomeLinked/view.tsx @@ -0,0 +1,44 @@ +import { Tab, Tabs } from '@mui/material'; +import { useState } from 'react'; +import { InfoResult } from '../../../common/api/orcidlinkAPI'; +import { + LinkRecordPublic, + ORCIDProfile, +} from '../../../common/api/orcidLinkCommon'; +import TabPanel from '../common/TabPanel'; +import OverviewTab from './OverviewTab'; + +export interface HomeLinkedProps { + info: InfoResult; + linkRecord: LinkRecordPublic; + profile: ORCIDProfile; +} + +export default function HomeLinked({ + info, + linkRecord, + profile, +}: HomeLinkedProps) { + const [tab, setTab] = useState(0); + + return ( + <> + { + setTab(newValue); + }} + value={tab} + > + + + + + + + +
MANAGE TAB
+
+ + ); +} diff --git a/src/features/orcidlink/HomeUnlinked.test.tsx b/src/features/orcidlink/HomeUnlinked.test.tsx new file mode 100644 index 00000000..9c35e1f5 --- /dev/null +++ b/src/features/orcidlink/HomeUnlinked.test.tsx @@ -0,0 +1,17 @@ +import { render } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import HomeUnlinked from './HomeUnlinked'; + +describe('The HomeUnlinked Component', () => { + it('renders correctly', () => { + const { container } = render( + + + + ); + + expect(container).toHaveTextContent( + 'You do not currently have a link from your KBase account to an ORCID® account.' + ); + }); +}); diff --git a/src/features/orcidlink/HomeUnlinked.tsx b/src/features/orcidlink/HomeUnlinked.tsx new file mode 100644 index 00000000..9d0580a3 --- /dev/null +++ b/src/features/orcidlink/HomeUnlinked.tsx @@ -0,0 +1,58 @@ +/** + * Displays the "home page", or primary orcidlink view, for unlinked users. + */ +import { faPlus } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + Button, + Card, + CardActions, + CardContent, + CardHeader, + Typography, + Unstable_Grid2 as Grid, +} from '@mui/material'; + +export default function Unlinked() { + return ( + + + + + + + You do not currently have a link from your KBase account to an + ORCID® account. + + + Click the button below to begin the KBase ORCID® Link process. + + + + + + + + + + + + NOTES HERE + + + + + + LINKS TO MORE INFORMATION HERE + + + + + ); +} diff --git a/src/features/orcidlink/Linked.tsx b/src/features/orcidlink/Linked.tsx deleted file mode 100644 index c32e2a7c..00000000 --- a/src/features/orcidlink/Linked.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { Alert, AlertTitle, CircularProgress } from '@mui/material'; -import { SerializedError } from '@reduxjs/toolkit'; -import { orcidlinkAPI, OwnerLinkResult } from '../../common/api/orcidlinkAPI'; -import { KBaseBaseQueryError } from '../../common/api/utils/common'; -import { useAppSelector } from '../../common/hooks'; -import { authUsername } from '../auth/authSlice'; -import ErrorMessage from './ErrorMessage'; -import styles from './orcidlink.module.scss'; - -export default function Linked() { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const username = useAppSelector(authUsername)!; - - function renderLoading(title: string, description: string) { - return ( -
- }> - - {title} - -

{description}

-
-
- ); - } - - function renderLink(data: OwnerLinkResult) { - return ( -
-

Congratulations! You do indeed have an ORCID Link

-
-
-
Username
-
{data.username}
-
-
-
ORCID Id
-
{data.orcid_auth.orcid}
-
-
-
Name at ORCID
-
{data.orcid_auth.name}
-
-
-
- ); - } - - function renderError(error: KBaseBaseQueryError | SerializedError) { - return ; - } - - const { - data, - error, - isLoading, - isError, - isFetching, - isSuccess, - isUninitialized, - } = orcidlinkAPI.useOrcidlinkOwnerLinkQuery({ username }); - - if (isUninitialized) { - return renderLoading('Uninitialized...', 'Loading your ORCID Link...'); - } else if (isLoading) { - return renderLoading('Loading...', 'Loading your ORCID Link ...'); - } else if (isFetching) { - return renderLoading('Fetching...', 'Loading your ORCID Link...'); - } else if (isError) { - return renderError(error); - } else if (isSuccess) { - return renderLink(data); - } else { - return renderError({ - status: 'CUSTOM_ERROR', - error: 'Unknown State', - }); - } -} diff --git a/src/features/orcidlink/Unlinked.tsx b/src/features/orcidlink/Unlinked.tsx deleted file mode 100644 index ffb970e8..00000000 --- a/src/features/orcidlink/Unlinked.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export default function Unlinked() { - return ( -
-

Your KBase account is not linked to an ORCID account.

-
- ); -} 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.module.scss b/src/features/orcidlink/common/ErrorMessage.module.scss new file mode 100644 index 00000000..509c5b5d --- /dev/null +++ b/src/features/orcidlink/common/ErrorMessage.module.scss @@ -0,0 +1,8 @@ + +.main { + align-items: center; + display: flex; + flex-direction: row; + justify-content: center; + margin-top: 2rem; +} diff --git a/src/features/orcidlink/common/ErrorMessage.test.tsx b/src/features/orcidlink/common/ErrorMessage.test.tsx new file mode 100644 index 00000000..325829c0 --- /dev/null +++ b/src/features/orcidlink/common/ErrorMessage.test.tsx @@ -0,0 +1,102 @@ +import { SerializedError } from '@reduxjs/toolkit'; +import { FetchBaseQueryError } from '@reduxjs/toolkit/dist/query'; +import { render } from '@testing-library/react'; +import { KBaseBaseQueryError } from '../../../common/api/utils/common'; +import ErrorMessage from './ErrorMessage'; + +describe('The ErrorMessage Component', () => { + it('renders CUSTOM_ERROR correctly', () => { + const error: KBaseBaseQueryError = { + status: 'CUSTOM_ERROR', + error: 'foo', + }; + const { container } = render(); + + expect(container).toHaveTextContent('foo'); + }); + it('renders JSONRPC_ERROR correctly', () => { + // TODO: the JSON-RPC error should NOT contain the entire response body. + // This will not work for JSON-RPC 2.0, and the information is not helpful. + const error: KBaseBaseQueryError = { + status: 'JSONRPC_ERROR', + data: { + version: '1.1', + id: '123', + error: { + name: 'bar', + code: 123, + message: 'foo', + }, + }, + }; + const { container } = render(); + + expect(container).toHaveTextContent('foo'); + }); + it('renders FETCH_ERROR correctly', () => { + const error: KBaseBaseQueryError = { + status: 'FETCH_ERROR', + error: 'bar', + }; + const { container } = render(); + + expect(container).toHaveTextContent('bar'); + }); + it('renders PARSING_ERROR correctly', () => { + const error: KBaseBaseQueryError = { + status: 'PARSING_ERROR', + error: 'baz', + data: 'fuzz', + originalStatus: 123, + }; + const { container } = render(); + + expect(container).toHaveTextContent('baz'); + }); + + // TODO: type error for TIMEOUT_ERROR below - figure it out and fix, disabled + // for now because it is all crap. + + it('renders TIMEOUT_ERROR correctly', () => { + const error: FetchBaseQueryError = { + status: 'TIMEOUT_ERROR', + error: 'foo', + }; + const { container } = render(); + + expect(container).toHaveTextContent('foo'); + }); + + it('renders an http error correctly', () => { + const error: KBaseBaseQueryError = { + status: 400, + data: 'bar', + }; + const { container } = render(); + + expect(container).toHaveTextContent(`HTTP Status Code: ${400}`); + }); + + it('renders a Redux SerializedError error with a message correctly', () => { + const error: SerializedError = { + code: '123', + message: 'foo', + name: 'bar', + stack: 'baz', + }; + const { container } = render(); + + expect(container).toHaveTextContent('foo'); + }); + + it('renders a Redux SerializedError error without a message correctly', () => { + const error: SerializedError = { + code: '123', + name: 'bar', + stack: 'baz', + }; + const { container } = render(); + + expect(container).toHaveTextContent('Unknown Error'); + }); +}); diff --git a/src/features/orcidlink/ErrorMessage.tsx b/src/features/orcidlink/common/ErrorMessage.tsx similarity index 60% rename from src/features/orcidlink/ErrorMessage.tsx rename to src/features/orcidlink/common/ErrorMessage.tsx index e73484f1..938958c7 100644 --- a/src/features/orcidlink/ErrorMessage.tsx +++ b/src/features/orcidlink/common/ErrorMessage.tsx @@ -1,7 +1,13 @@ +/** + * Displays an error message as may be retured by an RTK query. + * + * 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'; -import { KBaseBaseQueryError } from '../../common/api/utils/common'; -import styles from './orcidlink.module.scss'; +import { KBaseBaseQueryError } from '../../../common/api/utils/common'; +import styles from './ErrorMessage.module.scss'; export interface ErrorMessageProps { error: KBaseBaseQueryError | SerializedError; @@ -14,7 +20,7 @@ export default function ErrorMessage({ error }: ErrorMessageProps) { case 'JSONRPC_ERROR': return error.data.error.message; case 'FETCH_ERROR': - return 'Fetch Error'; + return error.error; case 'CUSTOM_ERROR': return error.error; case 'PARSING_ERROR': @@ -22,12 +28,15 @@ export default function ErrorMessage({ error }: ErrorMessageProps) { case 'TIMEOUT_ERROR': return error.error; } + if ('status' in error && typeof error.status === 'number') { + return `HTTP Status Code: ${error.status}`; + } } else { return error.message || 'Unknown Error'; } })(); return ( -
+
{message} diff --git a/src/features/orcidlink/common/LoadingOverlay.module.scss b/src/features/orcidlink/common/LoadingOverlay.module.scss new file mode 100644 index 00000000..6958b70b --- /dev/null +++ b/src/features/orcidlink/common/LoadingOverlay.module.scss @@ -0,0 +1,13 @@ + +/* A wrapper around a loading indicator when presented standalone */ +.loading { + align-items: center; + display: flex; + flex-direction: row; + 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.test.tsx b/src/features/orcidlink/common/Scopes.test.tsx new file mode 100644 index 00000000..9e3a3bb9 --- /dev/null +++ b/src/features/orcidlink/common/Scopes.test.tsx @@ -0,0 +1,36 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import Scopes from './Scopes'; + +describe('The Scopes component', () => { + it('renders scopes correctly', () => { + const scopes = '/read-limited'; + const { container } = render(); + const expectedText = + 'Allows KBase to read your information with visibility set to Trusted Organizations.'; + expect(container).toHaveTextContent(expectedText); + }); + + it('renders scope description when scope is selected', async () => { + const scopes = '/read-limited'; + render(); + const buttonText = + 'Allows KBase to read your information with visibility set to Trusted Organizations.'; + const button = await screen.findByText(buttonText); + expect(button).toBeVisible(); + act(() => { + button.click(); + }); + const revealedContentSample = + 'Allows KBase to read any information from your record'; + waitFor(async () => { + expect(await screen.findByText(revealedContentSample)).toBeVisible(); + }); + }); + + it('renders invalid scope ', async () => { + const scopes = 'foo'; + render(); + const expectedErrorMessage = 'Invalid scope: foo'; + expect(await screen.findByText(expectedErrorMessage)).toBeVisible(); + }); +}); diff --git a/src/features/orcidlink/common/Scopes.tsx b/src/features/orcidlink/common/Scopes.tsx new file mode 100644 index 00000000..2116b275 --- /dev/null +++ b/src/features/orcidlink/common/Scopes.tsx @@ -0,0 +1,88 @@ +/** + * Renders a set of ORCID OAuth Scopes with custom help content, wrapped in an accordion. + * + * This form allows users to scan the set of scopes since the accordion is + * initially closed, showing just the scope titles. The user may insepct a scope + * by opening the accordion for that item. + */ + +import { faAngleRight } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Alert, + Typography, +} from '@mui/material'; +import { ORCIDScope, ScopeHelp, SCOPE_HELP } from '../constants'; +import styles from '../orcidlink.module.scss'; + +export interface ScopesProps { + scopes: string; +} + +function getScopeHelp(scope: ORCIDScope): ScopeHelp { + return SCOPE_HELP[scope]; +} + +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)) { + return ( + + Invalid scope: {scope} + + ); + } + const { orcid, help, seeAlso } = getScopeHelp(scope); + return ( + + } + aria-controls={`accordion-panel-${index}-content`} + id={`accordion-panel-${index}-header`} + sx={{ + '& .MuiAccordionSummary-expandIconWrapper.Mui-expanded': { + transform: 'rotate(90deg)', + }, + }} + > + {orcid.label} + + +
ORCID® Policy
+

{orcid.tooltip}

+
How KBase Uses It
+ {help.map((item, index) => { + return

{item}

; + })} +
See Also
+
    + {seeAlso.map(({ url, label }, index) => { + return ( +
  • + + {label} + +
  • + ); + })} +
+
+
+ ); + }); + return
{rows}
; +} diff --git a/src/features/orcidlink/common/TabPanel.test.tsx b/src/features/orcidlink/common/TabPanel.test.tsx new file mode 100644 index 00000000..683c8764 --- /dev/null +++ b/src/features/orcidlink/common/TabPanel.test.tsx @@ -0,0 +1,24 @@ +import { render } from '@testing-library/react'; +import TabPanel from './TabPanel'; + +describe('The TabPanel component', () => { + it('to render children if selected', () => { + const { container } = render( + + FOO + + ); + + expect(container).toHaveTextContent('FOO'); + }); + + it('not to render children if not selected', () => { + const { container } = render( + + FOO + + ); + + expect(container).not.toHaveTextContent('FOO'); + }); +}); diff --git a/src/features/orcidlink/common/TabPanel.tsx b/src/features/orcidlink/common/TabPanel.tsx new file mode 100644 index 00000000..b7ff5423 --- /dev/null +++ b/src/features/orcidlink/common/TabPanel.tsx @@ -0,0 +1,30 @@ +/** + * The TabPanel component wraps up the functionality for MUI tab panels. + * + * MUI does not have a general-purpose component for a tab's content. This + * component uses the example given in the MUI docs and generalizes it for + * usage, at least in the orcidlink feature. + */ +import { Box } from '@mui/material'; + +interface TabPanelProps { + children?: React.ReactNode; + index: number; + value: number; +} + +export default function TabPanel(props: TabPanelProps) { + const { children, value, index, ...other } = props; + + return ( + + ); +} diff --git a/src/features/orcidlink/constants.ts b/src/features/orcidlink/constants.ts new file mode 100644 index 00000000..94e77506 --- /dev/null +++ b/src/features/orcidlink/constants.ts @@ -0,0 +1,68 @@ +/** + * Constant values used by the orcidlink feature. + * + * A grab bag of values needed by the orcidlink ui. + * + * If a constat value is hardcoded, it should be moved here. + */ + +export const MANAGER_ROLE = 'ORCIDLINK_MANAGER'; + +export type ORCIDScope = '/read-limited' | '/activities/update'; + +export const ALL_SCOPES: Array = [ + '/read-limited', + '/activities/update', +]; + +const SCOPE_USER = 'KBase'; + +export interface ScopeHelp { + label: string; + orcid: { + label: string; + tooltip: string; + }; + help: Array; + seeAlso: Array<{ url: string; label: string }>; +} + +export const SCOPE_HELP: { [K in ORCIDScope]: ScopeHelp } = { + '/read-limited': { + label: 'Read Limited', + orcid: { + label: `Allows ${SCOPE_USER} to read your information with visibility set to Trusted Organizations.`, + tooltip: `Allows ${SCOPE_USER} to read any information from your record you have marked as available to \ + "Everyone" (public) or "Trusted parties". ${SCOPE_USER} cannot read information you have marked as "Only me" (private).`, + }, + help: [ + `${SCOPE_USER} accesses your ORCID® profile to show relevant information in the KBase ORCID® Link tool (available only to you), and to assist in filling out forms`, + ], + seeAlso: [ + { + url: 'https://support.orcid.org/hc/en-us/articles/360006973893-Trusted-organizations', + label: 'About ORCID® Trusted Parties', + }, + ], + }, + '/activities/update': { + label: 'Update Activities', + orcid: { + label: `Allows ${SCOPE_USER} to add/update your research activities (works, affiliations, etc).`, + tooltip: `Allows ${SCOPE_USER} to add information about your research activites \ + (for example, works, affiliations) that is stored in the ${SCOPE_USER} system(s) to your \ + ORCID® record. ${SCOPE_USER} will also be able to update research activites \ + ${SCOPE_USER} has added, but will not be able to edit information added by you or \ + any other trusted organization.`, + }, + help: [ + 'In the future, you may use this feature to automatically share published "FAIR Narratives" in your ORCID® account.', + ], + seeAlso: [ + { + url: 'https://support.orcid.org/hc/en-us/articles/360006973893-Trusted-organizations', + label: 'About ORCID® Trusted Organizations', + }, + ], + }, +}; diff --git a/src/features/orcidlink/images.ts b/src/features/orcidlink/images.ts new file mode 100644 index 00000000..c6aa70c3 --- /dev/null +++ b/src/features/orcidlink/images.ts @@ -0,0 +1,20 @@ +/** + * Constructs urls for images used in the orcidlink ui + * + * Note that the usual technique, importing the image, which creates a url back + * into the web app, does not work with the current state of Europa dependencies. + * Therefore, we use hardcoded image paths, combined with the PUBLIC_URL. + * When Europa deps are updated, or it is switched to vite, the image imports + * should be re-enabled. + */ + +const ORCID_ICON = '/assets/images/ORCID-iD_icon-vector.svg'; + +export type ImageName = 'orcidIcon'; + +export function image(name: ImageName): string { + switch (name) { + case 'orcidIcon': + return `${process.env.PUBLIC_URL}${ORCID_ICON}`; + } +} diff --git a/src/features/orcidlink/index.tsx b/src/features/orcidlink/index.tsx index defeef40..b57131e2 100644 --- a/src/features/orcidlink/index.tsx +++ b/src/features/orcidlink/index.tsx @@ -1,7 +1,13 @@ +import { Box } from '@mui/material'; import Home from './Home'; +import styles from './orcidlink.module.scss'; const ORCIDLinkFeature = () => { - return ; + return ( + + + + ); }; export default ORCIDLinkFeature; diff --git a/src/features/orcidlink/orcidlink.module.scss b/src/features/orcidlink/orcidlink.module.scss index 608fb0b8..6aa137f0 100644 --- a/src/features/orcidlink/orcidlink.module.scss +++ b/src/features/orcidlink/orcidlink.module.scss @@ -7,26 +7,6 @@ padding: 1rem; } -.loading { - align-items: center; - display: flex; - flex-direction: row; - justify-content: center; - margin-top: 2rem; -} - -.loading-title { - margin-left: 0.5rem; -} - -.error-message { - align-items: center; - display: flex; - flex-direction: row; - justify-content: center; - margin-top: 2rem; -} - .prop-table { display: flex; flex-direction: column; @@ -39,10 +19,19 @@ } .prop-table > div > div:nth-child(1) { - flex: 0 0 8rem; + flex: 0 0 12rem; font-weight: bold; } .prop-table > div > div:nth-child(2) { flex: 1 1 0; } + +.paper { + background-color: use-color("base-lightest"); + height: 100%; +} + +.section-title { + font-weight: bold; +} diff --git a/src/features/orcidlink/test/data.ts b/src/features/orcidlink/test/data.ts new file mode 100644 index 00000000..0716be41 --- /dev/null +++ b/src/features/orcidlink/test/data.ts @@ -0,0 +1,103 @@ +import { InfoResult } from '../../../common/api/orcidlinkAPI'; +import { + LinkRecordPublic, + ORCIDProfile, +} from '../../../common/api/orcidLinkCommon'; +import { JSONRPC20Error } from '../../../common/api/utils/kbaseBaseQuery'; +import orcidlinkIsLinkedAuthorizationRequired from './data/orcidlink-is-linked-1010.json'; + +export const ORCIDLINK_IS_LINKED_AUTHORIZATION_REQUIRED: JSONRPC20Error = + orcidlinkIsLinkedAuthorizationRequired; + +export const PROFILE_1: ORCIDProfile = { + orcidId: '0009-0006-1955-0944', + nameGroup: { + private: false, + fields: { + firstName: 'Foo', + lastName: 'Bar', + creditName: 'Foo B. Bar', + }, + }, + biographyGroup: { + private: true, + fields: null, + }, + emailGroup: { + private: false, + fields: { + emailAddresses: [], + }, + }, + employment: [ + { + name: 'LBNL', + role: 'fictional test character', + startYear: '2014', + endYear: null, + }, + ], +}; + +export const LINK_RECORD_1: LinkRecordPublic = { + username: 'foo', + created_at: 1714546800000, + expires_at: 2345698800000, + retires_at: 1715670000000, + orcid_auth: { + expires_in: 123, + name: 'foo bar', + orcid: 'abc123', + scope: '/read-limited', + }, +}; + +export const SERVICE_INFO_1: InfoResult = { + 'git-info': { + author_name: 'foo', + branch: 'bar', + commit_hash: 'abc', + commit_hash_abbreviated: 'def', + committer_date: 123, + committer_name: 'baz', + tag: null, + url: 'fuzz', + }, + 'service-description': { + name: 'orcidlink', + description: 'the orcidlink service', + language: 'typescript', + repoURL: 'https://github.com/kbase/orcidlink', + title: 'ORCID Link Service', + version: '1.2.3', + }, + runtime_info: { + current_time: 123, + orcid_api_url: 'aaa', + orcid_oauth_url: 'bbb', + orcid_site_url: 'ccc', + }, +}; + +export const INITIAL_STORE_STATE = { + auth: { + token: 'foo_token', + username: 'foo', + tokenInfo: { + created: 123, + expires: 456, + id: 'abc123', + name: 'Foo Bar', + type: 'Login', + user: 'foo', + cachefor: 890, + }, + initialized: true, + }, +}; + +export const INITIAL_UNAUTHENTICATED_STORE_STATE = { + auth: { + initialized: true, + }, +}; diff --git a/src/features/orcidlink/test/data/orcidlink-is-linked-1010.json b/src/features/orcidlink/test/data/orcidlink-is-linked-1010.json new file mode 100644 index 00000000..522486a9 --- /dev/null +++ b/src/features/orcidlink/test/data/orcidlink-is-linked-1010.json @@ -0,0 +1,5 @@ +{ + "code": 1010, + "message": "Authorization Required", + "data": "Authorization Required" +} diff --git a/src/features/orcidlink/test/mocks.ts b/src/features/orcidlink/test/mocks.ts new file mode 100644 index 00000000..1e515560 --- /dev/null +++ b/src/features/orcidlink/test/mocks.ts @@ -0,0 +1,62 @@ +import { JSONRPC20Error } from '../../../common/api/utils/kbaseBaseQuery'; + +export function jsonRPC20_ResultResponse(id: string, result: unknown) { + return { + body: JSON.stringify({ + jsonrpc: '2.0', + id, + result, + }), + status: 200, + headers: { + 'content-type': 'application/json', + }, + }; +} + +export function jsonRPC20_ErrorResponse(id: string, error: JSONRPC20Error) { + return { + body: JSON.stringify({ + jsonrpc: '2.0', + id, + error, + }), + status: 200, + headers: { + 'content-type': 'application/json', + }, + }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function rest_response(result: any, status = 200) { + return { + body: JSON.stringify(result), + status, + headers: { + 'content-type': 'application/json', + }, + }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function mockIsLinked(body: any) { + const username = body['params']['username']; + + const result = (() => { + switch (username) { + case 'foo': + return true; + case 'bar': + return false; + default: + throw new Error('Invalid test value for username'); + } + })(); + return jsonRPC20_ResultResponse(body['id'], result); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function mockIsLinked_not(body: any) { + return jsonRPC20_ResultResponse(body['id'], false); +}