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
5 changes: 3 additions & 2 deletions app/components/assessment/EvaluationsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
EyeIcon,
RefreshIcon,
} from "@/app/components/icons";
import DataViewModal from "./DataViewModal";
import SpreadsheetModal from "./SpreadsheetModal";
import DownloadDropdown from "./DownloadDropdown";
import {
canRetryStatus,
Expand Down Expand Up @@ -412,7 +412,8 @@ export default function EvaluationsTab({ onForbidden }: EvaluationsTabProps) {
</div>

{previewModal && (
<DataViewModal
<SpreadsheetModal
runId={previewModal.runId}
title={previewModal.title}
subtitle={`${previewModal.rows.length} rows · ${previewModal.headers.length} columns`}
headers={previewModal.headers}
Expand Down
26 changes: 26 additions & 0 deletions app/components/assessment/SpreadsheetModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"use client";

import dynamic from "next/dynamic";
import Loader from "@/app/components/Loader";

const SpreadsheetModalInner = dynamic(() => import("./SpreadsheetModalInner"), {
ssr: false,
loading: () => (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
<Loader size="lg" message="Loading spreadsheet..." />
</div>
),
});
Comment thread
vprashrex marked this conversation as resolved.

interface SpreadsheetModalProps {
runId: number;
title: string;
subtitle?: string;
headers: string[];
rows: string[][];
onClose: () => void;
}

export default function SpreadsheetModal(props: SpreadsheetModalProps) {
return <SpreadsheetModalInner {...props} />;
}
97 changes: 97 additions & 0 deletions app/components/assessment/SpreadsheetModalInner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"use client";

import { useEffect, useRef } from "react";
import { createUniver, defaultTheme, LocaleType } from "@univerjs/presets";
import { UniverSheetsCorePreset } from "@univerjs/preset-sheets-core";
import sheetsEnUS from "@univerjs/preset-sheets-core/locales/en-US";
import "@univerjs/preset-sheets-core/lib/index.css";
import CloseIcon from "@/app/components/icons/document/CloseIcon";
import {
buildSpreadsheetWorkbookData,
loadSpreadsheetState,
persistSpreadsheetState,
} from "@/app/lib/assessment/results";
import type { UniverAPI } from "@/app/lib/types/assessment";

interface SpreadsheetModalInnerProps {
runId: number;
title: string;
subtitle?: string;
headers: string[];
rows: string[][];
onClose: () => void;
}

export default function SpreadsheetModalInner({
runId,
title,
subtitle,
headers,
rows,
onClose,
}: SpreadsheetModalInnerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const univerRef = useRef<{ dispose?: () => void } | null>(null);
Comment thread
vprashrex marked this conversation as resolved.

useEffect(() => {
if (!containerRef.current) return;

const { univerAPI } = createUniver({
locale: LocaleType.EN_US,
locales: { [LocaleType.EN_US]: sheetsEnUS },
theme: defaultTheme,
presets: [UniverSheetsCorePreset({ container: containerRef.current })],
});

const api = univerAPI as unknown as UniverAPI;
univerRef.current = api;

const saved = loadSpreadsheetState(runId);
api.createUniverSheet(saved ?? buildSpreadsheetWorkbookData(headers, rows));

let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const cmdDisposable = api.onCommandExecuted(() => {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
try {
const snapshot = api.getActiveWorkbook()?.save();
if (snapshot) persistSpreadsheetState(runId, snapshot);
} catch {
// silently skip — storage quota exceeded or unavailable
}
}, 1500);
});

return () => {
if (debounceTimer) clearTimeout(debounceTimer);
cmdDisposable.dispose();
api.dispose?.();
univerRef.current = null;
};
}, [runId, headers, rows]);

return (
<div className="fixed inset-0 z-50 flex flex-col bg-black/60 backdrop-blur-sm">
<div className="flex flex-col bg-bg-primary rounded-lg shadow-2xl mx-4 my-4 flex-1 overflow-hidden">
<div className="flex shrink-0 items-center justify-between border-b border-border px-6 py-4">
<div>
<h3 className="text-sm font-semibold text-text-primary">{title}</h3>
{subtitle && (
<p className="mt-0.5 text-xs text-text-secondary">{subtitle}</p>
)}
</div>
<button
type="button"
onClick={onClose}
className="cursor-pointer rounded p-1.5 text-text-secondary transition-colors hover:bg-bg-secondary hover:text-text-primary"
aria-label="Close"
>
<CloseIcon className="w-5 h-5" />
</button>
</div>

<div ref={containerRef} className="flex-1 overflow-hidden" />
</div>
</div>
);
}
11 changes: 8 additions & 3 deletions app/hooks/useAssessmentResults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@ import {
isCompletedStatus,
isFailedStatus,
jsonResultsToTableData,
PREVIEW_ROW_LIMIT,
} from "@/app/lib/assessment/results";
import {
ASSESSMENT_TAG,
RESULTS_POLL_INTERVAL_MS,
SPREADSHEET_PREVIEW_ROW_LIMIT,
} from "@/app/lib/assessment/constants";
import type {
ConfigResponse,
Expand Down Expand Up @@ -438,9 +438,14 @@ export default function useAssessmentResults({
? json
: json.data || [];
const { headers, rows } = jsonResultsToTableData(results, {
rowLimit: PREVIEW_ROW_LIMIT,
rowLimit: SPREADSHEET_PREVIEW_ROW_LIMIT,
});
setPreviewModal({ title: label, headers, rows });
if (results.length > SPREADSHEET_PREVIEW_ROW_LIMIT) {
toast.warning(
`Preview capped at ${SPREADSHEET_PREVIEW_ROW_LIMIT} rows. Download CSV for full data.`,
);
}
setPreviewModal({ runId, title: label, headers, rows });
} catch (error) {
if (handleForbiddenError(error, onForbidden)) return;
toast.error(getAsyncErrorMessage("Preview failed", error));
Expand Down
2 changes: 2 additions & 0 deletions app/lib/assessment/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export const ASSESSMENT_CONFIG_TAG = ASSESSMENT_TAG;
export const ASSESSMENT_CONFIG_VERSION_PAGE_SIZE = 8;

export const RESULTS_POLL_INTERVAL_MS = 60_000;
export const SPREADSHEET_STATE_STORAGE_PREFIX = "kaapi_sheet_state_";
export const SPREADSHEET_PREVIEW_ROW_LIMIT = 5000;

export const MAX_DATASET_FILE_BYTES = 5 * 1024 * 1024;
export const DATASET_SAMPLE_ROW_LIMIT = 10;
Expand Down
64 changes: 64 additions & 0 deletions app/lib/assessment/results.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
ACTIVE_ASSESSMENT_STATUSES,
COMPLETED_ASSESSMENT_STATUSES,
FAILED_ASSESSMENT_STATUSES,
SPREADSHEET_STATE_STORAGE_PREFIX,
} from "@/app/lib/assessment/constants";

export function isActiveStatus(status: string): boolean {
Expand Down Expand Up @@ -69,6 +70,69 @@ export function filterAssessments(

export const PREVIEW_ROW_LIMIT = 10;

export function spreadsheetStorageKey(runId: number): string {
return `${SPREADSHEET_STATE_STORAGE_PREFIX}${runId}`;
}

export function loadSpreadsheetState(runId: number): object | null {
try {
const raw = localStorage.getItem(spreadsheetStorageKey(runId));
return raw ? (JSON.parse(raw) as object) : null;
} catch {
return null;
}
}

export function persistSpreadsheetState(runId: number, data: object): void {
localStorage.setItem(spreadsheetStorageKey(runId), JSON.stringify(data));
}

type SpreadsheetCellEntry = { v: string | number; t: number; s?: object };

export function buildSpreadsheetWorkbookData(
headers: string[],
rows: string[][],
) {
const cellData: Record<number, Record<number, SpreadsheetCellEntry>> = {};

cellData[0] = {};
headers.forEach((h, col) => {
cellData[0][col] = {
v: h,
t: 1,
s: { bl: 1, bg: { rgb: "#EFF6FF" }, cl: { rgb: "#1E40AF" } },
};
});

rows.forEach((row, rowIdx) => {
cellData[rowIdx + 1] = {};
row.forEach((cell, col) => {
const numVal = Number(cell);
const isNum = cell.trim() !== "" && !isNaN(numVal) && isFinite(numVal);
cellData[rowIdx + 1][col] = isNum
? { v: numVal, t: 2 }
: { v: cell, t: 1 };
});
});

return {
id: "assessment-results",
locale: "enUS",
name: "Assessment Results",
appVersion: "0.5.0",
sheets: {
sheet1: {
id: "sheet1",
name: "Results",
cellData,
rowCount: Math.max(rows.length + 1, 100),
columnCount: Math.max(headers.length, 26),
},
},
styles: {},
};
}

export function jsonResultsToTableData(
results: Record<string, unknown>[],
opts?: { skipFields?: Set<string>; rowLimit?: number },
Expand Down
8 changes: 8 additions & 0 deletions app/lib/types/assessment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,15 @@ export interface ResultsCounts {
failed: number;
}

export type UniverAPI = {
dispose?: () => void;
onCommandExecuted: (cb: () => void) => { dispose: () => void };
getActiveWorkbook: () => { save: () => object } | null;
createUniverSheet: (d: object) => void;
};

export interface AssessmentResultsPreview {
runId: number;
title: string;
headers: string[];
rows: string[][];
Expand Down
Loading
Loading