From 0411e8321916494c8b8831fa6eaae372ab5ec6bb Mon Sep 17 00:00:00 2001 From: Erik Pearson Date: Fri, 3 May 2024 08:14:03 -0700 Subject: [PATCH 01/20] add tests and light refactoring in response to tests [URO-208] needed to extend KBaseBaseQueryError directly for TS --- src/common/api/utils/common.ts | 6 + src/features/orcidlink/ErrorMessage.test.tsx | 98 +++++ src/features/orcidlink/ErrorMessage.tsx | 5 +- src/features/orcidlink/Home/Home.test.tsx | 29 ++ src/features/orcidlink/Home/Home.tsx | 22 ++ src/features/orcidlink/Home/index.test.tsx | 358 ++++++++++++++++++ .../orcidlink/{Home.tsx => Home/index.tsx} | 49 +-- src/features/orcidlink/Linked.tsx | 79 ---- src/features/orcidlink/Linked/Linked.test.tsx | 23 ++ src/features/orcidlink/Linked/Linked.tsx | 32 ++ src/features/orcidlink/Linked/index.test.tsx | 289 ++++++++++++++ src/features/orcidlink/Linked/index.tsx | 49 +++ src/features/orcidlink/Unlinked.test.tsx | 12 + 13 files changed, 939 insertions(+), 112 deletions(-) create mode 100644 src/features/orcidlink/ErrorMessage.test.tsx create mode 100644 src/features/orcidlink/Home/Home.test.tsx create mode 100644 src/features/orcidlink/Home/Home.tsx create mode 100644 src/features/orcidlink/Home/index.test.tsx rename src/features/orcidlink/{Home.tsx => Home/index.tsx} (54%) delete mode 100644 src/features/orcidlink/Linked.tsx create mode 100644 src/features/orcidlink/Linked/Linked.test.tsx create mode 100644 src/features/orcidlink/Linked/Linked.tsx create mode 100644 src/features/orcidlink/Linked/index.test.tsx create mode 100644 src/features/orcidlink/Linked/index.tsx create mode 100644 src/features/orcidlink/Unlinked.test.tsx diff --git a/src/common/api/utils/common.ts b/src/common/api/utils/common.ts index 860baef2..6d28b670 100644 --- a/src/common/api/utils/common.ts +++ b/src/common/api/utils/common.ts @@ -22,11 +22,17 @@ type JsonRpcError = { }; }; +// https://github.com/reduxjs/redux-toolkit/blob/7cd8142f096855eb7cd03fb54c149ebfdc7dd084/packages/toolkit/src/query/fetchBaseQuery.ts#L48 export type KBaseBaseQueryError = | FetchBaseQueryError | { status: 'JSONRPC_ERROR'; data: JsonRpcError; + } + | { + status: 'CUSTOM_ERROR'; + error: 'JsonRpcProtocolError'; + data: string; }; export const isJsonRpcError = (obj: unknown): obj is JsonRpcError => { diff --git a/src/features/orcidlink/ErrorMessage.test.tsx b/src/features/orcidlink/ErrorMessage.test.tsx new file mode 100644 index 00000000..9577915d --- /dev/null +++ b/src/features/orcidlink/ErrorMessage.test.tsx @@ -0,0 +1,98 @@ +import { SerializedError } from '@reduxjs/toolkit'; +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'); + }); + it('renders TIMEOUT_ERROR correctly', () => { + const error: KBaseBaseQueryError = { + status: 'TIMEOUT_ERROR', + error: 'foo', + data: 'bar', + }; + 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/ErrorMessage.tsx index e73484f1..3b945db7 100644 --- a/src/features/orcidlink/ErrorMessage.tsx +++ b/src/features/orcidlink/ErrorMessage.tsx @@ -14,7 +14,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,6 +22,9 @@ 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'; } diff --git a/src/features/orcidlink/Home/Home.test.tsx b/src/features/orcidlink/Home/Home.test.tsx new file mode 100644 index 00000000..38d9452e --- /dev/null +++ b/src/features/orcidlink/Home/Home.test.tsx @@ -0,0 +1,29 @@ +import { render } from '@testing-library/react'; +import Home from './Home'; + +jest.mock('../Linked', () => { + 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( + 'Your KBase account is not linked to an ORCID 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..2a23fa8d --- /dev/null +++ b/src/features/orcidlink/Home/Home.tsx @@ -0,0 +1,22 @@ +import Linked from '../Linked'; +import Unlinked from '../Unlinked'; +import styles from '../orcidlink.module.scss'; + +export interface HomeProps { + isLinked: boolean; +} + +export default function Home({ isLinked }: 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..233b3408 --- /dev/null +++ b/src/features/orcidlink/Home/index.test.tsx @@ -0,0 +1,358 @@ +import { act, render, waitFor } from '@testing-library/react'; +import fetchMock, { MockResponseInit } from 'jest-fetch-mock'; +import { Provider } from 'react-redux'; +import { createTestStore } from '../../../app/store'; +import { setAuth } from '../../auth/authSlice'; +import LinkedController from './index'; + +// const TEST_LINK_RECORD_FOO: LinkRecordPublic = { +// username: 'foo', +// created_at: 123, +// expires_at: 456, +// retires_at: 789, +// orcid_auth: { +// name: 'Foo', +// orcid: 'abc123', +// scope: 'baz', +// expires_in: 100, +// }, +// }; + +// const TEST_LINK_RECORD_BAR: LinkRecordPublic = { +// username: 'bar', +// created_at: 123, +// expires_at: 456, +// retires_at: 789, +// orcid_auth: { +// name: 'Bar', +// orcid: 'xyz123', +// scope: 'baz', +// expires_in: 100, +// }, +// }; + +jest.mock('../Linked', () => { + 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 => { + 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': { + const result = (() => { + const username = body['params']['username']; + switch (username) { + case 'foo': + return true; + case 'bar': + return false; + } + })(); + return { + body: JSON.stringify({ + jsonrpc: '2.0', + id: body['id'], + result, + }), + status: 200, + headers: { + 'content-type': 'application/json', + }, + }; + } + default: + return ''; + } + } + default: + return ''; + } + } + ); + + const initialStoreState = { + auth: { + token: 'xyz123', + username: 'foo', + tokenInfo: { + created: 123, + expires: 456, + id: 'abc123', + name: 'Foo Bar', + type: 'Login', + user: 'foo', + cachefor: 890, + }, + initialized: true, + }, + }; + + const { container } = render( + + + + ); + + await waitFor(() => { + expect(container).toHaveTextContent('Loading...'); + }); + + 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 { + body: JSON.stringify({ + jsonrpc: '2.0', + id: body['id'], + result: false, + }), + status: 200, + headers: { + 'content-type': 'application/json', + }, + }; + } + default: + return ''; + } + } + default: + return ''; + } + } + ); + + const initialStoreState = { + auth: { + token: 'xyz123', + username: 'bar', + tokenInfo: { + created: 123, + expires: 456, + id: 'abc123', + name: 'Bar Baz', + type: 'Login', + user: 'bar', + cachefor: 890, + }, + initialized: true, + }, + }; + + const { container } = render( + + + + ); + + await waitFor(() => { + expect(container).toHaveTextContent('Loading...'); + }); + + await waitFor(() => { + expect(container).toHaveTextContent( + 'Your KBase account is not linked 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) { + case '/services/orcidlink/api/v1': { + const body = await request.json(); + switch (body['method']) { + case 'is-linked': { + const result = (() => { + const username = body['params']['username']; + switch (username) { + case 'foo': + return true; + case 'bar': + return false; + } + })(); + return { + body: JSON.stringify({ + jsonrpc: '2.0', + id: body['id'], + result, + }), + status: 200, + headers: { + 'content-type': 'application/json', + }, + }; + } + default: + return ''; + } + } + default: + return ''; + } + } + ); + + const initialStoreState = { + auth: { + token: 'abc123', + username: 'foo', + tokenInfo: { + created: 123, + expires: 456, + id: 'abc123', + name: 'Foo Bar', + type: 'Login', + user: 'foo', + cachefor: 890, + }, + initialized: true, + }, + }; + + const testStore = createTestStore(initialStoreState); + + const { container } = render( + + + + ); + + await waitFor(() => { + expect(container).toHaveTextContent('Loading...'); + }); + + await waitFor(() => { + expect(container).toHaveTextContent('Mocked Linked Component'); + }); + + // initialStoreState.auth.username = 'bar'; + + 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('Fetching...'); + }); + + await waitFor(() => { + expect(container).toHaveTextContent( + 'Your KBase account is not linked 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 initialStoreState = { + auth: { + token: 'xyz123', + username: 'foo', + tokenInfo: { + created: 123, + expires: 456, + id: 'abc123', + name: 'Foo Bar', + type: 'Login', + user: 'foo', + cachefor: 890, + }, + initialized: true, + }, + }; + + const { container } = render( + + + + ); + + await waitFor(() => { + expect(container).toHaveTextContent( + `SyntaxError: Unexpected token 'b', "bad" is not valid JSON` + ); + }); + }); +}); diff --git a/src/features/orcidlink/Home.tsx b/src/features/orcidlink/Home/index.tsx similarity index 54% rename from src/features/orcidlink/Home.tsx rename to src/features/orcidlink/Home/index.tsx index 233bcf74..552b98ff 100644 --- a/src/features/orcidlink/Home.tsx +++ b/src/features/orcidlink/Home/index.tsx @@ -1,16 +1,15 @@ 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'; +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 styles from '../orcidlink.module.scss'; +import Home from './Home'; -export default function Home() { +export default function HomeController() { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const username = useAppSelector(authUsername)!; @@ -34,40 +33,26 @@ export default function Home() { usePageTitle('KBase ORCID Link'); const { - data: isLInked, + data: isLinked, error, isLoading, isError, isFetching, isSuccess, - isUninitialized, } = orcidlinkAPI.useOrcidlinkIsLinkedQuery({ username }); - if (isUninitialized) { - return renderLoading('Uninitialized...', 'Loading the ORCID Link App...'); - } else if (isLoading) { + 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', - }); + return ; } + + // Because TS cannot have any way of knowing that the state filtering above + // catches all cases. + // TODO: how can we test this case without mocking a broken RTK query api? + return null; } 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/Linked/Linked.test.tsx b/src/features/orcidlink/Linked/Linked.test.tsx new file mode 100644 index 00000000..bc229ecf --- /dev/null +++ b/src/features/orcidlink/Linked/Linked.test.tsx @@ -0,0 +1,23 @@ +import { render } from '@testing-library/react'; +import { LinkRecordPublic } from '../../../common/api/orcidlinkAPI'; +import Linked from './Linked'; + +describe('The Linked Component', () => { + it('renders correctly', () => { + const linkRecord: LinkRecordPublic = { + username: 'foo', + created_at: 123, + expires_at: 456, + retires_at: 789, + orcid_auth: { + name: 'bar', + orcid: 'abc123', + scope: 'baz', + expires_in: 100, + }, + }; + const { container } = render(); + + expect(container).toHaveTextContent('abc123'); + }); +}); diff --git a/src/features/orcidlink/Linked/Linked.tsx b/src/features/orcidlink/Linked/Linked.tsx new file mode 100644 index 00000000..34b6ee25 --- /dev/null +++ b/src/features/orcidlink/Linked/Linked.tsx @@ -0,0 +1,32 @@ +import { LinkRecordPublic } from '../../../common/api/orcidlinkAPI'; +import styles from '../orcidlink.module.scss'; + +export interface LinkedProps { + linkRecord: LinkRecordPublic; +} + +export default function Linked({ linkRecord }: LinkedProps) { + const { + username, + orcid_auth: { orcid, name }, + } = linkRecord; + return ( +
+

