diff --git a/apps/drive-integration/src/locations/Page/components/overview/OverviewEntryList.tsx b/apps/drive-integration/src/locations/Page/components/overview/OverviewEntryList.tsx index 5f0dd0c2b..0b3ebe6b5 100644 --- a/apps/drive-integration/src/locations/Page/components/overview/OverviewEntryList.tsx +++ b/apps/drive-integration/src/locations/Page/components/overview/OverviewEntryList.tsx @@ -2,6 +2,8 @@ import { Badge, Box, Card, Checkbox, Flex, Paragraph, Text } from '@contentful/f import tokens from '@contentful/f36-tokens'; import { cx } from '@emotion/css'; import type { EntryListRow as OverviewEntryListRow } from '../../../../utils/overviewEntryList'; +import { ValidationFindingSeverity } from '@types'; +import type { ValidationFinding } from '@types'; import { noMappedContentBadge, treeChildRowBase, @@ -18,6 +20,8 @@ export interface OverviewEntryListProps { onSelect: (entryIndex: number) => void; onToggleEntrySelection: (entryKey: string, isSelected: boolean) => void; areEntrySelectionsDisabled?: boolean; + /** Validation findings keyed by entry index, for badge rendering. */ + findingsByEntryIndex?: ReadonlyMap; } interface OverviewEntryRowCardProps { @@ -29,6 +33,7 @@ interface OverviewEntryRowCardProps { areEntrySelectionsDisabled: boolean; showTreeLines: boolean; isLastRow?: boolean; + findingsByEntryIndex?: ReadonlyMap; } function OverviewEntryRowCard({ @@ -40,9 +45,15 @@ function OverviewEntryRowCard({ areEntrySelectionsDisabled, showTreeLines, isLastRow = true, + findingsByEntryIndex, }: OverviewEntryRowCardProps) { const isSelected = row.entryIndex === selectedEntryIndex; const isEntrySelectedForCreation = selectedEntryKeys.has(row.id); + const entryFindings = findingsByEntryIndex?.get(row.entryIndex) ?? []; + const hasBlockFindings = entryFindings.some( + (f) => f.severity === ValidationFindingSeverity.Block + ); + const hasWarnFindings = entryFindings.some((f) => f.severity === ValidationFindingSeverity.Warn); const treeLineClass = showTreeLines && cx(treeChildRowBase, isLastRow ? treeChildRowLast : treeChildRowNotLast); @@ -95,6 +106,16 @@ function OverviewEntryRowCard({ No mapped content )} + {hasBlockFindings && ( + + Needs attention + + )} + {!hasBlockFindings && hasWarnFindings && ( + + Warning + + )} @@ -112,6 +133,7 @@ function OverviewEntryRowCard({ areEntrySelectionsDisabled={areEntrySelectionsDisabled} showTreeLines isLastRow={index === row.children.length - 1} + findingsByEntryIndex={findingsByEntryIndex} /> ))} @@ -137,6 +159,7 @@ export function OverviewEntryList({ onSelect, onToggleEntrySelection, areEntrySelectionsDisabled = false, + findingsByEntryIndex, }: OverviewEntryListProps) { return ( @@ -150,6 +173,7 @@ export function OverviewEntryList({ onToggleEntrySelection={onToggleEntrySelection} areEntrySelectionsDisabled={areEntrySelectionsDisabled} showTreeLines={false} + findingsByEntryIndex={findingsByEntryIndex} /> ))} diff --git a/apps/drive-integration/src/locations/Page/components/overview/OverviewSection.tsx b/apps/drive-integration/src/locations/Page/components/overview/OverviewSection.tsx index 7b6d50f7b..69b31e85c 100644 --- a/apps/drive-integration/src/locations/Page/components/overview/OverviewSection.tsx +++ b/apps/drive-integration/src/locations/Page/components/overview/OverviewSection.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react'; -import { Box, Button, Flex, Note, Paragraph, Text } from '@contentful/f36-components'; +import { Box, Button, Checkbox, Flex, Note, Paragraph, Text } from '@contentful/f36-components'; import { LightbulbIcon } from '@contentful/f36-icons'; -import type { MappingReviewSuspendPayload } from '@types'; +import type { MappingReviewSuspendPayload, ValidationFinding } from '@types'; import { buildEntryListFromEntryBlockGraph } from '../../../../utils/overviewEntryList'; import { OverviewEntryList } from './OverviewEntryList'; import { overviewSectionBox, overviewSectionBoxScrollable } from './OverviewSection.styles'; @@ -18,6 +18,12 @@ interface OverviewProps { isCtaLoading?: boolean; isCtaDisabled?: boolean; areEntrySelectionsDisabled?: boolean; + /** Whether any block-severity findings exist; drives the acknowledgement Note visibility. */ + hasBlockFindings?: boolean; + /** Called with `true` when the user checks the block-findings acknowledgement. */ + onBlockFindingsAcknowledged?: (acknowledged: boolean) => void; + /** Whether the user has acknowledged block findings. */ + blockFindingsAcknowledged?: boolean; } const OverviewSection = ({ @@ -31,6 +37,9 @@ const OverviewSection = ({ isCtaLoading = false, isCtaDisabled = false, areEntrySelectionsDisabled = false, + hasBlockFindings = false, + onBlockFindingsAcknowledged, + blockFindingsAcknowledged = false, }: OverviewProps) => { const entryRows = useMemo( () => @@ -42,6 +51,17 @@ const OverviewSection = ({ [payload.entryBlockGraph.entries, payload.contentTypes, payload.referenceGraph.edges] ); + const findingsByEntryIndex = useMemo((): ReadonlyMap => { + const map = new Map(); + for (const finding of payload.validationFindings ?? []) { + if (finding.entryIndex === undefined) continue; + const list = map.get(finding.entryIndex) ?? []; + list.push(finding); + map.set(finding.entryIndex, list); + } + return map; + }, [payload.validationFindings]); + return ( <> @@ -53,12 +73,28 @@ const OverviewSection = ({ Review your content and associated entries below. Highlight text to make adjustments. - Select which entries you’d like to create. + Select which entries you'd like to create. + {hasBlockFindings && ( + + + + Some entries have issues that may prevent the content from being created + correctly. Review the highlighted entries before proceeding. + + onBlockFindingsAcknowledged?.(event.target.checked)}> + I have reviewed the issues and want to proceed + + + + )} + @@ -91,6 +127,7 @@ const OverviewSection = ({ onSelect={onSelectEntryIndex} onToggleEntrySelection={onToggleEntrySelection} areEntrySelectionsDisabled={areEntrySelectionsDisabled} + findingsByEntryIndex={findingsByEntryIndex} /> )} diff --git a/apps/drive-integration/src/locations/Page/components/review/ReviewPage.tsx b/apps/drive-integration/src/locations/Page/components/review/ReviewPage.tsx index 3ffdca3a4..3f1eb23da 100644 --- a/apps/drive-integration/src/locations/Page/components/review/ReviewPage.tsx +++ b/apps/drive-integration/src/locations/Page/components/review/ReviewPage.tsx @@ -52,6 +52,7 @@ export const ReviewPage = ({ const [createdEntries, setCreatedEntries] = useState(null); const [isSummaryModalOpen, setIsSummaryModalOpen] = useState(false); const [createError, setCreateError] = useState(null); + const [blockFindingsAcknowledged, setBlockFindingsAcknowledged] = useState(false); const [entryBlockGraph, setEntryBlockGraph] = useState(() => structuredClone(payload.entryBlockGraph) ); @@ -65,6 +66,7 @@ export const ReviewPage = ({ const nextEntryBlockGraph = structuredClone(payload.entryBlockGraph); setEntryBlockGraph(nextEntryBlockGraph); setSelectedEntryKeys(getAllEntrySelectionKeys(nextEntryBlockGraph.entries)); + setBlockFindingsAcknowledged(false); // eslint-disable-next-line react-hooks/exhaustive-deps -- only re-init on run identity }, [runId, payload.documentId]); @@ -84,6 +86,7 @@ export const ReviewPage = ({ }, [payload.contentTypes]); const hasCreatedEntries = createdEntries !== null; const isMappingDisabled = isCreatePending || hasCreatedEntries; + const hasBlockFindings = (payload.validationFindings ?? []).some((f) => f.severity === 'block'); const selectedEntryCount = useMemo( () => countSelectedEntries(entryBlockGraph.entries, selectedEntryKeys), [entryBlockGraph.entries, selectedEntryKeys] @@ -263,8 +266,14 @@ export const ReviewPage = ({ ctaLabel={hasCreatedEntries ? 'View entries' : 'Create selected entries'} onCtaClick={handleCreateOrViewEntries} isCtaLoading={isCreatePending} - isCtaDisabled={!hasCreatedEntries && !hasSelectedEntries} + isCtaDisabled={ + !hasCreatedEntries && + (!hasSelectedEntries || (hasBlockFindings && !blockFindingsAcknowledged)) + } areEntrySelectionsDisabled={isMappingDisabled} + hasBlockFindings={hasBlockFindings} + blockFindingsAcknowledged={blockFindingsAcknowledged} + onBlockFindingsAcknowledged={setBlockFindingsAcknowledged} /> ({ + id: `row-${entryIndex}`, + entryIndex, + contentTypeName: 'Article', + entryTitle: label, + children: [], +}); + +const renderList = ( + rows: EntryListRow[], + findingsByEntryIndex?: ReadonlyMap +) => + render( + + ); + +afterEach(() => cleanup()); + +describe('OverviewEntryList — validation finding badges (INTEG-4383)', () => { + it('renders a "Needs attention" badge for entries with block findings', () => { + const rows = [makeRow(0, 'Entry A')]; + const findings: ValidationFinding[] = [ + { + code: 'required-field-missing', + message: 'title missing', + severity: ValidationFindingSeverity.Block, + entryIndex: 0, + }, + ]; + renderList(rows, new Map([[0, findings]])); + + expect(screen.getByText('Needs attention')).toBeTruthy(); + expect(screen.queryByText('Warning')).toBeNull(); + }); + + it('renders a "Warning" badge for entries with only warn findings', () => { + const rows = [makeRow(0, 'Entry A')]; + const findings: ValidationFinding[] = [ + { + code: 'displayField-blank', + message: 'title blank', + severity: ValidationFindingSeverity.Warn, + entryIndex: 0, + }, + ]; + renderList(rows, new Map([[0, findings]])); + + expect(screen.getByText('Warning')).toBeTruthy(); + expect(screen.queryByText('Needs attention')).toBeNull(); + }); + + it('renders "Needs attention" (not Warning) when entry has both block and warn findings', () => { + const rows = [makeRow(0, 'Entry A')]; + const findings: ValidationFinding[] = [ + { + code: 'required-field-missing', + message: 'title missing', + severity: ValidationFindingSeverity.Block, + entryIndex: 0, + }, + { + code: 'displayField-blank', + message: 'title blank', + severity: ValidationFindingSeverity.Warn, + entryIndex: 0, + }, + ]; + renderList(rows, new Map([[0, findings]])); + + expect(screen.getByText('Needs attention')).toBeTruthy(); + expect(screen.queryByText('Warning')).toBeNull(); + }); + + it('renders no finding badges when findingsByEntryIndex is undefined', () => { + const rows = [makeRow(0, 'Entry A')]; + renderList(rows, undefined); + + expect(screen.queryByText('Needs attention')).toBeNull(); + expect(screen.queryByText('Warning')).toBeNull(); + }); + + it('renders no finding badges for entries with no findings', () => { + const rows = [makeRow(0, 'Entry A'), makeRow(1, 'Entry B')]; + const findings: ValidationFinding[] = [ + { + code: 'required-field-missing', + message: 'title missing', + severity: ValidationFindingSeverity.Block, + entryIndex: 1, + }, + ]; + renderList(rows, new Map([[1, findings]])); + + // Only entry 1 should have the badge + expect(screen.getAllByText('Needs attention')).toHaveLength(1); + }); +}); diff --git a/apps/drive-integration/test/locations/Page/components/review/ReviewPage.spec.tsx b/apps/drive-integration/test/locations/Page/components/review/ReviewPage.spec.tsx index f40ed2295..6e475ff89 100644 --- a/apps/drive-integration/test/locations/Page/components/review/ReviewPage.spec.tsx +++ b/apps/drive-integration/test/locations/Page/components/review/ReviewPage.spec.tsx @@ -2,8 +2,8 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re import { Layout } from '@contentful/f36-components'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { PageAppSDK } from '@contentful/app-sdk'; +import { RunStatus, ValidationFindingSeverity } from '@types'; import type { MappingReviewSuspendPayload } from '@types'; -import { RunStatus } from '@types'; import { createMockSDK } from '../../../../mocks'; import { ReviewPage } from '../../../../../src/locations/Page/components/review/ReviewPage'; @@ -125,6 +125,75 @@ const renderReviewPage = (payload: MappingReviewSuspendPayload = createPayload() ); +describe('ReviewPage — block findings acknowledgement (INTEG-4383)', () => { + beforeEach(() => { + sdk = createMockSDK() as PageAppSDK; + vi.clearAllMocks(); + }); + + it('disables Create button when block findings are present and not acknowledged', () => { + const payload: MappingReviewSuspendPayload = { + ...createPayload(), + validationFindings: [ + { + code: 'required-field-missing', + message: 'title missing', + severity: ValidationFindingSeverity.Block, + entryIndex: 0, + }, + ], + }; + renderReviewPage(payload); + + expect(screen.getByRole('button', { name: 'Create selected entries' })).toBeDisabled(); + }); + + it('enables Create button after user acknowledges block findings', () => { + const payload: MappingReviewSuspendPayload = { + ...createPayload(), + validationFindings: [ + { + code: 'required-field-missing', + message: 'title missing', + severity: ValidationFindingSeverity.Block, + entryIndex: 0, + }, + ], + }; + renderReviewPage(payload); + + const checkbox = screen.getByRole('checkbox', { + name: 'I have reviewed the issues and want to proceed', + }); + fireEvent.click(checkbox); + + expect(screen.getByRole('button', { name: 'Create selected entries' })).toBeEnabled(); + }); + + it('does not disable Create button when only warn findings are present', () => { + const payload: MappingReviewSuspendPayload = { + ...createPayload(), + validationFindings: [ + { + code: 'displayField-blank', + message: 'title blank', + severity: ValidationFindingSeverity.Warn, + entryIndex: 0, + }, + ], + }; + renderReviewPage(payload); + + expect(screen.getByRole('button', { name: 'Create selected entries' })).toBeEnabled(); + }); + + it('does not show acknowledgement note when there are no block findings', () => { + renderReviewPage(); + + expect(screen.queryByText('I have reviewed the issues and want to proceed')).toBeNull(); + }); +}); + describe('ReviewPage entry selection', () => { beforeEach(() => { sdk = createMockSDK() as PageAppSDK;