From 91d4898df080d57042ece1705cfaa6ff7f2b4c54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BAnar=20Vestmann?= <43557895+RunarVestmann@users.noreply.github.com> Date: Wed, 5 Jun 2024 15:12:04 +0000 Subject: [PATCH] feat(web): Clickable generic list items (#15003) * Add model fields * Validate contentful field * Add generic list item detail page * Remove json stringify call * Add org subpage layout * Handle language toggle for org subpages * Add project subpage layout to generic list item page * Skip article layouts for now * Add og:title * Remove comment * Add type imports * Stop considering only within lists as needing to have a unique slug * Render custom content for project subpages * Use template literals * Remove console.log --------- Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- apps/contentful-apps/constants/index.ts | 5 + .../pages/fields/event-slug-field.tsx | 9 +- .../fields/generic-list-item-slug-field.tsx | 166 +++++++++++ apps/contentful-apps/utils/index.ts | 10 + .../components/GenericList/GenericList.tsx | 100 +++++-- .../LanguageToggler/LanguageToggler.tsx | 2 +- .../Organization/Slice/SliceMachine.tsx | 1 + .../Wrapper/OrganizationWrapper.tsx | 51 +++- .../hooks/useLinkResolver/useLinkResolver.ts | 8 + .../[subSlug]/[genericListItemSlug]/index.ts | 12 + .../[subSlug]/[genericListItemSlug]/index.ts | 12 + .../[subSlug]/[genericListItemSlug]/index.tsx | 115 ++++++++ .../[subSlug]/[genericListItemSlug]/index.tsx | 121 ++++++++ .../screens/GenericList/GenericListItem.tsx | 86 ++++++ apps/web/screens/Organization/SubPage.tsx | 153 +++++----- apps/web/screens/Project/Project.tsx | 262 ++++++++++-------- .../ProjectWrapper/ProjectWrapper.tsx | 1 + apps/web/screens/queries/GenericList.ts | 19 +- apps/web/screens/queries/fragments.ts | 2 + apps/web/utils/richText.tsx | 1 + libs/cms/src/lib/cms.elasticsearch.service.ts | 11 + libs/cms/src/lib/cms.resolver.ts | 10 + .../lib/dto/getGenericListItemBySlug.input.ts | 14 + .../src/lib/generated/contentfulTypes.d.ts | 9 + libs/cms/src/lib/models/genericList.model.ts | 22 +- .../src/lib/models/genericListItem.model.ts | 34 ++- .../importers/genericListItem.service.ts | 21 +- 27 files changed, 1000 insertions(+), 257 deletions(-) create mode 100644 apps/contentful-apps/pages/fields/generic-list-item-slug-field.tsx create mode 100644 apps/web/pages/en/o/[slug]/[subSlug]/[genericListItemSlug]/index.ts create mode 100644 apps/web/pages/en/p/[slug]/[subSlug]/[genericListItemSlug]/index.ts create mode 100644 apps/web/pages/s/[slug]/[subSlug]/[genericListItemSlug]/index.tsx create mode 100644 apps/web/pages/v/[slug]/[subSlug]/[genericListItemSlug]/index.tsx create mode 100644 apps/web/screens/GenericList/GenericListItem.tsx create mode 100644 libs/cms/src/lib/dto/getGenericListItemBySlug.input.ts diff --git a/apps/contentful-apps/constants/index.ts b/apps/contentful-apps/constants/index.ts index 61f10e9bc30c9..b7f7d094ff8d1 100644 --- a/apps/contentful-apps/constants/index.ts +++ b/apps/contentful-apps/constants/index.ts @@ -5,3 +5,8 @@ export const DEV_WEB_BASE_URL = 'https://beta.dev01.devland.is' export const TITLE_SEARCH_POSTFIX = '--title-search' export const SLUGIFIED_POSTFIX = '--slugified' + +export const CUSTOM_SLUGIFY_REPLACEMENTS: ReadonlyArray<[string, string]> = [ + ['ö', 'o'], + ['þ', 'th'], +] diff --git a/apps/contentful-apps/pages/fields/event-slug-field.tsx b/apps/contentful-apps/pages/fields/event-slug-field.tsx index 44aa381b3e90f..d949b73d6ffd0 100644 --- a/apps/contentful-apps/pages/fields/event-slug-field.tsx +++ b/apps/contentful-apps/pages/fields/event-slug-field.tsx @@ -5,14 +5,7 @@ import { TextInput } from '@contentful/f36-components' import { useSDK } from '@contentful/react-apps-toolkit' import slugify from '@sindresorhus/slugify' -const slugifyDate = (value: string) => { - try { - const date = new Date(value) - return `${date.getDate()}-${date.getMonth() + 1}-${date.getFullYear()}` - } catch { - return '' - } -} +import { slugifyDate } from '../../utils' const EventSlugField = () => { const sdk = useSDK() diff --git a/apps/contentful-apps/pages/fields/generic-list-item-slug-field.tsx b/apps/contentful-apps/pages/fields/generic-list-item-slug-field.tsx new file mode 100644 index 0000000000000..c93144b3a6591 --- /dev/null +++ b/apps/contentful-apps/pages/fields/generic-list-item-slug-field.tsx @@ -0,0 +1,166 @@ +import { useEffect, useState } from 'react' +import { useDebounce } from 'react-use' +import type { FieldExtensionSDK } from '@contentful/app-sdk' +import { Stack, Text, TextInput } from '@contentful/f36-components' +import { useCMA, useSDK } from '@contentful/react-apps-toolkit' +import slugify from '@sindresorhus/slugify' + +import { CUSTOM_SLUGIFY_REPLACEMENTS } from '../../constants' +import { slugifyDate } from '../../utils' + +const DEBOUNCE_TIME = 100 + +const GenericListItemSlugField = () => { + const sdk = useSDK() + const cma = useCMA() + + const [genericList, setGenericList] = useState(null) + const [hasEntryBeenPublished, setHasEntryBeenPublished] = useState( + Boolean(sdk.entry.getSys()?.firstPublishedAt), + ) + + const [value, setValue] = useState(sdk.field.getValue() ?? '') + const [isValid, setIsValid] = useState(true) + + useEffect(() => { + sdk.entry.onSysChanged((newSys) => { + setHasEntryBeenPublished(Boolean(newSys?.firstPublishedAt)) + }) + }, [sdk.entry]) + + useEffect(() => { + const unsubscribe = sdk.entry.fields.genericList.onValueChanged( + (updatedList) => { + if (updatedList?.sys?.id) { + cma.entry + .get({ + entryId: updatedList.sys.id, + }) + .then(setGenericList) + } else { + setGenericList(null) + } + }, + ) + + return unsubscribe + }, [cma.entry, sdk.entry.fields.genericList]) + + useEffect(() => { + sdk.window.startAutoResizer() + }, [sdk.window]) + + useEffect(() => { + const unsubscribeFromTitleValueChanges = sdk.entry.fields.title + .getForLocale(sdk.field.locale) + .onValueChanged((newTitle) => { + if (!newTitle || hasEntryBeenPublished) { + return + } + const date = sdk.entry.fields.date.getValue() + setValue( + `${slugify(newTitle, { + customReplacements: CUSTOM_SLUGIFY_REPLACEMENTS, + })}${date ? '-' + slugifyDate(date) : ''}`, + ) + }) + + const unsubscribeFromDateValueChanges = + sdk.entry.fields.date.onValueChanged((newDate) => { + const date = newDate + const title = sdk.entry.fields.title + .getForLocale(sdk.field.locale) + .getValue() + if (!title || hasEntryBeenPublished) { + return + } + setValue( + `${slugify(title, { + customReplacements: CUSTOM_SLUGIFY_REPLACEMENTS, + })}${date ? `-${slugifyDate(date)}` : ''}`, + ) + }) + + return () => { + unsubscribeFromTitleValueChanges() + unsubscribeFromDateValueChanges() + } + }, [ + hasEntryBeenPublished, + sdk.entry.fields.date, + sdk.entry.fields.title, + sdk.field.locale, + ]) + + useDebounce( + async () => { + const genericListId = sdk.entry.fields.genericList.getValue()?.sys?.id + if (!genericListId || !value) { + return + } + const itemsInSameListWithSameSlug = + ( + await cma.entry.getMany({ + environmentId: sdk.ids.environment, + spaceId: sdk.ids.space, + query: { + locale: sdk.field.locale, + content_type: 'genericListItem', + 'fields.slug': value, + 'sys.id[ne]': sdk.entry.getSys().id, + 'sys.archivedVersion[exists]': false, + }, + }) + )?.items ?? [] + setIsValid(itemsInSameListWithSameSlug.length <= 0) + }, + DEBOUNCE_TIME, + [value], + ) + + useDebounce( + () => { + if (isValid) { + sdk.field.setValue(value) + } else { + sdk.field.setValue(null) // Set to null to prevent entry publish + } + sdk.field.setInvalid(!isValid) + }, + DEBOUNCE_TIME, + [isValid, value], + ) + + if ( + genericList && + genericList?.fields?.itemType?.[sdk.locales.default] !== 'Clickable' + ) { + return ( + + Slug can only be changed if the list item type is {`"Clickable"`} + + ) + } + + const isInvalid = value.length === 0 || !isValid + + return ( + + { + setValue(ev.target.value) + }} + isInvalid={isInvalid} + /> + {value.length === 0 && sdk.field.locale === sdk.locales.default && ( + Invalid slug + )} + {value.length > 0 && isInvalid && ( + Item already exists with this slug + )} + + ) +} + +export default GenericListItemSlugField diff --git a/apps/contentful-apps/utils/index.ts b/apps/contentful-apps/utils/index.ts index 93d523d9741ed..6a73c2d1841eb 100644 --- a/apps/contentful-apps/utils/index.ts +++ b/apps/contentful-apps/utils/index.ts @@ -60,3 +60,13 @@ export const parseContentfulErrorMessage = (error: unknown) => { } return errorMessage } + +export const slugifyDate = (value: string) => { + if (!value) return '' + try { + const date = new Date(value) + return `${date.getDate()}-${date.getMonth() + 1}-${date.getFullYear()}` + } catch { + return '' + } +} diff --git a/apps/web/components/GenericList/GenericList.tsx b/apps/web/components/GenericList/GenericList.tsx index 835f62905a469..46c72da665d63 100644 --- a/apps/web/components/GenericList/GenericList.tsx +++ b/apps/web/components/GenericList/GenericList.tsx @@ -1,18 +1,21 @@ import { useRef, useState } from 'react' import { useDebounce } from 'react-use' import { Locale } from 'locale' +import { useRouter } from 'next/router' import { useLazyQuery } from '@apollo/client' import { AlertMessage, Box, FilterInput, + FocusableBox, GridContainer, Pagination, Stack, Text, } from '@island.is/island-ui/core' import { + GenericListItem, GenericListItemResponse, GetGenericListItemsQueryVariables, Query, @@ -44,16 +47,80 @@ const getResultsFoundText = (totalItems: number, locale: Locale) => { return plural } +interface ItemProps { + item: GenericListItem +} + +const NonClickableItem = ({ item }: ItemProps) => { + const { format } = useDateUtils() + + return ( + + + + + {item.date && format(new Date(item.date), 'dd.MM.yyyy')} + + + {item.title} + + + {item.cardIntro?.length > 0 && ( + {webRichText(item.cardIntro ?? [])} + )} + + + ) +} + +const ClickableItem = ({ item }: ItemProps) => { + const { format } = useDateUtils() + const router = useRouter() + + const pathname = new URL(router.asPath, 'https://island.is').pathname + + return ( + + + + + {item.date && format(new Date(item.date), 'dd.MM.yyyy')} + + + {item.title} + + + {item.cardIntro?.length > 0 && ( + {webRichText(item.cardIntro ?? [])} + )} + + + ) +} + interface GenericListProps { id: string firstPageItemResponse?: GenericListItemResponse searchInputPlaceholder?: string | null + itemType?: string | null } export const GenericList = ({ id, firstPageItemResponse, searchInputPlaceholder, + itemType, }: GenericListProps) => { const [searchValue, setSearchValue] = useState('') const [page, setPage] = useState(1) @@ -61,7 +128,6 @@ export const GenericList = ({ const searchValueRef = useRef(searchValue) const [itemsResponse, setItemsResponse] = useState(firstPageItemResponse) const firstRender = useRef(true) - const { format } = useDateUtils() const [errorOccurred, setErrorOccurred] = useState(false) const { activeLocale } = useI18n() @@ -119,6 +185,8 @@ export const GenericList = ({ const resultsFoundText = getResultsFoundText(totalItems, activeLocale) + const itemsAreClickable = itemType === 'Clickable' + return ( @@ -153,28 +221,14 @@ export const GenericList = ({ {totalItems} {resultsFoundText} - {items.map((item) => ( - - - - - {item.date && format(new Date(item.date), 'dd.MM.yyyy')} - - - {item.title} - - - {item.cardIntro?.length > 0 && ( - {webRichText(item.cardIntro ?? [])} - )} - - - ))} + {!itemsAreClickable && + items.map((item) => ( + + ))} + {itemsAreClickable && + items.map((item) => ( + + ))} )} diff --git a/apps/web/components/LanguageToggler/LanguageToggler.tsx b/apps/web/components/LanguageToggler/LanguageToggler.tsx index 92387da822c91..18acbb753c274 100644 --- a/apps/web/components/LanguageToggler/LanguageToggler.tsx +++ b/apps/web/components/LanguageToggler/LanguageToggler.tsx @@ -110,7 +110,7 @@ export const LanguageToggler = ({ activeTranslations = res.data?.getContentSlug?.activeTranslations } - if (resolveLinkTypeLocally) { + if ((type as string) === 'genericListItem' || resolveLinkTypeLocally) { const localType = typeResolver(pathWithoutQueryParams)?.type if (localType) { type = localType diff --git a/apps/web/components/Organization/Slice/SliceMachine.tsx b/apps/web/components/Organization/Slice/SliceMachine.tsx index ddd2f254b5c18..1ff93403ffa8d 100644 --- a/apps/web/components/Organization/Slice/SliceMachine.tsx +++ b/apps/web/components/Organization/Slice/SliceMachine.tsx @@ -191,6 +191,7 @@ const renderSlice = ( searchInputPlaceholder={ (slice as GenericListSchema).searchInputPlaceholder } + itemType={(slice as GenericListSchema).itemType} /> ) default: diff --git a/apps/web/components/Organization/Wrapper/OrganizationWrapper.tsx b/apps/web/components/Organization/Wrapper/OrganizationWrapper.tsx index fe35329d43a5f..f3ddc9978dbb0 100644 --- a/apps/web/components/Organization/Wrapper/OrganizationWrapper.tsx +++ b/apps/web/components/Organization/Wrapper/OrganizationWrapper.tsx @@ -14,6 +14,7 @@ import { GridRow, Inline, Link, + LinkV2, Navigation, NavigationItem, ProfileCard, @@ -123,6 +124,7 @@ interface WrapperProps { showExternalLinks?: boolean showReadSpeaker?: boolean isSubpage?: boolean + backLink?: { text: string; url: string } } interface HeaderProps { @@ -872,6 +874,7 @@ export const OrganizationWrapper: React.FC< showExternalLinks = false, showReadSpeaker = true, isSubpage = true, + backLink, }) => { const router = useRouter() const { width } = useWindowSize() @@ -922,21 +925,39 @@ export const OrganizationWrapper: React.FC< fullWidthContent={fullWidthContent} sidebarContent={ - { - return item?.href ? ( - - {link} - - ) : ( - link - ) - }} - /> + + {backLink && ( + + + + + + )} + { + return item?.href ? ( + + {link} + + ) : ( + link + ) + }} + /> + {showSecondaryMenu && ( <> {organizationPage.secondaryMenu && diff --git a/apps/web/hooks/useLinkResolver/useLinkResolver.ts b/apps/web/hooks/useLinkResolver/useLinkResolver.ts index 216dcabce6a5d..338bbb0a8bb46 100644 --- a/apps/web/hooks/useLinkResolver/useLinkResolver.ts +++ b/apps/web/hooks/useLinkResolver/useLinkResolver.ts @@ -39,6 +39,14 @@ export const routesTemplate = { is: '/s/[organization]/vidburdir', en: '/en/o/[organization]/events', }, + organizationsubpagelistitem: { + is: '/s/[organization]/[slug]/[listItemSlug]', + en: '/en/o/[organization]/[slug]/[listItemSlug]', + }, + projectsubpagelistitem: { + is: '/v/[project]/[slug]/[listItemSlug]', + en: '/en/p/[project]/[slug]/[listItemSlug]', + }, aboutsubpage: { is: '/s/stafraent-island/[slug]', en: '', diff --git a/apps/web/pages/en/o/[slug]/[subSlug]/[genericListItemSlug]/index.ts b/apps/web/pages/en/o/[slug]/[subSlug]/[genericListItemSlug]/index.ts new file mode 100644 index 0000000000000..64f34948d7ef4 --- /dev/null +++ b/apps/web/pages/en/o/[slug]/[subSlug]/[genericListItemSlug]/index.ts @@ -0,0 +1,12 @@ +import withApollo from '@island.is/web/graphql/withApollo' +import { withLocale } from '@island.is/web/i18n' +import { Component } from '@island.is/web/pages/s/[slug]/[subSlug]/[genericListItemSlug]' +import { getServerSidePropsWrapper } from '@island.is/web/utils/getServerSidePropsWrapper' + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore make web strict +const Screen = withApollo(withLocale('en')(Component)) + +export default Screen + +export const getServerSideProps = getServerSidePropsWrapper(Screen) diff --git a/apps/web/pages/en/p/[slug]/[subSlug]/[genericListItemSlug]/index.ts b/apps/web/pages/en/p/[slug]/[subSlug]/[genericListItemSlug]/index.ts new file mode 100644 index 0000000000000..a0af49de48f03 --- /dev/null +++ b/apps/web/pages/en/p/[slug]/[subSlug]/[genericListItemSlug]/index.ts @@ -0,0 +1,12 @@ +import withApollo from '@island.is/web/graphql/withApollo' +import { withLocale } from '@island.is/web/i18n' +import { Component } from '@island.is/web/pages/v/[slug]/[subSlug]/[genericListItemSlug]' +import { getServerSidePropsWrapper } from '@island.is/web/utils/getServerSidePropsWrapper' + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore make web strict +const Screen = withApollo(withLocale('en')(Component)) + +export default Screen + +export const getServerSideProps = getServerSidePropsWrapper(Screen) diff --git a/apps/web/pages/s/[slug]/[subSlug]/[genericListItemSlug]/index.tsx b/apps/web/pages/s/[slug]/[subSlug]/[genericListItemSlug]/index.tsx new file mode 100644 index 0000000000000..26366989d0230 --- /dev/null +++ b/apps/web/pages/s/[slug]/[subSlug]/[genericListItemSlug]/index.tsx @@ -0,0 +1,115 @@ +import { useMemo } from 'react' +import { useRouter } from 'next/router' + +import withApollo from '@island.is/web/graphql/withApollo' +import { useLinkResolver } from '@island.is/web/hooks' +import { useI18n, withLocale } from '@island.is/web/i18n' +import type { LayoutProps } from '@island.is/web/layouts/main' +import GenericListItemPage, { + type GenericListItemPageProps, +} from '@island.is/web/screens/GenericList/GenericListItem' +import SubPageLayout, { + type SubPageProps, +} from '@island.is/web/screens/Organization/SubPage' +import type { Screen as ScreenType } from '@island.is/web/types' +import { CustomNextError } from '@island.is/web/units/errors' +import { getServerSidePropsWrapper } from '@island.is/web/utils/getServerSidePropsWrapper' + +interface ComponentProps { + parentProps: { + layoutProps: LayoutProps + componentProps: SubPageProps + } + genericListItemProps: GenericListItemPageProps +} + +export const Component: ScreenType = ({ + parentProps, + genericListItemProps, +}) => { + const { activeLocale } = useI18n() + const router = useRouter() + const { linkResolver } = useLinkResolver() + const backLinkUrl = useMemo(() => { + const pathname = new URL(router.asPath, 'https://island.is').pathname + return pathname.slice(0, pathname.lastIndexOf('/')) + }, [router.asPath]) + + const { organizationPage, subpage } = parentProps.componentProps + + return ( + + ), + customBreadcrumbItems: [ + { + title: 'Ísland.is', + href: linkResolver('homepage').href, + }, + { + title: organizationPage?.title ?? '', + href: linkResolver('organizationpage', [ + organizationPage?.slug ?? '', + ]).href, + }, + { + title: subpage?.title ?? '', + href: backLinkUrl, + isTag: true, + }, + ], + backLink: { + text: activeLocale === 'is' ? 'Til baka' : 'Go back', + url: backLinkUrl, + }, + customContentfulIds: [ + organizationPage?.id, + subpage?.id, + genericListItemProps.item.id, + ], + }} + /> + ) +} + +Component.getProps = async (ctx) => { + const [parentProps, genericListItemProps] = await Promise.all([ + SubPageLayout.getProps?.(ctx), + GenericListItemPage.getProps?.(ctx), + ]) + + if (!parentProps) { + throw new CustomNextError( + 404, + 'Could not fetch subpage layout for generic list item', + ) + } + if (!genericListItemProps) { + throw new CustomNextError(404, 'Could not fetch generic list item props') + } + + return { + parentProps, + genericListItemProps, + } +} + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore make web strict +const Screen = withApollo(withLocale('is')(Component)) + +export default Screen + +export const getServerSideProps = getServerSidePropsWrapper(Screen) diff --git a/apps/web/pages/v/[slug]/[subSlug]/[genericListItemSlug]/index.tsx b/apps/web/pages/v/[slug]/[subSlug]/[genericListItemSlug]/index.tsx new file mode 100644 index 0000000000000..94a01d0664e69 --- /dev/null +++ b/apps/web/pages/v/[slug]/[subSlug]/[genericListItemSlug]/index.tsx @@ -0,0 +1,121 @@ +import { useMemo } from 'react' +import { useRouter } from 'next/router' + +import type { ProjectPage } from '@island.is/web/graphql/schema' +import withApollo from '@island.is/web/graphql/withApollo' +import { useLinkResolver } from '@island.is/web/hooks' +import { useI18n, withLocale } from '@island.is/web/i18n' +import type { LayoutProps } from '@island.is/web/layouts/main' +import type { GenericListItemPageProps } from '@island.is/web/screens/GenericList/GenericListItem' +import GenericListItemPage from '@island.is/web/screens/GenericList/GenericListItem' +import SubPageLayout, { + type PageProps, +} from '@island.is/web/screens/Project/Project' +import type { Screen as ScreenType } from '@island.is/web/types' +import { CustomNextError } from '@island.is/web/units/errors' +import { getServerSidePropsWrapper } from '@island.is/web/utils/getServerSidePropsWrapper' + +interface ComponentProps { + parentProps: { + layoutProps: LayoutProps + componentProps: PageProps + } + genericListItemProps: GenericListItemPageProps +} + +export const Component: ScreenType = ({ + parentProps, + genericListItemProps, +}) => { + const { activeLocale } = useI18n() + const router = useRouter() + const { linkResolver } = useLinkResolver() + const backLinkUrl = useMemo(() => { + const pathname = new URL(router.asPath, 'https://island.is').pathname + return pathname.slice(0, pathname.lastIndexOf('/')) + }, [router.asPath]) + + const projectPage = parentProps.componentProps.projectPage as ProjectPage + const subpage = projectPage.projectSubpages.find( + (subpage) => subpage.slug === router.query.subSlug, + ) + + return ( + + ), + projectPage: { + ...projectPage, + backLink: { + date: '', + id: '', + text: activeLocale === 'is' ? 'Til baka' : 'Go back', + url: backLinkUrl, + }, + }, + }} + /> + ) +} + +Component.getProps = async (ctx) => { + const [parentProps, genericListItemProps] = await Promise.all([ + SubPageLayout.getProps?.(ctx), + GenericListItemPage.getProps?.(ctx), + ]) + + if (!parentProps) { + throw new CustomNextError( + 404, + 'Could not fetch subpage layout for generic list item', + ) + } + if (!genericListItemProps) { + throw new CustomNextError(404, 'Could not fetch generic list item props') + } + + return { + parentProps, + genericListItemProps, + } +} + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore make web strict +const Screen = withApollo(withLocale('is')(Component)) + +export default Screen + +export const getServerSideProps = getServerSidePropsWrapper(Screen) diff --git a/apps/web/screens/GenericList/GenericListItem.tsx b/apps/web/screens/GenericList/GenericListItem.tsx new file mode 100644 index 0000000000000..579279a5904ea --- /dev/null +++ b/apps/web/screens/GenericList/GenericListItem.tsx @@ -0,0 +1,86 @@ +import { Box, GridContainer, Stack, Text } from '@island.is/island-ui/core' +import { HeadWithSocialSharing, Webreader } from '@island.is/web/components' +import type { + GenericListItem, + Query, + QueryGetGenericListItemBySlugArgs, +} from '@island.is/web/graphql/schema' +import { useDateUtils } from '@island.is/web/i18n/useDateUtils' +import { CustomNextError } from '@island.is/web/units/errors' +import { webRichText } from '@island.is/web/utils/richText' + +import type { Screen } from '../../types' +import { GET_GENERIC_LIST_ITEM_BY_SLUG_QUERY } from '../queries/GenericList' + +export interface GenericListItemPageProps { + item: GenericListItem + showReadspeaker?: boolean + ogTitle?: string +} + +const GenericListItemPage: Screen = ({ + item, + showReadspeaker = true, + ogTitle, +}) => { + const { format } = useDateUtils() + + return ( + + + {ogTitle && } + + {item.date && ( + + {format(new Date(item.date), 'dd.MM.yyyy')} + + )} + + + {item.title} + + {showReadspeaker && } + + {webRichText(item.content ?? [])} + + + + ) +} + +GenericListItemPage.getProps = async ({ apolloClient, query, locale }) => { + const slug = query.genericListItemSlug + + if (!slug) { + throw new CustomNextError( + 404, + 'Generic List item could not be found since no slug was provided', + ) + } + + const response = await apolloClient.query< + Query, + QueryGetGenericListItemBySlugArgs + >({ + query: GET_GENERIC_LIST_ITEM_BY_SLUG_QUERY, + variables: { + input: { + lang: locale, + slug: slug as string, + }, + }, + }) + + if (!response?.data?.getGenericListItemBySlug) { + throw new CustomNextError( + 404, + `Generic List item with slug: ${slug} could not be found`, + ) + } + + return { + item: response.data.getGenericListItemBySlug, + } +} + +export default GenericListItemPage diff --git a/apps/web/screens/Organization/SubPage.tsx b/apps/web/screens/Organization/SubPage.tsx index 814241cb3c2fe..79f0f6c5cbd7b 100644 --- a/apps/web/screens/Organization/SubPage.tsx +++ b/apps/web/screens/Organization/SubPage.tsx @@ -5,6 +5,7 @@ import { ParsedUrlQuery } from 'querystring' import { SliceType } from '@island.is/island-ui/contentful' import { Box, + BreadCrumbItem, GridColumn, GridContainer, GridRow, @@ -14,7 +15,6 @@ import { Text, } from '@island.is/island-ui/core' import { - Form, getThemeConfig, OrganizationWrapper, SignLanguageButton, @@ -49,11 +49,15 @@ import { GET_ORGANIZATION_SUBPAGE_QUERY, } from '../queries' -interface SubPageProps { +export interface SubPageProps { organizationPage: Query['getOrganizationPage'] subpage: Query['getOrganizationSubpage'] namespace: Record locale: Locale + customContent?: React.ReactNode + backLink?: { text: string; url: string } + customBreadcrumbItems?: BreadCrumbItem[] + customContentfulIds?: (string | undefined)[] } const SubPage: Screen = ({ @@ -61,6 +65,10 @@ const SubPage: Screen = ({ subpage, namespace, locale, + customContent, + customBreadcrumbItems, + customContentfulIds, + backLink, }) => { const router = useRouter() const { activeLocale } = useI18n() @@ -68,7 +76,11 @@ const SubPage: Screen = ({ const n = useNamespace(namespace) const { linkResolver } = useLinkResolver() - useContentfulId(organizationPage?.id, subpage?.id) + const contentfulIds = customContentfulIds + ? customContentfulIds + : [organizationPage?.id, subpage?.id] + + useContentfulId(...contentfulIds) const pathWithoutHash = router.asPath.split('#')[0] // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -150,20 +162,25 @@ const SubPage: Screen = ({ pageFeaturedImage={ subpage?.featuredImage ?? organizationPage?.featuredImage } - breadcrumbItems={[ - { - title: 'Ísland.is', - href: linkResolver('homepage', [], locale).href, - }, - { - title: organizationPage?.title ?? '', - href: linkResolver( - 'organizationpage', - [organizationPage?.slug ?? ''], - locale, - ).href, - }, - ]} + backLink={backLink} + breadcrumbItems={ + customBreadcrumbItems + ? customBreadcrumbItems + : [ + { + title: 'Ísland.is', + href: linkResolver('homepage', [], locale).href, + }, + { + title: organizationPage?.title ?? '', + href: linkResolver( + 'organizationpage', + [organizationPage?.slug ?? ''], + locale, + ).href, + }, + ] + } navigationData={{ title: n('navigationTitle', 'Efnisyfirlit'), items: navList, @@ -182,56 +199,62 @@ const SubPage: Screen = ({ subpage?.links?.length ? '7/12' : '12/12', ]} > - - - {subpage?.title} - - - - - {subpage?.signLanguageVideo?.url && ( - - - {subpage.title} - - {content} - {renderSlices( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore make web strict - subpage.slices, - subpage.sliceCustomRenderer, - subpage.sliceExtraText, - namespace, - organizationPage?.slug, - )} - - } - /> - )} - + {customContent ? ( + customContent + ) : ( + <> + + + {subpage?.title} + + + + + {subpage?.signLanguageVideo?.url && ( + + + {subpage.title} + + {content} + {renderSlices( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore make web strict + subpage.slices, + subpage.sliceCustomRenderer, + subpage.sliceExtraText, + namespace, + organizationPage?.slug, + )} + + } + /> + )} + + + )} - {content} + {!customContent && content} diff --git a/apps/web/screens/Project/Project.tsx b/apps/web/screens/Project/Project.tsx index cc5ca0380ccc4..433573aa5fbfd 100644 --- a/apps/web/screens/Project/Project.tsx +++ b/apps/web/screens/Project/Project.tsx @@ -46,7 +46,7 @@ import { ProjectFooter } from './components/ProjectFooter' import { ProjectWrapper } from './components/ProjectWrapper' import { getThemeConfig } from './utils' -interface PageProps { +export interface PageProps { projectPage: Query['getProjectPage'] namespace: Record projectNamespace: Record @@ -54,6 +54,10 @@ interface PageProps { stepOptionsFromNamespace: { data: Record[]; slug: string }[] stepperNamespace: Record locale: Locale + backLink?: { url: string; text: string } + customContentfulIds?: (string | undefined)[] + customBreadcrumbItems?: BreadCrumbItem[] + customContent?: React.ReactNode } const ProjectPage: Screen = ({ projectPage, @@ -62,6 +66,10 @@ const ProjectPage: Screen = ({ stepperNamespace, stepOptionsFromNamespace, locale, + backLink, + customContentfulIds, + customBreadcrumbItems, + customContent, }) => { const n = useNamespace(namespace) const p = useNamespace(projectNamespace) @@ -77,7 +85,11 @@ const ProjectPage: Screen = ({ [router.query.subSlug, projectPage?.projectSubpages], ) - useContentfulId(projectPage?.id, subpage?.id) + const contentfulIds = customContentfulIds + ? customContentfulIds + : [projectPage?.id, subpage?.id] + + useContentfulId(...contentfulIds) const baseRouterPath = router.asPath.split('?')[0].split('#')[0] @@ -116,7 +128,9 @@ const ProjectPage: Screen = ({ } }, [renderSlicesAsTabs, subpage, router.asPath]) - const breadCrumbs: BreadCrumbItem[] = !subpage + const breadCrumbs: BreadCrumbItem[] = customBreadcrumbItems + ? customBreadcrumbItems + : !subpage ? [] : [ { @@ -140,6 +154,128 @@ const ProjectPage: Screen = ({ const pageSlices = (subpage ?? projectPage)?.slices ?? [] + const mainContent = ( + <> + {!subpage && shouldDisplayWebReader && ( + + )} + {!!subpage && ( + + + {subpage.title} + + {shouldDisplayWebReader && ( + + )} + {subpage.showTableOfContents && ( + + + + )} + {subpage.content && ( + + {webRichText( + subpage.content as SliceType[], + undefined, + activeLocale, + )} + + )} + + )} + {renderSlicesAsTabs && !!subpage && subpage.slices.length > 1 && ( + + ({ + headingId: slice.id, + headingTitle: (slice as OneColumnText).title, + }))} + selectedHeadingId={selectedSliceTab?.id} + onClick={(id) => { + const slice = subpage.slices.find( + (s) => s.id === id, + ) as OneColumnText + router.push( + `${baseRouterPath}#${slugify(slice.title)}`, + undefined, + { shallow: true }, + ) + setSelectedSliceTab(slice) + }} + /> + + )} + {renderSlicesAsTabs && selectedSliceTab && ( + + + {selectedSliceTab.title} + + + )} + {content?.length > 0 && ( + + {webRichText(content, { + renderComponent: { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore make web strict + TabSection: (slice) => ( + + ), + }, + })} + + )} + {!subpage && projectPage?.stepper && ( + + + + )} + {!renderSlicesAsTabs && pageSlices.length > 0 && ( + + {pageSlices.map((slice: Slice, index) => { + const sliceCount = pageSlices.length + return ( + + + + ) + })} + + )} + + ) + return ( <> = ({ breadcrumbItems={breadCrumbs} sidebarNavigationTitle={navigationTitle} withSidebar={projectPage?.sidebar} + backLink={backLink} > - {!subpage && shouldDisplayWebReader && ( - - )} - {!!subpage && ( - - - {subpage.title} - - {shouldDisplayWebReader && ( - - )} - {subpage.showTableOfContents && ( - - - - )} - {subpage.content && ( - - {webRichText( - subpage.content as SliceType[], - undefined, - activeLocale, - )} - - )} - - )} - {renderSlicesAsTabs && !!subpage && subpage.slices.length > 1 && ( - - ({ - headingId: slice.id, - headingTitle: (slice as OneColumnText).title, - }))} - selectedHeadingId={selectedSliceTab?.id} - onClick={(id) => { - const slice = subpage.slices.find( - (s) => s.id === id, - ) as OneColumnText - router.push( - `${baseRouterPath}#${slugify(slice.title)}`, - undefined, - { shallow: true }, - ) - setSelectedSliceTab(slice) - }} - /> - - )} - {renderSlicesAsTabs && selectedSliceTab && ( - - - {selectedSliceTab.title} - - - )} - {content?.length > 0 && ( - - {webRichText(content, { - renderComponent: { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore make web strict - TabSection: (slice) => ( - - ), - }, - })} - - )} - {!subpage && projectPage?.stepper && ( - - - - )} - {!renderSlicesAsTabs && pageSlices.length > 0 && ( - - {pageSlices.map((slice: Slice, index) => { - const sliceCount = pageSlices.length - return ( - - - - ) - })} - - )} + {customContent ? customContent : mainContent} ), } diff --git a/libs/cms/src/lib/cms.elasticsearch.service.ts b/libs/cms/src/lib/cms.elasticsearch.service.ts index ec99f59ec227c..8e90d69363c70 100644 --- a/libs/cms/src/lib/cms.elasticsearch.service.ts +++ b/libs/cms/src/lib/cms.elasticsearch.service.ts @@ -39,6 +39,8 @@ import { CustomPage } from './models/customPage.model' import { GetGenericListItemsInput } from './dto/getGenericListItems.input' import { GenericListItemResponse } from './models/genericListItemResponse.model' import { GetCustomSubpageInput } from './dto/getCustomSubpage.input' +import { GetGenericListItemBySlugInput } from './dto/getGenericListItemBySlug.input' +import { GenericListItem } from './models/genericListItem.model' @Injectable() export class CmsElasticsearchService { @@ -440,6 +442,15 @@ export class CmsElasticsearchService { ) } + async getGenericListItemBySlug( + input: GetGenericListItemBySlugInput, + ): Promise { + return this.getSingleDocumentTypeBySlug(getElasticsearchIndex(input.lang), { + slug: input.slug, + type: 'webGenericListItem', + }) + } + async getGenericListItems( input: GetGenericListItemsInput, ): Promise { diff --git a/libs/cms/src/lib/cms.resolver.ts b/libs/cms/src/lib/cms.resolver.ts index ebbbb2ce658e9..4d47feba92e8d 100644 --- a/libs/cms/src/lib/cms.resolver.ts +++ b/libs/cms/src/lib/cms.resolver.ts @@ -115,6 +115,8 @@ import { GenericListItemResponse } from './models/genericListItemResponse.model' import { GetGenericListItemsInput } from './dto/getGenericListItems.input' import { GenericList } from './models/genericList.model' import { GetCustomSubpageInput } from './dto/getCustomSubpage.input' +import { GetGenericListItemBySlugInput } from './dto/getGenericListItemBySlug.input' +import { GenericListItem } from './models/genericListItem.model' const defaultCache: CacheControlOptions = { maxAge: CACHE_CONTROL_MAX_AGE } @@ -667,6 +669,14 @@ export class CmsResolver { ): Promise { return this.cmsElasticsearchService.getGenericListItems(input) } + + @CacheControl(defaultCache) + @Query(() => GenericListItem, { nullable: true }) + getGenericListItemBySlug( + @Args('input') input: GetGenericListItemBySlugInput, + ): Promise { + return this.cmsElasticsearchService.getGenericListItemBySlug(input) + } } @Resolver(() => LatestNewsSlice) diff --git a/libs/cms/src/lib/dto/getGenericListItemBySlug.input.ts b/libs/cms/src/lib/dto/getGenericListItemBySlug.input.ts new file mode 100644 index 0000000000000..6c0153bd1020f --- /dev/null +++ b/libs/cms/src/lib/dto/getGenericListItemBySlug.input.ts @@ -0,0 +1,14 @@ +import type { ElasticsearchIndexLocale } from '@island.is/content-search-index-manager' +import { Field, InputType } from '@nestjs/graphql' +import { IsString } from 'class-validator' + +@InputType() +export class GetGenericListItemBySlugInput { + @Field() + @IsString() + slug!: string + + @Field(() => String) + @IsString() + lang: ElasticsearchIndexLocale = 'is' +} diff --git a/libs/cms/src/lib/generated/contentfulTypes.d.ts b/libs/cms/src/lib/generated/contentfulTypes.d.ts index 15fbec8e8fa88..bd1046b15becf 100644 --- a/libs/cms/src/lib/generated/contentfulTypes.d.ts +++ b/libs/cms/src/lib/generated/contentfulTypes.d.ts @@ -1542,6 +1542,9 @@ export interface IGenericListFields { /** Search Input Placeholder */ searchInputPlaceholder: string + + /** Item Type */ + itemType?: 'Non-clickable' | 'Clickable' | undefined } /** A list of items which can be embedded into rich text */ @@ -1578,6 +1581,12 @@ export interface IGenericListItemFields { /** Card Intro */ cardIntro?: Document | undefined + + /** Content */ + content?: Document | undefined + + /** Slug */ + slug?: string | undefined } /** An item that belongs to a generic list */ diff --git a/libs/cms/src/lib/models/genericList.model.ts b/libs/cms/src/lib/models/genericList.model.ts index 0c5cd2b2b503a..48622500262f8 100644 --- a/libs/cms/src/lib/models/genericList.model.ts +++ b/libs/cms/src/lib/models/genericList.model.ts @@ -1,11 +1,20 @@ -import { Field, ID, ObjectType } from '@nestjs/graphql' +import { Field, ID, ObjectType, registerEnumType } from '@nestjs/graphql' import { ElasticsearchIndexLocale } from '@island.is/content-search-index-manager' import { SystemMetadata } from '@island.is/shared/types' import { CacheField } from '@island.is/nest/graphql' -import { IGenericList } from '../generated/contentfulTypes' +import { IGenericList, IGenericListFields } from '../generated/contentfulTypes' import { GenericListItemResponse } from './genericListItemResponse.model' import { GetGenericListItemsInput } from '../dto/getGenericListItems.input' +enum GenericListItemType { + NonClickable = 'NonClickable', + Clickable = 'Clickable', +} + +registerEnumType(GenericListItemType, { + name: 'GenericListItemType', +}) + @ObjectType() export class GenericList { @Field(() => ID) @@ -16,8 +25,16 @@ export class GenericList { @Field(() => String, { nullable: true }) searchInputPlaceholder?: string + + @CacheField(() => GenericListItemType, { nullable: true }) + itemType?: GenericListItemType } +const mapItemType = (itemType?: IGenericListFields['itemType']) => + itemType === 'Clickable' + ? GenericListItemType.Clickable + : GenericListItemType.NonClickable + export const mapGenericList = ({ fields, sys, @@ -31,4 +48,5 @@ export const mapGenericList = ({ page: 1, }, searchInputPlaceholder: fields.searchInputPlaceholder, + itemType: mapItemType(fields.itemType), }) diff --git a/libs/cms/src/lib/models/genericListItem.model.ts b/libs/cms/src/lib/models/genericListItem.model.ts index c8654a16ff247..d14c6ebc1a590 100644 --- a/libs/cms/src/lib/models/genericListItem.model.ts +++ b/libs/cms/src/lib/models/genericListItem.model.ts @@ -20,21 +20,29 @@ export class GenericListItem { @CacheField(() => [SliceUnion]) cardIntro: Array = [] + + @CacheField(() => [SliceUnion], { nullable: true }) + content?: Array + + @Field(() => String, { nullable: true }) + slug?: string } export const mapGenericListItem = ({ fields, sys, -}: IGenericListItem): GenericListItem => { - return { - id: sys.id, - genericList: fields.genericList - ? mapGenericList(fields.genericList) - : undefined, - title: fields.title ?? '', - date: fields.date || null, - cardIntro: fields.cardIntro - ? mapDocument(fields.cardIntro, sys.id + ':cardIntro') - : [], - } -} +}: IGenericListItem): GenericListItem => ({ + id: sys.id, + genericList: fields.genericList + ? mapGenericList(fields.genericList) + : undefined, + title: fields.title ?? '', + date: fields.date || null, + cardIntro: fields.cardIntro + ? mapDocument(fields.cardIntro, `${sys.id}:cardIntro`) + : [], + content: fields.content + ? mapDocument(fields.content, `${sys.id}:content`) + : [], + slug: fields.slug, +}) diff --git a/libs/cms/src/lib/search/importers/genericListItem.service.ts b/libs/cms/src/lib/search/importers/genericListItem.service.ts index 4890200025895..6c8e4981c0c48 100644 --- a/libs/cms/src/lib/search/importers/genericListItem.service.ts +++ b/libs/cms/src/lib/search/importers/genericListItem.service.ts @@ -38,6 +38,18 @@ export class GenericListItemSyncService 2, ) + const tags: MappedData['tags'] = [] + + if (mapped.genericList) { + tags.push({ + type: 'referencedBy', + key: mapped.genericList.id, + }) + } + if (mapped.slug) { + tags.push({ type: 'slug', key: mapped.slug }) + } + return { _id: mapped.id, title: mapped.title, @@ -50,14 +62,7 @@ export class GenericListItemSyncService }), dateCreated: entry.sys.createdAt, dateUpdated: new Date().getTime().toString(), - tags: mapped.genericList - ? [ - { - type: 'referencedBy', - key: mapped.genericList.id, - }, - ] - : [], + tags, releaseDate: mapped.date, } } catch (error) {