diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 848e3fa76..17c3bf460 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -143,7 +143,7 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} - name: Upload Cypress screenshots - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 if: failure() with: name: cypress-screenshots diff --git a/packages/design-system/CHANGELOG.md b/packages/design-system/CHANGELOG.md index 992bef245..51ec69460 100644 --- a/packages/design-system/CHANGELOG.md +++ b/packages/design-system/CHANGELOG.md @@ -48,6 +48,7 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline - **Stack:** NEW Stack element to manage layouts. - **TransferList:** NEW TransferList component to transfer items between two list, also sortable. - **Table:** extend Props Interface to accept `bordered` prop to add or remove borders on all sides of the table and cells. Defaults to true. +- **Tab:** extend Props Interface to accept `disabled` prop to disable the tab. # [1.1.0](https://github.com/IQSS/dataverse-frontend/compare/@iqss/dataverse-design-system@1.0.1...@iqss/dataverse-design-system@1.1.0) (2024-03-12) diff --git a/packages/design-system/src/lib/components/tabs/Tab.tsx b/packages/design-system/src/lib/components/tabs/Tab.tsx index 5f1ef3cbb..9ba07a6a1 100644 --- a/packages/design-system/src/lib/components/tabs/Tab.tsx +++ b/packages/design-system/src/lib/components/tabs/Tab.tsx @@ -4,11 +4,12 @@ import { Tab as TabBS } from 'react-bootstrap' export interface TabProps { title: string eventKey: string + disabled?: boolean } -export function Tab({ title, eventKey, children }: PropsWithChildren) { +export function Tab({ title, eventKey, disabled = false, children }: PropsWithChildren) { return ( - + {children} ) diff --git a/packages/design-system/src/lib/stories/tabs/Tabs.stories.tsx b/packages/design-system/src/lib/stories/tabs/Tabs.stories.tsx index e9f8a5708..8cb7c1adf 100644 --- a/packages/design-system/src/lib/stories/tabs/Tabs.stories.tsx +++ b/packages/design-system/src/lib/stories/tabs/Tabs.stories.tsx @@ -43,3 +43,19 @@ export const Default: Story = { ) } + +export const SomeTabDisabled: Story = { + render: () => ( + + + + + + + + + + + + ) +} diff --git a/public/locales/en/account.json b/public/locales/en/account.json new file mode 100644 index 000000000..41b74e884 --- /dev/null +++ b/public/locales/en/account.json @@ -0,0 +1,18 @@ +{ + "pageTitle": "Account", + "tabs": { + "myData": "My Data", + "notifications": "Notifications", + "accountInformation": "Account Information", + "apiToken": "API Token" + }, + "apiToken": { + "helperText": "Your API Token is valid for a year. Check out our API Guide for more information on using your API Token with the Dataverse APIs.", + "notCreatedApiToken": "API Token for Dataverse Admin has not been created.", + "expirationDate": "Expiration date", + "copyToClipboard": "Copy to Clipboard", + "recreateToken": "Recreate Token", + "revokeToken": "Revoke Token", + "createToken": "Create Token" + } +} diff --git a/public/locales/en/header.json b/public/locales/en/header.json index 0a7d4df8e..b9313292b 100644 --- a/public/locales/en/header.json +++ b/public/locales/en/header.json @@ -7,6 +7,7 @@ "navigation": { "addData": "Add Data", "newCollection": "New Collection", - "newDataset": "New Dataset" + "newDataset": "New Dataset", + "apiToken": "API Token" } } diff --git a/src/Router.tsx b/src/Router.tsx index f6a6426f3..0cfa19960 100644 --- a/src/Router.tsx +++ b/src/Router.tsx @@ -10,6 +10,7 @@ import { UploadDatasetFilesFactory } from './sections/upload-dataset-files/Uploa import { EditDatasetMetadataFactory } from './sections/edit-dataset-metadata/EditDatasetMetadataFactory' import { DatasetNonNumericVersion } from './dataset/domain/models/Dataset' import { CreateCollectionFactory } from './sections/create-collection/CreateCollectionFactory' +import { AccountFactory } from './sections/account/AccountFactory' const router = createBrowserRouter( [ @@ -49,6 +50,10 @@ const router = createBrowserRouter( { path: Route.FILES, element: FileFactory.create() + }, + { + path: Route.ACCOUNT, + element: AccountFactory.create() } ] } diff --git a/src/sections/Route.enum.ts b/src/sections/Route.enum.ts index ee104c035..57cef9e1f 100644 --- a/src/sections/Route.enum.ts +++ b/src/sections/Route.enum.ts @@ -11,7 +11,8 @@ export enum Route { EDIT_DATASET_METADATA = '/datasets/edit-metadata', FILES = '/files', COLLECTIONS = '/collections/:collectionId', - CREATE_COLLECTION = '/collections/:ownerCollectionId/create' + CREATE_COLLECTION = '/collections/:ownerCollectionId/create', + ACCOUNT = '/account' } export const RouteWithParams = { diff --git a/src/sections/account/Account.module.scss b/src/sections/account/Account.module.scss new file mode 100644 index 000000000..e76e0535b --- /dev/null +++ b/src/sections/account/Account.module.scss @@ -0,0 +1,10 @@ +@import 'node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/colors.module'; + +.tab-container { + padding: 1rem; +} + +.helper-text { + color: $dv-subtext-color; + font-size: 14px; +} diff --git a/src/sections/account/Account.tsx b/src/sections/account/Account.tsx new file mode 100644 index 000000000..eaa4090b2 --- /dev/null +++ b/src/sections/account/Account.tsx @@ -0,0 +1,59 @@ +import { useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { Tabs } from '@iqss/dataverse-design-system' +import { useLoading } from '../loading/LoadingContext' +import { AccountHelper, AccountPanelTabKey } from './AccountHelper' +import { ApiTokenSection } from './api-token-section/ApiTokenSection' +import { BreadcrumbsGenerator } from '../shared/hierarchy/BreadcrumbsGenerator' +import { UpwardHierarchyNodeMother } from '../../../tests/component/shared/hierarchy/domain/models/UpwardHierarchyNodeMother' +import styles from './Account.module.scss' + +const tabsKeys = AccountHelper.ACCOUNT_PANEL_TABS_KEYS + +interface AccountProps { + defaultActiveTabKey: AccountPanelTabKey +} + +export const Account = ({ defaultActiveTabKey }: AccountProps) => { + const { t } = useTranslation('account') + const { setIsLoading } = useLoading() + + const rootHierarchy = UpwardHierarchyNodeMother.createCollection({ + name: 'Root', + id: 'root' + }) + + useEffect(() => { + setIsLoading(false) + }, [setIsLoading]) + + return ( +
+ + +
+

