Skip to content

Commit

Permalink
feat(web): Clickable generic list items (#15003)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
RunarVestmann and kodiakhq[bot] authored Jun 5, 2024
1 parent 543857a commit ac26fb2
Show file tree
Hide file tree
Showing 27 changed files with 1,000 additions and 257 deletions.
5 changes: 5 additions & 0 deletions apps/contentful-apps/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
]
9 changes: 1 addition & 8 deletions apps/contentful-apps/pages/fields/event-slug-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<FieldExtensionSDK>()
Expand Down
166 changes: 166 additions & 0 deletions apps/contentful-apps/pages/fields/generic-list-item-slug-field.tsx
Original file line number Diff line number Diff line change
@@ -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<FieldExtensionSDK>()
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 (
<Text>
Slug can only be changed if the list item type is {`"Clickable"`}
</Text>
)
}

const isInvalid = value.length === 0 || !isValid

return (
<Stack spacing="spacingXs" flexDirection="column" alignItems="flex-start">
<TextInput
value={value}
onChange={(ev) => {
setValue(ev.target.value)
}}
isInvalid={isInvalid}
/>
{value.length === 0 && sdk.field.locale === sdk.locales.default && (
<Text fontColor="red400">Invalid slug</Text>
)}
{value.length > 0 && isInvalid && (
<Text fontColor="red400">Item already exists with this slug</Text>
)}
</Stack>
)
}

export default GenericListItemSlugField
10 changes: 10 additions & 0 deletions apps/contentful-apps/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ''
}
}
100 changes: 77 additions & 23 deletions apps/web/components/GenericList/GenericList.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -44,24 +47,87 @@ const getResultsFoundText = (totalItems: number, locale: Locale) => {
return plural
}

interface ItemProps {
item: GenericListItem
}

const NonClickableItem = ({ item }: ItemProps) => {
const { format } = useDateUtils()

return (
<Box
key={item.id}
padding={[2, 2, 3]}
border="standard"
borderRadius="large"
>
<Stack space={0}>
<Stack space={0}>
<Text variant="eyebrow" color="purple400">
{item.date && format(new Date(item.date), 'dd.MM.yyyy')}
</Text>
<Text variant="h3" as="span" color="dark400">
{item.title}
</Text>
</Stack>
{item.cardIntro?.length > 0 && (
<Box>{webRichText(item.cardIntro ?? [])}</Box>
)}
</Stack>
</Box>
)
}

const ClickableItem = ({ item }: ItemProps) => {
const { format } = useDateUtils()
const router = useRouter()

const pathname = new URL(router.asPath, 'https://island.is').pathname

return (
<FocusableBox
key={item.id}
padding={[2, 2, 3]}
border="standard"
borderRadius="large"
href={item.slug ? `${pathname}/${item.slug}` : undefined}
>
<Stack space={0}>
<Stack space={0}>
<Text variant="eyebrow" color="purple400">
{item.date && format(new Date(item.date), 'dd.MM.yyyy')}
</Text>
<Text variant="h3" as="span" color="blue400">
{item.title}
</Text>
</Stack>
{item.cardIntro?.length > 0 && (
<Box>{webRichText(item.cardIntro ?? [])}</Box>
)}
</Stack>
</FocusableBox>
)
}

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)
const pageRef = useRef(page)
const searchValueRef = useRef(searchValue)
const [itemsResponse, setItemsResponse] = useState(firstPageItemResponse)
const firstRender = useRef(true)
const { format } = useDateUtils()
const [errorOccurred, setErrorOccurred] = useState(false)

const { activeLocale } = useI18n()
Expand Down Expand Up @@ -119,6 +185,8 @@ export const GenericList = ({

const resultsFoundText = getResultsFoundText(totalItems, activeLocale)

const itemsAreClickable = itemType === 'Clickable'

return (
<Box paddingBottom={3}>
<GridContainer>
Expand Down Expand Up @@ -153,28 +221,14 @@ export const GenericList = ({
<Text>
{totalItems} {resultsFoundText}
</Text>
{items.map((item) => (
<Box
key={item.id}
padding={[2, 2, 3]}
border="standard"
borderRadius="large"
>
<Stack space={0}>
<Stack space={0}>
<Text variant="eyebrow" color="purple400">
{item.date && format(new Date(item.date), 'dd.MM.yyyy')}
</Text>
<Text variant="h3" as="span" color="dark400">
{item.title}
</Text>
</Stack>
{item.cardIntro?.length > 0 && (
<Box>{webRichText(item.cardIntro ?? [])}</Box>
)}
</Stack>
</Box>
))}
{!itemsAreClickable &&
items.map((item) => (
<NonClickableItem key={item.id} item={item} />
))}
{itemsAreClickable &&
items.map((item) => (
<ClickableItem key={item.id} item={item} />
))}
</Stack>
)}

Expand Down
2 changes: 1 addition & 1 deletion apps/web/components/LanguageToggler/LanguageToggler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions apps/web/components/Organization/Slice/SliceMachine.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ const renderSlice = (
searchInputPlaceholder={
(slice as GenericListSchema).searchInputPlaceholder
}
itemType={(slice as GenericListSchema).itemType}
/>
)
default:
Expand Down
Loading

0 comments on commit ac26fb2

Please sign in to comment.