Skip to content

Commit 3c28304

Browse files
committed
commit before rebase
1 parent 7981a90 commit 3c28304

File tree

3 files changed

+174
-34
lines changed

3 files changed

+174
-34
lines changed

frontend/src/lib/utils/crud.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1062,6 +1062,8 @@ export const URL_MODEL_MAP: ModelMap = {
10621062
{ field: 'owner', urlModel: 'users' },
10631063
{ field: 'purposes', urlModel: 'purposes' },
10641064
{ field: 'assigned_to', urlModel: 'users', urlParams: 'is_third_party=false' },
1065+
{ field: 'associated_controls', urlModel: 'applied-controls' },
1066+
{ field: 'evidences', urlModel: 'evidences' },
10651067
{ field: 'filtering_labels', urlModel: 'filtering-labels' }
10661068
],
10671069
reverseForeignKeyFields: [

frontend/src/routes/(app)/(third-party)/compliance-assessments/[id=uuid]/+page.svelte

Lines changed: 149 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { run } from 'svelte/legacy';
33
44
import { page } from '$app/state';
5+
import { browser } from '$app/environment';
56
import RecursiveTreeView from '$lib/components/TreeView/RecursiveTreeView.svelte';
67
78
import { onMount } from 'svelte';
@@ -39,6 +40,7 @@
3940
import ConfirmModal from '$lib/components/Modals/ConfirmModal.svelte';
4041
import { displayScoreColor, darkenColor } from '$lib/utils/helpers';
4142
import { auditFiltersStore, expandedNodesState } from '$lib/utils/stores';
43+
import { fetchNames, isUuid } from '$lib/utils/related-names';
4244
import { derived } from 'svelte/store';
4345
import { canPerformAction } from '$lib/utils/access-control';
4446
import MarkdownRenderer from '$lib/components/MarkdownRenderer.svelte';
@@ -53,7 +55,9 @@
5355
5456
const compliance_assessment = $derived(data.compliance_assessment);
5557
const folderId =
56-
typeof compliance_assessment.folder === 'string'
58+
!compliance_assessment.folder
59+
? undefined
60+
:typeof compliance_assessment.folder === 'string'
5761
? compliance_assessment.folder
5862
: compliance_assessment.folder?.id;
5963
const framework = data.framework ?? compliance_assessment.framework;
@@ -78,6 +82,69 @@
7882
domain: folderId
7983
});
8084
85+
let relatedNames: Record<string, Record<string, string>> = $state({});
86+
let relatedNamesKey = '';
87+
88+
const getUrlModelForField = (fieldName: string): string | undefined => {
89+
const modelField = model?.foreignKeyFields?.find((item) => item.field === fieldName);
90+
return modelField?.urlModel ?? modelField?.field?.replace(/_/g, '-');
91+
};
92+
93+
const extractId = (value: any): string | null => {
94+
if (typeof value === 'string' && isUuid(value)) return value;
95+
if (value && typeof value === 'object' && 'id' in value) {
96+
const idValue = String(value.id);
97+
return isUuid(idValue) ? idValue : null;
98+
}
99+
return null;
100+
};
101+
102+
const extractIds = (value: any): string[] => {
103+
if (Array.isArray(value)) {
104+
return value.map((item) => extractId(item)).filter((item): item is string => Boolean(item));
105+
}
106+
const single = extractId(value);
107+
return single ? [single] : [];
108+
};
109+
110+
const isIdOnlyArray = (value: any[]): boolean => {
111+
const hasDisplayLabels = value.some(
112+
(item) => item && typeof item === 'object' && ('str' in item || 'name' in item)
113+
);
114+
return !hasDisplayLabels && extractIds(value).length > 0;
115+
};
116+
117+
const refreshRelatedNames = async () => {
118+
if (!browser || !model?.foreignKeyFields) return;
119+
const idsByModel = new Map<string, Set<string>>();
120+
for (const fieldConfig of model.foreignKeyFields) {
121+
const urlModel = fieldConfig.urlModel ?? fieldConfig.field?.replace(/_/g, '-');
122+
if (!urlModel) continue;
123+
const ids = extractIds(compliance_assessment?.[fieldConfig.field]);
124+
if (ids.length === 0) continue;
125+
const bucket = idsByModel.get(urlModel) ?? new Set<string>();
126+
ids.forEach((id) => bucket.add(id));
127+
idsByModel.set(urlModel, bucket);
128+
}
129+
const key = JSON.stringify(
130+
Array.from(idsByModel.entries()).map(([urlModel, ids]) => [urlModel, Array.from(ids).sort()])
131+
);
132+
if (!key || key === relatedNamesKey) return;
133+
relatedNamesKey = key;
134+
135+
const updates: Record<string, Record<string, string>> = {};
136+
for (const [urlModel, ids] of idsByModel.entries()) {
137+
updates[urlModel] = await fetchNames(urlModel, Array.from(ids));
138+
}
139+
if (Object.keys(updates).length > 0) {
140+
relatedNames = { ...relatedNames, ...updates };
141+
}
142+
};
143+
144+
$effect(() => {
145+
void refreshRelatedNames();
146+
});
147+
81148
const has_threats = data.threats.total_unique_threats > 0;
82149
83150
let threatDialogOpen = $state(false);
@@ -484,26 +551,89 @@
484551
>
485552
{#if value}
486553
{#if Array.isArray(value)}
487-
<ul>
488-
{#each value as val}
489-
<li>
490-
{#if val.str && val.id}
491-
{@const itemHref = `/${
492-
URL_MODEL_MAP[data.URLModel]['foreignKeyFields']?.find(
493-
(item) => item.field === key
494-
)?.urlModel
495-
}/${val.id}`}
496-
{#if !page.data.user.is_third_party}
497-
<Anchor href={itemHref} class="anchor">{val.str}</Anchor>
554+
{@const isIdArray = isIdOnlyArray(value)}
555+
{@const idValues = isIdArray ? extractIds(value) : []}
556+
{@const urlModel = isIdArray ? getUrlModelForField(key) : undefined}
557+
{@const resolvedNames = urlModel ? relatedNames[urlModel] : undefined}
558+
{@const hasResolvedNames = Boolean(resolvedNames)}
559+
{@const visibleIds = hasResolvedNames
560+
? idValues.filter((id) => resolvedNames?.[id])
561+
: []}
562+
{@const hiddenCount = hasResolvedNames ? idValues.length - visibleIds.length : 0}
563+
{#if isIdArray}
564+
{#if hasResolvedNames}
565+
<ul>
566+
{#each visibleIds as id}
567+
{@const label = resolvedNames?.[id]}
568+
{@const itemHref = label && urlModel ? `/${urlModel}/${id}` : undefined}
569+
<li>
570+
{#if label && itemHref}
571+
{#if !page.data.user.is_third_party}
572+
<Anchor href={itemHref} class="anchor">{label}</Anchor>
573+
{:else}
574+
{label}
575+
{/if}
576+
{:else if label}
577+
{label}
578+
{/if}
579+
</li>
580+
{/each}
581+
</ul>
582+
{#if hiddenCount > 0}
583+
<p class="text-xs text-yellow-700">
584+
{m.objectsNotVisible({
585+
count: hiddenCount,
586+
s: hiddenCount === 1 ? '' : 's'
587+
})}
588+
</p>
589+
{/if}
590+
{:else}
591+
<ul>
592+
{#each idValues as id}
593+
<li>{id}</li>
594+
{/each}
595+
</ul>
596+
{/if}
597+
{:else}
598+
<ul>
599+
{#each value as val}
600+
<li>
601+
{#if val?.str && val?.id}
602+
{@const itemHref = `/${
603+
URL_MODEL_MAP[data.URLModel]['foreignKeyFields']?.find(
604+
(item) => item.field === key
605+
)?.urlModel
606+
}/${val.id}`}
607+
{#if !page.data.user.is_third_party}
608+
<Anchor href={itemHref} class="anchor">{val.str}</Anchor>
609+
{:else}
610+
{val.str}
611+
{/if}
612+
{:else if val?.str}
613+
{safeTranslate(val.str)}
498614
{:else}
499-
{val.str}
615+
{safeTranslate(val)}
500616
{/if}
501-
{:else}
502-
{val}
503-
{/if}
504-
</li>
505-
{/each}
506-
</ul>
617+
</li>
618+
{/each}
619+
</ul>
620+
{/if}
621+
{:else if typeof value === 'string' && isUuid(value) && getUrlModelForField(key)}
622+
{@const urlModel = getUrlModelForField(key)}
623+
{@const resolvedNames = urlModel ? relatedNames[urlModel] : undefined}
624+
{@const label = resolvedNames?.[value]}
625+
{@const itemHref = label && urlModel ? `/${urlModel}/${value}` : undefined}
626+
{#if label && itemHref}
627+
{#if !page.data.user.is_third_party}
628+
<Anchor href={itemHref} class="anchor">{label}</Anchor>
629+
{:else}
630+
{label}
631+
{/if}
632+
{:else if label}
633+
{label}
634+
{:else}
635+
{value}
636+
{/if}
507637
{:else if value.str && value.id}
508638
{@const itemHref = `/${
509639
URL_MODEL_MAP['compliance-assessments']['foreignKeyFields']?.find(

frontend/tests/utils/form-content.ts

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,33 +4,41 @@ const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\
44

55
const stripLeadingSlash = (value: string) => value.replace(/^\/+/, '');
66

7+
/**
8+
* Extracts the last meaningful part of a path-like string
9+
* e.g., "domain/perimeter/assessment" -> "assessment"
10+
* "domain/perimeter/assessment - 1.4.2" -> "assessment - 1.4.2"
11+
*/
12+
const extractLastSegment = (value: string): string => {
13+
const segments = value.split('/').map(s => s.trim()).filter(Boolean);
14+
return segments[segments.length - 1] || value.trim();
15+
};
16+
717
const buildSearchboxMatcher = (value: string): RegExp => {
818
const raw = String(value).trim();
919
const normalized = stripLeadingSlash(raw);
1020
const escaped = escapeRegExp(normalized);
1121
if (!raw.includes('/')) {
1222
return new RegExp(`\\s*/?${escaped}\\s*`);
1323
}
14-
const suffix = stripLeadingSlash(raw.split('/').pop()?.trim() ?? '');
15-
if (!suffix || suffix === raw) {
24+
const suffix = escapeRegExp(extractLastSegment(raw));
25+
if (!suffix || suffix === escaped) {
1626
return new RegExp(`\\s*/?${escaped}\\s*`);
1727
}
18-
return new RegExp(`\\s*(?:/?${escaped}|/?${escapeRegExp(suffix)})\\s*`);
28+
return new RegExp(`\\s*(?:/?${escaped}|/?${suffix})\\s*`);
1929
};
2030

21-
const buildOptionMatcher = (value: string): string | RegExp => {
31+
const buildOptionMatcher = (value: string): RegExp => {
2232
const raw = String(value).trim();
23-
const normalized = stripLeadingSlash(raw);
24-
const escaped = escapeRegExp(normalized);
25-
if (!raw.includes('/')) {
26-
return new RegExp(`^\\s*(?:/?${escaped}|.*${escapeRegExp(raw)})\\s*$`);
27-
}
28-
const suffix = stripLeadingSlash(raw.split('/').pop()?.trim() ?? '');
29-
if (!suffix || suffix === raw) {
30-
return new RegExp(`^\\s*/?${escaped}\\s*$`);
31-
}
32-
// Match either the full path or just the final suffix (which may have UUID prefixes)
33-
return new RegExp(`^\\s*(?:.*${escapeRegExp(suffix)})\\s*$`);
33+
const lastSegment = extractLastSegment(raw);
34+
const escaped = escapeRegExp(lastSegment);
35+
36+
// Match options that may have:
37+
// 1. Index prefix (e.g., "0-", "1-", "42-")
38+
// 2. UUID suffix (e.g., "-c817", "-d0c8")
39+
// 3. Full breadcrumb path
40+
// Examples: "0-Test domain-d0c8/...", "Test asset-c817 Primary", "asset-c817 Primary"
41+
return new RegExp(`^\\s*(?:\\d+-)?.*${escaped}.*$`, 'i');
3442
};
3543

3644
export enum FormFieldType {

0 commit comments

Comments
 (0)