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:
@@ -0,0 +1,4 @@
|
||||
.filterForm {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
.jobList {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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" })
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
.list {
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
46
apps/scandic-web/types/components/blocks/jobylonFeed.ts
Normal file
46
apps/scandic-web/types/components/blocks/jobylonFeed.ts
Normal 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"> {}
|
||||
6
apps/scandic-web/types/enums/jobylonFeed.ts
Normal file
6
apps/scandic-web/types/enums/jobylonFeed.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export enum JobylonFilterKey {
|
||||
country = "country",
|
||||
city = "city",
|
||||
department = "department",
|
||||
category = "category",
|
||||
}
|
||||
Reference in New Issue
Block a user