From a575f7f41d46fd9cd636d26cc79e96e4d466da04 Mon Sep 17 00:00:00 2001 From: ivannissimrch Date: Wed, 19 Aug 2026 21:29:38 -0400 Subject: [PATCH 1/2] add skills filter to the opportunity list --- public/locales/de/translations.json | 1 + public/locales/en/translations.json | 1 + .../Opportunities/Filters/FiltersContent.tsx | 12 ++++++-- .../Opportunities/Filters/constants.ts | 1 + .../Opportunities/Filters/helpers.ts | 29 +++++++++++++++++-- .../Dashboard/Opportunities/Filters/types.ts | 1 + .../Dashboard/Opportunities/Opportunities.tsx | 1 + .../Dashboard/Opportunities/helpers.ts | 14 +++++++++ 8 files changed, 55 insertions(+), 5 deletions(-) diff --git a/public/locales/de/translations.json b/public/locales/de/translations.json index 95b0c789..8b67fc42 100644 --- a/public/locales/de/translations.json +++ b/public/locales/de/translations.json @@ -381,6 +381,7 @@ "district": "Bezirk", "languages": "Sprachen", "activities": "Aktivitäten", + "skills": "Fähigkeiten", "engagement": { "header": "Engagement", "vol-active": "Aktiv", diff --git a/public/locales/en/translations.json b/public/locales/en/translations.json index 75219285..de90f0b6 100644 --- a/public/locales/en/translations.json +++ b/public/locales/en/translations.json @@ -375,6 +375,7 @@ "district": "District", "languages": "Languages", "activities": "Activities", + "skills": "Skills", "engagement": { "header": "Engagement", "vol-active": "Active", diff --git a/src/components/Dashboard/Opportunities/Filters/FiltersContent.tsx b/src/components/Dashboard/Opportunities/Filters/FiltersContent.tsx index a42dcc3c..210624a5 100644 --- a/src/components/Dashboard/Opportunities/Filters/FiltersContent.tsx +++ b/src/components/Dashboard/Opportunities/Filters/FiltersContent.tsx @@ -15,8 +15,15 @@ export default function FiltersContent({ setFilter, filter }: Props) { const { t } = useTranslation(); const { isAuthorized } = useAuth(); - const { districtFilters, languageFilters, statusFilters, typeFilters, activityFilters, availabilityFilters } = - createOpportunityFilterItems(filter, setFilter, t); + const { + districtFilters, + languageFilters, + statusFilters, + typeFilters, + activityFilters, + skillFilters, + availabilityFilters, + } = createOpportunityFilterItems(filter, setFilter, t); return ( @@ -24,6 +31,7 @@ export default function FiltersContent({ setFilter, filter }: Props) { + {isAuthorized && ( key, ); + const skillFilters = generateNestedFilterControlItems( + filter[EntityTableName.SKILL], + setFilter, + EntityTableName.SKILL, + (key) => key, + ); + const availabilityFilters = createAvailabilityFilterItems(filter[QueryParamsKeys.AVAILABILITY], setFilter, t); - return { districtFilters, languageFilters, statusFilters, typeFilters, activityFilters, availabilityFilters }; + return { + districtFilters, + languageFilters, + statusFilters, + typeFilters, + activityFilters, + skillFilters, + availabilityFilters, + }; }; /** @@ -86,8 +101,15 @@ export const createSelectedOpportunityFiltersAsFlatArray = ( setFilter: SetFilter, t: TFunction, ) => { - const { districtFilters, languageFilters, statusFilters, typeFilters, activityFilters, availabilityFilters } = - createOpportunityFilterItems(filter, setFilter, t); + const { + districtFilters, + languageFilters, + statusFilters, + typeFilters, + activityFilters, + skillFilters, + availabilityFilters, + } = createOpportunityFilterItems(filter, setFilter, t); const flatAvFilters = availabilityFilters.map((avFilter) => avFilter.items).flat(); return [ ...districtFilters, @@ -95,6 +117,7 @@ export const createSelectedOpportunityFiltersAsFlatArray = ( ...statusFilters, ...typeFilters, ...activityFilters, + ...skillFilters, ...flatAvFilters, ].filter((f) => f.checked); }; diff --git a/src/components/Dashboard/Opportunities/Filters/types.ts b/src/components/Dashboard/Opportunities/Filters/types.ts index b1c22a33..bd78a693 100644 --- a/src/components/Dashboard/Opportunities/Filters/types.ts +++ b/src/components/Dashboard/Opportunities/Filters/types.ts @@ -8,6 +8,7 @@ export interface OpportunityCardsFilter { status: SelectionMap; type: SelectionMap; [EntityTableName.ACTIVITY]: SelectionMap; + [EntityTableName.SKILL]: SelectionMap; [QueryParamsKeys.AVAILABILITY]: ScheduleFilter; } diff --git a/src/components/Dashboard/Opportunities/Opportunities.tsx b/src/components/Dashboard/Opportunities/Opportunities.tsx index 8c032598..dc76c509 100644 --- a/src/components/Dashboard/Opportunities/Opportunities.tsx +++ b/src/components/Dashboard/Opportunities/Opportunities.tsx @@ -120,6 +120,7 @@ export function Opportunities() { district: createFilterFromOption(apiFilterOptions, EntityTableName.DISTRICT), language: createFilterFromOption(apiFilterOptions, EntityTableName.LANGUAGE), activity: createFilterFromOption(apiFilterOptions, EntityTableName.ACTIVITY), + skill: createFilterFromOption(apiFilterOptions, EntityTableName.SKILL), }; return deserializeOpportunityFilters(baseFilters, searchParams); diff --git a/src/components/Dashboard/Opportunities/helpers.ts b/src/components/Dashboard/Opportunities/helpers.ts index 2d0d81a3..6935f758 100644 --- a/src/components/Dashboard/Opportunities/helpers.ts +++ b/src/components/Dashboard/Opportunities/helpers.ts @@ -104,6 +104,15 @@ export function serializeOpportunityFilters( } }); + params.delete(EntityTableName.SKILL); + Object.entries(filter.skill).forEach(([key, value]) => { + if (value === true) { + const paramValue = + (options?.serializeToIDs && options.apiFilterOptions?.skill?.find((d) => d.title === key)?.id) || key; + params.append(EntityTableName.SKILL, String(paramValue)); + } + }); + params.delete(QueryParamsKeys.AVAILABILITY); Object.entries(filter.availability).forEach(([key, subSlot]) => { const availabilityKey = key as AvailabilityKeys; @@ -150,6 +159,11 @@ export function deserializeOpportunityFilters( newFilter.activity[l] = true; }); + const querySkills = searchParams.getAll(EntityTableName.SKILL); + querySkills.forEach((s) => { + newFilter.skill[s] = true; + }); + const queryAvailability = searchParams.getAll(QueryParamsKeys.AVAILABILITY); queryAvailability.forEach((item) => { const [firstKey, secondKey] = item.split(SEPARATOR); From fccd07fc199df0a24eed0abb7c6cf4a2be967429 Mon Sep 17 00:00:00 2001 From: ivannissimrch Date: Fri, 28 Aug 2026 16:01:34 -0400 Subject: [PATCH 2/2] refactor: drive filter serialization from shared key lists --- .../Dashboard/Opportunities/helpers.ts | 109 ++++++------------ 1 file changed, 34 insertions(+), 75 deletions(-) diff --git a/src/components/Dashboard/Opportunities/helpers.ts b/src/components/Dashboard/Opportunities/helpers.ts index d9d07e52..c31061f8 100644 --- a/src/components/Dashboard/Opportunities/helpers.ts +++ b/src/components/Dashboard/Opportunities/helpers.ts @@ -29,6 +29,30 @@ export const DEFAULT_SORT_ORDER: string = SortOrder.NewToOld; export const APPOINTMENT_SORT_VALUES = ["appointment-proximal", "appointment-distant"] as const; export type AppointmentSort = (typeof APPOINTMENT_SORT_VALUES)[number]; +const ID_MAPPED_FILTER_KEYS = [ + EntityTableName.DISTRICT, + EntityTableName.LANGUAGE, + EntityTableName.ACTIVITY, + EntityTableName.SKILL, +] as const; +const PLAIN_FILTER_KEYS = [STATUS_PARAM, "type"] as const; + +function appendIdMappedFilter( + params: URLSearchParams, + filter: OpportunityCardsFilter, + name: (typeof ID_MAPPED_FILTER_KEYS)[number], + options?: SerializeFiltersOptions, +) { + params.delete(name); + Object.entries(filter[name]).forEach(([key, value]) => { + if (value === true) { + const paramValue = + (options?.serializeToIDs && options.apiFilterOptions?.[name]?.find((d) => d.title === key)?.id) || key; + params.append(name, String(paramValue)); + } + }); +} + export function isAppointmentSort(sort: string): sort is AppointmentSort { return (APPOINTMENT_SORT_VALUES as readonly string[]).includes(sort); } @@ -63,54 +87,12 @@ export function serializeOpportunityFilters( if (filter.search) params.set(QueryParamsKeys.SEARCH, filter.search); else params.delete(QueryParamsKeys.SEARCH); - params.delete(QueryParamsKeys.DISTRICT); - Object.entries(filter.district).forEach(([key, value]) => { - if (value === true) { - const paramValue = - (options?.serializeToIDs && options.apiFilterOptions?.district?.find((d) => d.title === key)?.id) || key; - params.append(QueryParamsKeys.DISTRICT, String(paramValue)); - } - }); - - params.delete(QueryParamsKeys.LANGUAGE); - Object.entries(filter.language).forEach(([key, value]) => { - if (value === true) { - const paramValue = - (options?.serializeToIDs && options.apiFilterOptions?.language?.find((d) => d.title === key)?.id) || key; - params.append(QueryParamsKeys.LANGUAGE, String(paramValue)); - } - }); - - params.delete(STATUS_PARAM); - Object.entries(filter.status).forEach(([key, value]) => { - if (value === true) { - params.append(STATUS_PARAM, key); - } - }); - - params.delete("type"); - Object.entries(filter.type).forEach(([key, value]) => { - if (value === true) { - params.append("type", key); - } - }); - - params.delete(EntityTableName.ACTIVITY); - Object.entries(filter.activity).forEach(([key, value]) => { - if (value === true) { - const paramValue = - (options?.serializeToIDs && options.apiFilterOptions?.activity?.find((d) => d.title === key)?.id) || key; - params.append(EntityTableName.ACTIVITY, String(paramValue)); - } - }); - - params.delete(EntityTableName.SKILL); - Object.entries(filter.skill).forEach(([key, value]) => { - if (value === true) { - const paramValue = - (options?.serializeToIDs && options.apiFilterOptions?.skill?.find((d) => d.title === key)?.id) || key; - params.append(EntityTableName.SKILL, String(paramValue)); - } + ID_MAPPED_FILTER_KEYS.forEach((name) => appendIdMappedFilter(params, filter, name, options)); + PLAIN_FILTER_KEYS.forEach((name) => { + params.delete(name); + Object.entries(filter[name]).forEach(([key, value]) => { + if (value === true) params.append(name, key); + }); }); params.delete(QueryParamsKeys.AVAILABILITY); @@ -135,33 +117,10 @@ export function deserializeOpportunityFilters( const search = searchParams.get(QueryParamsKeys.SEARCH); if (search !== null) newFilter.search = search; - const queryDistricts = searchParams.getAll(QueryParamsKeys.DISTRICT); - queryDistricts.forEach((d) => { - newFilter.district[d] = true; - }); - const queryLanguages = searchParams.getAll(QueryParamsKeys.LANGUAGE); - queryLanguages.forEach((l) => { - newFilter.language[l] = true; - }); - - const queryStatus = searchParams.getAll(STATUS_PARAM); - queryStatus.forEach((s) => { - newFilter.status[s] = true; - }); - - const queryType = searchParams.getAll("type"); - queryType.forEach((s) => { - newFilter.type[s] = true; - }); - - const queryActivities = searchParams.getAll(EntityTableName.ACTIVITY); - queryActivities.forEach((l) => { - newFilter.activity[l] = true; - }); - - const querySkills = searchParams.getAll(EntityTableName.SKILL); - querySkills.forEach((s) => { - newFilter.skill[s] = true; + [...ID_MAPPED_FILTER_KEYS, ...PLAIN_FILTER_KEYS].forEach((name) => { + searchParams.getAll(name).forEach((value) => { + newFilter[name][value] = true; + }); }); const queryAvailability = searchParams.getAll(QueryParamsKeys.AVAILABILITY);