Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { cn } from '@trycompai/ui/cn';

/** Swatch + label; shared by read-only display and select items (policy table pattern). */
export function ApplicableSwatchRow({ isApplicable }: { isApplicable: boolean | null }) {
const swatchClass =
isApplicable === true
? 'bg-primary'
: isApplicable === false
? 'bg-red-600 dark:bg-red-400'
: 'bg-gray-400 dark:bg-gray-500';
const label = isApplicable === true ? 'Yes' : isApplicable === false ? 'No' : '\u2014';

return (
<span className="flex items-center gap-2 text-sm text-foreground">
<span className={cn('size-2.5 shrink-0 rounded-none', swatchClass)} aria-hidden />
<span>{label}</span>
</span>
);
}

export function ApplicableReadOnlyDisplay({ isApplicable }: { isApplicable: boolean | null }) {
return (
<div className="flex w-full items-center justify-center">
<ApplicableSwatchRow isApplicable={isApplicable} />
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ import {
import { X, Loader2, Edit2 } from 'lucide-react';
import { toast } from 'sonner';
import { useSOADocument } from '../../hooks/useSOADocument';
import { ApplicableReadOnlyDisplay, ApplicableSwatchRow } from './ApplicableSwatch';
import type { SOAFieldSavePayload } from './soa-field-types';

export type { SOAFieldSavePayload, SOATableAnswerData } from './soa-field-types';

interface EditableSOAFieldsProps {
documentId: string;
Expand All @@ -31,7 +35,8 @@ interface EditableSOAFieldsProps {
isControl7?: boolean;
isFullyRemote?: boolean;
organizationId: string;
onUpdate?: (savedAnswer: string | null) => void;
/** Called after a successful save so the table can override autofill/cache without a full reload. */
onUpdate?: (payload: SOAFieldSavePayload) => void;
}

export function EditableSOAFields({
Expand All @@ -54,14 +59,6 @@ export function EditableSOAFields({
const justificationTextareaRef = useRef<HTMLTextAreaElement>(null);
const [isJustificationDialogOpen, setJustificationDialogOpen] = useState(false);
const dialogSavedRef = useRef(false);
const badgeBaseClasses =
'inline-flex items-center justify-center rounded-full border px-3 py-1 text-xs font-medium tracking-wide w-[3rem]';
const badgeClasses =
isApplicable === true
? `${badgeBaseClasses} bg-primary text-primary-foreground border-primary/70 shadow-sm shadow-primary/40`
: isApplicable === false
? `${badgeBaseClasses} bg-destructive text-destructive-foreground border-destructive/70 shadow-sm shadow-destructive/40`
: `${badgeBaseClasses} bg-muted text-muted-foreground border-transparent`;

useEffect(() => {
setIsApplicable(initialIsApplicable);
Expand Down Expand Up @@ -101,9 +98,10 @@ export function EditableSOAFields({
setIsEditing(false);
setError(null);
toast.success('Answer saved successfully');
// Call onUpdate with the saved answer value to update parent state optimistically
const savedAnswer = nextIsApplicable === false ? nextJustification : null;
onUpdate?.(savedAnswer);
onUpdate?.({
isApplicable: nextIsApplicable,
justification: nextIsApplicable === false ? nextJustification : null,
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to save answer';
if (!isJustificationDialogOpen) {
Expand All @@ -128,11 +126,6 @@ export function EditableSOAFields({

const handleEditClick = () => {
setIsEditing(true);
if (isApplicable === false) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Edit click no longer opens justification dialog for "No" answers

High Severity

handleEditClick no longer opens the justification dialog when isApplicable is already false. Since Radix UI's Select only fires onValueChange when the value actually changes, re-selecting "no" while it's already "no" won't trigger handleSelectChange. This means users can't access the justification dialog to edit justification text for existing "No" answers. The only workaround is switching to "Yes" first, which triggers an immediate destructive save via executeSave(true, null), wiping the previous justification.

Fix in Cursor Fix in Web

setJustificationDialogOpen(true);
} else {
setJustificationDialogOpen(false);
}
};

const handleSelectChange = (value: 'yes' | 'no' | 'null') => {
Expand Down Expand Up @@ -186,19 +179,15 @@ export function EditableSOAFields({
// Display mode
return (
<div className="flex w-full flex-col items-center gap-2 text-center">
<span className={`${badgeClasses} uppercase`}>
{isApplicable === true ? 'YES' : isApplicable === false ? 'NO' : '\u2014'}
</span>
<ApplicableReadOnlyDisplay isApplicable={isApplicable} />
</div>
);
}

if (!isEditing) {
return (
<div className="group relative flex w-full items-center justify-center">
<span className={`${badgeClasses} uppercase`}>
{isApplicable === true ? 'YES' : isApplicable === false ? 'NO' : '\u2014'}
</span>
<ApplicableReadOnlyDisplay isApplicable={isApplicable} />
<button
type="button"
onClick={handleEditClick}
Expand All @@ -223,10 +212,14 @@ export function EditableSOAFields({
</SelectTrigger>
<SelectContent>
<SelectItem value="null" disabled>
{'\u2014'}
<ApplicableSwatchRow isApplicable={null} />
</SelectItem>
<SelectItem value="yes">
<ApplicableSwatchRow isApplicable />
</SelectItem>
<SelectItem value="no">
<ApplicableSwatchRow isApplicable={false} />
</SelectItem>
<SelectItem value="yes">YES</SelectItem>
<SelectItem value="no">NO</SelectItem>
</SelectContent>
</Select>
<Button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { SOADocumentInfo } from './SOADocumentInfo';
import { SOAPendingApprovalAlert } from './SOAPendingApprovalAlert';
import { SubmitApprovalDialog } from './SubmitApprovalDialog';
import { SOATable } from './SOATable';
import type { SOAFieldSavePayload, SOATableAnswerData } from './EditableSOAFields';
import type { FrameworkWithLatestDocument } from '../types';

type Framework = FrameworkWithLatestDocument['framework'];
Expand Down Expand Up @@ -95,7 +96,7 @@ export function SOAFrameworkTable({
const questions = configuration.questions as SOAQuestion[];

// Create answers map from document answers
const [answersMap, setAnswersMap] = useState<Map<string, { answer: string | null; answerVersion: number }>>(() => {
const [answersMap, setAnswersMap] = useState<Map<string, SOATableAnswerData>>(() => {
return new Map(
(document?.answers || []).map((answer: { questionId: string; answer: string | null; answerVersion: number }) => [
answer.questionId,
Expand All @@ -116,13 +117,14 @@ export function SOAFrameworkTable({
);
}, [document?.answers]);

const handleAnswerUpdate = (questionId: string, answer: string | null) => {
const handleAnswerUpdate = (questionId: string, payload: SOAFieldSavePayload) => {
setAnswersMap((prev) => {
const newMap = new Map(prev);
const existing = newMap.get(questionId);
newMap.set(questionId, {
answer,
answer: payload.justification,
answerVersion: existing ? existing.answerVersion + 1 : 1,
savedIsApplicable: payload.isApplicable,
});
return newMap;
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { Loader2 } from 'lucide-react';
import type { SOAFieldSavePayload, SOATableAnswerData } from './EditableSOAFields';
import { EditableSOAFields } from './EditableSOAFields';

type SOAColumn = {
Expand Down Expand Up @@ -30,14 +31,14 @@ type ProcessedResult = {
interface SOAMobileRowProps {
question: SOAQuestion;
columns: SOAColumn[];
answerData?: { answer: string | null; answerVersion: number };
answerData?: SOATableAnswerData;
questionStatus?: string;
processedResult?: ProcessedResult;
isFullyRemote: boolean;
documentId: string;
isPendingApproval: boolean;
organizationId: string;
onUpdate?: (savedAnswer: string | null) => void;
onUpdate?: (payload: SOAFieldSavePayload) => void;
}

export function SOAMobileRow({
Expand All @@ -55,23 +56,35 @@ export function SOAMobileRow({
const controlClosure = question.columnMapping.closure || '';
const isControl7 = controlClosure.startsWith('7.');

let displayIsApplicable: boolean;
let displayIsApplicable: boolean | null;
let justificationValue: string | null;

if (isFullyRemote && isControl7) {
displayIsApplicable = false;
justificationValue = processedResult?.justification
|| answerData?.answer
|| question.columnMapping.justification
|| 'This control is not applicable as our organization operates fully remotely.';
justificationValue =
processedResult?.justification ||
answerData?.answer ||
question.columnMapping.justification ||
'This control is not applicable as our organization operates fully remotely.';
} else if (answerData?.savedIsApplicable !== undefined) {
displayIsApplicable = answerData.savedIsApplicable;
justificationValue =
displayIsApplicable === false
? (answerData.answer ?? question.columnMapping.justification ?? null)
: null;
} else {
displayIsApplicable = processedResult?.isApplicable !== null && processedResult?.isApplicable !== undefined
? processedResult.isApplicable
: (question.columnMapping.isApplicable ?? true);
displayIsApplicable =
processedResult?.isApplicable !== null && processedResult?.isApplicable !== undefined
? processedResult.isApplicable
: (question.columnMapping.isApplicable ?? true);

justificationValue = displayIsApplicable === false
? (processedResult?.justification || answerData?.answer || question.columnMapping.justification || null)
: null;
justificationValue =
displayIsApplicable === false
? (processedResult?.justification ||
answerData?.answer ||
question.columnMapping.justification ||
null)
: null;
}

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
import { Card } from '@trycompai/ui';
import { Button } from '@trycompai/design-system';
import { ChevronUp, ChevronDown } from '@trycompai/design-system/icons';
import type { SOAFieldSavePayload, SOATableAnswerData } from './EditableSOAFields';
import { SOATableRow } from './SOATableRow';
import { SOAMobileRow } from './SOAMobileRow';

export type { SOATableAnswerData };

type SOAColumn = {
name: string;
type: 'string' | 'boolean' | 'text';
Expand Down Expand Up @@ -33,7 +36,7 @@ type ProcessedResult = {
interface SOATableProps {
columns: SOAColumn[];
questions: SOAQuestion[];
answersMap: Map<string, { answer: string | null; answerVersion: number }>;
answersMap: Map<string, SOATableAnswerData>;
questionStatuses: Map<string, string>;
processedResults: Map<string, ProcessedResult>;
isFullyRemote: boolean;
Expand All @@ -42,7 +45,7 @@ interface SOATableProps {
documentId: string;
isPendingApproval: boolean;
organizationId: string;
onAnswerUpdate?: (questionId: string, answer: string | null) => void;
onAnswerUpdate?: (questionId: string, payload: SOAFieldSavePayload) => void;
}

const columnLabelMap: Record<string, string> = {
Expand Down Expand Up @@ -79,7 +82,7 @@ export function SOATable({
documentId,
isPendingApproval,
organizationId,
onUpdate: (savedAnswer: string | null) => onAnswerUpdate?.(question.id, savedAnswer),
onUpdate: (payload: SOAFieldSavePayload) => onAnswerUpdate?.(question.id, payload),
});

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { Loader2 } from 'lucide-react';
import type { SOAFieldSavePayload, SOATableAnswerData } from './EditableSOAFields';
import { EditableSOAFields } from './EditableSOAFields';

type SOAColumn = {
Expand Down Expand Up @@ -30,14 +31,14 @@ type ProcessedResult = {
interface SOATableRowProps {
question: SOAQuestion;
columns: SOAColumn[];
answerData?: { answer: string | null; answerVersion: number };
answerData?: SOATableAnswerData;
questionStatus?: string;
processedResult?: ProcessedResult;
isFullyRemote: boolean;
documentId: string;
isPendingApproval: boolean;
organizationId: string;
onUpdate?: (savedAnswer: string | null) => void;
onUpdate?: (payload: SOAFieldSavePayload) => void;
}

export function SOATableRow({
Expand All @@ -61,31 +62,41 @@ export function SOATableRow({
const isControl7 = controlClosure.startsWith('7.');

// Determine displayIsApplicable and justificationValue based on fully remote logic
let displayIsApplicable: boolean;
let displayIsApplicable: boolean | null;
let justificationValue: string | null;

// If fully remote and control starts with "7.", always show NO (override any other value)
if (isFullyRemote && isControl7) {
displayIsApplicable = false;
justificationValue = processedResult?.justification
|| answerData?.answer
|| question.columnMapping.justification
|| 'This control is not applicable as our organization operates fully remotely.';
justificationValue =
processedResult?.justification ||
answerData?.answer ||
question.columnMapping.justification ||
'This control is not applicable as our organization operates fully remotely.';
} else if (answerData?.savedIsApplicable !== undefined) {
// Manual save overrides autofill processedResult so the table updates without a full reload
displayIsApplicable = answerData.savedIsApplicable;
justificationValue =
displayIsApplicable === false
? (answerData.answer ?? question.columnMapping.justification ?? null)
: null;
} else {
// Normal logic for other controls
const isApplicableValue = processedResult?.isApplicable !== null && processedResult?.isApplicable !== undefined
? processedResult.isApplicable
: (question.columnMapping.isApplicable ?? true); // Default to YES

// For justification: only show if isApplicable is NO
justificationValue = (isApplicableValue === false && processedResult?.justification)
|| (isApplicableValue === false && answerData?.answer)
|| (isApplicableValue === false && question.columnMapping.justification)
|| null;

displayIsApplicable = processedResult?.isApplicable !== null && processedResult?.isApplicable !== undefined
? processedResult.isApplicable
: (question.columnMapping.isApplicable ?? true); // Default to YES
// Normal logic: processedResult / column mapping until user saves (then branch above)
const isApplicableValue =
processedResult?.isApplicable !== null && processedResult?.isApplicable !== undefined
? processedResult.isApplicable
: (question.columnMapping.isApplicable ?? true);

justificationValue =
(isApplicableValue === false && processedResult?.justification) ||
(isApplicableValue === false && answerData?.answer) ||
(isApplicableValue === false && question.columnMapping.justification) ||
null;

displayIsApplicable =
processedResult?.isApplicable !== null && processedResult?.isApplicable !== undefined
? processedResult.isApplicable
: (question.columnMapping.isApplicable ?? true);
}

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export type SOAFieldSavePayload = {
isApplicable: boolean | null;
justification: string | null;
};

/** Row-level answer state; `savedIsApplicable` is set after manual save to override autofill. */
export type SOATableAnswerData = {
answer: string | null;
answerVersion: number;
savedIsApplicable?: boolean | null;
};
Loading