Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement content truncation for Action Plan cards (M2-7861) #541

Open
wants to merge 7 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 50 additions & 130 deletions src/entities/activity/ui/items/ActionPlan/Document.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
import React, { useEffect, useState, useContext, useMemo, useCallback } from 'react';
import React, { useCallback, useContext, useMemo, useState } from 'react';

import { Box } from '@mui/material';
import { v4 as uuidV4 } from 'uuid';

import { DocumentContext } from './DocumentContext';
import { useAvailableBodyWidth, useCorrelatedPageMaxHeightLineCount } from './hooks';
import { Page } from './Page';
import {
DocumentContext,
DocumentData,
IdentifiablePhrasalTemplatePhrase,
PageComponent,
} from './Document.type';
import { useAvailableBodyWidth, usePageMaxHeight } from './hooks';
import { buildDocumentData, buildPageComponents } from './pageComponent';
import { pageRenderer, pagesRenderer } from './pageRenderer';
import { extractActivitiesPhrasalData } from './phrasalData';

import { getProgressId } from '~/abstract/lib';
import { PhrasalTemplatePhrase } from '~/entities/activity/lib';
import { useActionPlanTranslation } from '~/entities/activity/lib/useActionPlanTranslation';
import { appletModel } from '~/entities/applet';
import { SurveyContext } from '~/features/PassSurvey';
import { useAppSelector } from '~/shared/utils';
import measureComponentHeight from '~/shared/utils/measureComponentHeight';
import { useAppSelector, useOnceEffect } from '~/shared/utils';

