From e5ab82582caae815a328bdf8306edf7e32d0b203 Mon Sep 17 00:00:00 2001 From: Nadav Nir Date: Wed, 20 May 2026 12:54:03 +0200 Subject: [PATCH 1/2] fix(#544): fold translation purpose into residents speak; make all fields optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - formatLanguagesByPurpose now accepts LangPurpose | LangPurpose[] and dedupes by id - OpportunityDetailsDisplay: residents speak aggregates RECIPIENT + TRANSLATION (deduped) - OpportunityDetailsEdit: seed residents speak from RECIPIENT + TRANSLATION (deduped); on save, sent as languagesResidents → BE stores all as TRANSLATION per API contract - Save button: disabled={!isValid} only — empty fields no longer block saving - opportunityDetailsSchema: dropped description.min(1), numberOfVolunteers refine, languagesValidator required check, activities.min(1); kept description.max only Co-Authored-By: Claude Sonnet 4.6 --- .../OpportunityDetailsDisplay.tsx | 2 +- .../OpportunityDetailsEdit.tsx | 14 ++++++++--- .../sections/OpportunityDetails/formatters.ts | 18 ++++++++++--- .../opportunityDetailsSchema.ts | 25 ++++--------------- 4 files changed, 30 insertions(+), 29 deletions(-) diff --git a/src/components/Dashboard/Profile/sections/OpportunityDetails/OpportunityDetailsDisplay.tsx b/src/components/Dashboard/Profile/sections/OpportunityDetails/OpportunityDetailsDisplay.tsx index 6354a4b8..96248a39 100644 --- a/src/components/Dashboard/Profile/sections/OpportunityDetails/OpportunityDetailsDisplay.tsx +++ b/src/components/Dashboard/Profile/sections/OpportunityDetails/OpportunityDetailsDisplay.tsx @@ -23,7 +23,7 @@ export function OpportunityDetailsDisplay({ opportunity }: Props) { const isEventType = opp.volunteerType === VolunteerStateTypeType.EVENTS; const mainCommunication = formatLanguagesByPurpose(opp.languages, LangPurpose.GENERAL, t); - const residentsSpeak = formatLanguagesByPurpose(opp.languages, LangPurpose.RECIPIENT, t); + const residentsSpeak = formatLanguagesByPurpose(opp.languages, [LangPurpose.RECIPIENT, LangPurpose.TRANSLATION], t); const schedule = formatAvailability(opp.availability, t); const activities = extractOptionTitles(opp.activities, lang); const skills = extractOptionTitles(opp.skills, lang); diff --git a/src/components/Dashboard/Profile/sections/OpportunityDetails/OpportunityDetailsEdit.tsx b/src/components/Dashboard/Profile/sections/OpportunityDetails/OpportunityDetailsEdit.tsx index 4c9b2e65..42b6730a 100644 --- a/src/components/Dashboard/Profile/sections/OpportunityDetails/OpportunityDetailsEdit.tsx +++ b/src/components/Dashboard/Profile/sections/OpportunityDetails/OpportunityDetailsEdit.tsx @@ -89,14 +89,20 @@ export function OpportunityDetailsEdit({ opportunity, onCancel }: Props) { })); const generalLangs = opp.languages.filter((l) => l.purpose === LangPurpose.GENERAL); - const recipientLangs = opp.languages.filter((l) => l.purpose === LangPurpose.RECIPIENT); + const seenResidents = new Set(); + const residentsLangs = opp.languages.filter((l) => { + if (l.purpose !== LangPurpose.RECIPIENT && l.purpose !== LangPurpose.TRANSLATION) return false; + if (seenResidents.has(l.id)) return false; + seenResidents.add(l.id); + return true; + }); const schema = createOpportunityDetailsSchema(t); const { control, handleSubmit, reset, - formState: { errors, isDirty, isValid }, + formState: { errors, isValid }, } = useForm({ resolver: zodResolver(schema), mode: "onChange", @@ -104,7 +110,7 @@ export function OpportunityDetailsEdit({ opportunity, onCancel }: Props) { description: opp.description ?? "", numberOfVolunteers: String(opp.numberOfVolunteers ?? ""), mainCommunication: languagesToFormValues(generalLangs, t), - residentsSpeak: languagesToFormValues(recipientLangs, t), + residentsSpeak: languagesToFormValues(residentsLangs, t), availability: isEventType ? undefined : apiToFormAvailability(opp.availability), eventDate: null, eventTime: "", @@ -324,7 +330,7 @@ export function OpportunityDetailsEdit({ opportunity, onCancel }: Props) { onClick={handleSubmit(onSubmit)} width="auto" padding="var(--volunteer-profile-section-card-header-button-padding)" - disabled={!isDirty || !isValid} + disabled={!isValid} /> diff --git a/src/components/Dashboard/Profile/sections/OpportunityDetails/formatters.ts b/src/components/Dashboard/Profile/sections/OpportunityDetails/formatters.ts index 86f034bb..74a9b5b7 100644 --- a/src/components/Dashboard/Profile/sections/OpportunityDetails/formatters.ts +++ b/src/components/Dashboard/Profile/sections/OpportunityDetails/formatters.ts @@ -3,15 +3,25 @@ import { LanguageObject } from "@/types"; import { TFunction } from "i18next"; import { ApiLanguage, Lang, LangPurpose, OptionById } from "need4deed-sdk"; -export function formatLanguagesByPurpose(languages: ApiLanguage[], purpose: LangPurpose, t: TFunction): string { - const filtered = languages.filter((lang) => lang.purpose === purpose); +export function formatLanguagesByPurpose( + languages: ApiLanguage[], + purposes: LangPurpose | LangPurpose[], + t: TFunction, +): string { + const purposeSet = new Set(Array.isArray(purposes) ? purposes : [purposes]); + const seen = new Set(); + const filtered = languages.filter((lang) => { + if (!purposeSet.has(lang.purpose)) return false; + if (seen.has(lang.id)) return false; + seen.add(lang.id); + return true; + }); if (filtered.length === 0) return EMPTY_PLACEHOLDER_VALUE; return filtered .map((lang) => { const key = `languageNames.${lang.title.toLowerCase()}`; const translated = t(key); - const hasTranslation = translated !== key; - return hasTranslation ? translated : lang.title; + return translated !== key ? translated : lang.title; }) .join(", "); } diff --git a/src/components/Dashboard/Profile/sections/OpportunityDetails/opportunityDetailsSchema.ts b/src/components/Dashboard/Profile/sections/OpportunityDetails/opportunityDetailsSchema.ts index 9cfa388b..3a961cea 100644 --- a/src/components/Dashboard/Profile/sections/OpportunityDetails/opportunityDetailsSchema.ts +++ b/src/components/Dashboard/Profile/sections/OpportunityDetails/opportunityDetailsSchema.ts @@ -11,32 +11,17 @@ const languageObjectSchema = z.object({ level: z.union([z.nativeEnum(LanguageLevel), z.literal("")]), }); -const languagesValidator = (t: (key: string) => string) => - z.array(languageObjectSchema).superRefine((languages, ctx) => { - const hasCompleteRow = languages.some((lang) => lang.language !== ""); - if (!hasCompleteRow) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: t(`${i18nPrefix}.languageRequired`), - }); - } - }); export const createOpportunityDetailsSchema = (t: (key: string) => string) => z.object({ - description: z - .string() - .min(1, t(`${i18nPrefix}.descriptionRequired`)) - .max(MAX_DESCRIPTION_LENGTH, t(`${i18nPrefix}.descriptionTooLong`)), - numberOfVolunteers: z.string().refine((val) => val !== "" && val !== "0", { - message: t(`${i18nPrefix}.numberOfVolunteersRequired`), - }), - mainCommunication: languagesValidator(t), - residentsSpeak: languagesValidator(t), + description: z.string().max(MAX_DESCRIPTION_LENGTH, t(`${i18nPrefix}.descriptionTooLong`)), + numberOfVolunteers: z.string(), + mainCommunication: z.array(languageObjectSchema), + residentsSpeak: z.array(languageObjectSchema), availability: z.custom().nullable().optional(), eventDate: z.date().nullable().optional(), eventTime: z.string().optional(), - activities: z.array(z.string()).min(1, t(`${i18nPrefix}.activitiesRequired`)), + activities: z.array(z.string()), skills: z.array(z.string()), }); From 78fd0434878fd85e92941e62e6d7ca4660892419 Mon Sep 17 00:00:00 2001 From: Nadav Nir Date: Wed, 20 May 2026 13:00:56 +0200 Subject: [PATCH 2/2] fix: handle undefined lang.purpose in formatLanguagesByPurpose to pass typecheck Co-Authored-By: Claude Sonnet 4.6 --- .../Dashboard/Profile/sections/OpportunityDetails/formatters.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Dashboard/Profile/sections/OpportunityDetails/formatters.ts b/src/components/Dashboard/Profile/sections/OpportunityDetails/formatters.ts index 74a9b5b7..9e1fdf00 100644 --- a/src/components/Dashboard/Profile/sections/OpportunityDetails/formatters.ts +++ b/src/components/Dashboard/Profile/sections/OpportunityDetails/formatters.ts @@ -11,7 +11,7 @@ export function formatLanguagesByPurpose( const purposeSet = new Set(Array.isArray(purposes) ? purposes : [purposes]); const seen = new Set(); const filtered = languages.filter((lang) => { - if (!purposeSet.has(lang.purpose)) return false; + if (!lang.purpose || !purposeSet.has(lang.purpose)) return false; if (seen.has(lang.id)) return false; seen.add(lang.id); return true;