diff --git a/apps/google-docs/src/locations/Page/components/overview/OverviewEntryList.tsx b/apps/google-docs/src/locations/Page/components/overview/OverviewEntryList.tsx index 2afa21c01f..8a01a6d891 100644 --- a/apps/google-docs/src/locations/Page/components/overview/OverviewEntryList.tsx +++ b/apps/google-docs/src/locations/Page/components/overview/OverviewEntryList.tsx @@ -1,4 +1,4 @@ -import { Box, Card, Flex, Paragraph, Text } from '@contentful/f36-components'; +import { Box, Card, Checkbox, Flex, Paragraph, Text } from '@contentful/f36-components'; import tokens from '@contentful/f36-tokens'; import { cx } from '@emotion/css'; import type { EntryListRow as OverviewEntryListRow } from '../../../../utils/overviewEntryList'; @@ -13,13 +13,19 @@ import { truncateLabel } from '../../../../utils/utils'; export interface OverviewEntryListProps { rows: OverviewEntryListRow[]; selectedEntryIndex: number | null; + selectedEntryKeys: ReadonlySet; onSelect: (entryIndex: number) => void; + onToggleEntrySelection: (entryKey: string, isSelected: boolean) => void; + areEntrySelectionsDisabled?: boolean; } interface OverviewEntryRowCardProps { row: OverviewEntryListRow; selectedEntryIndex: number | null; + selectedEntryKeys: ReadonlySet; onSelect: (entryIndex: number) => void; + onToggleEntrySelection: (entryKey: string, isSelected: boolean) => void; + areEntrySelectionsDisabled: boolean; showTreeLines: boolean; isLastRow?: boolean; } @@ -27,11 +33,15 @@ interface OverviewEntryRowCardProps { function OverviewEntryRowCard({ row, selectedEntryIndex, + selectedEntryKeys, onSelect, + onToggleEntrySelection, + areEntrySelectionsDisabled, showTreeLines, isLastRow = true, }: OverviewEntryRowCardProps) { const isSelected = row.entryIndex === selectedEntryIndex; + const isEntrySelectedForCreation = selectedEntryKeys.has(row.id); const treeLineClass = showTreeLines && cx(treeChildRowBase, isLastRow ? treeChildRowLast : treeChildRowNotLast); @@ -39,30 +49,49 @@ function OverviewEntryRowCard({ const rowContent = ( <> { - if (!isSelected) onSelect(row.entryIndex); - }} style={{ border: `2px solid ${isSelected ? tokens.blue500 : tokens.gray300}`, backgroundColor: tokens.colorWhite, - cursor: isSelected ? 'default' : 'pointer', - textAlign: 'left', width: '100%', padding: `${tokens.spacingXs} ${tokens.spacingS}`, }}> - - - {row.contentTypeName || 'Untitled'} - - {row.contentTypeName && row.entryTitle ? ( - - {' '} - ({truncateLabel(row.entryTitle, 150)}) - - ) : null} - + + onToggleEntrySelection(row.id, event.target.checked)} + /> + + {row.children.length > 0 ? ( @@ -71,7 +100,10 @@ function OverviewEntryRowCard({ key={child.id} row={child} selectedEntryIndex={selectedEntryIndex} + selectedEntryKeys={selectedEntryKeys} onSelect={onSelect} + onToggleEntrySelection={onToggleEntrySelection} + areEntrySelectionsDisabled={areEntrySelectionsDisabled} showTreeLines isLastRow={index === row.children.length - 1} /> @@ -92,7 +124,14 @@ function OverviewEntryRowCard({ ); } -export function OverviewEntryList({ rows, selectedEntryIndex, onSelect }: OverviewEntryListProps) { +export function OverviewEntryList({ + rows, + selectedEntryIndex, + selectedEntryKeys, + onSelect, + onToggleEntrySelection, + areEntrySelectionsDisabled = false, +}: OverviewEntryListProps) { return ( {rows.map((row) => ( @@ -100,7 +139,10 @@ export function OverviewEntryList({ rows, selectedEntryIndex, onSelect }: Overvi key={row.id} row={row} selectedEntryIndex={selectedEntryIndex} + selectedEntryKeys={selectedEntryKeys} onSelect={onSelect} + onToggleEntrySelection={onToggleEntrySelection} + areEntrySelectionsDisabled={areEntrySelectionsDisabled} showTreeLines={false} /> ))} diff --git a/apps/google-docs/src/locations/Page/components/overview/OverviewSection.tsx b/apps/google-docs/src/locations/Page/components/overview/OverviewSection.tsx index 6e16467d0f..7b6d50f7b4 100644 --- a/apps/google-docs/src/locations/Page/components/overview/OverviewSection.tsx +++ b/apps/google-docs/src/locations/Page/components/overview/OverviewSection.tsx @@ -10,19 +10,27 @@ import Splitter from '../mainpage/Splitter'; interface OverviewProps { payload: MappingReviewSuspendPayload; selectedEntryIndex: number | null; + selectedEntryKeys: ReadonlySet; onSelectEntryIndex: (index: number) => void; + onToggleEntrySelection: (entryKey: string, isSelected: boolean) => void; ctaLabel: string; onCtaClick: () => void; isCtaLoading?: boolean; + isCtaDisabled?: boolean; + areEntrySelectionsDisabled?: boolean; } const OverviewSection = ({ payload, selectedEntryIndex, + selectedEntryKeys, onSelectEntryIndex, + onToggleEntrySelection, ctaLabel, onCtaClick, isCtaLoading = false, + isCtaDisabled = false, + areEntrySelectionsDisabled = false, }: OverviewProps) => { const entryRows = useMemo( () => @@ -45,7 +53,7 @@ const OverviewSection = ({ Review your content and associated entries below. Highlight text to make adjustments. - Create entries when you are complete. + Select which entries you’d like to create. @@ -56,7 +64,6 @@ const OverviewSection = ({ Entries - Click row to view content by entry below. @@ -64,7 +71,7 @@ const OverviewSection = ({ variant="primary" onClick={onCtaClick} isLoading={isCtaLoading} - isDisabled={isCtaLoading}> + isDisabled={isCtaLoading || isCtaDisabled}> {ctaLabel} @@ -80,7 +87,10 @@ const OverviewSection = ({ )} diff --git a/apps/google-docs/src/locations/Page/components/review/ReviewPage.tsx b/apps/google-docs/src/locations/Page/components/review/ReviewPage.tsx index 05fbd03743..32424515a0 100644 --- a/apps/google-docs/src/locations/Page/components/review/ReviewPage.tsx +++ b/apps/google-docs/src/locations/Page/components/review/ReviewPage.tsx @@ -10,6 +10,11 @@ import { RunStatus } from '@types'; import { useWorkflowAgent } from '@hooks/useWorkflowAgent'; import { createEntriesFromPreviewPayload } from '../../../../services/entryService'; import type { ContentTypeDisplayInfoMap } from '../../../../utils/overviewEntryList'; +import { + countSelectedEntries, + filterEntryBlockGraphBySelection, + getAllEntrySelectionKeys, +} from '../../../../utils/selectEntryBlockGraph'; import Splitter from '../mainpage/Splitter'; import { ConfirmCancelModal } from '../modals/ConfirmCancelModal'; import { ErrorModal } from '../modals/ErrorModal'; @@ -50,11 +55,16 @@ export const ReviewPage = ({ const [entryBlockGraph, setEntryBlockGraph] = useState(() => structuredClone(payload.entryBlockGraph) ); + const [selectedEntryKeys, setSelectedEntryKeys] = useState>(() => + getAllEntrySelectionKeys(payload.entryBlockGraph.entries) + ); // Reset local graph when starting a different run; do not depend on payload.entryBlockGraph // alone or user edits would be wiped when the parent re-renders with a new object reference. useEffect(() => { - setEntryBlockGraph(structuredClone(payload.entryBlockGraph)); + const nextEntryBlockGraph = structuredClone(payload.entryBlockGraph); + setEntryBlockGraph(nextEntryBlockGraph); + setSelectedEntryKeys(getAllEntrySelectionKeys(nextEntryBlockGraph.entries)); // eslint-disable-next-line react-hooks/exhaustive-deps -- only re-init on run identity }, [runId, payload.documentId]); @@ -74,20 +84,45 @@ export const ReviewPage = ({ }, [payload.contentTypes]); const hasCreatedEntries = createdEntries !== null; const isMappingDisabled = isCreatePending || hasCreatedEntries; + const selectedEntryCount = useMemo( + () => countSelectedEntries(entryBlockGraph.entries, selectedEntryKeys), + [entryBlockGraph.entries, selectedEntryKeys] + ); + const hasSelectedEntries = selectedEntryCount > 0; const { resumeWorkflow } = useWorkflowAgent({ sdk, documentId: '', oauthToken: '' }); + const handleToggleEntrySelection = (entryKey: string, isSelected: boolean) => { + setSelectedEntryKeys((previous) => { + const next = new Set(previous); + if (isSelected) { + next.add(entryKey); + } else { + next.delete(entryKey); + } + return next; + }); + }; + const handleCreateEntries = useCallback(async (): Promise => { if (!runId) { onExitReview(); return; } + if (!hasSelectedEntries) { + return; + } + setIsCreatePending(true); try { - const result = await resumeWorkflow(runId, { + const selectedEntryBlockGraph = filterEntryBlockGraphBySelection( entryBlockGraph, + selectedEntryKeys + ); + const result = await resumeWorkflow(runId, { + entryBlockGraph: selectedEntryBlockGraph, }); if (result.status === RunStatus.COMPLETED && 'googleDocPayload' in result) { @@ -122,7 +157,15 @@ export const ReviewPage = ({ } finally { setIsCreatePending(false); } - }, [runId, resumeWorkflow, entryBlockGraph, sdk, onExitReview]); + }, [ + runId, + hasSelectedEntries, + entryBlockGraph, + selectedEntryKeys, + resumeWorkflow, + sdk, + onExitReview, + ]); const handleConfirmCancel = useCallback(async () => { setIsCancelling(true); @@ -211,13 +254,17 @@ export const ReviewPage = ({ { setSelectedEntryIndex(index); setReviewMode('edit'); }} - ctaLabel={hasCreatedEntries ? 'View entries' : 'Create entries'} + onToggleEntrySelection={handleToggleEntrySelection} + ctaLabel={hasCreatedEntries ? 'View entries' : 'Create selected entries'} onCtaClick={handleCreateOrViewEntries} isCtaLoading={isCreatePending} + isCtaDisabled={!hasCreatedEntries && !hasSelectedEntries} + areEntrySelectionsDisabled={isMappingDisabled} /> { - const id = entry.tempId ?? String(index); + const id = getEntrySelectionKey(entry, index); const childTempIds = entry.tempId ? childrenByParent.get(entry.tempId) ?? [] : []; const childRows = childTempIds .map((childId) => { diff --git a/apps/google-docs/src/utils/selectEntryBlockGraph.ts b/apps/google-docs/src/utils/selectEntryBlockGraph.ts new file mode 100644 index 0000000000..d514277638 --- /dev/null +++ b/apps/google-docs/src/utils/selectEntryBlockGraph.ts @@ -0,0 +1,51 @@ +import type { EntryBlockGraph, EntryBlockGraphEntry } from '../types/entryBlockGraph'; + +export function getEntrySelectionKey(entry: EntryBlockGraphEntry, index: number): string { + return entry.tempId !== undefined ? `temp:${entry.tempId}` : `index:${index}`; +} + +export function getAllEntrySelectionKeys(entries: EntryBlockGraphEntry[]): Set { + return new Set(entries.map((entry, index) => getEntrySelectionKey(entry, index))); +} + +export function countSelectedEntries( + entries: EntryBlockGraphEntry[], + selectedEntryKeys: ReadonlySet +): number { + return entries.reduce( + (count, entry, index) => + selectedEntryKeys.has(getEntrySelectionKey(entry, index)) ? count + 1 : count, + 0 + ); +} + +export function filterEntryBlockGraphBySelection( + graph: EntryBlockGraph, + selectedEntryKeys: ReadonlySet +): EntryBlockGraph { + const selectedEntries = graph.entries.filter((entry, index) => + selectedEntryKeys.has(getEntrySelectionKey(entry, index)) + ); + const selectedTempIds = new Set( + selectedEntries + .map((entry) => entry.tempId) + .filter((tempId): tempId is string => typeof tempId === 'string') + ); + + return { + ...graph, + entries: selectedEntries.map((entry) => ({ + ...entry, + fieldMappings: entry.fieldMappings.map((fieldMapping) => { + if (!fieldMapping.sourceEntryIds) { + return fieldMapping; + } + + return { + ...fieldMapping, + sourceEntryIds: fieldMapping.sourceEntryIds.filter((id) => selectedTempIds.has(id)), + }; + }), + })), + }; +} diff --git a/apps/google-docs/test/locations/Page/components/review/ReviewPage.spec.tsx b/apps/google-docs/test/locations/Page/components/review/ReviewPage.spec.tsx new file mode 100644 index 0000000000..f40ed2295f --- /dev/null +++ b/apps/google-docs/test/locations/Page/components/review/ReviewPage.spec.tsx @@ -0,0 +1,192 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { Layout } from '@contentful/f36-components'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { PageAppSDK } from '@contentful/app-sdk'; +import type { MappingReviewSuspendPayload } from '@types'; +import { RunStatus } from '@types'; +import { createMockSDK } from '../../../../mocks'; +import { ReviewPage } from '../../../../../src/locations/Page/components/review/ReviewPage'; + +const { mockResumeWorkflow, mockCreateEntriesFromPreviewPayload } = vi.hoisted(() => ({ + mockResumeWorkflow: vi.fn(), + mockCreateEntriesFromPreviewPayload: vi.fn(), +})); + +vi.mock('@hooks/useWorkflowAgent', () => ({ + useWorkflowAgent: () => ({ + resumeWorkflow: mockResumeWorkflow, + }), +})); + +vi.mock('../../../../../src/services/entryService', () => ({ + createEntriesFromPreviewPayload: mockCreateEntriesFromPreviewPayload, +})); + +vi.mock('../../../../../src/locations/Page/components/review/mapping/MappingView', () => ({ + MappingView: ({ selectedEntryIndex }: { selectedEntryIndex: number | null }) => ( +
{`selected-index:${selectedEntryIndex ?? 'none'}`}
+ ), +})); + +const createTextSourceRef = (blockId: string, text: string) => ({ + type: 'blockText' as const, + blockId, + start: 0, + end: text.length, + flattenedRuns: [{ text, start: 0, end: text.length }], +}); + +const createPayload = (): MappingReviewSuspendPayload => ({ + suspendStepId: 'mapping-review', + reason: 'Mapping review required before CMA payload generation continues', + documentId: 'doc-review', + documentTitle: 'Review document', + normalizedDocument: { + documentId: 'doc-review', + title: 'Review document', + designValues: [], + contentBlocks: [], + images: [], + tables: [], + assets: [], + }, + entryBlockGraph: { + entries: [ + { + tempId: 'page-1', + contentTypeId: 'article', + fieldMappings: [ + { + fieldId: 'title', + fieldType: 'Symbol', + sourceRefs: [createTextSourceRef('block-1', 'First title')], + confidence: 0.9, + }, + { + fieldId: 'related', + fieldType: 'Array', + sourceRefs: [], + sourceEntryIds: ['hero-1'], + confidence: 0.9, + }, + ], + }, + { + tempId: 'hero-1', + contentTypeId: 'article', + fieldMappings: [ + { + fieldId: 'title', + fieldType: 'Symbol', + sourceRefs: [createTextSourceRef('block-2', 'Second title')], + confidence: 0.9, + }, + ], + }, + ], + excludedSourceRefs: [], + }, + referenceGraph: { + edges: [{ from: 'page-1', to: 'hero-1', fieldId: 'related' }], + creationOrder: ['hero-1', 'page-1'], + deferredFields: [], + hasCircularDependency: false, + }, + contentTypes: [ + { + sys: { id: 'article' }, + name: 'Article', + displayField: 'title', + fields: [ + { id: 'title', name: 'Title', type: 'Symbol' }, + { + id: 'related', + name: 'Related entries', + type: 'Array', + items: { type: 'Link', linkType: 'Entry' }, + }, + ], + }, + ], +}); + +let sdk: PageAppSDK; + +const renderReviewPage = (payload: MappingReviewSuspendPayload = createPayload()) => + render( + + + + ); + +describe('ReviewPage entry selection', () => { + beforeEach(() => { + sdk = createMockSDK() as PageAppSDK; + vi.clearAllMocks(); + mockResumeWorkflow.mockResolvedValue({ + status: RunStatus.COMPLETED, + runId: 'run-1', + messages: [], + googleDocPayload: { entries: [], assets: [], referenceGraph: {} }, + }); + mockCreateEntriesFromPreviewPayload.mockResolvedValue({ createdEntries: [], errors: [] }); + }); + + afterEach(() => { + cleanup(); + }); + + it('selects all entries by default and toggles checkboxes independently from row focus', () => { + renderReviewPage(); + + const firstCheckbox = screen.getByRole('checkbox', { + name: 'Create entry Article (First title)', + }); + const secondCheckbox = screen.getByRole('checkbox', { + name: 'Create entry Article (Second title)', + }); + + expect(firstCheckbox).toBeChecked(); + expect(secondCheckbox).toBeChecked(); + expect(screen.getByRole('button', { name: 'Create selected entries' })).toBeEnabled(); + expect(screen.getByText('selected-index:none')).toBeTruthy(); + + fireEvent.click(secondCheckbox); + + expect(secondCheckbox).not.toBeChecked(); + expect(screen.getByRole('button', { name: 'Create selected entries' })).toBeEnabled(); + expect(screen.getByText('selected-index:none')).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: /Article \(First title\)/ })); + expect(screen.getByText('selected-index:0')).toBeTruthy(); + }); + + it('disables create when no entries are selected', () => { + renderReviewPage(); + + fireEvent.click(screen.getByRole('checkbox', { name: 'Create entry Article (First title)' })); + fireEvent.click(screen.getByRole('checkbox', { name: 'Create entry Article (Second title)' })); + + expect(screen.getByRole('button', { name: 'Create selected entries' })).toBeDisabled(); + }); + + it('resumes the workflow with only selected entries and pruned sourceEntryIds', async () => { + renderReviewPage(); + + fireEvent.click(screen.getByRole('checkbox', { name: 'Create entry Article (Second title)' })); + fireEvent.click(screen.getByRole('button', { name: 'Create selected entries' })); + + await waitFor(() => expect(mockResumeWorkflow).toHaveBeenCalledTimes(1)); + + const resumePayload = mockResumeWorkflow.mock.calls[0][1]; + expect(resumePayload.entryBlockGraph.entries).toHaveLength(1); + expect(resumePayload.entryBlockGraph.entries[0].tempId).toBe('page-1'); + expect(resumePayload.entryBlockGraph.entries[0].fieldMappings[1].sourceEntryIds).toEqual([]); + }); +}); diff --git a/apps/google-docs/test/utils/selectEntryBlockGraph.test.ts b/apps/google-docs/test/utils/selectEntryBlockGraph.test.ts new file mode 100644 index 0000000000..b82469fa19 --- /dev/null +++ b/apps/google-docs/test/utils/selectEntryBlockGraph.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import type { EntryBlockGraph } from '../../src/types/entryBlockGraph'; +import { + countSelectedEntries, + filterEntryBlockGraphBySelection, + getAllEntrySelectionKeys, +} from '../../src/utils/selectEntryBlockGraph'; + +describe('selectEntryBlockGraph utilities', () => { + const graph: EntryBlockGraph = { + entries: [ + { + tempId: 'page-1', + contentTypeId: 'page', + fieldMappings: [ + { + fieldId: 'modules', + fieldType: 'Array', + sourceRefs: [], + sourceEntryIds: ['hero-1', 'quote-1'], + confidence: 0.9, + }, + ], + }, + { + tempId: 'hero-1', + contentTypeId: 'hero', + fieldMappings: [ + { + fieldId: 'title', + fieldType: 'Symbol', + sourceRefs: [], + confidence: 0.9, + }, + ], + }, + { + contentTypeId: 'quote', + fieldMappings: [ + { + fieldId: 'body', + fieldType: 'Text', + sourceRefs: [], + confidence: 0.9, + }, + ], + }, + ], + excludedSourceRefs: [], + }; + + it('uses namespaced temp ids and index fallback keys for initial selection', () => { + expect([...getAllEntrySelectionKeys(graph.entries)]).toEqual([ + 'temp:page-1', + 'temp:hero-1', + 'index:2', + ]); + }); + + it('counts selected entries against the current graph entries', () => { + expect( + countSelectedEntries(graph.entries, new Set(['temp:page-1', 'index:2', 'missing'])) + ).toBe(2); + }); + + it('filters entries and prunes references to unselected temp ids', () => { + const selectedGraph = filterEntryBlockGraphBySelection( + graph, + new Set(['temp:page-1', 'index:2']) + ); + + expect(selectedGraph.entries.map((entry) => entry.contentTypeId)).toEqual(['page', 'quote']); + expect(selectedGraph.entries[0].fieldMappings[0].sourceEntryIds).toEqual([]); + }); + + it('does not collide numeric temp ids with index fallback keys', () => { + const entries: EntryBlockGraph['entries'] = [ + { tempId: '1', contentTypeId: 'numeric', fieldMappings: [] }, + { + contentTypeId: 'fallback', + fieldMappings: [], + }, + ]; + + expect([...getAllEntrySelectionKeys(entries)]).toEqual(['temp:1', 'index:1']); + expect(countSelectedEntries(entries, new Set(['temp:1']))).toBe(1); + expect(countSelectedEntries(entries, new Set(['index:1']))).toBe(1); + }); +});