{t('pageTitle')}

+
+ + + +
+
+ +
+
+ +
+
+ +
+ +
+
+
+
+ ) +} diff --git a/src/sections/account/AccountFactory.tsx b/src/sections/account/AccountFactory.tsx new file mode 100644 index 000000000..0ecbab6b5 --- /dev/null +++ b/src/sections/account/AccountFactory.tsx @@ -0,0 +1,17 @@ +import { ReactElement } from 'react' +import { useSearchParams } from 'react-router-dom' +import { AccountHelper } from './AccountHelper' +import { Account } from './Account' + +export class AccountFactory { + static create(): ReactElement { + return + } +} + +function AccountWithSearchParams() { + const [searchParams] = useSearchParams() + const defaultActiveTabKey = AccountHelper.defineSelectedTabKey(searchParams) + + return +} diff --git a/src/sections/account/AccountHelper.ts b/src/sections/account/AccountHelper.ts new file mode 100644 index 000000000..799a0bc5f --- /dev/null +++ b/src/sections/account/AccountHelper.ts @@ -0,0 +1,22 @@ +export class AccountHelper { + static ACCOUNT_PANEL_TABS_KEYS = { + myData: 'myData', + notifications: 'notifications', + accountInformation: 'accountInformation', + apiToken: 'apiToken' + } as const + + static ACCOUNT_PANEL_TAB_QUERY_KEY = 'tab' + + public static defineSelectedTabKey(searchParams: URLSearchParams): AccountPanelTabKey { + const tabValue = searchParams.get(this.ACCOUNT_PANEL_TAB_QUERY_KEY) + + return ( + this.ACCOUNT_PANEL_TABS_KEYS[tabValue as keyof typeof this.ACCOUNT_PANEL_TABS_KEYS] ?? + this.ACCOUNT_PANEL_TABS_KEYS.myData + ) + } +} + +export type AccountPanelTabKey = + (typeof AccountHelper.ACCOUNT_PANEL_TABS_KEYS)[keyof typeof AccountHelper.ACCOUNT_PANEL_TABS_KEYS] diff --git a/src/sections/account/api-token-section/ApiTokenSection.module.scss b/src/sections/account/api-token-section/ApiTokenSection.module.scss new file mode 100644 index 000000000..292eb1829 --- /dev/null +++ b/src/sections/account/api-token-section/ApiTokenSection.module.scss @@ -0,0 +1,32 @@ +@import 'node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/colors.module'; + +.exp-date { + display: flex; + gap: 1.5rem; + align-items: center; + padding-left: 0.5rem; + font-weight: bold; + + time { + font-weight: normal; + } + + @media (min-width: 768px) { + gap: 3rem; + } +} + +.api-token { + padding: 0.5rem 1rem; + background-color: #f7f7f9; + border: solid 1px $dv-border-color; + border-radius: 4px; +} + +.btns-wrapper { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + padding-top: 1rem; +} diff --git a/src/sections/account/api-token-section/ApiTokenSection.tsx b/src/sections/account/api-token-section/ApiTokenSection.tsx new file mode 100644 index 000000000..a6a5e24c2 --- /dev/null +++ b/src/sections/account/api-token-section/ApiTokenSection.tsx @@ -0,0 +1,71 @@ +import { Trans, useTranslation } from 'react-i18next' +import { Button } from '@iqss/dataverse-design-system' +import accountStyles from '../Account.module.scss' +import styles from './ApiTokenSection.module.scss' + +export const ApiTokenSection = () => { + const { t } = useTranslation('account', { keyPrefix: 'apiToken' }) + + const apiToken = '999fff-666rrr-this-is-not-a-real-token-123456' + const expirationDate = '2025-09-04' + + const copyToClipboard = () => { + navigator.clipboard.writeText(apiToken).catch( + /* istanbul ignore next */ (error) => { + console.error('Failed to copy text:', error) + } + ) + } + + return ( + <> +

+ + ) + }} + /> +

