feat: add page titles using React Helmet (#1321)

* feat: add page titles using React Helmet

* refactor: extract page title logic to separate component

* fix: resolve review comments

* fix: resolve testing errors
This commit is contained in:
Mustajab Ikram
2023-08-28 22:19:04 +05:30
committed by GitHub
parent 74919eff7a
commit 8bb4071f09
10 changed files with 242 additions and 137 deletions

View File

@ -19,6 +19,7 @@
"@types/node": "^16.18.4", "@types/node": "^16.18.4",
"@types/react": "^18.0.25", "@types/react": "^18.0.25",
"@types/react-dom": "^18.0.9", "@types/react-dom": "^18.0.9",
"@types/react-helmet-async": "^1.0.3",
"@types/react-modal": "^3.16.0", "@types/react-modal": "^3.16.0",
"afterframe": "^1.0.2", "afterframe": "^1.0.2",
"apollo-link-rest": "^0.9.0", "apollo-link-rest": "^0.9.0",
@ -41,6 +42,7 @@
"react-datepicker": "^4.11.0", "react-datepicker": "^4.11.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-dropzone": "^14.2.3", "react-dropzone": "^14.2.3",
"react-helmet-async": "^1.3.0",
"react-hook-form": "^7.45.1", "react-hook-form": "^7.45.1",
"react-hotkeys-hook": "^4.4.0", "react-hotkeys-hook": "^4.4.0",
"react-loading-skeleton": "^3.3.1", "react-loading-skeleton": "^3.3.1",

View File

@ -1,8 +1,9 @@
import { Navigate, Route, Routes } from 'react-router-dom'; import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
import { AppPath } from '@/types/AppPath'; import { AppPath } from '@/types/AppPath';
import { SettingsPath } from '@/types/SettingsPath'; import { SettingsPath } from '@/types/SettingsPath';
import { DefaultLayout } from '@/ui/layout/components/DefaultLayout'; import { DefaultLayout } from '@/ui/layout/components/DefaultLayout';
import { PageTitle } from '@/ui/utilities/page-title/PageTitle';
import { CreateProfile } from '~/pages/auth/CreateProfile'; import { CreateProfile } from '~/pages/auth/CreateProfile';
import { CreateWorkspace } from '~/pages/auth/CreateWorkspace'; import { CreateWorkspace } from '~/pages/auth/CreateWorkspace';
import { SignInUp } from '~/pages/auth/SignInUp'; import { SignInUp } from '~/pages/auth/SignInUp';
@ -21,13 +22,18 @@ import { Tasks } from '~/pages/tasks/Tasks';
import { AppInternalHooks } from '~/sync-hooks/AppInternalHooks'; import { AppInternalHooks } from '~/sync-hooks/AppInternalHooks';
import { NotFound } from './pages/not-found/NotFound'; import { NotFound } from './pages/not-found/NotFound';
import { getPageTitleFromPath } from './utils/title-utils';
// TEMP FEATURE FLAG FOR VIEW FIELDS // TEMP FEATURE FLAG FOR VIEW FIELDS
export const ACTIVATE_VIEW_FIELDS = true; export const ACTIVATE_VIEW_FIELDS = true;
export function App() { export function App() {
const { pathname } = useLocation();
const pageTitle = getPageTitleFromPath(pathname);
return ( return (
<> <>
<PageTitle title={pageTitle} />
<AppInternalHooks /> <AppInternalHooks />
<DefaultLayout> <DefaultLayout>
<Routes> <Routes>

View File

@ -1,3 +1,4 @@
import { HelmetProvider } from 'react-helmet-async';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { useRecoilState } from 'recoil'; import { useRecoilState } from 'recoil';
@ -24,7 +25,9 @@ const meta: Meta<typeof App> = {
<MemoryRouter> <MemoryRouter>
<FullHeightStorybookLayout> <FullHeightStorybookLayout>
<MockedAuth> <MockedAuth>
<Story /> <HelmetProvider>
<Story />
</HelmetProvider>
</MockedAuth> </MockedAuth>
</FullHeightStorybookLayout> </FullHeightStorybookLayout>
</MemoryRouter> </MemoryRouter>

View File

@ -1,5 +1,6 @@
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { HelmetProvider } from 'react-helmet-async';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import { RecoilRoot } from 'recoil'; import { RecoilRoot } from 'recoil';
@ -27,20 +28,22 @@ root.render(
<RecoilRoot> <RecoilRoot>
<BrowserRouter> <BrowserRouter>
<ApolloProvider> <ApolloProvider>
<ClientConfigProvider> <HelmetProvider>
<UserProvider> <ClientConfigProvider>
<PageChangeEffect /> <UserProvider>
<AppThemeProvider> <PageChangeEffect />
<SnackBarProvider> <AppThemeProvider>
<DialogProvider> <SnackBarProvider>
<StrictMode> <DialogProvider>
<App /> <StrictMode>
</StrictMode> <App />
</DialogProvider> </StrictMode>
</SnackBarProvider> </DialogProvider>
</AppThemeProvider> </SnackBarProvider>
</UserProvider> </AppThemeProvider>
</ClientConfigProvider> </UserProvider>
</ClientConfigProvider>
</HelmetProvider>
</ApolloProvider> </ApolloProvider>
</BrowserRouter> </BrowserRouter>
</RecoilRoot>, </RecoilRoot>,

View File

@ -0,0 +1,13 @@
import { Helmet } from 'react-helmet-async';
type OwnProps = {
title: string;
};
export function PageTitle({ title }: OwnProps) {
return (
<Helmet>
<title>{title}</title>
</Helmet>
);
}

View File

@ -17,6 +17,7 @@ import { ShowPageLeftContainer } from '@/ui/layout/show-page/components/ShowPage
import { ShowPageRightContainer } from '@/ui/layout/show-page/components/ShowPageRightContainer'; import { ShowPageRightContainer } from '@/ui/layout/show-page/components/ShowPageRightContainer';
import { ShowPageSummaryCard } from '@/ui/layout/show-page/components/ShowPageSummaryCard'; import { ShowPageSummaryCard } from '@/ui/layout/show-page/components/ShowPageSummaryCard';
import { ShowPageRecoilScopeContext } from '@/ui/layout/states/ShowPageRecoilScopeContext'; import { ShowPageRecoilScopeContext } from '@/ui/layout/states/ShowPageRecoilScopeContext';
import { PageTitle } from '@/ui/utilities/page-title/PageTitle';
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope'; import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
import { useUpdateOneCompanyMutation } from '~/generated/graphql'; import { useUpdateOneCompanyMutation } from '~/generated/graphql';
import { getLogoUrlFromDomainName } from '~/utils'; import { getLogoUrlFromDomainName } from '~/utils';
@ -45,66 +46,71 @@ export function CompanyShow() {
} }
return ( return (
<WithTopBarContainer <>
title={company.name ?? ''} <PageTitle title={company.name || 'No Name'} />
hasBackButton <WithTopBarContainer
isFavorite={isFavorite} title={company.name ?? ''}
icon={<IconBuildingSkyscraper size={theme.icon.size.md} />} hasBackButton
onFavoriteButtonClick={handleFavoriteButtonClick} isFavorite={isFavorite}
extraButtons={[ icon={<IconBuildingSkyscraper size={theme.icon.size.md} />}
<ShowPageAddButton onFavoriteButtonClick={handleFavoriteButtonClick}
key="add" extraButtons={[
entity={{ <ShowPageAddButton
id: company.id, key="add"
type: ActivityTargetableEntityType.Company,
}}
/>,
]}
>
<RecoilScope SpecificContext={ShowPageRecoilScopeContext}>
<ShowPageContainer>
<ShowPageLeftContainer>
<ShowPageSummaryCard
id={company.id}
logoOrAvatar={getLogoUrlFromDomainName(company.domainName ?? '')}
title={company.name ?? 'No name'}
date={company.createdAt ?? ''}
renderTitleEditComponent={() => (
<CompanyNameEditableField company={company} />
)}
/>
<PropertyBox extraPadding={true}>
<EditableFieldMutationContext.Provider
value={useUpdateOneCompanyMutation}
>
<EditableFieldEntityIdContext.Provider value={company.id}>
{companyShowFieldDefinition.map((fieldDefinition) => {
return (
<EditableFieldDefinitionContext.Provider
value={fieldDefinition}
key={fieldDefinition.id}
>
<GenericEditableField />
</EditableFieldDefinitionContext.Provider>
);
})}
</EditableFieldEntityIdContext.Provider>
</EditableFieldMutationContext.Provider>
</PropertyBox>
<CompanyTeam company={company}></CompanyTeam>
</ShowPageLeftContainer>
<ShowPageRightContainer
entity={{ entity={{
id: company.id, id: company.id,
type: ActivityTargetableEntityType.Company, type: ActivityTargetableEntityType.Company,
}} }}
timeline />,
tasks ]}
notes >
emails <RecoilScope SpecificContext={ShowPageRecoilScopeContext}>
/> <ShowPageContainer>
</ShowPageContainer> <ShowPageLeftContainer>
</RecoilScope> <ShowPageSummaryCard
</WithTopBarContainer> id={company.id}
logoOrAvatar={getLogoUrlFromDomainName(
company.domainName ?? '',
)}
title={company.name ?? 'No name'}
date={company.createdAt ?? ''}
renderTitleEditComponent={() => (
<CompanyNameEditableField company={company} />
)}
/>
<PropertyBox extraPadding={true}>
<EditableFieldMutationContext.Provider
value={useUpdateOneCompanyMutation}
>
<EditableFieldEntityIdContext.Provider value={company.id}>
{companyShowFieldDefinition.map((fieldDefinition) => {
return (
<EditableFieldDefinitionContext.Provider
value={fieldDefinition}
key={fieldDefinition.id}
>
<GenericEditableField />
</EditableFieldDefinitionContext.Provider>
);
})}
</EditableFieldEntityIdContext.Provider>
</EditableFieldMutationContext.Provider>
</PropertyBox>
<CompanyTeam company={company}></CompanyTeam>
</ShowPageLeftContainer>
<ShowPageRightContainer
entity={{
id: company.id,
type: ActivityTargetableEntityType.Company,
}}
timeline
tasks
notes
emails
/>
</ShowPageContainer>
</RecoilScope>
</WithTopBarContainer>
</>
); );
} }

View File

@ -18,6 +18,7 @@ import { ShowPageLeftContainer } from '@/ui/layout/show-page/components/ShowPage
import { ShowPageRightContainer } from '@/ui/layout/show-page/components/ShowPageRightContainer'; import { ShowPageRightContainer } from '@/ui/layout/show-page/components/ShowPageRightContainer';
import { ShowPageSummaryCard } from '@/ui/layout/show-page/components/ShowPageSummaryCard'; import { ShowPageSummaryCard } from '@/ui/layout/show-page/components/ShowPageSummaryCard';
import { ShowPageRecoilScopeContext } from '@/ui/layout/states/ShowPageRecoilScopeContext'; import { ShowPageRecoilScopeContext } from '@/ui/layout/states/ShowPageRecoilScopeContext';
import { PageTitle } from '@/ui/utilities/page-title/PageTitle';
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope'; import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
import { import {
useUpdateOnePersonMutation, useUpdateOnePersonMutation,
@ -63,66 +64,73 @@ export function PersonShow() {
} }
return ( return (
<WithTopBarContainer <>
title={person.firstName ?? ''} <PageTitle title={person.displayName || 'No Name'} />
icon={<IconUser size={theme.icon.size.md} />} <WithTopBarContainer
hasBackButton title={person.firstName ?? ''}
isFavorite={isFavorite} icon={<IconUser size={theme.icon.size.md} />}
onFavoriteButtonClick={handleFavoriteButtonClick} hasBackButton
extraButtons={[ isFavorite={isFavorite}
<ShowPageAddButton onFavoriteButtonClick={handleFavoriteButtonClick}
key="add" extraButtons={[
entity={{ <ShowPageAddButton
id: person.id, key="add"
type: ActivityTargetableEntityType.Person,
}}
/>,
]}
>
<RecoilScope SpecificContext={ShowPageRecoilScopeContext}>
<ShowPageContainer>
<ShowPageLeftContainer>
<ShowPageSummaryCard
id={person.id}
title={person.displayName ?? 'No name'}
logoOrAvatar={person.avatarUrl ?? undefined}
date={person.createdAt ?? ''}
renderTitleEditComponent={() =>
person ? <PeopleFullNameEditableField people={person} /> : <></>
}
onUploadPicture={onUploadPicture}
/>
<PropertyBox extraPadding={true}>
<EditableFieldMutationContext.Provider
value={useUpdateOnePersonMutation}
>
<EditableFieldEntityIdContext.Provider value={person.id}>
{personShowFieldDefinition.map((fieldDefinition) => {
return (
<EditableFieldDefinitionContext.Provider
value={fieldDefinition}
key={fieldDefinition.id}
>
<GenericEditableField />
</EditableFieldDefinitionContext.Provider>
);
})}
</EditableFieldEntityIdContext.Provider>
</EditableFieldMutationContext.Provider>
</PropertyBox>
</ShowPageLeftContainer>
<ShowPageRightContainer
entity={{ entity={{
id: person.id ?? '', id: person.id,
type: ActivityTargetableEntityType.Person, type: ActivityTargetableEntityType.Person,
}} }}
timeline />,
tasks ]}
notes >
emails <RecoilScope SpecificContext={ShowPageRecoilScopeContext}>
/> <ShowPageContainer>
</ShowPageContainer> <ShowPageLeftContainer>
</RecoilScope> <ShowPageSummaryCard
</WithTopBarContainer> id={person.id}
title={person.displayName ?? 'No name'}
logoOrAvatar={person.avatarUrl ?? undefined}
date={person.createdAt ?? ''}
renderTitleEditComponent={() =>
person ? (
<PeopleFullNameEditableField people={person} />
) : (
<></>
)
}
onUploadPicture={onUploadPicture}
/>
<PropertyBox extraPadding={true}>
<EditableFieldMutationContext.Provider
value={useUpdateOnePersonMutation}
>
<EditableFieldEntityIdContext.Provider value={person.id}>
{personShowFieldDefinition.map((fieldDefinition) => {
return (
<EditableFieldDefinitionContext.Provider
value={fieldDefinition}
key={fieldDefinition.id}
>
<GenericEditableField />
</EditableFieldDefinitionContext.Provider>
);
})}
</EditableFieldEntityIdContext.Provider>
</EditableFieldMutationContext.Provider>
</PropertyBox>
</ShowPageLeftContainer>
<ShowPageRightContainer
entity={{
id: person.id ?? '',
type: ActivityTargetableEntityType.Person,
}}
timeline
tasks
notes
emails
/>
</ShowPageContainer>
</RecoilScope>
</WithTopBarContainer>
</>
); );
} }

View File

@ -1,3 +1,4 @@
import { HelmetProvider } from 'react-helmet-async';
import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { Decorator } from '@storybook/react'; import { Decorator } from '@storybook/react';
@ -29,11 +30,13 @@ export const PageDecorator: Decorator<{
initialEntries={[computeLocation(args.routePath, args.routeParams)]} initialEntries={[computeLocation(args.routePath, args.routeParams)]}
> >
<FullHeightStorybookLayout> <FullHeightStorybookLayout>
<DefaultLayout> <HelmetProvider>
<Routes> <DefaultLayout>
<Route path={args.routePath} element={<Story />} /> <Routes>
</Routes> <Route path={args.routePath} element={<Story />} />
</DefaultLayout> </Routes>
</DefaultLayout>
</HelmetProvider>
</FullHeightStorybookLayout> </FullHeightStorybookLayout>
</MemoryRouter> </MemoryRouter>
</ClientConfigProvider> </ClientConfigProvider>

View File

@ -0,0 +1,38 @@
import { AppBasePath } from '@/types/AppBasePath';
import { AppPath } from '@/types/AppPath';
import { SettingsPath } from '@/types/SettingsPath';
export function getPageTitleFromPath(pathname: string): string {
switch (pathname) {
case AppPath.Verify:
return 'Verify';
case AppPath.SignIn:
return 'Sign In';
case AppPath.SignUp:
return 'Sign Up';
case AppPath.Invite:
return 'Invite';
case AppPath.CreateWorkspace:
return 'Create Workspace';
case AppPath.CreateProfile:
return 'Create Profile';
case AppPath.PeoplePage:
return 'People';
case AppPath.CompaniesPage:
return 'Companies';
case AppPath.TasksPage:
return 'Tasks';
case AppPath.OpportunitiesPage:
return 'Opportunities';
case `${AppBasePath.Settings}/${SettingsPath.ProfilePage}`:
return 'Profile';
case `${AppBasePath.Settings}/${SettingsPath.Experience}`:
return 'Experience';
case `${AppBasePath.Settings}/${SettingsPath.WorkspaceMembersPage}`:
return 'Workspace Members';
case `${AppBasePath.Settings}/${SettingsPath.Workspace}`:
return 'Workspace';
default:
return 'Twenty';
}
}

View File

@ -5603,6 +5603,13 @@
dependencies: dependencies:
"@types/react" "*" "@types/react" "*"
"@types/react-helmet-async@^1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@types/react-helmet-async/-/react-helmet-async-1.0.3.tgz#89d581d6cb129e5357d39d7d1b41313b20523989"
integrity sha512-DqbSuZPSHiH1l3XI/y8LbhrAGNh+Bpc9QY4MsYRM1yD4+qhax8bN4DInUMpv/tNyIdjsa+1V8XXmbRx8W5dB0w==
dependencies:
react-helmet-async "*"
"@types/react-modal@^3.16.0": "@types/react-modal@^3.16.0":
version "3.16.0" version "3.16.0"
resolved "https://registry.yarnpkg.com/@types/react-modal/-/react-modal-3.16.0.tgz#b8d6be10de894139a2ea9f4a2505b1b5d02023df" resolved "https://registry.yarnpkg.com/@types/react-modal/-/react-modal-3.16.0.tgz#b8d6be10de894139a2ea9f4a2505b1b5d02023df"
@ -16074,11 +16081,22 @@ react-fast-compare@3.2.1:
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.1.tgz#53933d9e14f364281d6cba24bfed7a4afb808b5f" resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.1.tgz#53933d9e14f364281d6cba24bfed7a4afb808b5f"
integrity sha512-xTYf9zFim2pEif/Fw16dBiXpe0hoy5PxcD8+OwBnTtNLfIm3g6WxhKNurY+6OmdH1u6Ta/W/Vl6vjbYP1MFnDg== integrity sha512-xTYf9zFim2pEif/Fw16dBiXpe0hoy5PxcD8+OwBnTtNLfIm3g6WxhKNurY+6OmdH1u6Ta/W/Vl6vjbYP1MFnDg==
react-fast-compare@^3.0.1: react-fast-compare@^3.0.1, react-fast-compare@^3.2.0:
version "3.2.2" version "3.2.2"
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49" resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49"
integrity sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ== integrity sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==
react-helmet-async@*, react-helmet-async@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/react-helmet-async/-/react-helmet-async-1.3.0.tgz#7bd5bf8c5c69ea9f02f6083f14ce33ef545c222e"
integrity sha512-9jZ57/dAn9t3q6hneQS0wukqC2ENOBgMNVEhb/ZG9ZSxUetzVIw4iAmEU38IaVg3QGYauQPhSeUTuIUtFglWpg==
dependencies:
"@babel/runtime" "^7.12.5"
invariant "^2.2.4"
prop-types "^15.7.2"
react-fast-compare "^3.2.0"
shallowequal "^1.1.0"
react-hook-form@^7.45.1: react-hook-form@^7.45.1:
version "7.45.4" version "7.45.4"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.45.4.tgz#73d228b704026ae95d7e5f7b207a681b173ec62a" resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.45.4.tgz#73d228b704026ae95d7e5f7b207a681b173ec62a"
@ -17160,6 +17178,11 @@ shallow-equal@^1.2.1:
resolved "https://registry.yarnpkg.com/shallow-equal/-/shallow-equal-1.2.1.tgz#4c16abfa56043aa20d050324efa68940b0da79da" resolved "https://registry.yarnpkg.com/shallow-equal/-/shallow-equal-1.2.1.tgz#4c16abfa56043aa20d050324efa68940b0da79da"
integrity sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA== integrity sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==
shallowequal@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8"
integrity sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==
shebang-command@^1.2.0: shebang-command@^1.2.0:
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea"