diff --git a/apps/portals/nf/src/config/resources.ts b/apps/portals/nf/src/config/resources.ts index b15bafbbfb..f7ad3cdcd8 100644 --- a/apps/portals/nf/src/config/resources.ts +++ b/apps/portals/nf/src/config/resources.ts @@ -1,7 +1,8 @@ export const datasetsSql = 'SELECT * FROM syn50913342' export const publicationsSql = 'SELECT * FROM syn16857542' export const studiesSql = 'SELECT * FROM syn52694652' -export const initiativesSql = 'SELECT * FROM syn24189696 order by initiative asc' +export const initiativesSql = + 'SELECT * FROM syn24189696 order by initiative asc' export const toolsSql = 'SELECT * FROM syn51730943' export const peopleSql = 'SELECT * FROM syn23564971' export const filesSql = `SELECT * FROM syn52702673 WHERE resourceType in ('experimentalData', 'results', 'analysis')` diff --git a/apps/portals/nf/src/config/routesConfig.ts b/apps/portals/nf/src/config/routesConfig.ts index 9618d2e549..3ef064e959 100644 --- a/apps/portals/nf/src/config/routesConfig.ts +++ b/apps/portals/nf/src/config/routesConfig.ts @@ -1,4 +1,5 @@ import { GenericRoute } from '@sage-bionetworks/synapse-portal-framework/types/portal-config' +import { SharePageLinkButtonConfig } from '@sage-bionetworks/synapse-portal-framework/src/shared-config/SharePageLinkButtonConfig' import { SynapseConstants } from 'synapse-react-client' import { newStudiesSql, @@ -265,6 +266,7 @@ const routes: GenericRoute[] = [ { path: '', synapseConfigArray: [ + SharePageLinkButtonConfig, { name: 'CardContainerLogic', isOutsideContainer: true, diff --git a/apps/portals/nf/src/config/synapseConfigs/tools.ts b/apps/portals/nf/src/config/synapseConfigs/tools.ts index bbd2cb64b9..d2aaaa0607 100644 --- a/apps/portals/nf/src/config/synapseConfigs/tools.ts +++ b/apps/portals/nf/src/config/synapseConfigs/tools.ts @@ -1,6 +1,7 @@ import type { CardConfiguration } from 'synapse-react-client' import { GenericCardSchema, SynapseConstants } from 'synapse-react-client' import { SynapseConfig } from '@sage-bionetworks/synapse-portal-framework/types/portal-config' +import { SharePageLinkButtonConfig } from '@sage-bionetworks/synapse-portal-framework/src/shared-config/SharePageLinkButtonConfig' import { columnAliases } from './commonProps' import { catalogNumberSql, @@ -358,6 +359,7 @@ export const toolDetailsPageConfig: DetailsPageProps = { } export const toolsDetailsPage: SynapseConfig[] = [ + SharePageLinkButtonConfig, { name: 'CardContainerLogic', isOutsideContainer: true, diff --git a/apps/synapse-portal-framework/src/shared-config/SharePageLinkButtonConfig.ts b/apps/synapse-portal-framework/src/shared-config/SharePageLinkButtonConfig.ts new file mode 100644 index 0000000000..f9be05dd81 --- /dev/null +++ b/apps/synapse-portal-framework/src/shared-config/SharePageLinkButtonConfig.ts @@ -0,0 +1,19 @@ +import { SynapseConfig } from '../types/portal-config' + +export const SharePageLinkButtonConfig: SynapseConfig = { + name: 'SharePageLinkButton', + props: { + shortIoPublicApiKey: 'pk_y4sPMLrxonM7kNQV', + buttonProps: { + variant: 'text', + color: 'light', + sx: { + position: 'absolute', + top: '50px', + right: '20px', + zIndex: 100, + }, + }, + }, + containerClassName: 'container-full-width', +} diff --git a/apps/synapse-portal-framework/src/style/components/_DetailsPage.scss b/apps/synapse-portal-framework/src/style/components/_DetailsPage.scss index 4f8ca0f551..7e1386223b 100644 --- a/apps/synapse-portal-framework/src/style/components/_DetailsPage.scss +++ b/apps/synapse-portal-framework/src/style/components/_DetailsPage.scss @@ -29,7 +29,6 @@ $svg-icon-height: 30px; .DetailsPage { display: flex; - // keeps h2 in markdown and in this component the same .h2, h2 { diff --git a/apps/synapse-portal-framework/src/types/portal-config.ts b/apps/synapse-portal-framework/src/types/portal-config.ts index 22dcc47b07..5349ac447c 100644 --- a/apps/synapse-portal-framework/src/types/portal-config.ts +++ b/apps/synapse-portal-framework/src/types/portal-config.ts @@ -27,6 +27,7 @@ import { UserCardListRotateProps, UserCardProps, DynamicFormProps, + SharePageLinkButtonProps, } from 'synapse-react-client' import { RouteControlWrapperProps } from '../components/RouteControlWrapper' import { HomePageCardContainerProps } from '../components/csbc-home-page/HomePageCardContainer' @@ -177,6 +178,10 @@ type GenieHomePageHeader = { name: 'GenieHomePageHeader' props: undefined } +type SharePageLinkButton = { + name: 'SharePageLinkButton' + props: SharePageLinkButtonProps +} type SynapseComponentCollapse = { name: 'SynapseComponentCollapse' props: SynapseComponentCollapseProps @@ -388,6 +393,7 @@ export type SynapseConfig = ( | TimelinePlot | DatasetJsonLdScript | DynamicForm + | SharePageLinkButton ) & Metadata diff --git a/packages/synapse-react-client/src/components/SharePageLinkButton/SharePageLinkButton.stories.tsx b/packages/synapse-react-client/src/components/SharePageLinkButton/SharePageLinkButton.stories.tsx new file mode 100644 index 0000000000..6adf3ddd7d --- /dev/null +++ b/packages/synapse-react-client/src/components/SharePageLinkButton/SharePageLinkButton.stories.tsx @@ -0,0 +1,23 @@ +import { Meta, StoryObj } from '@storybook/react' +import SharePageLinkButton from './SharePageLinkButton' + +const meta = { + title: 'UI/SharePageLinkButton', + component: SharePageLinkButton, +} satisfies Meta +export default meta +type Story = StoryObj + +export const SharePageLinkButtonStory: Story = { + args: { + buttonProps: { + sx: { position: 'fixed', right: '20px' }, + }, + }, + parameters: { + stack: 'mock', + msw: { + handlers: [], + }, + }, +} diff --git a/packages/synapse-react-client/src/components/SharePageLinkButton/SharePageLinkButton.test.tsx b/packages/synapse-react-client/src/components/SharePageLinkButton/SharePageLinkButton.test.tsx new file mode 100644 index 0000000000..26b11c7934 --- /dev/null +++ b/packages/synapse-react-client/src/components/SharePageLinkButton/SharePageLinkButton.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import React from 'react' +import { createWrapper } from '../../testutils/TestingLibraryUtils' +import SharePageLinkButton, { + SharePageLinkButtonProps, +} from './SharePageLinkButton' +import { MOCK_SHORT_IO_URL } from '../../mocks/mockShortIo' +import { server } from '../../mocks/msw/server' + +function renderComponent(props: SharePageLinkButtonProps) { + return render(, { + wrapper: createWrapper(), + }) +} +describe('SharePageLinkButton', () => { + beforeAll(() => server.listen()) + afterEach(() => server.restoreHandlers()) + afterAll(() => server.close()) + beforeEach(() => { + jest.clearAllMocks() + // Replace clipboard.writeText with a mock + Object.assign(navigator, { + clipboard: { + writeText: jest.fn().mockImplementation(() => Promise.resolve()), + }, + }) + }) + + it('Copies short.io response to clipboard', async () => { + renderComponent({ shortIoPublicApiKey: 'abc' }) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + + await userEvent.click( + screen.getByRole('button', { name: 'Share Page Link' }), + ) + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + MOCK_SHORT_IO_URL, + ) + }) +}) diff --git a/packages/synapse-react-client/src/components/SharePageLinkButton/SharePageLinkButton.tsx b/packages/synapse-react-client/src/components/SharePageLinkButton/SharePageLinkButton.tsx new file mode 100644 index 0000000000..4cd64b823e --- /dev/null +++ b/packages/synapse-react-client/src/components/SharePageLinkButton/SharePageLinkButton.tsx @@ -0,0 +1,70 @@ +import React, { useCallback } from 'react' +import { useMutation } from '@tanstack/react-query' +import { displayToast } from '../ToastMessage' +import IconSvg from '../IconSvg' +import { Button, ButtonProps } from '@mui/material' + +export type SharePageLinkButtonProps = { + shortIoPublicApiKey?: string + domain?: string + buttonProps?: ButtonProps +} + +export const SharePageLinkButton: React.FunctionComponent< + SharePageLinkButtonProps +> = ({ shortIoPublicApiKey, domain = 'sageb.io', buttonProps }) => { + const copyToClipboard = useCallback((value: string) => { + navigator.clipboard.writeText(value).then(() => { + displayToast('Page URL copied to the clipboard', 'success') + }) + }, []) + // create short io link (if not already created) + const { mutate: createShortUrl } = useMutation({ + mutationFn: async () => { + if (!shortIoPublicApiKey) { + return window.location.href + } else { + const response = await fetch('https://api.short.io/links/public', { + method: 'POST', + headers: { + Authorization: shortIoPublicApiKey, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + originalURL: window.location.href, + domain: domain, + }), + }) + if (!response.ok) { + const responseText = await response.text() + throw new Error(responseText) + } + const jsonResponse = await response.json() + return jsonResponse.shortURL + } + }, + onSuccess: data => { + copyToClipboard(data) + }, + onError: error => { + console.error(error) + copyToClipboard(window.location.href) + }, + }) + + return ( + + ) +} + +export default SharePageLinkButton diff --git a/packages/synapse-react-client/src/components/SharePageLinkButton/index.ts b/packages/synapse-react-client/src/components/SharePageLinkButton/index.ts new file mode 100644 index 0000000000..d8e5a130b2 --- /dev/null +++ b/packages/synapse-react-client/src/components/SharePageLinkButton/index.ts @@ -0,0 +1,4 @@ +import SharePageLinkButton from './SharePageLinkButton' +import type { SharePageLinkButtonProps } from './SharePageLinkButton' +export { SharePageLinkButton, SharePageLinkButtonProps } +export default SharePageLinkButton diff --git a/packages/synapse-react-client/src/components/SynapseForm/SynapseFormSubmissionGrid.tsx b/packages/synapse-react-client/src/components/SynapseForm/SynapseFormSubmissionGrid.tsx index 9502c7b9e8..6b68e9c089 100644 --- a/packages/synapse-react-client/src/components/SynapseForm/SynapseFormSubmissionGrid.tsx +++ b/packages/synapse-react-client/src/components/SynapseForm/SynapseFormSubmissionGrid.tsx @@ -499,8 +499,8 @@ export class SynapseFormSubmissionGrid extends React.Component< title="More Information" content={ <> - Please contact us{' '} - for more information about your submission + Please contact us for + more information about your submission } className={`theme-${this.props.formClass}`} diff --git a/packages/synapse-react-client/src/components/index.ts b/packages/synapse-react-client/src/components/index.ts index 8309d2ceeb..c5441d5cd2 100644 --- a/packages/synapse-react-client/src/components/index.ts +++ b/packages/synapse-react-client/src/components/index.ts @@ -75,6 +75,7 @@ export * from './AccessRequirementRelatedProjectsList' export * from './HelpPopover' export * from './MuiContainer' export * from './DatasetJsonLdScript' +export * from './SharePageLinkButton' export * from './SageResourcesPopover' // TODO: Find a better way to expose Icon components diff --git a/packages/synapse-react-client/src/mocks/mockShortIo.ts b/packages/synapse-react-client/src/mocks/mockShortIo.ts new file mode 100644 index 0000000000..35d78e0e56 --- /dev/null +++ b/packages/synapse-react-client/src/mocks/mockShortIo.ts @@ -0,0 +1,12 @@ +export const MOCK_SHORT_IO_URL = 'https://short.io/abc123' +export const mockShortIoResponse: any = ( + originalURL: string, + domain: string, +) => { + return { + id: '123456', + originalURL, + shortURL: MOCK_SHORT_IO_URL, + domain, + } +} diff --git a/packages/synapse-react-client/src/mocks/msw/handlers.ts b/packages/synapse-react-client/src/mocks/msw/handlers.ts index 11d800e823..c3f700dcc8 100644 --- a/packages/synapse-react-client/src/mocks/msw/handlers.ts +++ b/packages/synapse-react-client/src/mocks/msw/handlers.ts @@ -32,6 +32,7 @@ import { getResetTwoFactorAuthHandlers } from './handlers/resetTwoFactorAuthHand import { getMessageHandlers } from './handlers/messageHandlers' import { getFeatureFlagsOverride } from './handlers/featureFlagHandlers' import { getDoiHandler } from './handlers/doiHandlers' +import { getShortIoHandlers } from './handlers/shortIoHandlers' // Simple utility type that just indicates that the response body could be an error like the Synapse backend may send. export type SynapseApiResponse = T | SynapseError @@ -75,6 +76,7 @@ const getHandlers = (backendOrigin: string, portalOrigin?: string) => [ getFeatureFlagsOverride({ portalOrigin }), ...getHandlersForTableQuery(backendOrigin), ...getDoiHandler(backendOrigin), + ...getShortIoHandlers(), ] const handlers = getHandlers( diff --git a/packages/synapse-react-client/src/mocks/msw/handlers/shortIoHandlers.ts b/packages/synapse-react-client/src/mocks/msw/handlers/shortIoHandlers.ts new file mode 100644 index 0000000000..854ceac827 --- /dev/null +++ b/packages/synapse-react-client/src/mocks/msw/handlers/shortIoHandlers.ts @@ -0,0 +1,12 @@ +import { rest } from 'msw' +import { mockShortIoResponse } from '../../../mocks/mockShortIo' + +export const getShortIoHandlers = () => [ + rest.post('https://api.short.io/links/public', async (req, res, ctx) => { + const body = await req.json() + return res( + ctx.status(200), + ctx.json(mockShortIoResponse(body.originalURL, body.domain)), + ) + }), +]