diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/data/getAllFrameworkInstancesWithControls.ts b/apps/app/src/app/(app)/[orgId]/frameworks/data/getAllFrameworkInstancesWithControls.ts index 7cd656cd2..13cef3dad 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/data/getAllFrameworkInstancesWithControls.ts +++ b/apps/app/src/app/(app)/[orgId]/frameworks/data/getAllFrameworkInstancesWithControls.ts @@ -2,76 +2,73 @@ import type { Control, PolicyStatus, RequirementMap } from '@db'; import { db } from '@db'; -import { cache } from 'react'; import type { FrameworkInstanceWithControls } from '../types'; -export const getAllFrameworkInstancesWithControls = cache( - async function getAllFrameworkInstancesWithControls({ - organizationId, - }: { - organizationId: string; - }): Promise { - const frameworkInstancesFromDb = await db.frameworkInstance.findMany({ - where: { - organizationId, - }, - include: { - framework: true, - requirementsMapped: { - include: { - control: { - include: { - policies: { - select: { - id: true, - name: true, - status: true, - }, +export async function getAllFrameworkInstancesWithControls({ + organizationId, +}: { + organizationId: string; +}): Promise { + const frameworkInstancesFromDb = await db.frameworkInstance.findMany({ + where: { + organizationId, + }, + include: { + framework: true, + requirementsMapped: { + include: { + control: { + include: { + policies: { + select: { + id: true, + name: true, + status: true, }, - requirementsMapped: true, }, + requirementsMapped: true, }, }, }, }, - }); + }, + }); - const frameworksWithControls: FrameworkInstanceWithControls[] = frameworkInstancesFromDb.map( - (fi) => { - const controlsMap = new Map< - string, - Control & { - policies: Array<{ - id: string; - name: string; - status: PolicyStatus; - }>; - requirementsMapped: RequirementMap[]; - } - >(); + const frameworksWithControls: FrameworkInstanceWithControls[] = frameworkInstancesFromDb.map( + (fi) => { + const controlsMap = new Map< + string, + Control & { + policies: Array<{ + id: string; + name: string; + status: PolicyStatus; + }>; + requirementsMapped: RequirementMap[]; + } + >(); - for (const rm of fi.requirementsMapped) { - if (rm.control) { - const { requirementsMapped: _, ...controlData } = rm.control; - if (!controlsMap.has(rm.control.id)) { - controlsMap.set(rm.control.id, { - ...controlData, - policies: rm.control.policies || [], - requirementsMapped: rm.control.requirementsMapped || [], - }); - } + for (const rm of fi.requirementsMapped) { + if (rm.control) { + const { requirementsMapped: _, ...controlData } = rm.control; + if (!controlsMap.has(rm.control.id)) { + controlsMap.set(rm.control.id, { + ...controlData, + policies: rm.control.policies || [], + requirementsMapped: rm.control.requirementsMapped || [], + }); } } + } - const { requirementsMapped, ...restOfFi } = fi; + const { requirementsMapped, ...restOfFi } = fi; - return { - ...restOfFi, - controls: Array.from(controlsMap.values()), - }; - }, - ); + return { + ...restOfFi, + controls: Array.from(controlsMap.values()), + }; + }, + ); - return frameworksWithControls; - }, -); + return frameworksWithControls; +} diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/lib/compute.ts b/apps/app/src/app/(app)/[orgId]/frameworks/lib/compute.ts index be538b21a..2223f6b77 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/lib/compute.ts +++ b/apps/app/src/app/(app)/[orgId]/frameworks/lib/compute.ts @@ -29,8 +29,12 @@ export function computeFrameworkStats( const controlIds = controls.map((c) => c.id); const frameworkTasks = tasks.filter((t) => t.controls.some((c) => controlIds.includes(c.id))); - const totalTasks = frameworkTasks.length; - const doneTasks = frameworkTasks.filter((t) => t.status === 'done').length; + // Deduplicate tasks by id to avoid double counting across multiple controls + const uniqueTaskMap = new Map(); + for (const t of frameworkTasks) uniqueTaskMap.set(t.id, t); + const uniqueTasks = Array.from(uniqueTaskMap.values()); + const totalTasks = uniqueTasks.length; + const doneTasks = uniqueTasks.filter((t) => t.status === 'done').length; const taskRatio = totalTasks > 0 ? doneTasks / totalTasks : 1; const complianceScore = Math.round(((policyRatio + taskRatio) / 2) * 100); diff --git a/packages/ui/src/components/editor/extensions.ts b/packages/ui/src/components/editor/extensions.ts index 53e54e74f..0ab2e2aea 100644 --- a/packages/ui/src/components/editor/extensions.ts +++ b/packages/ui/src/components/editor/extensions.ts @@ -15,7 +15,15 @@ import Typography from '@tiptap/extension-typography'; import Underline from '@tiptap/extension-underline'; import StarterKit from '@tiptap/starter-kit'; -export const defaultExtensions = (placeholder: string = 'Start writing...') => [ +type DefaultExtensionsOptions = { + placeholder?: string; + openLinksOnClick?: boolean; +}; + +export const defaultExtensions = ({ + placeholder = 'Start writing...', + openLinksOnClick = false, +}: DefaultExtensionsOptions = {}) => [ StarterKit.configure({ bulletList: { HTMLAttributes: { @@ -69,10 +77,15 @@ export const defaultExtensions = (placeholder: string = 'Start writing...') => [ }), // Links and images Link.configure({ - openOnClick: false, + // Make links clickable when viewing (readOnly). When editing, keep disabled. + openOnClick: openLinksOnClick, + autolink: true, + linkOnPaste: true, HTMLAttributes: { class: 'text-muted-foreground underline underline-offset-[3px] hover:text-primary transition-colors cursor-pointer', + target: '_blank', + rel: 'noopener noreferrer', }, }), Image.configure({ diff --git a/packages/ui/src/components/editor/index.tsx b/packages/ui/src/components/editor/index.tsx index ab27cc2a8..99940275b 100644 --- a/packages/ui/src/components/editor/index.tsx +++ b/packages/ui/src/components/editor/index.tsx @@ -9,6 +9,7 @@ import { defaultExtensions } from './extensions'; import { LinkSelector } from './selectors/link-selector'; import { NodeSelector } from './selectors/node-selector'; import { TextButtons } from './selectors/text-buttons'; +import { linkifyContent } from './utils/linkify-content'; import { validateAndFixTipTapContent } from './utils/validate-content'; export interface EditorProps { @@ -46,11 +47,12 @@ export const Editor = ({ const [openNode, setOpenNode] = useState(false); const [openLink, setOpenLink] = useState(false); - // Ensure content is properly structured with a doc type and fix any schema issues - const formattedContent = initialContent ? validateAndFixTipTapContent(initialContent) : null; + // Ensure content is properly structured and add link marks for plain URLs in read-only mode + const validated = initialContent ? validateAndFixTipTapContent(initialContent) : null; + const formattedContent = readOnly && validated ? linkifyContent(validated) : validated; const editor = useEditor({ - extensions: defaultExtensions(placeholder), + extensions: defaultExtensions({ placeholder, openLinksOnClick: readOnly }), content: formattedContent || '', editable: !readOnly, immediatelyRender: false, diff --git a/packages/ui/src/components/editor/utils/linkify-content.ts b/packages/ui/src/components/editor/utils/linkify-content.ts new file mode 100644 index 000000000..90eea885b --- /dev/null +++ b/packages/ui/src/components/editor/utils/linkify-content.ts @@ -0,0 +1,80 @@ +import type { JSONContent } from '@tiptap/react'; + +const URL_REGEX = /\b(https?:\/\/[^\s)]+|www\.[^\s)]+)\b/gi; + +function createLinkMark(href: string) { + const normalized = href.startsWith('http') ? href : `https://${href}`; + return { + type: 'link', + attrs: { + href: normalized, + target: '_blank', + rel: 'noopener noreferrer', + }, + }; +} + +function linkifyText(text: string): JSONContent[] { + const parts: JSONContent[] = []; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = URL_REGEX.exec(text)) !== null) { + const [raw] = match; + const start = match.index; + const end = start + raw.length; + + if (start > lastIndex) { + parts.push({ type: 'text', text: text.slice(lastIndex, start) }); + } + parts.push({ type: 'text', text: raw, marks: [createLinkMark(raw) as any] }); + lastIndex = end; + } + + if (lastIndex < text.length) { + parts.push({ type: 'text', text: text.slice(lastIndex) }); + } + + return parts; +} + +export function linkifyContent(doc: JSONContent): JSONContent { + if (!doc || typeof doc !== 'object') return doc; + + const recurse = (node: JSONContent): JSONContent => { + if (!node) return node; + + if (Array.isArray(node.content)) { + const newChildren: JSONContent[] = []; + for (const child of node.content) { + // Only transform plain text nodes here to avoid complex wrapping + if (child && child.type === 'text' && typeof child.text === 'string') { + const hasLink = Array.isArray(child.marks) && child.marks.some((m) => m.type === 'link'); + if (hasLink) { + newChildren.push(child); + } else { + const segments = linkifyText(child.text); + if (segments.length === 0) { + newChildren.push(child); + } else if ( + segments.length === 1 && + segments[0]?.text === child.text && + !segments[0]?.marks + ) { + newChildren.push(child); + } else { + newChildren.push(...segments); + } + } + } else { + newChildren.push(recurse(child as JSONContent)); + } + } + return { ...node, content: newChildren } as JSONContent; + } + + return node; + }; + + return recurse(doc); +}