diff --git a/package.json b/package.json index d6eb98a67..349b4d797 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "@analytics/google-tag-manager": "^0.6.0", "@analytics/hubspot": "^0.5.1", "@appquality/languages": "1.4.3", - "@appquality/unguess-design-system": "4.0.50", + "@appquality/unguess-design-system": "4.0.53", "@atlaskit/pragmatic-drag-and-drop": "^1.7.4", "@atlaskit/pragmatic-drag-and-drop-flourish": "^2.0.3", "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0", @@ -22,6 +22,7 @@ "i18n-iso-countries": "^7.3.0", "i18next": "^23.15.1", "i18next-browser-languagedetector": "^8.0.0", + "mailchecker": "^6.0.19", "motion": "^12.16.0", "qs": "^6.10.3", "query-string": "^7.1.1", @@ -130,4 +131,4 @@ "*.{tsx,ts,js,css,md}": "prettier --write" }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" -} \ No newline at end of file +} diff --git a/src/assets/icons/email-icon.svg b/src/assets/icons/email-icon.svg new file mode 100644 index 000000000..8cf0237ce --- /dev/null +++ b/src/assets/icons/email-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/eye-icon-fill.svg b/src/assets/icons/eye-icon-fill.svg new file mode 100644 index 000000000..c1829260d --- /dev/null +++ b/src/assets/icons/eye-icon-fill.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/eye-icon-slash.svg b/src/assets/icons/eye-icon-slash.svg new file mode 100644 index 000000000..906f48a59 --- /dev/null +++ b/src/assets/icons/eye-icon-slash.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/eye-icon.svg b/src/assets/icons/eye-icon.svg new file mode 100644 index 000000000..e7e4f55d0 --- /dev/null +++ b/src/assets/icons/eye-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/save-icon.svg b/src/assets/icons/save-icon.svg new file mode 100644 index 000000000..88a8684f5 --- /dev/null +++ b/src/assets/icons/save-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/save.svg b/src/assets/icons/save.svg new file mode 100644 index 000000000..174d63f91 --- /dev/null +++ b/src/assets/icons/save.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/common/disposableEmail.ts b/src/common/disposableEmail.ts new file mode 100644 index 000000000..988009caf --- /dev/null +++ b/src/common/disposableEmail.ts @@ -0,0 +1,5 @@ +import MailChecker from 'mailchecker'; + +export function isDisposableEmail(email: string): any { + return !MailChecker.isValid(email); +} diff --git a/src/common/schema.ts b/src/common/schema.ts index ed571c854..c43532b86 100644 --- a/src/common/schema.ts +++ b/src/common/schema.ts @@ -24,10 +24,12 @@ export interface paths { }; "/buy": { /** - * Stripe webhook destination, listen three event types: - * - charge.failed, - * - checkout.session.completed - * - checkout.session.expired + * Stripe webhook destination, listen following event types: + * - checkout.session.completed (Occurs when a Checkout Session has been successfully completed) + * - checkout.session.expired (Occurs when a Checkout Session is expired) + * - checkout.session.async_payment_failed (Occurs when a payment intent using a delayed payment method fails) + * - checkout.session.async_payment_succeeded (Occurs when a payment intent using a delayed payment method finally succeeds) + * - charge.refunded (Occurs whenever a charge is refunded, including partial refunds. Listen to refund.created for information about the refund) */ post: operations["post-buy"]; }; @@ -296,6 +298,15 @@ export interface paths { }; }; }; + "/campaigns/{cid}/video-tags/{tagId}": { + patch: operations["patch-campaigns-cid-video-tags-tagId"]; + parameters: { + path: { + cid: string; + tagId: string; + }; + }; + }; "/campaigns/{cid}/videos": { /** Return all published video for a specific campaign */ get: operations["get-campaigns-cid-videos"]; @@ -475,6 +486,10 @@ export interface paths { }; }; }; + "/users/me/watched/plans": { + get: operations["get-users-me-watched-plans"]; + parameters: {}; + }; "/users/roles": { get: operations["get-users-roles"]; parameters: {}; @@ -622,6 +637,17 @@ export interface paths { }; }; }; + "/plans/{pid}/watchers": { + /** Returns all the watcher added to a plan. It always returns at least one item. */ + get: operations["get-plans-pid-watchers"]; + put: operations["put-plans-pid-watchers"]; + post: operations["post-plans-pid-watchers"]; + parameters: { + path: { + pid: string; + }; + }; + }; "/workspaces/{wid}/templates": { get: operations["get-workspaces-templates"]; post: operations["post-workspaces-wid-templates"]; @@ -656,6 +682,15 @@ export interface paths { }; }; }; + "/plans/{pid}/watchers/{profile_id}": { + delete: operations["delete-plans-pid-watchers-profile_id"]; + parameters: { + path: { + pid: string; + profile_id: string; + }; + }; + }; } export interface components { @@ -1332,8 +1367,7 @@ export interface components { kind: "app"; os: { ios?: string; - linux?: string; - windows?: string; + android?: string; }; }; /** OutputModuleTouchpointsWebDesktop */ @@ -1442,7 +1476,8 @@ export interface components { | "module_type" | "number_of_testers" | "number_of_tasks" - | "task_type"; + | "task_type" + | "duplicate_touchpoint_form_factors"; /** Report */ Report: { creation_date?: string; @@ -1999,10 +2034,12 @@ export interface operations { requestBody: components["requestBodies"]["Credentials"]; }; /** - * Stripe webhook destination, listen three event types: - * - charge.failed, - * - checkout.session.completed - * - checkout.session.expired + * Stripe webhook destination, listen following event types: + * - checkout.session.completed (Occurs when a Checkout Session has been successfully completed) + * - checkout.session.expired (Occurs when a Checkout Session is expired) + * - checkout.session.async_payment_failed (Occurs when a payment intent using a delayed payment method fails) + * - checkout.session.async_payment_succeeded (Occurs when a payment intent using a delayed payment method finally succeeds) + * - charge.refunded (Occurs whenever a charge is refunded, including partial refunds. Listen to refund.created for information about the refund) */ "post-buy": { responses: { @@ -2027,13 +2064,17 @@ export interface operations { key: string; tag: string; }; + /** @enum {string} */ + payment_status?: "paid" | "unpaid"; }; }; /** @enum {undefined} */ type: - | "checkout.session.completed" + | "checkout.session.async_payment_succeeded" | "checkout.session.async_payment_failed" - | "checkout.session.expired"; + | "checkout.session.completed" + | "checkout.session.expired" + | "charge.refunded"; }; }; }; @@ -3119,6 +3160,37 @@ export interface operations { }; }; }; + "patch-campaigns-cid-video-tags-tagId": { + parameters: { + path: { + cid: string; + tagId: string; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": { [key: string]: unknown }; + }; + }; + /** Bad Request */ + 400: unknown; + /** Conflict */ + 409: { + content: { + "application/json": { [key: string]: unknown }; + }; + }; + }; + requestBody: { + content: { + "application/json": { + newTagName: string; + }; + }; + }; + }; /** Return all published video for a specific campaign */ "get-campaigns-cid-videos": { parameters: { @@ -3864,6 +3936,31 @@ export interface operations { }; }; }; + "get-users-me-watched-plans": { + parameters: {}; + responses: { + 200: { + content: { + "application/json": { + items: { + id?: number; + name?: string; + project?: { + name?: string; + id?: number; + }; + isLast?: boolean; + }[]; + allItems: number; + }; + }; + }; + 400: components["responses"]["Error"]; + 403: components["responses"]["Error"]; + 404: components["responses"]["Error"]; + 500: components["responses"]["Error"]; + }; + }; "get-users-roles": { parameters: {}; responses: { @@ -4393,6 +4490,81 @@ export interface operations { 500: components["responses"]["Error"]; }; }; + /** Returns all the watcher added to a plan. It always returns at least one item. */ + "get-plans-pid-watchers": { + parameters: { + path: { + pid: string; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": { + items: { + id: number; + name: string; + surname: string; + email: string; + image?: string; + isInternal: boolean; + }[]; + }; + }; + }; + 403: components["responses"]["Error"]; + 404: components["responses"]["Error"]; + 500: components["responses"]["Error"]; + }; + }; + "put-plans-pid-watchers": { + parameters: { + path: { + pid: string; + }; + }; + responses: { + /** OK */ + 200: unknown; + 400: components["responses"]["Error"]; + 403: components["responses"]["Error"]; + 404: components["responses"]["Error"]; + 500: components["responses"]["Error"]; + }; + requestBody: { + content: { + "application/json": { + users: { + id: number; + }[]; + }; + }; + }; + }; + "post-plans-pid-watchers": { + parameters: { + path: { + pid: string; + }; + }; + responses: { + /** OK */ + 200: unknown; + 403: components["responses"]["Error"]; + 404: components["responses"]["Error"]; + 500: components["responses"]["Error"]; + }; + requestBody: { + content: { + "application/json": { + users: { + id: number; + }[]; + }; + }; + }; + }; "get-workspaces-templates": { parameters: { path: { @@ -4596,6 +4768,25 @@ export interface operations { }; }; }; + "delete-plans-pid-watchers-profile_id": { + parameters: { + path: { + pid: string; + profile_id: string; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": { + success?: boolean; + }; + }; + }; + 403: components["responses"]["Error"]; + }; + }; } export interface external {} diff --git a/src/features/api/api.ts b/src/features/api/api.ts index 09f20fe15..4f1b55b80 100644 --- a/src/features/api/api.ts +++ b/src/features/api/api.ts @@ -26,6 +26,7 @@ export const apiSlice = createApi({ 'Tags', 'CustomStatuses', 'Bug', + 'PlanWatchers', 'BugComments', 'Preferences', 'VideoTags', diff --git a/src/features/api/apiTags.ts b/src/features/api/apiTags.ts index 0b0253cfa..5df8562ec 100644 --- a/src/features/api/apiTags.ts +++ b/src/features/api/apiTags.ts @@ -154,6 +154,9 @@ unguessApi.enhanceEndpoints({ getCampaignsByCidVideoTags: { providesTags: ['VideoTags'], }, + patchCampaignsByCidVideoTagsAndTagId: { + invalidatesTags: ['VideoTags'], + }, postCampaignsByCidVideoTags: { invalidatesTags: ['VideoTags'], }, @@ -330,6 +333,21 @@ unguessApi.enhanceEndpoints({ getPlansByPidRulesEvaluation: { providesTags: ['EvaluationRules'], }, + getPlansByPidWatchers: { + providesTags: ['PlanWatchers'], + }, + postPlansByPidWatchers: { + invalidatesTags: ['PlanWatchers'], + }, + putPlansByPidWatchers: { + invalidatesTags: ['PlanWatchers'], + }, + deletePlansByPidWatchersAndProfileId: { + invalidatesTags: ['PlanWatchers', 'Plans'], + }, + getUsersMeWatchedPlans: { + providesTags: ['Plans', 'PlanWatchers'], + }, }, }); diff --git a/src/features/api/index.ts b/src/features/api/index.ts index 1a35d8b1e..f367d74a2 100644 --- a/src/features/api/index.ts +++ b/src/features/api/index.ts @@ -349,6 +349,16 @@ const injectedRtkApi = api.injectEndpoints({ body: queryArg.body, }), }), + patchCampaignsByCidVideoTagsAndTagId: build.mutation< + PatchCampaignsByCidVideoTagsAndTagIdApiResponse, + PatchCampaignsByCidVideoTagsAndTagIdApiArg + >({ + query: (queryArg) => ({ + url: `/campaigns/${queryArg.cid}/video-tags/${queryArg.tagId}`, + method: 'PATCH', + body: queryArg.body, + }), + }), getCampaignsByCidVideos: build.query< GetCampaignsByCidVideosApiResponse, GetCampaignsByCidVideosApiArg @@ -611,6 +621,12 @@ const injectedRtkApi = api.injectEndpoints({ body: queryArg.body, }), }), + getUsersMeWatchedPlans: build.query< + GetUsersMeWatchedPlansApiResponse, + GetUsersMeWatchedPlansApiArg + >({ + query: () => ({ url: `/users/me/watched/plans` }), + }), getUsersRoles: build.query({ query: () => ({ url: `/users/roles` }), }), @@ -786,6 +802,32 @@ const injectedRtkApi = api.injectEndpoints({ params: { limit: queryArg.limit, start: queryArg.start }, }), }), + getPlansByPidWatchers: build.query< + GetPlansByPidWatchersApiResponse, + GetPlansByPidWatchersApiArg + >({ + query: (queryArg) => ({ url: `/plans/${queryArg.pid}/watchers` }), + }), + postPlansByPidWatchers: build.mutation< + PostPlansByPidWatchersApiResponse, + PostPlansByPidWatchersApiArg + >({ + query: (queryArg) => ({ + url: `/plans/${queryArg.pid}/watchers`, + method: 'POST', + body: queryArg.body, + }), + }), + putPlansByPidWatchers: build.mutation< + PutPlansByPidWatchersApiResponse, + PutPlansByPidWatchersApiArg + >({ + query: (queryArg) => ({ + url: `/plans/${queryArg.pid}/watchers`, + method: 'PUT', + body: queryArg.body, + }), + }), getWorkspacesByWidTemplates: build.query< GetWorkspacesByWidTemplatesApiResponse, GetWorkspacesByWidTemplatesApiArg @@ -862,6 +904,15 @@ const injectedRtkApi = api.injectEndpoints({ body: queryArg.body, }), }), + deletePlansByPidWatchersAndProfileId: build.mutation< + DeletePlansByPidWatchersAndProfileIdApiResponse, + DeletePlansByPidWatchersAndProfileIdApiArg + >({ + query: (queryArg) => ({ + url: `/plans/${queryArg.pid}/watchers/${queryArg.profileId}`, + method: 'DELETE', + }), + }), }), overrideExisting: false, }); @@ -896,12 +947,15 @@ export type PostBuyApiArg = { key: string; tag: string; }; + payment_status?: 'paid' | 'unpaid'; }; }; type: - | 'checkout.session.completed' + | 'checkout.session.async_payment_succeeded' | 'checkout.session.async_payment_failed' - | 'checkout.session.expired'; + | 'checkout.session.completed' + | 'checkout.session.expired' + | 'charge.refunded'; }; }; export type GetCampaignsByCidApiResponse = @@ -1441,6 +1495,15 @@ export type PostCampaignsByCidVideoTagsApiArg = { }; }; }; +export type PatchCampaignsByCidVideoTagsAndTagIdApiResponse = + /** status 200 OK */ {}; +export type PatchCampaignsByCidVideoTagsAndTagIdApiArg = { + cid: string; + tagId: string; + body: { + newTagName: string; + }; +}; export type GetCampaignsByCidVideosApiResponse = /** status 200 OK */ { items: (Video & { usecaseId: number; @@ -1778,6 +1841,19 @@ export type PutUsersMePreferencesBySlugApiArg = { value: string; }; }; +export type GetUsersMeWatchedPlansApiResponse = /** status 200 */ { + items: { + id?: number; + name?: string; + project?: { + name?: string; + id?: number; + }; + isLast?: boolean; + }[]; + allItems: number; +}; +export type GetUsersMeWatchedPlansApiArg = void; export type GetUsersRolesApiResponse = /** status 200 OK */ { id: number; name: string; @@ -2005,6 +2081,37 @@ export type GetWorkspacesByWidProjectsAndPidCampaignsApiArg = { /** Start pagination parameter */ start?: number; }; +export type GetPlansByPidWatchersApiResponse = /** status 200 OK */ { + items: { + id: number; + name: string; + surname: string; + email: string; + image?: string; + isInternal: boolean; + }[]; +}; +export type GetPlansByPidWatchersApiArg = { + pid: string; +}; +export type PostPlansByPidWatchersApiResponse = /** status 200 OK */ void; +export type PostPlansByPidWatchersApiArg = { + pid: string; + body: { + users: { + id: number; + }[]; + }; +}; +export type PutPlansByPidWatchersApiResponse = /** status 200 OK */ void; +export type PutPlansByPidWatchersApiArg = { + pid: string; + body: { + users: { + id: number; + }[]; + }; +}; export type GetWorkspacesByWidTemplatesApiResponse = /** status 200 OK */ { items: CpReqTemplate[]; } & PaginationData; @@ -2104,6 +2211,14 @@ export type PostWorkspacesByWidUsersApiArg = { surname?: string; }; }; +export type DeletePlansByPidWatchersAndProfileIdApiResponse = + /** status 200 OK */ { + success?: boolean; + }; +export type DeletePlansByPidWatchersAndProfileIdApiArg = { + pid: string; + profileId: string; +}; export type Error = { code: number; error: boolean; @@ -2999,6 +3114,7 @@ export const { useGetCampaignsByCidUxQuery, useGetCampaignsByCidVideoTagsQuery, usePostCampaignsByCidVideoTagsMutation, + usePatchCampaignsByCidVideoTagsAndTagIdMutation, useGetCampaignsByCidVideosQuery, useGetCampaignsByCidWidgetsQuery, usePostCheckoutMutation, @@ -3031,6 +3147,7 @@ export const { usePatchUsersMeMutation, useGetUsersMePreferencesQuery, usePutUsersMePreferencesBySlugMutation, + useGetUsersMeWatchedPlansQuery, useGetUsersRolesQuery, useGetVideosByVidQuery, useGetVideosByVidObservationsQuery, @@ -3050,6 +3167,9 @@ export const { useGetWorkspacesByWidProjectsQuery, useGetWorkspacesByWidProjectsAndPidQuery, useGetWorkspacesByWidProjectsAndPidCampaignsQuery, + useGetPlansByPidWatchersQuery, + usePostPlansByPidWatchersMutation, + usePutPlansByPidWatchersMutation, useGetWorkspacesByWidTemplatesQuery, usePostWorkspacesByWidTemplatesMutation, useDeleteWorkspacesByWidTemplatesAndTidMutation, @@ -3057,4 +3177,5 @@ export const { useDeleteWorkspacesByWidUsersMutation, useGetWorkspacesByWidUsersQuery, usePostWorkspacesByWidUsersMutation, + useDeletePlansByPidWatchersAndProfileIdMutation, } = injectedRtkApi; diff --git a/src/features/navigation/Navigation/NavigationProfileModal.tsx b/src/features/navigation/Navigation/NavigationProfileModal.tsx index 045d376f4..ed0f99940 100644 --- a/src/features/navigation/Navigation/NavigationProfileModal.tsx +++ b/src/features/navigation/Navigation/NavigationProfileModal.tsx @@ -9,11 +9,7 @@ import { useAppDispatch, useAppSelector } from 'src/app/hooks'; import { isDev } from 'src/common/isDevEnvironment'; import { prepareGravatar } from 'src/common/utils'; import WPAPI from 'src/common/wpapi'; -import { - useGetUsersMePreferencesQuery, - useGetUsersMeQuery, - usePutUsersMePreferencesBySlugMutation, -} from 'src/features/api'; +import { useGetUsersMeQuery } from 'src/features/api'; import { useActiveWorkspace } from 'src/hooks/useActiveWorkspace'; import { setProfileModalOpen } from '../navigationSlice'; import { usePathWithoutLocale } from '../usePathWithoutLocale'; @@ -31,41 +27,12 @@ export const NavigationProfileModal = () => { const dispatch = useAppDispatch(); const navigate = useNavigate(); - const { data: preferences } = useGetUsersMePreferencesQuery(); - - const notificationsPreference = preferences?.items?.find( - (preference) => preference?.name === 'notifications_enabled' - ); const pathWithoutLocale = usePathWithoutLocale(); - const [updatePreference] = usePutUsersMePreferencesBySlugMutation(); - const onProfileModalClose = () => { dispatch(setProfileModalOpen(false)); }; - const onSetSettings = async (value: string) => { - await updatePreference({ - slug: `${notificationsPreference?.name}`, - body: { value }, - }) - .unwrap() - .then(() => { - addToast( - ({ close }) => ( - - ), - { placement: 'top' } - ); - }); - }; - if (dataError || !user || isLoading) return null; const profileModal = { @@ -114,22 +81,6 @@ export const NavigationProfileModal = () => { title: t('__PROFILE_MODAL_PRIVACY_ITEM_LABEL'), url: 'https://www.iubenda.com/privacy-policy/833252/full-legal', }, - settingValue: notificationsPreference?.value ?? '0', - i18n: { - settingsTitle: t('__PROFILE_MODAL_NOTIFICATIONS_TITLE'), - settingsIntroText: t('__PROFILE_MODAL_NOTIFICATIONS_INTRO'), - settingsOutroText: { - paragraph_1: t('__PROFILE_MODAL_NOTIFICATIONS_OUTRO_P_1'), - paragraph_2: t('__PROFILE_MODAL_NOTIFICATIONS_OUTRO_P_2'), - paragraph_3: t('__PROFILE_MODAL_NOTIFICATIONS_OUTRO_P_3'), - }, - settingsToggle: { - title: t('__PROFILE_MODAL_NOTIFICATIONS_TOGGLE_TITLE'), - on: t('__PROFILE_MODAL_NOTIFICATIONS_TOGGLE_ON'), - off: t('__PROFILE_MODAL_NOTIFICATIONS_TOGGLE_OFF'), - }, - }, - onSetSettings, onSelectLanguage: (lang: string) => { if (pathWithoutLocale === false) return; if (lang === i18n.language) return; diff --git a/src/hooks/usePlan.ts b/src/hooks/usePlan.ts index 9170b728e..ea1861baf 100644 --- a/src/hooks/usePlan.ts +++ b/src/hooks/usePlan.ts @@ -135,4 +135,14 @@ const usePlanIsPurchasable = (planId?: string) => { return isPurchasable; }; -export { usePlan, usePlanIsDraft, usePlanIsPurchasable }; +const usePlanIsApproved = (planId?: string) => { + const { planComposedStatus } = usePlan(planId); + + const isApproved = + !!planComposedStatus && + ['PurchasedPlan', 'Accepted'].includes(planComposedStatus); + + return isApproved; +}; + +export { usePlan, usePlanIsDraft, usePlanIsPurchasable, usePlanIsApproved }; diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index affefa462..47d11da72 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -849,6 +849,11 @@ "__PLAN_PAGE_MODAL_SEND_REQUEST_TITLE_LABEL": "Activity title", "__PLAN_PAGE_MODAL_SEND_REQUEST_TOAST_ERROR": "Something went wrong while requesting the activity", "__PLAN_PAGE_MODAL_SEND_REQUEST_WAIT": "Setting up your requestWe're preparing everything for our experts to review. This will take just a few seconds.", + "__PLAN_PAGE_MODAL_SEND_REQUEST_WATCHERS_DESCRIPTION": "They’ll receive email updates at every stage", + "__PLAN_PAGE_MODAL_SEND_REQUEST_WATCHERS_ERROR": "Please add at least one team member to continue", + "__PLAN_PAGE_MODAL_SEND_REQUEST_WATCHERS_HINT": "You can add only workspace members in this phase", + "__PLAN_PAGE_MODAL_SEND_REQUEST_WATCHERS_LABEL": "Involve your team in this activity", + "__PLAN_PAGE_MODAL_SEND_REQUEST_WATCHERS_PLACEHOLDER": "Search for team members...", "__PLAN_PAGE_MODUL_GENERAL_REMOVE_MODAL_CANCEL": "Cancel", "__PLAN_PAGE_MODUL_GENERAL_REMOVE_MODAL_CONFIRM": "Confirm", "__PLAN_PAGE_MODUL_GENERAL_REMOVE_MODAL_DESCRIPTION": "If you delete the item, the entered information will no longer be recoverable.", @@ -1168,7 +1173,35 @@ "__PLAN_PAGE_TAB_SUMMARY_TAB_TITLE": "Get expert feedback", "__PLAN_PAGE_TAB_TARGET_TAB_TITLE": "Screen participants", "__PLAN_PAGE_TITLE": "Plan your activity", - "__PLAN_REQUEST_QUOTATION_CTA": "Submit Request", + "__PLAN_PAGE_WATCHER_LIST_ADD_SELF_TOAST_ERROR_MESSAGE": "Ops! Something went wrong. Please try again", + "__PLAN_PAGE_WATCHER_LIST_ADD_SELF_TOAST_MESSAGE": "You’ve started following this activity", + "__PLAN_PAGE_WATCHER_LIST_ADD_USER_ERROR_TOAST_MESSAGE": "Error while adding user", + "__PLAN_PAGE_WATCHER_LIST_APPROVED_ALERT_TEXT": "Add or remove followers from the dashboard", + "__PLAN_PAGE_WATCHER_LIST_APPROVED_ALERT_TITLE": "You’ll keep receiving notifications while the activity is in progress", + "__PLAN_PAGE_WATCHER_LIST_MODAL_ADD_MEMBERS_INFO_TOOLTIP": "Only workspace members can be added during the setup phase", + "__PLAN_PAGE_WATCHER_LIST_MODAL_FOLLOW_BUTTON": "Follow this activity", + "__PLAN_PAGE_WATCHER_LIST_MODAL_FOLLOW_BUTTON_DISABLED_TOOLTIP": "Become a workspace member to be included in updates", + "__PLAN_PAGE_WATCHER_LIST_MODAL_NO_WATCHERS_DESCRIPTION": "Add your team so they stay updated too", + "__PLAN_PAGE_WATCHER_LIST_MODAL_NO_WATCHERS_TITLE": "Add yourself as a workspace member", + "__PLAN_PAGE_WATCHER_LIST_MODAL_ONLY_ONE_WATCHER_DESCRIPTION": "Add your team so they stay updated too", + "__PLAN_PAGE_WATCHER_LIST_MODAL_ONLY_ONE_WATCHER_TITLE": "Only one person is following this activity!", + "__PLAN_PAGE_WATCHER_LIST_MODAL_REMOVE_BUTTON_DISABLED_TOOLTIP": "At least one person must follow this activity ", + "__PLAN_PAGE_WATCHER_LIST_MODAL_REMOVE_BUTTON_TOOLTIP": "If you remove this person, they will no longer receive email updates about this activity", + "__PLAN_PAGE_WATCHER_LIST_MODAL_SUGGESTIONS_TITLE": "Add members from your workspace", + "__PLAN_PAGE_WATCHER_LIST_MODAL_TITLE": "Stay updated on the activity setup", + "__PLAN_PAGE_WATCHER_LIST_MODAL_TITLE_DESCRIPTION": "Follow this activity and turn on notifications to receive important email updates", + "__PLAN_PAGE_WATCHER_LIST_MODAL_TITLE_DESCRIPTION_APPROVED": "Great job! You’ve completed the setup phase", + "__PLAN_PAGE_WATCHER_LIST_MODAL_UNFOLLOW_BUTTON": "Unfollow this activity", + "__PLAN_PAGE_WATCHER_LIST_MODAL_UNFOLLOW_BUTTON_DISABLED_TOOLTIP": "At least one person must follow this activity ", + "__PLAN_PAGE_WATCHER_LIST_MODAL_WATCHERS_TITLE": "People following this activity", + "__PLAN_PAGE_WATCHER_LIST_MODAL_WATCHERS_TITLE_APPROVED": "People followed setup phase", + "__PLAN_PAGE_WATCHER_LIST_REMOVE_LAST_USER_ERROR_TOAST_MESSAGE": "At least one person must follow this activity", + "__PLAN_PAGE_WATCHER_LIST_REMOVE_SELF_TOAST_MESSAGE": "You’ve unfollowed this activity", + "__PLAN_PAGE_WATCHER_LIST_REMOVE_USER_ERROR_TOAST_MESSAGE": "Error while removing user", + "__PLAN_PAGE_WATCHER_LIST_SELECT_ADD_MEMBERS_PLACEHOLDER": "Search by name or email", + "__PLAN_PAGE_WATCHER_LIST_TOOLTIP": "Follow this activity", + "__PLAN_PAGE_WATCHER_LIST_TOOLTIP_DESCRIPTION": "Stay updated with important email notifications", + "__PLAN_REQUEST_QUOTATION_CTA": "Submit", "__PLAN_RULE_DUPLICATE_TOUCHPOINT_FORM_FACTORS": "Multiple touchpoints of the same type", "__PLAN_RULE_MODULE_TYPE": "Custom features added", "__PLAN_RULE_NUMBER_OF_MODULES": "Extra modules added", @@ -1180,7 +1213,8 @@ "__PLAN_RULES_WHAT_MEANS_DESCRIPTION_1": "2-day expert review", "__PLAN_RULES_WHAT_MEANS_DESCRIPTION_2": "Custom quote via email", "__PLAN_RULES_WHAT_MEANS_DESCRIPTION_3": "Personalised support", - "__PLAN_SAVE_CONFIGURATION_CTA": "Save Draft", + "__PLAN_SAVE_CONFIGURATION_CTA": "Save", + "__PLAN_SAVE_CONFIGURATION_TOOLTIP": "Save draft", "__PLAN_SAVE_DRAFT_TOAST_ERROR": "We couldn't save your draft: please try again later.", "__PLAN_SAVE_DRAFT_TOAST_SUCCESS": "Activity draft saved! You can safely continue editing.", "__PLAN_SAVE_TEMPLATE_CTA": "Save as template", @@ -1212,10 +1246,34 @@ "__PROFILE_PAGE_COMPANY_SIZE_REQUIRED_ERROR": "Company size is required", "__PROFILE_PAGE_CONFIRM_PASSWORD_MUST_MATCH_NEW_PASSWORD": "The confirmation password must match the new password", "__PROFILE_PAGE_NAME_REQUIRED_ERROR": "Name is required", + "__PROFILE_PAGE_NAV_ITEM_NOTIFICATION_SETTINGS": "Notification settings", "__PROFILE_PAGE_NAV_ITEM_PASSWORD": "Password settings", "__PROFILE_PAGE_NAV_ITEM_PROFILE": "Profile settings", "__PROFILE_PAGE_NAV_SECTION_PASSWORD": "PASSWORD", "__PROFILE_PAGE_NEW_PASSWORD_REQUIRED_ERROR": "New password is required", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_PROGRESS_ALERT_ITEM_1": "When a quote is ready", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_PROGRESS_ALERT_ITEM_2": "When a payment is confirmed", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_PROGRESS_ALERT_ITEM_3": "When an activity is scheduled", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_PROGRESS_ALERT_ITEM_4": "When an activity is completed", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_PROGRESS_ALERT_ITEM_5": "When you’re mentioned in a comment", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_PROGRESS_ALERT_TITLE": "You’ll always receive:", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_PROGRESS_CHECKBOX_HINT": "From execution to completion, including comments in threads you participate in", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_PROGRESS_CHECKBOX_LABEL": "Activity progress", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_PROGRESS_UPDATES_FORM_LABEL": "Receive updates for:", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_PROGRESS_UPDATES_HINT": "Choose which progress notifications you’d like to receive by email for the activities you follow", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_PROGRESS_UPDATES_LABEL": "Activity updates", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_PROGRESS_UPDATES_TAG": "Recommended to keep enabled", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_SETUP_CHECKBOX_HINT": "From configuration through planning", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_SETUP_CHECKBOX_LABEL": "Activity setup", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_DESCRIPTION": "Manage which email updates you want to receive", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_FOLLOW_ACTIVITIES_BUTTON_TEXT": "Unfollow", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_FOLLOW_ACTIVITIES_HINT": "You’ll receive updates for activities only for these activities", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_FOLLOW_ACTIVITIES_HINT_TEXT": "Your changes are saved automatically", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_FOLLOW_ACTIVITIES_LABEL": "Activities you’re following", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_FOLLOW_ACTIVITIES_SETUP_DESCRIPTION": "Activity setup ", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_FOLLOW_ACTIVITIES_TAG": "tot.", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_LABEL": "Notification settings", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_SAVE_BUTTON_LABEL": "Save changes", "__PROFILE_PAGE_PASSWORD_ACCORDION_LABEL": "Password settings", "__PROFILE_PAGE_ROLE_REQUIRED_ERROR": "Role is required", "__PROFILE_PAGE_SURNAME_REQUIRED_ERROR": "Surname is required", @@ -1223,7 +1281,12 @@ "__PROFILE_PAGE_TOAST_ERROR_INVALID_CURRENT_PASSWORD": "Invalid current password. Please try again.", "__PROFILE_PAGE_TOAST_ERROR_UPDATING_PASSWORD": "An error occurred while updating the password. Please try again.", "__PROFILE_PAGE_TOAST_ERROR_UPDATING_PROFILE": "An error occurred while updating your profile. Please try again.", + "__PROFILE_PAGE_TOAST_ERROR_UPDATING_SETTINGS": "Something went wrong, please try again later.", "__PROFILE_PAGE_TOAST_SUCCESS_PASSWORD_UPDATED": "Password updated successfully", + "__PROFILE_PAGE_UNFOLLOW_BUTTON_DISABLED_TOOLTIP": "At least one person must follow this activity", + "__PROFILE_PAGE_UNFOLLOW_ERROR": "Ops! Something went wrong. Please try again", + "__PROFILE_PAGE_UNFOLLOW_SUCCESS": "You’ve unfollowed this activity", + "__PROFILE_PAGE_UPDATE_SETTINGS_SUCCESS": "Settings updated successfully", "__PROFILE_PAGE_UPDATE_SUCCESS": "Profile updated successfully", "__PROFILE_PAGE_USER_CARD_COMPANY_SIZE_LABEL": "Company size", "__PROFILE_PAGE_USER_CARD_COMPANY_SIZE_PLACEHOLDER": "Select a Company size", @@ -1340,12 +1403,18 @@ "__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_TAGS_SHOW_MORE_one": "Show more", "__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_TAGS_SHOW_MORE_other": "Show more", "__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_TITLE_DESCRIPTION": "Use themes to clusterize your observations", + "__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_TITLE_DUPLICATE_ERROR": "This option has already been used. Try another choice.", "__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_TITLE_ERROR": "You must insert a theme", "__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_TITLE_LABEL": "Theme", "__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_TITLE_MAX_ERROR": "You must insert a theme between 1 and 70 characters", "__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_TITLE_PLACEHOLDER": "Select or add a theme", + "__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_TITLE_REQUIRED_ERROR": "This field is required", "__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_SAVE_BUTTON": "Save", "__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_SAVE_TOAST_SUCCESS": "Observation saved successfully", + "__VIDEO_PAGE_DROPDOWN_EDIT_MODAL_ERROR_TOAST_MESSAGE": "An error occurred while saving the changes. Please try again.", + "__VIDEO_PAGE_DROPDOWN_EDIT_MODAL_INPUT_HELPER_TEXT": "enter [↵] to save", + "__VIDEO_PAGE_DROPDOWN_EDIT_MODAL_SAVE_BUTTON": "Save", + "__VIDEO_PAGE_DROPDOWN_EDIT_MODAL_SUCCESS_TOAST_MESSAGE": "The changes were successful.", "__VIDEO_PAGE_NO_OBSERVATIONS": "Start watching the video and mark the highlights.", "__VIDEO_PAGE_NO_OBSERVATIONS_TITLE": "For this video, there are no observations yet.", "__VIDEO_PAGE_OBSERVATION_DESELECTED_TOAST_MESSAGE": "Observation deselected", @@ -1358,6 +1427,16 @@ "__VIDEO_PAGE_PLAYER_SHORTCUT_OBSERVATION_STARTED": "Observation started", "__VIDEO_PAGE_PLAYER_START_ADD_OBSERVATION": "Start observation", "__VIDEO_PAGE_PLAYER_STOP_ADD_OBSERVATION": "End observation", + "__VIDEO_PAGE_TAGS_DROPDOWN_EDIT_MODAL_DESCRIPTION_zero": "No observations related to this tag", + "__VIDEO_PAGE_TAGS_DROPDOWN_EDIT_MODAL_DESCRIPTION_one": "1 Observation related to this tag", + "__VIDEO_PAGE_TAGS_DROPDOWN_EDIT_MODAL_DESCRIPTION_other": "{{usageNumber}} Observations related to this tag", + "__VIDEO_PAGE_TAGS_DROPDOWN_EDIT_MODAL_LABEL": "Tag name", + "__VIDEO_PAGE_TAGS_DROPDOWN_EDIT_MODAL_TITLE": "Edit Tag", + "__VIDEO_PAGE_THEMES_DROPDOWN_EDIT_MODAL_DESCRIPTION_zero": "No observations related to this theme", + "__VIDEO_PAGE_THEMES_DROPDOWN_EDIT_MODAL_DESCRIPTION_one": "1 Observation related to this theme", + "__VIDEO_PAGE_THEMES_DROPDOWN_EDIT_MODAL_DESCRIPTION_other": "{{usageNumber}} Observations related to this theme", + "__VIDEO_PAGE_THEMES_DROPDOWN_EDIT_MODAL_LABEL": "Theme name", + "__VIDEO_PAGE_THEMES_DROPDOWN_EDIT_MODAL_TITLE": "Edit Theme", "__VIDEO_PAGE_TITLE": "UX Tagging Tool", "__VIDEO_PAGE_TRANSCRIPT_EMPTY_STATE": "For this video, there is no available transcript.", "__VIDEO_PAGE_TRANSCRIPT_INFO": "Select some text to create an observation or use the button on the video player.", @@ -1414,6 +1493,7 @@ "_TOAST_UNPUBLISHED_MESSAGE": "successfully unpublished", "{{count}} bugs_one": "{{count}} bug", "{{count}} bugs_other": "{{count}} bugs", + "{{name}} (you)": "", "INSIGHT_PAGE_COLLECTION_OBSERVATIONS_LABEL": "Observations: <1>{{counter}}", "INSIGHT_PAGE_COLLECTION_THEMES_LABEL": "Themes: <1>{{counter}}", "INSIGHT_PAGE_COLLECTION_UNGROUPED_USECASE_SUBTITTLE": "Isolated Observations", @@ -1454,6 +1534,7 @@ "SIGNUP_FORM_COMPANY_SIZE_PLACEHOLDER": "Select company size", "SIGNUP_FORM_CTA_RETURN_TO_UNGUESS_LANDING": "or visit UNGUESS website", "SIGNUP_FORM_EMAIL_ALREADY_TAKEN": "Error: User with provided email already exists", + "SIGNUP_FORM_EMAIL_DISPOSABLE_NOT_ALLOWED": "Temporary email addresses are not allowed", "SIGNUP_FORM_EMAIL_ERROR_SERVER_MAIL_CHECK": "Oops! Check your email format", "SIGNUP_FORM_EMAIL_IS_REQUIRED": "This field is required", "SIGNUP_FORM_EMAIL_LABEL": "Work Email", diff --git a/src/locales/it/translation.json b/src/locales/it/translation.json index c41de16f3..a685d1175 100644 --- a/src/locales/it/translation.json +++ b/src/locales/it/translation.json @@ -882,6 +882,11 @@ "__PLAN_PAGE_MODAL_SEND_REQUEST_TITLE_LABEL": "", "__PLAN_PAGE_MODAL_SEND_REQUEST_TOAST_ERROR": "", "__PLAN_PAGE_MODAL_SEND_REQUEST_WAIT": "", + "__PLAN_PAGE_MODAL_SEND_REQUEST_WATCHERS_DESCRIPTION": "", + "__PLAN_PAGE_MODAL_SEND_REQUEST_WATCHERS_ERROR": "", + "__PLAN_PAGE_MODAL_SEND_REQUEST_WATCHERS_HINT": "", + "__PLAN_PAGE_MODAL_SEND_REQUEST_WATCHERS_LABEL": "", + "__PLAN_PAGE_MODAL_SEND_REQUEST_WATCHERS_PLACEHOLDER": "", "__PLAN_PAGE_MODUL_GENERAL_REMOVE_MODAL_CANCEL": "", "__PLAN_PAGE_MODUL_GENERAL_REMOVE_MODAL_CONFIRM": "", "__PLAN_PAGE_MODUL_GENERAL_REMOVE_MODAL_DESCRIPTION": "", @@ -1202,6 +1207,34 @@ "__PLAN_PAGE_TAB_SUMMARY_TAB_TITLE": "", "__PLAN_PAGE_TAB_TARGET_TAB_TITLE": "", "__PLAN_PAGE_TITLE": "Pianifica la tua attività", + "__PLAN_PAGE_WATCHER_LIST_ADD_SELF_TOAST_ERROR_MESSAGE": "", + "__PLAN_PAGE_WATCHER_LIST_ADD_SELF_TOAST_MESSAGE": "", + "__PLAN_PAGE_WATCHER_LIST_ADD_USER_ERROR_TOAST_MESSAGE": "", + "__PLAN_PAGE_WATCHER_LIST_APPROVED_ALERT_TEXT": "", + "__PLAN_PAGE_WATCHER_LIST_APPROVED_ALERT_TITLE": "", + "__PLAN_PAGE_WATCHER_LIST_MODAL_ADD_MEMBERS_INFO_TOOLTIP": "", + "__PLAN_PAGE_WATCHER_LIST_MODAL_FOLLOW_BUTTON": "", + "__PLAN_PAGE_WATCHER_LIST_MODAL_FOLLOW_BUTTON_DISABLED_TOOLTIP": "", + "__PLAN_PAGE_WATCHER_LIST_MODAL_NO_WATCHERS_DESCRIPTION": "", + "__PLAN_PAGE_WATCHER_LIST_MODAL_NO_WATCHERS_TITLE": "", + "__PLAN_PAGE_WATCHER_LIST_MODAL_ONLY_ONE_WATCHER_DESCRIPTION": "", + "__PLAN_PAGE_WATCHER_LIST_MODAL_ONLY_ONE_WATCHER_TITLE": "", + "__PLAN_PAGE_WATCHER_LIST_MODAL_REMOVE_BUTTON_DISABLED_TOOLTIP": "", + "__PLAN_PAGE_WATCHER_LIST_MODAL_REMOVE_BUTTON_TOOLTIP": "", + "__PLAN_PAGE_WATCHER_LIST_MODAL_SUGGESTIONS_TITLE": "", + "__PLAN_PAGE_WATCHER_LIST_MODAL_TITLE": "", + "__PLAN_PAGE_WATCHER_LIST_MODAL_TITLE_DESCRIPTION": "", + "__PLAN_PAGE_WATCHER_LIST_MODAL_TITLE_DESCRIPTION_APPROVED": "", + "__PLAN_PAGE_WATCHER_LIST_MODAL_UNFOLLOW_BUTTON": "", + "__PLAN_PAGE_WATCHER_LIST_MODAL_UNFOLLOW_BUTTON_DISABLED_TOOLTIP": "", + "__PLAN_PAGE_WATCHER_LIST_MODAL_WATCHERS_TITLE": "", + "__PLAN_PAGE_WATCHER_LIST_MODAL_WATCHERS_TITLE_APPROVED": "", + "__PLAN_PAGE_WATCHER_LIST_REMOVE_LAST_USER_ERROR_TOAST_MESSAGE": "", + "__PLAN_PAGE_WATCHER_LIST_REMOVE_SELF_TOAST_MESSAGE": "", + "__PLAN_PAGE_WATCHER_LIST_REMOVE_USER_ERROR_TOAST_MESSAGE": "", + "__PLAN_PAGE_WATCHER_LIST_SELECT_ADD_MEMBERS_PLACEHOLDER": "", + "__PLAN_PAGE_WATCHER_LIST_TOOLTIP": "", + "__PLAN_PAGE_WATCHER_LIST_TOOLTIP_DESCRIPTION": "", "__PLAN_REQUEST_QUOTATION_CTA": "", "__PLAN_RULE_DUPLICATE_TOUCHPOINT_FORM_FACTORS": "", "__PLAN_RULE_MODULE_TYPE": "", @@ -1215,6 +1248,7 @@ "__PLAN_RULES_WHAT_MEANS_DESCRIPTION_2": "", "__PLAN_RULES_WHAT_MEANS_DESCRIPTION_3": "", "__PLAN_SAVE_CONFIGURATION_CTA": "", + "__PLAN_SAVE_CONFIGURATION_TOOLTIP": "", "__PLAN_SAVE_DRAFT_TOAST_ERROR": "", "__PLAN_SAVE_DRAFT_TOAST_SUCCESS": "", "__PLAN_SAVE_TEMPLATE_CTA": "", @@ -1246,10 +1280,34 @@ "__PROFILE_PAGE_COMPANY_SIZE_REQUIRED_ERROR": "", "__PROFILE_PAGE_CONFIRM_PASSWORD_MUST_MATCH_NEW_PASSWORD": "", "__PROFILE_PAGE_NAME_REQUIRED_ERROR": "", + "__PROFILE_PAGE_NAV_ITEM_NOTIFICATION_SETTINGS": "", "__PROFILE_PAGE_NAV_ITEM_PASSWORD": "", "__PROFILE_PAGE_NAV_ITEM_PROFILE": "", "__PROFILE_PAGE_NAV_SECTION_PASSWORD": "", "__PROFILE_PAGE_NEW_PASSWORD_REQUIRED_ERROR": "", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_PROGRESS_ALERT_ITEM_1": "", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_PROGRESS_ALERT_ITEM_2": "", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_PROGRESS_ALERT_ITEM_3": "", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_PROGRESS_ALERT_ITEM_4": "", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_PROGRESS_ALERT_ITEM_5": "", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_PROGRESS_ALERT_TITLE": "", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_PROGRESS_CHECKBOX_HINT": "", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_PROGRESS_CHECKBOX_LABEL": "", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_PROGRESS_UPDATES_FORM_LABEL": "", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_PROGRESS_UPDATES_HINT": "", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_PROGRESS_UPDATES_LABEL": "", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_PROGRESS_UPDATES_TAG": "", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_SETUP_CHECKBOX_HINT": "", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_SETUP_CHECKBOX_LABEL": "", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_DESCRIPTION": "", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_FOLLOW_ACTIVITIES_BUTTON_TEXT": "", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_FOLLOW_ACTIVITIES_HINT": "", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_FOLLOW_ACTIVITIES_HINT_TEXT": "", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_FOLLOW_ACTIVITIES_LABEL": "", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_FOLLOW_ACTIVITIES_SETUP_DESCRIPTION": "", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_FOLLOW_ACTIVITIES_TAG": "", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_LABEL": "", + "__PROFILE_PAGE_NOTIFICATIONS_CARD_SAVE_BUTTON_LABEL": "", "__PROFILE_PAGE_PASSWORD_ACCORDION_LABEL": "", "__PROFILE_PAGE_ROLE_REQUIRED_ERROR": "", "__PROFILE_PAGE_SURNAME_REQUIRED_ERROR": "", @@ -1257,7 +1315,12 @@ "__PROFILE_PAGE_TOAST_ERROR_INVALID_CURRENT_PASSWORD": "", "__PROFILE_PAGE_TOAST_ERROR_UPDATING_PASSWORD": "", "__PROFILE_PAGE_TOAST_ERROR_UPDATING_PROFILE": "", + "__PROFILE_PAGE_TOAST_ERROR_UPDATING_SETTINGS": "", "__PROFILE_PAGE_TOAST_SUCCESS_PASSWORD_UPDATED": "", + "__PROFILE_PAGE_UNFOLLOW_BUTTON_DISABLED_TOOLTIP": "", + "__PROFILE_PAGE_UNFOLLOW_ERROR": "", + "__PROFILE_PAGE_UNFOLLOW_SUCCESS": "", + "__PROFILE_PAGE_UPDATE_SETTINGS_SUCCESS": "", "__PROFILE_PAGE_UPDATE_SUCCESS": "", "__PROFILE_PAGE_USER_CARD_COMPANY_SIZE_LABEL": "", "__PROFILE_PAGE_USER_CARD_COMPANY_SIZE_PLACEHOLDER": "", @@ -1380,12 +1443,18 @@ "__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_TAGS_SHOW_MORE_many": "", "__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_TAGS_SHOW_MORE_other": "Mostra ancora", "__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_TITLE_DESCRIPTION": "Usa i temi per raggruppare le tue osservazioni", + "__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_TITLE_DUPLICATE_ERROR": "", "__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_TITLE_ERROR": "Devi inserire un tema", "__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_TITLE_LABEL": "Tema", "__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_TITLE_MAX_ERROR": "Devi inserire un tema compreso tra 1 e 70 caratteri", "__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_TITLE_PLACEHOLDER": "Seleziona o crea un tema", + "__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_TITLE_REQUIRED_ERROR": "", "__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_SAVE_BUTTON": "Salva", "__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_SAVE_TOAST_SUCCESS": "Osservazione salvata con successo", + "__VIDEO_PAGE_DROPDOWN_EDIT_MODAL_ERROR_TOAST_MESSAGE": "", + "__VIDEO_PAGE_DROPDOWN_EDIT_MODAL_INPUT_HELPER_TEXT": "", + "__VIDEO_PAGE_DROPDOWN_EDIT_MODAL_SAVE_BUTTON": "", + "__VIDEO_PAGE_DROPDOWN_EDIT_MODAL_SUCCESS_TOAST_MESSAGE": "", "__VIDEO_PAGE_NO_OBSERVATIONS": "Inizia a guardare il video e segna i momenti salienti.", "__VIDEO_PAGE_NO_OBSERVATIONS_TITLE": "Per questo video non ci sono ancora evidenze.", "__VIDEO_PAGE_OBSERVATION_DESELECTED_TOAST_MESSAGE": "Osservazione de-selezionata", @@ -1398,6 +1467,16 @@ "__VIDEO_PAGE_PLAYER_SHORTCUT_OBSERVATION_STARTED": "Osservazione iniziata", "__VIDEO_PAGE_PLAYER_START_ADD_OBSERVATION": "Inizia osservazione", "__VIDEO_PAGE_PLAYER_STOP_ADD_OBSERVATION": "Fine osservazione", + "__VIDEO_PAGE_TAGS_DROPDOWN_EDIT_MODAL_DESCRIPTION_one": "", + "__VIDEO_PAGE_TAGS_DROPDOWN_EDIT_MODAL_DESCRIPTION_many": "", + "__VIDEO_PAGE_TAGS_DROPDOWN_EDIT_MODAL_DESCRIPTION_other": "", + "__VIDEO_PAGE_TAGS_DROPDOWN_EDIT_MODAL_LABEL": "", + "__VIDEO_PAGE_TAGS_DROPDOWN_EDIT_MODAL_TITLE": "", + "__VIDEO_PAGE_THEMES_DROPDOWN_EDIT_MODAL_DESCRIPTION_one": "", + "__VIDEO_PAGE_THEMES_DROPDOWN_EDIT_MODAL_DESCRIPTION_many": "", + "__VIDEO_PAGE_THEMES_DROPDOWN_EDIT_MODAL_DESCRIPTION_other": "", + "__VIDEO_PAGE_THEMES_DROPDOWN_EDIT_MODAL_LABEL": "", + "__VIDEO_PAGE_THEMES_DROPDOWN_EDIT_MODAL_TITLE": "", "__VIDEO_PAGE_TITLE": "Tagging Tool", "__VIDEO_PAGE_TRANSCRIPT_EMPTY_STATE": "Per questo video non è disponibile una trascrizione.", "__VIDEO_PAGE_TRANSCRIPT_INFO": "Seleziona del testo per creare un'osservazione o usa il pulsante sul lettore video.", @@ -1457,6 +1536,7 @@ "{{count}} bugs_one": "", "{{count}} bugs_many": "", "{{count}} bugs_other": "", + "{{name}} (you)": "", "INSIGHT_PAGE_COLLECTION_OBSERVATIONS_LABEL": "Osservazioni: <1>{{counter}}", "INSIGHT_PAGE_COLLECTION_THEMES_LABEL": "Temi: <1>{{counter}}", "INSIGHT_PAGE_COLLECTION_UNGROUPED_USECASE_SUBTITTLE": "Osservazioni isolate", @@ -1498,6 +1578,7 @@ "SIGNUP_FORM_COMPANY_SIZE_PLACEHOLDER": "", "SIGNUP_FORM_CTA_RETURN_TO_UNGUESS_LANDING": "", "SIGNUP_FORM_EMAIL_ALREADY_TAKEN": "", + "SIGNUP_FORM_EMAIL_DISPOSABLE_NOT_ALLOWED": "", "SIGNUP_FORM_EMAIL_ERROR_SERVER_MAIL_CHECK": "", "SIGNUP_FORM_EMAIL_IS_REQUIRED": "", "SIGNUP_FORM_EMAIL_LABEL": "", diff --git a/src/pages/Campaign/pageHeader/Meta/index.tsx b/src/pages/Campaign/pageHeader/Meta/index.tsx index d610a3461..d5f151053 100644 --- a/src/pages/Campaign/pageHeader/Meta/index.tsx +++ b/src/pages/Campaign/pageHeader/Meta/index.tsx @@ -183,7 +183,10 @@ export const Metas = ({ {' '} {t('__INSIGHTS_PAGE_NAVIGATION_LABEL')} - + { let error; let error_event; if (status?.isInvited) return error; + if (value) { + const isDisposable = isDisposableEmail(value); + + if (isDisposable) { + error = t('SIGNUP_FORM_EMAIL_DISPOSABLE_NOT_ALLOWED'); + error_event = 'SIGNUP_FORM_EMAIL_DISPOSABLE_NOT_ALLOWED'; + sendGTMevent({ + event: 'sign-up-flow', + category: 'not set', + action: 'validate email', + content: `error: ${error_event}`, + target: `is invited: ${status?.isInvited}`, + }); + return error; + } + } const res = await fetch( `${process.env.REACT_APP_API_URL}/users/by-email/${value}`, { diff --git a/src/pages/Plan/Controls/SaveConfigurationButton.tsx b/src/pages/Plan/Controls/SaveConfigurationButton.tsx index 30afdc2d5..8719e8d29 100644 --- a/src/pages/Plan/Controls/SaveConfigurationButton.tsx +++ b/src/pages/Plan/Controls/SaveConfigurationButton.tsx @@ -1,8 +1,10 @@ import { Button, Notification, + Tooltip, useToast, } from '@appquality/unguess-design-system'; +import { ReactComponent as SaveIcon } from 'src/assets/icons/save-icon.svg'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; import { useSubmit } from 'src/features/modules/useModuleConfiguration'; @@ -56,14 +58,24 @@ const SaveConfigurationButton = () => { }; return ( - + + ); }; diff --git a/src/pages/Plan/Controls/WatcherList/MemberAddAutoComplete.tsx b/src/pages/Plan/Controls/WatcherList/MemberAddAutoComplete.tsx new file mode 100644 index 000000000..3cf69923e --- /dev/null +++ b/src/pages/Plan/Controls/WatcherList/MemberAddAutoComplete.tsx @@ -0,0 +1,120 @@ +import { + Autocomplete, + DropdownFieldNew, + MD, + Notification, + useToast, +} from '@appquality/unguess-design-system'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + useGetPlansByPidWatchersQuery, + useGetUsersMeQuery, + useGetWorkspacesByWidUsersQuery, + usePostPlansByPidWatchersMutation, +} from 'src/features/api'; +import { useActiveWorkspace } from 'src/hooks/useActiveWorkspace'; +import { useTheme } from 'styled-components'; + +const ItemContent = ({ name, email }: { name: string; email: string }) => { + const appTheme = useTheme(); + return ( +
+
+ {name} + {email} +
+
+ ); +}; + +const MemberAddAutocomplete = ({ planId }: { planId: string }) => { + const { activeWorkspace, isLoading } = useActiveWorkspace(); + + const [addUser] = usePostPlansByPidWatchersMutation(); + const { data } = useGetWorkspacesByWidUsersQuery( + { + wid: (activeWorkspace?.id || '0').toString(), + }, + { + skip: !activeWorkspace?.id, + } + ); + const { addToast } = useToast(); + const { t } = useTranslation(); + const { data: currentUser, isLoading: isLoadingCurrentUser } = + useGetUsersMeQuery(); + const { data: watchers, isLoading: isLoadingWatchers } = + useGetPlansByPidWatchersQuery({ pid: planId }); + const [inputValue, setInputValue] = useState(''); + + if (!data || isLoading || isLoadingWatchers || isLoadingCurrentUser) + return null; + + const users = data.items + .filter((user) => !user.invitationPending) + .map((user) => ({ + name: user.name, + email: user.email, + id: user.profile_id, + })) + + .filter( + (user) => + !watchers?.items.find((watcher) => watcher.id === user.id) && + user.id !== currentUser?.profile_id + ); + + return ( + + setInputValue(value)} + inputValue={inputValue} + selectionValue={null} + placeholder={t( + '__PLAN_PAGE_WATCHER_LIST_SELECT_ADD_MEMBERS_PLACEHOLDER' + )} + options={users.map((user) => ({ + children: , + id: `user-${user.id}`, + label: `${user.name} - ${user.email}`, + value: `${user.id}`, + }))} + onOptionClick={({ selectionValue }) => { + addUser({ + pid: planId, + body: { users: [{ id: Number(selectionValue) }] }, + }) + .unwrap() + .catch(() => { + addToast( + ({ close }) => ( + + ), + { placement: 'top' } + ); + }); + setInputValue(''); + }} + /> + + ); +}; + +export { MemberAddAutocomplete }; diff --git a/src/pages/Plan/Controls/WatcherList/UserItem.tsx b/src/pages/Plan/Controls/WatcherList/UserItem.tsx new file mode 100644 index 000000000..a625dc75b --- /dev/null +++ b/src/pages/Plan/Controls/WatcherList/UserItem.tsx @@ -0,0 +1,135 @@ +import { + Avatar, + Ellipsis, + getColor, + IconButton, + MD, + SM, + Tooltip, +} from '@appquality/unguess-design-system'; +import { t } from 'i18next'; +import { appTheme } from 'src/app/theme'; +import { ReactComponent as XStroke } from 'src/assets/icons/x-stroke.svg'; +import { getInitials } from 'src/common/components/navigation/header/utils'; +import { prepareGravatar } from 'src/common/utils'; +import { usePlanIsApproved } from 'src/hooks/usePlan'; +import styled from 'styled-components'; +import { useIsLastOne } from './hooks/useIsLastOne'; +import { useRemoveWatcher } from './hooks/useRemoveWatcher'; + +const StyledEllipsis = styled(Ellipsis)``; + +const UserListItem = styled.div` + display: flex; + padding: ${({ theme }) => `${theme.space.xs} 0`}; + align-items: center; + gap: ${({ theme }) => theme.space.sm}; + + ${StyledEllipsis} { + width: 250px; + } +`; + +const UserAvatar = ({ + image, + name, + isInternal, +}: { + image?: string; + name: string; + isInternal: boolean; +}) => { + if (isInternal) { + return ; + } + return ( + + {image ? prepareGravatar(image, 64) : getInitials(name)} + + ); +}; + +const UserItem = ({ + planId, + user, +}: { + planId: string; + user: { + id: number; + name: string; + email: string; + image?: string; + isInternal: boolean; + isMe?: boolean; + }; +}) => { + const { isMe } = user; + const isLastOne = useIsLastOne({ planId }); + const isApproved = usePlanIsApproved(planId); + + const { removeWatcher } = useRemoveWatcher(); + + const iconButton = ( + removeWatcher({ planId, profileId: user.id.toString() })} + > + + + ); + + return ( + +
+ +
+
+ + + {user.name.length ? user.name : user.email}{' '} + {isMe && t('__WORKSPACE_SETTINGS_CURRENT_MEMBER_YOU_LABEL')} + + + {user.name.length > 0 && ( + + {user.email} + + )} +
+
+ {!isApproved && ( + + {/* the following div is necessary to make Tooltip work with disabled IconButton */} +
{iconButton}
+
+ )} +
+
+ ); +}; + +export { UserItem }; diff --git a/src/pages/Plan/Controls/WatcherList/UserList.tsx b/src/pages/Plan/Controls/WatcherList/UserList.tsx new file mode 100644 index 000000000..876a87bfc --- /dev/null +++ b/src/pages/Plan/Controls/WatcherList/UserList.tsx @@ -0,0 +1,102 @@ +import { MD, Skeleton, SM } from '@appquality/unguess-design-system'; +import { styled, useTheme } from 'styled-components'; +import { useTranslation } from 'react-i18next'; +import { + useGetPlansByPidWatchersQuery, + useGetUsersMeQuery, +} from 'src/features/api'; +import { usePlanIsApproved } from 'src/hooks/usePlan'; +import { ReactComponent as Empty } from './assets/Empty.svg'; +import { UserItem } from './UserItem'; + +const UserItemContainer = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.space.xxs}; +`; + +const EmptyState = ({ watchers }: { watchers?: number }) => { + const { t } = useTranslation(); + const appTheme = useTheme(); + + return ( +
+ + {(!watchers || watchers === 0) && ( + <> + + {t('__PLAN_PAGE_WATCHER_LIST_MODAL_NO_WATCHERS_TITLE')} + + + {t('__PLAN_PAGE_WATCHER_LIST_MODAL_NO_WATCHERS_DESCRIPTION')} + + + )} + + {watchers && watchers === 1 && ( + <> + + {t('__PLAN_PAGE_WATCHER_LIST_MODAL_ONLY_ONE_WATCHER_TITLE')} + + + {t('__PLAN_PAGE_WATCHER_LIST_MODAL_ONLY_ONE_WATCHER_DESCRIPTION')} + + + )} +
+ ); +}; + +const UserList = ({ planId }: { planId: string }) => { + const { t } = useTranslation(); + const { data: currentUser } = useGetUsersMeQuery(); + const { data, isLoading } = useGetPlansByPidWatchersQuery({ + pid: planId, + }); + + const isApproved = usePlanIsApproved(planId); + if (isLoading) return ; + + if (!data || data.items.length === 0) return ; + + return ( + + + {isApproved + ? t('__PLAN_PAGE_WATCHER_LIST_MODAL_WATCHERS_TITLE_APPROVED') + : t('__PLAN_PAGE_WATCHER_LIST_MODAL_WATCHERS_TITLE')} + + + {[...data.items] + .sort((a, b) => { + const aName = `${a.name} ${a.surname}`.toLowerCase(); + const bName = `${b.name} ${b.surname}`.toLowerCase(); + + return aName.localeCompare(bName); + }) + .map((user) => ( + + ))} + {data.items.length === 1 && !isApproved && ( + + )} + + ); +}; + +export { UserList }; diff --git a/src/pages/Plan/Controls/WatcherList/WatchButton.tsx b/src/pages/Plan/Controls/WatcherList/WatchButton.tsx new file mode 100644 index 000000000..9f0e2f431 --- /dev/null +++ b/src/pages/Plan/Controls/WatcherList/WatchButton.tsx @@ -0,0 +1,150 @@ +import { + Button, + Notification, + Tooltip, + useToast, +} from '@appquality/unguess-design-system'; +import { useTranslation } from 'react-i18next'; +import { appTheme } from 'src/app/theme'; + +import { ReactComponent as EyeIconFill } from 'src/assets/icons/eye-icon-fill.svg'; +import { ReactComponent as EyeIconSlash } from 'src/assets/icons/eye-icon-slash.svg'; +import { + useGetUsersMeQuery, + useGetWorkspacesByWidUsersQuery, + usePostPlansByPidWatchersMutation, +} from 'src/features/api'; +import { useActiveWorkspace } from 'src/hooks/useActiveWorkspace'; +import { useIsLastOne } from './hooks/useIsLastOne'; +import { useIsWatching } from './hooks/useIsWatching'; +import { useRemoveWatcher } from './hooks/useRemoveWatcher'; + +const useHasWorkspaceAccess = () => { + const { activeWorkspace } = useActiveWorkspace(); + const { data: user } = useGetUsersMeQuery(); + + const { data } = useGetWorkspacesByWidUsersQuery( + { + wid: (activeWorkspace?.id || '0').toString(), + }, + { + skip: !activeWorkspace?.id, + } + ); + + return ( + (data?.items || []).find((u) => u.profile_id === user?.profile_id) !== + undefined + ); +}; + +const WatchButton = ({ planId }: { planId: string }) => { + const isWatching = useIsWatching({ planId }); + const isLastOne = useIsLastOne({ planId }); + const { addToast } = useToast(); + const { removeWatcher } = useRemoveWatcher(); + const [addUser] = usePostPlansByPidWatchersMutation(); + const hasWorkspaceAccess = useHasWorkspaceAccess(); + const { data: currentUser } = useGetUsersMeQuery(); + const { t } = useTranslation(); + + const isLastWatcher = isWatching && isLastOne; + + const isDisabled = !hasWorkspaceAccess || isLastWatcher; + + const iconColor = (() => { + if (isDisabled) return appTheme.palette.grey[400]; + if (!isWatching) return '#fff'; + return undefined; + })(); + + const EyeIcon = isWatching ? EyeIconSlash : EyeIconFill; + + if (!currentUser) return null; + + const button = ( + + ); + + if (isDisabled) { + return ( + + {/* the following div is necessary to make Tooltip work with disabled Button */} +
{button}
+
+ ); + } + + return button; +}; + +export { WatchButton }; diff --git a/src/pages/Plan/Controls/WatcherList/assets/Empty.svg b/src/pages/Plan/Controls/WatcherList/assets/Empty.svg new file mode 100644 index 000000000..a8d1ba37c --- /dev/null +++ b/src/pages/Plan/Controls/WatcherList/assets/Empty.svg @@ -0,0 +1,203 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/pages/Plan/Controls/WatcherList/hooks/useIsLastOne.tsx b/src/pages/Plan/Controls/WatcherList/hooks/useIsLastOne.tsx new file mode 100644 index 000000000..20c1be09b --- /dev/null +++ b/src/pages/Plan/Controls/WatcherList/hooks/useIsLastOne.tsx @@ -0,0 +1,13 @@ +import { useGetPlansByPidWatchersQuery } from 'src/features/api'; + +const useIsLastOne = ({ planId }: { planId: string }) => { + const { data } = useGetPlansByPidWatchersQuery({ + pid: planId, + }); + + if (!data || data.items.length === 0) return false; + + return data.items.length === 1; +}; + +export { useIsLastOne }; diff --git a/src/pages/Plan/Controls/WatcherList/hooks/useIsWatching.tsx b/src/pages/Plan/Controls/WatcherList/hooks/useIsWatching.tsx new file mode 100644 index 000000000..cbb8098f4 --- /dev/null +++ b/src/pages/Plan/Controls/WatcherList/hooks/useIsWatching.tsx @@ -0,0 +1,22 @@ +import { + useGetPlansByPidWatchersQuery, + useGetUsersMeQuery, +} from 'src/features/api'; + +const useIsWatching = ({ planId }: { planId: string }) => { + const { data: currentUser } = useGetUsersMeQuery(); + + const { data } = useGetPlansByPidWatchersQuery({ + pid: planId, + }); + + if (!data || data.items.length === 0 || !currentUser) return false; + + if (data.items.some((user) => user.id === currentUser.profile_id)) { + return true; + } + + return false; +}; + +export { useIsWatching }; diff --git a/src/pages/Plan/Controls/WatcherList/hooks/useRemoveWatcher.tsx b/src/pages/Plan/Controls/WatcherList/hooks/useRemoveWatcher.tsx new file mode 100644 index 000000000..c397be796 --- /dev/null +++ b/src/pages/Plan/Controls/WatcherList/hooks/useRemoveWatcher.tsx @@ -0,0 +1,77 @@ +import { Notification, useToast } from '@appquality/unguess-design-system'; +import { useTranslation } from 'react-i18next'; +import { + useDeletePlansByPidWatchersAndProfileIdMutation, + useGetUsersMeQuery, +} from 'src/features/api'; + +const useRemoveWatcher = () => { + const [removeUser] = useDeletePlansByPidWatchersAndProfileIdMutation(); + const { addToast } = useToast(); + const { t } = useTranslation(); + const { data: currentUser } = useGetUsersMeQuery(); + + const removeWatcher = async ({ + planId, + profileId, + }: { + planId: string; + profileId: string; + }) => + removeUser({ pid: planId, profileId }) + .unwrap() + .then(() => { + if (currentUser?.profile_id.toString() === profileId) { + addToast( + ({ close }) => ( + + ), + { placement: 'top' } + ); + } + }) + .catch((error) => { + if (error.status === 406) { + addToast( + ({ close }) => ( + + ), + { placement: 'top' } + ); + } else { + addToast( + ({ close }) => ( + + ), + { placement: 'top' } + ); + } + }); + return { removeWatcher }; +}; + +export { useRemoveWatcher }; diff --git a/src/pages/Plan/Controls/WatcherList/index.tsx b/src/pages/Plan/Controls/WatcherList/index.tsx new file mode 100644 index 000000000..08657cb8a --- /dev/null +++ b/src/pages/Plan/Controls/WatcherList/index.tsx @@ -0,0 +1,177 @@ +import { + Alert, + Anchor, + Button, + IconButton, + MD, + Skeleton, + Tooltip, + TooltipModal, +} from '@appquality/unguess-design-system'; + +import { useRef, useState } from 'react'; +import { Divider } from 'src/common/components/divider'; +import { styled, useTheme } from 'styled-components'; + +import { Trans, useTranslation } from 'react-i18next'; +import { ReactComponent as EyeIconFill } from 'src/assets/icons/eye-icon-fill.svg'; +import { ReactComponent as EyeIcon } from 'src/assets/icons/eye-icon.svg'; +import { ReactComponent as InfoIcon } from 'src/assets/icons/info-icon.svg'; +import { useGetPlansByPidWatchersQuery } from 'src/features/api'; +import { usePlanIsApproved } from 'src/hooks/usePlan'; +import { useIsWatching } from './hooks/useIsWatching'; +import { MemberAddAutocomplete } from './MemberAddAutoComplete'; +import { UserList } from './UserList'; +import { WatchButton } from './WatchButton'; + +const ModalBodyContainer = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.space.md}; +`; + +const DropdownContainer = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.space.xs}; + .title-with-icon { + display: flex; + align-items: center; + + gap: ${({ theme }) => theme.space.xs}; + } +`; + +const WatcherList = ({ planId }: { planId: string }) => { + const { t } = useTranslation(); + const ref = useRef(null); + const [referenceElement, setReferenceElement] = + useState(null); + const appTheme = useTheme(); + const isWatching = useIsWatching({ planId }); + const { data: watchers, isLoading } = useGetPlansByPidWatchersQuery({ + pid: planId, + }); + const watchersCount = watchers ? watchers.items.length : 0; + + const isApproved = usePlanIsApproved(planId); + + return ( + <> + + + {t('__PLAN_PAGE_WATCHER_LIST_TOOLTIP')} + + {t('__PLAN_PAGE_WATCHER_LIST_TOOLTIP_DESCRIPTION')} + + } + > + + + setReferenceElement(null)} + role="dialog" + > + + + {t('__PLAN_PAGE_WATCHER_LIST_MODAL_TITLE')} + + + + + + {isApproved ? ( + t('__PLAN_PAGE_WATCHER_LIST_MODAL_TITLE_DESCRIPTION_APPROVED') + ) : ( + + ), + }} + /> + )} + + {isApproved && ( + + + {t('__PLAN_PAGE_WATCHER_LIST_APPROVED_ALERT_TITLE')} + + {t('__PLAN_PAGE_WATCHER_LIST_APPROVED_ALERT_TEXT')} + + )} + {!isApproved && } + + + + + + {!isApproved && ( + +
+ + {t('__PLAN_PAGE_WATCHER_LIST_MODAL_SUGGESTIONS_TITLE')} + + + + + + +
+ +
+ )} +
+
+ + ); +}; + +export { WatcherList }; diff --git a/src/pages/Plan/Controls/index.tsx b/src/pages/Plan/Controls/index.tsx index 2a586d1be..0e3daa63d 100644 --- a/src/pages/Plan/Controls/index.tsx +++ b/src/pages/Plan/Controls/index.tsx @@ -3,9 +3,7 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; import { appTheme } from 'src/app/theme'; -import { Pipe } from 'src/common/components/Pipe'; import { useModule } from 'src/features/modules/useModule'; -import styled from 'styled-components'; import { useSubmit } from '../../../features/modules/useModuleConfiguration'; import { usePlan, usePlanIsPurchasable } from '../../../hooks/usePlan'; import { usePlanContext } from '../context/planContext'; @@ -18,12 +16,7 @@ import { GoToCampaignButton } from './GoToCampaignButton'; import { IconButtonMenu } from './IconButtonMenu'; import { RequestQuotationButton } from './RequestQuotationButton'; import { SaveConfigurationButton } from './SaveConfigurationButton'; - -const StyledPipe = styled(Pipe)` - display: inline; - margin: 0; - height: auto; -`; +import { WatcherList } from './WatcherList'; export const Controls = () => { const { t } = useTranslation(); @@ -42,7 +35,7 @@ export const Controls = () => { const { addToast } = useToast(); const { handleSubmit } = useSubmit(planId || ''); - if (!plan) return null; + if (!plan || !planId) return null; const handleRequestQuotation = async () => { try { @@ -74,6 +67,7 @@ export const Controls = () => {
+ {(planComposedStatus === 'Accepted' || planComposedStatus === 'PurchasedPlan') && } {(planComposedStatus === 'AwaitingApproval' || @@ -86,7 +80,6 @@ export const Controls = () => { planComposedStatus === 'UnquotedDraft') && ( <> - )} diff --git a/src/pages/Plan/modals/SendRequestModal.tsx b/src/pages/Plan/modals/SendRequestModal.tsx index 7b5135a60..123763485 100644 --- a/src/pages/Plan/modals/SendRequestModal.tsx +++ b/src/pages/Plan/modals/SendRequestModal.tsx @@ -10,17 +10,23 @@ import { Notification, Skeleton, SM, + Span, useToast, XL, } from '@appquality/unguess-design-system'; +import { useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; import { appTheme } from 'src/app/theme'; -import { useGetPlansByPidRulesEvaluationQuery } from 'src/features/api'; +import { + useGetPlansByPidRulesEvaluationQuery, + usePutPlansByPidWatchersMutation, +} from 'src/features/api'; import { useRequestQuotation } from 'src/features/modules/useRequestQuotation'; import { useValidateForm } from 'src/features/planModules'; import { getModuleBySlug } from '../modules/Factory'; import { PurchasablePlanRulesGuide } from './PurchasablePlanRules'; +import { Watchers } from './Watchers'; const SendRequestModal = ({ onQuit, @@ -31,10 +37,12 @@ const SendRequestModal = ({ }) => { const { planId } = useParams(); const { t } = useTranslation(); + const [updateWatchers] = usePutPlansByPidWatchersMutation(); const { isRequestingQuote, handleQuoteRequest } = useRequestQuotation(); const { data, isLoading } = useGetPlansByPidRulesEvaluationQuery({ pid: planId || '', }); + const [watchers, setWatchers] = useState([]); const isFailed = isPurchasable && data && data.failed.length > 0; const { addToast } = useToast(); @@ -43,8 +51,14 @@ const SendRequestModal = ({ const { validateForm } = useValidateForm(); + if (!planId) return null; + const handleConfirm = async () => { try { + await updateWatchers({ + pid: planId, + body: { users: watchers.map((id) => ({ id })) }, + }).unwrap(); await validateForm(); await handleQuoteRequest(); } catch (e) { @@ -137,6 +151,8 @@ const SendRequestModal = ({ )}
+ * + <Message style={{ marginTop: appTheme.space.sm }}> {t('__PLAN_PAGE_MODAL_SEND_REQUEST_TITLE_HINT')} @@ -154,6 +170,19 @@ const SendRequestModal = ({ {t('__PLAN_PAGE_MODAL_SEND_REQUEST_DATES_HINT')} </Message> </div> + <div style={{ padding: `${appTheme.space.md} 0` }}> + <Label style={{ marginBottom: appTheme.space.xxs }}> + {t('__PLAN_PAGE_MODAL_SEND_REQUEST_WATCHERS_LABEL')} + <Span style={{ color: appTheme.palette.red[500] }}>*</Span> + </Label> + <SM style={{ marginBottom: appTheme.space.sm }}> + {t('__PLAN_PAGE_MODAL_SEND_REQUEST_WATCHERS_DESCRIPTION')} + </SM> + <Watchers onChange={setWatchers} planId={planId} /> + <Message style={{ marginTop: appTheme.space.sm }}> + {t('__PLAN_PAGE_MODAL_SEND_REQUEST_WATCHERS_HINT')} + </Message> + </div> </> )} </Modal.Body> @@ -171,6 +200,7 @@ const SendRequestModal = ({ </FooterItem> <FooterItem> <Button + disabled={watchers.length === 0} isAccent isPrimary onClick={handleConfirm} diff --git a/src/pages/Plan/modals/Watchers.tsx b/src/pages/Plan/modals/Watchers.tsx new file mode 100644 index 000000000..34db72794 --- /dev/null +++ b/src/pages/Plan/modals/Watchers.tsx @@ -0,0 +1,111 @@ +import { + Message, + MultiSelect, + Skeleton, +} from '@appquality/unguess-design-system'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + useGetPlansByPidWatchersQuery, + useGetUsersMeQuery, + useGetWorkspacesByWidUsersQuery, +} from 'src/features/api'; +import { useActiveWorkspace } from 'src/hooks/useActiveWorkspace'; +import { useTheme } from 'styled-components'; + +const useOptions = (planId: string) => { + const { t } = useTranslation(); + const { data: currentUser } = useGetUsersMeQuery(); + const [watchers, setWatchers] = useState< + { + id: number; + label: string; + selected: boolean; + }[] + >([]); + const { activeWorkspace, isLoading: isActiveWorkspaceLoading } = + useActiveWorkspace(); + const { data: users, isLoading: isLoadingUsers } = + useGetWorkspacesByWidUsersQuery( + { + wid: (activeWorkspace?.id || '0').toString(), + }, + { + skip: !activeWorkspace?.id, + } + ); + const { data, isLoading } = useGetPlansByPidWatchersQuery({ pid: planId }); + useEffect(() => { + if (users) { + const watchersIds = (data?.items || []).map((watcher) => watcher.id); + const options = (users?.items || []) + .filter((user) => !user.invitationPending) + .map((user) => ({ + id: user.profile_id, + label: + user.profile_id === currentUser?.profile_id + ? t(`{{name}} (you)`, { name: user.name }) + : user.name, + selected: watchersIds.includes(user.profile_id), + })); + setWatchers(options); + } + }, [data, users]); + + if (isLoading || isLoadingUsers || isActiveWorkspaceLoading) + return { options: [], select: () => {}, isLoading: true }; + + return { + options: watchers, + select: (selected: number[]) => + setWatchers( + watchers.map((w) => ({ ...w, selected: selected.includes(w.id) })) + ), + isLoading: false, + }; +}; + +const Watchers = ({ + onChange, + planId, +}: { + onChange: (selectedIds: number[]) => void; + planId: string; +}) => { + const { t } = useTranslation(); + const appTheme = useTheme(); + const { options, select, isLoading } = useOptions(planId); + + useEffect(() => { + const selected = options.filter((item) => item.selected); + onChange(selected.map((item) => Number(item.id))); + }, [options]); + + if (isLoading) return <Skeleton width="100%" height="40px" />; + + return ( + <div> + <MultiSelect + listboxAppendToNode={document.body} + options={options} + size="small" + maxItems={3} + i18n={{ + placeholder: t('__PLAN_PAGE_MODAL_SEND_REQUEST_WATCHERS_PLACEHOLDER'), + }} + onChange={async (selectedItems) => { + const selected = selectedItems.filter((item) => item.selected); + select(selected.map((item) => Number(item.id))); + onChange(selected.map((item) => Number(item.id))); + }} + /> + {options.filter((item) => item.selected).length === 0 && ( + <Message style={{ marginTop: appTheme.space.xs }} validation="error"> + {t('__PLAN_PAGE_MODAL_SEND_REQUEST_WATCHERS_ERROR')} + </Message> + )} + </div> + ); +}; + +export { Watchers }; diff --git a/src/pages/Plan/summary/components/BuyButton.tsx b/src/pages/Plan/summary/components/BuyButton.tsx index 27fbf6825..730235ead 100644 --- a/src/pages/Plan/summary/components/BuyButton.tsx +++ b/src/pages/Plan/summary/components/BuyButton.tsx @@ -60,7 +60,7 @@ const BuyButton = ({ isStretched={isStretched} disabled={ planComposedStatus && - ['AwaitingPayment', 'PurchasedPlan'].includes(planComposedStatus) + ['Accepted', 'PurchasedPlan'].includes(planComposedStatus) } onClick={handleBuyButtonClick} > diff --git a/src/pages/Profile/FormNotificationSettings.tsx b/src/pages/Profile/FormNotificationSettings.tsx new file mode 100644 index 000000000..878585c32 --- /dev/null +++ b/src/pages/Profile/FormNotificationSettings.tsx @@ -0,0 +1,99 @@ +import { useToast, Notification } from '@appquality/unguess-design-system'; +import { Formik } from 'formik'; +import { useTranslation } from 'react-i18next'; +import * as Yup from 'yup'; +import { + useGetUsersMePreferencesQuery, + usePutUsersMePreferencesBySlugMutation, +} from 'src/features/api'; +import { NotificationSettingsFormValues } from './valuesType'; +import { NotificationSettingsCard } from './parts/NotificationSettingsCard'; +import { Loader } from './parts/cardLoader'; + +export const FormNotificationSettings = () => { + const { t } = useTranslation(); + const { addToast } = useToast(); + const { data, isLoading } = useGetUsersMePreferencesQuery(); + const [updatePreferences] = usePutUsersMePreferencesBySlugMutation(); + const activitySetup = + data?.items?.find((pref) => pref.name === 'plan_notifications_enabled') + ?.value ?? '1'; + + const activityProgress = + data?.items?.find((pref) => pref.name === 'notifications_enabled')?.value ?? + '1'; + + const initialValues: NotificationSettingsFormValues = { + activitySetupUpdates: activitySetup === '1', + activityProgress: activityProgress === '1', + }; + + if (isLoading) return <Loader />; + + const schema = Yup.object().shape({ + activitySetupUpdates: Yup.boolean(), + activityProgress: Yup.boolean(), + }); + + const handleUpdatePreferences = async ( + values: NotificationSettingsFormValues + ) => { + try { + if (values.activitySetupUpdates !== initialValues.activitySetupUpdates) { + await updatePreferences({ + slug: 'plan_notifications_enabled', + body: { + value: values.activitySetupUpdates ? '1' : '0', + }, + }).unwrap(); + } + if (values.activityProgress !== initialValues.activityProgress) { + await updatePreferences({ + slug: 'notifications_enabled', + body: { + value: values.activityProgress ? '1' : '0', + }, + }).unwrap(); + } + addToast( + ({ close }) => ( + <Notification + onClose={close} + type="success" + message={t('__PROFILE_PAGE_UPDATE_SETTINGS_SUCCESS')} + isPrimary + /> + ), + { placement: 'top' } + ); + } catch (error) { + addToast( + ({ close }) => ( + <Notification + onClose={close} + type="error" + message={t('__PROFILE_PAGE_TOAST_ERROR_UPDATING_SETTINGS')} + isPrimary + /> + ), + { placement: 'top' } + ); + } + }; + + return ( + <Formik + initialValues={initialValues} + validationSchema={schema} + enableReinitialize + validateOnChange + onSubmit={async (values, actions) => { + actions.setSubmitting(true); + await handleUpdatePreferences(values); + actions.setSubmitting(false); + }} + > + <NotificationSettingsCard /> + </Formik> + ); +}; diff --git a/src/pages/Profile/index.tsx b/src/pages/Profile/index.tsx index e1c96cf73..5865a092c 100644 --- a/src/pages/Profile/index.tsx +++ b/src/pages/Profile/index.tsx @@ -10,6 +10,7 @@ import { Page } from 'src/features/templates/Page'; import ProfilePageHeader from 'src/pages/Profile/Header'; import { FormPassword } from './FormPassword'; import { FormProfile } from './FormProfile'; +import { FormNotificationSettings } from './FormNotificationSettings'; const Profile = () => { const { t } = useTranslation(); @@ -25,29 +26,49 @@ const Profile = () => { <Grid gutters="xl" columns={12} style={{ marginTop: theme.space.xxl }}> <Row> <Col xs={12} lg={2} style={{ margin: 0 }}> - <AsideNav containerId="main"> + <AsideNav + containerId="main" + isSpy + isSmooth + duration={500} + offset={-30} + > <> <StickyNavItem - id="anchor-profile" to="anchor-profile-id" containerId="main" + spy smooth duration={500} offset={-30} + activeClass="isCurrent" > {t('__PROFILE_PAGE_NAV_ITEM_PROFILE')} </StickyNavItem> + <StickyNavItem + to="anchor-notification-settings-id" + containerId="main" + spy + smooth + duration={500} + offset={-30} + activeClass="isCurrent" + > + {t('__PROFILE_PAGE_NAV_ITEM_NOTIFICATION_SETTINGS')} + </StickyNavItem> <StickyNavItemLabel> {t('__PROFILE_PAGE_NAV_SECTION_PASSWORD')} </StickyNavItemLabel> <StickyNavItem - id="anchor-password" to="anchor-password-id" containerId="main" + spy smooth duration={500} + offset={-30} + activeClass="isCurrent" > {t('__PROFILE_PAGE_NAV_ITEM_PASSWORD')} </StickyNavItem> @@ -68,6 +89,7 @@ const Profile = () => { }} > <FormProfile /> + <FormNotificationSettings /> <FormPassword /> </Col> </Row> diff --git a/src/pages/Profile/parts/FollowActivitiesAccordion.tsx/FollowActivitiesPanel.tsx b/src/pages/Profile/parts/FollowActivitiesAccordion.tsx/FollowActivitiesPanel.tsx new file mode 100644 index 000000000..316372200 --- /dev/null +++ b/src/pages/Profile/parts/FollowActivitiesAccordion.tsx/FollowActivitiesPanel.tsx @@ -0,0 +1,125 @@ +import { + Anchor, + Label, + SM, + useToast, + Notification, +} from '@appquality/unguess-design-system'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as InfoIcon } from 'src/assets/icons/info-icon.svg'; + +import styled from 'styled-components'; +import { + GetUsersMeWatchedPlansApiResponse, + useDeletePlansByPidWatchersAndProfileIdMutation, + useGetUsersMeQuery, +} from 'src/features/api'; +import { UnfollowButton } from './UnfollowButton'; + +const StyledPanelSectionContainer = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.space.xs}; + margin-bottom: ${({ theme }) => theme.space.xxl}; +`; + +const StyledHintContainer = styled.div` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.space.xs}; +`; + +const StyledActivityItem = styled.div` + padding-top: ${({ theme }) => theme.space.sm}; + padding-bottom: ${({ theme }) => theme.space.sm}; + display: flex; + align-items: center; + justify-content: space-between; +`; + +export const FollowActivitiesPanel = ({ + followedActivities, +}: { + followedActivities: GetUsersMeWatchedPlansApiResponse['items']; +}) => { + const { t } = useTranslation(); + const { addToast } = useToast(); + const { data: userData } = useGetUsersMeQuery(); + const [unfollowPlan] = useDeletePlansByPidWatchersAndProfileIdMutation(); + + const truncateName = ( + name: string | undefined, + maxLength: number = 40 + ): string => { + if (!name) return ''; + return name.length > maxLength + ? `${name.substring(0, maxLength)}...` + : name; + }; + const handleUnfollow = async (planId: number) => { + try { + await unfollowPlan({ + pid: planId.toString(), + profileId: userData?.profile_id.toString() ?? '', + }).unwrap(); + + addToast( + ({ close }) => ( + <Notification + onClose={close} + type="success" + message={t('__PROFILE_PAGE_UNFOLLOW_SUCCESS')} + isPrimary + /> + ), + { placement: 'top' } + ); + } catch (error) { + addToast( + ({ close }) => ( + <Notification + onClose={close} + type="error" + message={t('__PROFILE_PAGE_UNFOLLOW_ERROR')} + isPrimary + /> + ), + { placement: 'top' } + ); + } + }; + + return ( + <div> + <StyledPanelSectionContainer> + <Label> + {t( + '__PROFILE_PAGE_NOTIFICATIONS_CARD_FOLLOW_ACTIVITIES_SETUP_DESCRIPTION' + )} + {`(${followedActivities.length})`} + </Label> + {followedActivities.map((activity) => ( + <StyledActivityItem> + <div> + <Anchor href={`/plans/${activity.id}`}> + {truncateName(activity?.name)} + </Anchor> + <SM>{activity?.project?.name}</SM> + </div> + <UnfollowButton + isDisabled={!!activity.isLast} + activityId={activity.id ?? 0} + handleUnfollow={handleUnfollow} + /> + </StyledActivityItem> + ))} + </StyledPanelSectionContainer> + <StyledHintContainer> + <InfoIcon /> + <SM> + {t('__PROFILE_PAGE_NOTIFICATIONS_CARD_FOLLOW_ACTIVITIES_HINT_TEXT')} + </SM> + </StyledHintContainer> + </div> + ); +}; diff --git a/src/pages/Profile/parts/FollowActivitiesAccordion.tsx/UnfollowButton.tsx b/src/pages/Profile/parts/FollowActivitiesAccordion.tsx/UnfollowButton.tsx new file mode 100644 index 000000000..18386db1f --- /dev/null +++ b/src/pages/Profile/parts/FollowActivitiesAccordion.tsx/UnfollowButton.tsx @@ -0,0 +1,40 @@ +import { Button, Tooltip } from '@appquality/unguess-design-system'; +import { useTranslation } from 'react-i18next'; + +const UnfollowButton = ({ + isDisabled, + activityId, + handleUnfollow, +}: { + isDisabled: boolean; + activityId: number; + handleUnfollow: (id: number) => void; +}) => { + const { t } = useTranslation(); + + const button = ( + <Button + disabled={isDisabled} + size="small" + isBasic + onClick={() => handleUnfollow(activityId)} + > + {t('__PROFILE_PAGE_NOTIFICATIONS_CARD_FOLLOW_ACTIVITIES_BUTTON_TEXT')} + </Button> + ); + if (isDisabled) { + return ( + <Tooltip + placement="start" + type="light" + size="large" + content={t('__PROFILE_PAGE_UNFOLLOW_BUTTON_DISABLED_TOOLTIP')} + > + <div>{button}</div> + </Tooltip> + ); + } + return button; +}; + +export { UnfollowButton }; diff --git a/src/pages/Profile/parts/FollowActivitiesAccordion.tsx/index.tsx b/src/pages/Profile/parts/FollowActivitiesAccordion.tsx/index.tsx new file mode 100644 index 000000000..2d035e43d --- /dev/null +++ b/src/pages/Profile/parts/FollowActivitiesAccordion.tsx/index.tsx @@ -0,0 +1,62 @@ +import { AccordionNew, Tag } from '@appquality/unguess-design-system'; +import { useTranslation } from 'react-i18next'; +import { appTheme } from 'src/app/theme'; +import { ReactComponent as EyeIcon } from 'src/assets/icons/eye-icon-fill.svg'; +import { useGetUsersMeWatchedPlansQuery } from 'src/features/api'; +import { FollowActivitiesPanel } from './FollowActivitiesPanel'; +import { Loader } from '../cardLoader'; + +export const FollowActivitiesAccordion = () => { + const { t } = useTranslation(); + const { + data: followedActivities, + isLoading, + isError, + } = useGetUsersMeWatchedPlansQuery(); + + if (isLoading) return <Loader />; + if (isError || !followedActivities) return null; + return ( + <AccordionNew + isCompact + hasBorder={false} + level={3} + defaultExpandedSections={[]} + responsiveBreakpoint={650} + > + <AccordionNew.Section> + <AccordionNew.Header + icon={ + <EyeIcon + style={{ + color: appTheme.palette.blue[600], + }} + /> + } + > + <AccordionNew.Label + label={t( + '__PROFILE_PAGE_NOTIFICATIONS_CARD_FOLLOW_ACTIVITIES_LABEL' + )} + subtitle={t( + '__PROFILE_PAGE_NOTIFICATIONS_CARD_FOLLOW_ACTIVITIES_HINT' + )} + /> + <AccordionNew.Meta> + <Tag> + {`${followedActivities.items.length}/${followedActivities.allItems} `} + {t('__PROFILE_PAGE_NOTIFICATIONS_CARD_FOLLOW_ACTIVITIES_TAG')} + </Tag> + </AccordionNew.Meta> + </AccordionNew.Header> + <AccordionNew.Panel> + {followedActivities.items.length > 0 && ( + <FollowActivitiesPanel + followedActivities={followedActivities.items} + /> + )} + </AccordionNew.Panel> + </AccordionNew.Section> + </AccordionNew> + ); +}; diff --git a/src/pages/Profile/parts/NotificationSettingsCard.tsx b/src/pages/Profile/parts/NotificationSettingsCard.tsx new file mode 100644 index 000000000..a403150c3 --- /dev/null +++ b/src/pages/Profile/parts/NotificationSettingsCard.tsx @@ -0,0 +1,76 @@ +import { + Button, + ContainerCard, + LG, + MD, +} from '@appquality/unguess-design-system'; +import { useTranslation } from 'react-i18next'; +import { appTheme } from 'src/app/theme'; +import { ReactComponent as EmailIcon } from 'src/assets/icons/email-icon.svg'; + +import { Divider } from 'src/common/components/divider'; +import { useFormikContext } from 'formik'; +import { + StyledCardHeader, + StyledNotificationsCardHeaderWrapper, +} from './common'; +import { NotificationSettingsAccordion } from './NotificationsAccordion'; +import { FollowActivitiesAccordion } from './FollowActivitiesAccordion.tsx'; +import { NotificationSettingsFormValues } from '../valuesType'; + +export const NotificationSettingsCard = () => { + const { t } = useTranslation(); + const { dirty, isSubmitting, submitForm } = + useFormikContext<NotificationSettingsFormValues>(); + const canSave = dirty && !isSubmitting; + return ( + <ContainerCard + id="anchor-notification-settings-id" + data-qa="notification-settings-card" + title={t('__PROFILE_PAGE_NOTIFICATIONS_CARD_LABEL')} + > + <div style={{ display: 'flex', flexDirection: 'column' }}> + <StyledNotificationsCardHeaderWrapper> + <StyledCardHeader> + <EmailIcon + style={{ + color: appTheme.palette.blue[600], + width: appTheme.iconSizes.md, + height: appTheme.iconSizes.md, + }} + /> + <LG isBold style={{ color: appTheme.palette.grey[800] }}> + {t('__PROFILE_PAGE_NOTIFICATIONS_CARD_LABEL')} + </LG> + </StyledCardHeader> + <MD color={appTheme.palette.grey[600]}> + {t('__PROFILE_PAGE_NOTIFICATIONS_CARD_DESCRIPTION')} + </MD> + </StyledNotificationsCardHeaderWrapper> + <Divider + style={{ + marginTop: appTheme.space.sm, + marginBottom: appTheme.space.sm, + }} + /> + <NotificationSettingsAccordion /> + <Button + disabled={!canSave} + isAccent + isPrimary + style={{ alignSelf: 'flex-end' }} + onClick={submitForm} + > + {t('__PROFILE_PAGE_NOTIFICATIONS_CARD_SAVE_BUTTON_LABEL')} + </Button> + </div> + <Divider + style={{ + marginTop: appTheme.space.lg, + marginBottom: appTheme.space.lg, + }} + /> + <FollowActivitiesAccordion /> + </ContainerCard> + ); +}; diff --git a/src/pages/Profile/parts/NotificationsAccordion/CommunicationUpdatesPanel.tsx b/src/pages/Profile/parts/NotificationsAccordion/CommunicationUpdatesPanel.tsx new file mode 100644 index 000000000..d8ede6c9b --- /dev/null +++ b/src/pages/Profile/parts/NotificationsAccordion/CommunicationUpdatesPanel.tsx @@ -0,0 +1,121 @@ +import { + Alert, + Checkbox, + FormField, + Hint, + Label, + UnorderedList, +} from '@appquality/unguess-design-system'; +import { Field, FieldProps, useFormikContext } from 'formik'; +import { useTranslation } from 'react-i18next'; +import { appTheme } from 'src/app/theme'; +import styled from 'styled-components'; +import { NotificationSettingsFormValues } from '../../valuesType'; + +const StyledPanelContainer = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.space.lg}; +`; + +const StyledCheckBoxContainer = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.space.sm}; +`; + +export const CommunicationUpdatesPanel = () => { + const { t } = useTranslation(); + const { setFieldValue } = useFormikContext<NotificationSettingsFormValues>(); + return ( + <StyledPanelContainer> + <StyledCheckBoxContainer> + <Label> + {t( + '__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_PROGRESS_UPDATES_FORM_LABEL' + )} + </Label> + <Field name="activitySetupUpdates"> + {({ field }: FieldProps) => ( + <FormField> + <Checkbox + role="checkbox" + key="all" + checked={field.value} + onChange={() => + setFieldValue('activitySetupUpdates', !field.value) + } + > + <Label> + {t( + '__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_SETUP_CHECKBOX_LABEL' + )} + </Label> + <Hint> + {t( + '__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_SETUP_CHECKBOX_HINT' + )} + </Hint> + </Checkbox> + </FormField> + )} + </Field> + <Field name="activityProgress"> + {({ field }: FieldProps) => ( + <FormField> + <Checkbox + role="checkbox" + key="all" + checked={field.value} + onChange={() => setFieldValue('activityProgress', !field.value)} + > + <Label> + {t( + '__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_PROGRESS_CHECKBOX_LABEL' + )} + </Label> + <Hint> + {t( + '__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_PROGRESS_CHECKBOX_HINT' + )} + </Hint> + </Checkbox> + </FormField> + )} + </Field> + </StyledCheckBoxContainer> + <Alert type="info"> + <Alert.Title style={{ marginBottom: appTheme.space.xxs }}> + {t('__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_PROGRESS_ALERT_TITLE')} + </Alert.Title> + <UnorderedList> + <UnorderedList.Item> + {t( + '__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_PROGRESS_ALERT_ITEM_1' + )} + </UnorderedList.Item> + <UnorderedList.Item> + {t( + '__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_PROGRESS_ALERT_ITEM_2' + )} + </UnorderedList.Item> + <UnorderedList.Item> + {t( + '__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_PROGRESS_ALERT_ITEM_3' + )} + </UnorderedList.Item> + <UnorderedList.Item> + {t( + '__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_PROGRESS_ALERT_ITEM_4' + )} + </UnorderedList.Item> + <UnorderedList.Item> + {t( + '__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_PROGRESS_ALERT_ITEM_5' + )} + </UnorderedList.Item> + </UnorderedList> + </Alert> + </StyledPanelContainer> + ); +}; diff --git a/src/pages/Profile/parts/NotificationsAccordion/index.tsx b/src/pages/Profile/parts/NotificationsAccordion/index.tsx new file mode 100644 index 000000000..4ab08a6f8 --- /dev/null +++ b/src/pages/Profile/parts/NotificationsAccordion/index.tsx @@ -0,0 +1,41 @@ +import { AccordionNew, Tag } from '@appquality/unguess-design-system'; +import { useTranslation } from 'react-i18next'; +import { appTheme } from 'src/app/theme'; +import { CommunicationUpdatesPanel } from './CommunicationUpdatesPanel'; + +export const NotificationSettingsAccordion = () => { + const { t } = useTranslation(); + + return ( + <AccordionNew + isCompact + hasBorder={false} + level={3} + defaultExpandedSections={[]} + responsiveBreakpoint={650} + > + <AccordionNew.Section> + <AccordionNew.Header> + <AccordionNew.Label + label={t( + '__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_PROGRESS_UPDATES_LABEL' + )} + subtitle={t( + '__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_PROGRESS_UPDATES_HINT' + )} + /> + <AccordionNew.Meta> + <Tag color={appTheme.palette.green[600]}> + {t( + '__PROFILE_PAGE_NOTIFICATIONS_CARD_ACTIVITY_PROGRESS_UPDATES_TAG' + )} + </Tag> + </AccordionNew.Meta> + </AccordionNew.Header> + <AccordionNew.Panel> + <CommunicationUpdatesPanel /> + </AccordionNew.Panel> + </AccordionNew.Section> + </AccordionNew> + ); +}; diff --git a/src/pages/Profile/parts/PasswordAccordion/index.tsx b/src/pages/Profile/parts/PasswordAccordion/index.tsx index af4817601..aa6fa0a54 100644 --- a/src/pages/Profile/parts/PasswordAccordion/index.tsx +++ b/src/pages/Profile/parts/PasswordAccordion/index.tsx @@ -78,7 +78,7 @@ export const PasswordAccordion = () => { submitForm, } = useFormikContext<PasswordFormValues>(); - const isOpen = false; // Temporary, as the context is not fully implemented yet + const isOpen = false; return ( <ContainerCard style={{ padding: 0 }}> diff --git a/src/pages/Profile/parts/ProfileCard.tsx b/src/pages/Profile/parts/ProfileCard.tsx index 541298063..34a79a88d 100644 --- a/src/pages/Profile/parts/ProfileCard.tsx +++ b/src/pages/Profile/parts/ProfileCard.tsx @@ -1,5 +1,6 @@ import { Button, + ContainerCard, FormField, Input, Label, @@ -19,12 +20,7 @@ import { } from 'src/features/api'; import { ProfileFormValues } from '../valuesType'; import { Loader } from './cardLoader'; -import { - CardInnerPanel, - StyledCardHeader, - StyledContainerCard, - StyledFooter, -} from './common'; +import { CardInnerPanel, StyledCardHeader, StyledFooter } from './common'; export const ProfileCard = () => { const { t } = useTranslation(); @@ -40,7 +36,7 @@ export const ProfileCard = () => { if (userRoleIsLoading || userCompanySizesIsLoading) return <Loader />; return ( - <StyledContainerCard + <ContainerCard id="anchor-profile-id" data-qa="profile-card" title={t('__PROFILE_PAGE_USER_CARD_LABEL')} @@ -261,6 +257,6 @@ export const ProfileCard = () => { </Button> </StyledFooter> </CardInnerPanel> - </StyledContainerCard> + </ContainerCard> ); }; diff --git a/src/pages/Profile/parts/common.tsx b/src/pages/Profile/parts/common.tsx index d7d2f432d..6d75c52f1 100644 --- a/src/pages/Profile/parts/common.tsx +++ b/src/pages/Profile/parts/common.tsx @@ -10,6 +10,14 @@ export const StyledCardHeader = styled.div` align-items: center; gap: ${({ theme }) => theme.space.xs}; `; + +export const StyledNotificationsCardHeaderWrapper = styled(StyledCardHeader)` + display: flex; + flex-direction: column; + align-items: flex-start; + gap: ${({ theme }) => theme.space.sm}; +`; + export const StyledContainerCard = styled(ContainerCard)` padding: ${({ theme }) => theme.space.md}; padding-bottom: ${({ theme }) => theme.space.lg}; diff --git a/src/pages/Profile/valuesType.ts b/src/pages/Profile/valuesType.ts index a436ec8f3..c265f73c7 100644 --- a/src/pages/Profile/valuesType.ts +++ b/src/pages/Profile/valuesType.ts @@ -8,6 +8,11 @@ export type ProfileFormValues = { companySizeId: number; }; +export type NotificationSettingsFormValues = { + activitySetupUpdates: boolean; + activityProgress: boolean; +}; + export type PasswordFormValues = { currentPassword: string; newPassword: string; diff --git a/src/pages/Video/Actions.tsx b/src/pages/Video/Actions.tsx index 9381b14ec..dd0974bd3 100644 --- a/src/pages/Video/Actions.tsx +++ b/src/pages/Video/Actions.tsx @@ -106,7 +106,7 @@ const Actions = () => { <Divider /> <SentimentOverview /> <div style={{ padding: `${appTheme.space.md} 0` }}> - <LG isBold> + <LG isBold data-qa="tagging_tool_page_title_observations"> {t('__OBSERVATIONS_DRAWER_TOTAL')}: {observations.length} </LG> {observations && severities && severities.length > 0 && ( diff --git a/src/pages/Video/components/EditTagModal.tsx b/src/pages/Video/components/EditTagModal.tsx new file mode 100644 index 000000000..2d191b548 --- /dev/null +++ b/src/pages/Video/components/EditTagModal.tsx @@ -0,0 +1,192 @@ +import { + Button, + Input, + Label, + MD, + Message, + Notification, + Paragraph, + SM, + TooltipModal, + useToast, +} from '@appquality/unguess-design-system'; +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router-dom'; +import analytics from 'src/analytics'; +import { appTheme } from 'src/app/theme'; +import { ReactComponent as SaveIcon } from 'src/assets/icons/save.svg'; +import { + GetCampaignsByCidVideoTagsApiResponse, + usePatchCampaignsByCidVideoTagsAndTagIdMutation, +} from 'src/features/api'; + +interface EditModalProps { + tag: GetCampaignsByCidVideoTagsApiResponse[number]['tags'][number]; + closeModal: () => void; + title: string; + label: string; + description: string; + type: 'theme' | 'extraTag'; +} + +export const EditTagModal = ({ + closeModal, + tag, + title, + label, + description, + type, +}: EditModalProps) => { + // Extract both the current title and the usage number in parentheses in two variables + + const [newName, setNewName] = useState(tag.name); + const inputRef = useRef<HTMLInputElement>(null); + const [error, setError] = useState<string | null>(null); + const [patchVideoTag] = usePatchCampaignsByCidVideoTagsAndTagIdMutation({}); + const { addToast } = useToast(); + const { t } = useTranslation(); + const { campaignId } = useParams(); + + useEffect(() => { + if (newName.trim() === '') { + setError( + t('__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_TITLE_REQUIRED_ERROR') + ); + } else { + setError(null); + } + }, [newName]); + + const handleSubmit = async () => { + analytics.track('tagUpdateSubmitted', { + tagId: tag.id.toString(), + tagType: type, + associatedObservations: tag.usageNumber, + submissionTime: Date.now(), + }); + + if (error) return; + // Update the title in the form + try { + await patchVideoTag({ + cid: campaignId?.toString() || '0', + tagId: tag.id.toString(), + body: { + newTagName: newName, + }, + }).unwrap(); + closeModal(); + addToast( + ({ close }) => ( + <Notification + onClose={close} + type="success" + message={t( + '__VIDEO_PAGE_DROPDOWN_EDIT_MODAL_SUCCESS_TOAST_MESSAGE' + )} + closeText={t('__TOAST_CLOSE_TEXT')} + isPrimary + /> + ), + { placement: 'top' } + ); + } catch (err: any) { + analytics.track('tagUpdateFailed', { + tagId: tag.id.toString(), + tagType: type, + attemptedTagName: newName, + errorType: err.status === 409 ? 'duplicate' : 'other', + errorMessage: err.message, + associatedObservations: tag.usageNumber, + }); + + // Handle error (e.g., show error toast) + // if status code is 409, conflict with another already saved name, show specific error + if (err.status === 409) { + setError( + t('__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_TITLE_DUPLICATE_ERROR') + ); + } else { + addToast( + ({ close }) => ( + <Notification + onClose={close} + type="error" + message={t( + '__VIDEO_PAGE_DROPDOWN_EDIT_MODAL_ERROR_TOAST_MESSAGE' + )} + closeText={t('__TOAST_CLOSE_TEXT')} + isPrimary + /> + ), + { placement: 'top' } + ); + } + } + }; + const handleClick = () => { + inputRef.current?.focus(); + }; + + return ( + <> + <TooltipModal.Title> + <MD isBold style={{ marginBottom: appTheme.space.sm }}> + {title} + </MD> + </TooltipModal.Title> + <TooltipModal.Body> + <Label htmlFor="title-input"> + {label} + <span style={{ color: appTheme.palette.red[500] }}>*</span> + </Label> + <Input + ref={inputRef} + id="title-input" + onClick={handleClick} + value={newName} + onChange={(e) => setNewName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleSubmit(); + } + }} + /> + {error ? ( + <Message validation="error" style={{ marginTop: appTheme.space.xs }}> + {error} + </Message> + ) : ( + <Message style={{ marginTop: appTheme.space.xs }}> + {t('__VIDEO_PAGE_DROPDOWN_EDIT_MODAL_INPUT_HELPER_TEXT')} + </Message> + )} + <div + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + margin: `${appTheme.space.md} 0 0 0`, + }} + > + <Paragraph style={{ margin: 0 }}> + <SM>{description}</SM> + </Paragraph> + <Button + size="small" + disabled={!!error} + isPrimary + isAccent + onClick={handleSubmit} + > + <Button.StartIcon> + <SaveIcon /> + </Button.StartIcon> + {t('__VIDEO_PAGE_DROPDOWN_EDIT_MODAL_SAVE_BUTTON')} + </Button> + </div> + </TooltipModal.Body> + </> + ); +}; diff --git a/src/pages/Video/components/Observation.tsx b/src/pages/Video/components/Observation.tsx index 824b12ad0..7d0bae4a3 100644 --- a/src/pages/Video/components/Observation.tsx +++ b/src/pages/Video/components/Observation.tsx @@ -106,10 +106,9 @@ const Observation = ({ ); if (activeElement) { refScroll.current.scrollTo({ - top: activeElement.offsetTop, + top: activeElement.offsetTop - 150, // account for header height behavior: 'smooth', }); - activeElement.querySelectorAll('input')[0]?.focus(); } setOpenAccordion(undefined); }, 100); @@ -147,6 +146,7 @@ const Observation = ({ onChange={handleAccordionChange} key={`observation_accordion_${observation.id}_${isOpen}`} id={`video-observation-accordion-${observation.id}`} + data-qa={`observation-accordion-${observation.id}`} > <AccordionNew.Section> <AccordionNew.Header diff --git a/src/pages/Video/components/ObservationForm.tsx b/src/pages/Video/components/ObservationForm.tsx index 1ee728829..c93eab7c7 100644 --- a/src/pages/Video/components/ObservationForm.tsx +++ b/src/pages/Video/components/ObservationForm.tsx @@ -12,10 +12,12 @@ import { Textarea, useToast, } from '@appquality/unguess-design-system'; +import { ReactComponent as EditIcon } from '@zendeskgarden/svg-icons/src/12/pencil-stroke.svg'; import { Form, Formik, FormikHelpers, FormikProps } from 'formik'; -import { useEffect, useRef, useState } from 'react'; +import { ComponentProps, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; +import analytics from 'src/analytics'; import { appTheme } from 'src/app/theme'; import { getColorWithAlpha } from 'src/common/utils'; import { @@ -29,6 +31,8 @@ import { import { styled } from 'styled-components'; import * as Yup from 'yup'; import { ConfirmDeleteModal } from './ConfirmDeleteModal'; +import { TooltipModalContextProvider } from './context'; +import { EditTagModal } from './EditTagModal'; import { ObservationFormValues, TitleDropdown } from './TitleDropdownNew'; const FormContainer = styled.div` @@ -72,7 +76,7 @@ const ObservationForm = ({ const formRef = useRef<FormikProps<ObservationFormValues>>(null); const { addToast } = useToast(); const [options, setOptions] = useState< - { id: number; label: string; selected?: boolean }[] + ComponentProps<typeof MultiSelect>['options'] >([]); const [selectedSeverity, setSelectedSeverity] = useState< GetCampaignsByCidVideoTagsApiResponse[number]['tags'][number] | undefined @@ -141,8 +145,34 @@ const ObservationForm = ({ .sort((a, b) => b.usageNumber - a.usageNumber) .map((tag) => ({ id: tag.id, + itemID: tag.id.toString(), label: `${tag.name} (${tag.usageNumber})`, selected: selectedOptions.some((bt) => bt.id === tag.id), + actions: ({ closeModal }) => ( + <EditTagModal + type="extraTag" + tag={tag} + closeModal={closeModal} + title={t('__VIDEO_PAGE_TAGS_DROPDOWN_EDIT_MODAL_TITLE')} + label={t('__VIDEO_PAGE_TAGS_DROPDOWN_EDIT_MODAL_LABEL')} + description={t( + '__VIDEO_PAGE_TAGS_DROPDOWN_EDIT_MODAL_DESCRIPTION', + { + usageNumber: tag.usageNumber, + count: Number(tag.usageNumber), + } + )} + /> + ), + actionIcon: <EditIcon />, + onOptionActionClick: () => { + analytics.track('tagEditModalOpened', { + tagId: tag.id.toString(), + tagType: 'extraTag', + tagName: tag.name, + associatedObservations: tag.usageNumber, + }); + }, })) ); } @@ -231,7 +261,7 @@ const ObservationForm = ({ }; return ( - <> + <TooltipModalContextProvider> <FormContainer> <Formik innerRef={formRef} @@ -354,10 +384,21 @@ const ObservationForm = ({ <Skeleton /> ) : ( <MultiSelect + data-qa="video-tags-dropdown" + isEditable options={options} selectedItems={options.filter((o) => o.selected)} + listboxAppendToNode={document.body} creatable maxItems={4} + onClick={() => + analytics.track('tagDropdownOpened', { + dropdownType: 'extraTags', + availableTagsCount: options.length, + selectedTagsCount: options.filter((o) => o.selected) + .length, + }) + } size="medium" i18n={{ placeholder: t( @@ -479,7 +520,7 @@ const ObservationForm = ({ setIsConfirmationModalOpen={setIsConfirmationModalOpen} /> )} - </> + </TooltipModalContextProvider> ); }; diff --git a/src/pages/Video/components/SentimentOverview/index.tsx b/src/pages/Video/components/SentimentOverview/index.tsx index 7ca0750e9..6188330b8 100644 --- a/src/pages/Video/components/SentimentOverview/index.tsx +++ b/src/pages/Video/components/SentimentOverview/index.tsx @@ -58,6 +58,7 @@ export const SentimentOverview = () => { <AccordionNew.Section> <StyledHeader icon={<AiIcon />}> <AccordionNew.Label + data-qa="tagging_tool_page_accordions_header_summary" label={t('__SENTIMENT_OVERVIEW_TITLE')} subtitle={t('__SENTIMENT_OVERVIEW_SUBTITLE')} /> diff --git a/src/pages/Video/components/TitleDropdownNew.tsx b/src/pages/Video/components/TitleDropdownNew.tsx index 7966e6506..d30615a33 100644 --- a/src/pages/Video/components/TitleDropdownNew.tsx +++ b/src/pages/Video/components/TitleDropdownNew.tsx @@ -2,14 +2,18 @@ import { Autocomplete, DropdownFieldNew as Field, } from '@appquality/unguess-design-system'; +import { ReactComponent as EditIcon } from '@zendeskgarden/svg-icons/src/12/pencil-stroke.svg'; import { FormikProps } from 'formik'; +import { ComponentProps, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; +import analytics from 'src/analytics'; import { ReactComponent as CopyIcon } from 'src/assets/icons/copy-icon.svg'; import { GetCampaignsByCidVideoTagsApiResponse, usePostCampaignsByCidVideoTagsMutation, } from 'src/features/api'; +import { EditTagModal } from './EditTagModal'; export interface ObservationFormValues { title: number; @@ -29,6 +33,43 @@ export const TitleDropdown = ({ const { campaignId } = useParams(); const [addVideoTags] = usePostCampaignsByCidVideoTagsMutation(); const titleMaxLength = 70; + const options: ComponentProps<typeof Autocomplete>['options'] = useMemo( + () => + (titles || []).map((i) => ({ + id: i.id.toString(), + value: i.id.toString(), + children: `${i.name} (${i.usageNumber})`, + label: i.name, + isSelected: formProps.values.title === i.id, + actions: ({ closeModal }) => ( + <EditTagModal + type="theme" + tag={i} + closeModal={closeModal} + title={t('__VIDEO_PAGE_THEMES_DROPDOWN_EDIT_MODAL_TITLE')} + label={t('__VIDEO_PAGE_THEMES_DROPDOWN_EDIT_MODAL_LABEL')} + description={t( + '__VIDEO_PAGE_THEMES_DROPDOWN_EDIT_MODAL_DESCRIPTION', + { + usageNumber: i.usageNumber, + count: Number(i.usageNumber), + } + )} + /> + ), + actionIcon: <EditIcon />, + itemID: i.id.toString(), + onOptionActionClick: () => { + analytics.track('tagEditModalOpened', { + tagId: i.id.toString(), + tagType: 'theme', + tagName: i.name, + associatedObservations: i.usageNumber, + }); + }, + })), + [titles, formProps.values.title] + ); if (!titles) { return null; @@ -37,14 +78,23 @@ export const TitleDropdown = ({ return ( <Field> <Autocomplete - listboxAppendToNode={document.querySelector('main') || undefined} + options={options} + data-qa="video-title-dropdown" + isEditable isCreatable + listboxAppendToNode={document.body} renderValue={({ selection }) => { if (!selection) return ''; // @ts-ignore const title = titles.find((i) => i.id === Number(selection.value)); return title?.name || ''; }} + onClick={() => + analytics.track('tagDropdownOpened', { + dropdownType: 'theme', + availableTagsCount: options.length, + }) + } selectionValue={formProps.values.title.toString()} onCreateNewOption={async (value) => { if (value.length > titleMaxLength) { @@ -79,12 +129,6 @@ export const TitleDropdown = ({ if (!selectionValue || !inputValue) return; formProps.setFieldValue('title', Number(selectionValue)); }} - options={(titles || []).map((i) => ({ - id: i.id.toString(), - value: i.id.toString(), - label: `${i.name} (${i.usageNumber})`, - isSelected: formProps.values.title === i.id, - }))} startIcon={<CopyIcon />} placeholder={t( '__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_TITLE_PLACEHOLDER' diff --git a/src/pages/Video/components/Transcript/index.tsx b/src/pages/Video/components/Transcript/index.tsx index 745911baa..3618fc739 100644 --- a/src/pages/Video/components/Transcript/index.tsx +++ b/src/pages/Video/components/Transcript/index.tsx @@ -36,7 +36,7 @@ const TranscriptWrapper = ({ marginBottom: appTheme.space.xl, }} > - <ContainerCard> + <ContainerCard data-qa="tagging_tool_page_transcript_card"> <Header editor={isEmpty ? undefined : editor} isEmpty={isEmpty} /> {video?.transcript ? children : <EmptyState />} </ContainerCard> diff --git a/src/pages/Video/components/context.tsx b/src/pages/Video/components/context.tsx new file mode 100644 index 000000000..78a2ee71c --- /dev/null +++ b/src/pages/Video/components/context.tsx @@ -0,0 +1,40 @@ +import { createContext, useContext, useMemo, useState } from 'react'; + +interface TooltipModalContextType { + modalRef: HTMLDivElement | null; + setModalRef: (ref: HTMLDivElement | null) => void; +} + +const TooltipModalContext = createContext<TooltipModalContextType | null>(null); + +export const TooltipModalContextProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const [modalRef, setModalRef] = + useState<TooltipModalContextType['modalRef']>(null); + + const TooltipModalContextValue = useMemo( + () => ({ + modalRef, + setModalRef, + }), + [modalRef, setModalRef] + ); + + return ( + <TooltipModalContext.Provider value={TooltipModalContextValue}> + {children} + </TooltipModalContext.Provider> + ); +}; + +export const useTooltipModalContext = () => { + const context = useContext(TooltipModalContext); + + if (!context) + throw new Error('Provider not found for TooltipModalContextProvider'); + + return context; // Now we can use the context in the component, SAFELY. +}; diff --git a/tests/api/campaigns/cid/video-tags/_get/200_Example_1.json b/tests/api/campaigns/cid/video-tags/_get/200_Example_1.json index 9ce5ec201..a356ecea0 100644 --- a/tests/api/campaigns/cid/video-tags/_get/200_Example_1.json +++ b/tests/api/campaigns/cid/video-tags/_get/200_Example_1.json @@ -13,5 +13,98 @@ "usageNumber": 0 } ] + }, + { + "group": { "id": 3, "name": "severity" }, + "tags": [ + { + "id": 1094, + "name": "Positive Finding", + "style": "#007D5A", + "usageNumber": 34 + }, + { + "id": 1099, + "name": "Observation", + "style": "#003A57", + "usageNumber": 29 + }, + { + "id": 1095, + "name": "Minor issue", + "style": "#955F1D", + "usageNumber": 5 + }, + { + "id": 1096, + "name": "Major Issue", + "style": "#932127", + "usageNumber": 5 + } + ] + }, + { + "group": { "id": 2, "name": "tags" }, + "tags": [ + { "id": 1114, "name": "marco", "style": "white", "usageNumber": 14 }, + { "id": 1097, "name": "tag 1", "style": "white", "usageNumber": 5 }, + { "id": 1103, "name": "Bloccante", "style": "white", "usageNumber": 5 }, + { "id": 1104, "name": "non b", "style": "white", "usageNumber": 3 }, + { "id": 20797, "name": "Confused", "style": "white", "usageNumber": 3 }, + { "id": 20756, "name": "gimmi test", "style": "white", "usageNumber": 2 }, + { "id": 20798, "name": "Layot", "style": "white", "usageNumber": 2 }, + { "id": 20800, "name": "Blocker", "style": "white", "usageNumber": 2 }, + { "id": 24855, "name": "new", "style": "white", "usageNumber": 1 }, + { "id": 1108, "name": "confu", "style": "white", "usageNumber": 0 }, + { "id": 1109, "name": "confusion", "style": "white", "usageNumber": 0 } + ] + }, + { + "group": { "id": 4, "name": "title" }, + "tags": [ + { + "id": 13390, + "name": "Home page navigation", + "style": "", + "usageNumber": 11 + }, + { + "id": 20767, + "name": "grey 500 andrebbe corretto in tutte le pagine per il testo perchè non", + "style": "white", + "usageNumber": 3 + }, + { + "id": 22099, + "name": "Out of scope", + "style": "white", + "usageNumber": 3 + }, + { "id": 22102, "name": "Vpn", "style": "white", "usageNumber": 3 }, + { + "id": 23125, + "name": "prova modifica quote", + "style": "white", + "usageNumber": 3 + }, + { + "id": 20795, + "name": "Login page inputs", + "style": "white", + "usageNumber": 1 + }, + { + "id": 20799, + "name": "Sign up information", + "style": "white", + "usageNumber": 1 + }, + { + "id": 22555, + "name": "Out of scope \\(3\\) test", + "style": "white", + "usageNumber": 0 + } + ] } ] diff --git a/tests/api/campaigns/cid/video-tags/tagId/_patch/request_Example_1.json b/tests/api/campaigns/cid/video-tags/tagId/_patch/request_Example_1.json new file mode 100644 index 000000000..275526337 --- /dev/null +++ b/tests/api/campaigns/cid/video-tags/tagId/_patch/request_Example_1.json @@ -0,0 +1,3 @@ +{ + "newTagName": "Tag New" +} diff --git a/tests/api/plans/pid/watchers/_get/200_Example_1.json b/tests/api/plans/pid/watchers/_get/200_Example_1.json new file mode 100644 index 000000000..a647089c8 --- /dev/null +++ b/tests/api/plans/pid/watchers/_get/200_Example_1.json @@ -0,0 +1,18 @@ +{ + "items": [ + { + "id": 1, + "name": "Antonio", + "surname": "Arezzo", + "email": "antonio.arezzo@unguess.io", + "isInternal": true + }, + { + "id": 2, + "name": "string", + "surname": "string", + "email": "user@example.com", + "isInternal": false + } + ] +} diff --git a/tests/api/plans/pid/watchers/_post/request_Example_1.json b/tests/api/plans/pid/watchers/_post/request_Example_1.json new file mode 100644 index 000000000..feb744c28 --- /dev/null +++ b/tests/api/plans/pid/watchers/_post/request_Example_1.json @@ -0,0 +1,10 @@ +{ + "users": [ + { + "id": 1 + }, + { + "id": 2 + } + ] +} diff --git a/tests/api/plans/pid/watchers/_put/request_Example_1.json b/tests/api/plans/pid/watchers/_put/request_Example_1.json new file mode 100644 index 000000000..feb744c28 --- /dev/null +++ b/tests/api/plans/pid/watchers/_put/request_Example_1.json @@ -0,0 +1,10 @@ +{ + "users": [ + { + "id": 1 + }, + { + "id": 2 + } + ] +} diff --git a/tests/api/users/me/_get/200_Users_Me_example.json b/tests/api/users/me/_get/200_Users_Me_example.json index 852bf2e77..6a716a13a 100644 --- a/tests/api/users/me/_get/200_Users_Me_example.json +++ b/tests/api/users/me/_get/200_Users_Me_example.json @@ -24,4 +24,4 @@ "role": "administrator", "tryber_wp_user_id": 1, "unguess_wp_user_id": 1 -} \ No newline at end of file +} diff --git a/tests/api/videos/vid/observations/_get/200_Example_1.json b/tests/api/videos/vid/observations/_get/200_Example_1.json index 14cfbca6c..25a476bb9 100644 --- a/tests/api/videos/vid/observations/_get/200_Example_1.json +++ b/tests/api/videos/vid/observations/_get/200_Example_1.json @@ -21,5 +21,105 @@ ], "title": "string", "uxNote": "string" + }, + { + "id": 1, + "title": "grey 500 andrebbe corretto in tutte le pagine per il testo perchè non", + "description": "", + "quotes": "che belle quotes modificate a dovere", + "start": 2, + "end": 5.62, + "tags": [ + { + "group": { "id": 2, "name": "tags" }, + "tag": { + "id": 1114, + "name": "marco", + "style": "white", + "usageNumber": 14 + } + }, + { + "group": { "id": 2, "name": "tags" }, + "tag": { + "id": 1098, + "name": "tag 2", + "style": "white", + "usageNumber": 13 + } + }, + { + "group": { "id": 2, "name": "tags" }, + "tag": { + "id": 1097, + "name": "tag 1", + "style": "white", + "usageNumber": 5 + } + }, + { + "group": { "id": 2, "name": "tags" }, + "tag": { + "id": 1104, + "name": "non b", + "style": "white", + "usageNumber": 3 + } + }, + { + "group": { "id": 2, "name": "tags" }, + "tag": { + "id": 20756, + "name": "gimmi test", + "style": "white", + "usageNumber": 2 + } + }, + { + "group": { "id": 2, "name": "tags" }, + "tag": { + "id": 20786, + "name": "prova\"dell'annoincredibile", + "style": "white", + "usageNumber": 2 + } + }, + { + "group": { "id": 2, "name": "tags" }, + "tag": { + "id": 1129, + "name": "GIOMMI - INT", + "style": "white", + "usageNumber": 1 + } + }, + { + "group": { "id": 2, "name": "tags" }, + "tag": { + "id": 24855, + "name": "new", + "style": "white", + "usageNumber": 1 + } + }, + { + "group": { "id": 3, "name": "severity" }, + "tag": { + "id": 1094, + "name": "Positive Finding", + "style": "#007D5A", + "usageNumber": 34 + } + }, + { + "group": { "id": 4, "name": "title" }, + "tag": { + "id": 20767, + "name": "grey 500 andrebbe corretto in tutte le pagine per il testo perchè non", + "style": "white", + "usageNumber": 3 + } + } + ] } ] diff --git a/tests/api/workspaces/wid/users/_get/200_Example_1.json b/tests/api/workspaces/wid/users/_get/200_Example_1.json index 2b9948f8d..1accbd220 100644 --- a/tests/api/workspaces/wid/users/_get/200_Example_1.json +++ b/tests/api/workspaces/wid/users/_get/200_Example_1.json @@ -6,10 +6,17 @@ "invitationPending": true, "name": "string", "profile_id": 999 + }, + { + "email": "invited@example.com", + "id": 2, + "invitationPending": false, + "name": "string string", + "profile_id": 2 } ], "limit": 0, - "size": 1, + "size": 2, "start": 0, - "total": 1 + "total": 2 } diff --git a/tests/e2e/join/index.spec.ts b/tests/e2e/join/index.spec.ts index 830bfd2c4..dc731c460 100644 --- a/tests/e2e/join/index.spec.ts +++ b/tests/e2e/join/index.spec.ts @@ -75,11 +75,16 @@ test.describe('The Join page first step - case new user', () => { await expect(step1.elements().emailError()).toHaveText( i18n.t('SIGNUP_FORM_EMAIL_IS_REQUIRED') ); - await step1.fillEmail('invalid-email'); + await step1.fillEmail('invalid-email@'); await expect( page.getByText(i18n.t('SIGNUP_FORM_EMAIL_MUST_BE_A_VALID_EMAIL')) ).toBeVisible(); + await step1.fillEmail('fake-email@mailinator.com'); + await expect( + page.getByText(i18n.t('SIGNUP_FORM_EMAIL_DISPOSABLE_NOT_ALLOWED')) + ).toBeVisible(); + await step1.fillRegisteredEmail(); await expect( page.getByText(i18n.t('SIGNUP_FORM_EMAIL_ALREADY_TAKEN')) @@ -94,7 +99,6 @@ test.describe('The Join page first step - case new user', () => { await expect(step1.elements().container()).not.toBeVisible(); await expect(step2.elements().container()).toBeVisible(); }); - test('display two links to go to app.unguess and a link to terms and conditions', async () => {}); }); test.describe('The Join page second step', () => { diff --git a/tests/e2e/login/index.spec.ts b/tests/e2e/login/index.spec.ts deleted file mode 100644 index 830bfd2c4..000000000 --- a/tests/e2e/login/index.spec.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { test, expect } from '../../fixtures/app'; -import { Join } from '../../fixtures/pages/Join'; -import { Step1 } from '../../fixtures/pages/Join/Step1'; -import { Step2 } from '../../fixtures/pages/Join/Step2'; -import { Step3 } from '../../fixtures/pages/Join/Step3'; - -test.describe('The Join page first step - case new user', () => { - let join: Join; - let step1: Step1; - let step2: Step2; - - test.beforeEach(async ({ page }) => { - join = new Join(page); - await join.notLoggedIn(); - step1 = new Step1(page); - step2 = new Step2(page); - await join.open(); - }); - - test('display a form with user email and password input, a CTA to go to the next step', async () => { - await expect(step1.elements().container()).toBeVisible(); - await expect(step1.elements().emailInput()).toBeVisible(); - await expect(step1.elements().passwordInput()).toBeVisible(); - await expect(step1.elements().buttonGoToStep2()).toBeVisible(); - }); - - test('the password input check if the password is strong enough', async ({ - page, - i18n, - }) => { - await step1.elements().buttonGoToStep2().click(); - await expect(step1.elements().passwordError()).toHaveText( - i18n.t('SIGNUP_FORM_PASSWORD_IS_A_REQUIRED_FIELD') - ); - - await expect(step1.elements().passwordRequirements()).toBeVisible(); - - await step1.fillPassword('weak'); - await expect( - page.getByText( - i18n.t('SIGNUP_FORM_PASSWORD_MUST_BE_AT_LEAST_6_CHARACTER_LONG') - ) - ).toBeVisible(); - - await step1.fillPassword('weakpassword'); - await expect( - page.getByText( - i18n.t('SIGNUP_FORM_PASSWORD_MUST_CONTAIN_AT_LEAST_A_NUMBER') - ) - ).toBeVisible(); - - await step1.fillPassword('weakpassword123'); - await expect( - page.getByText( - i18n.t('SIGNUP_FORM_PASSWORD_MUST_CONTAIN_AT_LEAST_AN_UPPERCASE_LETTER') - ) - ).toBeVisible(); - - await step1.fillPassword('WEAKPASSWORD123'); - await expect( - page.getByText( - i18n.t('SIGNUP_FORM_PASSWORD_MUST_CONTAIN_AT_LEAST_A_LOWERCASE_LETTER') - ) - ).toBeVisible(); - - await step1.fillValidPassword(); - await expect(page.getByTestId('message-error-password')).not.toBeVisible(); - }); - - test('the email input check if the email is valid', async ({ - page, - i18n, - }) => { - await step1.elements().buttonGoToStep2().click(); - await expect(step1.elements().emailError()).toHaveText( - i18n.t('SIGNUP_FORM_EMAIL_IS_REQUIRED') - ); - await step1.fillEmail('invalid-email'); - await expect( - page.getByText(i18n.t('SIGNUP_FORM_EMAIL_MUST_BE_A_VALID_EMAIL')) - ).toBeVisible(); - - await step1.fillRegisteredEmail(); - await expect( - page.getByText(i18n.t('SIGNUP_FORM_EMAIL_ALREADY_TAKEN')) - ).toBeVisible(); - - await step1.fillValidEmail(); - await expect(page.getByTestId('message-error-email')).not.toBeVisible(); - }); - - test('when the user click the next step cta we validate current inputs and if ok goes to the next step', async () => { - await step1.goToNextStep(); - await expect(step1.elements().container()).not.toBeVisible(); - await expect(step2.elements().container()).toBeVisible(); - }); - test('display two links to go to app.unguess and a link to terms and conditions', async () => {}); -}); - -test.describe('The Join page second step', () => { - let join: Join; - let step1: Step1; - let step2: Step2; - let step3: Step3; - - test.beforeEach(async ({ page }) => { - join = new Join(page); - await join.notLoggedIn(); - step1 = new Step1(page); - step2 = new Step2(page); - step3 = new Step3(page); - - await step2.mockGetRoles(); - await step2.mockGetCompanySizes(); - await join.open(); - await step1.goToNextStep(); - }); - test('display required inputs for name, surname, job role and company size dropdowns populated from api userRole and companySize', async ({ - i18n, - }) => { - await expect(step2.elements().nameInput()).toBeVisible(); - await expect(step2.elements().surnameInput()).toBeVisible(); - await expect(step2.elements().roleSelect()).toBeVisible(); - await step2.elements().roleSelect().click(); - await expect(step2.elements().roleSelectOptions()).toHaveCount(3); - - await expect(step2.elements().companySizeSelect()).toBeVisible(); - await step2.elements().companySizeSelect().click(); - await expect(step2.elements().companySizeSelectOptions()).toHaveCount(3); - - await step2.elements().buttonGoToStep3().click(); - - await expect(step2.elements().nameError()).toHaveText( - i18n.t('SIGNUP_FORM_NAME_IS_REQUIRED') - ); - await expect(step2.elements().surnameError()).toHaveText( - i18n.t('SIGNUP_FORM_SURNAME_IS_REQUIRED') - ); - await expect(step2.elements().roleSelectError()).toHaveText( - i18n.t('SIGNUP_FORM_ROLE_IS_REQUIRED') - ); - await expect(step2.elements().companySizeSelectError()).toHaveText( - i18n.t('SIGNUP_FORM_COMPANY_SIZE_IS_REQUIRED') - ); - - await step2.fillValidFields(); - await expect(step2.elements().nameError()).not.toBeVisible(); - await expect(step2.elements().surnameError()).not.toBeVisible(); - await expect(step2.elements().roleSelectError()).not.toBeVisible(); - }); - test('display back and next navigation, clicking on next validate this step and goes to step 3', async () => { - await expect(step2.elements().buttonBackToStep1()).toBeVisible(); - await expect(step2.elements().buttonGoToStep3()).toBeVisible(); - await step2.goToNextStep(); - await expect(step2.elements().container()).not.toBeVisible(); - await expect(step3.elements().container()).toBeVisible(); - }); -}); - -test.describe('The Join page third step', () => { - let join: Join; - let step1: Step1; - let step2: Step2; - let step3: Step3; - - test.beforeEach(async ({ page }) => { - join = new Join(page); - await join.notLoggedIn(); - step1 = new Step1(page); - step2 = new Step2(page); - step3 = new Step3(page); - - await join.open(); - await join.mockPostNewUser(); - await step1.goToNextStep(); - await step2.goToNextStep(); - }); - test('display a required text input for the workspace name and a back button to return to step 2', async () => { - await expect(step3.elements().workspaceInput()).toBeVisible(); - await expect(step3.elements().buttonBackToStep2()).toBeVisible(); - await step3.elements().buttonBackToStep2().click(); - await expect(step3.elements().container()).not.toBeVisible(); - await expect(step2.elements().container()).toBeVisible(); - }); - test('display a submit-button, clicking on submit-button validate the whole form and calls the api post', async ({ - page, - i18n, - }) => { - const postPromise = page.waitForResponse( - (response) => - /\/api\/users/.test(response.url()) && - response.status() === 200 && - response.request().method() === 'POST' - ); - await step3.elements().buttonSubmit().click(); - await expect(step3.elements().workspaceError()).toHaveText( - i18n.t('SIGNUP_FORM_WORKSPACE_IS_REQUIRED') - ); - await step3.fillValidWorkspace(); - await expect(step3.elements().workspaceError()).not.toBeVisible(); - await step3.elements().buttonSubmit().click(); - const response = await postPromise; - const data = response.request().postDataJSON(); - expect(data).toEqual( - expect.objectContaining({ - type: 'new', - email: 'new.user@example.com', - password: 'ValidPassword123', - name: step2.name, - surname: step2.surname, - roleId: step2.roleId, - companySizeId: step2.companySizeId, - workspace: step3.workspace, - }) - ); - }); -}); diff --git a/tests/e2e/plan/draft.spec.ts b/tests/e2e/plan/draft.spec.ts index c01982b2e..4c1512f20 100644 --- a/tests/e2e/plan/draft.spec.ts +++ b/tests/e2e/plan/draft.spec.ts @@ -17,6 +17,8 @@ test.describe('The module builder', () => { await planPage.loggedIn(); await planPage.mockPreferences(); await planPage.mockWorkspace(); + await planPage.mockWatchers(); + await planPage.mockWorkspaceUsers(); await planPage.mockWorkspacesList(); await planPage.mockGetDraftWithOnlyMandatoryModulesPlan(); await requestQuotationModal.mockPatchStatus(); diff --git a/tests/e2e/plan/draft_plan_info_card.spec.ts b/tests/e2e/plan/draft_plan_info_card.spec.ts index 0c140f415..216b3f4f6 100644 --- a/tests/e2e/plan/draft_plan_info_card.spec.ts +++ b/tests/e2e/plan/draft_plan_info_card.spec.ts @@ -43,6 +43,7 @@ test.describe('A plan without a template and price', () => { await planPage.loggedIn(); await planPage.mockPreferences(); await planPage.mockWorkspace(); + await planPage.mockWorkspaceUsers(); await planPage.mockWorkspacesList(); await planPage.mockGetDraftPlan(); await planPage.mockPatchPlan(); @@ -76,7 +77,9 @@ test.describe('A plan with template and price', () => { touchpointsModule = new TouchpointsModule(page); await planPage.loggedIn(); await planPage.mockPreferences(); + await planPage.mockWatchers(); await planPage.mockWorkspace(); + await planPage.mockWorkspaceUsers(); await planPage.mockWorkspacesList(); await planPage.mockGetDraftPlanWithTemplateAndPrice(); await requestQuotationModal.mockPatchStatus(); diff --git a/tests/e2e/plan/modules/tasks.spec.ts b/tests/e2e/plan/modules/tasks.spec.ts index fd5f3face..d23f4371f 100644 --- a/tests/e2e/plan/modules/tasks.spec.ts +++ b/tests/e2e/plan/modules/tasks.spec.ts @@ -1,7 +1,7 @@ -import { test, expect } from '../../../fixtures/app'; +import apiGetDraftMandatoryPlan from '../../../api/plans/pid/_get/200_draft_mandatory_only.json'; +import { expect, test } from '../../../fixtures/app'; import { PlanPage } from '../../../fixtures/pages/Plan'; import { TasksModule } from '../../../fixtures/pages/Plan/Module_tasks'; -import apiGetDraftMandatoryPlan from '../../../api/plans/pid/_get/200_draft_mandatory_only.json'; import { RequestQuotationModal } from '../../../fixtures/pages/Plan/RequestQuotationModal'; test.describe('The tasks module defines a list of activities.', () => { @@ -17,6 +17,8 @@ test.describe('The tasks module defines a list of activities.', () => { await moduleBuilderPage.mockPreferences(); await moduleBuilderPage.mockWorkspace(); await moduleBuilderPage.mockPatchPlan(); + await moduleBuilderPage.mockWorkspaceUsers(); + await moduleBuilderPage.mockWatchers(); await moduleBuilderPage.mockWorkspacesList(); await moduleBuilderPage.mockGetDraftWithOnlyMandatoryModulesPlan(); await moduleBuilderPage.open(); diff --git a/tests/e2e/video/observation.spec.ts b/tests/e2e/video/observation.spec.ts new file mode 100644 index 000000000..b5c11ccd5 --- /dev/null +++ b/tests/e2e/video/observation.spec.ts @@ -0,0 +1,269 @@ +import { expect, test } from '../../fixtures/app'; +import { VideoPage } from '../../fixtures/pages/Video'; + +test.describe('Video page', () => { + let videopage: VideoPage; + + test.beforeEach(async ({ page }) => { + videopage = new VideoPage(page); + await videopage.loggedIn(); + await videopage.mockPreferences(); + await videopage.mockWorkspace(); + await videopage.mockWorkspacesList(); + await videopage.mockGetCampaign(); + await videopage.mockGetVideo(); + await videopage.mockGetVideoTags(); + await videopage.mockGetVideoObservations(); + await videopage.open(); + await videopage.elements().observationAccordion(1).scrollIntoViewIfNeeded(); + }); + + // THEMES EDITING TESTS BELOW + + test('should open the edit dialog in the themes combobox and display an input and a summary text for the current item and a save button', async ({ + i18n, + }) => { + await videopage.openObservationAccordion(1); + await videopage.openComboboxVideoTitle(1); + await videopage.clickOptionItemActions('20767'); + await expect( + videopage + .elements() + .tooltipModalOptions() + .getByText(i18n.t('__VIDEO_PAGE_THEMES_DROPDOWN_EDIT_MODAL_TITLE'), { + exact: true, + }) + ).toBeVisible(); + await expect( + videopage.elements().tooltipModalOptionsThemeInput() + ).toHaveValue( + 'grey 500 andrebbe corretto in tutte le pagine per il testo perchè non' + ); + await expect( + videopage + .elements() + .tooltipModalOptions() + .getByText( + i18n.t('__VIDEO_PAGE_THEMES_DROPDOWN_EDIT_MODAL_DESCRIPTION_other', { + count: 3, + usageNumber: '3', + }) + ) + ).toBeVisible(); + await expect( + videopage.elements().tooltipModalOptionsSaveButton() + ).toBeVisible(); + }); + + test('should allow changing the name of a theme', async ({ page }) => { + await videopage.mockPatchVideoTag('20767'); + const patchTitleTag = page.waitForResponse( + (response) => + /\/api\/campaigns\/1\/video-tags\/20767/.test(response.url()) && + response.status() === 200 && + response.request().method() === 'PATCH' + ); + const getVideoTags = page.waitForResponse( + (response) => + /\/api\/campaigns\/1\/video-tags/.test(response.url()) && + response.status() === 200 && + response.request().method() === 'GET' + ); + await videopage.openObservationAccordion(1); + await videopage.openComboboxVideoTitle(1); + await videopage.clickOptionItemActions('20767'); + await videopage + .elements() + .tooltipModalOptionsThemeInput() + .fill('New Theme Name'); + await videopage.elements().tooltipModalOptionsSaveButton().click(); + const patchRequest = await patchTitleTag; + const requestBody = await patchRequest.request().postDataJSON(); + expect(requestBody).toEqual({ + newTagName: 'New Theme Name', + }); + // show user a success toast + await expect(videopage.elements().toastEditSuccessMessage()).toBeVisible(); + // invalidate and refetch video tags + await getVideoTags; + }); + + test('in the theme edit modal should show an error if trying to save with empty name or an existing theme name', async ({ + i18n, + }) => { + await videopage.mockPatchVideoTag('20767', 409); + await videopage.openObservationAccordion(1); + await videopage.openComboboxVideoTitle(1); + await videopage.clickOptionItemActions('20767'); + await videopage.elements().tooltipModalOptionsThemeInput().clear(); + await expect( + videopage + .elements() + .tooltipModalOptions() + .getByText( + i18n.t( + '__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_TITLE_REQUIRED_ERROR' + ) + ) + ).toBeVisible(); + await expect( + videopage.elements().tooltipModalOptionsSaveButton() + ).toBeDisabled(); + await videopage + .elements() + .tooltipModalOptionsThemeInput() + .fill('Existing Theme Name'); + await expect( + videopage.elements().tooltipModalOptionsSaveButton() + ).not.toBeDisabled(); + await videopage.elements().tooltipModalOptionsSaveButton().click(); + await expect( + videopage + .elements() + .tooltipModalOptions() + .getByText( + i18n.t( + '__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_TITLE_DUPLICATE_ERROR' + ) + ) + ).toBeVisible(); + await expect( + videopage.elements().tooltipModalOptionsSaveButton() + ).toBeDisabled(); + await videopage + .elements() + .tooltipModalOptionsThemeInput() + .fill('Now Unique Name'); + await expect( + videopage.elements().tooltipModalOptionsSaveButton() + ).not.toBeDisabled(); + }); + + // TAGS EDITING TESTS BELOW + + test('should open the edit dialog in the tags combobox', async () => { + await videopage.openObservationAccordion(1); + await videopage.openComboboxVideoTags(1); + await videopage.clickOptionItemActions('1103'); + await expect( + videopage + .elements() + .tooltipModalOptions() + .getByText( + videopage.i18n.t('__VIDEO_PAGE_TAGS_DROPDOWN_EDIT_MODAL_TITLE'), + { + exact: true, + } + ) + ).toBeVisible(); + await expect( + videopage + .elements() + .tooltipModalOptions() + .getByLabel( + videopage.i18n.t('__VIDEO_PAGE_TAGS_DROPDOWN_EDIT_MODAL_LABEL') + ) + ).toHaveValue('Bloccante'); + await expect( + videopage + .elements() + .tooltipModalOptions() + .getByText( + videopage.i18n.t( + '__VIDEO_PAGE_TAGS_DROPDOWN_EDIT_MODAL_DESCRIPTION_other', + { + count: 5, + usageNumber: '5', + } + ) + ) + ).toBeVisible(); + await expect( + videopage.elements().tooltipModalOptionsSaveButton() + ).toBeVisible(); + }); + + test('should allow changing the name of a tag', async ({ page }) => { + await videopage.mockPatchVideoTag('1103'); + const patchTitleTag = page.waitForResponse( + (response) => + /\/api\/campaigns\/1\/video-tags\/1103/.test(response.url()) && + response.status() === 200 && + response.request().method() === 'PATCH' + ); + const getVideoTags = page.waitForResponse( + (response) => + /\/api\/campaigns\/1\/video-tags/.test(response.url()) && + response.status() === 200 && + response.request().method() === 'GET' + ); + await videopage.openObservationAccordion(1); + await videopage.openComboboxVideoTags(1); + await videopage.clickOptionItemActions('1103'); + await videopage + .elements() + .tooltipModalOptionsTagInput() + .fill('New Tag Name'); + await videopage.elements().tooltipModalOptionsSaveButton().click(); + const patchRequest = await patchTitleTag; + const requestBody = await patchRequest.request().postDataJSON(); + expect(requestBody).toEqual({ + newTagName: 'New Tag Name', + }); + // show user a success toast + await expect(videopage.elements().toastEditSuccessMessage()).toBeVisible(); + // invalidate and refetch video tags + await getVideoTags; + }); + + test('in the tag edit modal should show an error if trying to save with empty name or an existing tag name', async ({ + i18n, + }) => { + await videopage.mockPatchVideoTag('1103', 409); + await videopage.openObservationAccordion(1); + await videopage.openComboboxVideoTags(1); + await videopage.clickOptionItemActions('1103'); + await videopage.elements().tooltipModalOptionsTagInput().clear(); + await expect( + videopage + .elements() + .tooltipModalOptions() + .getByText( + i18n.t( + '__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_TITLE_REQUIRED_ERROR' + ) + ) + ).toBeVisible(); + await expect( + videopage.elements().tooltipModalOptionsSaveButton() + ).toBeDisabled(); + await videopage + .elements() + .tooltipModalOptionsTagInput() + .fill('Existing Tag Name'); + await expect( + videopage.elements().tooltipModalOptionsSaveButton() + ).not.toBeDisabled(); + await videopage.elements().tooltipModalOptionsSaveButton().click(); + await expect( + videopage + .elements() + .tooltipModalOptions() + .getByText( + i18n.t( + '__VIDEO_PAGE_ACTIONS_OBSERVATION_FORM_FIELD_TITLE_DUPLICATE_ERROR' + ) + ) + ).toBeVisible(); + await expect( + videopage.elements().tooltipModalOptionsSaveButton() + ).toBeDisabled(); + await videopage + .elements() + .tooltipModalOptionsTagInput() + .fill('Now Unique Name'); + await expect( + videopage.elements().tooltipModalOptionsSaveButton() + ).not.toBeDisabled(); + }); +}); diff --git a/tests/fixtures/UnguessPage.ts b/tests/fixtures/UnguessPage.ts index 30e307cb4..956585564 100644 --- a/tests/fixtures/UnguessPage.ts +++ b/tests/fixtures/UnguessPage.ts @@ -76,6 +76,14 @@ export class UnguessPage { }); } + async mockWorkspaceUsers() { + await this.page.route('*/**/api/workspaces/1/users*', async (route) => { + await route.fulfill({ + path: 'tests/api/workspaces/wid/users/_get/200_Example_1.json', + }); + }); + } + async mockWorkspacesList() { await this.page.route( '*/**/api/workspaces?orderBy=company', diff --git a/tests/fixtures/pages/Plan/index.ts b/tests/fixtures/pages/Plan/index.ts index 9632cba24..8c647159a 100644 --- a/tests/fixtures/pages/Plan/index.ts +++ b/tests/fixtures/pages/Plan/index.ts @@ -2,6 +2,7 @@ import { type Page } from '@playwright/test'; import { UnguessPage } from '../../UnguessPage'; import { AgeModule } from './Module_age'; import { BankModule } from './Module_bank'; +import { BrowserModule } from './Module_browser'; import { DigitalLiteracyModule } from './Module_digital_literacy'; import { ElectricityModule } from './Module_electricity'; import { GasModule } from './Module_gas'; @@ -14,7 +15,6 @@ import { OutOfScopeModule } from './Module_out_of_scope'; import { TargetModule } from './Module_target'; import { TasksModule } from './Module_tasks'; import { TouchpointsModule } from './Module_touchpoints'; -import { BrowserModule } from './Module_browser'; interface TabModule { expectToBeReadonly(): Promise<void>; @@ -291,6 +291,26 @@ export class PlanPage extends UnguessPage { }); } + async mockWatchers() { + await this.page.route('*/**/api/plans/1/watchers*', async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ + path: 'tests/api/plans/pid/watchers/_get/200_Example_1.json', + }); + } else if (route.request().method() === 'PUT') { + await route.fulfill({ + body: JSON.stringify({}), + }); + } else if (route.request().method() === 'POST') { + await route.fulfill({ + body: JSON.stringify({}), + }); + } else { + await route.fallback(); + } + }); + } + async mockGetDraftPlanWithDateError() { await this.page.route('*/**/api/plans/1', async (route) => { if (route.request().method() === 'GET') { diff --git a/tests/fixtures/pages/Video.ts b/tests/fixtures/pages/Video.ts index 04ed1f23a..2aa2ce43b 100644 --- a/tests/fixtures/pages/Video.ts +++ b/tests/fixtures/pages/Video.ts @@ -20,9 +20,70 @@ export class VideoPage extends UnguessPage { this.page .getByTestId('transcript-sentiment') .locator('[data-garden-id="tags.tag_view"]'), + observationAccordion: (id: number) => + this.page.getByTestId(`observation-accordion-${id}`), + comboboxVideoTitle: (id: number) => + this.elements() + .observationAccordion(id) + .getByTestId(`video-title-dropdown`), + comboboxVideoTags: (id: number) => + this.elements() + .observationAccordion(id) + .getByTestId(`video-tags-dropdown`), + optionsItem: (itemID: string) => + this.page.locator(`[itemid="${itemID}"]`), + tooltipModalOptions: () => this.page.getByTestId('tooltip-modal-option'), + tooltipModalOptionsSaveButton: () => + this.elements() + .tooltipModalOptions() + .getByRole('button', { + name: this.i18n.t('__VIDEO_PAGE_DROPDOWN_EDIT_MODAL_SAVE_BUTTON'), + }), + toastEditSuccessMessage: () => + this.page.getByText( + this.i18n.t('__VIDEO_PAGE_DROPDOWN_EDIT_MODAL_SUCCESS_TOAST_MESSAGE') + ), + tooltipModalOptionsThemeInput: () => + this.elements() + .tooltipModalOptions() + .getByLabel( + this.i18n.t('__VIDEO_PAGE_THEMES_DROPDOWN_EDIT_MODAL_LABEL') + ), + tooltipModalOptionsTagInput: () => + this.elements() + .tooltipModalOptions() + .getByLabel( + this.i18n.t('__VIDEO_PAGE_TAGS_DROPDOWN_EDIT_MODAL_LABEL') + ), }; } + async openObservationAccordion(id: number) { + await this.elements().observationAccordion(id).click(); + } + + async openComboboxVideoTitle(id: number) { + await this.elements().comboboxVideoTitle(id).click(); + } + + async openComboboxVideoTags(id: number) { + await this.elements() + .comboboxVideoTags(id) + .locator('[data-garden-id="dropdowns.combobox.input_icon"]') + .click(); + } + + async clickOptionItem(itemID: string) { + await this.elements().optionsItem(itemID).click(); + } + + async clickOptionItemActions(itemID: string) { + await this.elements() + .optionsItem(itemID) + .getByTestId('select-option-actions') + .click(); + } + async mockGetCampaign() { await this.page.route('*/**/api/campaigns/1', async (route) => { await route.fulfill({ @@ -31,6 +92,43 @@ export class VideoPage extends UnguessPage { }); } + async mockPatchVideoTag(id: string, error?: number | boolean) { + await this.page.route( + `*/**/api/campaigns/1/video-tags/${id}`, + async (route) => { + if (route.request().method() === 'PATCH') { + if (error === 409) { + await route.fulfill({ + status: 409, + }); + } else if (error === true) { + await route.fulfill({ + status: 500, + }); + } else { + await route.fulfill({ + status: 200, + }); + } + } else { + await route.fallback(); + } + } + ); + } + + async mockGetVideoTags() { + await this.page.route('*/**/api/campaigns/1/video-tags', async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ + path: 'tests/api/campaigns/cid/video-tags/_get/200_Example_1.json', + }); + } else { + await route.fallback(); + } + }); + } + async mockGetVideo() { await this.page.route('*/**/api/videos/1', async (route) => { await route.fulfill({ diff --git a/yarn.lock b/yarn.lock index ecde5e97b..1deb9c090 100644 --- a/yarn.lock +++ b/yarn.lock @@ -122,10 +122,10 @@ dependencies: hls.js "^1.4.8" -"@appquality/unguess-design-system@4.0.50": - version "4.0.50" - resolved "https://registry.yarnpkg.com/@appquality/unguess-design-system/-/unguess-design-system-4.0.50.tgz#1080f1963fc7430848d50f964a76c8fa903d0105" - integrity sha512-bfQnSWAHQyuDroaCRcpvmXn0V2Jtp2N15ukTuYiVAa7Z0g9tOsFskWsiR8IzLoinsEQ0qam0ySqIN6TCcnUbdQ== +"@appquality/unguess-design-system@4.0.53": + version "4.0.53" + resolved "https://registry.yarnpkg.com/@appquality/unguess-design-system/-/unguess-design-system-4.0.53.tgz#d0b78c82ad3e3f9f0b7e0aee64aad6abc22bec4f" + integrity sha512-ZbsSBZR9AUY7DRZS/kApozaXVbOEArE6Io99/mpCStLiUKJcSdQoRBR2wQ36txZKXrVQ2CdmoyuJNTwT+Kbfkw== dependencies: "@appquality/stream-player" "1.0.6" "@nivo/bar" "^0.87.0" @@ -6266,6 +6266,11 @@ lru-cache@^5.1.1: dependencies: yallist "^3.0.2" +mailchecker@^6.0.19: + version "6.0.19" + resolved "https://registry.yarnpkg.com/mailchecker/-/mailchecker-6.0.19.tgz#7d0bc89e6f846a2dc1adb44e065e4d146e8f7d8d" + integrity sha512-a7yghUa0IC1n6OkSJIqCSw2HS8ujKJGBJcRkjhHiKvHqPU3JB28Yze9o90L52r6lA/sp/EBveDXOWbozcdmxYg== + make-error@^1.1.1: version "1.3.6" resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2"