From 0be46e16cb80d71c4e957d0622e07a7a254c2afa Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Fri, 11 Sep 2026 10:02:11 +0530 Subject: [PATCH 01/22] Update text on system admin UI --- .../src/pages/badge-detail/ManualAwardTab/ManualAwardTab.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/apps/admin/src/platform/gamification-admin/src/pages/badge-detail/ManualAwardTab/ManualAwardTab.tsx b/src/apps/admin/src/platform/gamification-admin/src/pages/badge-detail/ManualAwardTab/ManualAwardTab.tsx index b2dd7b27a..9242953a4 100644 --- a/src/apps/admin/src/platform/gamification-admin/src/pages/badge-detail/ManualAwardTab/ManualAwardTab.tsx +++ b/src/apps/admin/src/platform/gamification-admin/src/pages/badge-detail/ManualAwardTab/ManualAwardTab.tsx @@ -65,10 +65,6 @@ const ManualAwardTab: FC = (props: ManualAwardTabProps) =>

Manual Award

-

- Lorem ipsum dolor sit amet, consectetur adipiscing elit. - Neque ullamcorper neque sed orci, enim amet, sed. -

Date: Tue, 15 Sep 2026 13:49:53 +1000 Subject: [PATCH 02/22] fix(PM-6323): apply component navigation and dropdown colors --- .../DropdownMenu/DropdownMenu.module.scss | 6 +++++- src/apps/work/README.md | 8 +++++++ .../components/NavTabs/NavTabs.module.scss | 9 ++++++-- .../ProjectListTabs.module.scss | 21 +++++++------------ 4 files changed, 27 insertions(+), 17 deletions(-) diff --git a/src/apps/admin/src/lib/components/common/DropdownMenu/DropdownMenu.module.scss b/src/apps/admin/src/lib/components/common/DropdownMenu/DropdownMenu.module.scss index 548a0d707..76e310d60 100644 --- a/src/apps/admin/src/lib/components/common/DropdownMenu/DropdownMenu.module.scss +++ b/src/apps/admin/src/lib/components/common/DropdownMenu/DropdownMenu.module.scss @@ -22,8 +22,12 @@ color: $black-100; padding: $sp-2 $sp-4; + a { + color: inherit; + } + &:hover:not(.disabled) { - background: $turq-160; + background: var(--tc-2026-teal, #{$turq-160}); color: $tc-white; cursor: pointer; } diff --git a/src/apps/work/README.md b/src/apps/work/README.md index 71fa4afca..f04ba6d99 100644 --- a/src/apps/work/README.md +++ b/src/apps/work/README.md @@ -20,6 +20,14 @@ The Work app provides work management capabilities for: `config/routes.config.ts` contains route ids and the `rootRoute` resolver based on the active subdomain. +## Navigation styles + +The Work subnavigation uses regular black text and a bold active item on desktop +and mobile. Project section tabs use black text with a bold teal active label and +underline. These component styles override the shared theme's general link color. +System Admin dropdown links similarly inherit their menu row color: black by +default and white on a teal hover background. + ## Providers `WorkApp.tsx` composes these providers: diff --git a/src/apps/work/src/lib/components/NavTabs/NavTabs.module.scss b/src/apps/work/src/lib/components/NavTabs/NavTabs.module.scss index 71c250cdc..03f9ca62b 100644 --- a/src/apps/work/src/lib/components/NavTabs/NavTabs.module.scss +++ b/src/apps/work/src/lib/components/NavTabs/NavTabs.module.scss @@ -73,9 +73,15 @@ li { font-family: 'Nunito Sans', sans-serif; + font-weight: 400; margin-left: $sp-8; color: var(--FontColor); + .tabLink { + color: inherit; + font-weight: inherit; + } + &.active { font-weight: 700; } @@ -112,8 +118,7 @@ width: 100%; &.active { - background-color: var(--Actived); - color: var(--invertButtonColor); + color: var(--FontColor); } } } diff --git a/src/apps/work/src/lib/components/ProjectListTabs/ProjectListTabs.module.scss b/src/apps/work/src/lib/components/ProjectListTabs/ProjectListTabs.module.scss index f1fc91a77..0003a4f33 100644 --- a/src/apps/work/src/lib/components/ProjectListTabs/ProjectListTabs.module.scss +++ b/src/apps/work/src/lib/components/ProjectListTabs/ProjectListTabs.module.scss @@ -9,25 +9,18 @@ border-bottom: 1px solid #d5d5d5; } -.tabLink { +.container .tabLink { display: inline-block; padding: 10px 0; border-bottom: 2px solid transparent; - color: $black-60; + color: $black-100; font-size: 14px; - font-weight: 500; + font-weight: 400; text-decoration: none; -} - -.tabLink:hover { - color: $black-60; -} - -.active { - color: #2a62d5; - border-bottom-color: #2a62d5; - &:hover { - color: #2a62d5; + &.active { + color: var(--tc-2026-teal, #00797a); + border-bottom-color: var(--tc-2026-teal, #00797a); + font-weight: 700; } } From e0bab7e8480c5acf8d18d4adccb68ae6b2c3a199 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 15 Sep 2026 13:55:09 +1000 Subject: [PATCH 03/22] fix(PM-6324): scroll submission actions to the upload form --- src/apps/opportunities/README.md | 6 +++++- .../pages/ChallengeDetailsPage.flows.spec.tsx | 14 ++++++++++++++ .../src/pages/ChallengeDetailsPage.module.scss | 1 + .../src/pages/ChallengeDetailsPage.tsx | 18 +++++++++++++++++- 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/apps/opportunities/README.md b/src/apps/opportunities/README.md index 1dfa05d2e..c888efedc 100644 --- a/src/apps/opportunities/README.md +++ b/src/apps/opportunities/README.md @@ -443,7 +443,11 @@ An empty Winners tab reflects the challenge lifecycle: cancelled challenges state that no winners were selected, drafts explain that judging has not run, and active challenges retain the ongoing-review guidance. -Registered members submit without leaving challenge details. My Submissions +Registered members submit without leaving challenge details. The prize card's +Submit a solution action revalidates registration, opens My Submissions, and +scrolls the rendered upload panel below the fixed navigation. The scroll is +instant when reduced motion is preferred; repeated clicks return to the open +form. Selecting ordinary tabs does not trigger this upload scroll. My Submissions also exposes the environment-specific Review App handoff before and after an upload. File-backed rows expose the authorized download action for every challenge track, while authored external-URL rows omit that file action. The diff --git a/src/apps/opportunities/src/pages/ChallengeDetailsPage.flows.spec.tsx b/src/apps/opportunities/src/pages/ChallengeDetailsPage.flows.spec.tsx index c970c97cf..6c31f888c 100644 --- a/src/apps/opportunities/src/pages/ChallengeDetailsPage.flows.spec.tsx +++ b/src/apps/opportunities/src/pages/ChallengeDetailsPage.flows.spec.tsx @@ -1124,6 +1124,10 @@ describe('ChallengeDetailsPage member flows', () => { mockRegistration = { id: 'resource-id' } renderPage() + const panel = screen.getByRole('tabpanel') + const scrollIntoView = jest.fn() + panel.scrollIntoView = scrollIntoView + expect(scrollIntoView).not.toHaveBeenCalled() fireEvent.click(screen.getByRole('button', { name: 'Submit a solution' })) await waitFor(() => expect(screen.getByRole('tab', { name: 'My Submissions' })) @@ -1132,9 +1136,19 @@ describe('ChallengeDetailsPage member flows', () => { .toHaveBeenCalledTimes(1) expect(screen.getByText('Submission upload form')) .toBeInTheDocument() + expect(scrollIntoView) + .toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' }) + expect(panel) + .toHaveAttribute('id', 'challenge-panel-mine') + + fireEvent.click(screen.getByRole('button', { name: 'Submit a solution' })) + await waitFor(() => expect(scrollIntoView) + .toHaveBeenCalledTimes(2)) fireEvent.click(screen.getByRole('button', { name: 'Back to submissions' })) expect(screen.getByText('You have no submissions yet')) .toBeInTheDocument() + expect(scrollIntoView) + .toHaveBeenCalledTimes(2) }) it('keeps the upload mounted and challenge tabs locked until an active upload finishes', async () => { diff --git a/src/apps/opportunities/src/pages/ChallengeDetailsPage.module.scss b/src/apps/opportunities/src/pages/ChallengeDetailsPage.module.scss index d81af22c2..761bcbffb 100644 --- a/src/apps/opportunities/src/pages/ChallengeDetailsPage.module.scss +++ b/src/apps/opportunities/src/pages/ChallengeDetailsPage.module.scss @@ -110,6 +110,7 @@ .mainContent { min-width: 0; + scroll-margin-top: 80px; } .requirements { diff --git a/src/apps/opportunities/src/pages/ChallengeDetailsPage.tsx b/src/apps/opportunities/src/pages/ChallengeDetailsPage.tsx index 71bbf0040..e841da733 100644 --- a/src/apps/opportunities/src/pages/ChallengeDetailsPage.tsx +++ b/src/apps/opportunities/src/pages/ChallengeDetailsPage.tsx @@ -7,6 +7,7 @@ import { SyntheticEvent, useEffect, useMemo, + useRef, useState, } from 'react' import { @@ -356,6 +357,8 @@ export const ChallengeDetailsPage: FC = () => { const [registrationBusy, setRegistrationBusy] = useState(false) const [registrantsRevision, setRegistrantsRevision] = useState(0) const [submissionFlowOpen, setSubmissionFlowOpen] = useState(false) + const [submissionScrollRequest, setSubmissionScrollRequest] = useState(0) + const submissionPanelRef = useRef(null) const [submissionUploadBusy, setSubmissionUploadBusy] = useState(false) const [unregisterConfirmOpen, setUnregisterConfirmOpen] = useState(false) const [visibleTerms, setVisibleTerms] = useState([]) @@ -421,6 +424,7 @@ export const ChallengeDetailsPage: FC = () => { setIssueOpen(false) setRegistrationBusy(false) setSubmissionFlowOpen(false) + setSubmissionScrollRequest(0) setSubmissionUploadBusy(false) setTermsMode('view') setTermsOpen(false) @@ -482,6 +486,15 @@ export const ChallengeDetailsPage: FC = () => { || registrationAccessIsLoading || memberResourceAccessIsLoading + useEffect(() => { + if (!submissionScrollRequest || !submissionFlowOpen || activeTab !== 'mine') return + + submissionPanelRef.current?.scrollIntoView?.({ + behavior: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth', + block: 'start', + }) + }, [activeTab, submissionFlowOpen, submissionScrollRequest]) + useEffect(() => { if (!challenge || !searchParams.has('tab') || tabAccessIsLoading || requestedTabIsVisible) return @@ -554,7 +567,8 @@ export const ChallengeDetailsPage: FC = () => { /** * Opens the Figma submission flow under the member's revalidated My Submissions tab - * only while the challenge accepts submissions. + * only while the challenge accepts submissions, then scrolls the rendered upload + * panel into view. Repeated clicks also return to the open upload panel. * * @returns promise settled after selecting the tab, restoring registration state, * or redirecting an anonymous member to sign in. @@ -570,6 +584,7 @@ export const ChallengeDetailsPage: FC = () => { if (!await validateSubmissionRegistration()) return selectTab('mine') setSubmissionFlowOpen(true) + setSubmissionScrollRequest(request => request + 1) } /** @@ -817,6 +832,7 @@ export const ChallengeDetailsPage: FC = () => { aria-labelledby={`challenge-tab-${activeTab}`} className={styles.mainContent} id={`challenge-panel-${activeTab}`} + ref={submissionPanelRef} role='tabpanel' > Date: Tue, 15 Sep 2026 13:55:10 +1000 Subject: [PATCH 04/22] fix(PM-6231): persist pending final deliverable file types --- .../src/lib/models/ChallengeEditor.model.ts | 2 + .../lib/utils/challenge-editor.utils.spec.ts | 25 ++++++ .../src/lib/utils/challenge-editor.utils.ts | 4 +- .../src/lib/utils/final-deliverables.utils.ts | 90 +++++++++++++++++++ .../challenges/ChallengeEditorPage/README.md | 2 +- .../components/ChallengeEditorForm.tsx | 5 +- .../FinalDeliverablesField.spec.tsx | 41 +++++++++ .../FinalDeliverablesField.tsx | 83 ++++------------- 8 files changed, 181 insertions(+), 71 deletions(-) create mode 100644 src/apps/work/src/lib/utils/final-deliverables.utils.ts diff --git a/src/apps/work/src/lib/models/ChallengeEditor.model.ts b/src/apps/work/src/lib/models/ChallengeEditor.model.ts index 3b39d0c5a..1652ab78f 100644 --- a/src/apps/work/src/lib/models/ChallengeEditor.model.ts +++ b/src/apps/work/src/lib/models/ChallengeEditor.model.ts @@ -37,6 +37,8 @@ export interface ChallengeEditorFormData extends Omit, 'id' | copilot?: string description: string discussionForum?: boolean + /** Pending file type included in final-deliverables metadata when creating or saving. */ + finalDeliverable?: string funChallenge?: boolean wiproAllowed?: boolean groups?: string[] diff --git a/src/apps/work/src/lib/utils/challenge-editor.utils.spec.ts b/src/apps/work/src/lib/utils/challenge-editor.utils.spec.ts index 9f3ce23ae..bd646fd94 100644 --- a/src/apps/work/src/lib/utils/challenge-editor.utils.spec.ts +++ b/src/apps/work/src/lib/utils/challenge-editor.utils.spec.ts @@ -24,6 +24,31 @@ jest.mock('~/config', () => ({ }), }), { virtual: true }) +describe('final deliverables save mapping', () => { + it.each<[string, string[]]>([ + [' PDF ', ['PNG', 'PDF']], + [' png ', ['PNG']], + [' ', ['PNG']], + ])('includes pending input %j without duplicating or losing existing metadata', (finalDeliverable, expected) => { + const formData = transformChallengeToFormData({ + metadata: [ + { name: 'fileTypes', value: '["PNG"]' }, + { name: 'allowStockArt', value: 'true' }, + ], + }) + const saved = transformFormDataToChallenge({ ...formData, finalDeliverable }) + + expect(saved.metadata) + .toEqual(expect.arrayContaining([ + { name: 'fileTypes', value: JSON.stringify(expected) }, + { name: 'allowStockArt', value: 'true' }, + ])) + expect(saved).not.toHaveProperty('finalDeliverable') + expect(transformChallengeToFormData(saved).finalDeliverable) + .toBe('') + }) +}) + describe('formatLastSaved', () => { it('reports when a challenge has not been saved', () => { expect(formatLastSaved()) diff --git a/src/apps/work/src/lib/utils/challenge-editor.utils.ts b/src/apps/work/src/lib/utils/challenge-editor.utils.ts index 6c5118310..ea533975d 100644 --- a/src/apps/work/src/lib/utils/challenge-editor.utils.ts +++ b/src/apps/work/src/lib/utils/challenge-editor.utils.ts @@ -36,6 +36,7 @@ import { metadataToBoolean, metadataToGiteaTeams, } from './metadata.utils' +import { commitPendingFinalDeliverable } from './final-deliverables.utils' interface BillingInfo { billingAccountId?: number | string @@ -1031,6 +1032,7 @@ export function transformChallengeToFormData( createdBy: normalizeOptionalString(challenge?.createdBy), description, discussionForum: normalizeOptionalBoolean(challenge?.discussionForum), + finalDeliverable: '', funChallenge: normalizeOptionalBoolean(challenge?.funChallenge) || false, giteaTeams: metadataToGiteaTeams(metadata, GITEA_METADATA_FIELD), groups: normalizeStringArray(challenge?.groups), @@ -1087,7 +1089,7 @@ export function transformFormDataToChallenge( && formData.phases.length > 0 const isSchedulingEnabled = isSchedulingApiEnabled(formData.legacy?.useSchedulingAPI) || hasEditableSchedule - const metadataWithoutMilestone = normalizeMetadataEntries(formData.metadata) + const metadataWithoutMilestone = normalizeMetadataEntries(commitPendingFinalDeliverable(formData).metadata) .filter(metadataEntry => !MILESTONE_METADATA_KEYS.includes(metadataEntry.name)) const metadataWithTestChallenge = booleanToMetadata( metadataWithoutMilestone, diff --git a/src/apps/work/src/lib/utils/final-deliverables.utils.ts b/src/apps/work/src/lib/utils/final-deliverables.utils.ts new file mode 100644 index 000000000..dbc1faf3d --- /dev/null +++ b/src/apps/work/src/lib/utils/final-deliverables.utils.ts @@ -0,0 +1,90 @@ +import { ChallengeEditorFormData } from '../models' + +import { getMetadataValue, setMetadataValue } from './metadata.utils' + +export const FILE_TYPES_METADATA_NAME = 'fileTypes' + +/** + * Normalizes a single final-deliverable label coming from either user input or saved metadata. + * + * @param value raw file-type value from the input or metadata payload. + * @returns the trimmed file-type label, or `undefined` when the value is empty. + * @throws Does not throw. + */ +export function normalizeFileTypeValue(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined + } + + const normalizedValue = value.trim() + + return normalizedValue || undefined +} + +/** + * Parses the persisted `fileTypes` challenge metadata into a unique, display-ready list. + * + * @param value serialized challenge metadata entry for final deliverables. + * @returns normalized file-type labels in the saved order, excluding empty or duplicate values. + * @throws Does not throw; invalid JSON produces an empty list. + */ +export function parseFileTypesMetadata(value: string | undefined): string[] { + if (!value) { + return [] + } + + try { + const parsedValue = JSON.parse(value) as unknown + + if (!Array.isArray(parsedValue)) { + return [] + } + + const addedFileTypes = new Set() + + return parsedValue + .map(item => normalizeFileTypeValue(item)) + .filter((item): item is string => { + if (!item) { + return false + } + + const normalizedKey = item.toLowerCase() + + if (addedFileTypes.has(normalizedKey)) { + return false + } + + addedFileTypes.add(normalizedKey) + + return true + }) + } catch { + return [] + } +} + +/** + * Includes a pending final-deliverable input in create and save payloads. + * + * @param formData editor values, including the unfinished file-type input. + * @returns form values with a unique, trimmed file type in metadata and a cleared input. + * @throws Does not throw; malformed saved metadata is treated as an empty list. + */ +export function commitPendingFinalDeliverable(formData: ChallengeEditorFormData): ChallengeEditorFormData { + const value = normalizeFileTypeValue(formData.finalDeliverable) + if (!value) return formData + + const fileTypes = parseFileTypesMetadata(getMetadataValue(formData.metadata, FILE_TYPES_METADATA_NAME)) + const isDuplicate = fileTypes.some(fileType => fileType.toLowerCase() === value.toLowerCase()) + + return { + ...formData, + finalDeliverable: '', + metadata: isDuplicate ? formData.metadata : setMetadataValue( + formData.metadata, + FILE_TYPES_METADATA_NAME, + JSON.stringify([...fileTypes, value]), + ), + } +} diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md index 7ce237844..e0a58d3ad 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md @@ -92,7 +92,7 @@ The form uses `challengeBasicInfoSchema` from `src/apps/work/src/lib/schemas/cha - `ReviewersField`: hidden for `Task` and `Marathon Match` challenges because manual reviewer assignment is handled elsewhere. The simplified Design Challenge review section fetches defaults for the selected timeline template, resolves the API's phase-name-only defaults against the challenge phases, then repairs missing, duplicate, and stale hidden reviewer rows while exposing the Screening and Checkpoint Screening member selectors. Checkpoint Review, Review, and Approval are private and automatically assigned to the selected copilot during save; Design Challenge creation and saving highlight the Copilot field when no copilot is selected. On the full human-review tab, each manual reviewer card keeps the legacy review-type dropdown, backfills missing legacy review-type values from the matching default reviewer or iterative-review phase fallback, and each manual reviewer phase selector hides registration/submission phases and any phase already assigned on another manual reviewer card while preserving the card's current selection. When default reviewer metadata is missing, stale, or already covered by existing rows, `Add reviewer` starts from the next unassigned selectable reviewer phase, preferring review phases before approval or screening phases, so single-round Design schedules add the Approver row instead of a registration/submission or duplicate reviewer row. Manual reviewer counts are capped before rendering member assignment controls so closed public opportunities cannot create an unbounded number of member selectors. The full Design reviewer editor keeps the public review opportunity checkbox disabled and unchecked. Screening and Checkpoint Screening member selectors remain available but are optional so a copilot can assign the Screener or Checkpoint Screener after launch. For Design `Challenge` challenges the advanced view also drops the required marker from the Checkpoint Review, Review, and Approval member selectors, because those private phases are assigned to the selected copilot during save. - `Submission Settings`: shown for Design `Challenge` and Design `First2Finish` types, and contains the final-deliverables, stock-art, and submission-limit compatibility fields. - `RegisteredMemberDownloadField`: shown in Advanced Options for every created challenge type. The radio group persists `allowAllRegistrantsToDownloadWinningSubmissions` as the exact string `true` for all challenge registrants or `false` for passing submitters only. New Development challenges default to passing submitters; other new challenges, including Design, default to all registrants. Existing challenges without the metadata retain passing-submitter-only access. -- `FinalDeliverablesField`: design-challenge file-type editor that persists the legacy `fileTypes` metadata payload used on challenge draft pages. +- `FinalDeliverablesField`: design-challenge file-type editor that persists the legacy `fileTypes` metadata payload used on challenge draft pages. Add File Type and Enter append a trimmed, unique file type. The pending input also belongs to the form: create, draft save, autosave, and launch include it in metadata without requiring an Add click, and a successful manual save restores the saved list with an empty input. - `MaximumSubmissionsField`: submission-limit editor with `Unlimited` (the default) and `Limited` modes. Limited mode reveals a numeric count field, and both modes persist the legacy `submissionLimit` JSON metadata contract consumed by challenge and review applications. The selection and count are display-only fields, so they are re-seeded from the persisted metadata on every render; that keeps the saved limit visible after the challenge loads and after a draft save resets the form. Existing limited values are restored without being overwritten, including when a draft-save response omits submission-limit metadata, while missing or malformed metadata is normalized to unlimited after initial resource hydration so copilot restoration completes before autosave/manual-save treats the default as a user change. Once the challenge has at least one contest or checkpoint submission the mode and count become read-only, because review scorecards are created from the limit that applied when members submitted. Limited mode requires a count of at least 1: the challenge editor schema validates the persisted `submissionLimit` metadata and reports a missing count on the visible `Limit count` field, so saving, autosaving, and launching are blocked until the count is entered. The rule is skipped when the limit is not configurable, which keeps non-Design challenges and challenges that already have submissions saveable. - `ChallengeDescriptionField`: public markdown spec editor with a `Copy spec` action that copies the current Markdown in both edit and read-only view modes. diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx index f5811fd10..fb011c8c7 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx @@ -92,6 +92,7 @@ import { getMetadataValue, setMetadataValue, } from '../../../../lib/utils/metadata.utils' +import { commitPendingFinalDeliverable } from '../../../../lib/utils/final-deliverables.utils' import { isReviewerAssignmentOptional } from '../../../../lib/utils/reviewer.utils' import { getProjectBillingAccountChallengeErrorMessage, @@ -3359,7 +3360,7 @@ export const ChallengeEditorForm: FC = ( setSaveValidationError(undefined) try { - const formData = getValues() + const formData = commitPendingFinalDeliverable(getValues()) const resolvedProjectBillingAccount = await resolveProjectBillingAccount() const selectedRoundType = getCreateRoundType(formData.roundType, formElementRef.current) const createProjectId = resolveRequiredCreateProjectId(formData.projectId, fallbackProjectId) @@ -3653,7 +3654,7 @@ export const ChallengeEditorForm: FC = ( } const formDataWithProjectBilling = applyProjectBillingToChallengeFormData( - formDataToSave, + commitPendingFinalDeliverable(formDataToSave), resolvedProjectBillingAccount, ) const payload = transformFormDataToChallenge({ diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/FinalDeliverablesField/FinalDeliverablesField.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/FinalDeliverablesField/FinalDeliverablesField.spec.tsx index b40f90258..52ecd4951 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/FinalDeliverablesField/FinalDeliverablesField.spec.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/FinalDeliverablesField/FinalDeliverablesField.spec.tsx @@ -5,6 +5,7 @@ import { } from 'react' import { render, + RenderResult, screen, } from '@testing-library/react' import '@testing-library/jest-dom' @@ -16,6 +17,7 @@ import { } from 'react-hook-form' import { ChallengeEditorFormData } from '../../../../../lib/models' +import { commitPendingFinalDeliverable } from '../../../../../lib/utils/final-deliverables.utils' import { FinalDeliverablesField } from './FinalDeliverablesField' @@ -51,6 +53,7 @@ interface TestHarnessProps { name: string value: unknown }> + onSave?: (formData: ChallengeEditorFormData) => void } const MetadataWatcher: FC = () => { @@ -74,15 +77,53 @@ const TestHarness: FC = (props: TestHarnessProps) => { }, }) + /** Submits the editor values using the create/save metadata normalization. */ + function handleSave(): void { + props.onSave?.(commitPendingFinalDeliverable(formMethods.getValues())) + } + return ( + {props.onSave && ( + + )} ) } describe('FinalDeliverablesField', () => { + it('saves a pending file type when Save is clicked and restores it from saved metadata', async () => { + const user = userEvent.setup() + const onSave = jest.fn() + const { unmount }: RenderResult = render( + , + ) + + await user.type(screen.getByRole('textbox'), ' PNG ') + await user.click(screen.getByRole('button', { name: 'Save challenge' })) + + expect(onSave) + .toHaveBeenCalledWith(expect.objectContaining({ + metadata: [ + { name: 'allowStockArt', value: 'true' }, + { name: 'fileTypes', value: '["PNG"]' }, + ], + })) + unmount() + render() + expect(screen.getByText('PNG')) + .toBeInTheDocument() + expect(screen.getByRole('textbox')) + .toHaveValue('') + }) + it('adds unique file types to the saved metadata payload', async () => { const user = userEvent.setup() diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/FinalDeliverablesField/FinalDeliverablesField.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/FinalDeliverablesField/FinalDeliverablesField.tsx index 28f482791..5289905a3 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/FinalDeliverablesField/FinalDeliverablesField.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/FinalDeliverablesField/FinalDeliverablesField.tsx @@ -5,7 +5,6 @@ import { MouseEvent, useCallback, useMemo, - useState, } from 'react' import { useFormContext, @@ -23,75 +22,21 @@ import { getMetadataValue, setMetadataValue, } from '../../../../../lib/utils/metadata.utils' +import { + FILE_TYPES_METADATA_NAME, + normalizeFileTypeValue, + parseFileTypesMetadata, +} from '../../../../../lib/utils/final-deliverables.utils' import styles from './FinalDeliverablesField.module.scss' -const FILE_TYPES_METADATA_NAME = 'fileTypes' - -/** - * Normalizes a single final-deliverable label coming from either user input or saved metadata. - * - * @param value raw file-type value from the input or metadata payload. - * @returns the trimmed file-type label, or `undefined` when the value is empty. - */ -function normalizeFileTypeValue(value: unknown): string | undefined { - if (typeof value !== 'string') { - return undefined - } - - const normalizedValue = value.trim() - - return normalizedValue || undefined -} - -/** - * Parses the persisted `fileTypes` challenge metadata into a unique, display-ready list. - * - * @param value serialized challenge metadata entry for final deliverables. - * @returns normalized file-type labels in the saved order, excluding empty or duplicate values. - */ -function parseFileTypesMetadata(value: string | undefined): string[] { - if (!value) { - return [] - } - - try { - const parsedValue = JSON.parse(value) as unknown - - if (!Array.isArray(parsedValue)) { - return [] - } - - const addedFileTypes = new Set() - - return parsedValue - .map(item => normalizeFileTypeValue(item)) - .filter((item): item is string => { - if (!item) { - return false - } - - const normalizedKey = item.toLowerCase() - - if (addedFileTypes.has(normalizedKey)) { - return false - } - - addedFileTypes.add(normalizedKey) - - return true - }) - } catch { - return [] - } -} - /** * Edits the design challenge final-deliverables metadata stored under `fileTypes`. * * The field mirrors the legacy work-manager behavior used on design challenge drafts: * users can add or remove deliverable file types, and the component persists them as the - * JSON-serialized `fileTypes` metadata entry on the challenge form. + * JSON-serialized `fileTypes` metadata entry on the challenge form. The pending input + * also belongs to the form so create and save actions can include it without an Add click. * * @returns the final-deliverables editor UI. */ @@ -102,7 +47,10 @@ export const FinalDeliverablesField: FC = () => { control: dynamicFormControl, name: 'metadata', }) as ChallengeMetadata[] | undefined - const [newFileType, setNewFileType] = useState('') + const newFileType = useWatch({ + control: dynamicFormControl, + name: 'finalDeliverable', + }) as string | undefined const fileTypes = useMemo( () => parseFileTypesMetadata(getMetadataValue(metadata, FILE_TYPES_METADATA_NAME)), @@ -129,8 +77,8 @@ export const FinalDeliverablesField: FC = () => { }, [formContext, metadata]) const handleInputChange = useCallback((event: ChangeEvent): void => { - setNewFileType(event.target.value) - }, []) + formContext.setValue('finalDeliverable', event.target.value, { shouldDirty: true }) + }, [formContext]) const handleAddFileType = useCallback((): void => { if (!normalizedNewFileType || isDuplicateValue) { @@ -141,9 +89,10 @@ export const FinalDeliverablesField: FC = () => { ...fileTypes, normalizedNewFileType, ]) - setNewFileType('') + formContext.setValue('finalDeliverable', '', { shouldDirty: true }) }, [ fileTypes, + formContext, isDuplicateValue, normalizedNewFileType, updateFileTypes, @@ -213,7 +162,7 @@ export const FinalDeliverablesField: FC = () => { onKeyDown={handleInputKeyDown} placeholder='Add final deliverable file type' type='text' - value={newFileType} + value={newFileType || ''} /> + ), + LoadingSpinner: (): JSX.Element =>
, + useConfirmationModal: () => ({}), +}), { virtual: true }) +jest.mock('../../../lib/contexts', () => { + const React: typeof import('react') = jest.requireActual('react') + return { WorkAppContext: React.createContext({ isAdmin: true, loginUserInfo: { userId: 42 }, userRoles: [] }) } +}) +jest.mock('../../../lib/components', () => ({ + ErrorMessage: (props: any): JSX.Element =>
{props.message}
, + Pagination: (): JSX.Element =>
, + ProjectPageWrapper: (props: any): JSX.Element => ( +
+ {props.headerActions} + {props.children} +
+ ), + ProjectsShowcaseFilter: (): JSX.Element =>
, + ShowcasePostPreview: (): JSX.Element =>
, +})) +jest.mock('../../../lib/components/form', () => { + const forms: typeof import('react-hook-form') = jest.requireActual('react-hook-form') + return { + FormCheckboxField: (props: any): JSX.Element => { + const form = forms.useFormContext() + return + }, + FormMarkdownEditor: (props: any): JSX.Element => { + const form = forms.useFormContext() + return