diff --git a/api/app/Policies/WorkStreamPolicy.php b/api/app/Policies/WorkStreamPolicy.php index 02b48143663..27c270cc221 100644 --- a/api/app/Policies/WorkStreamPolicy.php +++ b/api/app/Policies/WorkStreamPolicy.php @@ -20,7 +20,7 @@ public function viewAny(?User $user): bool */ public function view(?User $user, WorkStream $workStream): bool { - return false; + return true; } /** diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql index cd331bcbed7..dc476dcde97 100644 --- a/api/graphql/schema.graphql +++ b/api/graphql/schema.graphql @@ -1167,6 +1167,7 @@ type Query { team(id: UUID! @eq): Team @find @canQuery(ability: "view") teams: [Team]! @all @canModel(ability: "viewAny") roles: [Role]! @all @canModel(ability: "viewAny") + workStream(id: UUID! @eq): WorkStream @find @canQuery(ability: "view") workStreams: [WorkStream]! @all @canModel(ability: "viewAny") notifications( diff --git a/api/storage/app/lighthouse-schema.graphql b/api/storage/app/lighthouse-schema.graphql index 1effd0a7398..51d79b2652c 100755 --- a/api/storage/app/lighthouse-schema.graphql +++ b/api/storage/app/lighthouse-schema.graphql @@ -331,6 +331,7 @@ type Query { team(id: UUID!): Team teams: [Team]! roles: [Role]! + workStream(id: UUID!): WorkStream workStreams: [WorkStream]! sitewideAnnouncement: SitewideAnnouncement trainingOpportunity(id: UUID!): TrainingOpportunity diff --git a/apps/web/src/components/Router.tsx b/apps/web/src/components/Router.tsx index c265dcce8a4..f9c0699a372 100644 --- a/apps/web/src/components/Router.tsx +++ b/apps/web/src/components/Router.tsx @@ -936,6 +936,40 @@ const createRoute = (locale: Locales) => lazy: () => import("../pages/AnnouncementsPage/AnnouncementsPage"), }, + { + path: "work-streams", + children: [ + { + index: true, + lazy: () => + import("../pages/WorkStreams/IndexWorkStreamPage"), + }, + { + path: "create", + lazy: () => + import("../pages/WorkStreams/CreateWorkStreamPage"), + }, + { + path: ":workStreamId", + children: [ + { + index: true, + lazy: () => + import( + "../pages/WorkStreams/ViewWorkStreamsPage" + ), + }, + { + path: "edit", + lazy: () => + import( + "../pages/WorkStreams/UpdateWorkStreamPage" + ), + }, + ], + }, + ], + }, ], }, { diff --git a/apps/web/src/hooks/useRoutes.ts b/apps/web/src/hooks/useRoutes.ts index 1213da00eea..9d6000db7f4 100644 --- a/apps/web/src/hooks/useRoutes.ts +++ b/apps/web/src/hooks/useRoutes.ts @@ -333,6 +333,14 @@ const getRoutes = (lang: Locales) => { "/", ), + // Admin - Work Streams + workStreamTable: () => [adminUrl, "settings", "work-streams"].join("/"), + workStreamCreate: () => + [adminUrl, "settings", "work-streams", "create"].join("/"), + workStreamView: (id: string) => `${adminUrl}/settings/work-streams/${id}`, + workStreamUpdate: (workStreamId: string) => + [adminUrl, "settings", "work-streams", workStreamId, "edit"].join("/"), + /** * Deprecated * diff --git a/apps/web/src/messages/pageTitles.ts b/apps/web/src/messages/pageTitles.ts index e86601fe870..f962d192e02 100644 --- a/apps/web/src/messages/pageTitles.ts +++ b/apps/web/src/messages/pageTitles.ts @@ -87,4 +87,9 @@ export default defineMessages({ description: "Title for the page showing list of job advertisement templates", }, + workStreams: { + defaultMessage: "Work streams", + id: "uK9/a4", + description: "Title for the index work streams page", + }, }); diff --git a/apps/web/src/pages/AdminDashboardPage/AdminDashboardPage.tsx b/apps/web/src/pages/AdminDashboardPage/AdminDashboardPage.tsx index b40d3709306..410f4e7568b 100644 --- a/apps/web/src/pages/AdminDashboardPage/AdminDashboardPage.tsx +++ b/apps/web/src/pages/AdminDashboardPage/AdminDashboardPage.tsx @@ -194,6 +194,11 @@ export const DashboardPage = ({ currentUser }: DashboardPageProps) => { ROLE_NAME.PlatformAdmin, ], }, + { + label: intl.formatMessage(pageTitles.workStreams), + href: adminRoutes.workStreamTable(), + roles: [ROLE_NAME.PlatformAdmin], + }, ]; const administrationCollectionFiltered = administrationCollection.filter( (item) => hasRolesHandleNoRolesRequired(item.roles, roleAssignments), diff --git a/apps/web/src/pages/CommunityDashboardPage/CommunityDashboardPage.tsx b/apps/web/src/pages/CommunityDashboardPage/CommunityDashboardPage.tsx index 85fc09095a1..582410a4ae0 100644 --- a/apps/web/src/pages/CommunityDashboardPage/CommunityDashboardPage.tsx +++ b/apps/web/src/pages/CommunityDashboardPage/CommunityDashboardPage.tsx @@ -194,6 +194,11 @@ export const DashboardPage = ({ currentUser }: DashboardPageProps) => { ROLE_NAME.PlatformAdmin, ], }, + { + label: intl.formatMessage(pageTitles.workStreams), + href: adminRoutes.workStreamTable(), + roles: [ROLE_NAME.PlatformAdmin], + }, ]; const administrationCollectionFiltered = administrationCollection.filter( (item) => hasRolesHandleNoRolesRequired(item.roles, roleAssignments), diff --git a/apps/web/src/pages/WorkStreams/CreateWorkStreamPage.tsx b/apps/web/src/pages/WorkStreams/CreateWorkStreamPage.tsx new file mode 100644 index 00000000000..98d57b06b22 --- /dev/null +++ b/apps/web/src/pages/WorkStreams/CreateWorkStreamPage.tsx @@ -0,0 +1,339 @@ +import { useNavigate } from "react-router"; +import { SubmitHandler } from "react-hook-form"; +import { useIntl } from "react-intl"; +import { useMutation, useQuery } from "urql"; +import IdentificationIcon from "@heroicons/react/24/outline/IdentificationIcon"; + +import { toast } from "@gc-digital-talent/toast"; +import { + BasicForm, + Input, + OptGroupOrOption, + Select, + Submit, +} from "@gc-digital-talent/forms"; +import { commonMessages, errorMessages } from "@gc-digital-talent/i18n"; +import { + graphql, + Scalars, + CreateWorkStreamInput, + LocalizedStringInput, + InputMaybe, +} from "@gc-digital-talent/graphql"; +import { ROLE_NAME } from "@gc-digital-talent/auth"; +import { + Heading, + Link, + CardSeparator, + CardBasic, + Pending, +} from "@gc-digital-talent/ui"; +import { unpackMaybes } from "@gc-digital-talent/helpers"; + +import SEO from "~/components/SEO/SEO"; +import useRoutes from "~/hooks/useRoutes"; +import useBreadcrumbs from "~/hooks/useBreadcrumbs"; +import RequireAuth from "~/components/RequireAuth/RequireAuth"; +import pageTitles from "~/messages/pageTitles"; +import Hero from "~/components/Hero"; +import adminMessages from "~/messages/adminMessages"; + +const CreateWorkStream_Mutation = graphql(/* GraphQL */ ` + mutation CreateWorkStream($workStream: CreateWorkStreamInput!) { + createWorkStream(workStream: $workStream) { + id + } + } +`); + +interface FormValues { + key: InputMaybe; + name: LocalizedStringInput; + plainLanguageName?: InputMaybe; + community: string; +} + +const formValuesToSubmitData = (data: FormValues): CreateWorkStreamInput => { + const communityId = data.community; + return { + key: data.key, + name: { + en: data.name?.en, + fr: data.name?.fr, + }, + plainLanguageName: { + en: data.plainLanguageName?.en, + fr: data.plainLanguageName?.fr, + }, + community: { connect: communityId }, + }; +}; + +interface CreateWorkStreamProps { + communityOptions: OptGroupOrOption[]; +} + +export const CreateWorkStreamForm = ({ + communityOptions, +}: CreateWorkStreamProps) => { + const intl = useIntl(); + const navigate = useNavigate(); + const paths = useRoutes(); + const [, executeMutation] = useMutation(CreateWorkStream_Mutation); + + const handleError = () => { + toast.error( + intl.formatMessage({ + defaultMessage: "Error: creating work stream failed", + id: "R+PiXo", + description: "Messaged displayed after creating work stream fails", + }), + ); + }; + + const handleSubmit: SubmitHandler = async (data) => { + return executeMutation({ workStream: formValuesToSubmitData(data) }) + .then(async (result) => { + if (result.data?.createWorkStream) { + await navigate( + paths.workStreamView(result.data?.createWorkStream.id), + ); + toast.success( + intl.formatMessage({ + defaultMessage: "Work stream created successfully!", + id: "bPN0EF", + description: "Message displayed after a work stream is created", + }), + ); + } else { + handleError(); + } + }) + .catch(handleError); + }; + + const navigationCrumbs = useBreadcrumbs({ + crumbs: [ + { + label: intl.formatMessage(pageTitles.workStreams), + url: paths.workStreamTable(), + }, + { + label: intl.formatMessage({ + defaultMessage: "Create work stream", + id: "TwR8/e", + description: "Breadcrumb title for the create work stream page link.", + }), + url: paths.workStreamCreate(), + }, + ], + }); + + const pageTitle = intl.formatMessage({ + defaultMessage: "Create a work stream", + id: "fo51DL", + description: "Page title for the work stream creation page", + }); + + return ( + <> + + + +
+ + {intl.formatMessage({ + defaultMessage: "Work stream information", + id: "0bf24C", + description: "Heading for the 'create a work stream' form", + })} + +
+ +
+ + +
+

+ {intl.formatMessage({ + defaultMessage: + "We recommend adding an alternative name using plain language for non-government users.", + id: "eZT5Vr", + description: "Suggestion for the next work stream input.", + })} +

+
+ + +
+ +
+
+ +
+ + + {intl.formatMessage({ + defaultMessage: "Cancel and go back to work streams", + id: "kh9KTx", + description: "Link text to cancel updating a work stream", + })} + +
+
+
+
+ + ); +}; + +const CreateWorkStream_Query = graphql(/* GraphQL */ ` + query CreateWorkStreamOptions { + communities { + id + name { + en + fr + localized + } + } + } +`); + +const CreateWorkStreamPage = () => { + const [{ data: lookupData, fetching, error }] = useQuery({ + query: CreateWorkStream_Query, + }); + + const communityOptions: OptGroupOrOption[] = unpackMaybes( + lookupData?.communities, + ).map(({ id, name }) => ({ + value: id, + label: name?.localized, + })); + + return ( + + + + ); +}; + +export const Component = () => ( + + + +); + +Component.displayName = "AdminCreateWorkStreamPage"; + +export default CreateWorkStreamPage; diff --git a/apps/web/src/pages/WorkStreams/IndexWorkStreamPage.tsx b/apps/web/src/pages/WorkStreams/IndexWorkStreamPage.tsx new file mode 100644 index 00000000000..0d535430d8e --- /dev/null +++ b/apps/web/src/pages/WorkStreams/IndexWorkStreamPage.tsx @@ -0,0 +1,49 @@ +import { useIntl } from "react-intl"; + +import { ROLE_NAME } from "@gc-digital-talent/auth"; + +import SEO from "~/components/SEO/SEO"; +import useRoutes from "~/hooks/useRoutes"; +import useBreadcrumbs from "~/hooks/useBreadcrumbs"; +import RequireAuth from "~/components/RequireAuth/RequireAuth"; +import pageTitles from "~/messages/pageTitles"; +import Hero from "~/components/Hero"; +import AdminContentWrapper from "~/components/AdminContentWrapper/AdminContentWrapper"; + +import WorkStreamTableApi from "./WorkStreamTable"; + +export const IndexWorkStreamPage = () => { + const intl = useIntl(); + const routes = useRoutes(); + + const formattedPageTitle = intl.formatMessage(pageTitles.workStreams); + + const navigationCrumbs = useBreadcrumbs({ + crumbs: [ + { + label: formattedPageTitle, + url: routes.workStreamTable(), + }, + ], + }); + + return ( + <> + + + + + + + ); +}; + +export const Component = () => ( + + + +); + +Component.displayName = "AdminIndexWorkStreamPage"; + +export default IndexWorkStreamPage; diff --git a/apps/web/src/pages/WorkStreams/UpdateWorkStreamPage.tsx b/apps/web/src/pages/WorkStreams/UpdateWorkStreamPage.tsx new file mode 100644 index 00000000000..d5488f3639e --- /dev/null +++ b/apps/web/src/pages/WorkStreams/UpdateWorkStreamPage.tsx @@ -0,0 +1,379 @@ +import { useNavigate } from "react-router"; +import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; +import { useIntl } from "react-intl"; +import { useMutation, useQuery } from "urql"; +import IdentificationIcon from "@heroicons/react/24/outline/IdentificationIcon"; + +import { toast } from "@gc-digital-talent/toast"; +import { + Input, + OptGroupOrOption, + Select, + Submit, +} from "@gc-digital-talent/forms"; +import { + errorMessages, + commonMessages, + formMessages, +} from "@gc-digital-talent/i18n"; +import { + Pending, + NotFound, + Heading, + Link, + CardSeparator, + CardBasic, +} from "@gc-digital-talent/ui"; +import { + FragmentType, + InputMaybe, + LocalizedStringInput, + Scalars, + UpdateWorkStreamInput, + WorkStreamQuery, + getFragment, + graphql, +} from "@gc-digital-talent/graphql"; +import { ROLE_NAME } from "@gc-digital-talent/auth"; +import { unpackMaybes } from "@gc-digital-talent/helpers"; + +import SEO from "~/components/SEO/SEO"; +import useRoutes from "~/hooks/useRoutes"; +import useRequiredParams from "~/hooks/useRequiredParams"; +import adminMessages from "~/messages/adminMessages"; +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"; + +const UpdateWorkStream_Mutation = graphql(/* GraphQL */ ` + mutation UpdateWorkStream($id: UUID!, $workStream: UpdateWorkStreamInput!) { + updateWorkStream(id: $id, workStream: $workStream) { + id + } + } +`); + +export const WorkStreamUpdate_Fragment = graphql(/* GraphQL */ ` + fragment WorkStreamUpdate on WorkStream { + id + key + name { + en + fr + localized + } + plainLanguageName { + en + fr + } + community { + id + key + name { + en + fr + } + } + } +`); + +interface FormValues { + name?: InputMaybe; + plainLanguageName?: InputMaybe; + community: string; +} + +const formValuesToSubmitData = (data: FormValues): UpdateWorkStreamInput => { + const communityId = data.community; + return { + name: { + en: data.name?.en, + fr: data.name?.fr, + }, + plainLanguageName: { + en: data.plainLanguageName?.en, + fr: data.plainLanguageName?.fr, + }, + community: { connect: communityId }, + }; +}; + +interface UpdateWorkStreamProps { + query: FragmentType; + communityOptions: OptGroupOrOption[]; +} + +export const UpdateWorkStreamForm = ({ + query, + communityOptions, +}: UpdateWorkStreamProps) => { + const intl = useIntl(); + const navigate = useNavigate(); + const paths = useRoutes(); + const [, executeMutation] = useMutation(UpdateWorkStream_Mutation); + const workStream = getFragment(WorkStreamUpdate_Fragment, query); + + const methods = useForm({ + defaultValues: { + name: workStream.name, + plainLanguageName: workStream.plainLanguageName, + community: workStream.community?.id, + }, + }); + const { handleSubmit } = methods; + + const handleError = () => { + toast.error( + intl.formatMessage({ + defaultMessage: "Error: updating work stream failed", + id: "uNKq32", + description: + "Message displayed to user after work stream fails to get updated.", + }), + ); + }; + + const onSubmit: SubmitHandler = async (data: FormValues) => { + return executeMutation({ + id: workStream.id, + workStream: formValuesToSubmitData(data), + }) + .then(async (result) => { + if (result.data?.updateWorkStream) { + await navigate( + paths.workStreamView(result.data?.updateWorkStream.id), + ); + toast.success( + intl.formatMessage({ + defaultMessage: "Work stream updated successfully!", + id: "0cieq3", + description: + "Message displayed to user after work stream is updated successfully.", + }), + ); + } else { + handleError(); + } + }) + .catch(handleError); + }; + + const navigationCrumbs = useBreadcrumbs({ + crumbs: [ + { + label: intl.formatMessage(pageTitles.workStreams), + url: paths.workStreamTable(), + }, + { + label: workStream.name?.localized, + url: paths.workStreamView(workStream.id), + }, + { + label: intl.formatMessage({ + defaultMessage: "Edit work stream", + id: "da/TLc", + description: "Breadcrumb title for the edit work stream page link.", + }), + url: paths.workStreamUpdate(workStream.id), + }, + ], + }); + + const pageTitle = intl.formatMessage({ + defaultMessage: "Edit a work stream", + id: "4SdmnS", + description: "Page title for the work stream edit page", + }); + + return ( + <> + + + +
+ + {intl.formatMessage({ + defaultMessage: "Work stream information", + id: "3D8drd", + description: "Heading for the 'update a work stream' form", + })} + +
+ +
+
+ + + + +
+