+ {apiToken ? ( + <> +

+ {t('expirationDate')} +

+
+ {apiToken} +
+
+ + + +
+ + ) : ( + <> +
+ {t('notCreatedApiToken')} +
+
+ +
+ + )} + + ) +} diff --git a/src/sections/layout/header/LoggedInHeaderActions.tsx b/src/sections/layout/header/LoggedInHeaderActions.tsx index 031bf4338..0fc30205c 100644 --- a/src/sections/layout/header/LoggedInHeaderActions.tsx +++ b/src/sections/layout/header/LoggedInHeaderActions.tsx @@ -7,6 +7,7 @@ import { Route, RouteWithParams } from '../../Route.enum' import { User } from '../../../users/domain/models/User' import { CollectionRepository } from '../../../collection/domain/repositories/CollectionRepository' import { ROOT_COLLECTION_ALIAS } from '../../../collection/domain/models/Collection' +import { AccountHelper } from '../../account/AccountHelper' const currentPage = 0 @@ -56,6 +57,11 @@ export const LoggedInHeaderActions = ({ + + {t('navigation.apiToken')} + {t('logOut')} diff --git a/src/stories/account/Account.stories.tsx b/src/stories/account/Account.stories.tsx new file mode 100644 index 000000000..b19475721 --- /dev/null +++ b/src/stories/account/Account.stories.tsx @@ -0,0 +1,22 @@ +import { Meta, StoryObj } from '@storybook/react' +import { Account } from '../../sections/account/Account' +import { WithI18next } from '../WithI18next' +import { WithLayout } from '../WithLayout' +import { WithLoggedInUser } from '../WithLoggedInUser' +import { AccountHelper } from '../../sections/account/AccountHelper' + +const meta: Meta = { + title: 'Pages/Account', + component: Account, + decorators: [WithI18next, WithLayout, WithLoggedInUser], + parameters: { + // Sets the delay for all stories. + chromatic: { delay: 15000, pauseAnimationAtEnd: true } + } +} +export default meta +type Story = StoryObj + +export const APITokenTab: Story = { + render: () => +} diff --git a/tests/component/sections/account/Account.spec.tsx b/tests/component/sections/account/Account.spec.tsx new file mode 100644 index 000000000..77fc048eb --- /dev/null +++ b/tests/component/sections/account/Account.spec.tsx @@ -0,0 +1,14 @@ +import { Account } from '../../../../src/sections/account/Account' + +describe('Account', () => { + it('should render the correct breadcrumbs', () => { + cy.mountAuthenticated() + + cy.findByRole('link', { name: 'Root' }).should('exist') + + cy.get('li[aria-current="page"]') + .should('exist') + .should('have.text', 'Account') + .should('have.class', 'active') + }) +}) diff --git a/tests/component/sections/account/ApiTokenSection.spec.tsx b/tests/component/sections/account/ApiTokenSection.spec.tsx new file mode 100644 index 000000000..26ff3220b --- /dev/null +++ b/tests/component/sections/account/ApiTokenSection.spec.tsx @@ -0,0 +1,24 @@ +import { ApiTokenSection } from '../../../../src/sections/account/api-token-section/ApiTokenSection' + +describe('ApiTokenSection', () => { + beforeEach(() => { + cy.mountAuthenticated() + }) + + it('should copy the api token to the clipboard', () => { + cy.window().then((win) => { + cy.stub(win.navigator.clipboard, 'writeText').resolves() + + cy.findByRole('button', { name: /Copy to Clipboard/ }).click() + + cy.get('[data-testid="api-token"]').then(($element) => { + const textToCopy = $element.text() + + // eslint-disable-next-line @typescript-eslint/unbound-method + cy.wrap(win.navigator.clipboard.writeText).should('be.calledWith', textToCopy) + }) + }) + }) + + // TODO: When we get the api token from the use case, we could mock the response and test more things. +})