Skip to content

Commit e756218

Browse files
authored
Merge pull request #1344 from trycompai/main
[comp] Production Deploy
2 parents 74c6a02 + 09e99d4 commit e756218

File tree

5 files changed

+161
-65
lines changed

5 files changed

+161
-65
lines changed

apps/app/src/app/(app)/[orgId]/frameworks/data/getAllFrameworkInstancesWithControls.ts

Lines changed: 55 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -2,76 +2,73 @@
22

33
import type { Control, PolicyStatus, RequirementMap } from '@db';
44
import { db } from '@db';
5-
import { cache } from 'react';
65
import type { FrameworkInstanceWithControls } from '../types';
76

8-
export const getAllFrameworkInstancesWithControls = cache(
9-
async function getAllFrameworkInstancesWithControls({
10-
organizationId,
11-
}: {
12-
organizationId: string;
13-
}): Promise<FrameworkInstanceWithControls[]> {
14-
const frameworkInstancesFromDb = await db.frameworkInstance.findMany({
15-
where: {
16-
organizationId,
17-
},
18-
include: {
19-
framework: true,
20-
requirementsMapped: {
21-
include: {
22-
control: {
23-
include: {
24-
policies: {
25-
select: {
26-
id: true,
27-
name: true,
28-
status: true,
29-
},
7+
export async function getAllFrameworkInstancesWithControls({
8+
organizationId,
9+
}: {
10+
organizationId: string;
11+
}): Promise<FrameworkInstanceWithControls[]> {
12+
const frameworkInstancesFromDb = await db.frameworkInstance.findMany({
13+
where: {
14+
organizationId,
15+
},
16+
include: {
17+
framework: true,
18+
requirementsMapped: {
19+
include: {
20+
control: {
21+
include: {
22+
policies: {
23+
select: {
24+
id: true,
25+
name: true,
26+
status: true,
3027
},
31-
requirementsMapped: true,
3228
},
29+
requirementsMapped: true,
3330
},
3431
},
3532
},
3633
},
37-
});
34+
},
35+
});
3836

39-
const frameworksWithControls: FrameworkInstanceWithControls[] = frameworkInstancesFromDb.map(
40-
(fi) => {
41-
const controlsMap = new Map<
42-
string,
43-
Control & {
44-
policies: Array<{
45-
id: string;
46-
name: string;
47-
status: PolicyStatus;
48-
}>;
49-
requirementsMapped: RequirementMap[];
50-
}
51-
>();
37+
const frameworksWithControls: FrameworkInstanceWithControls[] = frameworkInstancesFromDb.map(
38+
(fi) => {
39+
const controlsMap = new Map<
40+
string,
41+
Control & {
42+
policies: Array<{
43+
id: string;
44+
name: string;
45+
status: PolicyStatus;
46+
}>;
47+
requirementsMapped: RequirementMap[];
48+
}
49+
>();
5250

53-
for (const rm of fi.requirementsMapped) {
54-
if (rm.control) {
55-
const { requirementsMapped: _, ...controlData } = rm.control;
56-
if (!controlsMap.has(rm.control.id)) {
57-
controlsMap.set(rm.control.id, {
58-
...controlData,
59-
policies: rm.control.policies || [],
60-
requirementsMapped: rm.control.requirementsMapped || [],
61-
});
62-
}
51+
for (const rm of fi.requirementsMapped) {
52+
if (rm.control) {
53+
const { requirementsMapped: _, ...controlData } = rm.control;
54+
if (!controlsMap.has(rm.control.id)) {
55+
controlsMap.set(rm.control.id, {
56+
...controlData,
57+
policies: rm.control.policies || [],
58+
requirementsMapped: rm.control.requirementsMapped || [],
59+
});
6360
}
6461
}
62+
}
6563

66-
const { requirementsMapped, ...restOfFi } = fi;
64+
const { requirementsMapped, ...restOfFi } = fi;
6765

68-
return {
69-
...restOfFi,
70-
controls: Array.from(controlsMap.values()),
71-
};
72-
},
73-
);
66+
return {
67+
...restOfFi,
68+
controls: Array.from(controlsMap.values()),
69+
};
70+
},
71+
);
7472

75-
return frameworksWithControls;
76-
},
77-
);
73+
return frameworksWithControls;
74+
}

apps/app/src/app/(app)/[orgId]/frameworks/lib/compute.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,12 @@ export function computeFrameworkStats(
2929

3030
const controlIds = controls.map((c) => c.id);
3131
const frameworkTasks = tasks.filter((t) => t.controls.some((c) => controlIds.includes(c.id)));
32-
const totalTasks = frameworkTasks.length;
33-
const doneTasks = frameworkTasks.filter((t) => t.status === 'done').length;
32+
// Deduplicate tasks by id to avoid double counting across multiple controls
33+
const uniqueTaskMap = new Map<string, Task & { controls: Control[] }>();
34+
for (const t of frameworkTasks) uniqueTaskMap.set(t.id, t);
35+
const uniqueTasks = Array.from(uniqueTaskMap.values());
36+
const totalTasks = uniqueTasks.length;
37+
const doneTasks = uniqueTasks.filter((t) => t.status === 'done').length;
3438
const taskRatio = totalTasks > 0 ? doneTasks / totalTasks : 1;
3539

3640
const complianceScore = Math.round(((policyRatio + taskRatio) / 2) * 100);

packages/ui/src/components/editor/extensions.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,15 @@ import Typography from '@tiptap/extension-typography';
1515
import Underline from '@tiptap/extension-underline';
1616
import StarterKit from '@tiptap/starter-kit';
1717

18-
export const defaultExtensions = (placeholder: string = 'Start writing...') => [
18+
type DefaultExtensionsOptions = {
19+
placeholder?: string;
20+
openLinksOnClick?: boolean;
21+
};
22+
23+
export const defaultExtensions = ({
24+
placeholder = 'Start writing...',
25+
openLinksOnClick = false,
26+
}: DefaultExtensionsOptions = {}) => [
1927
StarterKit.configure({
2028
bulletList: {
2129
HTMLAttributes: {
@@ -69,10 +77,15 @@ export const defaultExtensions = (placeholder: string = 'Start writing...') => [
6977
}),
7078
// Links and images
7179
Link.configure({
72-
openOnClick: false,
80+
// Make links clickable when viewing (readOnly). When editing, keep disabled.
81+
openOnClick: openLinksOnClick,
82+
autolink: true,
83+
linkOnPaste: true,
7384
HTMLAttributes: {
7485
class:
7586
'text-muted-foreground underline underline-offset-[3px] hover:text-primary transition-colors cursor-pointer',
87+
target: '_blank',
88+
rel: 'noopener noreferrer',
7689
},
7790
}),
7891
Image.configure({

packages/ui/src/components/editor/index.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { defaultExtensions } from './extensions';
99
import { LinkSelector } from './selectors/link-selector';
1010
import { NodeSelector } from './selectors/node-selector';
1111
import { TextButtons } from './selectors/text-buttons';
12+
import { linkifyContent } from './utils/linkify-content';
1213
import { validateAndFixTipTapContent } from './utils/validate-content';
1314

1415
export interface EditorProps {
@@ -46,11 +47,12 @@ export const Editor = ({
4647
const [openNode, setOpenNode] = useState(false);
4748
const [openLink, setOpenLink] = useState(false);
4849

49-
// Ensure content is properly structured with a doc type and fix any schema issues
50-
const formattedContent = initialContent ? validateAndFixTipTapContent(initialContent) : null;
50+
// Ensure content is properly structured and add link marks for plain URLs in read-only mode
51+
const validated = initialContent ? validateAndFixTipTapContent(initialContent) : null;
52+
const formattedContent = readOnly && validated ? linkifyContent(validated) : validated;
5153

5254
const editor = useEditor({
53-
extensions: defaultExtensions(placeholder),
55+
extensions: defaultExtensions({ placeholder, openLinksOnClick: readOnly }),
5456
content: formattedContent || '',
5557
editable: !readOnly,
5658
immediatelyRender: false,
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import type { JSONContent } from '@tiptap/react';
2+
3+
const URL_REGEX = /\b(https?:\/\/[^\s)]+|www\.[^\s)]+)\b/gi;
4+
5+
function createLinkMark(href: string) {
6+
const normalized = href.startsWith('http') ? href : `https://${href}`;
7+
return {
8+
type: 'link',
9+
attrs: {
10+
href: normalized,
11+
target: '_blank',
12+
rel: 'noopener noreferrer',
13+
},
14+
};
15+
}
16+
17+
function linkifyText(text: string): JSONContent[] {
18+
const parts: JSONContent[] = [];
19+
let lastIndex = 0;
20+
let match: RegExpExecArray | null;
21+
22+
while ((match = URL_REGEX.exec(text)) !== null) {
23+
const [raw] = match;
24+
const start = match.index;
25+
const end = start + raw.length;
26+
27+
if (start > lastIndex) {
28+
parts.push({ type: 'text', text: text.slice(lastIndex, start) });
29+
}
30+
parts.push({ type: 'text', text: raw, marks: [createLinkMark(raw) as any] });
31+
lastIndex = end;
32+
}
33+
34+
if (lastIndex < text.length) {
35+
parts.push({ type: 'text', text: text.slice(lastIndex) });
36+
}
37+
38+
return parts;
39+
}
40+
41+
export function linkifyContent(doc: JSONContent): JSONContent {
42+
if (!doc || typeof doc !== 'object') return doc;
43+
44+
const recurse = (node: JSONContent): JSONContent => {
45+
if (!node) return node;
46+
47+
if (Array.isArray(node.content)) {
48+
const newChildren: JSONContent[] = [];
49+
for (const child of node.content) {
50+
// Only transform plain text nodes here to avoid complex wrapping
51+
if (child && child.type === 'text' && typeof child.text === 'string') {
52+
const hasLink = Array.isArray(child.marks) && child.marks.some((m) => m.type === 'link');
53+
if (hasLink) {
54+
newChildren.push(child);
55+
} else {
56+
const segments = linkifyText(child.text);
57+
if (segments.length === 0) {
58+
newChildren.push(child);
59+
} else if (
60+
segments.length === 1 &&
61+
segments[0]?.text === child.text &&
62+
!segments[0]?.marks
63+
) {
64+
newChildren.push(child);
65+
} else {
66+
newChildren.push(...segments);
67+
}
68+
}
69+
} else {
70+
newChildren.push(recurse(child as JSONContent));
71+
}
72+
}
73+
return { ...node, content: newChildren } as JSONContent;
74+
}
75+
76+
return node;
77+
};
78+
79+
return recurse(doc);
80+
}

0 commit comments

Comments
 (0)