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
20 changes: 11 additions & 9 deletions frontend/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,17 @@
},
"dependencies": {
"@internationalized/date": "^3.12.4",
"@lucide/vue": "^1.61.0",
"@lucide/vue": "^1.63.0",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/vue-table": "^8.21.3",
"@tanstack/vue-table": "^9.3.1",
"@vee-validate/zod": "^4.15.1",
"@vueuse/core": "^15.0.0",
"axios": "^1.20.0",
"axios": "^1.21.0",
"chart.js": "^4.6.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dompurify": "^3.4.17",
"marked": "^18.1.1",
"marked": "^18.1.2",
"oidc-client-ts": "^3.5.0",
"pinia": "^4.1.0",
"qrcode": "^1.5.4",
Expand Down
12 changes: 8 additions & 4 deletions frontend/src/components/assessment/ActivityAssetsManager.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ import {
SearchCode,
Shield,
ShieldCheck,
Trash,
User,
Users,
X,
} from '@lucide/vue';
import { computed, onMounted, ref, watch } from 'vue';
import ManageAssetsModal from '@/components/assessment/ManageAssetsModal.vue';
Expand Down Expand Up @@ -91,6 +91,7 @@ const iconMap: Record<string, any> = {
Database,
Shield,
ShieldCheck,
Trash,
Users,
Network,
SearchCode,
Expand Down Expand Up @@ -330,11 +331,12 @@ onMounted(() => {

<!-- Assets Grid -->
<div :class="compact || showOnlySources ? 'flex flex-wrap gap-2' : 'grid grid-cols-2 gap-2'">
<!-- Tiles use isolate so the edit/remove buttons' z-10 stays inside the tile (below the sticky header and tabs) -->
<Popover v-for="asset in section.items" :key="asset.id">
<PopoverTrigger as-child>
<div
:class="[
'group relative flex items-center transition-colors cursor-pointer',
'group relative isolate flex items-center transition-colors cursor-pointer',
compact
? 'gap-2 px-2 py-1.5 rounded-md border bg-card hover:bg-accent/20'
: 'flex-col justify-center p-3 rounded-lg border-2 bg-card hover:bg-accent/20 min-h-[120px]',
Expand All @@ -359,9 +361,10 @@ onMounted(() => {
variant="ghost"
size="icon"
class="absolute top-1 right-1 h-5 w-5 rounded-full text-muted-foreground hover:text-destructive z-10"
title="Remove from activity"
@click.stop="removeAsset(asset.id, section.key)"
>
<X class="h-3 w-3" />
<Trash class="h-3 w-3" />
</Button>

<!-- Icon -->
Expand Down Expand Up @@ -395,9 +398,10 @@ onMounted(() => {
variant="ghost"
size="icon"
:class="['h-5 w-5 rounded-full text-muted-foreground hover:text-destructive shrink-0', asset.deleted ? 'ml-auto' : '']"
title="Remove from activity"
@click.stop="removeAsset(asset.id, section.key)"
>
<X class="h-3 w-3" />
<Trash class="h-3 w-3" />
</Button>
</div>
</PopoverTrigger>
Expand Down
52 changes: 43 additions & 9 deletions frontend/src/components/assessment/ActivityForm.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { BookOpen, Copy, History } from '@lucide/vue';
import { useElementSize } from '@vueuse/core';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import ActivityAssetsManager from '@/components/assessment/ActivityAssetsManager.vue';
Expand Down Expand Up @@ -130,38 +131,60 @@ watch(
},
);

// The tab bar sticks right below the sticky header, whose height varies
const stickyHeader = ref<HTMLElement | null>(null);
const sectionTabs = ref<{ $el: HTMLElement } | null>(null);
const { height: stickyHeaderHeight } = useElementSize(stickyHeader, undefined, {
box: 'border-box',
});

// Clicking a tab while the bar is stuck jumps to the start of that section
function onSectionTabClicked() {
const tabsEl = sectionTabs.value?.$el;
const headerEl = stickyHeader.value;
if (!tabsEl || !headerEl) return;
if (
tabsEl.getBoundingClientRect().top <
headerEl.getBoundingClientRect().bottom
) {
// scroll-margin-top on the tabs keeps them below the header
tabsEl.scrollIntoView({ block: 'start' });
}
}

// Browser-style section tabs: the active tab joins the page below and shows its section colour on top
const SECTION_TABS = [
{
value: 'details',
label: 'Activity Details',
dot: 'bg-red-500',
accent: 'data-[state=active]:border-t-red-500 dark:data-[state=active]:border-t-red-500',
accent: 'hover:border-t-red-500/50 data-[state=active]:border-t-red-500 dark:data-[state=active]:border-t-red-500',
},
{
value: 'detection',
label: 'Activity Detection',
dot: 'bg-blue-500',
accent: 'data-[state=active]:border-t-blue-500 dark:data-[state=active]:border-t-blue-500',
accent: 'hover:border-t-blue-500/50 data-[state=active]:border-t-blue-500 dark:data-[state=active]:border-t-blue-500',
},
{
value: 'evaluation',
label: 'Evaluation',
dot: 'bg-purple-500',
accent: 'data-[state=active]:border-t-purple-500 dark:data-[state=active]:border-t-purple-500',
accent: 'hover:border-t-purple-500/50 data-[state=active]:border-t-purple-500 dark:data-[state=active]:border-t-purple-500',
},
{
value: 'attachments',
label: 'Attachments',
dot: 'bg-muted-foreground/50',
accent: 'data-[state=active]:border-t-muted-foreground dark:data-[state=active]:border-t-muted-foreground',
accent: 'hover:border-t-muted-foreground/50 data-[state=active]:border-t-muted-foreground dark:data-[state=active]:border-t-muted-foreground',
},
] as const;

const SECTION_TAB_CLASS = [
'-mb-px h-12 min-w-[200px] flex-none justify-start gap-2.5 px-5 text-base',
'rounded-t-lg rounded-b-none border border-t-2 border-transparent',
'text-muted-foreground dark:text-muted-foreground hover:bg-muted/50 hover:text-foreground',
// Inactive tabs look like background browser tabs: light fill, border open at the bottom
'rounded-t-lg rounded-b-none border border-t-2 border-border/60 border-b-transparent',
'cursor-pointer bg-muted/40 text-muted-foreground dark:text-muted-foreground transition-colors hover:bg-muted hover:text-foreground',
'data-[state=active]:relative data-[state=active]:z-10 data-[state=active]:border-border data-[state=active]:border-b-card data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow-none',
'dark:data-[state=active]:border-border dark:data-[state=active]:border-b-card dark:data-[state=active]:bg-card',
].join(' ');
Expand Down Expand Up @@ -579,7 +602,7 @@ defineExpose({ hasUnsavedChanges });
<template>
<div class="w-full px-4 mx-auto space-y-8 pb-24">
<!-- Sticky Header -->
<div class="sticky top-0 z-20 bg-background pb-4 border-b">
<div ref="stickyHeader" class="sticky top-0 z-20 bg-background pb-4 border-b">
<div class="flex justify-between items-start pt-2">
<div>
<h2 class="text-3xl font-bold tracking-tight">{{ formData.name || 'Activity Details' }}</h2>
Expand Down Expand Up @@ -663,8 +686,19 @@ defineExpose({ hasUnsavedChanges });
/>

<!-- Sections as tabs; hidden tabs stay mounted so the dirty tracking and editor state are kept -->
<Tabs v-model="activeSectionTab" :unmount-on-hide="false" class="gap-0">
<TabsList class="h-auto w-full items-end justify-start gap-1 rounded-none bg-transparent p-0">
<Tabs
ref="sectionTabs"
v-model="activeSectionTab"
:unmount-on-hide="false"
class="gap-0"
:style="{ scrollMarginTop: `${stickyHeaderHeight}px` }"
@update:model-value="onSectionTabClicked"
>
<!-- Sticky below the header for quick switching while scrolled down -->
<TabsList
class="sticky z-10 h-auto w-full items-end justify-start gap-1 rounded-none bg-background p-0"
:style="{ top: `${stickyHeaderHeight}px` }"
>
<TabsTrigger
v-for="tab in SECTION_TABS"
:key="tab.value"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
<script setup lang="ts">
import {
type ColumnDef,
getCoreRowModel,
columnFilteringFeature,
columnVisibilityFeature,
type RowSelectionState,
useVueTable,
rowSelectionFeature,
tableFeatures,
useTable,
} from '@tanstack/vue-table';
import { Loader2 } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
Expand Down Expand Up @@ -60,13 +63,20 @@ const sortOrder = ref<'asc' | 'desc' | null>(null);
// Server-side column filters
const columnFilters = ref<Record<string, string>>({});

// Table features: filtering runs server-side and selection lives in rowSelection;
// the features only provide the table APIs used here (getCanFilter, getVisibleCells, row selection state)
const features = tableFeatures({
columnFilteringFeature,
columnVisibilityFeature,
rowSelectionFeature,
});

// Column definitions
const columns: ColumnDef<ActivityGroupTemplateRead>[] = [
const columns: ColumnDef<typeof features, ActivityGroupTemplateRead>[] = [
{
id: 'select',
header: 'select-header',
cell: 'select-cell',
enableSorting: false,
enableColumnFilter: false,
},
{
Expand All @@ -83,22 +93,14 @@ const columns: ColumnDef<ActivityGroupTemplateRead>[] = [
},
];

const table = useVueTable({
get data() {
return groupTemplates.value;
},
const table = useTable<typeof features, ActivityGroupTemplateRead>({
features,
data: groupTemplates,
columns,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
manualSorting: true,
manualFiltering: true,
enableRowSelection: true,
getRowId: (row) => row.id,
state: {
get rowSelection() {
return rowSelection.value;
},
},
state: computed(() => ({ rowSelection: rowSelection.value })),
onRowSelectionChange: (updaterOrValue) => {
rowSelection.value =
typeof updaterOrValue === 'function'
Expand Down
34 changes: 18 additions & 16 deletions frontend/src/components/assessment/ImportActivityTemplatesModal.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
<script setup lang="ts">
import {
type ColumnDef,
getCoreRowModel,
columnFilteringFeature,
columnVisibilityFeature,
type RowSelectionState,
useVueTable,
rowSelectionFeature,
tableFeatures,
useTable,
} from '@tanstack/vue-table';
import { Check, ChevronDown, Loader2 } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
Expand Down Expand Up @@ -70,13 +73,20 @@ const columnFilters = ref<Record<string, string | string[]>>({});
// Filter options
const priorityOptions = ['Critical', 'High', 'Medium', 'Low'];

// Table features: filtering runs server-side and selection lives in rowSelection;
// the features only provide the table APIs used here (getCanFilter, getVisibleCells, row selection state)
const features = tableFeatures({
columnFilteringFeature,
columnVisibilityFeature,
rowSelectionFeature,
});

// Column definitions
const columns: ColumnDef<ActivityTemplateRead>[] = [
const columns: ColumnDef<typeof features, ActivityTemplateRead>[] = [
{
id: 'select',
header: 'select-header',
cell: 'select-cell',
enableSorting: false,
enableColumnFilter: false,
},
{
Expand Down Expand Up @@ -112,22 +122,14 @@ const columns: ColumnDef<ActivityTemplateRead>[] = [
},
];

const table = useVueTable({
get data() {
return templates.value;
},
const table = useTable<typeof features, ActivityTemplateRead>({
features,
data: templates,
columns,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
manualSorting: true,
manualFiltering: true,
enableRowSelection: true,
getRowId: (row) => row.id,
state: {
get rowSelection() {
return rowSelection.value;
},
},
state: computed(() => ({ rowSelection: rowSelection.value })),
onRowSelectionChange: (updaterOrValue) => {
rowSelection.value =
typeof updaterOrValue === 'function'
Expand Down
Loading
Loading