Congratulations! You do indeed have an ORCID Link

+
+
+
Username
+
{username}
+
+
+
ORCID Id
+
{orcid}
+
+
+
Name at ORCID
+
{name}
+
+
+
+ ); +} diff --git a/src/features/orcidlink/Linked/index.test.tsx b/src/features/orcidlink/Linked/index.test.tsx new file mode 100644 index 00000000..95c3feba --- /dev/null +++ b/src/features/orcidlink/Linked/index.test.tsx @@ -0,0 +1,289 @@ +import { act, render, waitFor } from '@testing-library/react'; +import fetchMock, { MockResponseInit } from 'jest-fetch-mock'; +import { Provider } from 'react-redux'; +import { createTestStore } from '../../../app/store'; +import { LinkRecordPublic } from '../../../common/api/orcidlinkAPI'; +import { setAuth } from '../../auth/authSlice'; +import LinkedController from './index'; + +const TEST_LINK_RECORD_FOO: LinkRecordPublic = { + username: 'foo', + created_at: 123, + expires_at: 456, + retires_at: 789, + orcid_auth: { + name: 'Foo', + orcid: 'abc123', + scope: 'baz', + expires_in: 100, + }, +}; + +const TEST_LINK_RECORD_BAR: LinkRecordPublic = { + username: 'bar', + created_at: 123, + expires_at: 456, + retires_at: 789, + orcid_auth: { + name: 'Bar', + orcid: 'xyz123', + scope: 'baz', + expires_in: 100, + }, +}; + +describe('The LinkedController Component', () => { + beforeEach(() => { + fetchMock.resetMocks(); + fetchMock.enableMocks(); + }); + + it('renders 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: JSON.stringify({ is_linked: true }), + status: 200, + headers: { + 'content-type': 'application/json', + }, + }; + } + case 'owner-link': { + return { + body: JSON.stringify({ + jsonrpc: '2.0', + id: body['id'], + result: TEST_LINK_RECORD_FOO, + }), + status: 200, + headers: { + 'content-type': 'application/json', + }, + }; + } + default: + return ''; + } + } + default: + return ''; + } + } + ); + + const initialStoreState = { + auth: { + token: 'xyz123', + username: 'foo', + tokenInfo: { + created: 123, + expires: 456, + id: 'abc123', + name: 'Foo Bar', + type: 'Login', + user: 'foo', + cachefor: 890, + }, + initialized: true, + }, + }; + + const { container } = render( + + + + ); + + await waitFor(() => { + expect(container).toHaveTextContent('Loading...'); + }); + + await waitFor(() => { + expect(container).toHaveTextContent('abc123'); + }); + }); + + it('re-renders correctly', async () => { + fetchMock.mockResponse( + 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: JSON.stringify({ is_linked: true }), + status: 200, + headers: { + 'content-type': 'application/json', + }, + }; + } + case 'owner-link': { + const result = (() => { + const username = body['params']['username']; + switch (username) { + case 'foo': + return TEST_LINK_RECORD_FOO; + case 'bar': + return TEST_LINK_RECORD_BAR; + } + })(); + return { + body: JSON.stringify({ + jsonrpc: '2.0', + id: body['id'], + result, + }), + status: 200, + headers: { + 'content-type': 'application/json', + }, + }; + } + default: + return ''; + } + } + default: + return ''; + } + } + ); + + const initialStoreState = { + auth: { + token: 'abc123', + username: 'foo', + tokenInfo: { + created: 123, + expires: 456, + id: 'abc123', + name: 'Foo Bar', + type: 'Login', + user: 'foo', + cachefor: 890, + }, + initialized: true, + }, + }; + + const testStore = createTestStore(initialStoreState); + + const { container } = render( + + + + ); + + await waitFor(() => { + expect(container).toHaveTextContent('Loading...'); + }); + + await waitFor(() => { + expect(container).toHaveTextContent('abc123'); + }); + + // initialStoreState.auth.username = 'bar'; + + 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('Fetching...'); + }); + + await waitFor(() => { + expect(container).toHaveTextContent('xyz123'); + }); + }); + + 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 'owner-link': { + return { + body: 'bad', + status: 200, + headers: { + 'content-type': 'application/json', + }, + }; + } + default: + return ''; + } + } + default: + return ''; + } + } + ); + + const initialStoreState = { + auth: { + token: 'xyz123', + username: 'foo', + tokenInfo: { + created: 123, + expires: 456, + id: 'abc123', + name: 'Foo Bar', + type: 'Login', + user: 'foo', + cachefor: 890, + }, + initialized: true, + }, + }; + + const { container } = render( + + + + ); + + await waitFor(() => { + expect(container).toHaveTextContent( + `SyntaxError: Unexpected token 'b', "bad" is not valid JSON` + ); + }); + }); +}); diff --git a/src/features/orcidlink/Linked/index.tsx b/src/features/orcidlink/Linked/index.tsx new file mode 100644 index 00000000..d967cab7 --- /dev/null +++ b/src/features/orcidlink/Linked/index.tsx @@ -0,0 +1,49 @@ +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 ErrorMessage from '../ErrorMessage'; +import styles from '../orcidlink.module.scss'; +import Linked from './Linked'; + +export default function LinkedController() { + // 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 ; + } + + const { data, error, isLoading, isError, isFetching, isSuccess } = + orcidlinkAPI.useOrcidlinkOwnerLinkQuery({ username }); + + 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 ; + } + + // Because TS cannot have any way of knowing that the state filtering above + // catches all cases. + // TODO: how can we test this case without mocking a broken RTK query api? + return null; +} diff --git a/src/features/orcidlink/Unlinked.test.tsx b/src/features/orcidlink/Unlinked.test.tsx new file mode 100644 index 00000000..cd3837f6 --- /dev/null +++ b/src/features/orcidlink/Unlinked.test.tsx @@ -0,0 +1,12 @@ +import { render } from '@testing-library/react'; +import Unlinked from './Unlinked'; + +describe('The Unlinked Component', () => { + it('renders correctly', () => { + const { container } = render(); + + expect(container).toHaveTextContent( + 'Your KBase account is not linked to an ORCID account.' + ); + }); +}); From 7a2fdd8a6860ed0620187b58e632767c3ee95281 Mon Sep 17 00:00:00 2001 From: Erik Pearson Date: Fri, 10 May 2024 20:00:37 +0000 Subject: [PATCH 02/20] add icons-material dependency for accordion icons [URO-208] --- package-lock.json | 32 +++++++++++++++++++++++++++++--- package.json | 1 + 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 67c2dff1..aea7f92c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@fortawesome/fontawesome-svg-core": "^6.1.1", "@fortawesome/free-solid-svg-icons": "^6.1.1", "@fortawesome/react-fontawesome": "^0.1.18", + "@mui/icons-material": "^5.15.16", "@mui/material": "^5.14.18", "@popperjs/core": "^2.11.8", "@reduxjs/toolkit": "^1.9.5", @@ -1872,9 +1873,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" }, @@ -4299,6 +4300,31 @@ "url": "https://opencollective.com/mui" } }, + "node_modules/@mui/icons-material": { + "version": "5.15.16", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.15.16.tgz", + "integrity": "sha512-s8vYbyACzTNZRKv+20fCfVXJwJqNcVotns2EKnu1wmAga6wv2LAo5kB1d5yqQqZlMFtp34EJvRXf7cy8X0tJVA==", + "dependencies": { + "@babel/runtime": "^7.23.9" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^5.0.0", + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@mui/material": { "version": "5.14.18", "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.14.18.tgz", diff --git a/package.json b/package.json index 8cd54391..84b36ec5 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@fortawesome/free-solid-svg-icons": "^6.1.1", "@fortawesome/react-fontawesome": "^0.1.18", "@mui/material": "^5.14.18", + "@mui/icons-material": "5.15.16", "@popperjs/core": "^2.11.8", "@reduxjs/toolkit": "^1.9.5", "@tanstack/react-table": "^8.5.13", From fd3fc8a0624c800b3dbef2a369929b1eabbba3b6 Mon Sep 17 00:00:00 2001 From: Erik Pearson Date: Fri, 10 May 2024 20:05:13 +0000 Subject: [PATCH 03/20] add orcid icon [URO-208] --- public/assets/images/ORCID-iD_icon-vector.svg | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 public/assets/images/ORCID-iD_icon-vector.svg 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 From 5f98eff4784e587e9250b4aabc9d1cd92443d62f Mon Sep 17 00:00:00 2001 From: Erik Pearson Date: Fri, 10 May 2024 20:27:27 +0000 Subject: [PATCH 04/20] udpate orcidlink api with multi-call endpoints [URO-208] --- src/common/api/orcidLinkCommon.ts | 73 +++++++++++++ src/common/api/orcidlinkAPI.ts | 164 +++++++++++++++++++++--------- 2 files changed, 189 insertions(+), 48 deletions(-) create mode 100644 src/common/api/orcidLinkCommon.ts 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..3c08f9f9 100644 --- a/src/common/api/orcidlinkAPI.ts +++ b/src/common/api/orcidlinkAPI.ts @@ -1,56 +1,79 @@ import { baseApi } from '.'; -import { jsonRpcService } from './utils/serviceHelpers'; +import { LinkRecordPublic, ORCIDProfile } from './orcidLinkCommon'; +import { jsonRpc2Service } from './utils/serviceHelpers'; -// orcidlink system types +// is-linked + +export type IsLinkedResult = boolean; + +// owner-link + +export type OwnerLinkResult = LinkRecordPublic; + +// 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 +// orcid profile + +export type GetProfileResult = ORCIDProfile; + +// combined api calls for initial view + +export interface ORCIDLinkInitialStateResult { + isLinked: IsLinkedResult; + 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({ +const orcidlinkService = jsonRpc2Service({ url: '/services/orcidlink/api/v1', version: '2.0', }); @@ -62,31 +85,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 IsLinkedResult, + 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, + }, + }; }, }), }), From fe890cd268598b21680ebbe88f7b377b64176c14 Mon Sep 17 00:00:00 2001 From: Erik Pearson Date: Fri, 10 May 2024 20:36:49 +0000 Subject: [PATCH 05/20] improve JSON-RPC 2.0 support [URO-208] tried to keep the changes minimal --- src/common/api/utils/common.ts | 28 ++++++++++++++++---- src/common/api/utils/kbaseBaseQuery.ts | 36 ++++++++++++++++++++++++-- src/common/api/utils/serviceHelpers.ts | 12 +++++++++ 3 files changed, 69 insertions(+), 7 deletions(-) diff --git a/src/common/api/utils/common.ts b/src/common/api/utils/common.ts index 6d28b670..6782692d 100644 --- a/src/common/api/utils/common.ts +++ b/src/common/api/utils/common.ts @@ -28,11 +28,6 @@ export type KBaseBaseQueryError = | { status: 'JSONRPC_ERROR'; data: JsonRpcError; - } - | { - status: 'CUSTOM_ERROR'; - error: 'JsonRpcProtocolError'; - data: string; }; export const isJsonRpcError = (obj: unknown): obj is JsonRpcError => { @@ -55,6 +50,29 @@ 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; + } + // const versionsSupported = new Set(['1.1', '2.0']); + // if (!versionsSupported.has(version)) 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..924d2094 100644 --- a/src/common/api/utils/kbaseBaseQuery.ts +++ b/src/common/api/utils/kbaseBaseQuery.ts @@ -1,5 +1,8 @@ import { BaseQueryApi } from '@reduxjs/toolkit/dist/query/baseQueryTypes'; -import { FetchBaseQueryArgs } from '@reduxjs/toolkit/dist/query/fetchBaseQuery'; +import { + FetchBaseQueryArgs, + ResponseHandler, +} from '@reduxjs/toolkit/dist/query/fetchBaseQuery'; import { BaseQueryFn, FetchArgs, @@ -7,7 +10,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; @@ -25,6 +32,7 @@ export interface JsonRpcQueryArgs { method: string; params?: unknown; fetchArgs?: FetchArgs; + responseHandler?: ResponseHandler; } export interface JSONRPC11Body { @@ -41,6 +49,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 +240,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 +260,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..0d4929f1 100644 --- a/src/common/api/utils/serviceHelpers.ts +++ b/src/common/api/utils/serviceHelpers.ts @@ -13,6 +13,18 @@ export const jsonRpcService = ( }); }; +export const jsonRpc2Service = ( + service: JsonRpcQueryArgs['service'] +): (( + queryArgs: Omit +) => JsonRpcQueryArgs) => { + return (queryArgs) => ({ + apiType: 'JsonRpc', + service, + ...queryArgs, + }); +}; + export const httpService = ( service: HttpQueryArgs['service'] ): (( From 13cf80811a372c11eb86a53580cb625616c34af7 Mon Sep 17 00:00:00 2001 From: Erik Pearson Date: Fri, 10 May 2024 20:39:13 +0000 Subject: [PATCH 06/20] add support and tests for view for linked and unlinked user [URO-208] incomplete - still needs support for manager, developer, help text but this set of changes minimized --- src/features/orcidlink/Home/Home.test.tsx | 20 +- src/features/orcidlink/Home/Home.tsx | 27 +- src/features/orcidlink/Home/index.test.tsx | 209 ++----------- src/features/orcidlink/Home/index.tsx | 47 +-- .../orcidlink/HomeLinked/LinkInfo.test.tsx | 23 ++ .../orcidlink/HomeLinked/LinkInfo.tsx | 82 +++++ .../orcidlink/HomeLinked/OverviewTab.test.tsx | 21 ++ .../orcidlink/HomeLinked/OverviewTab.tsx | 56 ++++ .../orcidlink/HomeLinked/index.test.tsx | 168 ++++++++++ src/features/orcidlink/HomeLinked/index.tsx | 42 +++ .../orcidlink/HomeLinked/view.test.tsx | 67 ++++ src/features/orcidlink/HomeLinked/view.tsx | 44 +++ src/features/orcidlink/HomeUnlinked.test.tsx | 17 ++ src/features/orcidlink/HomeUnlinked.tsx | 71 +++++ src/features/orcidlink/Linked/Linked.test.tsx | 23 -- src/features/orcidlink/Linked/Linked.tsx | 32 -- src/features/orcidlink/Linked/index.test.tsx | 289 ------------------ src/features/orcidlink/Linked/index.tsx | 49 --- src/features/orcidlink/Unlinked.test.tsx | 12 - src/features/orcidlink/Unlinked.tsx | 7 - src/features/orcidlink/common.test.ts | 22 ++ .../orcidlink/common/ErrorMessage.module.scss | 8 + .../{ => common}/ErrorMessage.test.tsx | 10 +- .../orcidlink/{ => common}/ErrorMessage.tsx | 12 +- src/features/orcidlink/common/Scopes.test.tsx | 36 +++ src/features/orcidlink/common/Scopes.tsx | 71 +++++ .../orcidlink/common/TabPanel.test.tsx | 24 ++ src/features/orcidlink/common/TabPanel.tsx | 30 ++ .../orcidlink/common/misc.module.scss | 17 ++ src/features/orcidlink/common/misc.test.tsx | 71 +++++ src/features/orcidlink/common/misc.tsx | 95 ++++++ src/features/orcidlink/constants.ts | 68 +++++ src/features/orcidlink/images.ts | 20 ++ src/features/orcidlink/index.tsx | 8 +- src/features/orcidlink/orcidlink.module.scss | 31 +- src/features/orcidlink/test/data.ts | 97 ++++++ .../test/data/orcidlink-is-linked-1010.json | 5 + src/features/orcidlink/test/mocks.ts | 62 ++++ src/features/orcidlink/utils.ts | 21 ++ 39 files changed, 1337 insertions(+), 677 deletions(-) create mode 100644 src/features/orcidlink/HomeLinked/LinkInfo.test.tsx create mode 100644 src/features/orcidlink/HomeLinked/LinkInfo.tsx create mode 100644 src/features/orcidlink/HomeLinked/OverviewTab.test.tsx create mode 100644 src/features/orcidlink/HomeLinked/OverviewTab.tsx create mode 100644 src/features/orcidlink/HomeLinked/index.test.tsx create mode 100644 src/features/orcidlink/HomeLinked/index.tsx create mode 100644 src/features/orcidlink/HomeLinked/view.test.tsx create mode 100644 src/features/orcidlink/HomeLinked/view.tsx create mode 100644 src/features/orcidlink/HomeUnlinked.test.tsx create mode 100644 src/features/orcidlink/HomeUnlinked.tsx delete mode 100644 src/features/orcidlink/Linked/Linked.test.tsx delete mode 100644 src/features/orcidlink/Linked/Linked.tsx delete mode 100644 src/features/orcidlink/Linked/index.test.tsx delete mode 100644 src/features/orcidlink/Linked/index.tsx delete mode 100644 src/features/orcidlink/Unlinked.test.tsx delete mode 100644 src/features/orcidlink/Unlinked.tsx create mode 100644 src/features/orcidlink/common.test.ts create mode 100644 src/features/orcidlink/common/ErrorMessage.module.scss rename src/features/orcidlink/{ => common}/ErrorMessage.test.tsx (90%) rename src/features/orcidlink/{ => common}/ErrorMessage.tsx (72%) create mode 100644 src/features/orcidlink/common/Scopes.test.tsx create mode 100644 src/features/orcidlink/common/Scopes.tsx create mode 100644 src/features/orcidlink/common/TabPanel.test.tsx create mode 100644 src/features/orcidlink/common/TabPanel.tsx create mode 100644 src/features/orcidlink/common/misc.module.scss create mode 100644 src/features/orcidlink/common/misc.test.tsx create mode 100644 src/features/orcidlink/common/misc.tsx create mode 100644 src/features/orcidlink/constants.ts create mode 100644 src/features/orcidlink/images.ts create mode 100644 src/features/orcidlink/test/data.ts create mode 100644 src/features/orcidlink/test/data/orcidlink-is-linked-1010.json create mode 100644 src/features/orcidlink/test/mocks.ts create mode 100644 src/features/orcidlink/utils.ts diff --git a/src/features/orcidlink/Home/Home.test.tsx b/src/features/orcidlink/Home/Home.test.tsx index 38d9452e..3a3bc776 100644 --- a/src/features/orcidlink/Home/Home.test.tsx +++ b/src/features/orcidlink/Home/Home.test.tsx @@ -1,7 +1,11 @@ import { render } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { SERVICE_INFO_1 } from '../test/data'; import Home from './Home'; -jest.mock('../Linked', () => { +// We are not testing the HomeLinked component; we just want to be sure that it +// is rendered. +jest.mock('../HomeLinked', () => { return { __esModule: true, default: () => { @@ -12,16 +16,24 @@ jest.mock('../Linked', () => { describe('The Home Component', () => { it('renders correctly for unlinked', () => { - const { container } = render(); + const { container } = render( + + + + ); expect(container).not.toBeNull(); expect(container).toHaveTextContent( - 'Your KBase account is not linked to an ORCID account.' + 'You do not currently have a link from your KBase account' ); }); it('renders correctly for linked', () => { - const { container } = render(); + 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 index 2a23fa8d..1e0a77b5 100644 --- a/src/features/orcidlink/Home/Home.tsx +++ b/src/features/orcidlink/Home/Home.tsx @@ -1,22 +1,21 @@ -import Linked from '../Linked'; -import Unlinked from '../Unlinked'; -import styles from '../orcidlink.module.scss'; +/** + * 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 }: HomeProps) { +export default function Home({ isLinked, info }: HomeProps) { if (isLinked) { - return ( -
- -
- ); + return ; } - return ( -
- -
- ); + return ; } diff --git a/src/features/orcidlink/Home/index.test.tsx b/src/features/orcidlink/Home/index.test.tsx index 233b3408..8603fcde 100644 --- a/src/features/orcidlink/Home/index.test.tsx +++ b/src/features/orcidlink/Home/index.test.tsx @@ -1,37 +1,14 @@ import { act, render, 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 { setAuth } from '../../auth/authSlice'; -import LinkedController from './index'; +import { INITIAL_STORE_STATE } from '../test/data'; +import { mockIsLinked, mockIsLinked_not } from '../test/mocks'; +import HomeController from './index'; -// const TEST_LINK_RECORD_FOO: LinkRecordPublic = { -// username: 'foo', -// created_at: 123, -// expires_at: 456, -// retires_at: 789, -// orcid_auth: { -// name: 'Foo', -// orcid: 'abc123', -// scope: 'baz', -// expires_in: 100, -// }, -// }; - -// const TEST_LINK_RECORD_BAR: LinkRecordPublic = { -// username: 'bar', -// created_at: 123, -// expires_at: 456, -// retires_at: 789, -// orcid_auth: { -// name: 'Bar', -// orcid: 'xyz123', -// scope: 'baz', -// expires_in: 100, -// }, -// }; - -jest.mock('../Linked', () => { +jest.mock('../HomeLinked', () => { return { __esModule: true, default: () => { @@ -49,35 +26,16 @@ describe('The HomeController Component', () => { it('renders mocked "Linked" component if user is 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': { + if (request.method !== 'POST') { + return ''; + } const body = await request.json(); switch (body['method']) { case 'is-linked': { - const result = (() => { - const username = body['params']['username']; - switch (username) { - case 'foo': - return true; - case 'bar': - return false; - } - })(); - return { - body: JSON.stringify({ - jsonrpc: '2.0', - id: body['id'], - result, - }), - status: 200, - headers: { - 'content-type': 'application/json', - }, - }; + return mockIsLinked(body); } default: return ''; @@ -89,33 +47,14 @@ describe('The HomeController Component', () => { } ); - const initialStoreState = { - auth: { - token: 'xyz123', - username: 'foo', - tokenInfo: { - created: 123, - expires: 456, - id: 'abc123', - name: 'Foo Bar', - type: 'Login', - user: 'foo', - cachefor: 890, - }, - initialized: true, - }, - }; - const { container } = render( - - + + + + ); - await waitFor(() => { - expect(container).toHaveTextContent('Loading...'); - }); - await waitFor(() => { expect(container).toHaveTextContent('Mocked Linked Component'); }); @@ -133,17 +72,7 @@ describe('The HomeController Component', () => { const body = await request.json(); switch (body['method']) { case 'is-linked': { - return { - body: JSON.stringify({ - jsonrpc: '2.0', - id: body['id'], - result: false, - }), - status: 200, - headers: { - 'content-type': 'application/json', - }, - }; + return mockIsLinked_not(body); } default: return ''; @@ -155,36 +84,17 @@ describe('The HomeController Component', () => { } ); - const initialStoreState = { - auth: { - token: 'xyz123', - username: 'bar', - tokenInfo: { - created: 123, - expires: 456, - id: 'abc123', - name: 'Bar Baz', - type: 'Login', - user: 'bar', - cachefor: 890, - }, - initialized: true, - }, - }; - const { container } = render( - - + + + + ); - await waitFor(() => { - expect(container).toHaveTextContent('Loading...'); - }); - await waitFor(() => { expect(container).toHaveTextContent( - 'Your KBase account is not linked to an ORCID account.' + 'You do not currently have a link from your KBase account to an ORCID® account.' ); }); }); @@ -197,30 +107,13 @@ describe('The HomeController Component', () => { } 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': { - const result = (() => { - const username = body['params']['username']; - switch (username) { - case 'foo': - return true; - case 'bar': - return false; - } - })(); - return { - body: JSON.stringify({ - jsonrpc: '2.0', - id: body['id'], - result, - }), - status: 200, - headers: { - 'content-type': 'application/json', - }, - }; + // In this mock, user "foo" is linked, user "bar" is not. + return mockIsLinked(body); } default: return ''; @@ -232,41 +125,20 @@ describe('The HomeController Component', () => { } ); - const initialStoreState = { - auth: { - token: 'abc123', - username: 'foo', - tokenInfo: { - created: 123, - expires: 456, - id: 'abc123', - name: 'Foo Bar', - type: 'Login', - user: 'foo', - cachefor: 890, - }, - initialized: true, - }, - }; - - const testStore = createTestStore(initialStoreState); + const testStore = createTestStore(INITIAL_STORE_STATE); const { container } = render( - + + + ); - await waitFor(() => { - expect(container).toHaveTextContent('Loading...'); - }); - await waitFor(() => { expect(container).toHaveTextContent('Mocked Linked Component'); }); - // initialStoreState.auth.username = 'bar'; - act(() => { testStore.dispatch( setAuth({ @@ -285,13 +157,9 @@ describe('The HomeController Component', () => { ); }); - await waitFor(() => { - expect(container).toHaveTextContent('Fetching...'); - }); - await waitFor(() => { expect(container).toHaveTextContent( - 'Your KBase account is not linked to an ORCID account.' + 'You do not currently have a link from your KBase account to an ORCID® account.' ); }); }); @@ -326,26 +194,11 @@ describe('The HomeController Component', () => { } ); - const initialStoreState = { - auth: { - token: 'xyz123', - username: 'foo', - tokenInfo: { - created: 123, - expires: 456, - id: 'abc123', - name: 'Foo Bar', - type: 'Login', - user: 'foo', - cachefor: 890, - }, - initialized: true, - }, - }; - const { container } = render( - - + + + + ); diff --git a/src/features/orcidlink/Home/index.tsx b/src/features/orcidlink/Home/index.tsx index 552b98ff..4461cb2a 100644 --- a/src/features/orcidlink/Home/index.tsx +++ b/src/features/orcidlink/Home/index.tsx @@ -1,58 +1,23 @@ -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 styles from '../orcidlink.module.scss'; +import ErrorMessage from '../common/ErrorMessage'; import Home from './Home'; export default function HomeController() { // 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, - } = orcidlinkAPI.useOrcidlinkIsLinkedQuery({ username }); + const { data, error, isError, isSuccess } = + orcidlinkAPI.useOrcidlinkInitialStateQuery({ username }); - 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); + if (isError) { + return ; } else if (isSuccess) { - return ; + return ; } - - // Because TS cannot have any way of knowing that the state filtering above - // catches all cases. - // TODO: how can we test this case without mocking a broken RTK query api? return null; } 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..e282ac52 --- /dev/null +++ b/src/features/orcidlink/HomeLinked/LinkInfo.tsx @@ -0,0 +1,82 @@ +import { + LinkRecordPublic, + ORCIDProfile, +} from '../../../common/api/orcidLinkCommon'; +import { + renderCreditName, + renderORCIDIcon, + renderRealname, +} from '../common/misc'; +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) { + function renderORCIDId() { + return ( + + {renderORCIDIcon()} + {orcidSiteURL}/{linkRecord.orcid_auth.orcid} + + ); + } + + return ( +
+
+
+
ORCID iD
+
{renderORCIDId()}
+
+
+
Name on Account
+
{renderRealname(profile)}
+
+
+
Published Name
+
{renderCreditName(profile)}
+
+
+
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..cd5f7762 --- /dev/null +++ b/src/features/orcidlink/HomeLinked/OverviewTab.tsx @@ -0,0 +1,56 @@ +import { + Card, + CardContent, + CardHeader, + 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..de595c2b --- /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 { renderLoadingOverlay } from '../common/misc'; +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 ( + <> + {renderLoadingOverlay(isFetching)} + {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..eac767d1 --- /dev/null +++ b/src/features/orcidlink/HomeUnlinked.tsx @@ -0,0 +1,71 @@ +/** + * 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 ( + + + + + {/* + A note on the padding and margin overrides. Mui is not very smart about components + embedded in card content. The card content has a padding on all subelements (header, + content, actions) which is added to any margins and padding of components contained + within, so it pretty easily blows up into a lot of blank space. + To keep the empty space under control (and others have commented on this + in GH issues), one must fiddle with padding and margins on a case-by-case + basis. + ) */} + + + 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. +

+
+ {/* Note that the card actions padding is overridden so that it matches + that of the card content and header. There are a number of formatting + issues with Cards. Some will apparently be fixed in v6. */} + + + +
+
+ + + + +
NOTES HERE
+
+
+ + + + +
LINKS TO MORE INFORMATION HERE
+
+
+
+
+ ); +} diff --git a/src/features/orcidlink/Linked/Linked.test.tsx b/src/features/orcidlink/Linked/Linked.test.tsx deleted file mode 100644 index bc229ecf..00000000 --- a/src/features/orcidlink/Linked/Linked.test.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { render } from '@testing-library/react'; -import { LinkRecordPublic } from '../../../common/api/orcidlinkAPI'; -import Linked from './Linked'; - -describe('The Linked Component', () => { - it('renders correctly', () => { - const linkRecord: LinkRecordPublic = { - username: 'foo', - created_at: 123, - expires_at: 456, - retires_at: 789, - orcid_auth: { - name: 'bar', - orcid: 'abc123', - scope: 'baz', - expires_in: 100, - }, - }; - const { container } = render(); - - expect(container).toHaveTextContent('abc123'); - }); -}); diff --git a/src/features/orcidlink/Linked/Linked.tsx b/src/features/orcidlink/Linked/Linked.tsx deleted file mode 100644 index 34b6ee25..00000000 --- a/src/features/orcidlink/Linked/Linked.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { LinkRecordPublic } from '../../../common/api/orcidlinkAPI'; -import styles from '../orcidlink.module.scss'; - -export interface LinkedProps { - linkRecord: LinkRecordPublic; -} - -export default function Linked({ linkRecord }: LinkedProps) { - const { - username, - orcid_auth: { orcid, name }, - } = linkRecord; - return ( -
-

Congratulations! You do indeed have an ORCID Link

-
-
-
Username
-
{username}
-
-
-
ORCID Id
-
{orcid}
-
-
-
Name at ORCID
-
{name}
-
-
-
- ); -} diff --git a/src/features/orcidlink/Linked/index.test.tsx b/src/features/orcidlink/Linked/index.test.tsx deleted file mode 100644 index 95c3feba..00000000 --- a/src/features/orcidlink/Linked/index.test.tsx +++ /dev/null @@ -1,289 +0,0 @@ -import { act, render, waitFor } from '@testing-library/react'; -import fetchMock, { MockResponseInit } from 'jest-fetch-mock'; -import { Provider } from 'react-redux'; -import { createTestStore } from '../../../app/store'; -import { LinkRecordPublic } from '../../../common/api/orcidlinkAPI'; -import { setAuth } from '../../auth/authSlice'; -import LinkedController from './index'; - -const TEST_LINK_RECORD_FOO: LinkRecordPublic = { - username: 'foo', - created_at: 123, - expires_at: 456, - retires_at: 789, - orcid_auth: { - name: 'Foo', - orcid: 'abc123', - scope: 'baz', - expires_in: 100, - }, -}; - -const TEST_LINK_RECORD_BAR: LinkRecordPublic = { - username: 'bar', - created_at: 123, - expires_at: 456, - retires_at: 789, - orcid_auth: { - name: 'Bar', - orcid: 'xyz123', - scope: 'baz', - expires_in: 100, - }, -}; - -describe('The LinkedController Component', () => { - beforeEach(() => { - fetchMock.resetMocks(); - fetchMock.enableMocks(); - }); - - it('renders 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: JSON.stringify({ is_linked: true }), - status: 200, - headers: { - 'content-type': 'application/json', - }, - }; - } - case 'owner-link': { - return { - body: JSON.stringify({ - jsonrpc: '2.0', - id: body['id'], - result: TEST_LINK_RECORD_FOO, - }), - status: 200, - headers: { - 'content-type': 'application/json', - }, - }; - } - default: - return ''; - } - } - default: - return ''; - } - } - ); - - const initialStoreState = { - auth: { - token: 'xyz123', - username: 'foo', - tokenInfo: { - created: 123, - expires: 456, - id: 'abc123', - name: 'Foo Bar', - type: 'Login', - user: 'foo', - cachefor: 890, - }, - initialized: true, - }, - }; - - const { container } = render( - - - - ); - - await waitFor(() => { - expect(container).toHaveTextContent('Loading...'); - }); - - await waitFor(() => { - expect(container).toHaveTextContent('abc123'); - }); - }); - - it('re-renders correctly', async () => { - fetchMock.mockResponse( - 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: JSON.stringify({ is_linked: true }), - status: 200, - headers: { - 'content-type': 'application/json', - }, - }; - } - case 'owner-link': { - const result = (() => { - const username = body['params']['username']; - switch (username) { - case 'foo': - return TEST_LINK_RECORD_FOO; - case 'bar': - return TEST_LINK_RECORD_BAR; - } - })(); - return { - body: JSON.stringify({ - jsonrpc: '2.0', - id: body['id'], - result, - }), - status: 200, - headers: { - 'content-type': 'application/json', - }, - }; - } - default: - return ''; - } - } - default: - return ''; - } - } - ); - - const initialStoreState = { - auth: { - token: 'abc123', - username: 'foo', - tokenInfo: { - created: 123, - expires: 456, - id: 'abc123', - name: 'Foo Bar', - type: 'Login', - user: 'foo', - cachefor: 890, - }, - initialized: true, - }, - }; - - const testStore = createTestStore(initialStoreState); - - const { container } = render( - - - - ); - - await waitFor(() => { - expect(container).toHaveTextContent('Loading...'); - }); - - await waitFor(() => { - expect(container).toHaveTextContent('abc123'); - }); - - // initialStoreState.auth.username = 'bar'; - - 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('Fetching...'); - }); - - await waitFor(() => { - expect(container).toHaveTextContent('xyz123'); - }); - }); - - 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 'owner-link': { - return { - body: 'bad', - status: 200, - headers: { - 'content-type': 'application/json', - }, - }; - } - default: - return ''; - } - } - default: - return ''; - } - } - ); - - const initialStoreState = { - auth: { - token: 'xyz123', - username: 'foo', - tokenInfo: { - created: 123, - expires: 456, - id: 'abc123', - name: 'Foo Bar', - type: 'Login', - user: 'foo', - cachefor: 890, - }, - initialized: true, - }, - }; - - const { container } = render( - - - - ); - - await waitFor(() => { - expect(container).toHaveTextContent( - `SyntaxError: Unexpected token 'b', "bad" is not valid JSON` - ); - }); - }); -}); diff --git a/src/features/orcidlink/Linked/index.tsx b/src/features/orcidlink/Linked/index.tsx deleted file mode 100644 index d967cab7..00000000 --- a/src/features/orcidlink/Linked/index.tsx +++ /dev/null @@ -1,49 +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 ErrorMessage from '../ErrorMessage'; -import styles from '../orcidlink.module.scss'; -import Linked from './Linked'; - -export default function LinkedController() { - // 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 ; - } - - const { data, error, isLoading, isError, isFetching, isSuccess } = - orcidlinkAPI.useOrcidlinkOwnerLinkQuery({ username }); - - 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 ; - } - - // Because TS cannot have any way of knowing that the state filtering above - // catches all cases. - // TODO: how can we test this case without mocking a broken RTK query api? - return null; -} diff --git a/src/features/orcidlink/Unlinked.test.tsx b/src/features/orcidlink/Unlinked.test.tsx deleted file mode 100644 index cd3837f6..00000000 --- a/src/features/orcidlink/Unlinked.test.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { render } from '@testing-library/react'; -import Unlinked from './Unlinked'; - -describe('The Unlinked Component', () => { - it('renders correctly', () => { - const { container } = render(); - - expect(container).toHaveTextContent( - 'Your KBase account is not linked to an ORCID account.' - ); - }); -}); 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.test.ts b/src/features/orcidlink/common.test.ts new file mode 100644 index 00000000..d60fdd9c --- /dev/null +++ b/src/features/orcidlink/common.test.ts @@ -0,0 +1,22 @@ +import { uiURL } from './utils'; + +describe('The common module uiURL function', () => { + // We can get away with a shallow copy, since env is shallow. + const INITIAL_ENV = { ...process.env }; + + beforeEach(() => { + process.env = INITIAL_ENV; + }); + + it('creates a sensible url using the expected environment variable', () => { + process.env.REACT_APP_KBASE_DOMAIN = 'example.com'; + const url = uiURL('foo'); + expect(url.toString()).toBe('https://example.com/foo'); + }); + + it('creates a sensible url without the expected environment variable', () => { + process.env.REACT_APP_KBASE_DOMAIN = ''; + const url = uiURL('foo'); + expect(url.toString()).toBe(`${window.location.origin}/foo`); + }); +}); 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/ErrorMessage.test.tsx b/src/features/orcidlink/common/ErrorMessage.test.tsx similarity index 90% rename from src/features/orcidlink/ErrorMessage.test.tsx rename to src/features/orcidlink/common/ErrorMessage.test.tsx index 9577915d..325829c0 100644 --- a/src/features/orcidlink/ErrorMessage.test.tsx +++ b/src/features/orcidlink/common/ErrorMessage.test.tsx @@ -1,6 +1,7 @@ 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 { KBaseBaseQueryError } from '../../../common/api/utils/common'; import ErrorMessage from './ErrorMessage'; describe('The ErrorMessage Component', () => { @@ -52,11 +53,14 @@ describe('The ErrorMessage Component', () => { 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: KBaseBaseQueryError = { + const error: FetchBaseQueryError = { status: 'TIMEOUT_ERROR', error: 'foo', - data: 'bar', }; const { container } = render(); diff --git a/src/features/orcidlink/ErrorMessage.tsx b/src/features/orcidlink/common/ErrorMessage.tsx similarity index 72% rename from src/features/orcidlink/ErrorMessage.tsx rename to src/features/orcidlink/common/ErrorMessage.tsx index 3b945db7..f133457c 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 basci, just displaying the message in an Alert. However, some + * errors can clearly use 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; @@ -30,7 +36,7 @@ export default function ErrorMessage({ error }: ErrorMessageProps) { } })(); return ( -
+
{message} 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..0b8148f5 --- /dev/null +++ b/src/features/orcidlink/common/Scopes.tsx @@ -0,0 +1,71 @@ +/** + * 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 { ExpandMore } from '@mui/icons-material'; +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); +} + +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 ( + + }> + {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/common/misc.module.scss b/src/features/orcidlink/common/misc.module.scss new file mode 100644 index 00000000..69a5a2f6 --- /dev/null +++ b/src/features/orcidlink/common/misc.module.scss @@ -0,0 +1,17 @@ +.orcid-icon { + height: 24px; + margin-right: 0.25em; +} + +/* 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/misc.test.tsx b/src/features/orcidlink/common/misc.test.tsx new file mode 100644 index 00000000..039ad7be --- /dev/null +++ b/src/features/orcidlink/common/misc.test.tsx @@ -0,0 +1,71 @@ +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 { renderCreditName, renderRealname } from './misc'; + +describe('The common module renderORCIDIcon function', () => { + it('renders correct', () => { + expect(true).toBe(true); + }); +}); + +describe('The common module renderRealName 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 common module renderCreditName 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'); + }); +}); diff --git a/src/features/orcidlink/common/misc.tsx b/src/features/orcidlink/common/misc.tsx new file mode 100644 index 00000000..df743ea9 --- /dev/null +++ b/src/features/orcidlink/common/misc.tsx @@ -0,0 +1,95 @@ +/** + * 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 + ); +} + +export function privateField() { + return private; +} + +export function naField() { + 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 privateField(); + } + + 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 privateField(); + } + if (!profile.nameGroup.fields.creditName) { + return naField(); + } + return {profile.nameGroup.fields.creditName}; +} + +export function renderLoading(title: string, description: string) { + return ( +
+ }> + + {title} + +

{description}

+
+
+ ); +} + +export function renderLoadingOverlay(open: boolean) { + return ( + + {renderLoading('Loading...', 'Loading ORCID Link')} + + ); +} 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..c29624b8 --- /dev/null +++ b/src/features/orcidlink/test/data.ts @@ -0,0 +1,97 @@ +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, + }, +}; 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); +} diff --git a/src/features/orcidlink/utils.ts b/src/features/orcidlink/utils.ts new file mode 100644 index 00000000..cd64e428 --- /dev/null +++ b/src/features/orcidlink/utils.ts @@ -0,0 +1,21 @@ +/** + * Creates a URL object given the configured KBase domain and a path. + * + * This is suitable for creating external links in the same KBase deploy environment. + * + * @param path + * @returns + */ +export function uiURL(path: string) { + let origin: string; + if (process.env.REACT_APP_KBASE_DOMAIN) { + origin = `https://${process.env.REACT_APP_KBASE_DOMAIN}`; + } else { + origin = window.location.origin; + } + + const url = new URL(origin); + url.pathname = path; + + return url; +} From 153bd48d5d862abc2f99884d166482d0bb8205b9 Mon Sep 17 00:00:00 2001 From: Erik Pearson Date: Thu, 16 May 2024 17:46:36 +0000 Subject: [PATCH 07/20] remove material icons, use fa chevron icon instead [URO-208] had followed the documentation which uses mui icons (and the docs have warnings and workarounds for fa icons,which may not be relevant here), but the fa icon does indeed work. Also overrides the default orientation and rotation, which matches the other usage in the codebase which itself matches the kbase-ui usage which matches the Narrative which uses an augmented bootstrap 3 "accordion". Notable, though that BS5 Accordions work the same as MUI (BS 3 and 4 do not use icon at all.) Also added the recommended aria- attributes. --- package-lock.json | 2 +- package.json | 1 - src/features/orcidlink/common/Scopes.tsx | 14 ++++++++++++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index aea7f92c..8b19c479 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@fortawesome/fontawesome-svg-core": "^6.1.1", "@fortawesome/free-solid-svg-icons": "^6.1.1", "@fortawesome/react-fontawesome": "^0.1.18", - "@mui/icons-material": "^5.15.16", + "@mui/icons-material": "5.15.16", "@mui/material": "^5.14.18", "@popperjs/core": "^2.11.8", "@reduxjs/toolkit": "^1.9.5", diff --git a/package.json b/package.json index 84b36ec5..8cd54391 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,6 @@ "@fortawesome/free-solid-svg-icons": "^6.1.1", "@fortawesome/react-fontawesome": "^0.1.18", "@mui/material": "^5.14.18", - "@mui/icons-material": "5.15.16", "@popperjs/core": "^2.11.8", "@reduxjs/toolkit": "^1.9.5", "@tanstack/react-table": "^8.5.13", diff --git a/src/features/orcidlink/common/Scopes.tsx b/src/features/orcidlink/common/Scopes.tsx index 0b8148f5..41f401f9 100644 --- a/src/features/orcidlink/common/Scopes.tsx +++ b/src/features/orcidlink/common/Scopes.tsx @@ -6,7 +6,8 @@ * by opening the accordion for that item. */ -import { ExpandMore } from '@mui/icons-material'; +import { faAngleRight } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Accordion, AccordionDetails, @@ -41,7 +42,16 @@ export default function Scopes({ scopes }: ScopesProps) { 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} From bc1a3a3ed3deb32bc7707c3a852db1b0bb62b12e Mon Sep 17 00:00:00 2001 From: Erik Pearson Date: Thu, 16 May 2024 18:50:47 +0000 Subject: [PATCH 08/20] remove upstream code reference (redux-toolkit) comment [URO-208] this was really a note-to-self; it is also easily discoverable via VSC go-to-definition which is also more accurate vis-a-vis the version being used --- src/common/api/utils/common.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/common/api/utils/common.ts b/src/common/api/utils/common.ts index 6782692d..57fbf87b 100644 --- a/src/common/api/utils/common.ts +++ b/src/common/api/utils/common.ts @@ -22,7 +22,6 @@ type JsonRpcError = { }; }; -// https://github.com/reduxjs/redux-toolkit/blob/7cd8142f096855eb7cd03fb54c149ebfdc7dd084/packages/toolkit/src/query/fetchBaseQuery.ts#L48 export type KBaseBaseQueryError = | FetchBaseQueryError | { From 77c60a83eb1d9dd34cc453ae33b93b546f714829 Mon Sep 17 00:00:00 2001 From: Erik Pearson Date: Thu, 16 May 2024 18:51:35 +0000 Subject: [PATCH 09/20] remove uiURL [URO-208] --- src/features/orcidlink/common.test.ts | 22 ---------------------- src/features/orcidlink/utils.ts | 21 --------------------- 2 files changed, 43 deletions(-) delete mode 100644 src/features/orcidlink/common.test.ts delete mode 100644 src/features/orcidlink/utils.ts diff --git a/src/features/orcidlink/common.test.ts b/src/features/orcidlink/common.test.ts deleted file mode 100644 index d60fdd9c..00000000 --- a/src/features/orcidlink/common.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { uiURL } from './utils'; - -describe('The common module uiURL function', () => { - // We can get away with a shallow copy, since env is shallow. - const INITIAL_ENV = { ...process.env }; - - beforeEach(() => { - process.env = INITIAL_ENV; - }); - - it('creates a sensible url using the expected environment variable', () => { - process.env.REACT_APP_KBASE_DOMAIN = 'example.com'; - const url = uiURL('foo'); - expect(url.toString()).toBe('https://example.com/foo'); - }); - - it('creates a sensible url without the expected environment variable', () => { - process.env.REACT_APP_KBASE_DOMAIN = ''; - const url = uiURL('foo'); - expect(url.toString()).toBe(`${window.location.origin}/foo`); - }); -}); diff --git a/src/features/orcidlink/utils.ts b/src/features/orcidlink/utils.ts deleted file mode 100644 index cd64e428..00000000 --- a/src/features/orcidlink/utils.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Creates a URL object given the configured KBase domain and a path. - * - * This is suitable for creating external links in the same KBase deploy environment. - * - * @param path - * @returns - */ -export function uiURL(path: string) { - let origin: string; - if (process.env.REACT_APP_KBASE_DOMAIN) { - origin = `https://${process.env.REACT_APP_KBASE_DOMAIN}`; - } else { - origin = window.location.origin; - } - - const url = new URL(origin); - url.pathname = path; - - return url; -} From 559e0daddaeddb94d6ca5d9efeef554f16f9043e Mon Sep 17 00:00:00 2001 From: Erik Pearson Date: Thu, 16 May 2024 19:58:15 +0000 Subject: [PATCH 10/20] remove card content padding top override [URO-208] rely upon adjustments to card content itself - e.g. remove margin and padding from top and bottom. But that adjustment depends on what is embedded in the content area, so leave to individual usages. --- .../orcidlink/HomeLinked/OverviewTab.tsx | 9 +++++---- src/features/orcidlink/HomeUnlinked.tsx | 16 ++++++++-------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/features/orcidlink/HomeLinked/OverviewTab.tsx b/src/features/orcidlink/HomeLinked/OverviewTab.tsx index cd5f7762..7a892a03 100644 --- a/src/features/orcidlink/HomeLinked/OverviewTab.tsx +++ b/src/features/orcidlink/HomeLinked/OverviewTab.tsx @@ -2,6 +2,7 @@ import { Card, CardContent, CardHeader, + Typography, Unstable_Grid2 as Grid, } from '@mui/material'; import { InfoResult } from '../../../common/api/orcidlinkAPI'; @@ -39,15 +40,15 @@ export default function OverviewTab({ - -
NOTES HERE
+ + NOTES HERE
- -
LINKS TO MORE INFO HERE
+ + LINKS TO MORE INFO HERE
diff --git a/src/features/orcidlink/HomeUnlinked.tsx b/src/features/orcidlink/HomeUnlinked.tsx index eac767d1..66d63687 100644 --- a/src/features/orcidlink/HomeUnlinked.tsx +++ b/src/features/orcidlink/HomeUnlinked.tsx @@ -28,14 +28,14 @@ export default function Unlinked() { in GH issues), one must fiddle with padding and margins on a case-by-case basis. ) */} - - + + 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. -

+
{/* Note that the card actions padding is overridden so that it matches that of the card content and header. There are a number of formatting @@ -54,15 +54,15 @@ export default function Unlinked() { - -
NOTES HERE
+ + NOTES HERE
- -
LINKS TO MORE INFORMATION HERE
+ + LINKS TO MORE INFORMATION HERE
From 97a6a148bbf8933d09b2f462195ab19a7180d37b Mon Sep 17 00:00:00 2001 From: Erik Pearson Date: Thu, 16 May 2024 20:44:33 +0000 Subject: [PATCH 11/20] remove types - one unused, one replaced with the aliased type [URO-208] --- src/common/api/orcidlinkAPI.ts | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/src/common/api/orcidlinkAPI.ts b/src/common/api/orcidlinkAPI.ts index 3c08f9f9..0b9b5dcb 100644 --- a/src/common/api/orcidlinkAPI.ts +++ b/src/common/api/orcidlinkAPI.ts @@ -2,14 +2,6 @@ import { baseApi } from '.'; import { LinkRecordPublic, ORCIDProfile } from './orcidLinkCommon'; import { jsonRpc2Service } from './utils/serviceHelpers'; -// is-linked - -export type IsLinkedResult = boolean; - -// owner-link - -export type OwnerLinkResult = LinkRecordPublic; - // system info export interface ServiceDescription { @@ -46,14 +38,10 @@ export interface InfoResult { runtime_info: RuntimeInfo; } -// orcid profile - -export type GetProfileResult = ORCIDProfile; - // combined api calls for initial view export interface ORCIDLinkInitialStateResult { - isLinked: IsLinkedResult; + isLinked: boolean; info: InfoResult; } @@ -113,7 +101,7 @@ export const orcidlinkAPI = baseApi } return { data: { - isLinked: isLinked.data as IsLinkedResult, + isLinked: isLinked.data as boolean, info: info.data as InfoResult, }, }; From 6f18524c7800bb8079fadb664de9d776ad0e31b5 Mon Sep 17 00:00:00 2001 From: Erik Pearson Date: Fri, 17 May 2024 14:36:31 +0000 Subject: [PATCH 12/20] replace non-null assertion with thrown exception [URO-208] --- src/features/orcidlink/Home/index.test.tsx | 28 +++++++++++++++++++++- src/features/orcidlink/Home/index.tsx | 7 ++++-- src/features/orcidlink/test/data.ts | 6 +++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/features/orcidlink/Home/index.test.tsx b/src/features/orcidlink/Home/index.test.tsx index 8603fcde..11f7c91f 100644 --- a/src/features/orcidlink/Home/index.test.tsx +++ b/src/features/orcidlink/Home/index.test.tsx @@ -1,10 +1,14 @@ 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 } from '../test/data'; +import { + INITIAL_STORE_STATE, + INITIAL_UNAUTHENTICATED_STORE_STATE, +} from '../test/data'; import { mockIsLinked, mockIsLinked_not } from '../test/mocks'; import HomeController from './index'; @@ -208,4 +212,26 @@ describe('The HomeController Component', () => { ); }); }); + + 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 index 4461cb2a..1d5809db 100644 --- a/src/features/orcidlink/Home/index.tsx +++ b/src/features/orcidlink/Home/index.tsx @@ -6,8 +6,11 @@ import ErrorMessage from '../common/ErrorMessage'; import Home from './Home'; export default function HomeController() { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const username = useAppSelector(authUsername)!; + const username = useAppSelector(authUsername); + + if (typeof username === 'undefined') { + throw new Error('Impossible - username is not defined'); + } usePageTitle('KBase ORCID Link'); diff --git a/src/features/orcidlink/test/data.ts b/src/features/orcidlink/test/data.ts index c29624b8..0716be41 100644 --- a/src/features/orcidlink/test/data.ts +++ b/src/features/orcidlink/test/data.ts @@ -95,3 +95,9 @@ export const INITIAL_STORE_STATE = { initialized: true, }, }; + +export const INITIAL_UNAUTHENTICATED_STORE_STATE = { + auth: { + initialized: true, + }, +}; From bd504a6b1e106eb31041883aca2857959df40d56 Mon Sep 17 00:00:00 2001 From: Erik Pearson Date: Fri, 17 May 2024 10:16:18 -0700 Subject: [PATCH 13/20] remove comments [URO-208] --- src/features/orcidlink/HomeUnlinked.tsx | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/features/orcidlink/HomeUnlinked.tsx b/src/features/orcidlink/HomeUnlinked.tsx index 66d63687..9d0580a3 100644 --- a/src/features/orcidlink/HomeUnlinked.tsx +++ b/src/features/orcidlink/HomeUnlinked.tsx @@ -19,15 +19,6 @@ export default function Unlinked() { - {/* - A note on the padding and margin overrides. Mui is not very smart about components - embedded in card content. The card content has a padding on all subelements (header, - content, actions) which is added to any margins and padding of components contained - within, so it pretty easily blows up into a lot of blank space. - To keep the empty space under control (and others have commented on this - in GH issues), one must fiddle with padding and margins on a case-by-case - basis. - ) */} You do not currently have a link from your KBase account to an @@ -37,9 +28,6 @@ export default function Unlinked() { Click the button below to begin the KBase ORCID® Link process. - {/* Note that the card actions padding is overridden so that it matches - that of the card content and header. There are a number of formatting - issues with Cards. Some will apparently be fixed in v6. */}