From 5bb1f94d3d8d6762d625eead0e423fe7bfb85807 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Tue, 19 Aug 2025 18:09:05 -0400 Subject: [PATCH 1/3] feat: enhance editor functionality with linkification and configurable extensions - Updated `defaultExtensions` to accept options for placeholder text and link click behavior. - Implemented `linkifyContent` utility to automatically convert plain URLs into clickable links in read-only mode. - Refactored editor content validation to incorporate linkification, improving user experience when viewing content. --- .../ui/src/components/editor/extensions.ts | 17 +++- packages/ui/src/components/editor/index.tsx | 8 +- .../editor/utils/linkify-content.ts | 77 +++++++++++++++++++ 3 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 packages/ui/src/components/editor/utils/linkify-content.ts 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..d2b3212ec --- /dev/null +++ b/packages/ui/src/components/editor/utils/linkify-content.ts @@ -0,0 +1,77 @@ +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 (node.type === 'text' && typeof node.text === 'string') { + // If it already has a link mark, leave as-is + const hasLink = Array.isArray(node.marks) && node.marks.some((m) => m.type === 'link'); + if (hasLink) return node; + const segments = linkifyText(node.text); + // If no links detected, return original + if (segments.length === 1 && segments[0].text === node.text && !segments[0].marks) { + return node; + } + return { type: 'text', text: '', content: segments } as any; // handled by parent rewrite below + } + + if (Array.isArray(node.content)) { + const newChildren: JSONContent[] = []; + for (const child of node.content) { + const next = recurse(child); + // If a text node returned a wrapper with inline content, flatten it + if (next && (next as any).content && next.type === 'text' && next.text === '') { + newChildren.push(...(((next as any).content as JSONContent[]) || [])); + } else { + newChildren.push(next); + } + } + return { ...node, content: newChildren }; + } + + return node; + }; + + return recurse(doc); +} From 9a9495c5c8bb673b263551dcf0a92fd380f89f36 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Tue, 19 Aug 2025 18:12:46 -0400 Subject: [PATCH 2/3] refactor: optimize linkification logic in linkifyContent utility - Streamlined the linkification process by refining the handling of text nodes, ensuring only plain text nodes are transformed. - Improved the conditions for returning original text nodes when no links are detected, enhancing performance and maintainability. - Updated the recursive structure to better manage child nodes and their content, resulting in cleaner code. --- .../editor/utils/linkify-content.ts | 39 ++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/packages/ui/src/components/editor/utils/linkify-content.ts b/packages/ui/src/components/editor/utils/linkify-content.ts index d2b3212ec..90eea885b 100644 --- a/packages/ui/src/components/editor/utils/linkify-content.ts +++ b/packages/ui/src/components/editor/utils/linkify-content.ts @@ -44,30 +44,33 @@ export function linkifyContent(doc: JSONContent): JSONContent { const recurse = (node: JSONContent): JSONContent => { if (!node) return node; - if (node.type === 'text' && typeof node.text === 'string') { - // If it already has a link mark, leave as-is - const hasLink = Array.isArray(node.marks) && node.marks.some((m) => m.type === 'link'); - if (hasLink) return node; - const segments = linkifyText(node.text); - // If no links detected, return original - if (segments.length === 1 && segments[0].text === node.text && !segments[0].marks) { - return node; - } - return { type: 'text', text: '', content: segments } as any; // handled by parent rewrite below - } - if (Array.isArray(node.content)) { const newChildren: JSONContent[] = []; for (const child of node.content) { - const next = recurse(child); - // If a text node returned a wrapper with inline content, flatten it - if (next && (next as any).content && next.type === 'text' && next.text === '') { - newChildren.push(...(((next as any).content as JSONContent[]) || [])); + // 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(next); + newChildren.push(recurse(child as JSONContent)); } } - return { ...node, content: newChildren }; + return { ...node, content: newChildren } as JSONContent; } return node; From e6d6f4744ee3287be30342057ba487d6a943850d Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Tue, 19 Aug 2025 18:18:38 -0400 Subject: [PATCH 3/3] refactor: simplify getAllFrameworkInstancesWithControls function and enhance task computation - Removed caching from the getAllFrameworkInstancesWithControls function for improved clarity and maintainability. - Streamlined the mapping of framework instances to controls, ensuring better readability. - Updated task computation logic in computeFrameworkStats to deduplicate tasks, preventing double counting and improving accuracy in task statistics. --- .../getAllFrameworkInstancesWithControls.ts | 113 +++++++++--------- .../(app)/[orgId]/frameworks/lib/compute.ts | 8 +- 2 files changed, 61 insertions(+), 60 deletions(-) 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);