From 78e5a8fac8764eed491c7f744d961f31b4af330e Mon Sep 17 00:00:00 2001 From: Billie He Date: Wed, 4 Sep 2024 17:34:06 -0700 Subject: [PATCH 01/26] fix: JS console warning about kebab case CSS keys --- src/shared/ui/Items/SelectBase/SelectBaseText.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/shared/ui/Items/SelectBase/SelectBaseText.tsx b/src/shared/ui/Items/SelectBase/SelectBaseText.tsx index 1a119ee58..00c983805 100644 --- a/src/shared/ui/Items/SelectBase/SelectBaseText.tsx +++ b/src/shared/ui/Items/SelectBase/SelectBaseText.tsx @@ -18,9 +18,13 @@ export const SelectBaseText = (props: Props) => { cursor: 'pointer', lineBreak: 'anywhere', display: '-webkit-box', - '-webkit-line-clamp': '3', - '-webkit-box-orient': 'vertical', overflow: 'hidden', + + // Using kebab-case (i.e. `-webkit-some-things`) would cause warnings + // in the JS console about kebab-case being not supported for CSS + // properties. + webkitLineClamp: '3', + webkitBoxOrient: 'vertical', }} > {props.text} From fecbb5dd04e8b177db28e60ff523d808b9effdf2 Mon Sep 17 00:00:00 2001 From: Billie He Date: Thu, 29 Aug 2024 14:13:28 -0700 Subject: [PATCH 02/26] fix: HTML dom structure warning --- src/widgets/Survey/ui/WelcomeScreen.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/widgets/Survey/ui/WelcomeScreen.tsx b/src/widgets/Survey/ui/WelcomeScreen.tsx index b3f36cf50..bfef16d4d 100644 --- a/src/widgets/Survey/ui/WelcomeScreen.tsx +++ b/src/widgets/Survey/ui/WelcomeScreen.tsx @@ -130,6 +130,10 @@ const WelcomeScreen = () => { /> ` here because `` renders + // bunch of `div`s, but ``'s default component is a `

`, + // which can not contain `

`. + component="div" variant="body1" fontSize="18px" fontWeight="400" From eac93365dd15b1a866f67e18f18270c70495e994 Mon Sep 17 00:00:00 2001 From: Billie He Date: Thu, 29 Aug 2024 14:13:51 -0700 Subject: [PATCH 03/26] chore: add phrasal-template as supported type --- src/abstract/lib/constants.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/abstract/lib/constants.ts b/src/abstract/lib/constants.ts index ce94da669..f9467b7d9 100644 --- a/src/abstract/lib/constants.ts +++ b/src/abstract/lib/constants.ts @@ -17,4 +17,5 @@ export const supportableResponseTypes = [ 'multiSelectRows', 'singleSelectRows', 'sliderRows', + 'phrasalTemplate', ]; From 202722165d972802f0601c853eba09ef8af314a1 Mon Sep 17 00:00:00 2001 From: Billie He Date: Thu, 29 Aug 2024 14:13:52 -0700 Subject: [PATCH 04/26] chore: add editorconfig --- .editorconfig | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..9a777b58b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +root = true + +[*] +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +indent_style = space +indent_size = 2 +max_line_length = 100 + +[*.md] +trim_trailing_whitespace = false From 9315dc272e03d4b9baaaccd18797bf6ad1d78f6a Mon Sep 17 00:00:00 2001 From: Billie He Date: Wed, 4 Sep 2024 17:35:34 -0700 Subject: [PATCH 05/26] chore: install html2canvas --- package.json | 1 + yarn.lock | 31 ++++++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 21e576655..0ca5ad817 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "buffer": "^6.0.3", "date-fns": "^2.29.3", "dayspan": "^1.1.0", + "html2canvas": "^1.4.1", "i18next": "^22.0.6", "i18next-browser-languagedetector": "^7.0.1", "launchdarkly-react-client-sdk": "^3.1.0", diff --git a/yarn.lock b/yarn.lock index 85b18799e..3e5cb1718 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2349,7 +2349,7 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== -base64-arraybuffer@^1.0.1: +base64-arraybuffer@^1.0.1, base64-arraybuffer@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz#1c37589a7c4b0746e34bd1feb951da2df01c1bdc" integrity sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ== @@ -2852,6 +2852,13 @@ crypto-js@^4.1.1: resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631" integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q== +css-line-break@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/css-line-break/-/css-line-break-2.1.0.tgz#bfef660dfa6f5397ea54116bb3cb4873edbc4fa0" + integrity sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w== + dependencies: + utrie "^1.0.2" + css-selector-parser@^1.0.0: version "1.4.1" resolved "https://registry.yarnpkg.com/css-selector-parser/-/css-selector-parser-1.4.1.tgz#03f9cb8a81c3e5ab2c51684557d5aaf6d2569759" @@ -4137,6 +4144,14 @@ html-void-elements@^2.0.0: resolved "https://registry.yarnpkg.com/html-void-elements/-/html-void-elements-2.0.1.tgz#29459b8b05c200b6c5ee98743c41b979d577549f" integrity sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A== +html2canvas@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/html2canvas/-/html2canvas-1.4.1.tgz#7cef1888311b5011d507794a066041b14669a543" + integrity sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA== + dependencies: + css-line-break "^2.1.0" + text-segmentation "^1.0.3" + http-proxy-agent@^7.0.0: version "7.0.2" resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz#9a8b1f246866c028509486585f62b8f2c18c270e" @@ -7313,6 +7328,13 @@ test-exclude@^6.0.0: glob "^7.1.4" minimatch "^3.0.4" +text-segmentation@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/text-segmentation/-/text-segmentation-1.0.3.tgz#52a388159efffe746b24a63ba311b6ac9f2d7943" + integrity sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw== + dependencies: + utrie "^1.0.2" + text-table@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" @@ -7632,6 +7654,13 @@ util@^0.12.4, util@^0.12.5: is-typed-array "^1.1.3" which-typed-array "^1.1.2" +utrie@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/utrie/-/utrie-1.0.2.tgz#d42fe44de9bc0119c25de7f564a6ed1b2c87a645" + integrity sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw== + dependencies: + base64-arraybuffer "^1.0.2" + uuid@^8.0.0: version "8.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" From 103a48ae438cc79a6186af900a1deb68928b05dc Mon Sep 17 00:00:00 2001 From: Billie He Date: Thu, 29 Aug 2024 14:13:53 -0700 Subject: [PATCH 06/26] feat: implement phrasal template data extractor --- src/abstract/lib/constants.ts | 14 ++ .../items/ActionPlan/activitiesPhrasalData.ts | 194 ++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 src/entities/activity/ui/items/ActionPlan/activitiesPhrasalData.ts diff --git a/src/abstract/lib/constants.ts b/src/abstract/lib/constants.ts index f9467b7d9..ae04607f1 100644 --- a/src/abstract/lib/constants.ts +++ b/src/abstract/lib/constants.ts @@ -19,3 +19,17 @@ export const supportableResponseTypes = [ 'sliderRows', 'phrasalTemplate', ]; + +export const phrasalTemplateCompatibleResponseTypes = [ + 'date', + 'multiSelect', + 'numberSelect', + 'singleSelect', + 'slider', + 'text', + 'time', + 'timeRange', + 'multiSelectRows', + 'singleSelectRows', + 'sliderRows', +]; diff --git a/src/entities/activity/ui/items/ActionPlan/activitiesPhrasalData.ts b/src/entities/activity/ui/items/ActionPlan/activitiesPhrasalData.ts new file mode 100644 index 000000000..2cce76abe --- /dev/null +++ b/src/entities/activity/ui/items/ActionPlan/activitiesPhrasalData.ts @@ -0,0 +1,194 @@ +import { phrasalTemplateCompatibleResponseTypes } from '~/abstract/lib/constants'; +import { ActivityItemType } from '~/entities/activity/lib'; +import { ActivityProgress } from '~/entities/applet/model'; + +export type ActivityPhrasalDataGenericContext = { + itemResponseType: ActivityItemType; +}; + +export type ActivityPhrasalDataSliderRowContext = ActivityPhrasalDataGenericContext & { + itemResponseType: 'sliderRows'; + maxValues: number[]; +}; + +export type ActivityPhrasalBaseData< + TType extends string, + TValue, + TContext extends ActivityPhrasalDataGenericContext = ActivityPhrasalDataGenericContext, +> = { + type: TType; + values: TValue; + context: TContext; +}; + +export type ActivityPhrasalArrayFieldData = ActivityPhrasalBaseData<'array', string[]>; + +export type ActivityPhrasalItemizedArrayValue = Record; + +export type ActivityPhrasalIndexedArrayFieldData = ActivityPhrasalBaseData< + 'indexed-array', + ActivityPhrasalItemizedArrayValue +>; + +export type ActivityPhrasalIndexedMatrixValue = { + label: string; + values: string[]; +}; + +export type ActivityPhrasalMatrixValue = { + byRow: ActivityPhrasalIndexedMatrixValue[]; + byColumn: ActivityPhrasalIndexedMatrixValue[]; +}; + +export type ActivityPhrasalMatrixFieldData = ActivityPhrasalBaseData< + 'matrix', + ActivityPhrasalMatrixValue +>; + +export type ActivityPhrasalData = + | ActivityPhrasalArrayFieldData + | ActivityPhrasalIndexedArrayFieldData + | ActivityPhrasalMatrixFieldData; + +export type ActivitiesPhrasalData = Record; + +export const extractActivitiesPhrasalData = ( + activityProgress: ActivityProgress, +): ActivitiesPhrasalData => { + const data: Record = {}; + for (const item of activityProgress.items) { + if (!phrasalTemplateCompatibleResponseTypes.includes(item.responseType)) { + continue; + } + + const fieldDataContext: ActivityPhrasalDataGenericContext = { + itemResponseType: item.responseType, + }; + let fieldData: ActivityPhrasalData | null = null; + + if (item.responseType === 'date') { + const dateFieldData: ActivityPhrasalArrayFieldData = { + type: 'array', + values: item.answer + .map((value) => new Date(value)) + .filter((value) => !!value) + .map((value) => value.toLocaleDateString()), + context: fieldDataContext, + }; + fieldData = dateFieldData; + } else if (item.responseType === 'time' || item.responseType === 'timeRange') { + const dateFieldData: ActivityPhrasalArrayFieldData = { + type: 'array', + values: item.answer + .map((value) => new Date(value)) + .filter((value) => !!value) + .map((value) => value.toLocaleTimeString()), + context: fieldDataContext, + }; + fieldData = dateFieldData; + } else if ( + item.responseType === 'numberSelect' || + item.responseType === 'slider' || + item.responseType === 'text' + ) { + const dateFieldData: ActivityPhrasalArrayFieldData = { + type: 'array', + values: item.answer.map((value) => `${value || ''}`), + context: fieldDataContext, + }; + fieldData = dateFieldData; + } else if (item.responseType === 'singleSelect' || item.responseType === 'multiSelect') { + const dateFieldData: ActivityPhrasalArrayFieldData = { + type: 'array', + values: item.answer + .map((value) => item.responseValues.options[parseInt(value, 10)]?.text) + .filter((value) => !!value), + context: fieldDataContext, + }; + fieldData = dateFieldData; + } else if (item.responseType === 'multiSelectRows') { + const byRow = item.responseValues.rows.map( + (row, rowIndex) => { + return { + label: row.rowName, + values: + item.answer[rowIndex]?.filter( + (value) => value !== null && value !== undefined, + ) || [], + }; + }, + ); + + const byColumn = item.responseValues.options.map( + (option) => { + const answerIndices: number[] = []; + item.answer.forEach((values, answerIndex) => { + if (values.find((value) => value === option.text)) { + answerIndices.push(answerIndex); + } + }); + return { + label: option.text, + values: answerIndices.map( + (answerIndex) => item.responseValues.rows[answerIndex].rowName || '', + ), + }; + }, + ); + + const dateFieldData: ActivityPhrasalMatrixFieldData = { + type: 'matrix', + values: { byRow, byColumn }, + context: fieldDataContext, + }; + fieldData = dateFieldData; + } else if (item.responseType === 'singleSelectRows') { + const byRow = item.responseValues.rows.map( + (row, rowIndex) => { + return { + label: row.rowName, + values: [ + item.responseValues.options.find((option) => option.id === item.answer[rowIndex]) + ?.text || '', + ], + }; + }, + ); + + const byColumn = item.responseValues.options.map( + (option) => { + return { + label: option.text, + values: [item.responseValues.rows[item.answer.indexOf(option.id)]?.rowName || ''], + }; + }, + ); + + const dateFieldData: ActivityPhrasalMatrixFieldData = { + type: 'matrix', + values: { byRow, byColumn }, + context: fieldDataContext, + }; + fieldData = dateFieldData; + } else if (item.responseType === 'sliderRows') { + (fieldDataContext as ActivityPhrasalDataSliderRowContext).maxValues = + item.responseValues.rows.map(({ maxValue }) => maxValue); + + const dateFieldData: ActivityPhrasalIndexedArrayFieldData = { + type: 'indexed-array', + values: item.answer.reduce((acc, answerValue, answerIndex) => { + acc[answerIndex] = [`${answerValue || ''}`]; + return acc; + }, {} as ActivityPhrasalItemizedArrayValue), + context: fieldDataContext, + }; + fieldData = dateFieldData; + } + + if (fieldData) { + data[item.name] = fieldData; + } + } + + return data; +}; From 9f5b557c9830a1216ed4d7faa3a987dac66a6efc Mon Sep 17 00:00:00 2001 From: Billie He Date: Thu, 29 Aug 2024 14:13:55 -0700 Subject: [PATCH 07/26] feat: allow phrasal template item to be skipped --- src/widgets/Survey/model/validateItem.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/widgets/Survey/model/validateItem.ts b/src/widgets/Survey/model/validateItem.ts index 89e13d9dd..c4e2145f0 100644 --- a/src/widgets/Survey/model/validateItem.ts +++ b/src/widgets/Survey/model/validateItem.ts @@ -104,7 +104,8 @@ export function validateBeforeMoveForward({ showWarning, hideWarning, }: ValidateItemProps): boolean { - const isSkippable = item.config.skippableItem || activity.isSkippable; + const isSkippable = + item.config.skippableItem || item.responseType === 'phrasalTemplate' || activity.isSkippable; if (isSkippable) { hideWarning(); From 9f66772ca31c3ba88315ab4e99746fcfb982aecc Mon Sep 17 00:00:00 2001 From: Billie He Date: Thu, 29 Aug 2024 14:13:56 -0700 Subject: [PATCH 08/26] chore: add applet display name to survey context --- src/features/PassSurvey/lib/SurveyContext.ts | 1 + src/features/PassSurvey/lib/mappers.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/features/PassSurvey/lib/SurveyContext.ts b/src/features/PassSurvey/lib/SurveyContext.ts index 9511d3a71..2ed68671f 100644 --- a/src/features/PassSurvey/lib/SurveyContext.ts +++ b/src/features/PassSurvey/lib/SurveyContext.ts @@ -11,6 +11,7 @@ import { SubjectDTO } from '~/shared/api/types/subject'; export type SurveyContext = { appletId: string; + appletDisplayName: string; appletVersion: string; watermark: string; diff --git a/src/features/PassSurvey/lib/mappers.ts b/src/features/PassSurvey/lib/mappers.ts index e598f6295..79ac35e4c 100644 --- a/src/features/PassSurvey/lib/mappers.ts +++ b/src/features/PassSurvey/lib/mappers.ts @@ -42,6 +42,7 @@ export const mapRawDataToSurveyContext = (props: Props): SurveyContext => { return { appletId: appletDTO.id, + appletDisplayName: appletDTO.displayName, watermark: appletDTO.watermark, activityId: activityDTO.id, From 7c39407149e86cd171cdd2b8524d54bfd1612133 Mon Sep 17 00:00:00 2001 From: Billie He Date: Thu, 29 Aug 2024 15:18:15 -0700 Subject: [PATCH 09/26] feat: allow render of card-item without markdown --- src/shared/ui/CardItem/index.tsx | 62 +++++++++++++++++--------------- 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/src/shared/ui/CardItem/index.tsx b/src/shared/ui/CardItem/index.tsx index 0581825fe..2909da80e 100644 --- a/src/shared/ui/CardItem/index.tsx +++ b/src/shared/ui/CardItem/index.tsx @@ -11,7 +11,7 @@ interface CardItemProps extends PropsWithChildren { watermark?: string; isInvalid?: boolean; isOptional?: boolean; - markdown: string; + markdown?: string | null; testId?: string; } @@ -23,9 +23,11 @@ export const CardItem = ({ children, markdown, isOptional, testId }: CardItemPro const context = useContext(SurveyContext); const processedMarkdown = useMemo(() => { - if (!context.targetSubject) return markdown; - - return insertAfterMedia(markdown, '
'); + if (markdown !== null && markdown !== undefined) { + if (!context.targetSubject) return markdown; + return insertAfterMedia(markdown, '
'); + } + return markdown; }, [markdown, context.targetSubject]); return ( @@ -38,31 +40,33 @@ export const CardItem = ({ children, markdown, isOptional, testId }: CardItemPro gap="48px" sx={{ fontFamily: 'Atkinson', fontWeight: '400', fontSize: '18px', lineHeight: '28px' }} > - - - props.id === 'target-subject' ? ( - - ) : ( -
- ), - }} - /> - {isOptional && ( - - {`(${t('optional')})`} - - )} - + {processedMarkdown ? ( + + + props.id === 'target-subject' ? ( + + ) : ( +
+ ), + }} + /> + {isOptional && ( + + {`(${t('optional')})`} + + )} + + ) : null} {children} ); From 09364b9e139c50db87881c7a0ebd9e63f8dc7d5a Mon Sep 17 00:00:00 2001 From: Billie He Date: Tue, 3 Sep 2024 15:02:02 -0700 Subject: [PATCH 10/26] fix: feature flag implementation --- .../PassSurvey/hooks/useSurveyDataQuery.ts | 6 ++-- src/shared/utils/hooks/useFeatureFlags.ts | 36 ++++++++++++++----- src/shared/utils/types/featureFlags.ts | 33 ++++++++++++----- src/widgets/ActivityGroups/ui/index.tsx | 6 ++-- 4 files changed, 60 insertions(+), 21 deletions(-) diff --git a/src/features/PassSurvey/hooks/useSurveyDataQuery.ts b/src/features/PassSurvey/hooks/useSurveyDataQuery.ts index 325deff4f..b53a25249 100644 --- a/src/features/PassSurvey/hooks/useSurveyDataQuery.ts +++ b/src/features/PassSurvey/hooks/useSurveyDataQuery.ts @@ -13,6 +13,7 @@ import { } from '~/shared/api'; import { SubjectDTO } from '~/shared/api/types/subject'; import { useFeatureFlags } from '~/shared/utils'; +import { FeatureFlag } from '~/shared/utils/types/featureFlags'; type Return = { appletDTO: AppletDTO | null; @@ -34,9 +35,10 @@ type Props = { export const useSurveyDataQuery = (props: Props): Return => { const { appletId, activityId, publicAppletKey, targetSubjectId } = props; - const { featureFlags } = useFeatureFlags(); + const { featureFlag } = useFeatureFlags(); + const isAssignmentsEnabled = - !!featureFlags?.enableActivityAssign && !!appletId && !!targetSubjectId; + featureFlag(FeatureFlag.EnableActivityAssign, false) && !!appletId && !!targetSubjectId; const { data: appletById, diff --git a/src/shared/utils/hooks/useFeatureFlags.ts b/src/shared/utils/hooks/useFeatureFlags.ts index 5835306f0..0b9278bca 100644 --- a/src/shared/utils/hooks/useFeatureFlags.ts +++ b/src/shared/utils/hooks/useFeatureFlags.ts @@ -1,6 +1,9 @@ +import { useMemo } from 'react'; + +import { LDFlagValue } from 'launchdarkly-react-client-sdk'; import { useFlags } from 'launchdarkly-react-client-sdk'; -import { FeatureFlags, FeatureFlagsKeys } from '../types/featureFlags'; +import { LaunchDarkyFlagsMap, FeatureFlag, FeatureFlagType } from '../types/featureFlags'; /** * Internal wrapper for LaunchDarkly's hooks and flags. @@ -8,15 +11,30 @@ import { FeatureFlags, FeatureFlagsKeys } from '../types/featureFlags'; export const useFeatureFlags = () => { const flags = useFlags(); - const featureFlags = () => { - const keys = Object.keys(FeatureFlagsKeys) as (keyof typeof FeatureFlagsKeys)[]; - const features: FeatureFlags = {}; - // We're assigning a known list of flags, safe to ignore - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - keys.forEach((key) => (features[key] = flags[FeatureFlagsKeys[key]])); + const featureFlags = useMemo(() => { + return Object.entries(LaunchDarkyFlagsMap).reduce( + (acc, [flag, ldFlag]) => { + acc[flag] = flags[ldFlag] as unknown; + return acc; + }, + {} as Partial>, + ); + }, [flags]); - return features; + const featureFlag = ( + flag: TFlag, + fallbackValue: NoInfer, + ): TValue => { + const flagValue = featureFlags[flag] as TValue | null | undefined; + return flagValue === null || flagValue === undefined ? fallbackValue : flagValue; }; - return { featureFlags: featureFlags() }; + return { + featureFlag, + + /** + * @deprecated Use generic function `featureFlag` instead. + */ + featureFlags, + }; }; diff --git a/src/shared/utils/types/featureFlags.ts b/src/shared/utils/types/featureFlags.ts index 571c22306..3efaba6df 100644 --- a/src/shared/utils/types/featureFlags.ts +++ b/src/shared/utils/types/featureFlags.ts @@ -1,11 +1,28 @@ -import { LDFlagValue } from 'launchdarkly-react-client-sdk'; +export enum FeatureFlag { + EnableParticipantMultiInformant = 'EnableParticipantMultiInformant', + EnablePhrasalTemplate = 'EnablePhrasalTemplate', + EnableActivityAssign = 'EnableActivityAssign', +} -// These keys use the camelCase representation of the feature flag value -// e.g. enable-participant-multi-informant in LaunchDarky becomes enableParticipantMultiInformant -export const FeatureFlagsKeys = { - enableParticipantMultiInformant: 'enableParticipantMultiInformant', - // TODO: https://mindlogger.atlassian.net/browse/M2-6518 Assign Activity flag cleanup - enableActivityAssign: 'enableActivityAssign', +export type FeatureFlagType = { + [FeatureFlag.EnableParticipantMultiInformant]: boolean; + [FeatureFlag.EnablePhrasalTemplate]: boolean; + [FeatureFlag.EnableActivityAssign]: boolean; }; -export type FeatureFlags = Partial>; +// Mapping between the feature flag we want to use in code, to the +// representation of LaunchDarky's feature flag key through LaunchDarky API. +// The keys in LaunchDarky are in kabab-case, and through the LaunchDarky API, +// those keys are represented as camel-case. +// For example, a feature flag in LaunchDarky may be called `my-feature`, but +// that key is exposed through LaunchDarky as `myFeature`. +// So, the "key" of this mapping dictionary is the name of the feature flag we +// want to use in code; and the "value" of this mapping dictionary is the name +// of the feature flag exposed through LaunchDarky API. +export const LaunchDarkyFlagsMap = { + [FeatureFlag.EnableParticipantMultiInformant]: 'enableParticipantMultiInformant', + [FeatureFlag.EnablePhrasalTemplate]: 'enablePhrasalTemplate', + + // TODO: https://mindlogger.atlassian.net/browse/M2-6518 Assign Activity flag cleanup + [FeatureFlag.EnableActivityAssign]: 'enableActivityAssign', +}; diff --git a/src/widgets/ActivityGroups/ui/index.tsx b/src/widgets/ActivityGroups/ui/index.tsx index 38355db71..b14ffa891 100644 --- a/src/widgets/ActivityGroups/ui/index.tsx +++ b/src/widgets/ActivityGroups/ui/index.tsx @@ -14,6 +14,7 @@ import Box from '~/shared/ui/Box'; import Loader from '~/shared/ui/Loader'; import { useCustomTranslation, useOnceEffect } from '~/shared/utils'; import { useFeatureFlags } from '~/shared/utils/hooks'; +import { FeatureFlag } from '~/shared/utils/types/featureFlags'; type PublicAppletDetails = { isPublic: true; @@ -37,8 +38,9 @@ export const ActivityGroups = (props: Props) => { useState({ isOpen: false, }); - const { featureFlags } = useFeatureFlags(); - const isAssignmentsEnabled = !!featureFlags.enableActivityAssign && !props.isPublic; + const { featureFlag } = useFeatureFlags(); + const isAssignmentsEnabled = + featureFlag(FeatureFlag.EnableActivityAssign, false) && !props.isPublic; const { isError: isAppletError, From 99753eaffa17d57107e6ae678fd08dbdd55dc228 Mon Sep 17 00:00:00 2001 From: Billie He Date: Wed, 4 Sep 2024 15:32:26 -0700 Subject: [PATCH 11/26] feat: activity action plan --- public/action-plan-page-background.svg | 5 + src/assets/download-icon-light.svg | 5 + src/assets/download-icon.svg | 5 + .../mindlogger-action-plan-footer-logo.svg | 19 ++ src/entities/activity/lib/types/item.ts | 52 +++- .../activity/lib/useActionPlanTranslation.ts | 6 + .../lib/usePhrasalTemplateTranslation.ts | 6 + src/entities/activity/ui/ActivityCardItem.tsx | 2 +- .../activity/ui/items/ActionPlan/Body.tsx | 15 + .../activity/ui/items/ActionPlan/Document.tsx | 114 ++++++++ .../ui/items/ActionPlan/DocumentContext.ts | 7 + .../activity/ui/items/ActionPlan/Header.tsx | 19 ++ .../activity/ui/items/ActionPlan/Page.tsx | 105 +++++++ .../activity/ui/items/ActionPlan/Phrase.tsx | 74 +++++ .../ui/items/ActionPlan/ResponseSegment.tsx | 139 +++++++++ .../ui/items/ActionPlan/TextSegment.tsx | 11 + .../activity/ui/items/ActionPlan/Title.tsx | 37 +++ .../activity/ui/items/ActionPlan/hooks.tsx | 74 +++++ .../ui/items/ActionPlan/pageDimension.ts | 5 + .../ui/items/ActionPlan/phrasalData.test.ts | 267 ++++++++++++++++++ ...ctivitiesPhrasalData.ts => phrasalData.ts} | 29 +- src/entities/activity/ui/items/ItemPicker.tsx | 4 + .../activity/ui/items/PhrasalTemplateItem.tsx | 109 +++++++ src/entities/applet/model/types.ts | 4 +- src/i18n/en/translation.json | 10 + src/i18n/fr/translation.json | 10 + src/shared/constants/theme.ts | 1 + src/shared/utils/chunkArray.ts | 14 + src/shared/utils/getWindowDimensions.ts | 9 + src/shared/utils/measureComponentHeight.tsx | 25 ++ .../Survey/model/hooks/useSurveyState.ts | 16 +- 31 files changed, 1176 insertions(+), 22 deletions(-) create mode 100644 public/action-plan-page-background.svg create mode 100644 src/assets/download-icon-light.svg create mode 100644 src/assets/download-icon.svg create mode 100644 src/assets/mindlogger-action-plan-footer-logo.svg create mode 100644 src/entities/activity/lib/useActionPlanTranslation.ts create mode 100644 src/entities/activity/lib/usePhrasalTemplateTranslation.ts create mode 100644 src/entities/activity/ui/items/ActionPlan/Body.tsx create mode 100644 src/entities/activity/ui/items/ActionPlan/Document.tsx create mode 100644 src/entities/activity/ui/items/ActionPlan/DocumentContext.ts create mode 100644 src/entities/activity/ui/items/ActionPlan/Header.tsx create mode 100644 src/entities/activity/ui/items/ActionPlan/Page.tsx create mode 100644 src/entities/activity/ui/items/ActionPlan/Phrase.tsx create mode 100644 src/entities/activity/ui/items/ActionPlan/ResponseSegment.tsx create mode 100644 src/entities/activity/ui/items/ActionPlan/TextSegment.tsx create mode 100644 src/entities/activity/ui/items/ActionPlan/Title.tsx create mode 100644 src/entities/activity/ui/items/ActionPlan/hooks.tsx create mode 100644 src/entities/activity/ui/items/ActionPlan/pageDimension.ts create mode 100644 src/entities/activity/ui/items/ActionPlan/phrasalData.test.ts rename src/entities/activity/ui/items/ActionPlan/{activitiesPhrasalData.ts => phrasalData.ts} (88%) create mode 100644 src/entities/activity/ui/items/PhrasalTemplateItem.tsx create mode 100644 src/shared/utils/chunkArray.ts create mode 100644 src/shared/utils/getWindowDimensions.ts create mode 100644 src/shared/utils/measureComponentHeight.tsx diff --git a/public/action-plan-page-background.svg b/public/action-plan-page-background.svg new file mode 100644 index 000000000..b736ee4c7 --- /dev/null +++ b/public/action-plan-page-background.svg @@ -0,0 +1,5 @@ + + + + diff --git a/src/assets/download-icon-light.svg b/src/assets/download-icon-light.svg new file mode 100644 index 000000000..4804e62f1 --- /dev/null +++ b/src/assets/download-icon-light.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/download-icon.svg b/src/assets/download-icon.svg new file mode 100644 index 000000000..98add734b --- /dev/null +++ b/src/assets/download-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/mindlogger-action-plan-footer-logo.svg b/src/assets/mindlogger-action-plan-footer-logo.svg new file mode 100644 index 000000000..4d86adbaa --- /dev/null +++ b/src/assets/mindlogger-action-plan-footer-logo.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/entities/activity/lib/types/item.ts b/src/entities/activity/lib/types/item.ts index c9b483991..e908aa5e3 100644 --- a/src/entities/activity/lib/types/item.ts +++ b/src/entities/activity/lib/types/item.ts @@ -32,7 +32,8 @@ export type ActivityItemType = | 'audio' | 'audioPlayer' | 'unsupportable' - | 'splashScreen'; + | 'splashScreen' + | 'phrasalTemplate'; export type ButtonsConfig = { removeBackButton: boolean; @@ -97,10 +98,57 @@ export type ResponseValues = | SelectorValues | AudioPlayerItemValues | MultiSelectionRowsItemResponseValues - | SliderRowsItemResponseValues; + | SliderRowsItemResponseValues + | PhrasalTemplateValues; export type EmptyResponseValues = null; +export interface PhrasalTemplateItem extends ActivityItemBase { + responseType: 'phrasalTemplate'; + config: never; + responseValues: PhrasalTemplateValues; + answer: DefaultAnswer; +} + +export type PhrasalTemplateValues = { + cardTitle: string; + phrases: PhrasalTemplatePhrase[]; +}; + +export type PhrasalTemplatePhrase = { + fields: PhrasalTemplateField[]; + image: string | null; +}; + +export type PhrasalTemplateField = + | PhrasalTemplateSentenceField + | PhrasalTemplateItemResponseField + | PhrasalTemplateLineBreakField; + +export type PhrasalTemplateSentenceField = { + type: 'sentence'; + text: string; +}; + +export type PhrasalTemplateItemResponseField = { + type: 'item_response'; + itemIndex: number; + itemName: string; + displayMode: PhrasalTemplateItemResponseFieldDisplayMode; +}; + +export type PhrasalTemplateLineBreakField = { + type: 'line_break'; +}; + +export type PhrasalTemplateItemResponseFieldDisplayMode = + | 'sentence' + | 'sentence_option_row' + | 'sentence_row_option' + | 'bullet_list' + | 'bullet_list_option_row' + | 'bullet_list_text_row'; + export interface TextItem extends ActivityItemBase { responseType: 'text'; config: TextItemConfig; diff --git a/src/entities/activity/lib/useActionPlanTranslation.ts b/src/entities/activity/lib/useActionPlanTranslation.ts new file mode 100644 index 000000000..65d5f22ec --- /dev/null +++ b/src/entities/activity/lib/useActionPlanTranslation.ts @@ -0,0 +1,6 @@ +import { useCustomTranslation } from '~/shared/utils'; + +export const useActionPlanTranslation = () => { + const { t, i18n } = useCustomTranslation({ keyPrefix: 'ActionPlan' }); + return { t, i18n }; +}; diff --git a/src/entities/activity/lib/usePhrasalTemplateTranslation.ts b/src/entities/activity/lib/usePhrasalTemplateTranslation.ts new file mode 100644 index 000000000..cdca9a83c --- /dev/null +++ b/src/entities/activity/lib/usePhrasalTemplateTranslation.ts @@ -0,0 +1,6 @@ +import { useCustomTranslation } from '~/shared/utils'; + +export const usePhrasalTemplateTranslation = () => { + const { t, i18n } = useCustomTranslation({ keyPrefix: 'PhrasalTemplate' }); + return { t, i18n }; +}; diff --git a/src/entities/activity/ui/ActivityCardItem.tsx b/src/entities/activity/ui/ActivityCardItem.tsx index cfdb844f6..5ba6ffa3e 100644 --- a/src/entities/activity/ui/ActivityCardItem.tsx +++ b/src/entities/activity/ui/ActivityCardItem.tsx @@ -47,7 +47,7 @@ export const ActivityCardItem = ({ return ( { + const gap = useXScaledDimension(32); + + return ( + + {children} + + ); +}; diff --git a/src/entities/activity/ui/items/ActionPlan/Document.tsx b/src/entities/activity/ui/items/ActionPlan/Document.tsx new file mode 100644 index 000000000..61803b813 --- /dev/null +++ b/src/entities/activity/ui/items/ActionPlan/Document.tsx @@ -0,0 +1,114 @@ +import React, { forwardRef, useEffect, useState, useContext, useMemo, useCallback } from 'react'; + +import { Box } from '@mui/material'; + +import { DocumentContext } from './DocumentContext'; +import { useAvailableBodyHeight, useAvailableBodyWidth } from './hooks'; +import { Page } from './Page'; +import { extractActivitiesPhrasalData } from './phrasalData'; +import { Phrase } from './Phrase'; + +import { getProgressId } from '~/abstract/lib'; +import { PhrasalTemplatePhrase } from '~/entities/activity/lib'; +import { appletModel } from '~/entities/applet'; +import { SurveyContext } from '~/features/PassSurvey'; +import { useAppSelector } from '~/shared/utils'; +import measureComponentHeight from '~/shared/utils/measureComponentHeight'; + +type DocumentProps = { + phrases: PhrasalTemplatePhrase[]; + appletTitle: string; + phrasalTemplateCardTitle: string; +}; + +export const Document = forwardRef( + ({ appletTitle, phrases, phrasalTemplateCardTitle }, ref) => { + const context = useContext(SurveyContext); + + const activityProgress = useAppSelector((state) => + appletModel.selectors.selectActivityProgress( + state, + getProgressId(context.activityId, context.eventId), + ), + ); + + const activitiesPhrasalData = useMemo( + () => extractActivitiesPhrasalData(activityProgress.items), + [activityProgress], + ); + + const noImage = phrases.filter((phrase) => !!phrase.image).length <= 0; + const [pages, setPages] = useState([]); + const availableHeight = useAvailableBodyHeight(phrasalTemplateCardTitle); + const availableWidth: number = (useAvailableBodyWidth as () => number)(); + const gap = 32; + + const renderPages = useCallback(async () => { + let runningHeight = 0; + let pagePhrases: PhrasalTemplatePhrase[] = []; + const _pages: React.ReactNode[] = []; + + for (const phrase of phrases) { + const height = await measureComponentHeight(availableWidth, () => ( + + )); + const pageNumber = _pages.length + 1; + + if (runningHeight + height <= availableHeight) { + runningHeight += height + gap; + pagePhrases.push(phrase); + } else { + _pages.push( + , + ); + runningHeight = height + gap; + pagePhrases = [phrase]; + } + } + + if (pagePhrases.length > 0) { + _pages.push( + , + ); + } + + setPages(_pages); + }, [ + noImage, + appletTitle, + phrasalTemplateCardTitle, + phrases, + activitiesPhrasalData, + availableWidth, + availableHeight, + ]); + + useEffect(() => { + void renderPages(); + }, [renderPages]); + + return ( + + + {pages} + + + ); + }, +); + +Document.displayName = 'Document'; diff --git a/src/entities/activity/ui/items/ActionPlan/DocumentContext.ts b/src/entities/activity/ui/items/ActionPlan/DocumentContext.ts new file mode 100644 index 000000000..ce28665a5 --- /dev/null +++ b/src/entities/activity/ui/items/ActionPlan/DocumentContext.ts @@ -0,0 +1,7 @@ +import React from 'react'; + +export const DocumentContext = React.createContext<{ + totalPages: number; +}>({ + totalPages: 0, +}); diff --git a/src/entities/activity/ui/items/ActionPlan/Header.tsx b/src/entities/activity/ui/items/ActionPlan/Header.tsx new file mode 100644 index 000000000..c100c1863 --- /dev/null +++ b/src/entities/activity/ui/items/ActionPlan/Header.tsx @@ -0,0 +1,19 @@ +import { useXScaledDimension } from './hooks'; + +import { Theme } from '~/shared/constants'; +import Text from '~/shared/ui/Text'; + +export const Header = ({ children }: { children: string }) => { + const headerFontSize = useXScaledDimension(20); + + return ( + + {children} + + ); +}; diff --git a/src/entities/activity/ui/items/ActionPlan/Page.tsx b/src/entities/activity/ui/items/ActionPlan/Page.tsx new file mode 100644 index 000000000..9019501e1 --- /dev/null +++ b/src/entities/activity/ui/items/ActionPlan/Page.tsx @@ -0,0 +1,105 @@ +import { useContext } from 'react'; + +import { Body } from './Body'; +import { DocumentContext } from './DocumentContext'; +import { Header } from './Header'; +import { + usePDFPageWidth, + useXScaledDimension, + useBackgroundHeight, + useBackgroundWidth, +} from './hooks'; +import { ActivitiesPhrasalData } from './phrasalData'; +import { Phrase } from './Phrase'; +import { Title } from './Title'; + +import footerLogo from '~/assets/mindlogger-action-plan-footer-logo.svg'; +import { PhrasalTemplatePhrase } from '~/entities/activity/lib'; +import { useActionPlanTranslation } from '~/entities/activity/lib/useActionPlanTranslation'; +import { Theme } from '~/shared/constants'; +import Box from '~/shared/ui/Box'; + +type PageProps = { + pageNumber: number; + phrases: PhrasalTemplatePhrase[]; + phrasalData: ActivitiesPhrasalData; + appletTitle: string; + phrasalTemplateCardTitle: string; +}; + +export const Page = ({ + appletTitle, + phrasalTemplateCardTitle, + phrases, + phrasalData, + pageNumber, +}: PageProps) => { + const { totalPages } = useContext(DocumentContext); + const { t } = useActionPlanTranslation(); + const pageWidth = usePDFPageWidth(); + const width = useBackgroundWidth(); + const height = useBackgroundHeight(); + const scaledPadding = useXScaledDimension(16); + const scaledTopPadding = useXScaledDimension(28); + const scaledRightPadding = useXScaledDimension(40); + const scaledBottomPadding = useXScaledDimension(40); + const scaledLeftPadding = useXScaledDimension(36.5); + + const noImage = phrases.filter((phrase) => !!phrase.image).length <= 0; + + return ( + + {appletTitle} + + +
+ {totalPages > 1 + ? `${phrasalTemplateCardTitle} (${pageNumber}/${totalPages})` + : phrasalTemplateCardTitle} +
+ + {phrases.map((phrase, index) => ( + + ))} + +
+ + {t('credit') + +
+
+ ); +}; diff --git a/src/entities/activity/ui/items/ActionPlan/Phrase.tsx b/src/entities/activity/ui/items/ActionPlan/Phrase.tsx new file mode 100644 index 000000000..17adee81d --- /dev/null +++ b/src/entities/activity/ui/items/ActionPlan/Phrase.tsx @@ -0,0 +1,74 @@ +import React from 'react'; + +import Avatar from '@mui/material/Avatar'; + +import { useXScaledDimension, useYScaledDimension } from './hooks'; +import { ActivitiesPhrasalData } from './phrasalData'; +import { ResponseSegment } from './ResponseSegment'; +import { TextSegment } from './TextSegment'; +import { PhrasalTemplatePhrase } from '../../../lib'; + +import { Theme } from '~/shared/constants'; +import Box from '~/shared/ui/Box'; + +export type PhraseProps = { + phrase: PhrasalTemplatePhrase; + phrasalData: ActivitiesPhrasalData; + noImage: boolean; +}; + +export const Phrase = ({ phrase, phrasalData, noImage }: PhraseProps) => { + const gap = useXScaledDimension(24); + const minHeight = useXScaledDimension(72); + + const imageWidth = useXScaledDimension(67); + const imageHeight = useXScaledDimension(66); + const imagePadding = useXScaledDimension(2); + + const fontSize = useXScaledDimension(16); + const lineHeight = useYScaledDimension(24); + + const components = phrase.fields.reduce((acc, field) => { + if (field.type === 'sentence') { + acc.push(); + } else if (field.type === 'item_response') { + acc.push(); + } else if (field.type === 'line_break') { + acc.push(
); + } + return acc; + }, [] as React.ReactNode[]); + + return ( + + {!noImage && ( + + {phrase.image && ( + + )} + + )} + + {React.Children.toArray(components)} + + + ); +}; diff --git a/src/entities/activity/ui/items/ActionPlan/ResponseSegment.tsx b/src/entities/activity/ui/items/ActionPlan/ResponseSegment.tsx new file mode 100644 index 000000000..24d965dcf --- /dev/null +++ b/src/entities/activity/ui/items/ActionPlan/ResponseSegment.tsx @@ -0,0 +1,139 @@ +import { useXScaledDimension } from './hooks'; +import { ActivitiesPhrasalData, ActivityPhrasalDataSliderRowContext } from './phrasalData'; +import { PhrasalTemplateItemResponseField } from '../../../lib'; + +import { useActionPlanTranslation } from '~/entities/activity/lib/useActionPlanTranslation'; +import Box from '~/shared/ui/Box'; +import Text from '~/shared/ui/Text'; + +const isAnswersSkipped = (answers: string[]): boolean => { + if (!answers || answers.length <= 0) { + return true; + } + + let allFalsy = true; + for (const answer of answers) { + if (answer !== null && answer !== undefined && answer.trim().length > 0) { + allFalsy = false; + } + } + + return allFalsy; +}; + +type FieldValueTransformer = (value: string) => string; +const identity: FieldValueTransformer = (value) => value; + +type FieldValuesJoiner = (values: string[]) => string; +const joinWithComma: FieldValuesJoiner = (values) => values.join(', '); + +type ResponseSegmentProps = { + phrasalData: ActivitiesPhrasalData; + field: PhrasalTemplateItemResponseField; +}; + +export const ResponseSegment = ({ phrasalData, field }: ResponseSegmentProps) => { + const { t } = useActionPlanTranslation(); + const listPadding = useXScaledDimension(40); + + const fieldDisplayMode = field.displayMode; + const fieldPhrasalData = phrasalData[field.itemName]; + + if (!fieldPhrasalData) { + // This really shouldn't happen. But we should still eliminate the logical + // path for nil/falsy values anyway. + return null; + } + + const fieldPhrasalDataType = fieldPhrasalData.type; + + let transformValue = identity; + let joinSentenceWords = joinWithComma; + + if (fieldPhrasalData.context.itemResponseType === 'sliderRows') { + const ctx = fieldPhrasalData.context as ActivityPhrasalDataSliderRowContext; + transformValue = (value) => { + return t('sliderValue', { value, total: ctx.maxValues[field.itemIndex] }); + }; + } else if (fieldPhrasalData.context.itemResponseType === 'timeRange') { + joinSentenceWords = (values) => values.join(' - '); + } + + let words: string[]; + if (fieldPhrasalDataType === 'array') { + words = isAnswersSkipped(fieldPhrasalData.values) + ? [t('questionSkipped')] + : fieldPhrasalData.values.map(transformValue); + } else if (fieldPhrasalDataType === 'indexed-array') { + const indexedAnswers = fieldPhrasalData.values[field.itemIndex] || []; + words = isAnswersSkipped(indexedAnswers) + ? [t('questionSkipped')] + : indexedAnswers.map(transformValue); + } else if (fieldPhrasalDataType === 'matrix') { + let renderByRowValues: boolean; + + if ( + fieldDisplayMode === 'sentence_option_row' || + fieldDisplayMode === 'bullet_list_option_row' + ) { + renderByRowValues = false; + } else if ( + fieldDisplayMode === 'sentence_row_option' || + fieldDisplayMode === 'bullet_list_text_row' + ) { + renderByRowValues = true; + } else { + // The admin UI actually allows matrix type items to have `sentence` as + // their display mode. So in this case, we're just going to assume the + // effective render order for the values to be "by row". + renderByRowValues = true; + } + + if (renderByRowValues) { + words = fieldPhrasalData.values.byRow + .map(({ label, values }) => { + const transformedValues = isAnswersSkipped(values) + ? [t('questionSkipped')] + : values.map(transformValue); + return transformedValues.map((transformedValue) => `${label} ${transformedValue}`); + }) + .flat(); + } else { + words = fieldPhrasalData.values.byColumn + .map(({ label, values }) => { + const transformedValues = isAnswersSkipped(values) + ? [t('questionSkipped')] + : values.map(transformValue); + return transformedValues.map((transformedValue) => `${label} ${transformedValue}`); + }) + .flat(); + } + } else { + // This also shouldn't happen. But including a `else` here allows all + // previous branches to have explicitly defined condition, so it's more + // clear this way. + throw new Error(`Invalie phrasal data type: ${fieldPhrasalDataType}`); + } + + return ( + + {fieldDisplayMode === 'bullet_list' || + fieldDisplayMode === 'bullet_list_option_row' || + fieldDisplayMode === 'bullet_list_text_row' ? ( + <> + +   +
+
+ + {words.map((item, index) => ( +
  • {item}
  • + ))} +
    + + ) : ( + <> {joinSentenceWords(words)}  + )} +
    + ); +}; diff --git a/src/entities/activity/ui/items/ActionPlan/TextSegment.tsx b/src/entities/activity/ui/items/ActionPlan/TextSegment.tsx new file mode 100644 index 000000000..f37c92e36 --- /dev/null +++ b/src/entities/activity/ui/items/ActionPlan/TextSegment.tsx @@ -0,0 +1,11 @@ +import Text from '~/shared/ui/Text'; + +type TextSegmentProps = { text: string }; + +export const TextSegment = ({ text }: TextSegmentProps) => { + return ( + + {text} + + ); +}; diff --git a/src/entities/activity/ui/items/ActionPlan/Title.tsx b/src/entities/activity/ui/items/ActionPlan/Title.tsx new file mode 100644 index 000000000..191b9385b --- /dev/null +++ b/src/entities/activity/ui/items/ActionPlan/Title.tsx @@ -0,0 +1,37 @@ +import { useXScaledDimension } from './hooks'; + +import { Theme } from '~/shared/constants'; +import Box from '~/shared/ui/Box'; +import Text from '~/shared/ui/Text'; + +export const Title = ({ children }: { children: string }) => { + const titleFontSize = useXScaledDimension(16); + const scaledTopPadding = useXScaledDimension(24); + const scaledRightPadding = useXScaledDimension(48); + const scaledBottomPadding = useXScaledDimension(12); + const scaledLeftPadding = useXScaledDimension(40); + const letterSpacing = useXScaledDimension(0.15); + + return ( + + + {children} + + + ); +}; diff --git a/src/entities/activity/ui/items/ActionPlan/hooks.tsx b/src/entities/activity/ui/items/ActionPlan/hooks.tsx new file mode 100644 index 000000000..f7dd5dbbb --- /dev/null +++ b/src/entities/activity/ui/items/ActionPlan/hooks.tsx @@ -0,0 +1,74 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { Header } from './Header'; +import { PageDimension } from './pageDimension'; + +import getWindowDimensions from '~/shared/utils/getWindowDimensions'; +import measureComponentHeight from '~/shared/utils/measureComponentHeight'; + +export const useWindowDimensions = () => { + const [windowDimensions, setWindowDimensions] = useState(getWindowDimensions()); + + const handleResize = useCallback(() => { + setWindowDimensions(getWindowDimensions()); + }, []); + + useEffect(() => { + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, [handleResize]); + + return windowDimensions; +}; + +export const usePDFPageWidth = () => { + const { width: windowWidth } = useWindowDimensions(); + return Math.min(windowWidth - PageDimension.padding, PageDimension.maxWidth); +}; + +export const useXScaledDimension = (dimension: number) => { + const pageWidth = usePDFPageWidth(); + return (pageWidth / PageDimension.maxWidth) * dimension; +}; + +export const useBackgroundWidth = () => { + return useXScaledDimension(580); +}; + +export const useBackgroundHeight = () => { + const width = useBackgroundWidth(); + return (width / 580) * 760; +}; + +export const useAvailableBodyWidth = () => { + const width = useBackgroundWidth(); + const scaledRightPadding = useXScaledDimension(40); + const scaledLeftPadding = useXScaledDimension(36.5); + return width - scaledRightPadding - scaledLeftPadding; +}; + +export const useAvailableBodyHeight = (headerPrefix: string) => { + const availableWidth = useAvailableBodyWidth(); + const height = useBackgroundHeight(); + const scaledTopPadding = useXScaledDimension(28); + const scaledBottomPadding = useXScaledDimension(40); + const [availableHeight, setAvailableHeight] = useState(height); + + const calculateAvailableHeight = useCallback(async () => { + const headerHeight = await measureComponentHeight(availableWidth, () => ( +
    {headerPrefix}
    + )); + setAvailableHeight(height - headerHeight - 24 - scaledTopPadding - scaledBottomPadding); + }, [availableWidth, headerPrefix, height, scaledBottomPadding, scaledTopPadding]); + + useEffect(() => { + void calculateAvailableHeight(); + }, [calculateAvailableHeight]); + + return availableHeight; +}; + +export const useYScaledDimension = (dimension: number) => { + const height = useBackgroundHeight(); + return (height / 760) * dimension; +}; diff --git a/src/entities/activity/ui/items/ActionPlan/pageDimension.ts b/src/entities/activity/ui/items/ActionPlan/pageDimension.ts new file mode 100644 index 000000000..070f09335 --- /dev/null +++ b/src/entities/activity/ui/items/ActionPlan/pageDimension.ts @@ -0,0 +1,5 @@ +export const PageDimension = { + maxWidth: 612, + maxHeight: 912, + padding: 48, +}; diff --git a/src/entities/activity/ui/items/ActionPlan/phrasalData.test.ts b/src/entities/activity/ui/items/ActionPlan/phrasalData.test.ts new file mode 100644 index 000000000..3c138f3ab --- /dev/null +++ b/src/entities/activity/ui/items/ActionPlan/phrasalData.test.ts @@ -0,0 +1,267 @@ +import { extractActivitiesPhrasalData } from './phrasalData'; + +import { ItemRecord } from '~/entities/applet/model'; + +describe('Action Plan', () => { + describe('extractActivitiesPhrasalData', () => { + const newAudioPlayerItem = (name: string) => + ({ + name, + responseType: 'audioPlayer', + responseValues: { file: 'file://lol.mp3' }, + answer: [], + }) as never as ItemRecord; + + const newSimpleItem = + (type: string) => + (name: string, answer: TAnswer[]) => + ({ + name, + responseType: type, + responseValues: null, + answer, + }) as never as ItemRecord; + + const newDateItem = newSimpleItem('date'); + const newTimeItem = newSimpleItem('time'); + const newTimeRangeItem = newSimpleItem('timeRange'); + const newNumberSelectItem = newSimpleItem('numberSelect'); + const newSliderItem = newSimpleItem('slider'); + const newTextItem = newSimpleItem('text'); + + const newSelectItem = (type: string) => (name: string, answer: string[], options: string[]) => + ({ + name, + responseType: type, + responseValues: { + options: options.map((option) => ({ text: option })), + }, + answer, + }) as never as ItemRecord; + + const newSingleSelectItem = newSelectItem('singleSelect'); + const newMultiSelectItem = newSelectItem('multiSelect'); + + const newMultiSelectRowsItem = ( + name: string, + answer: (string | null)[][], + options: [string[], string[]], + ) => + ({ + name, + responseType: 'multiSelectRows', + responseValues: { + rows: options[0].map((option) => ({ rowName: option })), + options: options[1].map((option) => ({ text: option })), + }, + answer, + }) as never as ItemRecord; + + const newSingleSelectRowsItem = ( + name: string, + answer: string[], + options: [string[], string[]], + ) => + ({ + name, + responseType: 'singleSelectRows', + responseValues: { + rows: options[0].map((option) => ({ rowName: option })), + options: options[1].map((option, index) => ({ id: `col:${index}`, text: option })), + }, + answer, + }) as never as ItemRecord; + + const newSliderRowsItem = (name: string, answer: number[], options: [number, number][]) => + ({ + name, + responseType: 'sliderRows', + responseValues: { + rows: options.map(([min, max]) => ({ minValue: min, maxValue: max })), + }, + answer, + }) as never as ItemRecord; + + it('should not extract data from unsupported activity type', () => { + const data = extractActivitiesPhrasalData([newAudioPlayerItem('item')]); + expect(data).toEqual({}); + }); + + it('should extract data from `date` activity type', () => { + const data = extractActivitiesPhrasalData([ + newDateItem('item', ['Fri Sep 06 2024 00:00:00 GMT-0700 (Pacific Daylight Time)']), + ]); + + expect(data).toHaveProperty('item'); + expect(data.item).toHaveProperty('type', 'array'); + expect(data.item).toHaveProperty('values.0', '2024-09-06'); + expect(data.item).toHaveProperty('context.itemResponseType', 'date'); + }); + + it('should extract data from `time` activity type', () => { + const data = extractActivitiesPhrasalData([ + newTimeItem('item', ['Wed Sep 04 2024 00:05:00 GMT-0700 (Pacific Daylight Time)']), + ]); + + expect(data).toHaveProperty('item'); + expect(data.item).toHaveProperty('type', 'array'); + expect(data.item).toHaveProperty('values.0', expect.stringContaining('12:05:00')); + expect(data.item).toHaveProperty('context.itemResponseType', 'time'); + }); + + it('should extract data from `timeRange` activity type', () => { + const data = extractActivitiesPhrasalData([ + newTimeRangeItem('item', [ + 'Wed Sep 04 2024 00:05:00 GMT-0700 (Pacific Daylight Time)', + 'Wed Sep 04 2024 00:15:00 GMT-0700 (Pacific Daylight Time)', + ]), + ]); + + expect(data).toHaveProperty('item'); + expect(data.item).toHaveProperty('type', 'array'); + expect(data.item).toHaveProperty('values.0', expect.stringContaining('12:05:00')); + expect(data.item).toHaveProperty('values.1', expect.stringContaining('12:15:00')); + expect(data.item).toHaveProperty('context.itemResponseType', 'timeRange'); + }); + + it('should extract data from `numberSelect` activity type', () => { + const data = extractActivitiesPhrasalData([newNumberSelectItem('item', [7])]); + + expect(data).toHaveProperty('item'); + expect(data.item).toHaveProperty('type', 'array'); + expect(data.item).toHaveProperty('values.0', '7'); + expect(data.item).toHaveProperty('context.itemResponseType', 'numberSelect'); + }); + + it('should extract data from `slider` activity type', () => { + const data = extractActivitiesPhrasalData([newSliderItem('item', ['6'])]); + + expect(data).toHaveProperty('item'); + expect(data.item).toHaveProperty('type', 'array'); + expect(data.item).toHaveProperty('values.0', '6'); + expect(data.item).toHaveProperty('context.itemResponseType', 'slider'); + }); + + it('should extract data from `text` activity type', () => { + const data = extractActivitiesPhrasalData([newTextItem('item', ['oh hai'])]); + + expect(data).toHaveProperty('item'); + expect(data.item).toHaveProperty('type', 'array'); + expect(data.item).toHaveProperty('values.0', 'oh hai'); + expect(data.item).toHaveProperty('context.itemResponseType', 'text'); + }); + + it('should extract data from `singleSelect` activity type', () => { + const data = extractActivitiesPhrasalData([ + newSingleSelectItem('item', ['1'], ['one', 'two', 'three']), + ]); + + expect(data).toHaveProperty('item'); + expect(data.item).toHaveProperty('type', 'array'); + expect(data.item).toHaveProperty('values.0', 'two'); + expect(data.item).toHaveProperty('context.itemResponseType', 'singleSelect'); + }); + + it('should extract data from `multiSelect` activity type', () => { + const data = extractActivitiesPhrasalData([ + newMultiSelectItem('item', ['1', '0'], ['one', 'two', 'three']), + ]); + + expect(data).toHaveProperty('item'); + expect(data.item).toHaveProperty('type', 'array'); + expect(data.item).toHaveProperty('values.0', 'two'); + expect(data.item).toHaveProperty('values.1', 'one'); + expect(data.item).toHaveProperty('context.itemResponseType', 'multiSelect'); + }); + + it('should extract data from `multiSelectRows` activity type', () => { + const data = extractActivitiesPhrasalData([ + newMultiSelectRowsItem( + 'item', + [ + ['C1', 'C2', null], + [null, 'C2', null], + [null, 'C2', 'C3'], + ], + [ + ['R1', 'R2', 'R3'], + ['C1', 'C2', 'C3'], + ], + ), + ]); + + expect(data).toHaveProperty('item'); + expect(data.item).toHaveProperty('type', 'matrix'); + expect(data.item).toHaveProperty('values.byRow.0.label', 'R1'); + expect(data.item).toHaveProperty('values.byRow.0.values.0', 'C1'); + expect(data.item).toHaveProperty('values.byRow.0.values.1', 'C2'); + expect(data.item).toHaveProperty('values.byRow.1.label', 'R2'); + expect(data.item).toHaveProperty('values.byRow.1.values.0', 'C2'); + expect(data.item).toHaveProperty('values.byRow.2.label', 'R3'); + expect(data.item).toHaveProperty('values.byRow.2.values.0', 'C2'); + expect(data.item).toHaveProperty('values.byRow.2.values.1', 'C3'); + expect(data.item).toHaveProperty('values.byColumn.0.label', 'C1'); + expect(data.item).toHaveProperty('values.byColumn.0.values.0', 'R1'); + expect(data.item).toHaveProperty('values.byColumn.1.label', 'C2'); + expect(data.item).toHaveProperty('values.byColumn.1.values.0', 'R1'); + expect(data.item).toHaveProperty('values.byColumn.1.values.1', 'R2'); + expect(data.item).toHaveProperty('values.byColumn.1.values.2', 'R3'); + expect(data.item).toHaveProperty('values.byColumn.2.label', 'C3'); + expect(data.item).toHaveProperty('values.byColumn.2.values.0', 'R3'); + expect(data.item).toHaveProperty('context.itemResponseType', 'multiSelectRows'); + }); + + it('should extract data from `singleSelectRows` activity type', () => { + const data = extractActivitiesPhrasalData([ + newSingleSelectRowsItem( + 'item', + ['col:2', 'col:0', 'col:1'], + [ + ['R1', 'R2', 'R3'], + ['C1', 'C2', 'C3'], + ], + ), + ]); + + expect(data).toHaveProperty('item'); + expect(data.item).toHaveProperty('type', 'matrix'); + expect(data.item).toHaveProperty('values.byRow.0.label', 'R1'); + expect(data.item).toHaveProperty('values.byRow.0.values.0', 'C3'); + expect(data.item).toHaveProperty('values.byRow.1.label', 'R2'); + expect(data.item).toHaveProperty('values.byRow.1.values.0', 'C1'); + expect(data.item).toHaveProperty('values.byRow.2.label', 'R3'); + expect(data.item).toHaveProperty('values.byRow.2.values.0', 'C2'); + expect(data.item).toHaveProperty('values.byColumn.0.label', 'C1'); + expect(data.item).toHaveProperty('values.byColumn.0.values.0', 'R2'); + expect(data.item).toHaveProperty('values.byColumn.1.label', 'C2'); + expect(data.item).toHaveProperty('values.byColumn.1.values.0', 'R3'); + expect(data.item).toHaveProperty('values.byColumn.2.label', 'C3'); + expect(data.item).toHaveProperty('values.byColumn.2.values.0', 'R1'); + expect(data.item).toHaveProperty('context.itemResponseType', 'singleSelectRows'); + }); + + it('should extract data from `sliderRows` activity type', () => { + const data = extractActivitiesPhrasalData([ + newSliderRowsItem( + 'item', + [3, 6, 9], + [ + [0, 10], + [0, 11], + [0, 12], + ], + ), + ]); + + expect(data).toHaveProperty('item'); + expect(data.item).toHaveProperty('type', 'indexed-array'); + expect(data.item).toHaveProperty('values.0.0', '3'); + expect(data.item).toHaveProperty('values.1.0', '6'); + expect(data.item).toHaveProperty('values.2.0', '9'); + expect(data.item).toHaveProperty('context.itemResponseType', 'sliderRows'); + expect(data.item).toHaveProperty('context.maxValues.0', 10); + expect(data.item).toHaveProperty('context.maxValues.1', 11); + expect(data.item).toHaveProperty('context.maxValues.2', 12); + }); + }); +}); diff --git a/src/entities/activity/ui/items/ActionPlan/activitiesPhrasalData.ts b/src/entities/activity/ui/items/ActionPlan/phrasalData.ts similarity index 88% rename from src/entities/activity/ui/items/ActionPlan/activitiesPhrasalData.ts rename to src/entities/activity/ui/items/ActionPlan/phrasalData.ts index 2cce76abe..67b409f68 100644 --- a/src/entities/activity/ui/items/ActionPlan/activitiesPhrasalData.ts +++ b/src/entities/activity/ui/items/ActionPlan/phrasalData.ts @@ -1,8 +1,8 @@ import { phrasalTemplateCompatibleResponseTypes } from '~/abstract/lib/constants'; import { ActivityItemType } from '~/entities/activity/lib'; -import { ActivityProgress } from '~/entities/applet/model'; +import { ItemRecord } from '~/entities/applet/model'; -export type ActivityPhrasalDataGenericContext = { +type ActivityPhrasalDataGenericContext = { itemResponseType: ActivityItemType; }; @@ -11,7 +11,7 @@ export type ActivityPhrasalDataSliderRowContext = ActivityPhrasalDataGenericCont maxValues: number[]; }; -export type ActivityPhrasalBaseData< +type ActivityPhrasalBaseData< TType extends string, TValue, TContext extends ActivityPhrasalDataGenericContext = ActivityPhrasalDataGenericContext, @@ -21,42 +21,37 @@ export type ActivityPhrasalBaseData< context: TContext; }; -export type ActivityPhrasalArrayFieldData = ActivityPhrasalBaseData<'array', string[]>; +type ActivityPhrasalArrayFieldData = ActivityPhrasalBaseData<'array', string[]>; -export type ActivityPhrasalItemizedArrayValue = Record; +type ActivityPhrasalItemizedArrayValue = Record; -export type ActivityPhrasalIndexedArrayFieldData = ActivityPhrasalBaseData< +type ActivityPhrasalIndexedArrayFieldData = ActivityPhrasalBaseData< 'indexed-array', ActivityPhrasalItemizedArrayValue >; -export type ActivityPhrasalIndexedMatrixValue = { +type ActivityPhrasalIndexedMatrixValue = { label: string; values: string[]; }; -export type ActivityPhrasalMatrixValue = { +type ActivityPhrasalMatrixValue = { byRow: ActivityPhrasalIndexedMatrixValue[]; byColumn: ActivityPhrasalIndexedMatrixValue[]; }; -export type ActivityPhrasalMatrixFieldData = ActivityPhrasalBaseData< - 'matrix', - ActivityPhrasalMatrixValue ->; +type ActivityPhrasalMatrixFieldData = ActivityPhrasalBaseData<'matrix', ActivityPhrasalMatrixValue>; -export type ActivityPhrasalData = +type ActivityPhrasalData = | ActivityPhrasalArrayFieldData | ActivityPhrasalIndexedArrayFieldData | ActivityPhrasalMatrixFieldData; export type ActivitiesPhrasalData = Record; -export const extractActivitiesPhrasalData = ( - activityProgress: ActivityProgress, -): ActivitiesPhrasalData => { +export const extractActivitiesPhrasalData = (items: ItemRecord[]): ActivitiesPhrasalData => { const data: Record = {}; - for (const item of activityProgress.items) { + for (const item of items) { if (!phrasalTemplateCompatibleResponseTypes.includes(item.responseType)) { continue; } diff --git a/src/entities/activity/ui/items/ItemPicker.tsx b/src/entities/activity/ui/items/ItemPicker.tsx index f248e9bba..2436474c8 100644 --- a/src/entities/activity/ui/items/ItemPicker.tsx +++ b/src/entities/activity/ui/items/ItemPicker.tsx @@ -5,6 +5,7 @@ import { MatrixCheckboxItem } from './Matrix/MatrixMultiSelectItem'; import { MatrixRadioItem } from './Matrix/MatrixSingleSelectItem'; import { SliderRows } from './Matrix/Slider'; import { ParagraphTextItem } from './ParagraphTextItem'; +import { PhrasalTemplateItem } from './PhrasalTemplateItem'; import { RadioItem } from './RadioItem'; import { SelectorItem } from './SelectorItem'; import { SliderItem } from './SliderItem'; @@ -129,6 +130,9 @@ export const ItemPicker = ({ item, onValueChange, isDisabled, replaceText }: Ite case 'sliderRows': return ; + case 'phrasalTemplate': + return ; + default: return <>; } diff --git a/src/entities/activity/ui/items/PhrasalTemplateItem.tsx b/src/entities/activity/ui/items/PhrasalTemplateItem.tsx new file mode 100644 index 000000000..2d7a1c80c --- /dev/null +++ b/src/entities/activity/ui/items/PhrasalTemplateItem.tsx @@ -0,0 +1,109 @@ +import { useMemo, useContext, useCallback, createRef, useState } from 'react'; + +import { Avatar, Button } from '@mui/material'; +import html2canvas from 'html2canvas'; + +import { Document as ActionPlanDocument } from './ActionPlan/Document'; +import { usePhrasalTemplateTranslation } from '../../lib/usePhrasalTemplateTranslation'; + +import downloadIconLight from '~/assets/download-icon-light.svg'; +import downloadIconDark from '~/assets/download-icon.svg'; +import { PhrasalTemplateItem as PhrasalTemplateItemType } from '~/entities/activity/lib'; +import { SurveyContext } from '~/features/PassSurvey'; +import { Theme } from '~/shared/constants'; +import { Markdown } from '~/shared/ui'; +import { Box, Text } from '~/shared/ui'; + +type PhrasalTemplateItemProps = { + item: PhrasalTemplateItemType; + replaceText: (value: string) => string; +}; + +export const PhrasalTemplateItem = ({ item, replaceText }: PhrasalTemplateItemProps) => { + const { appletDisplayName } = useContext(SurveyContext); + const [downloadIcon, setDownloadIcon] = useState(downloadIconDark); + const questionText = useMemo(() => replaceText(item.question), [item.question, replaceText]); + const ref = createRef(); + const { t } = usePhrasalTemplateTranslation(); + + const handleDownloadImage = useCallback(async () => { + if (!ref.current) { + return; + } + + const element = ref.current; + const canvas = await html2canvas(element, { useCORS: true }); + + const data = canvas.toDataURL('image/png'); + const link = document.createElement('a'); + + link.href = data; + link.download = `${t('filename', { appletName: appletDisplayName })}.png`; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }, [ref, t, appletDisplayName]); + + return ( + + + + {t('title')} + + {questionText && questionText.trim().length > 0 ? ( + + + + ) : null} + + + + + ); +}; diff --git a/src/entities/applet/model/types.ts b/src/entities/applet/model/types.ts index 395d96bb6..9b2c7abff 100644 --- a/src/entities/applet/model/types.ts +++ b/src/entities/applet/model/types.ts @@ -20,6 +20,7 @@ import { ParagraphTextItem, TimeItem, TimeRangeItem, + PhrasalTemplateItem, } from '~/entities/activity/lib'; import { ScoreAndReports } from '~/shared/api'; import { DayMonthYearDTO, HourMinuteDTO } from '~/shared/utils'; @@ -77,7 +78,8 @@ export type ItemRecord = | AudioPlayerItem | MultiSelectionRowsItem | SingleSelectionRowsItem - | SliderRowsItem; + | SliderRowsItem + | PhrasalTemplateItem; export type ItemWithAdditionalResponse = Extract< ItemRecord, diff --git a/src/i18n/en/translation.json b/src/i18n/en/translation.json index 214250605..81d5f2cfc 100644 --- a/src/i18n/en/translation.json +++ b/src/i18n/en/translation.json @@ -251,6 +251,16 @@ "or": "or", "signUp": "Sign Up" }, + "PhrasalTemplate": { + "title": "Here's your plan", + "download": "Download", + "filename": "{{appletName}} Action Plan" + }, + "ActionPlan": { + "credit": "MindLogger Action Plan", + "questionSkipped": "(Question Skipped)", + "sliderValue": "{{value}} out of {{total}}" + }, "submit": "Submit", "submitAnswerModalTitle": "Submit answer?", "submitAnswerModalDescription": "If you’re ready, send us your answers.", diff --git a/src/i18n/fr/translation.json b/src/i18n/fr/translation.json index 2879a7ae1..6d1f817e0 100644 --- a/src/i18n/fr/translation.json +++ b/src/i18n/fr/translation.json @@ -270,6 +270,16 @@ "or": "ou", "signUp": "S'inscrire" }, + "PhrasalTemplate": { + "title": "Voici votre plan", + "download": "Télécharger", + "filename": "Plan d'action {{appletName}}" + }, + "ActionPlan": { + "credit": "Plan d'action MindLogger", + "questionSkipped": "(Question ignorée)", + "sliderValue": "{{value}} sur {{total}}" + }, "submit": "Soumettre", "goBack": "Revenir En Arrière", "submitAnswerModalTitle": "Soumettre une réponse?", diff --git a/src/shared/constants/theme.ts b/src/shared/constants/theme.ts index a3077f402..be07f6b76 100644 --- a/src/shared/constants/theme.ts +++ b/src/shared/constants/theme.ts @@ -10,6 +10,7 @@ export const Theme = { primary008: 'rgba(0, 103, 160, 0.08)', primary012: 'rgba(0, 103, 160, 0.12)', primary95: '#E8F2FF', + primaryFixed: '#CEE5FF', secondary: '#51606F', secondaryContainer: '#D5E4F7', secondaryContainerHover: '#C5D4E7', diff --git a/src/shared/utils/chunkArray.ts b/src/shared/utils/chunkArray.ts new file mode 100644 index 000000000..64ef15d0f --- /dev/null +++ b/src/shared/utils/chunkArray.ts @@ -0,0 +1,14 @@ +function chunkArray(arr: T[], size: number): T[][] { + if (size <= 0) { + throw new Error('Size should be greater than zero'); + } + + const result: T[][] = []; + for (let i = 0; i < arr.length; i += size) { + result.push(arr.slice(i, i + size)); + } + + return result; +} + +export default chunkArray; diff --git a/src/shared/utils/getWindowDimensions.ts b/src/shared/utils/getWindowDimensions.ts new file mode 100644 index 000000000..23927b881 --- /dev/null +++ b/src/shared/utils/getWindowDimensions.ts @@ -0,0 +1,9 @@ +function getWindowDimensions() { + const { innerWidth: width, innerHeight: height } = window; + return { + width, + height, + }; +} + +export default getWindowDimensions; diff --git a/src/shared/utils/measureComponentHeight.tsx b/src/shared/utils/measureComponentHeight.tsx new file mode 100644 index 000000000..5dc8cbc26 --- /dev/null +++ b/src/shared/utils/measureComponentHeight.tsx @@ -0,0 +1,25 @@ +import React from 'react'; + +import { createRoot } from 'react-dom/client'; + +async function measureComponentHeight(parentWidth: number, Component: React.FC) { + const tempContainer = document.createElement('div'); + tempContainer.style.visibility = 'hidden'; + tempContainer.style.width = `${parentWidth}px`; + document.body.appendChild(tempContainer); + const tempRoot = createRoot(tempContainer); + tempRoot.render(); + return await new Promise((resolve) => { + const observer = new MutationObserver(() => { + const measuredHeight = tempContainer.getBoundingClientRect().height; + resolve(measuredHeight); + tempRoot.unmount(); + document.body.removeChild(tempContainer); + observer.disconnect(); + }); + + observer.observe(tempContainer, { childList: true, subtree: true }); + }); +} + +export default measureComponentHeight; diff --git a/src/widgets/Survey/model/hooks/useSurveyState.ts b/src/widgets/Survey/model/hooks/useSurveyState.ts index 0b2e93ded..dac39f164 100644 --- a/src/widgets/Survey/model/hooks/useSurveyState.ts +++ b/src/widgets/Survey/model/hooks/useSurveyState.ts @@ -2,11 +2,25 @@ import { useMemo } from 'react'; import { appletModel } from '~/entities/applet'; import { PassSurveyModel } from '~/features/PassSurvey'; +import { useFeatureFlags } from '~/shared/utils/hooks/useFeatureFlags'; +import { FeatureFlag } from '~/shared/utils/types/featureFlags'; export const useSurveyState = (activityProgress: appletModel.ActivityProgress) => { + const { featureFlag } = useFeatureFlags(); + const items = useMemo(() => activityProgress?.items ?? [], [activityProgress.items]); - const availableItems = items.filter((x) => !x.isHidden); + const availableItems = items.filter((item) => { + if (item.isHidden) { + return false; + } + + if (item.responseType === 'phrasalTemplate') { + return featureFlag(FeatureFlag.EnablePhrasalTemplate, false); + } + + return true; + }); const { visibleItems, hiddenItemIds } = PassSurveyModel.conditionalLogicFilter.filter(availableItems); From e3e4ebd527ebf04fa47545bdb91b63667055bbcf Mon Sep 17 00:00:00 2001 From: Billie He Date: Thu, 5 Sep 2024 11:56:29 -0700 Subject: [PATCH 12/26] fix: date/time serialization related test failures --- .../ui/items/ActionPlan/phrasalData.test.ts | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/src/entities/activity/ui/items/ActionPlan/phrasalData.test.ts b/src/entities/activity/ui/items/ActionPlan/phrasalData.test.ts index 3c138f3ab..94c0b3f04 100644 --- a/src/entities/activity/ui/items/ActionPlan/phrasalData.test.ts +++ b/src/entities/activity/ui/items/ActionPlan/phrasalData.test.ts @@ -88,39 +88,36 @@ describe('Action Plan', () => { }); it('should extract data from `date` activity type', () => { - const data = extractActivitiesPhrasalData([ - newDateItem('item', ['Fri Sep 06 2024 00:00:00 GMT-0700 (Pacific Daylight Time)']), - ]); + const date = new Date('Fri Sep 06 2024 00:00:00 GMT-0700 (Pacific Daylight Time)'); + const data = extractActivitiesPhrasalData([newDateItem('item', [date.toString()])]); expect(data).toHaveProperty('item'); expect(data.item).toHaveProperty('type', 'array'); - expect(data.item).toHaveProperty('values.0', '2024-09-06'); + expect(data.item).toHaveProperty('values.0', date.toLocaleDateString()); expect(data.item).toHaveProperty('context.itemResponseType', 'date'); }); it('should extract data from `time` activity type', () => { - const data = extractActivitiesPhrasalData([ - newTimeItem('item', ['Wed Sep 04 2024 00:05:00 GMT-0700 (Pacific Daylight Time)']), - ]); + const date = new Date('Wed Sep 04 2024 00:05:00 GMT-0700 (Pacific Daylight Time)'); + const data = extractActivitiesPhrasalData([newTimeItem('item', [date.toString()])]); expect(data).toHaveProperty('item'); expect(data.item).toHaveProperty('type', 'array'); - expect(data.item).toHaveProperty('values.0', expect.stringContaining('12:05:00')); + expect(data.item).toHaveProperty('values.0', date.toLocaleTimeString()); expect(data.item).toHaveProperty('context.itemResponseType', 'time'); }); it('should extract data from `timeRange` activity type', () => { + const date1 = new Date('Wed Sep 04 2024 00:05:00 GMT-0700 (Pacific Daylight Time)'); + const date2 = new Date('Wed Sep 04 2024 00:15:00 GMT-0700 (Pacific Daylight Time)'); const data = extractActivitiesPhrasalData([ - newTimeRangeItem('item', [ - 'Wed Sep 04 2024 00:05:00 GMT-0700 (Pacific Daylight Time)', - 'Wed Sep 04 2024 00:15:00 GMT-0700 (Pacific Daylight Time)', - ]), + newTimeRangeItem('item', [date1.toString(), date2.toString()]), ]); expect(data).toHaveProperty('item'); expect(data.item).toHaveProperty('type', 'array'); - expect(data.item).toHaveProperty('values.0', expect.stringContaining('12:05:00')); - expect(data.item).toHaveProperty('values.1', expect.stringContaining('12:15:00')); + expect(data.item).toHaveProperty('values.0', date1.toLocaleTimeString()); + expect(data.item).toHaveProperty('values.1', date2.toLocaleTimeString()); expect(data.item).toHaveProperty('context.itemResponseType', 'timeRange'); }); From 2d85cca8b8e099682df18dbe4030d9ef4f45961e Mon Sep 17 00:00:00 2001 From: Billie He Date: Thu, 5 Sep 2024 12:49:28 -0700 Subject: [PATCH 13/26] chore: add custom NoInfer type --- src/shared/utils/hooks/useFeatureFlags.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/shared/utils/hooks/useFeatureFlags.ts b/src/shared/utils/hooks/useFeatureFlags.ts index 0b9278bca..874d88a5f 100644 --- a/src/shared/utils/hooks/useFeatureFlags.ts +++ b/src/shared/utils/hooks/useFeatureFlags.ts @@ -5,6 +5,11 @@ import { useFlags } from 'launchdarkly-react-client-sdk'; import { LaunchDarkyFlagsMap, FeatureFlag, FeatureFlagType } from '../types/featureFlags'; +// The `NoInfer` generic is not available in the version of TypeScript used by +// this repo. So we have to just put a homemade version here. +// eslint-disable-next-line unused-imports/no-unused-vars +type NoInfer = intrinsic; + /** * Internal wrapper for LaunchDarkly's hooks and flags. */ From 865b1699a18a2afffc986dccc14a3a6a283da0ce Mon Sep 17 00:00:00 2001 From: Billie He Date: Thu, 5 Sep 2024 12:49:41 -0700 Subject: [PATCH 14/26] fix: filter predicate function return type --- src/entities/activity/ui/items/ActionPlan/phrasalData.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/entities/activity/ui/items/ActionPlan/phrasalData.ts b/src/entities/activity/ui/items/ActionPlan/phrasalData.ts index 67b409f68..50ba98406 100644 --- a/src/entities/activity/ui/items/ActionPlan/phrasalData.ts +++ b/src/entities/activity/ui/items/ActionPlan/phrasalData.ts @@ -108,7 +108,7 @@ export const extractActivitiesPhrasalData = (items: ItemRecord[]): ActivitiesPhr label: row.rowName, values: item.answer[rowIndex]?.filter( - (value) => value !== null && value !== undefined, + (value): value is string => value !== null && value !== undefined, ) || [], }; }, From aaeba51ecd387543f856d1bdb9be41681803a2c9 Mon Sep 17 00:00:00 2001 From: Billie He Date: Thu, 5 Sep 2024 12:52:50 -0700 Subject: [PATCH 15/26] fix: custom NoInfer definition --- src/shared/utils/hooks/useFeatureFlags.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/shared/utils/hooks/useFeatureFlags.ts b/src/shared/utils/hooks/useFeatureFlags.ts index 874d88a5f..90651f665 100644 --- a/src/shared/utils/hooks/useFeatureFlags.ts +++ b/src/shared/utils/hooks/useFeatureFlags.ts @@ -7,8 +7,8 @@ import { LaunchDarkyFlagsMap, FeatureFlag, FeatureFlagType } from '../types/feat // The `NoInfer` generic is not available in the version of TypeScript used by // this repo. So we have to just put a homemade version here. -// eslint-disable-next-line unused-imports/no-unused-vars -type NoInfer = intrinsic; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type NoInfer = [T][T extends any ? 0 : never]; /** * Internal wrapper for LaunchDarkly's hooks and flags. From 958ed6d8ceebb80471f0755af0f3d0a17597587c Mon Sep 17 00:00:00 2001 From: Billie He Date: Fri, 6 Sep 2024 09:53:56 -0700 Subject: [PATCH 16/26] fix: skilled question label casing --- src/i18n/en/translation.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/i18n/en/translation.json b/src/i18n/en/translation.json index 81d5f2cfc..6ef1bb5ca 100644 --- a/src/i18n/en/translation.json +++ b/src/i18n/en/translation.json @@ -258,7 +258,7 @@ }, "ActionPlan": { "credit": "MindLogger Action Plan", - "questionSkipped": "(Question Skipped)", + "questionSkipped": "(Question skipped)", "sliderValue": "{{value}} out of {{total}}" }, "submit": "Submit", From 9b4e3bea500192a58281aa6849d99746287b2343 Mon Sep 17 00:00:00 2001 From: Billie He Date: Fri, 6 Sep 2024 09:55:58 -0700 Subject: [PATCH 17/26] fix: error message spelling --- src/entities/activity/ui/items/ActionPlan/ResponseSegment.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/entities/activity/ui/items/ActionPlan/ResponseSegment.tsx b/src/entities/activity/ui/items/ActionPlan/ResponseSegment.tsx index 24d965dcf..c0ef10c60 100644 --- a/src/entities/activity/ui/items/ActionPlan/ResponseSegment.tsx +++ b/src/entities/activity/ui/items/ActionPlan/ResponseSegment.tsx @@ -112,7 +112,7 @@ export const ResponseSegment = ({ phrasalData, field }: ResponseSegmentProps) => // This also shouldn't happen. But including a `else` here allows all // previous branches to have explicitly defined condition, so it's more // clear this way. - throw new Error(`Invalie phrasal data type: ${fieldPhrasalDataType}`); + throw new Error(`Invalid phrasal data type: ${fieldPhrasalDataType}`); } return ( From 3a452e808ebccfa1b3b98e99f66562e71d70a066 Mon Sep 17 00:00:00 2001 From: Billie He Date: Mon, 9 Sep 2024 12:41:03 -0700 Subject: [PATCH 18/26] fix: leading space logic for words --- .../activity/ui/items/ActionPlan/Phrase.tsx | 35 +++++++++++++------ .../ui/items/ActionPlan/ResponseSegment.tsx | 8 +++-- .../ui/items/ActionPlan/TextSegment.tsx | 5 +-- 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/src/entities/activity/ui/items/ActionPlan/Phrase.tsx b/src/entities/activity/ui/items/ActionPlan/Phrase.tsx index 17adee81d..2a55c788c 100644 --- a/src/entities/activity/ui/items/ActionPlan/Phrase.tsx +++ b/src/entities/activity/ui/items/ActionPlan/Phrase.tsx @@ -6,7 +6,7 @@ import { useXScaledDimension, useYScaledDimension } from './hooks'; import { ActivitiesPhrasalData } from './phrasalData'; import { ResponseSegment } from './ResponseSegment'; import { TextSegment } from './TextSegment'; -import { PhrasalTemplatePhrase } from '../../../lib'; +import { PhrasalTemplatePhrase, PhrasalTemplateField } from '../../../lib'; import { Theme } from '~/shared/constants'; import Box from '~/shared/ui/Box'; @@ -28,16 +28,29 @@ export const Phrase = ({ phrase, phrasalData, noImage }: PhraseProps) => { const fontSize = useXScaledDimension(16); const lineHeight = useYScaledDimension(24); - const components = phrase.fields.reduce((acc, field) => { - if (field.type === 'sentence') { - acc.push(); - } else if (field.type === 'item_response') { - acc.push(); - } else if (field.type === 'line_break') { - acc.push(
    ); - } - return acc; - }, [] as React.ReactNode[]); + const { components } = phrase.fields.reduce( + (acc, field, fieldIndex) => { + const isLineStart = fieldIndex === 0 || acc.prevField?.type === 'line_break'; + const leadingSpace = !isLineStart; + + if (field.type === 'sentence') { + acc.components.push(); + } else if (field.type === 'item_response') { + acc.components.push( + , + ); + } else if (field.type === 'line_break') { + acc.components.push(
    ); + } + + acc.prevField = field; + return acc; + }, + { components: [], prevField: undefined } as { + components: React.ReactNode[]; + prevField?: PhrasalTemplateField; + }, + ); return ( diff --git a/src/entities/activity/ui/items/ActionPlan/ResponseSegment.tsx b/src/entities/activity/ui/items/ActionPlan/ResponseSegment.tsx index c0ef10c60..08fb911e9 100644 --- a/src/entities/activity/ui/items/ActionPlan/ResponseSegment.tsx +++ b/src/entities/activity/ui/items/ActionPlan/ResponseSegment.tsx @@ -30,9 +30,10 @@ const joinWithComma: FieldValuesJoiner = (values) => values.join(', '); type ResponseSegmentProps = { phrasalData: ActivitiesPhrasalData; field: PhrasalTemplateItemResponseField; + leadingSpace?: boolean; }; -export const ResponseSegment = ({ phrasalData, field }: ResponseSegmentProps) => { +export const ResponseSegment = ({ phrasalData, field, leadingSpace }: ResponseSegmentProps) => { const { t } = useActionPlanTranslation(); const listPadding = useXScaledDimension(40); @@ -132,7 +133,10 @@ export const ResponseSegment = ({ phrasalData, field }: ResponseSegmentProps) => ) : ( - <> {joinSentenceWords(words)}  + <> + {leadingSpace ? ' ' : ''} + {joinSentenceWords(words)} + )} ); diff --git a/src/entities/activity/ui/items/ActionPlan/TextSegment.tsx b/src/entities/activity/ui/items/ActionPlan/TextSegment.tsx index f37c92e36..975e711db 100644 --- a/src/entities/activity/ui/items/ActionPlan/TextSegment.tsx +++ b/src/entities/activity/ui/items/ActionPlan/TextSegment.tsx @@ -1,10 +1,11 @@ import Text from '~/shared/ui/Text'; -type TextSegmentProps = { text: string }; +type TextSegmentProps = { text: string; leadingSpace?: boolean }; -export const TextSegment = ({ text }: TextSegmentProps) => { +export const TextSegment = ({ text, leadingSpace }: TextSegmentProps) => { return ( + {leadingSpace ? ' ' : ''} {text} ); From 2bd7fa2e8997c9c52538c5ad840927433659fef2 Mon Sep 17 00:00:00 2001 From: Billie He Date: Mon, 9 Sep 2024 12:44:11 -0700 Subject: [PATCH 19/26] fix: launchdarkly import statement --- src/shared/utils/hooks/useFeatureFlags.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/shared/utils/hooks/useFeatureFlags.ts b/src/shared/utils/hooks/useFeatureFlags.ts index 90651f665..8f5a6974d 100644 --- a/src/shared/utils/hooks/useFeatureFlags.ts +++ b/src/shared/utils/hooks/useFeatureFlags.ts @@ -1,7 +1,6 @@ import { useMemo } from 'react'; -import { LDFlagValue } from 'launchdarkly-react-client-sdk'; -import { useFlags } from 'launchdarkly-react-client-sdk'; +import { LDFlagValue, useFlags } from 'launchdarkly-react-client-sdk'; import { LaunchDarkyFlagsMap, FeatureFlag, FeatureFlagType } from '../types/featureFlags'; From 7dc6cc17f034168e50f24a58e574143cffaeb538 Mon Sep 17 00:00:00 2001 From: Billie He Date: Mon, 9 Sep 2024 12:49:27 -0700 Subject: [PATCH 20/26] fix: footer logo positioning --- src/entities/activity/ui/items/ActionPlan/Page.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/entities/activity/ui/items/ActionPlan/Page.tsx b/src/entities/activity/ui/items/ActionPlan/Page.tsx index 9019501e1..94f2965a0 100644 --- a/src/entities/activity/ui/items/ActionPlan/Page.tsx +++ b/src/entities/activity/ui/items/ActionPlan/Page.tsx @@ -58,6 +58,8 @@ export const Page = ({ > {appletTitle} {t('credit') From 7c29c3a054b13a10c463858029f5a841a7e007b0 Mon Sep 17 00:00:00 2001 From: Billie He Date: Mon, 9 Sep 2024 18:32:41 -0700 Subject: [PATCH 21/26] chore: split card within phrases --- public/action-plan-page-background.svg | 5 - .../activity/ui/items/ActionPlan/Document.tsx | 141 ++++++++++++------ .../activity/ui/items/ActionPlan/Page.tsx | 54 ++++--- .../activity/ui/items/ActionPlan/Phrase.tsx | 5 +- .../ui/items/ActionPlan/ResponseSegment.tsx | 22 ++- .../ui/items/ActionPlan/TextSegment.tsx | 6 +- .../activity/ui/items/ActionPlan/hooks.tsx | 31 +--- src/shared/ui/Box/index.tsx | 8 +- src/shared/utils/measureComponentHeight.tsx | 9 +- 9 files changed, 168 insertions(+), 113 deletions(-) delete mode 100644 public/action-plan-page-background.svg diff --git a/public/action-plan-page-background.svg b/public/action-plan-page-background.svg deleted file mode 100644 index b736ee4c7..000000000 --- a/public/action-plan-page-background.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - diff --git a/src/entities/activity/ui/items/ActionPlan/Document.tsx b/src/entities/activity/ui/items/ActionPlan/Document.tsx index 61803b813..2d2eb488e 100644 --- a/src/entities/activity/ui/items/ActionPlan/Document.tsx +++ b/src/entities/activity/ui/items/ActionPlan/Document.tsx @@ -1,12 +1,12 @@ import React, { forwardRef, useEffect, useState, useContext, useMemo, useCallback } from 'react'; import { Box } from '@mui/material'; +import { v4 as uuidV4 } from 'uuid'; import { DocumentContext } from './DocumentContext'; -import { useAvailableBodyHeight, useAvailableBodyWidth } from './hooks'; +import { useAvailableBodyWidth, usePageMaxHeight } from './hooks'; import { Page } from './Page'; import { extractActivitiesPhrasalData } from './phrasalData'; -import { Phrase } from './Phrase'; import { getProgressId } from '~/abstract/lib'; import { PhrasalTemplatePhrase } from '~/entities/activity/lib'; @@ -21,10 +21,28 @@ type DocumentProps = { phrasalTemplateCardTitle: string; }; +type IdentifiblePhrasalTemplatePhrase = PhrasalTemplatePhrase & { id: string }; + export const Document = forwardRef( ({ appletTitle, phrases, phrasalTemplateCardTitle }, ref) => { const context = useContext(SurveyContext); + const noImage = useMemo( + () => phrases.filter((phrase) => !!phrase.image).length <= 0, + [phrases], + ); + + const identifiblePhrases = useMemo( + () => + phrases.map((phrase) => { + return { + ...phrase, + id: uuidV4(), + }; + }), + [phrases], + ); + const activityProgress = useAppSelector((state) => appletModel.selectors.selectActivityProgress( state, @@ -37,64 +55,95 @@ export const Document = forwardRef( [activityProgress], ); - const noImage = phrases.filter((phrase) => !!phrase.image).length <= 0; + const availableWidth = useAvailableBodyWidth(); + const pageMaxHeight = usePageMaxHeight(); const [pages, setPages] = useState([]); - const availableHeight = useAvailableBodyHeight(phrasalTemplateCardTitle); - const availableWidth: number = (useAvailableBodyWidth as () => number)(); - const gap = 32; const renderPages = useCallback(async () => { - let runningHeight = 0; - let pagePhrases: PhrasalTemplatePhrase[] = []; - const _pages: React.ReactNode[] = []; - - for (const phrase of phrases) { - const height = await measureComponentHeight(availableWidth, () => ( - - )); - const pageNumber = _pages.length + 1; - - if (runningHeight + height <= availableHeight) { - runningHeight += height + gap; - pagePhrases.push(phrase); - } else { - _pages.push( - , - ); - runningHeight = height + gap; - pagePhrases = [phrase]; - } - } + const renderedPages: React.ReactNode[] = []; + + const renderPage = async ( + pagePhrases: IdentifiblePhrasalTemplatePhrase[], + ): Promise<[React.ReactNode, IdentifiblePhrasalTemplatePhrase[]]> => { + const curPageNumber = renderedPages.length + 1; - if (pagePhrases.length > 0) { - _pages.push( + const curPage = ( , + noImage={noImage} + /> ); - } - setPages(_pages); + const pageHeight = await measureComponentHeight(availableWidth, curPage); + if (pageHeight <= pageMaxHeight) { + return [curPage, []]; + } else { + if (pagePhrases.length <= 1) { + const pagePhrase = pagePhrases[0]; + const pagePhraseFields = pagePhrase.fields; + + const splits: [IdentifiblePhrasalTemplatePhrase, IdentifiblePhrasalTemplatePhrase] = [ + { + id: pagePhrase.id, + image: pagePhrase.image, + fields: pagePhraseFields.slice(0, pagePhraseFields.length - 1), + }, + { + id: pagePhrase.id, + image: pagePhrase.image, + fields: pagePhraseFields.slice(pagePhraseFields.length - 1), + }, + ]; + + const [newPage, newPageRestPhrases] = await renderPage([splits[0]]); + const leftoverPhrases = [...newPageRestPhrases, splits[1]]; + return [newPage, leftoverPhrases]; + } else { + const newPagePhrases = pagePhrases.slice(0, pagePhrases.length - 1); + const curPageRestPhrases = pagePhrases.slice(pagePhrases.length - 1); + const [newPage, newPageRestPhrases] = await renderPage(newPagePhrases); + const leftoverPhrases = [...newPageRestPhrases, ...curPageRestPhrases]; + + const recombinedLeftoverPhrases = leftoverPhrases.reduce((acc, phrase) => { + const existingPhrase = acc.find(({ id }) => id === phrase.id); + if (existingPhrase) { + existingPhrase.fields = [...existingPhrase.fields, ...phrase.fields]; + } else { + acc.push(phrase); + } + return acc; + }, [] as IdentifiblePhrasalTemplatePhrase[]); + + return [newPage, recombinedLeftoverPhrases]; + } + } + }; + + const _renderPages = async (_pagePhrases: IdentifiblePhrasalTemplatePhrase[]) => { + const [renderedPage, leftoverPhrases] = await renderPage(_pagePhrases); + renderedPages.push(renderedPage); + + if (leftoverPhrases.length > 0) { + await _renderPages(leftoverPhrases); + } + }; + + await _renderPages(identifiblePhrases); + + setPages(renderedPages); }, [ - noImage, - appletTitle, - phrasalTemplateCardTitle, - phrases, activitiesPhrasalData, + appletTitle, availableWidth, - availableHeight, + pageMaxHeight, + phrasalTemplateCardTitle, + identifiblePhrases, + noImage, ]); useEffect(() => { diff --git a/src/entities/activity/ui/items/ActionPlan/Page.tsx b/src/entities/activity/ui/items/ActionPlan/Page.tsx index 94f2965a0..226ae0a37 100644 --- a/src/entities/activity/ui/items/ActionPlan/Page.tsx +++ b/src/entities/activity/ui/items/ActionPlan/Page.tsx @@ -3,12 +3,7 @@ import { useContext } from 'react'; import { Body } from './Body'; import { DocumentContext } from './DocumentContext'; import { Header } from './Header'; -import { - usePDFPageWidth, - useXScaledDimension, - useBackgroundHeight, - useBackgroundWidth, -} from './hooks'; +import { usePageWidth, usePageMaxHeight, useXScaledDimension } from './hooks'; import { ActivitiesPhrasalData } from './phrasalData'; import { Phrase } from './Phrase'; import { Title } from './Title'; @@ -25,6 +20,7 @@ type PageProps = { phrasalData: ActivitiesPhrasalData; appletTitle: string; phrasalTemplateCardTitle: string; + noImage: boolean; }; export const Page = ({ @@ -33,20 +29,18 @@ export const Page = ({ phrases, phrasalData, pageNumber, + noImage, }: PageProps) => { const { totalPages } = useContext(DocumentContext); const { t } = useActionPlanTranslation(); - const pageWidth = usePDFPageWidth(); - const width = useBackgroundWidth(); - const height = useBackgroundHeight(); + const pageWidth = usePageWidth(); + const pageMaxHeight = usePageMaxHeight(); const scaledPadding = useXScaledDimension(16); const scaledTopPadding = useXScaledDimension(28); const scaledRightPadding = useXScaledDimension(40); const scaledBottomPadding = useXScaledDimension(40); const scaledLeftPadding = useXScaledDimension(36.5); - const noImage = phrases.filter((phrase) => !!phrase.image).length <= 0; - return ( + + + +
    {totalPages > 1 @@ -108,3 +122,5 @@ export const Page = ({ ); }; + +Page.displayName = 'Page'; diff --git a/src/entities/activity/ui/items/ActionPlan/Phrase.tsx b/src/entities/activity/ui/items/ActionPlan/Phrase.tsx index 2a55c788c..50068c6c6 100644 --- a/src/entities/activity/ui/items/ActionPlan/Phrase.tsx +++ b/src/entities/activity/ui/items/ActionPlan/Phrase.tsx @@ -31,13 +31,12 @@ export const Phrase = ({ phrase, phrasalData, noImage }: PhraseProps) => { const { components } = phrase.fields.reduce( (acc, field, fieldIndex) => { const isLineStart = fieldIndex === 0 || acc.prevField?.type === 'line_break'; - const leadingSpace = !isLineStart; if (field.type === 'sentence') { - acc.components.push(); + acc.components.push(); } else if (field.type === 'item_response') { acc.components.push( - , + , ); } else if (field.type === 'line_break') { acc.components.push(
    ); diff --git a/src/entities/activity/ui/items/ActionPlan/ResponseSegment.tsx b/src/entities/activity/ui/items/ActionPlan/ResponseSegment.tsx index 08fb911e9..1e61579b3 100644 --- a/src/entities/activity/ui/items/ActionPlan/ResponseSegment.tsx +++ b/src/entities/activity/ui/items/ActionPlan/ResponseSegment.tsx @@ -30,10 +30,10 @@ const joinWithComma: FieldValuesJoiner = (values) => values.join(', '); type ResponseSegmentProps = { phrasalData: ActivitiesPhrasalData; field: PhrasalTemplateItemResponseField; - leadingSpace?: boolean; + isAtStart?: boolean; }; -export const ResponseSegment = ({ phrasalData, field, leadingSpace }: ResponseSegmentProps) => { +export const ResponseSegment = ({ phrasalData, field, isAtStart }: ResponseSegmentProps) => { const { t } = useActionPlanTranslation(); const listPadding = useXScaledDimension(40); @@ -122,11 +122,17 @@ export const ResponseSegment = ({ phrasalData, field, leadingSpace }: ResponseSe fieldDisplayMode === 'bullet_list_option_row' || fieldDisplayMode === 'bullet_list_text_row' ? ( <> - -   -
    -
    - + {isAtStart ? null : ( + +   +
    +
    + )} + {words.map((item, index) => (
  • {item}
  • ))} @@ -134,7 +140,7 @@ export const ResponseSegment = ({ phrasalData, field, leadingSpace }: ResponseSe ) : ( <> - {leadingSpace ? ' ' : ''} + {isAtStart ? '' : ' '} {joinSentenceWords(words)} )} diff --git a/src/entities/activity/ui/items/ActionPlan/TextSegment.tsx b/src/entities/activity/ui/items/ActionPlan/TextSegment.tsx index 975e711db..40f8c05f4 100644 --- a/src/entities/activity/ui/items/ActionPlan/TextSegment.tsx +++ b/src/entities/activity/ui/items/ActionPlan/TextSegment.tsx @@ -1,11 +1,11 @@ import Text from '~/shared/ui/Text'; -type TextSegmentProps = { text: string; leadingSpace?: boolean }; +type TextSegmentProps = { text: string; isAtStart?: boolean }; -export const TextSegment = ({ text, leadingSpace }: TextSegmentProps) => { +export const TextSegment = ({ text, isAtStart }: TextSegmentProps) => { return ( - {leadingSpace ? ' ' : ''} + {isAtStart ? '' : ' '} {text} ); diff --git a/src/entities/activity/ui/items/ActionPlan/hooks.tsx b/src/entities/activity/ui/items/ActionPlan/hooks.tsx index f7dd5dbbb..cd6c71459 100644 --- a/src/entities/activity/ui/items/ActionPlan/hooks.tsx +++ b/src/entities/activity/ui/items/ActionPlan/hooks.tsx @@ -1,10 +1,8 @@ import { useCallback, useEffect, useState } from 'react'; -import { Header } from './Header'; import { PageDimension } from './pageDimension'; import getWindowDimensions from '~/shared/utils/getWindowDimensions'; -import measureComponentHeight from '~/shared/utils/measureComponentHeight'; export const useWindowDimensions = () => { const [windowDimensions, setWindowDimensions] = useState(getWindowDimensions()); @@ -21,13 +19,17 @@ export const useWindowDimensions = () => { return windowDimensions; }; -export const usePDFPageWidth = () => { +export const usePageWidth = () => { const { width: windowWidth } = useWindowDimensions(); return Math.min(windowWidth - PageDimension.padding, PageDimension.maxWidth); }; +export const usePageMaxHeight = () => { + return 512; +}; + export const useXScaledDimension = (dimension: number) => { - const pageWidth = usePDFPageWidth(); + const pageWidth = usePageWidth(); return (pageWidth / PageDimension.maxWidth) * dimension; }; @@ -47,27 +49,6 @@ export const useAvailableBodyWidth = () => { return width - scaledRightPadding - scaledLeftPadding; }; -export const useAvailableBodyHeight = (headerPrefix: string) => { - const availableWidth = useAvailableBodyWidth(); - const height = useBackgroundHeight(); - const scaledTopPadding = useXScaledDimension(28); - const scaledBottomPadding = useXScaledDimension(40); - const [availableHeight, setAvailableHeight] = useState(height); - - const calculateAvailableHeight = useCallback(async () => { - const headerHeight = await measureComponentHeight(availableWidth, () => ( -
    {headerPrefix}
    - )); - setAvailableHeight(height - headerHeight - 24 - scaledTopPadding - scaledBottomPadding); - }, [availableWidth, headerPrefix, height, scaledBottomPadding, scaledTopPadding]); - - useEffect(() => { - void calculateAvailableHeight(); - }, [calculateAvailableHeight]); - - return availableHeight; -}; - export const useYScaledDimension = (dimension: number) => { const height = useBackgroundHeight(); return (height / 760) * dimension; diff --git a/src/shared/ui/Box/index.tsx b/src/shared/ui/Box/index.tsx index 70325ebc5..7815c55cd 100644 --- a/src/shared/ui/Box/index.tsx +++ b/src/shared/ui/Box/index.tsx @@ -1,3 +1,5 @@ +import { forwardRef } from 'react'; + import MUIBox, { BoxProps } from '@mui/material/Box'; /** @@ -10,8 +12,10 @@ import MUIBox, { BoxProps } from '@mui/material/Box'; * ) */ -function Box(props: BoxProps) { +const Box = forwardRef((props: BoxProps) => { return ; -} +}); + +Box.displayName = 'Box'; export default Box; diff --git a/src/shared/utils/measureComponentHeight.tsx b/src/shared/utils/measureComponentHeight.tsx index 5dc8cbc26..533b5e39e 100644 --- a/src/shared/utils/measureComponentHeight.tsx +++ b/src/shared/utils/measureComponentHeight.tsx @@ -2,17 +2,22 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; -async function measureComponentHeight(parentWidth: number, Component: React.FC) { +// TODO: Check to make sure this is still needed +async function measureComponentHeight(parentWidth: number, component: React.ReactNode) { const tempContainer = document.createElement('div'); tempContainer.style.visibility = 'hidden'; tempContainer.style.width = `${parentWidth}px`; document.body.appendChild(tempContainer); + const tempRoot = createRoot(tempContainer); - tempRoot.render(); + tempRoot.render(component); + return await new Promise((resolve) => { const observer = new MutationObserver(() => { const measuredHeight = tempContainer.getBoundingClientRect().height; + resolve(measuredHeight); + tempRoot.unmount(); document.body.removeChild(tempContainer); observer.disconnect(); From 0aa47f6550f637a48df240cceae370e69afbb70e Mon Sep 17 00:00:00 2001 From: Billie He Date: Mon, 9 Sep 2024 18:33:58 -0700 Subject: [PATCH 22/26] chore: revert card max height change --- src/entities/activity/ui/items/ActionPlan/hooks.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/entities/activity/ui/items/ActionPlan/hooks.tsx b/src/entities/activity/ui/items/ActionPlan/hooks.tsx index cd6c71459..509b2471a 100644 --- a/src/entities/activity/ui/items/ActionPlan/hooks.tsx +++ b/src/entities/activity/ui/items/ActionPlan/hooks.tsx @@ -25,7 +25,7 @@ export const usePageWidth = () => { }; export const usePageMaxHeight = () => { - return 512; + return 2504; }; export const useXScaledDimension = (dimension: number) => { From 66c8e1f158c65a319e84591b15d2f56c389b1c61 Mon Sep 17 00:00:00 2001 From: Billie He Date: Tue, 10 Sep 2024 13:00:12 -0700 Subject: [PATCH 23/26] chore: text truncation --- .../activity/ui/items/ActionPlan/Body.tsx | 2 +- .../activity/ui/items/ActionPlan/Document.tsx | 93 +++++++++++-------- .../activity/ui/items/ActionPlan/Page.tsx | 13 +-- .../activity/ui/items/ActionPlan/Phrase.tsx | 19 +++- .../activity/ui/items/ActionPlan/hooks.tsx | 8 ++ 5 files changed, 86 insertions(+), 49 deletions(-) diff --git a/src/entities/activity/ui/items/ActionPlan/Body.tsx b/src/entities/activity/ui/items/ActionPlan/Body.tsx index 0d641d2e1..33b2e24d0 100644 --- a/src/entities/activity/ui/items/ActionPlan/Body.tsx +++ b/src/entities/activity/ui/items/ActionPlan/Body.tsx @@ -8,7 +8,7 @@ export const Body = ({ children }: PropsWithChildren) => { const gap = useXScaledDimension(32); return ( - + {children} ); diff --git a/src/entities/activity/ui/items/ActionPlan/Document.tsx b/src/entities/activity/ui/items/ActionPlan/Document.tsx index 2d2eb488e..8dda7923c 100644 --- a/src/entities/activity/ui/items/ActionPlan/Document.tsx +++ b/src/entities/activity/ui/items/ActionPlan/Document.tsx @@ -81,47 +81,64 @@ export const Document = forwardRef( const pageHeight = await measureComponentHeight(availableWidth, curPage); if (pageHeight <= pageMaxHeight) { + // If the rendered page fits into the maximum allowed page height, + // then stop rendering. return [curPage, []]; - } else { - if (pagePhrases.length <= 1) { - const pagePhrase = pagePhrases[0]; - const pagePhraseFields = pagePhrase.fields; - - const splits: [IdentifiblePhrasalTemplatePhrase, IdentifiblePhrasalTemplatePhrase] = [ - { - id: pagePhrase.id, - image: pagePhrase.image, - fields: pagePhraseFields.slice(0, pagePhraseFields.length - 1), - }, - { - id: pagePhrase.id, - image: pagePhrase.image, - fields: pagePhraseFields.slice(pagePhraseFields.length - 1), - }, - ]; - - const [newPage, newPageRestPhrases] = await renderPage([splits[0]]); - const leftoverPhrases = [...newPageRestPhrases, splits[1]]; - return [newPage, leftoverPhrases]; - } else { - const newPagePhrases = pagePhrases.slice(0, pagePhrases.length - 1); - const curPageRestPhrases = pagePhrases.slice(pagePhrases.length - 1); - const [newPage, newPageRestPhrases] = await renderPage(newPagePhrases); - const leftoverPhrases = [...newPageRestPhrases, ...curPageRestPhrases]; - - const recombinedLeftoverPhrases = leftoverPhrases.reduce((acc, phrase) => { - const existingPhrase = acc.find(({ id }) => id === phrase.id); - if (existingPhrase) { - existingPhrase.fields = [...existingPhrase.fields, ...phrase.fields]; - } else { - acc.push(phrase); - } - return acc; - }, [] as IdentifiblePhrasalTemplatePhrase[]); - - return [newPage, recombinedLeftoverPhrases]; + } + + if (pagePhrases.length <= 1) { + const pagePhrase = pagePhrases[0]; + const pagePhraseFields = pagePhrase.fields; + + if (pagePhraseFields.length <= 1) { + // If the rendered page does not fit into the maximum allowed page + // height, and there is only 1 phrase for the page, but that phrase + // has on 1 field (this means there is nothing left to split), then + // stop rendering. + return [curPage, []]; } + + // If the rendered page does not fit into the maximum allowed page + // height, and there is only 1 phrase for the page, and that phrase + // has more than 1 field, then split the fields into multiple phrases + // with the same ID and re-render. + const splits: [IdentifiblePhrasalTemplatePhrase, IdentifiblePhrasalTemplatePhrase] = [ + { + id: pagePhrase.id, + image: pagePhrase.image, + fields: pagePhraseFields.slice(0, pagePhraseFields.length - 1), + }, + { + id: pagePhrase.id, + image: pagePhrase.image, + fields: pagePhraseFields.slice(pagePhraseFields.length - 1), + }, + ]; + + const [newPage, newPageRestPhrases] = await renderPage([splits[0]]); + const leftoverPhrases = [...newPageRestPhrases, splits[1]]; + return [newPage, leftoverPhrases]; } + + // If the rendered page does not fit into the maximum allowed page + // height, and the page has more than 1 phrase, then split the phrases + // and re-render. + const newPagePhrases = pagePhrases.slice(0, pagePhrases.length - 1); + const curPageRestPhrases = pagePhrases.slice(pagePhrases.length - 1); + const [newPage, newPageRestPhrases] = await renderPage(newPagePhrases); + const leftoverPhrases = [...newPageRestPhrases, ...curPageRestPhrases]; + + const recombinedLeftoverPhrases = leftoverPhrases.reduce((acc, phrase) => { + const existingPhrase = acc.find(({ id }) => id === phrase.id); + if (existingPhrase) { + existingPhrase.fields = [...existingPhrase.fields, ...phrase.fields]; + } else { + acc.push(phrase); + } + return acc; + }, [] as IdentifiblePhrasalTemplatePhrase[]); + + return [newPage, recombinedLeftoverPhrases]; }; const _renderPages = async (_pagePhrases: IdentifiblePhrasalTemplatePhrase[]) => { diff --git a/src/entities/activity/ui/items/ActionPlan/Page.tsx b/src/entities/activity/ui/items/ActionPlan/Page.tsx index 226ae0a37..be1d8a97e 100644 --- a/src/entities/activity/ui/items/ActionPlan/Page.tsx +++ b/src/entities/activity/ui/items/ActionPlan/Page.tsx @@ -53,14 +53,14 @@ export const Page = ({ {appletTitle}
    @@ -112,9 +112,10 @@ export const Page = ({ alignItems="center" justifyContent="center" position="absolute" + left="0" bottom="0" - justifySelf="center" - sx={{ margin: '10px 0' }} + width="100%" + sx={{ margin: '10px auto', zIndex: 2 }} >
    {t('credit')
    diff --git a/src/entities/activity/ui/items/ActionPlan/Phrase.tsx b/src/entities/activity/ui/items/ActionPlan/Phrase.tsx index 50068c6c6..e41927abc 100644 --- a/src/entities/activity/ui/items/ActionPlan/Phrase.tsx +++ b/src/entities/activity/ui/items/ActionPlan/Phrase.tsx @@ -2,7 +2,7 @@ import React from 'react'; import Avatar from '@mui/material/Avatar'; -import { useXScaledDimension, useYScaledDimension } from './hooks'; +import { usePageMaxLineCount, useXScaledDimension, useYScaledDimension } from './hooks'; import { ActivitiesPhrasalData } from './phrasalData'; import { ResponseSegment } from './ResponseSegment'; import { TextSegment } from './TextSegment'; @@ -20,13 +20,12 @@ export type PhraseProps = { export const Phrase = ({ phrase, phrasalData, noImage }: PhraseProps) => { const gap = useXScaledDimension(24); const minHeight = useXScaledDimension(72); - const imageWidth = useXScaledDimension(67); const imageHeight = useXScaledDimension(66); const imagePadding = useXScaledDimension(2); - const fontSize = useXScaledDimension(16); const lineHeight = useYScaledDimension(24); + const maxLineCount = usePageMaxLineCount(); const { components } = phrase.fields.reduce( (acc, field, fieldIndex) => { @@ -78,7 +77,19 @@ export const Phrase = ({ phrase, phrasalData, noImage }: PhraseProps) => { )}
    )} - + {React.Children.toArray(components)} diff --git a/src/entities/activity/ui/items/ActionPlan/hooks.tsx b/src/entities/activity/ui/items/ActionPlan/hooks.tsx index 509b2471a..4ec382ffa 100644 --- a/src/entities/activity/ui/items/ActionPlan/hooks.tsx +++ b/src/entities/activity/ui/items/ActionPlan/hooks.tsx @@ -28,6 +28,14 @@ export const usePageMaxHeight = () => { return 2504; }; +export const usePageMaxLineCount = () => { + // This number: 99, corresponds to the number of lines of plain text that can + // fit into a card of at most 2504px height. If the max height is adjusted, + // and/or if the text element's styling is adjusted, then this number would + // also need to be adjusted as well. + return 99; +}; + export const useXScaledDimension = (dimension: number) => { const pageWidth = usePageWidth(); return (pageWidth / PageDimension.maxWidth) * dimension; From 556af14c2fb28f30ba59d4dc35adfb8d0bff7a6b Mon Sep 17 00:00:00 2001 From: Billie He Date: Tue, 10 Sep 2024 13:06:29 -0700 Subject: [PATCH 24/26] chore: page min height --- src/entities/activity/ui/items/ActionPlan/Page.tsx | 6 +++--- src/entities/activity/ui/items/ActionPlan/hooks.tsx | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/entities/activity/ui/items/ActionPlan/Page.tsx b/src/entities/activity/ui/items/ActionPlan/Page.tsx index be1d8a97e..eae1ffe40 100644 --- a/src/entities/activity/ui/items/ActionPlan/Page.tsx +++ b/src/entities/activity/ui/items/ActionPlan/Page.tsx @@ -3,7 +3,7 @@ import { useContext } from 'react'; import { Body } from './Body'; import { DocumentContext } from './DocumentContext'; import { Header } from './Header'; -import { usePageWidth, usePageMaxHeight, useXScaledDimension } from './hooks'; +import { usePageWidth, usePageMaxHeight, useXScaledDimension, usePageMinHeight } from './hooks'; import { ActivitiesPhrasalData } from './phrasalData'; import { Phrase } from './Phrase'; import { Title } from './Title'; @@ -34,6 +34,7 @@ export const Page = ({ const { totalPages } = useContext(DocumentContext); const { t } = useActionPlanTranslation(); const pageWidth = usePageWidth(); + const pageMinHeight = usePageMinHeight(); const pageMaxHeight = usePageMaxHeight(); const scaledPadding = useXScaledDimension(16); const scaledTopPadding = useXScaledDimension(28); @@ -58,9 +59,8 @@ export const Page = ({ paddingRight={`${scaledRightPadding}px`} paddingBottom={`${scaledBottomPadding}px`} paddingLeft={`${scaledLeftPadding}px`} - // TODO: Implement truncation + minHeight={`${pageMinHeight}px`} maxHeight={`${pageMaxHeight}px`} - // overflow="hidden" > { return Math.min(windowWidth - PageDimension.padding, PageDimension.maxWidth); }; +export const usePageMinHeight = () => { + return 275; +}; + export const usePageMaxHeight = () => { return 2504; }; From 4c06bac26beb78fc65c63803bd00d99bfe42bc2f Mon Sep 17 00:00:00 2001 From: Billie He Date: Wed, 11 Sep 2024 11:37:12 -0700 Subject: [PATCH 25/26] chore: update card background image --- .../activity/ui/items/ActionPlan/Page.tsx | 77 +++++++++++++------ .../ui/items/ActionPlan/StretchySvg.tsx | 16 ++++ 2 files changed, 69 insertions(+), 24 deletions(-) create mode 100644 src/entities/activity/ui/items/ActionPlan/StretchySvg.tsx diff --git a/src/entities/activity/ui/items/ActionPlan/Page.tsx b/src/entities/activity/ui/items/ActionPlan/Page.tsx index eae1ffe40..4b3e0da4c 100644 --- a/src/entities/activity/ui/items/ActionPlan/Page.tsx +++ b/src/entities/activity/ui/items/ActionPlan/Page.tsx @@ -6,6 +6,7 @@ import { Header } from './Header'; import { usePageWidth, usePageMaxHeight, useXScaledDimension, usePageMinHeight } from './hooks'; import { ActivitiesPhrasalData } from './phrasalData'; import { Phrase } from './Phrase'; +import { StretchySvg } from './StretchySvg'; import { Title } from './Title'; import footerLogo from '~/assets/mindlogger-action-plan-footer-logo.svg'; @@ -62,29 +63,57 @@ export const Page = ({ minHeight={`${pageMinHeight}px`} maxHeight={`${pageMaxHeight}px`} > - - - - + + + + + + + + + + + + + + + + + + + + + + {t('credit')
    diff --git a/src/entities/activity/ui/items/ActionPlan/StretchySvg.tsx b/src/entities/activity/ui/items/ActionPlan/StretchySvg.tsx new file mode 100644 index 000000000..1cef514a7 --- /dev/null +++ b/src/entities/activity/ui/items/ActionPlan/StretchySvg.tsx @@ -0,0 +1,16 @@ +import { SVGProps } from 'react'; + +export const StretchySvg = (props: SVGProps) => ( + +); From 65fed60429f8cb5e04521ad4fd9ad49a8702b389 Mon Sep 17 00:00:00 2001 From: Billie He Date: Wed, 11 Sep 2024 12:02:27 -0700 Subject: [PATCH 26/26] chore: update some card styling --- .../activity/ui/items/ActionPlan/Document.tsx | 7 +++-- .../activity/ui/items/ActionPlan/Header.tsx | 4 +-- .../activity/ui/items/ActionPlan/Page.tsx | 29 ++++++++++++++----- .../activity/ui/items/ActionPlan/Phrase.tsx | 11 +++++-- .../ui/items/ActionPlan/ResponseSegment.tsx | 2 +- .../activity/ui/items/ActionPlan/hooks.tsx | 22 ++++++++------ 6 files changed, 50 insertions(+), 25 deletions(-) diff --git a/src/entities/activity/ui/items/ActionPlan/Document.tsx b/src/entities/activity/ui/items/ActionPlan/Document.tsx index 8dda7923c..19c940b86 100644 --- a/src/entities/activity/ui/items/ActionPlan/Document.tsx +++ b/src/entities/activity/ui/items/ActionPlan/Document.tsx @@ -4,7 +4,7 @@ import { Box } from '@mui/material'; import { v4 as uuidV4 } from 'uuid'; import { DocumentContext } from './DocumentContext'; -import { useAvailableBodyWidth, usePageMaxHeight } from './hooks'; +import { useAvailableBodyWidth, useCorrelatedPageMaxHeightLineCount } from './hooks'; import { Page } from './Page'; import { extractActivitiesPhrasalData } from './phrasalData'; @@ -46,7 +46,7 @@ export const Document = forwardRef( const activityProgress = useAppSelector((state) => appletModel.selectors.selectActivityProgress( state, - getProgressId(context.activityId, context.eventId), + getProgressId(context.activityId, context.eventId, context.targetSubject?.id), ), ); @@ -56,7 +56,8 @@ export const Document = forwardRef( ); const availableWidth = useAvailableBodyWidth(); - const pageMaxHeight = usePageMaxHeight(); + const correlatedPageMaxHeightLineCount = useCorrelatedPageMaxHeightLineCount(); + const pageMaxHeight = correlatedPageMaxHeightLineCount.maxHeight; const [pages, setPages] = useState([]); const renderPages = useCallback(async () => { diff --git a/src/entities/activity/ui/items/ActionPlan/Header.tsx b/src/entities/activity/ui/items/ActionPlan/Header.tsx index c100c1863..f200b6f80 100644 --- a/src/entities/activity/ui/items/ActionPlan/Header.tsx +++ b/src/entities/activity/ui/items/ActionPlan/Header.tsx @@ -9,9 +9,9 @@ export const Header = ({ children }: { children: string }) => { return ( {children} diff --git a/src/entities/activity/ui/items/ActionPlan/Page.tsx b/src/entities/activity/ui/items/ActionPlan/Page.tsx index 4b3e0da4c..759f89a81 100644 --- a/src/entities/activity/ui/items/ActionPlan/Page.tsx +++ b/src/entities/activity/ui/items/ActionPlan/Page.tsx @@ -3,7 +3,12 @@ import { useContext } from 'react'; import { Body } from './Body'; import { DocumentContext } from './DocumentContext'; import { Header } from './Header'; -import { usePageWidth, usePageMaxHeight, useXScaledDimension, usePageMinHeight } from './hooks'; +import { + usePageWidth, + useCorrelatedPageMaxHeightLineCount, + useXScaledDimension, + usePageMinHeight, +} from './hooks'; import { ActivitiesPhrasalData } from './phrasalData'; import { Phrase } from './Phrase'; import { StretchySvg } from './StretchySvg'; @@ -36,12 +41,17 @@ export const Page = ({ const { t } = useActionPlanTranslation(); const pageWidth = usePageWidth(); const pageMinHeight = usePageMinHeight(); - const pageMaxHeight = usePageMaxHeight(); + const correlatedPageMaxHeightLineCount = useCorrelatedPageMaxHeightLineCount(); + const pageMaxHeight = correlatedPageMaxHeightLineCount.maxHeight; const scaledPadding = useXScaledDimension(16); - const scaledTopPadding = useXScaledDimension(28); + const scaledTopPadding = useXScaledDimension(40); const scaledRightPadding = useXScaledDimension(40); - const scaledBottomPadding = useXScaledDimension(40); + const scaledBottomPadding = useXScaledDimension(80); const scaledLeftPadding = useXScaledDimension(36.5); + const scaledHeaderGap = useXScaledDimension(32); + const scaledFooterWidth = useXScaledDimension(113); + const scaledFooterHeight = useXScaledDimension(16); + const scaledFooterOffset = useXScaledDimension(25); return ( - {t('credit') + {t('credit')
    diff --git a/src/entities/activity/ui/items/ActionPlan/Phrase.tsx b/src/entities/activity/ui/items/ActionPlan/Phrase.tsx index e41927abc..fcc98c114 100644 --- a/src/entities/activity/ui/items/ActionPlan/Phrase.tsx +++ b/src/entities/activity/ui/items/ActionPlan/Phrase.tsx @@ -2,12 +2,16 @@ import React from 'react'; import Avatar from '@mui/material/Avatar'; -import { usePageMaxLineCount, useXScaledDimension, useYScaledDimension } from './hooks'; +import { + useCorrelatedPageMaxHeightLineCount, + useXScaledDimension, + useYScaledDimension, +} from './hooks'; import { ActivitiesPhrasalData } from './phrasalData'; import { ResponseSegment } from './ResponseSegment'; import { TextSegment } from './TextSegment'; -import { PhrasalTemplatePhrase, PhrasalTemplateField } from '../../../lib'; +import { PhrasalTemplatePhrase, PhrasalTemplateField } from '~/entities/activity'; import { Theme } from '~/shared/constants'; import Box from '~/shared/ui/Box'; @@ -25,7 +29,8 @@ export const Phrase = ({ phrase, phrasalData, noImage }: PhraseProps) => { const imagePadding = useXScaledDimension(2); const fontSize = useXScaledDimension(16); const lineHeight = useYScaledDimension(24); - const maxLineCount = usePageMaxLineCount(); + const correlatedPageMaxHeightLineCount = useCorrelatedPageMaxHeightLineCount(); + const maxLineCount = correlatedPageMaxHeightLineCount.lineCount; const { components } = phrase.fields.reduce( (acc, field, fieldIndex) => { diff --git a/src/entities/activity/ui/items/ActionPlan/ResponseSegment.tsx b/src/entities/activity/ui/items/ActionPlan/ResponseSegment.tsx index 1e61579b3..38594c018 100644 --- a/src/entities/activity/ui/items/ActionPlan/ResponseSegment.tsx +++ b/src/entities/activity/ui/items/ActionPlan/ResponseSegment.tsx @@ -1,7 +1,7 @@ import { useXScaledDimension } from './hooks'; import { ActivitiesPhrasalData, ActivityPhrasalDataSliderRowContext } from './phrasalData'; -import { PhrasalTemplateItemResponseField } from '../../../lib'; +import { PhrasalTemplateItemResponseField } from '~/entities/activity'; import { useActionPlanTranslation } from '~/entities/activity/lib/useActionPlanTranslation'; import Box from '~/shared/ui/Box'; import Text from '~/shared/ui/Text'; diff --git a/src/entities/activity/ui/items/ActionPlan/hooks.tsx b/src/entities/activity/ui/items/ActionPlan/hooks.tsx index fb0c21065..cfa2a7778 100644 --- a/src/entities/activity/ui/items/ActionPlan/hooks.tsx +++ b/src/entities/activity/ui/items/ActionPlan/hooks.tsx @@ -28,16 +28,20 @@ export const usePageMinHeight = () => { return 275; }; -export const usePageMaxHeight = () => { - return 2504; -}; +export const useCorrelatedPageMaxHeightLineCount = (): { maxHeight: number; lineCount: number } => { + // Use these for local development/testing when a shorter card would be + // easier to work with: + // return { maxHeight: 512, lineCount: 14 }; + + return { + maxHeight: 2504, -export const usePageMaxLineCount = () => { - // This number: 99, corresponds to the number of lines of plain text that can - // fit into a card of at most 2504px height. If the max height is adjusted, - // and/or if the text element's styling is adjusted, then this number would - // also need to be adjusted as well. - return 99; + // This number: 97, corresponds to the number of lines of plain text that + // can fit into a card of at most 2504px height. If the max height is + // adjusted, and/or if the text element's styling is adjusted, then this + // number would also need to be adjusted as well. + lineCount: 97, + }; }; export const useXScaledDimension = (dimension: number) => {