Merged in feat/SW-1555-jobylon-feed-filter (pull request #1494)

Feat/SW-1555 jobylon feed filter

* feat(SW-1555): Added jobylon feed component

* feat(SW-1555): Added filter functionality for Jobylon feed


Approved-by: Matilda Landström
This commit is contained in:
Erik Tiekstra
2025-03-07 11:46:42 +00:00
parent c3be694874
commit 7fa86a2077
15 changed files with 424 additions and 52 deletions

View File

@@ -0,0 +1,4 @@
.filterForm {
display: grid;
gap: var(--Spacing-x1);
}

View File

@@ -0,0 +1,71 @@
"use client"
import { useIntl } from "react-intl"
import Select from "@/components/TempDesignSystem/Select"
import styles from "./filter.module.css"
import type { Key } from "react-aria-components"
import type { JobylonFilterItem } from "@/types/components/blocks/jobylonFeed"
import { JobylonFilterKey } from "@/types/enums/jobylonFeed"
interface FilterProps {
onFilterChange: (filter: JobylonFilterKey, value: Key) => void
countryFilters: JobylonFilterItem[]
cityFilters: JobylonFilterItem[]
departmentFilters: JobylonFilterItem[]
categoryFilters: JobylonFilterItem[]
}
export default function Filter({
onFilterChange,
countryFilters,
cityFilters,
departmentFilters,
categoryFilters,
}: FilterProps) {
const intl = useIntl()
return (
<form className={styles.filterForm}>
<Select
items={countryFilters}
defaultSelectedKey={""}
label={intl.formatMessage({ id: "Country" })}
aria-label={intl.formatMessage({ id: "Country" })}
name="country"
onSelect={(value) => onFilterChange(JobylonFilterKey.country, value)}
/>
<Select
items={cityFilters}
defaultSelectedKey={""}
label={intl.formatMessage({
id: "Location (shown in local language)",
})}
aria-label={intl.formatMessage({
id: "Location (shown in local language)",
})}
name="city"
onSelect={(value) => onFilterChange(JobylonFilterKey.city, value)}
/>
<Select
items={departmentFilters}
defaultSelectedKey={""}
label={intl.formatMessage({ id: "Hotel or office" })}
aria-label={intl.formatMessage({ id: "Hotel or office" })}
name="department"
onSelect={(value) => onFilterChange(JobylonFilterKey.department, value)}
/>
<Select
items={categoryFilters}
defaultSelectedKey={""}
label={intl.formatMessage({ id: "Category" })}
aria-label={intl.formatMessage({ id: "Category" })}
name="category"
onSelect={(value) => onFilterChange(JobylonFilterKey.category, value)}
/>
</form>
)
}

View File

@@ -0,0 +1,76 @@
"use client"
import { useReducer } from "react"
import { useIntl } from "react-intl"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import JobylonCard from "../JobylonCard"
import Filter from "./Filter"
import { init, reducer } from "./reducer"
import styles from "./jobList.module.css"
import type { Key } from "react-aria-components"
import { ActionType } from "@/types/components/blocks/jobylonFeed"
import type { JobylonFilterKey } from "@/types/enums/jobylonFeed"
import type { JobylonItem } from "@/types/trpc/routers/jobylon"
interface JobListProps {
allJobs: JobylonItem[]
}
export default function JobList({ allJobs }: JobListProps) {
const intl = useIntl()
const [state, dispatch] = useReducer(reducer, { allJobs }, init)
function handleFilterChange(filter: JobylonFilterKey, value: Key) {
const payload = { filter, value }
dispatch({ type: ActionType.UPDATE_FILTER, payload })
}
const countryFilters = [
{ label: intl.formatMessage({ id: "All countries" }), value: "" },
...state.countryFilters,
]
const cityFilters = [
{ label: intl.formatMessage({ id: "All locations" }), value: "" },
...state.cityFilters,
]
const departmentFilters = [
{ label: intl.formatMessage({ id: "All hotels and offices" }), value: "" },
...state.departmentFilters,
]
const categoryFilters = [
{ label: intl.formatMessage({ id: "All categories" }), value: "" },
...state.categoryFilters,
]
return (
<div className={styles.jobList}>
<Filter
onFilterChange={handleFilterChange}
countryFilters={countryFilters}
cityFilters={cityFilters}
departmentFilters={departmentFilters}
categoryFilters={categoryFilters}
/>
<Subtitle type="two">
{intl.formatMessage(
{
id: "{count, plural, one {{count} Result} other {{count} Results}}",
},
{ count: state.jobs.length }
)}
</Subtitle>
<ul className={styles.list}>
{state.jobs.map((job) => (
<li key={job.id}>
<JobylonCard job={job} />
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,10 @@
.jobList {
display: grid;
gap: var(--Spacing-x2);
}
.list {
list-style: none;
display: grid;
gap: var(--Spacing-x2);
}

View File

@@ -0,0 +1,61 @@
import { getFilteredJobs, getFiltersFromJobs } from "./utils"
import {
type Action,
ActionType,
type InitState,
type State,
} from "@/types/components/blocks/jobylonFeed"
export function init(initState: InitState): State {
const { country, city, department, category } = getFiltersFromJobs(
initState.allJobs
)
return {
allJobs: initState.allJobs,
jobs: initState.allJobs,
countryFilters: country,
cityFilters: city,
departmentFilters: department,
categoryFilters: category,
chosenCategoryFilter: "",
chosenCityFilter: "",
chosenCountryFilter: "",
chosenDepartmentFilter: "",
}
}
export function reducer(state: State, action: Action) {
const type = action.type
switch (type) {
case ActionType.UPDATE_FILTER: {
const filters = {
country: state.chosenCountryFilter,
city: state.chosenCityFilter,
department: state.chosenDepartmentFilter,
category: state.chosenCategoryFilter,
[action.payload.filter]: action.payload.value,
}
const jobs = getFilteredJobs(state.allJobs, filters)
const { country, city, department, category } = getFiltersFromJobs(jobs)
return {
...state,
chosenCountryFilter: filters.country,
chosenCityFilter: filters.city,
chosenDepartmentFilter: filters.department,
chosenCategoryFilter: filters.category,
countryFilters: country,
cityFilters: city,
departmentFilters: department,
categoryFilters: category,
jobs,
}
}
default:
const unhandledActionType: never = type
console.info(`Unhandled type: ${unhandledActionType}`)
return state
}
}

View File

@@ -0,0 +1,109 @@
import type { Key } from "react-aria-components"
import type { JobylonFilters } from "@/types/components/blocks/jobylonFeed"
import { JobylonFilterKey } from "@/types/enums/jobylonFeed"
import type { JobylonItem } from "@/types/trpc/routers/jobylon"
export function getFiltersFromJobs(jobs: JobylonItem[]) {
const filters = jobs.reduce<JobylonFilters>(
(acc, job) => {
if (job.locations.length) {
job.locations.forEach((location) => {
if (location.country && location.countryShort) {
if (
!acc[JobylonFilterKey.country].find(
(f) => f.value === location.countryShort
)
) {
acc[JobylonFilterKey.country].push({
value: location.countryShort,
label: location.country,
})
}
}
if (location.city) {
if (
!acc[JobylonFilterKey.city].find((f) => f.value === location.city)
) {
acc[JobylonFilterKey.city].push({
value: location.city,
label: location.city,
})
}
}
})
}
if (job.departments.length) {
job.departments.forEach((department) => {
if (
!acc[JobylonFilterKey.department].find(
(f) => f.value === department.id
)
) {
acc[JobylonFilterKey.department].push({
value: department.id,
label: department.name,
})
}
})
}
if (job.categories.length) {
job.categories.forEach((category) => {
if (
!acc[JobylonFilterKey.category].find(
(f) => f.value === category.text
)
) {
acc[JobylonFilterKey.category].push({
value: category.text,
label: category.text,
})
}
})
}
return acc
},
{
[JobylonFilterKey.country]: [],
[JobylonFilterKey.category]: [],
[JobylonFilterKey.city]: [],
[JobylonFilterKey.department]: [],
}
)
// Sort each filter array by label
Object.keys(filters).forEach((key) => {
const typedKey = key as JobylonFilterKey
filters[typedKey].sort((a, b) => a.label.localeCompare(b.label))
})
return filters
}
export function getFilteredJobs(
jobs: JobylonItem[],
filters: Record<JobylonFilterKey, Key>
) {
const countryFilter = filters[JobylonFilterKey.country]
const cityFilter = filters[JobylonFilterKey.city]
const categoryFilter = filters[JobylonFilterKey.category]
const departmentFilter = filters[JobylonFilterKey.department]
return jobs.filter((job) => {
const countryMatch = countryFilter
? job.locations.some(
(location) => location.countryShort === countryFilter
)
: true
const cityMatch = cityFilter
? job.locations.some((location) => location.city === cityFilter)
: true
const departmentMatch = filters[JobylonFilterKey.department]
? job.departments.some((department) => department.id === departmentFilter)
: true
const categoryMatch = filters[JobylonFilterKey.category]
? job.categories.some((category) => category.text === categoryFilter)
: true
return countryMatch && cityMatch && departmentMatch && categoryMatch
})
}

View File

@@ -1,9 +1,14 @@
"use client"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import { OpenInNewSmallIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import useLang from "@/hooks/useLang"
import styles from "./jobylonCard.module.css"
@@ -13,13 +18,13 @@ interface JobylonCardProps {
job: JobylonItem
}
export default async function JobylonCard({ job }: JobylonCardProps) {
const intl = await getIntl()
const lang = getLang()
export default function JobylonCard({ job }: JobylonCardProps) {
const intl = useIntl()
const lang = useLang()
const deadlineText = job.toDate
? intl.formatMessage(
{ id: "Deadline: {date}" },
{ date: job.toDate.locale(lang).format("Do MMMM") }
{ date: dt(job.toDate).locale(lang).format("Do MMMM") }
)
: intl.formatMessage({ id: "Open for application" })

View File

@@ -3,12 +3,8 @@ import { getJobylonFeed } from "@/lib/trpc/memoizedRequests"
import SectionContainer from "@/components/Section/Container"
import SectionHeader from "@/components/Section/Header"
import SectionLink from "@/components/Section/Link"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { getIntl } from "@/i18n"
import JobylonCard from "./JobylonCard"
import styles from "./jobylonFeed.module.css"
import JobList from "./JobList"
interface JobylonFeedProps {
title?: string
@@ -21,7 +17,6 @@ export default async function JobylonFeed({
subtitle,
link,
}: JobylonFeedProps) {
const intl = await getIntl()
const allJobs = await getJobylonFeed()
if (!allJobs) {
@@ -37,23 +32,7 @@ export default async function JobylonFeed({
headingAs="h3"
headingLevel="h2"
/>
<div className={styles.content}>
<Subtitle type="two">
{intl.formatMessage(
{
id: "{count, plural, one {{count} Result} other {{count} Results}}",
},
{ count: allJobs.length }
)}
</Subtitle>
<ul className={styles.list}>
{allJobs.map((job) => (
<li key={job.id}>
<JobylonCard job={job} />
</li>
))}
</ul>
</div>
<JobList allJobs={allJobs} />
<SectionLink link={link} variant="mobile" />
</SectionContainer>
)

View File

@@ -1,10 +0,0 @@
.list {
list-style: none;
display: grid;
gap: var(--Spacing-x2);
}
.content {
display: grid;
gap: var(--Spacing-x2);
}

View File

@@ -17,7 +17,7 @@ import styles from "./staticPage.module.css"
import type { StaticPageProps } from "./staticPage"
export default function StaticPage({
export default async function StaticPage({
content,
tracking,
pageType,

View File

@@ -122,7 +122,7 @@ export default function Select({
aria-label={item.label}
className={`${styles.listBoxItem} ${showRadioButton && styles.showRadioButton} ${optionsIcon && styles.iconLabel}`}
id={item.value}
key={item.label}
key={`${item.value}_${item.label}`}
data-testid={item.label}
>
{optionsIcon ? optionsIcon : null}

View File

@@ -34,6 +34,10 @@
"Age": "Age",
"Airport": "Airport",
"All add-ons are delivered at the same time. Changes to delivery times will affect earlier add-ons.": "All add-ons are delivered at the same time. Changes to delivery times will affect earlier add-ons.",
"All categories": "All categories",
"All countries": "All countries",
"All hotels and offices": "All hotels and offices",
"All locations": "All locations",
"All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.": "All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.",
"All-day breakfast": "All-day breakfast",
"Allergy-friendly room": "Allergy room",
@@ -121,6 +125,7 @@
"Cancellation number": "Cancellation number",
"Cancellation policy": "Cancellation policy",
"Cancelled": "Cancelled",
"Category": "Category",
"Change room": "Change room",
"Changes can be made until {time} on {date}, subject to availability. Room rates may vary.": "Changes can be made until {time} on {date}, subject to availability. Room rates may vary.",
"Check for level upgrade": "Check for level upgrade",
@@ -297,6 +302,7 @@
"Hotel": "Hotel",
"Hotel details": "Hotel details",
"Hotel facilities": "Hotel facilities",
"Hotel or office": "Hotel or office",
"Hotel reservation": "Hotel reservation",
"Hotel surroundings": "Hotel surroundings",
"Hotels": "Hotels",
@@ -361,6 +367,7 @@
"Link my accounts": "Link my accounts",
"Link your accounts": "Link your accounts",
"Location": "Location",
"Location (shown in local language)": "Location (shown in local language)",
"Location in hotel": "Location in hotel",
"Locations": "Locations",
"Log in": "Log in",

View File

@@ -2,16 +2,22 @@ import { z } from "zod"
import { dt } from "@/lib/dt"
const categoriesSchema = z.array(
z
.object({ category: z.object({ id: z.number(), text: z.string() }) })
.transform(({ category }) => {
return {
id: category.id,
text: category.text,
}
})
)
const categoriesSchema = z
.array(
z
.object({ category: z.object({ id: z.number(), text: z.string() }) })
.transform(({ category }) => {
return {
id: category.id,
text: category.text,
}
})
)
.transform((categories) =>
categories.filter(
(category): category is NonNullable<typeof category> => !!category
)
)
const departmentsSchema = z
.array(
@@ -99,6 +105,8 @@ export const jobylonItemSchema = z
const now = dt.utc()
const fromDate = from_date ? dt(from_date) : null
const toDate = to_date ? dt(to_date) : null
// Transformed to string as Dayjs objects cannot be passed to client components
const toDateAsString = toDate?.toString() ?? null
return {
id,
@@ -109,7 +117,7 @@ export const jobylonItemSchema = z
(!toDate || now.isSameOrBefore(toDate)),
categories,
departments,
toDate,
toDate: toDateAsString,
locations,
url: urls,
}

View File

@@ -0,0 +1,46 @@
import type { Key } from "react-aria-components"
import type { JobylonFilterKey } from "@/types/enums/jobylonFeed"
import type { JobylonItem } from "@/types/trpc/routers/jobylon"
export interface JobylonFilterItem {
label: string
value: Key
}
export interface JobylonFilters
extends Record<JobylonFilterKey, JobylonFilterItem[]> {
[JobylonFilterKey.country]: JobylonFilterItem[]
[JobylonFilterKey.city]: JobylonFilterItem[]
[JobylonFilterKey.department]: JobylonFilterItem[]
[JobylonFilterKey.category]: JobylonFilterItem[]
}
export enum ActionType {
UPDATE_FILTER = "UPDATE_FILTER",
}
interface UpdateFilter {
payload: {
filter: JobylonFilterKey
value: Key
}
type: ActionType.UPDATE_FILTER
}
export type Action = UpdateFilter
export interface State {
allJobs: JobylonItem[]
jobs: JobylonItem[]
chosenCountryFilter: Key
chosenCityFilter: Key
chosenDepartmentFilter: Key
chosenCategoryFilter: Key
countryFilters: JobylonFilterItem[]
cityFilters: JobylonFilterItem[]
departmentFilters: JobylonFilterItem[]
categoryFilters: JobylonFilterItem[]
}
export interface InitState extends Pick<State, "allJobs"> {}

View File

@@ -0,0 +1,6 @@
export enum JobylonFilterKey {
country = "country",
city = "city",
department = "department",
category = "category",
}