type DocumentProps = {
documentId: string;
Expand All @@ -22,29 +28,15 @@ type DocumentProps = {
phrasalTemplateCardTitle: string;
};

type IdentifiablePhrasalTemplatePhrase = PhrasalTemplatePhrase & { id: string };

export const Document = ({
documentId,
appletTitle,
phrases,
phrasalTemplateCardTitle,
}: DocumentProps) => {
const { t } = useActionPlanTranslation();
const context = useContext(SurveyContext);

const noImage = useMemo(() => phrases.filter((phrase) => !!phrase.image).length <= 0, [phrases]);

const identifiablePhrases = useMemo(
() =>
phrases.map<IdentifiablePhrasalTemplatePhrase>((phrase) => {
return {
...phrase,
id: uuidV4(),
};
}),
[phrases],
);

const activityProgress = useAppSelector((state) =>
appletModel.selectors.selectActivityProgress(
state,
Expand All @@ -57,120 +49,48 @@ export const Document = ({
[activityProgress],
);

const identifiablePhrases = useMemo(
() => phrases.map<IdentifiablePhrasalTemplatePhrase>((phrase) => ({ ...phrase, id: uuidV4() })),
[phrases],
);

const documentData = useMemo<DocumentData>(
() => buildDocumentData(identifiablePhrases),
[identifiablePhrases],
);

const pageComponents = useMemo<PageComponent[]>(
() => buildPageComponents(t, activitiesPhrasalData, identifiablePhrases),
[t, activitiesPhrasalData, identifiablePhrases],
);

const availableWidth = useAvailableBodyWidth();
const correlatedPageMaxHeightLineCount = useCorrelatedPageMaxHeightLineCount();
const pageMaxHeight = correlatedPageMaxHeightLineCount.maxHeight;
const pageMaxHeight = usePageMaxHeight();
const [pages, setPages] = useState<React.ReactNode[]>([]);

const renderPages = useCallback(async () => {
const renderedPages: React.ReactNode[] = [];

const renderPage = async (
pagePhrases: IdentifiablePhrasalTemplatePhrase[],
): Promise<[React.ReactNode, IdentifiablePhrasalTemplatePhrase[]]> => {
const curPageNumber = renderedPages.length + 1;

const curPage = (
<Page
key={`page-${curPageNumber}`}
documentId={documentId}
pageNumber={curPageNumber}
appletTitle={appletTitle}
phrasalTemplateCardTitle={phrasalTemplateCardTitle}
phrases={pagePhrases}
phrasalData={activitiesPhrasalData}
noImage={noImage}
/>
);

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, []];
}

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: [IdentifiablePhrasalTemplatePhrase, IdentifiablePhrasalTemplatePhrase] = [
{
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 IdentifiablePhrasalTemplatePhrase[]);

return [newPage, recombinedLeftoverPhrases];
};

const _renderPages = async (_pagePhrases: IdentifiablePhrasalTemplatePhrase[]) => {
const [renderedPage, leftoverPhrases] = await renderPage(_pagePhrases);
renderedPages.push(renderedPage);

if (leftoverPhrases.length > 0) {
await _renderPages(leftoverPhrases);
}
};

await _renderPages(identifiablePhrases);
const renderOnePage = useMemo(
() =>
pageRenderer(availableWidth, {
documentId,
documentData,
appletTitle,
phrasalTemplateCardTitle,
}),
[appletTitle, availableWidth, documentData, documentId, phrasalTemplateCardTitle],
);

const renderMorePage = useMemo(
() => pagesRenderer(renderOnePage, pageMaxHeight),
[pageMaxHeight, renderOnePage],
);

const renderAllPages = useCallback(async () => {
const renderedPages = await renderMorePage(1, pageComponents);
setPages(renderedPages);
}, [
documentId,
activitiesPhrasalData,
appletTitle,
availableWidth,
pageMaxHeight,
phrasalTemplateCardTitle,
identifiablePhrases,
noImage,
]);

useEffect(() => {
void renderPages();
}, [renderPages]);
}, [pageComponents, renderMorePage]);
useOnceEffect(() => {
void renderAllPages();
});

return (
<Box
Expand Down
69 changes: 69 additions & 0 deletions src/entities/activity/ui/items/ActionPlan/Document.type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import React from 'react';

import { PhrasalTemplatePhrase } from '~/entities/activity';

export type IdentifiablePhrasalTemplatePhrase = PhrasalTemplatePhrase & { id: string };

// This should be kept in a separate file from `Document` because both `Document` and `Page` need
// this context, but `Document` also needs `Page`. So keeping this context in this separate file
// avoids a circular import path.
export const DocumentContext = React.createContext<{
totalPages: number;
}>({
totalPages: 0,
});

export type FieldValueTransformer = (value: string) => string;

export type FieldValueItemsJoiner = (values: string[]) => string;

type BasePageComponent = {
phraseIndex: number;
phraseId: string;
};

export type SentencePageComponent = BasePageComponent & {
componentType: 'sentence';
text: string;
};

type BaseItemResponsePageComponent = BasePageComponent & {
componentType: 'item_response';
};

export type ListItemResponsePageComponent = BaseItemResponsePageComponent & {
componentType: 'item_response';
itemResponseType: 'list';
items: string[];
};

export type TextItemResponsePageComponent = BaseItemResponsePageComponent & {
componentType: 'item_response';
itemResponseType: 'text';
text: string;
};

type ItemResponsePageComponent = ListItemResponsePageComponent | TextItemResponsePageComponent;

export type LineBreakPageComponent = BasePageComponent & {
componentType: 'line_break';
};

export type PageComponent =
| SentencePageComponent
| ItemResponsePageComponent
| LineBreakPageComponent;

export type DocumentData = {
imageUrlByPhraseId: Record<string, string>;
hasImage: boolean;
};

export type FlatComponentIndex = [number] | [number, number];

export type PageRenderer = (
pageNumber: number,
components: PageComponent[],
flatIndices: FlatComponentIndex[],
inclusivePivot: number,
) => Promise<{ page: React.ReactNode; pageHeight: number; restComponents: PageComponent[] }>;
7 changes: 0 additions & 7 deletions src/entities/activity/ui/items/ActionPlan/DocumentContext.ts

This file was deleted.

Loading
Loading