diff --git a/packages/shared/src/components/CustomFeedOptionsMenu.tsx b/packages/shared/src/components/CustomFeedOptionsMenu.tsx index f4bc4b9bd64..71459bdc71d 100644 --- a/packages/shared/src/components/CustomFeedOptionsMenu.tsx +++ b/packages/shared/src/components/CustomFeedOptionsMenu.tsx @@ -28,6 +28,8 @@ type CustomFeedOptionsMenuProps = { buttonVariant?: ButtonVariant; shareProps: UseShareOrCopyLinkProps; additionalOptions?: MenuItemProps[]; + /** Drop the in-menu share entry when the surface renders a visible one. */ + hideShare?: boolean; }; const CustomFeedOptionsMenu = ({ @@ -38,6 +40,7 @@ const CustomFeedOptionsMenu = ({ onCreateNewFeed, additionalOptions = [], buttonVariant = ButtonVariant.Float, + hideShare = false, }: CustomFeedOptionsMenuProps): ReactElement => { const { openModal } = useLazyModal(); const [, onShareOrCopyLink] = useShareOrCopyLink(shareProps); @@ -59,11 +62,15 @@ const CustomFeedOptionsMenu = ({ }; const options: MenuItemProps[] = [ - { - icon: , - label: 'Share', - action: () => onShareOrCopyLink(), - }, + ...(hideShare + ? [] + : [ + { + icon: , + label: 'Share', + action: () => onShareOrCopyLink(), + }, + ]), { icon: , label: 'Add to custom feed', diff --git a/packages/shared/src/components/share/EntityShareAction.spec.tsx b/packages/shared/src/components/share/EntityShareAction.spec.tsx new file mode 100644 index 00000000000..4a12766f09b --- /dev/null +++ b/packages/shared/src/components/share/EntityShareAction.spec.tsx @@ -0,0 +1,118 @@ +import React from 'react'; +import type { RenderResult } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import { EntityShareAction } from './EntityShareAction'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import { LogEvent, Origin } from '../../lib/log'; +import { ShareProvider } from '../../lib/share'; +import { ReferralCampaignKey } from '../../lib/referral'; +import type { ToastNotification } from '../../hooks/useToastNotification'; +import { TOAST_NOTIF_KEY } from '../../hooks/useToastNotification'; +import { useViewSize } from '../../hooks/useViewSize'; + +jest.mock('../../hooks/useViewSize', () => { + const actual = jest.requireActual('../../hooks/useViewSize'); + return { __esModule: true, ...actual, useViewSize: jest.fn() }; +}); + +const useViewSizeMock = useViewSize as jest.Mock; +const writeText = jest.fn().mockResolvedValue(undefined); +const logEvent = jest.fn(); +const link = 'https://daily.dev/tags/webdev'; +const text = 'Check out the webdev tag on daily.dev'; + +let client: QueryClient; + +beforeEach(() => { + jest.clearAllMocks(); + client = new QueryClient(); + useViewSizeMock.mockReturnValue(true); // default: laptop + Object.assign(navigator, { clipboard: { writeText }, maxTouchPoints: 0 }); +}); + +const renderComponent = (): RenderResult => + render( + + + , + ); + +const getToast = () => + client.getQueryData(TOAST_NOTIF_KEY) ?? null; + +describe('EntityShareAction', () => { + it('renders a labelled share trigger separate from the follow controls', () => { + renderComponent(); + + expect(screen.getByLabelText('Share')).toBeInTheDocument(); + }); + + it('copies the link and shows the copied toast', async () => { + renderComponent(); + + await act(async () => { + fireEvent.click(screen.getByLabelText('Share')); + }); + await act(async () => { + fireEvent.click(await screen.findByText('Copy link')); + }); + + await waitFor(() => expect(writeText).toHaveBeenCalledWith(link)); + await waitFor(() => + expect(getToast()?.message).toEqual('✅ Copied link to clipboard'), + ); + }); + + it('logs the entity share event with the provider and origin', async () => { + renderComponent(); + + await act(async () => { + fireEvent.click(screen.getByLabelText('Share')); + }); + await act(async () => { + fireEvent.click(await screen.findByText('Copy link')); + }); + + await waitFor(() => + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.ShareTag, + target_id: 'webdev', + extra: JSON.stringify({ + provider: ShareProvider.CopyLink, + origin: Origin.TagPage, + }), + }), + ); + }); + + it('opens the native share sheet on a single tap on mobile', async () => { + useViewSizeMock.mockReturnValue(false); + const share = jest.fn().mockResolvedValue(undefined); + Object.assign(navigator, { share, maxTouchPoints: 2 }); + + renderComponent(); + + await act(async () => { + fireEvent.click(screen.getByLabelText('Share')); + }); + + await waitFor(() => + expect(share).toHaveBeenCalledWith({ text: `${text}\n${link}` }), + ); + expect(writeText).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/shared/src/components/share/EntityShareAction.tsx b/packages/shared/src/components/share/EntityShareAction.tsx new file mode 100644 index 00000000000..265e72761ac --- /dev/null +++ b/packages/shared/src/components/share/EntityShareAction.tsx @@ -0,0 +1,64 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { ShareActions } from './ShareActions'; +import { ButtonSize, ButtonVariant } from '../buttons/Button'; +import { Divider } from '../utilities/Divider'; +import { useLogContext } from '../../contexts/LogContext'; +import type { LogEvent, Origin } from '../../lib/log'; +import type { ReferralCampaignKey } from '../../lib/referral'; +import type { ShareProvider } from '../../lib/share'; + +export interface EntityShareActionProps { + link: string; + text: string; + cid: ReferralCampaignKey; + /** Entity-specific share event, e.g. `LogEvent.ShareTag`. */ + event: LogEvent; + targetId: string; + origin: Origin; +} + +/** + * Share control for an entity header (tag / source), promoting share out of the + * "…" options menu. The leading vertical rule plus the ghost icon button keep + * "share this" spatially and visually separate from the filled Follow/Following + * button — different intents must not read as one segmented control. + * + * Rendered as a fragment so the host action row's own gap spaces the rule + * symmetrically against the buttons on either side of it. + */ +export function EntityShareAction({ + link, + text, + cid, + event, + targetId, + origin, +}: EntityShareActionProps): ReactElement { + const { logEvent } = useLogContext(); + + const onShare = (provider: ShareProvider) => + logEvent({ + event_name: event, + target_id: targetId, + extra: JSON.stringify({ provider, origin }), + }); + + return ( + <> + {/* `self-center` because host rows don't all set `items-center`. */} + + + + ); +} diff --git a/packages/shared/src/components/sources/SourceActions/SourceActions.spec.tsx b/packages/shared/src/components/sources/SourceActions/SourceActions.spec.tsx new file mode 100644 index 00000000000..ab89abc150c --- /dev/null +++ b/packages/shared/src/components/sources/SourceActions/SourceActions.spec.tsx @@ -0,0 +1,146 @@ +import React from 'react'; +import type { RenderResult } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import { GrowthBook } from '@growthbook/growthbook-react'; +import { SourceActions } from './index'; +import { TestBootProvider } from '../../../../__tests__/helpers/boot'; +import type { Source } from '../../../graphql/sources'; +import { SourceType } from '../../../graphql/sources'; +import { useSourceActions } from '../../../hooks'; +import { useContentPreference } from '../../../hooks/contentPreference/useContentPreference'; + +jest.mock('next/router', () => ({ + useRouter: () => ({ push: jest.fn() }), +})); + +jest.mock('../../../hooks/source/useSourceActions', () => ({ + __esModule: true, + useSourceActions: jest.fn(), + default: jest.fn(), +})); + +jest.mock('../../../hooks/contentPreference/useContentPreference', () => ({ + useContentPreference: jest.fn(), +})); + +const useSourceActionsMock = useSourceActions as jest.Mock; +const useContentPreferenceMock = useContentPreference as jest.Mock; + +const source: Source = { + id: 'tds', + name: 'Towards Data Science', + handle: 'tds', + permalink: 'https://app.daily.dev/sources/tds', + image: 'https://media.daily.dev/tds', + type: SourceType.Machine, + public: true, +}; + +beforeEach(() => { + jest.clearAllMocks(); + useSourceActionsMock.mockReturnValue({ + isBlocked: false, + toggleBlock: jest.fn(), + isFollowing: false, + toggleFollow: jest.fn(), + haveNotificationsOn: false, + toggleNotify: jest.fn(), + }); + useContentPreferenceMock.mockReturnValue({ + follow: jest.fn(), + unfollow: jest.fn(), + }); +}); + +const getGrowthBook = (isEnabled: boolean): GrowthBook => { + const gb = new GrowthBook(); + gb.setFeatures({ + sharing_visibility: { defaultValue: isEnabled }, + share_tags_sources: { defaultValue: isEnabled }, + }); + + return gb; +}; + +const renderComponent = ( + { isEnabled = false, showShare = true } = {}, + isFollowing = false, +): RenderResult => { + useSourceActionsMock.mockReturnValue({ + isBlocked: false, + toggleBlock: jest.fn(), + isFollowing, + toggleFollow: jest.fn(), + haveNotificationsOn: false, + toggleNotify: jest.fn(), + }); + + return render( + + + , + ); +}; + +describe('SourceActions share control', () => { + it('surfaces share next to Follow when the flag is on', async () => { + renderComponent({ isEnabled: true }); + + expect(await screen.findByLabelText('Share')).toBeInTheDocument(); + expect(screen.getAllByText('Follow').length).toBeGreaterThan(0); + }); + + it('keeps the control next to Following once the source is followed', async () => { + renderComponent({ isEnabled: true }, true); + + expect(await screen.findByLabelText('Share')).toBeInTheDocument(); + expect(screen.getAllByText('Following').length).toBeGreaterThan(0); + }); + + it('drops the in-menu share entry when the visible control is shown', async () => { + renderComponent({ isEnabled: true }); + + // Radix's trigger opens on pointerdown/keydown, not click; keyboard also + // covers the a11y requirement for the menu. + fireEvent.keyDown(screen.getByLabelText('Options'), { key: 'Enter' }); + + expect(await screen.findByText('Add to custom feed')).toBeInTheDocument(); + expect(screen.queryByText('Share')).not.toBeInTheDocument(); + }); + + it('keeps share inside the options menu when the flag is off', async () => { + renderComponent({ isEnabled: false }); + + expect(screen.queryByLabelText('Share')).not.toBeInTheDocument(); + + // Radix's trigger opens on pointerdown/keydown, not click; keyboard also + // covers the a11y requirement for the menu. + fireEvent.keyDown(screen.getByLabelText('Options'), { key: 'Enter' }); + + expect(await screen.findByText('Share')).toBeInTheDocument(); + expect(screen.getByText('Add to custom feed')).toBeInTheDocument(); + }); + + it('renders identical markup to the untouched consumer when the flag is off', async () => { + // Radix mints an incrementing id per mount; it carries no behaviour. + const normalize = (html: string) => + html.replace(/radix-:r[^:]*:/g, 'radix'); + + const { container: optedIn, unmount } = renderComponent({ + isEnabled: false, + showShare: true, + }); + const optedInHtml = normalize(optedIn.innerHTML); + unmount(); + + const { container: untouched } = renderComponent({ + isEnabled: false, + showShare: false, + }); + + await waitFor(() => + expect(normalize(untouched.innerHTML)).toEqual(optedInHtml), + ); + }); +}); diff --git a/packages/shared/src/components/sources/SourceActions/index.tsx b/packages/shared/src/components/sources/SourceActions/index.tsx index 266ef8d4c31..0a31a756b42 100644 --- a/packages/shared/src/components/sources/SourceActions/index.tsx +++ b/packages/shared/src/components/sources/SourceActions/index.tsx @@ -8,9 +8,11 @@ import SourceActionsNotify from './SourceActionsNotify'; import SourceActionsBlock from './SourceActionsBlock'; import SourceActionsFollow from './SourceActionsFollow'; import CustomFeedOptionsMenu from '../../CustomFeedOptionsMenu'; -import { LogEvent } from '../../../lib/log'; +import { LogEvent, Origin } from '../../../lib/log'; import { useContentPreference } from '../../../hooks/contentPreference/useContentPreference'; import { ContentPreferenceType } from '../../../graphql/contentPreference'; +import { useShareTagsSources } from '../../../hooks/useShareTagsSources'; +import { EntityShareAction } from '../../share/EntityShareAction'; interface SourceActionsButton { className?: string; @@ -25,6 +27,12 @@ export interface SourceActionsProps { hideFollow?: boolean; notifyProps?: SourceActionsButton; hideNotify?: boolean; + /** + * Opt in to the visible share control (source page header). Off by default so + * embedded consumers, e.g. the post page highlights widget, keep their + * existing DOM. + */ + showShare?: boolean; } export const SourceActions = ({ @@ -35,6 +43,7 @@ export const SourceActions = ({ hideNotify = false, source, notifyProps, + showShare = false, }: SourceActionsProps): ReactElement => { const { isBlocked, @@ -48,6 +57,13 @@ export const SourceActions = ({ }); const { follow, unfollow } = useContentPreference(); const router = useRouter(); + const isShareVisible = useShareTagsSources(showShare); + + const shareProps = { + text: `Check out ${source.handle} on daily.dev`, + link: source.permalink, + cid: ReferralCampaignKey.ShareSource, + }; return (
@@ -74,7 +90,16 @@ export const SourceActions = ({ {...blockProps} /> )} + {isShareVisible && ( + + )} router.push( `/feeds/new?entityId=${source.id}&entityType=${ContentPreferenceType.Source}`, @@ -97,9 +122,7 @@ export const SourceActions = ({ }) } shareProps={{ - text: `Check out ${source.handle} on daily.dev`, - link: source.permalink, - cid: ReferralCampaignKey.ShareSource, + ...shareProps, logObject: () => ({ event_name: LogEvent.ShareSource, target_id: source.id, diff --git a/packages/shared/src/components/tags/TagTopicPage.tsx b/packages/shared/src/components/tags/TagTopicPage.tsx index 3bd8c235d6a..574a7b7ea7c 100644 --- a/packages/shared/src/components/tags/TagTopicPage.tsx +++ b/packages/shared/src/components/tags/TagTopicPage.tsx @@ -67,6 +67,8 @@ import { TypographyTag, TypographyType, } from '../typography/Typography'; +import { useShareTagsSources } from '../../hooks/useShareTagsSources'; +import { EntityShareAction } from '../share/EntityShareAction'; const SUPPORTED_TYPES = [ PostType.Article, @@ -256,6 +258,7 @@ export const TagTopicPage = ({ const { follow, unfollow } = useContentPreference({ showToastOnSuccess: false, }); + const isShareVisible = useShareTagsSources(); const title = initialData?.flags?.title || formatKeyword(tag); const followers = initialData?.followers; @@ -323,6 +326,14 @@ export const TagTopicPage = ({ }, }; + // Shared by the visible share control and the "…" menu entry so both always + // hand out the same link — `location.href` is the tag page itself. + const tagShareProps = { + text: `Check out the ${tag} tag on daily.dev`, + link: globalThis?.location?.href, + cid: ReferralCampaignKey.ShareTag, + }; + const statParts: ReactNode[] = []; if (typeof followers === 'number') { statParts.push( @@ -407,7 +418,16 @@ export const TagTopicPage = ({ {tagStatus === 'blocked' ? 'Unblock' : 'Block'} )} + {isShareVisible && ( + + )} push( `${webappUrl}feeds/new?entityId=${tag}&entityType=${ContentPreferenceType.Keyword}`, @@ -430,9 +450,7 @@ export const TagTopicPage = ({ }) } shareProps={{ - text: `Check out the ${tag} tag on daily.dev`, - link: globalThis?.location?.href, - cid: ReferralCampaignKey.ShareTag, + ...tagShareProps, logObject: () => ({ event_name: LogEvent.ShareTag, target_id: tag, diff --git a/packages/shared/src/hooks/useShareTagsSources.ts b/packages/shared/src/hooks/useShareTagsSources.ts new file mode 100644 index 00000000000..0e3ca8df10b --- /dev/null +++ b/packages/shared/src/hooks/useShareTagsSources.ts @@ -0,0 +1,17 @@ +import { featureShareTagsSources } from '../lib/featureManagement'; +import { useConditionalFeature } from './useConditionalFeature'; +import { useSharingVisibility } from './useSharingVisibility'; + +// Resolves whether the tag/source pages should surface share as a visible +// control next to Follow. Gated by the initiative-wide `sharing_visibility` +// kill-switch first, so the per-topic flag is only evaluated when the master +// switch is on (and only when the surface would actually render it). +export const useShareTagsSources = (shouldEvaluate = true): boolean => { + const { isEnabled: isSharingVisible } = useSharingVisibility(shouldEvaluate); + const { value } = useConditionalFeature({ + feature: featureShareTagsSources, + shouldEvaluate: shouldEvaluate && isSharingVisible, + }); + + return isSharingVisible && value; +}; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 97b38cbdd2b..6bf92dcf8de 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -308,3 +308,8 @@ export const featureSharingVisibility = new Feature( // high-traffic icon, so it ramps on its own flag to watch share/copy metrics. // Keep the default `false` (control = existing `LinkIcon`). export const featureShareCopyIcon = new Feature('share_copy_icon', false); + +// Promotes share out of the buried "…" options menu into a visible control in +// the tag and source page action rows, next to Follow/Following. Control keeps +// share inside the menu only. Keep the default `false` — GrowthBook ramps it. +export const featureShareTagsSources = new Feature('share_tags_sources', false); diff --git a/packages/storybook/stories/components/EntityShareAction.stories.tsx b/packages/storybook/stories/components/EntityShareAction.stories.tsx new file mode 100644 index 00000000000..1f9d69f3707 --- /dev/null +++ b/packages/storybook/stories/components/EntityShareAction.stories.tsx @@ -0,0 +1,267 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fn } from 'storybook/test'; +import { EntityShareAction } from '@dailydotdev/shared/src/components/share/EntityShareAction'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + BlockIcon, + MenuIcon, + MiniCloseIcon, + PlusIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext'; +import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext'; +import { + FeaturesReadyContext, + GrowthBookProvider, +} from '@dailydotdev/shared/src/components/GrowthBookProvider'; +import { BootApp } from '@dailydotdev/shared/src/lib/boot'; +import { useShareTagsSources } from '@dailydotdev/shared/src/hooks/useShareTagsSources'; +import { LogEvent, Origin } from '@dailydotdev/shared/src/lib/log'; +import { ReferralCampaignKey } from '@dailydotdev/shared/src/lib/referral'; +import { getShortLinkProps } from '@dailydotdev/shared/src/hooks/utils/useGetShortUrl'; +import type { LoggedUser } from '@dailydotdev/shared/src/lib/user'; + +const mockUser = { + id: '1', + name: 'Test User', + username: 'testuser', + email: 'test@example.com', + image: 'https://daily-now-res.cloudinary.com/image/upload/placeholder.jpg', + providers: ['google'], + createdAt: '2024-01-01T00:00:00.000Z', + permalink: 'https://daily.dev/testuser', +} as unknown as LoggedUser; + +const SHORT_LINK = 'https://dly.to/abc123'; + +type Entity = 'tag' | 'source'; + +const entityConfig: Record< + Entity, + { + link: string; + text: string; + cid: ReferralCampaignKey; + event: LogEvent; + targetId: string; + origin: Origin; + /** Matches the gap the real header action row uses on each surface. */ + rowClassName: string; + } +> = { + tag: { + link: 'https://daily.dev/tags/webdev', + text: 'Check out the webdev tag on daily.dev', + cid: ReferralCampaignKey.ShareTag, + event: LogEvent.ShareTag, + targetId: 'webdev', + origin: Origin.TagPage, + rowClassName: 'gap-3', + }, + source: { + link: 'https://app.daily.dev/sources/tds', + text: 'Check out tds on daily.dev', + cid: ReferralCampaignKey.ShareSource, + event: LogEvent.ShareSource, + targetId: 'tds', + origin: Origin.SourcePage, + rowClassName: 'gap-2', + }, +}; + +interface ActionRowProps { + entity: Entity; + isFollowing: boolean; +} + +// Mirrors how the real surfaces gate: the parent resolves the flag and either +// renders the promoted control (and drops the in-menu entry) or falls back to +// the "…"-menu-only control. +const ActionRow = ({ entity, isFollowing }: ActionRowProps) => { + const config = entityConfig[entity]; + const isShareVisible = useShareTagsSources(); + + return ( +
+ + {!isFollowing && ( + + )} + {isShareVisible && ( + + )} +
+ ); +}; + +// Storybook aliases `@growthbook/growthbook` to a mock whose `getFeatureValue` +// coerces every falsy default to the truthy string `'control'`, so a flag can't +// evaluate to `false` here. Flag-off is therefore simulated by holding the +// features context as "not ready", which is the exact path +// `useConditionalFeature` takes to fall back to the (false) default value. +const withProviders = + (enabled: boolean) => + (Story: React.ComponentType): React.ReactElement => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + + // Seed the resolved short URL for both campaigns so nothing hits network. + [ReferralCampaignKey.ShareTag, ReferralCampaignKey.ShareSource].forEach( + (cid) => { + Object.values(entityConfig).forEach((config) => { + const { queryKey } = getShortLinkProps(config.link, cid, mockUser); + queryClient.setQueryData(queryKey, SHORT_LINK); + }); + }, + ); + + const LogContext = getLogContextStatic(); + + return ( + + + + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + feature.defaultValue as any, + }} + > + false, + }} + > +
+ +
+
+
+
+
+
+ ); + }; + +const meta: Meta = { + title: 'Components/Share/EntityShareAction', + component: ActionRow, + args: { entity: 'tag', isFollowing: false }, + argTypes: { + entity: { control: 'inline-radio', options: ['tag', 'source'] }, + isFollowing: { control: 'boolean' }, + }, + parameters: { + docs: { + description: { + component: + 'Promotes share out of the tag/source "…" options menu into the header action row. A vertical rule and a ghost icon button keep it distinct from the filled Follow/Following button. Gated by `share_tags_sources` plus the `sharing_visibility` master flag.', + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +// Flag on — tag page row, not following yet (Follow + Block + share). +export const TagNotFollowing: Story = { + decorators: [withProviders(true)], + args: { entity: 'tag', isFollowing: false }, +}; + +// Flag on — tag page row for a followed tag (Block is replaced by nothing). +export const TagFollowing: Story = { + decorators: [withProviders(true)], + args: { entity: 'tag', isFollowing: true }, +}; + +// Flag on — source page row, tighter `gap-2` action row. +export const SourceNotFollowing: Story = { + decorators: [withProviders(true)], + args: { entity: 'source', isFollowing: false }, +}; + +export const SourceFollowing: Story = { + decorators: [withProviders(true)], + args: { entity: 'source', isFollowing: true }, +}; + +// Flag off — must render exactly what ships today: share stays in the "…" menu. +export const Control: Story = { + decorators: [withProviders(false)], + args: { entity: 'tag', isFollowing: false }, +}; + +// Mobile: a single tap opens the native share sheet instead of the popover. +export const TagMobile: Story = { + decorators: [withProviders(true)], + args: { entity: 'tag', isFollowing: true }, + globals: { viewport: { value: 'mobile1' } }, +}; diff --git a/packages/webapp/__tests__/TagPage.tsx b/packages/webapp/__tests__/TagPage.tsx index 1cd77c30559..ca444a922fa 100644 --- a/packages/webapp/__tests__/TagPage.tsx +++ b/packages/webapp/__tests__/TagPage.tsx @@ -259,6 +259,13 @@ it('should show follow and block buttons', async () => { expect(blockButton).toBeInTheDocument(); }); +it('should keep share out of the header while share_tags_sources is off', async () => { + renderComponent(); + await waitForNock(); + expect(await screen.findByLabelText('Follow')).toBeInTheDocument(); + expect(screen.queryByLabelText('Share')).not.toBeInTheDocument(); +}); + it('should show only unfollow button', async () => { renderComponent([ createFeedMock(), diff --git a/packages/webapp/__tests__/TagSourceSeo.spec.ts b/packages/webapp/__tests__/TagSourceSeo.spec.ts new file mode 100644 index 00000000000..5cc0d8e1b61 --- /dev/null +++ b/packages/webapp/__tests__/TagSourceSeo.spec.ts @@ -0,0 +1,115 @@ +import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; +import { KEYWORD_QUERY } from '@dailydotdev/shared/src/graphql/keywords'; +import { + SOURCE_QUERY, + SourceType, +} from '@dailydotdev/shared/src/graphql/sources'; +import { getStaticProps as getTagStaticProps } from '../pages/tags/[tag]'; +import { getStaticProps as getSourceStaticProps } from '../pages/sources/[source]'; + +jest.mock('@dailydotdev/shared/src/graphql/common', () => { + const actual = jest.requireActual('@dailydotdev/shared/src/graphql/common'); + + return { ...actual, gqlClient: { request: jest.fn() } }; +}); + +const request = gqlClient.request as jest.Mock; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +// Share links are only worth promoting if the unfurl names the entity, so lock +// the per-entity title/description that `next-seo` turns into og:title and +// og:description (it falls back to `description` when `openGraph.description` +// is unset). og:url/canonical come from `_app`'s DefaultSeo, per route. +describe('tag page SEO', () => { + it('describes the specific tag', async () => { + request.mockImplementation((query) => { + if (query === KEYWORD_QUERY) { + return Promise.resolve({ + keyword: { + value: 'webdev', + flags: { + title: 'Web Development', + description: 'Everything about building for the web.', + }, + }, + }); + } + + return Promise.reject(new Error('not mocked')); + }); + + const result = await getTagStaticProps({ params: { tag: 'webdev' } }); + + expect('props' in result && result.props.seo).toMatchObject({ + title: 'Web Development posts | daily.dev', + description: 'Everything about building for the web.', + openGraph: { title: 'Web Development posts | daily.dev' }, + }); + }); + + it('falls back to a generated description when the tag has none', async () => { + request.mockImplementation((query) => { + if (query === KEYWORD_QUERY) { + return Promise.resolve({ keyword: { value: 'webdev', flags: {} } }); + } + + return Promise.reject(new Error('not mocked')); + }); + + const result = await getTagStaticProps({ params: { tag: 'webdev' } }); + + expect('props' in result && result.props.seo?.description).toEqual( + 'Find all the recent posts, videos, updates and discussions about webdev', + ); + }); +}); + +describe('source page SEO', () => { + const source = { + id: 'tds', + name: 'Towards Data Science', + handle: 'tds', + permalink: 'https://app.daily.dev/sources/tds', + image: 'https://media.daily.dev/tds', + type: SourceType.Machine, + public: true, + description: 'Sharing concepts, ideas and codes.', + }; + + it('describes the specific source', async () => { + request.mockImplementation((query) => { + if (query === SOURCE_QUERY) { + return Promise.resolve({ source }); + } + + return Promise.reject(new Error('not mocked')); + }); + + const result = await getSourceStaticProps({ params: { source: 'tds' } }); + + expect('props' in result && result.props.seo).toMatchObject({ + title: 'Towards Data Science posts | daily.dev', + description: 'Sharing concepts, ideas and codes.', + openGraph: { title: 'Towards Data Science posts | daily.dev' }, + }); + }); + + it('keeps the generic description when the source has none', async () => { + request.mockImplementation((query) => { + if (query === SOURCE_QUERY) { + return Promise.resolve({ source: { ...source, description: null } }); + } + + return Promise.reject(new Error('not mocked')); + }); + + const result = await getSourceStaticProps({ params: { source: 'tds' } }); + + expect('props' in result && result.props.seo?.description).toContain( + 'daily.dev is the easiest way', + ); + }); +}); diff --git a/packages/webapp/pages/sources/[source].tsx b/packages/webapp/pages/sources/[source].tsx index 74a0f3958cb..bd5d323c051 100644 --- a/packages/webapp/pages/sources/[source].tsx +++ b/packages/webapp/pages/sources/[source].tsx @@ -291,7 +291,7 @@ const SourcePage = ({

{source.name}

- +
{source?.description && (

{source?.description}