Skip to content
Merged
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
Expand Up @@ -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<FrameworkInstanceWithControls[]> {
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<FrameworkInstanceWithControls[]> {
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;
}
8 changes: 6 additions & 2 deletions apps/app/src/app/(app)/[orgId]/frameworks/lib/compute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Task & { controls: Control[] }>();
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);
Expand Down
17 changes: 15 additions & 2 deletions packages/ui/src/components/editor/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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({
Expand Down
8 changes: 5 additions & 3 deletions packages/ui/src/components/editor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
80 changes: 80 additions & 0 deletions packages/ui/src/components/editor/utils/linkify-content.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading