diff --git a/api/app/Enums/DeadlineStatus.php b/api/app/Enums/DeadlineStatus.php new file mode 100644 index 00000000000..5aae774b646 --- /dev/null +++ b/api/app/Enums/DeadlineStatus.php @@ -0,0 +1,18 @@ +where(function ($query) { $query->whereDate('registration_deadline', '>=', date('Y-m-d')) ->orWhereNull('registration_deadline'); @@ -70,4 +73,19 @@ public static function scopeOpportunityLanguage(Builder $query, ?string $languag return $query; } + + /** + * Get the registration deadline status with respect to the current date + */ + protected function registrationDeadlineStatus(): Attribute + { + /** @disregard P1003 Not using value parameter */ + return Attribute::make( + // this should match the logic in scopeHidePassedRegistrationDeadline + get: fn (mixed $value, array $attributes) => $attributes['registration_deadline'] >= date('Y-m-d') || is_null($attributes['registration_deadline']) + ? DeadlineStatus::PUBLISHED->name + : DeadlineStatus::EXPIRED->name + + ); + } } diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql index fea83b4f668..3d2925b5af5 100644 --- a/api/graphql/schema.graphql +++ b/api/graphql/schema.graphql @@ -918,6 +918,7 @@ type TrainingOpportunity { title: LocalizedString courseLanguage: LocalizedCourseLanguage @rename(attribute: "course_language") registrationDeadline: Date @rename(attribute: "registration_deadline") + registrationDeadlineStatus: LocalizedDeadlineStatus trainingStart: Date @rename(attribute: "training_start") trainingEnd: Date @rename(attribute: "training_end") description: LocalizedString @@ -1094,6 +1095,7 @@ type Query { trainingOpportunity(id: UUID! @eq): TrainingOpportunity @find trainingOpportunitiesPaginated( where: TrainingOpportunitiesFilterInput + orderBy: [OrderByClause!] @orderBy ): [TrainingOpportunity] @paginate( defaultCount: 10 diff --git a/api/lang/en/deadline_status.php b/api/lang/en/deadline_status.php new file mode 100644 index 00000000000..dd4109f7cdf --- /dev/null +++ b/api/lang/en/deadline_status.php @@ -0,0 +1,6 @@ + 'Published', + 'expired' => 'Expired', +]; diff --git a/api/lang/fr/deadline_status.php b/api/lang/fr/deadline_status.php new file mode 100644 index 00000000000..675967ac020 --- /dev/null +++ b/api/lang/fr/deadline_status.php @@ -0,0 +1,6 @@ + 'Publié', + 'expired' => 'Expiré', +]; diff --git a/api/storage/app/lighthouse-schema.graphql b/api/storage/app/lighthouse-schema.graphql index f765538c172..445a8a1ab69 100755 --- a/api/storage/app/lighthouse-schema.graphql +++ b/api/storage/app/lighthouse-schema.graphql @@ -63,6 +63,11 @@ type LocalizedCourseLanguage { label: LocalizedString! } +type LocalizedDeadlineStatus { + value: DeadlineStatus! + label: LocalizedString! +} + type LocalizedEducationRequirementOption { value: EducationRequirementOption! label: LocalizedString! @@ -332,6 +337,7 @@ type Query { ): NotificationPaginator! trainingOpportunitiesPaginated( where: TrainingOpportunitiesFilterInput + orderBy: [OrderByClause!] "Limits number of fetched items. Maximum allowed value: 1000." first: Int! = 10 @@ -1211,6 +1217,7 @@ type TrainingOpportunity { title: LocalizedString courseLanguage: LocalizedCourseLanguage registrationDeadline: Date + registrationDeadlineStatus: LocalizedDeadlineStatus trainingStart: Date trainingEnd: Date description: LocalizedString @@ -2695,6 +2702,11 @@ enum CourseLanguage { BILINGUAL } +enum DeadlineStatus { + PUBLISHED + EXPIRED +} + enum AdvertisementType { INTERNAL EXTERNAL diff --git a/apps/web/src/components/Router.tsx b/apps/web/src/components/Router.tsx index 0a923e7a8fd..b954866fcc4 100644 --- a/apps/web/src/components/Router.tsx +++ b/apps/web/src/components/Router.tsx @@ -788,6 +788,44 @@ const createRoute = (locale: Locales) => }, ], }, + { + path: "training-opportunities", + children: [ + { + index: true, + lazy: () => + import( + "../pages/TrainingOpportunities/IndexTrainingOpportunitiesPage" + ), + }, + { + path: "create", + lazy: () => + import( + "../pages/TrainingOpportunities/CreateTrainingOpportunityPage" + ), + }, + { + path: ":trainingOpportunityId", + children: [ + { + index: true, + lazy: () => + import( + "../pages/TrainingOpportunities/ViewTrainingOpportunityPage" + ), + }, + { + path: "edit", + lazy: () => + import( + "../pages/TrainingOpportunities/UpdateTrainingOpportunityPage" + ), + }, + ], + }, + ], + }, { path: "settings", children: [ diff --git a/apps/web/src/hooks/useRoutes.ts b/apps/web/src/hooks/useRoutes.ts index c322fe8494b..a4bad3fefa3 100644 --- a/apps/web/src/hooks/useRoutes.ts +++ b/apps/web/src/hooks/useRoutes.ts @@ -323,6 +323,18 @@ const getRoutes = (lang: Locales) => { // IT Training Fund itTrainingFund: () => [baseUrl, "it-training-fund"].join("/"), + // Training Opportunities (Admin) + trainingOpportunitiesIndex: () => + [adminUrl, "training-opportunities"].join("/"), + trainingOpportunityCreate: () => + [adminUrl, "training-opportunities", "create"].join("/"), + trainingOpportunityView: (trainingOpportunityId: string) => + [adminUrl, "training-opportunities", trainingOpportunityId].join("/"), + trainingOpportunityUpdate: (trainingOpportunityId: string) => + [adminUrl, "training-opportunities", trainingOpportunityId, "edit"].join( + "/", + ), + /** * Deprecated * diff --git a/apps/web/src/lang/__snapshots__/lang.test.ts.snap b/apps/web/src/lang/__snapshots__/lang.test.ts.snap index e6ca3316298..349cbd1e1bd 100644 --- a/apps/web/src/lang/__snapshots__/lang.test.ts.snap +++ b/apps/web/src/lang/__snapshots__/lang.test.ts.snap @@ -72,6 +72,13 @@ exports[`message files should have no changes to duplicate strings 1`] = ` "Ajoutez un rôle dans l'équipe" ] }, + { + "en": "Create a training opportunity", + "fr": [ + "Créez une possibilité de formation", + "Créer une possibilité de formation" + ] + }, { "en": "Add process role", "fr": [ diff --git a/apps/web/src/lang/fr.json b/apps/web/src/lang/fr.json index 2ca0459bf6a..66a3542c5ee 100644 --- a/apps/web/src/lang/fr.json +++ b/apps/web/src/lang/fr.json @@ -1047,6 +1047,10 @@ "defaultMessage": "Sélectionné(e)", "description": "Message displayed when candidate has been screened in at a specific assessment step" }, + "3YO9gR": { + "defaultMessage": "La possibilité de formation a été mise à jour avec succès!", + "description": "Message displayed to user after training opportunity is updated successfully." + }, "3b6cy/": { "defaultMessage": "Salaire minimal", "description": "Label displayed for the classification form min salary field." @@ -1527,6 +1531,10 @@ "defaultMessage": "Êtes-vous certain de vouloir supprimer la candidature {name}?", "description": "Question displayed when user attempts to delete an application" }, + "5pf2qR": { + "defaultMessage": "Modifier une possibilité de formation", + "description": "Page title for the training opportunity edit page" + }, "5pyCTN": { "defaultMessage": "Type d’instrument", "description": "Label for _instrument type_ fieldset in the _digital services contracting questionnaire_" @@ -2859,6 +2867,10 @@ "defaultMessage": "Sauvegarder et revenir en arrière", "description": "Text for save button on profile form." }, + "Cvl9dd": { + "defaultMessage": "Langue de la formation", + "description": "The language of the training opportunity" + }, "CwGljY": { "defaultMessage": "Votre première langue officielle", "description": "Legend first official language status in language information form" @@ -3167,6 +3179,10 @@ "defaultMessage": "Toutes les compétences", "description": "Label for removing the skill family filter" }, + "EInfnR": { + "defaultMessage": "Modifiez les informations sur la possibilité de formation", + "description": "Link to edit the currently viewed training opportunity" + }, "EJ09b7": { "defaultMessage": "Types d'expérience que vous pouvez ajouter", "description": "Button text to open section describing experience types" @@ -3347,6 +3363,10 @@ "defaultMessage": "Voici l’information qui a été soumise le {submittedAt}. Vous trouverez l’historique de la carrière du candidat/de la candidate dans la section sur l’échéancier.", "description": "Lead-in text for application information" }, + "FO0HTu": { + "defaultMessage": "Date limite de présentation des demandes", + "description": "The application deadline of the training opportunity" + }, "FRcbbi": { "defaultMessage": "Je suis autochtone, mais je ne trouve pas ma communauté ici", "description": "Label text for not represented community declaration" @@ -3727,6 +3747,10 @@ "defaultMessage": "Retirez du processus", "description": "Button label for the form to remove a process role from a user" }, + "Hc3RTQ": { + "defaultMessage": "Erreur : la création de la possibilité de formation a échoué", + "description": "Message displayed to user after a training opportunity fails to get created." + }, "Hd0nHP": { "defaultMessage": "(À déterminer)", "description": "Message displayed when a pool has no expiry date yet" @@ -4075,6 +4099,10 @@ "defaultMessage": "Le gouvernement du Canada offre parfois des possibilités de formation. Cette section vous permet de mettre en évidence jusqu’à 5 compétences techniques que vous souhaitez apprendre ou perfectionner.", "description": "Description of a users technical skills to be improved" }, + "JZlTsX": { + "defaultMessage": "Créez la possibilité de formation", + "description": "Button label to submit the create a new opportunity form" + }, "JamdKo": { "defaultMessage": "Premières Nations non inscrites", "description": "The indigenous community for non-status First Nations" @@ -4803,6 +4831,10 @@ "defaultMessage": "Veillez à fournir au moins une expérience de parcours professionnel pour chaque compétence requise, ainsi qu’une description concise des raisons pour lesquelles cette expérience met en évidence vos capacités dans cette compétence.", "description": "Instructions on requiring information for skills" }, + "N5xj2X": { + "defaultMessage": "URL de la demande (français)", + "description": "The French application URL of the training opportunity" + }, "N631me": { "defaultMessage": "Portée du contrat", "description": "Heading for the Scope of the contract section on the digital services contracting questionnaire" @@ -5039,6 +5071,10 @@ "defaultMessage": "Veuillez utiliser le bouton prévu à cet effet pour trouver et sélectionner toutes les compétences requises pour ce poste.", "description": "Introduction for _skills_ fieldset in the _digital services contracting questionnaire_" }, + "OU/MkT": { + "defaultMessage": "Annulez et revenez aux possibilités de formation", + "description": "Button label to return to the opportunities table" + }, "OVWo88": { "defaultMessage": "Statut d'ancien combattant", "description": "Title for Veteran status" @@ -5387,6 +5423,10 @@ "defaultMessage": "Rechercher et ajouter une compétence à votre vitrine", "description": "Title for the find a skill dialog within the skill showcase" }, + "QAo1Vy": { + "defaultMessage": "La possibilité de formation {trainingOpportunityId} n'a pas été trouvée.", + "description": "Message displayed for training opportunity not found." + }, "QCesvO": { "defaultMessage": "Ajouter un rôle individue", "description": "Header for the form to add a role to a user" @@ -5771,6 +5811,10 @@ "defaultMessage": "Il manque des données requises par le gouvernement", "description": "Error message displayed when a users government information is incomplete" }, + "RtX9oA": { + "defaultMessage": "Créez une possibilité de formation", + "description": "Title for link to page to create a training opportunity (imperative in French)" + }, "RvB1GT": { "defaultMessage": "Relations de travail", "description": "Label for _labour relations_ option in _authorities involved_ fieldset in the _digital services contracting questionnaire_" @@ -6350,6 +6394,10 @@ "defaultMessage": "Filtre de classification", "description": "Label for classification filter in search form." }, + "V95g4E": { + "defaultMessage": "Possibilités de formation", + "description": "Title for the index training opportunities page" + }, "V9XmLH": { "defaultMessage": "les résultats des évaluations automatisés d’accessibilité", "description": "List item three, things developers consider for accessibility" @@ -6614,6 +6662,10 @@ "defaultMessage": "Autre grade avec spécialisation", "description": "Heading for the `other education with specialization` option for EC education requirements" }, + "WWrQYI": { + "defaultMessage": "Erreur : la mise à jour de la possibilité de formation a échoué", + "description": "Message displayed to user after training opportunity fails to get updated." + }, "WX6NnA": { "defaultMessage": "Créer une nouvelle équipe", "description": "Button text for the create team form submit button" @@ -6790,6 +6842,10 @@ "defaultMessage": "Prénom", "description": "Label displayed on the user form first name field." }, + "XKpHTN": { + "defaultMessage": "La possibilité de formation a été créée avec succès!", + "description": "Message displayed to user after a training opportunity is created successfully." + }, "XOFVsC": { "defaultMessage": "{count, plural, =1 {# évaluation} other {# évaluations}}", "description": "Number of assessments for a skill" @@ -6990,6 +7046,10 @@ "defaultMessage": "Durée pendant laquelle le candidat sera pris en considération pour une affectation à la suite de cette procédure. La durée habituelle est de 2 ans.", "description": "Help text for setting a candidate expiry date" }, + "YEYD5j": { + "defaultMessage": "Créer une possibilité de formation", + "description": "Breadcrumb title for the create training opportunity page link." + }, "YGM/D5": { "defaultMessage": "Le programme dure 24 mois et les apprentis ont accès à un soutien pendant leur période de participation au programme.", "description": "Learn more dialog question two paragraph one" @@ -7610,6 +7670,10 @@ "defaultMessage": "{areaOfStudy} à {institution}", "description": "Study at institution, HTML" }, + "bg8KBg": { + "defaultMessage": "Date de fin de la formation", + "description": "The training end date of the training opportunity" + }, "bjcr+t": { "defaultMessage": "S’inscrire aux mises à jour sur les bons pour examens de certification", "description": "A link to sign up for updates" @@ -7678,6 +7742,10 @@ "defaultMessage": "Veuillez sélectionner un niveau approprié de maîtrise de la deuxième langue sur la base des définitions fournies.", "description": "Text requesting language levels given from bilingual evaluation in language information form" }, + "bwoJyk": { + "defaultMessage": "Informations sur la possibilité de formation", + "description": "Heading for the opportunity form information section" + }, "by9soK": { "defaultMessage": "S’agit-il d’un contrat pluriannuel?", "description": "Label for _contract amendable_ fieldset in the _digital services contracting questionnaire_" @@ -8110,6 +8178,10 @@ "defaultMessage": "Sauvegarder l’annonce à l’échelle du site", "description": "Text on a button to save the sitewide announcement" }, + "dzCyaO": { + "defaultMessage": "URL de la demande (anglais)", + "description": "The English application URL of the training opportunity" + }, "e+xir3": { "defaultMessage": "Autres exigences", "description": "Signpost title for _work requirement description_ textbox in the _digital services contracting questionnaire_" @@ -9790,6 +9862,10 @@ "defaultMessage": "Canada.ca", "description": "Alt text for the Canada logo link in the Footer." }, + "m3c4o8": { + "defaultMessage": "Sélectionnez un format", + "description": "Placeholder displayed on the select input for a format" + }, "m3qn9l": { "defaultMessage": "Groupes visés par l’équité en matière d’emploi", "description": "Legend for the employment equity checklist" @@ -10850,6 +10926,10 @@ "defaultMessage": "p. ex., développeur de logiciels", "description": "Placeholder for _type of resource_ field in the _digital services contracting questionnaire_" }, + "rTBi+P": { + "defaultMessage": "Créer une possibilité de formation", + "description": "Page title for the training opportunity creation page (infinitive in French)" + }, "rTnjej": { "defaultMessage": "Utilisateur supprimé avec succès", "description": "Message displayed when a user has been deleted" @@ -11050,10 +11130,6 @@ "defaultMessage": "La durée de l’étude est équivalente à l’exigence du diplôme", "description": "Text for education accepted information context in screening decision dialog" }, - "sk9CeW": { - "defaultMessage": "Langue", - "description": "Legend for the Working Language Ability radio buttons" - }, "skfKnv": { "defaultMessage": "Veuillez sélectionner les Premières Nations qui détiennent le statut ou les Premières Nations qui ne détiennent pas le statut.", "description": "Error message that the user has selected both status and non-status first nations." @@ -11782,6 +11858,10 @@ "defaultMessage": "Nous pouvons peut-être vous aider!", "description": "Heading for helping user if no candidates matched the filters chosen." }, + "xCjb82": { + "defaultMessage": "Date de début de la formation", + "description": "The training start date of the training opportunity" + }, "xCoGIm": { "defaultMessage": "Envisagez d’ajouter au moins une méthode d’évaluation pour améliorer la qualité de votre bassin de candidats.", "description": "Description for warning message when the user has few assessments to the assessment plan" @@ -11858,6 +11938,10 @@ "defaultMessage": "Votre profil est au cœur de la plateforme. Racontez votre histoire, montrez comment vous avez développé vos compétences et utilisez votre profil pour postuler à des emplois. Que vous soyez à la recherche d’un emploi maintenant ou que vous pensiez simplement à l’avenir, votre profil est votre outil pour être découvert par les responsables du recrutement.", "description": "Description du fonctionnement des profils de candidature" }, + "xhdl6Q": { + "defaultMessage": "Modifier la possibilité de formation", + "description": "Breadcrumb title for the edit training opportunity page link." + }, "xj7X6V": { "defaultMessage": "J’ai lu le préambule.", "description": "Preamble confirmation statement of the _digital services contracting questionnaire_" @@ -12026,6 +12110,10 @@ "defaultMessage": "Télécharger le guide", "description": "Link text for guidance resource download" }, + "yWen8A": { + "defaultMessage": "Format", + "description": "The format of the training opportunity" + }, "yXWaAj": { "defaultMessage": "Veuillez préciser le niveau de sécurité", "description": "Label for _other security level_ fieldset in the _digital services contracting questionnaire_" diff --git a/apps/web/src/lang/whitelist.yml b/apps/web/src/lang/whitelist.yml index 0b123b01023..12486a22137 100644 --- a/apps/web/src/lang/whitelist.yml +++ b/apps/web/src/lang/whitelist.yml @@ -30,3 +30,4 @@ - hbcdJm # Notifications - IhPS6D # Important - CdJQ7z # Administration +- yWen8A # Format - The format of the training opportunity diff --git a/apps/web/src/messages/pageTitles.ts b/apps/web/src/messages/pageTitles.ts index 9a10e0525cb..9ec166c9e97 100644 --- a/apps/web/src/messages/pageTitles.ts +++ b/apps/web/src/messages/pageTitles.ts @@ -66,4 +66,9 @@ export default defineMessages({ id: "bVQ/rm", description: "Title for the index user page", }, + trainingOpportunities: { + defaultMessage: "Training opportunities", + id: "V95g4E", + description: "Title for the index training opportunities page", + }, }); diff --git a/apps/web/src/pages/AdminDashboardPage/AdminDashboardPage.tsx b/apps/web/src/pages/AdminDashboardPage/AdminDashboardPage.tsx index 4aee62540ef..3d2e27e4eed 100644 --- a/apps/web/src/pages/AdminDashboardPage/AdminDashboardPage.tsx +++ b/apps/web/src/pages/AdminDashboardPage/AdminDashboardPage.tsx @@ -202,6 +202,11 @@ export const DashboardPage = ({ currentUser }: DashboardPageProps) => { ROLE_NAME.PlatformAdmin, ], }, + { + label: intl.formatMessage(pageTitles.trainingOpportunities), + href: adminRoutes.trainingOpportunitiesIndex(), + roles: [ROLE_NAME.PlatformAdmin], + }, { label: intl.formatMessage(navigationMessages.users), href: adminRoutes.userTable(), diff --git a/apps/web/src/pages/SearchRequests/SearchPage/components/FormFields.tsx b/apps/web/src/pages/SearchRequests/SearchPage/components/FormFields.tsx index 6c9606a2004..cf30e3d9059 100644 --- a/apps/web/src/pages/SearchRequests/SearchPage/components/FormFields.tsx +++ b/apps/web/src/pages/SearchRequests/SearchPage/components/FormFields.tsx @@ -179,12 +179,7 @@ const FormFields = ({ classifications, skills }: FormFieldsProps) => { > Promise; + formOptionsQuery: FragmentType< + typeof TrainingOpportunityFormOptions_Fragment + >; +} + +const CreateTrainingOpportunityForm = ({ + handleCreateTrainingOpportunity, + formOptionsQuery, +}: CreateTrainingOpportunityFormProps) => { + const intl = useIntl(); + const navigate = useNavigate(); + const paths = useRoutes(); + const methods = useForm(); + const { handleSubmit } = methods; + + const onSubmit: SubmitHandler = async ( + formValues: FormValues, + ) => { + return handleCreateTrainingOpportunity( + convertFormValuesToCreateInput(formValues), + ) + .then((id) => { + navigate(paths.trainingOpportunityView(id)); + toast.success( + intl.formatMessage({ + defaultMessage: "Training opportunity created successfully!", + id: "XKpHTN", + description: + "Message displayed to user after a training opportunity is created successfully.", + }), + ); + }) + .catch(() => { + toast.error( + intl.formatMessage({ + defaultMessage: "Error: creating training opportunity failed", + id: "Hc3RTQ", + description: + "Message displayed to user after a training opportunity fails to get created.", + }), + ); + }); + }; + + return ( + +
+ +
+ + {intl.formatMessage({ + defaultMessage: "Training opportunity information", + id: "bwoJyk", + description: + "Heading for the opportunity form information section", + })} + +
+ + +
+ + + {intl.formatMessage({ + defaultMessage: "Cancel and go back to training opportunities", + id: "OU/MkT", + description: + "Button label to return to the opportunities table", + })} + +
+
+
+
+ ); +}; + +const CreateTrainingOpportunityPage_Query = graphql(/* GraphQL */ ` + query CreateTrainingOpportunityPage { + ...TrainingOpportunityFormOptions + } +`); + +const CreateTrainingOpportunity_Mutation = graphql(/* GraphQL */ ` + mutation createTrainingOpportunity($input: CreateTrainingOpportunityInput!) { + createTrainingOpportunity(createTrainingOpportunity: $input) { + id + } + } +`); + +const CreateTrainingOpportunityPage = () => { + const intl = useIntl(); + const routes = useRoutes(); + const [{ data, fetching, error }] = useQuery({ + query: CreateTrainingOpportunityPage_Query, + }); + const [, executeMutation] = useMutation(CreateTrainingOpportunity_Mutation); + const handleCreateTrainingOpportunity = ( + input: CreateTrainingOpportunityInput, + ) => + executeMutation({ input }).then((result) => { + if (result.data?.createTrainingOpportunity?.id) { + return result.data.createTrainingOpportunity.id; + } + return Promise.reject(new Error(result.error?.toString())); + }); + + const navigationCrumbs = useBreadcrumbs({ + crumbs: [ + { + label: intl.formatMessage(pageTitles.trainingOpportunities), + url: routes.departmentTable(), + }, + { + label: intl.formatMessage({ + defaultMessage: "Create a training opportunity", + id: "YEYD5j", + description: + "Breadcrumb title for the create training opportunity page link.", + }), + url: routes.trainingOpportunityCreate(), + }, + ], + }); + + const pageTitle = intl.formatMessage({ + defaultMessage: "Create a training opportunity", + id: "rTBi+P", + description: + "Page title for the training opportunity creation page (infinitive in French)", + }); + + return ( + <> + + +
+ + {data ? ( + + ) : ( + +

{intl.formatMessage(commonMessages.notFound)}

+
+ )} +
+
+
+ + ); +}; + +export const Component = () => ( + + + +); + +Component.displayName = "AdminCreateTrainingOpportunityPage"; + +export default CreateTrainingOpportunityPage; diff --git a/apps/web/src/pages/TrainingOpportunities/IndexTrainingOpportunitiesPage.tsx b/apps/web/src/pages/TrainingOpportunities/IndexTrainingOpportunitiesPage.tsx new file mode 100644 index 00000000000..dc933693d38 --- /dev/null +++ b/apps/web/src/pages/TrainingOpportunities/IndexTrainingOpportunitiesPage.tsx @@ -0,0 +1,53 @@ +import { useIntl } from "react-intl"; + +import { ROLE_NAME } from "@gc-digital-talent/auth"; + +import Hero from "~/components/Hero"; +import RequireAuth from "~/components/RequireAuth/RequireAuth"; +import SEO from "~/components/SEO/SEO"; +import useBreadcrumbs from "~/hooks/useBreadcrumbs"; +import useRoutes from "~/hooks/useRoutes"; +import pageTitles from "~/messages/pageTitles"; + +import TrainingOpportunitiesTable from "./components/TrainingOpportunitiesTable"; + +export const IndexTrainingOpportunitiesPage = () => { + const intl = useIntl(); + const routes = useRoutes(); + + const formattedPageTitle = intl.formatMessage( + pageTitles.trainingOpportunities, + ); + + const navigationCrumbs = useBreadcrumbs({ + crumbs: [ + { + label: formattedPageTitle, + url: routes.trainingOpportunitiesIndex(), + }, + ], + }); + + return ( + <> + + +
+ +
+ + ); +}; + +export const Component = () => ( + + + +); + +Component.displayName = "AdminIndexTrainingOpportunitiesPage"; + +export default IndexTrainingOpportunitiesPage; diff --git a/apps/web/src/pages/TrainingOpportunities/UpdateTrainingOpportunityPage.tsx b/apps/web/src/pages/TrainingOpportunities/UpdateTrainingOpportunityPage.tsx new file mode 100644 index 00000000000..6ca1d4e7fa8 --- /dev/null +++ b/apps/web/src/pages/TrainingOpportunities/UpdateTrainingOpportunityPage.tsx @@ -0,0 +1,273 @@ +import { useNavigate } from "react-router-dom"; +import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; +import { useIntl } from "react-intl"; +import IdentificationIcon from "@heroicons/react/24/outline/IdentificationIcon"; +import { useMutation, useQuery } from "urql"; + +import { toast } from "@gc-digital-talent/toast"; +import { Submit } from "@gc-digital-talent/forms"; +import { + commonMessages, + formMessages, + getLocalizedName, +} from "@gc-digital-talent/i18n"; +import { + Heading, + Link, + CardSeparator, + CardBasic, + NotFound, + Pending, +} from "@gc-digital-talent/ui"; +import { + FragmentType, + Scalars, + UpdateTrainingOpportunityInput, + getFragment, + graphql, +} from "@gc-digital-talent/graphql"; +import { ROLE_NAME } from "@gc-digital-talent/auth"; + +import SEO from "~/components/SEO/SEO"; +import useRoutes from "~/hooks/useRoutes"; +import useRequiredParams from "~/hooks/useRequiredParams"; +import useBreadcrumbs from "~/hooks/useBreadcrumbs"; +import RequireAuth from "~/components/RequireAuth/RequireAuth"; +import pageTitles from "~/messages/pageTitles"; +import Hero from "~/components/Hero"; + +import { + FormValues, + TrainingOpportunityForm_Fragment, + convertApiFragmentToFormValues, + convertFormValuesToUpdateInput, +} from "./apiUtils"; +import TrainingOpportunityForm, { + TrainingOpportunityFormOptions_Fragment, +} from "./components/TrainingOpportunityForm"; + +interface UpdateTrainingOpportunityFormProps { + trainingOpportunityQuery: FragmentType< + typeof TrainingOpportunityForm_Fragment + >; + handleUpdateTrainingOpportunity: ( + input: UpdateTrainingOpportunityInput, + ) => Promise>; + formOptionsQuery: FragmentType< + typeof TrainingOpportunityFormOptions_Fragment + >; +} + +const UpdateTrainingOpportunityForm = ({ + trainingOpportunityQuery, + handleUpdateTrainingOpportunity, + formOptionsQuery, +}: UpdateTrainingOpportunityFormProps) => { + const intl = useIntl(); + const navigate = useNavigate(); + const paths = useRoutes(); + const { trainingOpportunityId } = useRequiredParams( + "trainingOpportunityId", + ); + const initialTrainingOpportunity = getFragment( + TrainingOpportunityForm_Fragment, + trainingOpportunityQuery, + ); + const methods = useForm({ + defaultValues: convertApiFragmentToFormValues(initialTrainingOpportunity), + }); + const { handleSubmit } = methods; + + const onSubmit: SubmitHandler = async ( + formValues: FormValues, + ) => { + return handleUpdateTrainingOpportunity( + convertFormValuesToUpdateInput(trainingOpportunityId, formValues), + ) + .then(() => { + navigate(paths.trainingOpportunityView(trainingOpportunityId)); + toast.success( + intl.formatMessage({ + defaultMessage: "Training opportunity updated successfully!", + id: "3YO9gR", + description: + "Message displayed to user after training opportunity is updated successfully.", + }), + ); + }) + .catch(() => { + toast.error( + intl.formatMessage({ + defaultMessage: "Error: updating training opportunity failed", + id: "WWrQYI", + description: + "Message displayed to user after training opportunity fails to get updated.", + }), + ); + }); + }; + + return ( + +
+ +
+ + {intl.formatMessage({ + defaultMessage: "Training opportunity information", + id: "bwoJyk", + description: + "Heading for the opportunity form information section", + })} + +
+ + +
+ + + {intl.formatMessage(commonMessages.cancel)} + +
+
+
+
+ ); +}; + +interface RouteParams extends Record { + trainingOpportunityId: Scalars["ID"]["output"]; +} + +const UpdateTrainingOpportunityPage_Query = graphql(/* GraphQL */ ` + query UpdateTrainingOpportunityPage($id: UUID!) { + trainingOpportunity(id: $id) { + title { + en + fr + } + ...TrainingOpportunityView + } + ...TrainingOpportunityFormOptions + } +`); + +const UpdateTrainingOpportunity_Mutation = graphql(/* GraphQL */ ` + mutation updateTrainingOpportunity($input: UpdateTrainingOpportunityInput!) { + updateTrainingOpportunity(updateTrainingOpportunity: $input) { + ...TrainingOpportunityView + } + } +`); + +const UpdateTrainingOpportunityPage = () => { + const intl = useIntl(); + const routes = useRoutes(); + const { trainingOpportunityId } = useRequiredParams( + "trainingOpportunityId", + ); + const [{ data, fetching, error }] = useQuery({ + query: UpdateTrainingOpportunityPage_Query, + variables: { id: trainingOpportunityId }, + }); + const [, executeMutation] = useMutation(UpdateTrainingOpportunity_Mutation); + const handleUpdateTrainingOpportunity = ( + input: UpdateTrainingOpportunityInput, + ) => + executeMutation({ + input, + }).then((result) => { + if (result.data?.updateTrainingOpportunity) { + return result.data.updateTrainingOpportunity; + } + return Promise.reject(new Error(result.error?.toString())); + }); + + const trainingOpportunityName = getLocalizedName( + data?.trainingOpportunity?.title, + intl, + ); + + const navigationCrumbs = useBreadcrumbs({ + crumbs: [ + { + label: intl.formatMessage(pageTitles.trainingOpportunities), + url: routes.trainingOpportunitiesIndex(), + }, + { + label: trainingOpportunityName, + url: routes.trainingOpportunityView(trainingOpportunityId), + }, + { + label: intl.formatMessage({ + defaultMessage: "Edit training opportunity", + id: "xhdl6Q", + description: + "Breadcrumb title for the edit training opportunity page link.", + }), + url: routes.trainingOpportunityUpdate(trainingOpportunityId), + }, + ], + }); + + const pageTitle = intl.formatMessage({ + defaultMessage: "Edit a training opportunity", + id: "5pf2qR", + description: "Page title for the training opportunity edit page", + }); + + return ( + <> + + +
+ + {data?.trainingOpportunity ? ( + + ) : ( + +

{intl.formatMessage(commonMessages.notFound)}

+
+ )} +
+
+
+ + ); +}; + +export const Component = () => ( + + + +); + +Component.displayName = "AdminUpdateTrainingOpportunityPage"; + +export default UpdateTrainingOpportunityPage; diff --git a/apps/web/src/pages/TrainingOpportunities/ViewTrainingOpportunityPage.tsx b/apps/web/src/pages/TrainingOpportunities/ViewTrainingOpportunityPage.tsx new file mode 100644 index 00000000000..740ff0f8114 --- /dev/null +++ b/apps/web/src/pages/TrainingOpportunities/ViewTrainingOpportunityPage.tsx @@ -0,0 +1,283 @@ +import { useIntl } from "react-intl"; +import IdentificationIcon from "@heroicons/react/24/outline/IdentificationIcon"; +import { useQuery } from "urql"; + +import { commonMessages, getLocalizedName } from "@gc-digital-talent/i18n"; +import { + NotFound, + Heading, + Link, + CardBasic, + CardSeparator, + Pending, + Chip, +} from "@gc-digital-talent/ui"; +import { + FragmentType, + getFragment, + graphql, + Scalars, + ViewTrainingOpportunityPageQuery, +} from "@gc-digital-talent/graphql"; +import { ROLE_NAME } from "@gc-digital-talent/auth"; +import { htmlToRichTextJSON, RichTextRenderer } from "@gc-digital-talent/forms"; + +import SEO from "~/components/SEO/SEO"; +import useRoutes from "~/hooks/useRoutes"; +import useRequiredParams from "~/hooks/useRequiredParams"; +import useBreadcrumbs from "~/hooks/useBreadcrumbs"; +import RequireAuth from "~/components/RequireAuth/RequireAuth"; +import pageTitles from "~/messages/pageTitles"; +import Hero from "~/components/Hero"; +import FieldDisplay from "~/components/ToggleForm/FieldDisplay"; +import adminMessages from "~/messages/adminMessages"; + +import formLabels from "./formLabels"; +import { TrainingOpportunityForm_Fragment } from "./apiUtils"; + +interface ViewTrainingOpportunityFormProps { + query: FragmentType; +} + +export const ViewTrainingOpportunityForm = ({ + query, +}: ViewTrainingOpportunityFormProps) => { + const intl = useIntl(); + const paths = useRoutes(); + const { trainingOpportunityId } = useRequiredParams( + "trainingOpportunityId", + ); + const trainingOpportunity = getFragment( + TrainingOpportunityForm_Fragment, + query, + ); + + return ( + <> +
+ + {intl.formatMessage({ + defaultMessage: "Training opportunity information", + id: "bwoJyk", + description: "Heading for the opportunity form information section", + })} + +
+ +
+ + {trainingOpportunity.title?.en} + + + {trainingOpportunity.title?.fr} + + + + {getLocalizedName( + trainingOpportunity.courseLanguage?.label, + intl, + )} + + + + {getLocalizedName(trainingOpportunity.courseFormat?.label, intl)} + + + {trainingOpportunity.registrationDeadline} + +
+ {/* intentionally left blank */} +
+ + {trainingOpportunity.trainingStart} + + + {trainingOpportunity.trainingEnd ?? + intl.formatMessage(adminMessages.noneProvided)} + + + {trainingOpportunity.description?.en ? ( + + ) : ( + intl.formatMessage(adminMessages.noneProvided) + )} + + + {trainingOpportunity.description?.fr ? ( + + ) : ( + intl.formatMessage(adminMessages.noneProvided) + )} + + + {trainingOpportunity.applicationUrl?.en} + + + {trainingOpportunity.applicationUrl?.fr} + +
+ +
+ + {intl.formatMessage({ + defaultMessage: "Edit training opportunity information", + id: "EInfnR", + description: + "Link to edit the currently viewed training opportunity", + })} + +
+
+ + ); +}; + +interface RouteParams extends Record { + trainingOpportunityId: Scalars["ID"]["output"]; +} + +interface ViewTrainingOpportunityPageProps { + trainingOpportunity: NonNullable< + ViewTrainingOpportunityPageQuery["trainingOpportunity"] + >; +} + +const ViewTrainingOpportunityPage = ({ + trainingOpportunity, +}: ViewTrainingOpportunityPageProps) => { + const intl = useIntl(); + const routes = useRoutes(); + const { trainingOpportunityId } = useRequiredParams( + "trainingOpportunityId", + ); + const trainingOpportunityName = getLocalizedName( + trainingOpportunity.title, + intl, + ); + + const navigationCrumbs = useBreadcrumbs({ + crumbs: [ + { + label: intl.formatMessage(pageTitles.trainingOpportunities), + url: routes.trainingOpportunitiesIndex(), + }, + { + label: trainingOpportunityName, + url: routes.trainingOpportunityView(trainingOpportunityId), + }, + ], + }); + + const navTabs = [ + { + url: routes.trainingOpportunityView(trainingOpportunityId), + label: intl.formatMessage({ + defaultMessage: "Training opportunity information", + id: "bwoJyk", + description: "Heading for the opportunity form information section", + }), + }, + ]; + + return ( + <> + + +
+
+ +
+
+ + ); +}; + +const ViewTrainingOpportunityPage_Query = graphql(/* GraphQL */ ` + query ViewTrainingOpportunityPage($id: UUID!) { + trainingOpportunity(id: $id) { + title { + en + fr + } + ...TrainingOpportunityView + } + } +`); + +// Since the SEO and Hero need API-loaded data, we wrap the entire page in a Pending +const ViewTrainingOpportunityPageApiWrapper = () => { + const intl = useIntl(); + const { trainingOpportunityId } = useRequiredParams( + "trainingOpportunityId", + ); + const [{ data, fetching, error }] = useQuery({ + query: ViewTrainingOpportunityPage_Query, + variables: { id: trainingOpportunityId }, + }); + + return ( + + {data?.trainingOpportunity ? ( + + ) : ( + +

+ {intl.formatMessage( + { + defaultMessage: + "Opportunity {trainingOpportunityId} not found.", + id: "QAo1Vy", + description: + "Message displayed for training opportunity not found.", + }, + { trainingOpportunityId }, + )} +

+
+ )} +
+ ); +}; + +export const Component = () => ( + + + +); + +Component.displayName = "AdminViewTrainingOpportunityPage"; + +export default ViewTrainingOpportunityPage; diff --git a/apps/web/src/pages/TrainingOpportunities/apiUtils.ts b/apps/web/src/pages/TrainingOpportunities/apiUtils.ts new file mode 100644 index 00000000000..cd5c88a76bc --- /dev/null +++ b/apps/web/src/pages/TrainingOpportunities/apiUtils.ts @@ -0,0 +1,110 @@ +import { + CourseFormat, + CourseLanguage, + CreateTrainingOpportunityInput, + graphql, + TrainingOpportunityViewFragment, + UpdateTrainingOpportunityInput, +} from "@gc-digital-talent/graphql"; + +export const TrainingOpportunityForm_Fragment = graphql(/* GraphQL */ ` + fragment TrainingOpportunityView on TrainingOpportunity { + title { + en + fr + } + courseLanguage { + value + label { + en + fr + } + } + courseFormat { + value + label { + en + fr + } + } + registrationDeadline + trainingStart + trainingEnd + description { + en + fr + } + applicationUrl { + en + fr + } + } +`); + +export interface FormValues { + titleEn: string; + titleFr: string; + courseLanguage: string; + courseFormat: string; + applicationDeadline: string; + trainingStartDate: string; + trainingEndDate: string; + descriptionEn: string; + descriptionFr: string; + applicationUrlEn: string; + applicationUrlFr: string; +} + +export function convertApiFragmentToFormValues( + apiData: TrainingOpportunityViewFragment, +): FormValues { + return { + titleEn: apiData.title?.en ?? "", + titleFr: apiData.title?.fr ?? "", + courseLanguage: apiData.courseLanguage?.value ?? "", + courseFormat: apiData.courseFormat?.value ?? "", + applicationDeadline: apiData.registrationDeadline ?? "", + trainingStartDate: apiData.trainingStart ?? "", + trainingEndDate: apiData.trainingEnd ?? "", + descriptionEn: apiData.description?.en ?? "", + descriptionFr: apiData.description?.fr ?? "", + applicationUrlEn: apiData.applicationUrl?.en ?? "", + applicationUrlFr: apiData.applicationUrl?.fr ?? "", + }; +} + +export function convertFormValuesToCreateInput( + formValues: FormValues, +): CreateTrainingOpportunityInput { + return { + title: { + en: formValues.titleEn, + fr: formValues.titleFr, + }, + courseLanguage: formValues.courseLanguage as CourseLanguage, + courseFormat: formValues.courseFormat as CourseFormat, + registrationDeadline: formValues.applicationDeadline, + trainingStart: formValues.trainingStartDate, + trainingEnd: formValues.trainingEndDate, + description: { + en: formValues.descriptionEn, + fr: formValues.descriptionFr, + }, + applicationUrl: { + en: formValues.applicationUrlEn, + fr: formValues.applicationUrlFr, + }, + }; +} + +export function convertFormValuesToUpdateInput( + id: string, + formValues: FormValues, +): UpdateTrainingOpportunityInput { + const createInput = convertFormValuesToCreateInput(formValues); + return { + id: id, + // input is the same as the one for "create" but also includes ID + ...createInput, + }; +} diff --git a/apps/web/src/pages/TrainingOpportunities/components/TrainingOpportunitiesTable.tsx b/apps/web/src/pages/TrainingOpportunities/components/TrainingOpportunitiesTable.tsx new file mode 100644 index 00000000000..8ac1b94fc60 --- /dev/null +++ b/apps/web/src/pages/TrainingOpportunities/components/TrainingOpportunitiesTable.tsx @@ -0,0 +1,306 @@ +import { useQuery } from "urql"; +import { ReactNode, useMemo, useState } from "react"; +import { + ColumnDef, + createColumnHelper, + PaginationState, + SortingState, +} from "@tanstack/react-table"; +import { useIntl } from "react-intl"; +import { useLocation } from "react-router-dom"; + +import { + DeadlineStatus, + graphql, + OrderByClause, + SortOrder, + TrainingOpportunitiesFilterInput, + TrainingOpportunity, +} from "@gc-digital-talent/graphql"; +import { notEmpty } from "@gc-digital-talent/helpers"; +import { + commonMessages, + getLocale, + getLocalizedName, + Locales, +} from "@gc-digital-talent/i18n"; +import { Chip, ChipProps, Link } from "@gc-digital-talent/ui"; + +import Table, { + getTableStateFromSearchParams, +} from "~/components/Table/ResponsiveTable/ResponsiveTable"; +import { InitialState } from "~/components/Table/ResponsiveTable/types"; +import adminMessages from "~/messages/adminMessages"; +import useRoutes from "~/hooks/useRoutes"; + +import formLabels from "../formLabels"; + +const columnHelper = createColumnHelper(); + +const INITIAL_STATE: InitialState = { + hiddenColumnIds: [], + paginationState: { + pageIndex: 0, + pageSize: 10, + }, + sortState: [], + searchState: {}, +}; + +const defaultState = { + ...INITIAL_STATE, + sortState: [{ id: "name", desc: false }], + filters: {}, +}; + +function transformSortStateToOrderByClause( + locale: Locales, + sortingRule?: SortingState, +): OrderByClause | OrderByClause[] | undefined { + const columnMap = new Map([ + ["name", `title->${locale}`], + ["language", "course_language"], + ["status", "registration_deadline"], // deadline status is not a real column, but storting by deadline achieves the same thing + ["applicationDeadline", "registration_deadline"], + ["trainingStartDate", "training_start"], + ["trainingEndDate", "training_end"], + ]); + + const orderBy = sortingRule + ?.map((rule) => { + const columnName = columnMap.get(rule.id); + if (!columnName) return undefined; + return { + column: columnName, + order: rule.desc ? SortOrder.Desc : SortOrder.Asc, + }; + }) + .filter(notEmpty); + + return orderBy?.length ? orderBy : undefined; +} + +const TrainingOpportunitiesPaginated_Query = graphql(/* GraphQL */ ` + query TrainingOpportunitiesPaginated( + $first: Int + $page: Int + $orderBy: [OrderByClause!] + ) { + trainingOpportunitiesPaginated( + first: $first + page: $page + orderBy: $orderBy + ) { + data { + id + title { + en + fr + } + courseLanguage { + value + label { + en + fr + } + } + registrationDeadline + registrationDeadlineStatus { + value + label { + en + fr + } + } + trainingStart + trainingEnd + } + paginatorInfo { + count + currentPage + firstItem + hasMorePages + lastItem + lastPage + perPage + total + } + } + } +`); + +interface TrainingOpportunitiesTableProps { + title: ReactNode; +} + +const TrainingOpportunitiesTable = ({ + title, +}: TrainingOpportunitiesTableProps) => { + const intl = useIntl(); + const locale = getLocale(intl); + const paths = useRoutes(); + const initialState = getTableStateFromSearchParams(defaultState); + + const { pathname, search, hash } = useLocation(); + const currentUrl = `${pathname}${search}${hash}`; + + const [paginationState, setPaginationState] = useState( + initialState.paginationState + ? { + ...initialState.paginationState, + pageIndex: initialState.paginationState.pageIndex + 1, + } + : INITIAL_STATE.paginationState, + ); + const [sortState, setSortState] = useState( + initialState.sortState ?? [{ id: "title", desc: false }], + ); + + const handlePaginationStateChange = ({ + pageIndex, + pageSize, + }: PaginationState) => { + setPaginationState((previous) => ({ + pageIndex: + previous.pageSize === pageSize + ? (pageIndex ?? INITIAL_STATE.paginationState.pageIndex) + : 0, + pageSize: pageSize ?? INITIAL_STATE.paginationState.pageSize, + })); + }; + + const statusChipStyles: Record = { + PUBLISHED: "primary", + EXPIRED: "black", + } as const; + + const columns = [ + columnHelper.accessor( + (opportunity) => getLocalizedName(opportunity.title, intl), + { + id: "name", + header: intl.formatMessage(commonMessages.name), + cell: ({ row: { original: opportunity } }) => + opportunity.id ? ( + + {getLocalizedName(opportunity.title, intl)} + + ) : ( + getLocalizedName(opportunity.title, intl) + ), + meta: { + isRowTitle: true, + }, + }, + ), + columnHelper.accessor( + (opportunity) => (opportunity.courseLanguage?.label, intl), + { + id: "language", + header: intl.formatMessage(commonMessages.language), + cell: ({ row: { original: opportunity } }) => + getLocalizedName(opportunity.courseLanguage?.label, intl), + }, + ), + columnHelper.accessor( + (opportunity) => opportunity.registrationDeadlineStatus, + { + id: "status", + header: intl.formatMessage(commonMessages.status), + cell: ({ row: { original: opportunity } }) => + opportunity.registrationDeadlineStatus?.value ? ( + + {getLocalizedName( + opportunity.registrationDeadlineStatus?.label, + intl, + )} + + ) : ( + intl.formatMessage(adminMessages.noneProvided) + ), + }, + ), + columnHelper.accessor( + (opportunity) => (opportunity.registrationDeadline, intl), + { + id: "applicationDeadline", + header: intl.formatMessage(formLabels.applicationDeadline), + cell: ({ row: { original: opportunity } }) => + opportunity.registrationDeadline, + }, + ), + columnHelper.accessor((opportunity) => (opportunity.trainingStart, intl), { + id: "trainingStartDate", + header: intl.formatMessage(formLabels.trainingStartDate), + cell: ({ row: { original: opportunity } }) => opportunity.trainingStart, + }), + columnHelper.accessor((opportunity) => (opportunity.trainingEnd, intl), { + id: "trainingEndDate", + header: intl.formatMessage(formLabels.trainingEndDate), + cell: ({ row: { original: opportunity } }) => + opportunity.trainingEnd ?? + intl.formatMessage(adminMessages.noneProvided), + }), + ] as ColumnDef[]; + + const [{ data, fetching }] = useQuery({ + query: TrainingOpportunitiesPaginated_Query, + variables: { + page: paginationState.pageIndex, + first: paginationState.pageSize, + orderBy: sortState + ? transformSortStateToOrderByClause(locale, sortState) + : undefined, + }, + }); + + const filteredData: TrainingOpportunity[] = useMemo(() => { + const opportunities = data?.trainingOpportunitiesPaginated?.data ?? []; + return opportunities.filter(notEmpty); + }, [data?.trainingOpportunitiesPaginated?.data]); + + return ( + + data={filteredData} + caption={title} + columns={columns} + isLoading={fetching} + pagination={{ + internal: false, + initialState: INITIAL_STATE.paginationState, + state: paginationState, + total: data?.trainingOpportunitiesPaginated?.paginatorInfo.total, + pageSizes: [10, 20, 50], + onPaginationChange: ({ pageIndex, pageSize }: PaginationState) => { + handlePaginationStateChange({ pageIndex, pageSize }); + }, + }} + sort={{ + internal: false, + onSortChange: setSortState, + initialState: defaultState.sortState, + }} + add={{ + linkProps: { + href: paths.trainingOpportunityCreate(), + label: intl.formatMessage({ + defaultMessage: "Create a training opportunity", + id: "RtX9oA", + description: + "Title for link to page to create a training opportunity (imperative in French)", + }), + from: currentUrl, + }, + }} + /> + ); +}; + +export default TrainingOpportunitiesTable; diff --git a/apps/web/src/pages/TrainingOpportunities/components/TrainingOpportunityForm.tsx b/apps/web/src/pages/TrainingOpportunities/components/TrainingOpportunityForm.tsx new file mode 100644 index 00000000000..092d1badf5f --- /dev/null +++ b/apps/web/src/pages/TrainingOpportunities/components/TrainingOpportunityForm.tsx @@ -0,0 +1,185 @@ +import { useIntl } from "react-intl"; +import { useWatch } from "react-hook-form"; + +import { + DATE_SEGMENT, + DateInput, + Input, + localizedEnumToOptions, + RichTextInput, + Select, +} from "@gc-digital-talent/forms"; +import { errorMessages } from "@gc-digital-talent/i18n"; +import { FragmentType, getFragment, graphql } from "@gc-digital-talent/graphql"; +import { currentDate } from "@gc-digital-talent/date-helpers"; + +import formLabels from "../formLabels"; +import { FormValues } from "../apiUtils"; + +export const TrainingOpportunityFormOptions_Fragment = graphql(/* GraphQL */ ` + fragment TrainingOpportunityFormOptions on Query { + courseLanguages: localizedEnumStrings(enumName: "CourseLanguage") { + value + label { + en + fr + } + } + courseFormats: localizedEnumStrings(enumName: "CourseFormat") { + value + label { + en + fr + } + } + } +`); + +interface TrainingOpportunityFormProps { + query: FragmentType; +} + +const TrainingOpportunityForm = ({ query }: TrainingOpportunityFormProps) => { + const intl = useIntl(); + const { courseLanguages, courseFormats } = getFragment( + TrainingOpportunityFormOptions_Fragment, + query, + ); + const startDate = useWatch({ name: "trainingStartDate" }); + return ( +
+ + + + +
+ {/* intentionally left blank */} +
+ + + + + + +
+ ); +}; + +export default TrainingOpportunityForm; diff --git a/apps/web/src/pages/TrainingOpportunities/formLabels.ts b/apps/web/src/pages/TrainingOpportunities/formLabels.ts new file mode 100644 index 00000000000..770fca5ed87 --- /dev/null +++ b/apps/web/src/pages/TrainingOpportunities/formLabels.ts @@ -0,0 +1,61 @@ +import { defineMessages } from "react-intl"; + +const formLabels = defineMessages({ + titleEn: { + defaultMessage: "Title (English)", + id: "MHZ1CD", + description: "The title, in English", + }, + titleFr: { + defaultMessage: "Title (French)", + id: "55Me4a", + description: "The title, in French", + }, + courseLanguage: { + defaultMessage: "Course language", + id: "Cvl9dd", + description: "The language of the training opportunity", + }, + format: { + defaultMessage: "Format", + id: "yWen8A", + description: "The format of the training opportunity", + }, + applicationDeadline: { + defaultMessage: "Application deadline", + id: "FO0HTu", + description: "The application deadline of the training opportunity", + }, + trainingStartDate: { + defaultMessage: "Training start date", + id: "xCjb82", + description: "The training start date of the training opportunity", + }, + trainingEndDate: { + defaultMessage: "Training end date", + id: "bg8KBg", + description: "The training end date of the training opportunity", + }, + descriptionEn: { + defaultMessage: "Description (English)", + id: "gASASB", + description: "Title for description in English.", + }, + descriptionFr: { + defaultMessage: "Description (French)", + id: "DK2tnK", + description: "Title for description in French.", + }, + applicationUrlEn: { + defaultMessage: "Application URL (English)", + id: "dzCyaO", + description: "The English application URL of the training opportunity", + }, + applicationUrlFr: { + defaultMessage: "Application URL (French)", + id: "N5xj2X", + description: "The French application URL of the training opportunity", + }, +}); + +export default formLabels; diff --git a/packages/i18n/src/lang/fr.json b/packages/i18n/src/lang/fr.json index f51d9ea574f..a52c1b93b22 100644 --- a/packages/i18n/src/lang/fr.json +++ b/packages/i18n/src/lang/fr.json @@ -1043,6 +1043,10 @@ "defaultMessage": "Termine aujourd’hui, à {time}", "description": "Text displayed when relative date is today." }, + "k3i6lU": { + "defaultMessage": "Langue", + "description": "Legend for a language input or title" + }, "k8IB9g": { "defaultMessage": "La date d’expiration doit être postérieure à aujourd’hui. Veuillez saisir une date valide.", "description": "Error message that an expiry date must be in the future" diff --git a/packages/i18n/src/messages/commonMessages.ts b/packages/i18n/src/messages/commonMessages.ts index e664a144c5f..5af1fff6360 100644 --- a/packages/i18n/src/messages/commonMessages.ts +++ b/packages/i18n/src/messages/commonMessages.ts @@ -289,6 +289,11 @@ const commonMessages = defineMessages({ id: "pOL68A", description: "Title for work email address", }, + language: { + defaultMessage: "Language", + id: "k3i6lU", + description: "Legend for a language input or title", + }, }); export default commonMessages;