diff --git a/package.json b/package.json index 293b23fc..9fc09d95 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "test": "vitest" + "test": "vitest --config vitest.config.ts" }, "eslintConfig": { "extends": [ diff --git a/setUp.ts b/setUp.ts new file mode 100644 index 00000000..16a28e21 --- /dev/null +++ b/setUp.ts @@ -0,0 +1,7 @@ +// src/setupTests.ts +import { vi } from "vitest"; +import "@testing-library/jest-dom"; + +// Make Vitest's `vi` available as global `jest` so existing Jest-style tests work +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).jest = vi; diff --git a/src/components/Modals/ExportModal.tsx b/src/components/Modals/ExportModal.tsx new file mode 100644 index 00000000..9e13af17 --- /dev/null +++ b/src/components/Modals/ExportModal.tsx @@ -0,0 +1,408 @@ +// src/components/ExportModal.tsx +import React, { useEffect, useState, memo, useCallback } from "react"; +import { Modal, Button, Form, Row, Col, OverlayTrigger, Tooltip } from "react-bootstrap"; +import useAPI from "../../hooks/useAPI"; +import { HttpMethod } from "../../utils/httpMethods"; + +/* ============================================================================= + Shared visual style — same as CreateTeams.tsx +============================================================================= */ + +const STANDARD_TEXT: React.CSSProperties = { + fontFamily: 'verdana, arial, helvetica, sans-serif', + color: '#333', + fontSize: '13px', + lineHeight: '30px', +}; + +const TABLE_TEXT: React.CSSProperties = { + fontFamily: 'verdana, arial, helvetica, sans-serif', + color: '#333', + fontSize: '15px', + lineHeight: '1.428em', +}; + +/* ============================================================================= + Icon utilities — same pattern as Import modal +============================================================================= */ + +const getBaseUrl = (): string => { + if (typeof document !== 'undefined') { + const base = document.querySelector('base[href]') as HTMLBaseElement | null; + if (base?.href) return base.href.replace(/\/$/, ''); + } + const fromGlobal = (globalThis as any)?.__BASE_URL__; + if (typeof fromGlobal === 'string' && fromGlobal) return fromGlobal.replace(/\/$/, ''); + const fromProcess = + (typeof process !== 'undefined' && (process as any)?.env?.PUBLIC_URL) || ''; + return String(fromProcess).replace(/\/$/, ''); +}; + +const assetUrl = (rel: string) => `${getBaseUrl()}/${rel.replace(/^\//, '')}`; + + + +const ICONS = { + info: 'assets/images/info-icon-16.png', +} as const; + +type IconName = keyof typeof ICONS; + +const Icon: React.FC<{ + name: IconName; + size?: number; + alt?: string; + className?: string; + style?: React.CSSProperties; +}> = memo(({ name, size = 16, alt, className, style }) => ( + {alt +)); +Icon.displayName = 'Icon'; + +/* ============================================================================= + Types +============================================================================= */ + +// type ExportConfig = { +// class_name: string; +// mandatory_fields: string[]; +// optional_fields: string[]; +// default_ordered_fields: string[]; +// }; + +type ExportModal = { + show: boolean; + onHide: () => void; + modelClass: string; +}; + +/* ============================================================================= + Component (dummy mode – no backend) +============================================================================= */ + +const ExportModal: React.FC = ({ show, onHide, modelClass }) => { + const [mandatoryFields, setMandatoryFields] = useState([]); + const [optionalFields, setOptionalFields] = useState([]); + const [externalFields, setExternalFields] = useState([]); + const [allFields, setAllFields] = useState([]); + const [selectedFields, setSelectedFields] = useState([]); + const [status, setStatus] = useState(''); + const { error, isLoading, data: exportResponse, sendRequest: fetchExports } = useAPI(); + const { data: sendExportResponse, error: exportError, sendRequest: sendExport } = useAPI(); + + const fetchConfig = useCallback(async () => { + try { + fetchExports({ url: `/export/${modelClass}` }); + // Handle the responses as needed + } catch (err) { + // Handle any errors that occur during the fetch + console.error("Error fetching data:", err); + } + }, [fetchExports]); + + const transformField = (field: string) => { + let f = field.replace(/_/g, " "); + return f.charAt(0).toUpperCase() + f.slice(1); + }; + + /** Format fields for multiline tooltip display */ + const formatTooltipList = (fields: string[]) => { + return ( +
+ {fields.map((f) => transformField(f)).join("\n")} +
+ ); + }; + + useEffect(() => { + if (!show) return; + + fetchConfig() + }, [show]); + + useEffect(() => { + if (exportResponse) { + setMandatoryFields(exportResponse.data.mandatory_fields); + setOptionalFields(exportResponse.data.optional_fields); + setExternalFields(exportResponse.data.external_fields); + + const fields = [ + ...exportResponse.data.mandatory_fields, + ...exportResponse.data.optional_fields, + ...exportResponse.data.external_fields + ] + + setAllFields(fields) + setSelectedFields(exportResponse.data.mandatory_fields) + + setStatus(''); + } + }, [exportResponse]); + + const toggleField = (field: string) => { + setSelectedFields((prev) => + prev.includes(field) ? prev.filter((f) => f !== field) : [...prev, field], + ); + }; + + const moveFieldUp = (index: number) => { + if (index <= 0) return; + setAllFields((prev) => { + const copy = [...prev]; + [copy[index - 1], copy[index]] = [copy[index], copy[index - 1]]; + return copy; + }); + }; + + const moveFieldDown = (index: number) => { + setAllFields((prev) => { + if (index < 0 || index >= prev.length - 1) return prev; + const copy = [...prev]; + [copy[index], copy[index + 1]] = [copy[index + 1], copy[index]]; + return copy; + }); + }; + + function getFormattedDateTimeForFilename() { + const now = new Date(); + + // Get year, month, day + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); // Months are 0-indexed + const day = String(now.getDate()).padStart(2, '0'); + + // Get hours, minutes, seconds + const hours = String(now.getHours()).padStart(2, '0'); + const minutes = String(now.getMinutes()).padStart(2, '0'); + const seconds = String(now.getSeconds()).padStart(2, '0'); + + // Combine into a string without invalid characters + return `${year}${month}${day}_${hours}${minutes}${seconds}`; + } + + const downloadFile = (file) => { + const url = window.URL.createObjectURL(new Blob([file])) + const link = document.createElement('a') + link.href = url + + const timestamp = Date.now().toLocaleString(); + + link.setAttribute('download', `${modelClass}_export_${getFormattedDateTimeForFilename()}.csv`) + document.body.appendChild(link) + link.click() + link.remove() + } + const on_export = async () => { + if (selectedFields.length === 0) { + setStatus('Please select at least one field.'); + return; + } + + setStatus('Generating CSV…'); + + try { + const formData = new FormData(); + + const orderedFields = allFields.filter((f) => selectedFields.includes(f)); + + formData.append("ordered_fields", JSON.stringify(orderedFields)); + + let url = `/export/${modelClass}`; + + await sendExport({ + url, + method: HttpMethod.POST, + data: formData, + headers: { "Content-Type": "multipart/form-data" }, + }); + + + console.log(sendExportResponse) + + } catch (err: any) { + setStatus(err.message || "Unexpected error."); + } + + }; + + + useEffect(() => { + if(sendExportResponse) { + setStatus(sendExportResponse.data.message); + downloadFile(sendExportResponse.data.file) + + if (!exportError){ + setTimeout(onHide, 1500); + } + } else if (exportError) { + setStatus(exportError); + } + }, [sendExportResponse, exportError]); + + return ( + + + + Export {modelClass} + + + + + {isLoading ? ( +
Loading…
+ ) : ( + <> + + +
+
+ Mandatory fields + + {formatTooltipList(mandatoryFields)} + + } + > + + + + +
+
+ Optional fields: + + {formatTooltipList(optionalFields)} + + } + > + + + + +
+
+ Optional fields: + + {formatTooltipList(externalFields)} + + } + > + + + + +
+
+ +
+ + + + + Columns to export + +
+ Only checked fields will be included. Use ↑ / ↓ to adjust column order. +
+
+ {selectedFields.length === 0 ? ( + + No fields selected. + + ) : ( + allFields.map((field, idx) => ( +
+ toggleField(field)} + label={field} + disabled={mandatoryFields.includes(field)} + /> +
+ + +
+
+ )) + )} +
+ +
+ + {status && ( + + +
+ + Status: {status} +
+ +
+ )} + + )} +
+ + + + + +
+ ); +}; + +export default ExportModal; diff --git a/src/components/Modals/ImportModal.test.tsx b/src/components/Modals/ImportModal.test.tsx new file mode 100644 index 00000000..9a7867f2 --- /dev/null +++ b/src/components/Modals/ImportModal.test.tsx @@ -0,0 +1,241 @@ +// src/components/Modals/ImportModal.test.tsx +import React from "react"; +import { + render, + screen, + fireEvent, + act, +} from "@testing-library/react"; +import "@testing-library/jest-dom"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, beforeEach, vi } from "vitest"; + +import ImportModal from "./ImportModal"; + +const mockUseAPI = vi.fn(); + +vi.mock("../../hooks/useAPI", () => ({ + __esModule: true, + default: (...args: any[]) => mockUseAPI(...args), +})); + +type UseAPIResult = { + error: unknown; + isLoading: boolean; + data: any; + sendRequest: ReturnType; +}; + +const makeUseAPIResult = ( + overrides: Partial = {} +): UseAPIResult => ({ + error: null, + isLoading: false, + data: null, + sendRequest: vi.fn(), + ...overrides, +}); + +const IMPORT_METADATA = { + mandatory_fields: ["email"], + optional_fields: ["name"], + external_fields: ["external_id"], + available_actions_on_dup: ["SkipRecordAction", "UpdateExistingRecordAction"], +}; + +describe("ImportModal", () => { + const onHide = vi.fn(); + + beforeEach(() => { + mockUseAPI.mockReset(); + onHide.mockReset(); + }); + + it("shows loading state when isLoading is true", async () => { + // Both useAPI calls return loading=true + mockUseAPI.mockReturnValue( + makeUseAPIResult({ isLoading: true }) + ); + + await act(async () => { + render(); + }); + + expect(screen.getByText(/Loading…/i)).toBeInTheDocument(); + }); + + it("renders field summary and duplicate options from metadata", async () => { + // First and second useAPI calls can share the same mock result + mockUseAPI.mockReturnValue( + makeUseAPIResult({ data: { data: IMPORT_METADATA } }) + ); + + await act(async () => { + render(); + }); + + // Section labels + await screen.findByText(/Mandatory fields:/i); + await screen.findByText(/Optional fields:/i); + await screen.findByText(/External fields:/i); + + // Field names are present somewhere in the summary text + expect(screen.getByText("email")).toBeInTheDocument(); + expect(screen.getByText("name")).toBeInTheDocument(); + expect(screen.getByText("external_id")).toBeInTheDocument(); + + // Duplicate handling radios + const skipRadio = screen.getByLabelText("SkipRecordAction") as HTMLInputElement; + const overwriteRadio = screen.getByLabelText("UpdateExistingRecordAction") as HTMLInputElement; + + expect(skipRadio).toBeInTheDocument(); + expect(overwriteRadio).toBeInTheDocument(); + // First option should be selected by default + expect(skipRadio.checked).toBe(true); + + // Switching duplicate option should update selection + await act(async () => { + overwriteRadio.click(); + }); + + expect(overwriteRadio.checked).toBe(true); + expect(skipRadio.checked).toBe(false); + }); + + it("shows status when importing with no file selected", async () => { + const user = userEvent.setup(); + + // No metadata needed here; just a basic hook result + mockUseAPI.mockReturnValue(makeUseAPIResult()); + + await act(async () => { + render(); + }); + + const importButton = screen.getByRole("button", { name: /import/i }); + + await act(async () => { + await user.click(importButton); + }); + + expect( + await screen.findByText(/Please select a CSV file/i) + ).toBeInTheDocument(); + }); + + it("shows column mapping and first-row values when header mode is off", async () => { + const user = userEvent.setup(); + + mockUseAPI.mockReturnValue( + makeUseAPIResult({ data: { data: IMPORT_METADATA } }) + ); + + await act(async () => { + render(); + }); + + // Wait for metadata to populate + await screen.findByText(/Mandatory fields:/i); + + const fileInput = screen.getByLabelText(/CSV file/i) as HTMLInputElement; + + // Fake "File" object with a .text() method + const fakeFile = { + name: "test.csv", + text: vi.fn().mockResolvedValue( + "email,name\nuser@example.com,Test User" + ), + }; + + await act(async () => { + fireEvent.change(fileInput, { + target: { files: [fakeFile] as any }, + }); + }); + + // Turn OFF header mode so column mapping UI appears + const headerSwitch = screen.getByLabelText( + /First row contains headers/i + ) as HTMLInputElement; + + await act(async () => { + await user.click(headerSwitch); + }); + + expect(headerSwitch.checked).toBe(false); + + // Column order section should now be visible + const columnOrderLabel = await screen.findByText(/Column order/i); + expect(columnOrderLabel).toBeInTheDocument(); + + // First-row previews should show the values from the second CSV line + await screen.findByText(/First Row Value: user@example\.com/i); + await screen.findByText(/First Row Value: Test User/i); + }); + + it("calls sendImport when import is valid", async () => { + const user = userEvent.setup(); + const sendImportSpy = vi.fn(); + + // Single hook result used for both fetchImports and sendImport. + // sendRequest will act as sendImport here. + mockUseAPI.mockReturnValue( + makeUseAPIResult({ + data: { data: IMPORT_METADATA }, + sendRequest: sendImportSpy, + }) + ); + + await act(async () => { + render(); + }); + + await screen.findByText(/Mandatory fields:/i); + + const fileInput = screen.getByLabelText(/CSV file/i) as HTMLInputElement; + + // CSV where the header is "email" (the only mandatory field) + // so selectedFields will all be "email" and mandatoryFieldsIncluded() will pass. + const fakeFile = { + name: "test.csv", + text: vi.fn().mockResolvedValue( + "email\nuser1@example.com" + ), + }; + + await act(async () => { + fireEvent.change(fileInput, { + target: { files: [fakeFile] as any }, + }); + }); + + const importButton = screen.getByRole("button", { name: /import/i }); + + await act(async () => { + await user.click(importButton); + }); + + // fetchConfig also calls sendRequest once; we only care that it was called at least once for import. + expect(sendImportSpy).toHaveBeenCalled(); + }); + + it("calls onHide when cancel is clicked", async () => { + mockUseAPI.mockReturnValue( + makeUseAPIResult({ data: { data: IMPORT_METADATA } }) + ); + + await act(async () => { + render(); + }); + + await screen.findByText(/Mandatory fields:/i); + + const cancelButton = screen.getByRole("button", { name: /cancel/i }); + + await act(async () => { + cancelButton.click(); + }); + + expect(onHide).toHaveBeenCalled(); + }); +}); diff --git a/src/components/Modals/ImportModal.tsx b/src/components/Modals/ImportModal.tsx new file mode 100644 index 00000000..a107aa77 --- /dev/null +++ b/src/components/Modals/ImportModal.tsx @@ -0,0 +1,535 @@ +// src/components/ImportModal.tsx + +import React, { useEffect, useState, memo, useCallback, ChangeEvent } from "react"; +import { + Modal, + Button, + Form, + Row, + Col, + OverlayTrigger, + Tooltip, + Container, + CloseButton, +} from "react-bootstrap"; + +import useAPI from "../../hooks/useAPI"; +import { HttpMethod } from "../../utils/httpMethods"; + +/* ---------------------------------------- + * Shared text styles for consistency + * ---------------------------------------- */ +const STANDARD_TEXT: React.CSSProperties = { + fontFamily: 'verdana, arial, helvetica, sans-serif', + color: '#333', + fontSize: '13px', + lineHeight: '30px', +}; + +const TABLE_TEXT: React.CSSProperties = { + fontFamily: 'verdana, arial, helvetica, sans-serif', + color: '#333', + fontSize: '15px', + lineHeight: '1.428em', +}; + +/* ---------------------------------------- + * Icon utilities — used for tooltip icons + * ---------------------------------------- */ + +/** Helper to resolve asset URLs correctly even under nested routes */ +const getBaseUrl = (): string => { + if (typeof document !== "undefined") { + const base = document.querySelector("base[href]") as HTMLBaseElement | null; + if (base?.href) return base.href.replace(/\/$/, ""); + } + + const fromGlobal = (globalThis as any)?.__BASE_URL__; + if (typeof fromGlobal === "string") return fromGlobal.replace(/\/$/, ""); + + const fromProcess = + (typeof process !== "undefined" && (process as any)?.env?.PUBLIC_URL) || ""; + + return String(fromProcess).replace(/\/$/, ""); +}; + +/** Helper for converting relative asset paths to usable URLs */ +const assetUrl = (rel: string) => `${getBaseUrl()}/${rel.replace(/^\//, "")}`; + +/** Asset map */ +const ICONS = { + info: "assets/images/info-icon-16.png", +} as const; + +/** Icon Types */ +type IconName = keyof typeof ICONS; + +/** + * Reusable component. + * Wrapped in React.memo to prevent unnecessary re-renders. + */ +const Icon: React.FC<{ + name: IconName; + size?: number; + alt?: string; + className?: string; + style?: React.CSSProperties; +}> = memo(({ name, size = 16, alt, className, style }) => ( + {alt +)); +Icon.displayName = "Icon"; + +/* ---------------------------------------- + * Props + * ---------------------------------------- */ +type ImportModalProps = { + show: boolean; // Parent-controlled visible flag + onHide: () => void; // Callback to parent when modal should close + modelClass: string; // "User", "Team", etc. +}; + +/* ============================================================================ + * ImportModal Component + * ============================================================================ */ +const ImportModal: React.FC = ({ show, onHide, modelClass }) => { + + /** + * Force-close handler — ALWAYS closes modal instantly. + * Then notifies parent so it can update state if needed. + */ + const forceClose = () => { + setTimeout(onHide, 10); // Notify parent AFTER close + }; + + /* --------------------------------------------------------- + * API metadata state + * --------------------------------------------------------- */ + const [mandatoryFields, setMandatoryFields] = useState([]); + const [optionalFields, setOptionalFields] = useState([]); + const [externalFields, setExternalFields] = useState([]); + const [duplicateActions, setDuplicateActions] = useState([]); + + /* CSV parsing & selection state */ + const [csvFirstLine, setCsvFirstLine] = useState([]); + const [selectedFields, setSelectedFields] = useState([]); + const [availableFields, setAvailableFields] = useState([]); + + const [duplicateAction, setDuplicateAction] = useState(""); + + const [file, setFile] = useState(null); + const [useHeader, setUseHeader] = useState(true); + const [status, setStatus] = useState(""); + + /* API hooks */ + const { isLoading, data: importResponse, sendRequest: fetchImports } = useAPI(); + const { error: importError, data: sendImportResponse, sendRequest: sendImport } = useAPI(); + + /* --------------------------------------------------------- + * Fetch import metadata from backend whenever modal opens + * --------------------------------------------------------- */ + + const fetchConfig = useCallback(async () => { + try { + await fetchImports({ url: `/import/${modelClass}` }); + } catch (err) { + console.error("Error fetching import config:", err); + } + }, [fetchImports, modelClass]); + + useEffect(() => { + if (show) { + setStatus(''); + setFile(null); + setUseHeader(true); + fetchConfig(); + } + }, [show, fetchConfig]); + + /* --------------------------------------------------------- + * Transform "column_name" → "Column name" + * --------------------------------------------------------- */ + const transformField = (field: string) => { + let f = field.replace(/_/g, " "); + return f.charAt(0).toUpperCase() + f.slice(1); + }; + + /** Format fields for multiline tooltip display */ + const formatTooltipList = (fields: string[]) => { + return ( +
+ {fields.map((f) => transformField(f)).join("\n")} +
+ ); + }; + + /* --------------------------------------------------------- + * Once metadata arrives from backend, populate state + * --------------------------------------------------------- */ + useEffect(() => { + if (!importResponse) return; + + const data = importResponse.data; + + setMandatoryFields(data.mandatory_fields); + setOptionalFields(data.optional_fields); + setExternalFields(data.external_fields); + setDuplicateActions(data.available_actions_on_dup); + + setAvailableFields([ + ...data.mandatory_fields, + ...data.optional_fields, + ...data.external_fields, + ]); + + setDuplicateAction(data.available_actions_on_dup[0] ?? ""); + }, [importResponse]); + + /* --------------------------------------------------------- + * CSV upload handler — extract headers + first content row + * --------------------------------------------------------- */ + const on_file_changed = async (incomingFile: File) => { + setFile(incomingFile); + + if (availableFields.length === 0) return; + + const text = await incomingFile.text(); + const lines = text.split("\n").filter(Boolean); + + if (lines.length > 0) { + const headers = lines[0].split(","); + + setSelectedFields(new Array(headers.length).fill(availableFields[0])); + + if (lines.length > 1) { + setCsvFirstLine(lines[1].split(",")); + } else { + setCsvFirstLine(headers); + } + } + }; + + /* Update field selection for each dropdown */ + const handleSelectField = (event: ChangeEvent, colIndex: number) => { + let copy = [...selectedFields]; + // @ts-ignore + copy[colIndex] = event.target.value; + setSelectedFields(copy); + }; + + /* Ensure all selected columns match mandatory fields */ + const mandatoryFieldsIncluded = () => + mandatoryFields.every((f) => selectedFields.includes(f)); + + /* --------------------------------------------------------- + * Submit import to backend + * --------------------------------------------------------- */ + const on_import = async () => { + if (!file) { + setStatus("Please select a CSV file."); + return; + } + + if (!useHeader && !mandatoryFieldsIncluded()) { + setStatus("Please make sure all mandatory fields are selected"); + return; + } + + setStatus("Importing…"); + + try { + const formData = new FormData(); + formData.append("csv_file", file); + formData.append("use_headers", String(useHeader)); + + if (duplicateAction) { + formData.append("dup_action", duplicateAction) + } + + if (!useHeader) { + formData.append("ordered_fields", JSON.stringify(selectedFields)); + } + + let url = `/import/${modelClass}`; + + await sendImport({ + url, + method: HttpMethod.POST, + data: formData, + headers: { "Content-Type": "multipart/form-data" }, + }); + + } catch (err: any) { + setStatus(err.message || "Unexpected error."); + } + }; + + useEffect(() => { + if(sendImportResponse) { + setStatus(sendImportResponse.data.message); + + if (!importError){ + setTimeout(forceClose, 1500); + } + } else if (importError) { + setStatus(importError); + } + }, [sendImportResponse, importError]); + + /* ============================================================================ + * Render + * ============================================================================ */ + return ( + + + + Import {modelClass} + + + + + {isLoading ? ( +
Loading…
+ ) : ( + <> + {/* --------------------------------------------------------- + * FIELD SUMMARY + * --------------------------------------------------------- */} + + +
+ + {/* Mandatory fields */} +
+ Mandatory fields + + {formatTooltipList(mandatoryFields)} + + } + > + + + + +
+ + {/* Optional fields */} +
+ Optional fields + + {formatTooltipList(optionalFields)} + + } + > + + + + +
+ + {/* External fields */} +
+ External fields + + {formatTooltipList(externalFields)} + + } + > + + + + +
+ +
+ +
+ + {/* --------------------------------------------------------- + * FILE INPUT + HEADER SWITCH + * --------------------------------------------------------- */} + + + + + CSV file + + + on_file_changed(e.target.files?.[0] ?? null)} + /> + + + + + setUseHeader(e.target.checked)} + style={TABLE_TEXT} + /> + + + In header mode, fields are matched by name. + + } + > + + + + + + + + {/* --------------------------------------------------------- + * COLUMN ORDER (only appears when not using header row) + * --------------------------------------------------------- */} + {selectedFields.length > 0 && !useHeader && ( + + + + + Column order + + +
+ Select the header for each column. Mandatory fields must be selected. +
+ +
+ {selectedFields.map((field, columnIndex) => ( +
+ + + {/* Dropdown for selecting header */} + + handleSelectField(e, columnIndex)} + className="auto-width-select-import" + style={{ + width: "auto", + minWidth: "max-content", + display: "inline-block", + }} + > + {availableFields.map((field, idx) => ( + + ))} + + + + {/* First row preview text */} + + {csvFirstLine[columnIndex] + ? `First Row Value: ${csvFirstLine[columnIndex]}` + : ""} + + + +
+ ))} +
+ + +
+ )} + + {/* --------------------------------------------------------- + * DUPLICATE HANDLING + * --------------------------------------------------------- */} + + + + Duplicate handling + + + {duplicateActions.map((action) => ( + setDuplicateAction(action)} + label={action} + /> + ))} + + + + {/* STATUS SECTION */} + {status && ( + + +
+ Status: {status} +
+ +
+ )} + + )} +
+ + {/* FOOTER */} + + + + + +
+ ); +}; + +export default ImportModal; diff --git a/src/index.css b/src/index.css index 69cf840e..426705f8 100644 --- a/src/index.css +++ b/src/index.css @@ -5,4 +5,12 @@ html { font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 14px; -} \ No newline at end of file +} + +/* Ensure dropdown width matches the longest option text */ +.auto-width-select-import { + width: auto !important; + min-width: max-content !important; + display: inline-block !important; + padding-right: 2rem; /* keeps the dropdown arrow visible */ +} diff --git a/src/pages/Assignments/CreateTeams.tsx b/src/pages/Assignments/CreateTeams.tsx index 17304e1c..eee84265 100644 --- a/src/pages/Assignments/CreateTeams.tsx +++ b/src/pages/Assignments/CreateTeams.tsx @@ -1,5 +1,946 @@ +// // src/pages/Assignments/CreateTeams.tsx +// import React, { useMemo, useState, useCallback, useRef, memo } from 'react'; +// import { +// Button, +// Container, +// Row, +// Col, +// Modal, +// Form, +// Tabs, +// Tab, +// OverlayTrigger, +// Tooltip, +// } from 'react-bootstrap'; +// import { useLoaderData, useNavigate } from 'react-router-dom'; + +// /* ============================================================================= +// Types +// ============================================================================= */ + +// type ContextType = 'assignment' | 'course'; + +// interface Participant { +// id: string | number; +// username: string; +// fullName?: string; +// teamName?: string; +// } + +// interface Team { +// id: string | number; +// name: string; +// mentor?: Participant; +// members: Participant[]; +// } + +// interface LoaderPayload { +// contextType?: ContextType; +// contextName?: string; +// initialTeams?: Team[]; +// initialUnassigned?: Participant[]; +// } + +// /* ============================================================================= +// Assets (icons used only where required) +// ============================================================================= */ + +// // const publicUrl = +// // (import.meta as any)?.env?.BASE_URL ?? +// // (typeof process !== 'undefined' ? (process as any)?.env?.PUBLIC_URL : '') ?? +// // ''; +// // +// // const assetUrl = (rel: string) => +// // `${publicUrl.replace(/\/$/, '')}/${rel.replace(/^\//, '')}`; + +// // Safe base URL (no import.meta) +// const getBaseUrl = (): string => { +// // 1) if present +// if (typeof document !== 'undefined') { +// const base = document.querySelector('base[href]') as HTMLBaseElement | null; +// if (base?.href) return base.href.replace(/\/$/, ''); +// } +// // 2) Optional global you can set from Rails/layout, etc. +// const fromGlobal = (globalThis as any)?.__BASE_URL__; +// if (typeof fromGlobal === 'string' && fromGlobal) return fromGlobal.replace(/\/$/, ''); + +// // 3) CRA-style env if available in tests/builds +// const fromProcess = +// (typeof process !== 'undefined' && (process as any)?.env?.PUBLIC_URL) || ''; +// return String(fromProcess).replace(/\/$/, ''); +// }; + +// const assetUrl = (rel: string) => +// `${getBaseUrl()}/${rel.replace(/^\//, '')}`; + +// const ICONS = { +// add: 'assets/icons/add-participant-24.png', +// delete: 'assets/images/delete-icon-24.png', +// edit: 'assets/images/edit-icon-24.png', +// } as const; + +// type IconName = keyof typeof ICONS; + +// const Icon: React.FC<{ +// name: IconName; +// size?: number; +// alt?: string; +// className?: string; +// style?: React.CSSProperties; +// }> = memo(({ name, size = 16, alt, className, style }) => ( +// {alt +// )); +// Icon.displayName = 'Icon'; + +// /* ============================================================================= +// Demo data +// ============================================================================= */ + +// const sampleUnassigned: Participant[] = [ +// { id: 2001, username: 'Student 10933', fullName: 'Kai Moore' }, +// { id: 2002, username: 'Student 10934', fullName: 'Rowan Diaz' }, +// { id: 2003, username: 'Student 10935', fullName: 'Parker Lee' }, +// { id: 2004, username: 'Student 10936', fullName: 'Jamie Rivera' }, +// ]; + +// const sampleTeams: Team[] = [ +// { +// id: 't1', +// name: 'sshivas MentoredTeam', +// mentor: { +// id: 'm1', +// username: 'Teaching Assistant 10816', +// fullName: 'Teaching Assistant 10816', +// }, +// members: [ +// { +// id: 1001, +// username: 'Student 10917', +// fullName: 'Avery Chen', +// teamName: 'sshivas MentoredTeam', +// }, +// { +// id: 1002, +// username: 'Student 10916', +// fullName: 'Jordan Park', +// teamName: 'sshivas MentoredTeam', +// }, +// { +// id: 1003, +// username: 'Teaching Assistant 10816 (Mentor)', +// fullName: 'Teaching Assistant 10816 (Mentor)', +// teamName: 'sshivas MentoredTeam', +// }, +// { +// id: 1004, +// username: 'Student 10928', +// fullName: 'Sam Patel', +// teamName: 'sshivas MentoredTeam', +// }, +// ], +// }, +// { +// id: 't2', +// name: 'agaudan MentoredTeam', +// mentor: { +// id: 'm2', +// username: 'Teaching Assistant 10624', +// fullName: 'Teaching Assistant 10624', +// }, +// members: [ +// { +// id: 1005, +// username: 'Student 10925', +// fullName: 'Riley Gomez', +// teamName: 'agaudan MentoredTeam', +// }, +// ], +// }, +// { +// id: 't3', +// name: 'tjbrown8 MentoredTeam', +// mentor: { +// id: 'm3', +// username: 'Teaching Assistant 10199', +// fullName: 'Teaching Assistant 10199', +// }, +// members: [ +// { +// id: 1006, +// username: 'Student 10909', +// fullName: 'Taylor Nguyen', +// teamName: 'tjbrown8 MentoredTeam', +// }, +// { +// id: 1007, +// username: 'Student 10921', +// fullName: 'Casey Morgan', +// teamName: 'tjbrown8 MentoredTeam', +// }, +// { +// id: 1008, +// username: 'Teaching Assistant 10199 (Mentor)', +// fullName: 'Teaching Assistant 10199 (Mentor)', +// teamName: 'tjbrown8 MentoredTeam', +// }, +// ], +// }, +// { +// id: 't4', +// name: 'IronMan2 MentoredTeam', +// mentor: { +// id: 'm4', +// username: 'Teaching Assistant 10234', +// fullName: 'Teaching Assistant 10234', +// }, +// members: [ +// { +// id: 1009, +// username: 'Student 10931', +// fullName: 'Aria Brooks', +// teamName: 'IronMan2 MentoredTeam', +// }, +// { +// id: 1010, +// username: 'Student 10932', +// fullName: 'Noah Shah', +// teamName: 'IronMan2 MentoredTeam', +// }, +// ], +// }, +// ]; + +// /* ============================================================================= +// Typography +// - Standard text: 13px / 30px +// - Subheading (provided for future use): 1.2em / 18px +// - Table data: 15px / 1.428em +// ============================================================================= */ + +// const HEADING_TEXT: React.CSSProperties = { +// fontSize: '30px', +// lineHeight: '1.2em', +// fontWeight: 700, +// }; + +// const STANDARD_TEXT: React.CSSProperties = { +// fontFamily: 'verdana, arial, helvetica, sans-serif', +// color: '#333', +// fontSize: '13px', +// lineHeight: '30px', +// }; + +// const SUBHEADING_TEXT: React.CSSProperties = { +// fontSize: '1.2em', +// lineHeight: '18px', +// }; + +// const TABLE_TEXT: React.CSSProperties = { +// fontFamily: 'verdana, arial, helvetica, sans-serif', +// color: '#333', +// fontSize: '15px', +// lineHeight: '1.428em', +// }; + +// /* ============================================================================= +// Layout / Reusable Styles +// ============================================================================= */ + +// const pageWrap: React.CSSProperties = { +// ...STANDARD_TEXT, +// maxWidth: 1160, +// margin: '20px auto 40px', +// padding: '0 16px', +// }; + +// const frame: React.CSSProperties = { +// border: '1px solid #9aa0a6', +// borderRadius: 12, +// backgroundColor: '#fff', +// boxShadow: '0 1px 2px rgba(0,0,0,0.04)', +// overflow: 'hidden', +// }; + +// const headerBar: React.CSSProperties = { +// background: '#f7f8fa', +// padding: '12px 16px', +// borderBottom: '1px solid #e4e6eb', +// fontWeight: 600, +// display: 'flex', +// }; + +// const teamRowStyle: React.CSSProperties = { +// display: 'flex', +// alignItems: 'center', +// padding: '10px 16px', +// background: '#d8d8b8', +// borderBottom: '1px solid #ebe9dc', +// whiteSpace: 'nowrap', +// }; + +// const membersRowBase: React.CSSProperties = { +// padding: '12px 16px', +// background: '#ffffff', +// borderBottom: '1px solid #f0f1f3', +// }; + +// const caretButton: React.CSSProperties = { +// border: 'none', +// background: 'transparent', +// cursor: 'pointer', +// fontSize: 14, +// lineHeight: 1, +// padding: 0, +// width: 24, +// height: 24, +// }; + +// const actionCell: React.CSSProperties = { width: 200, textAlign: 'right' }; + +// const chipBase: React.CSSProperties = { +// display: 'inline-flex', +// alignItems: 'center', +// padding: '6px 12px', +// marginRight: 10, +// marginBottom: 10, +// background: '#ffffff', +// border: '1px solid #e2e8f0', +// borderRadius: 18, +// boxShadow: '0 1px 0 rgba(0,0,0,0.03)', +// }; + +// const chipRemoveButton: React.CSSProperties = { +// marginLeft: 10, +// border: 'none', +// background: 'transparent', +// cursor: 'pointer', +// padding: 0, +// lineHeight: 1, +// }; + +// const toolbarWrap: React.CSSProperties = { margin: '4px 0 10px' }; +// const toolbarLinkBase: React.CSSProperties = { +// ...STANDARD_TEXT, +// color: '#8b5e3c', +// background: 'transparent', +// border: 'none', +// padding: 0, +// margin: 0, +// cursor: 'pointer', +// textDecoration: 'none', +// }; +// const pipe: React.CSSProperties = { margin: '0 8px', color: '#8b5e3c' }; + +// /* ============================================================================= +// Small presentational helpers +// ============================================================================= */ + +// const ToolbarLink: React.FC<{ +// onClick: () => void; +// children: React.ReactNode; +// }> = ({ onClick, children }) => ( +// +// ); + +// const MentorRemovalButton: React.FC<{ onClick: () => void }> = ({ onClick }) => ( +// Remove mentor}> +// +// +// ); + +// /* ============================================================================= +// Main Component +// ============================================================================= */ + +// const CreateTeams: React.FC<{ contextType?: ContextType; contextName?: string }> = ({ +// contextType, +// contextName, +// }) => { +// // Loader / routing +// const loader = (useLoaderData?.() as LoaderPayload) || {}; +// const navigate = useNavigate(); + +// // Context +// const ctxType = (contextType || loader.contextType || 'assignment') as ContextType; +// const ctxName = contextName || loader.contextName || 'Program'; + +// // Initial data +// const baseTeams = loader.initialTeams || sampleTeams; +// const baseUnassigned = loader.initialUnassigned || sampleUnassigned; + +// // Compute initial unassigned list excluding already-assigned members +// const initialUnassigned = useMemo(() => { +// const assignedIds = new Set( +// baseTeams.flatMap((t) => t.members.map((m) => String(m.id))), +// ); +// return baseUnassigned.filter((u) => !assignedIds.has(String(u.id))); +// }, [baseTeams, baseUnassigned]); + +// // State +// const [teams, setTeams] = useState(baseTeams); +// const [unassigned, setUnassigned] = useState(initialUnassigned); +// const [expanded, setExpanded] = useState>( +// () => Object.fromEntries(baseTeams.map((t) => [t.id, true])), +// ); +// const [showUsernames, setShowUsernames] = useState(true); + +// const [showAddModal, setShowAddModal] = useState(false); +// const [showEditModal, setShowEditModal] = useState(false); +// const [showCreateModal, setShowCreateModal] = useState(false); +// const [showCopyToModal, setShowCopyToModal] = useState(false); +// const [showCopyFromModal, setShowCopyFromModal] = useState(false); + +// const [selectedTeam, setSelectedTeam] = useState(null); +// const [selectedParticipantId, setSelectedParticipantId] = useState(''); +// const [editTeamName, setEditTeamName] = useState(''); +// const [newTeamName, setNewTeamName] = useState(''); +// const [copyTarget, setCopyTarget] = useState(''); +// const [copySource, setCopySource] = useState(''); + +// const fileInputRef = useRef(null); + +// /* ------------------------------------------------------------------------- +// Derived helpers +// ------------------------------------------------------------------------- */ + +// const displayName = useCallback( +// (p?: Participant) => +// p ? (showUsernames ? p.username : p.fullName || p.username) : '', +// [showUsernames], +// ); + +// const normalizedTeamName = useCallback( +// (name: string) => name.replace(/\s*MentoredTeam$/i, ''), +// [], +// ); + +// const studentsWithoutTeams = useMemo(() => unassigned, [unassigned]); + +// const isMentorMember = useCallback((team: Team, m: Participant) => { +// if (!team.mentor) return false; +// const normalize = (s: string) => s.replace(/\s*\(Mentor\)\s*$/i, '').trim(); +// const idMatch = String(m.id) === String(team.mentor.id); +// const usernameMatch = normalize(m.username) === normalize(team.mentor.username); +// const nameMatch = +// !!m.fullName && +// !!team.mentor.fullName && +// normalize(m.fullName) === normalize(team.mentor.fullName); +// return idMatch || usernameMatch || nameMatch; +// }, []); + +// /* ------------------------------------------------------------------------- +// UI event handlers +// ------------------------------------------------------------------------- */ + +// const toggleTeamExpand = useCallback((teamId: Team['id']) => { +// setExpanded((prev) => ({ ...prev, [teamId]: !prev[teamId] })); +// }, []); + +// const openAddMemberModal = useCallback((team: Team) => { +// setSelectedTeam(team); +// setSelectedParticipantId(''); +// setShowAddModal(true); +// }, []); + +// const confirmAddMember = useCallback(() => { +// if (!selectedTeam || !selectedParticipantId) return; +// const member = unassigned.find((u) => String(u.id) === selectedParticipantId); +// if (!member) return; + +// setUnassigned((prev) => prev.filter((u) => String(u.id) !== selectedParticipantId)); +// setTeams((prev) => +// prev.map((t) => +// t.id === selectedTeam.id +// ? { ...t, members: [...t.members, { ...member, teamName: t.name }] } +// : t, +// ), +// ); +// setShowAddModal(false); +// }, [selectedParticipantId, selectedTeam, unassigned]); + +// const removeMemberFromTeam = useCallback( +// (teamId: Team['id'], memberId: Participant['id']) => { +// const team = teams.find((t) => t.id === teamId); +// if (!team) return; + +// const member = team.members.find((m) => m.id === memberId); +// setTeams((prev) => +// prev.map((t) => +// t.id === teamId ? { ...t, members: t.members.filter((m) => m.id !== memberId) } : t, +// ), +// ); +// if (member) { +// setUnassigned((prev) => [...prev, { ...member, teamName: '' }]); +// } +// }, +// [teams], +// ); + +// const removeMentor = useCallback((teamId: Team['id']) => { +// setTeams((prev) => +// prev.map((t) => { +// if (t.id !== teamId || !t.mentor) return t; +// const filtered = t.members.filter((m) => !isMentorMember(t, m)); +// return { ...t, mentor: undefined, members: filtered }; +// }), +// ); +// }, [isMentorMember]); + +// const openEditTeamModal = useCallback((team: Team) => { +// setSelectedTeam(team); +// setEditTeamName(team.name); +// setShowEditModal(true); +// }, []); + +// const confirmEditTeamName = useCallback(() => { +// if (!selectedTeam || !editTeamName.trim()) return; +// const newName = editTeamName.trim(); +// setTeams((prev) => +// prev.map((t) => +// t.id !== selectedTeam.id +// ? t +// : { +// ...t, +// name: newName, +// members: t.members.map((m) => ({ ...m, teamName: newName })), +// }, +// ), +// ); +// setShowEditModal(false); +// }, [editTeamName, selectedTeam]); + +// const deleteTeam = useCallback( +// (teamId: Team['id']) => { +// const team = teams.find((t) => t.id === teamId); +// setTeams((prev) => prev.filter((t) => t.id !== teamId)); +// if (team) { +// setUnassigned((prev) => [...prev, ...team.members.map((m) => ({ ...m, teamName: '' }))]); +// } +// }, +// [teams], +// ); + +// const createTeam = useCallback(() => { +// const name = newTeamName.trim(); +// if (!name || teams.some((t) => t.name === name)) return; +// const id = `t-${Date.now()}`; +// setTeams((prev) => [...prev, { id, name, members: [] }]); +// setNewTeamName(''); +// setShowCreateModal(false); +// }, [newTeamName, teams]); + +// const deleteAllTeams = useCallback(() => { +// if (!window.confirm('Delete all teams? This returns all members to the unassigned list.')) +// return; +// const everyone = teams.flatMap((t) => t.members); +// setUnassigned((prev) => [...prev, ...everyone.map((m) => ({ ...m, teamName: '' }))]); +// setTeams([]); +// }, [teams]); + +// const triggerImportClick = useCallback(() => fileInputRef.current?.click(), []); + +// const handleImportFile = useCallback( +// (e: React.ChangeEvent) => { +// const file = e.target.files?.[0]; +// if (!file) return; +// const reader = new FileReader(); +// reader.onload = () => { +// try { +// const data = JSON.parse(String(reader.result)); +// const newTeams: Team[] = Array.isArray(data?.teams) ? data.teams : teams; +// const newUnassigned: Participant[] = Array.isArray(data?.unassigned) +// ? data.unassigned +// : unassigned; +// const assigned = new Set( +// newTeams.flatMap((t) => t.members.map((m) => String(m.id))), +// ); +// setTeams(newTeams); +// setUnassigned(newUnassigned.filter((u) => !assigned.has(String(u.id)))); +// } catch { +// alert('Invalid JSON file.'); +// } +// }; +// reader.readAsText(file); +// e.target.value = ''; +// }, +// [teams, unassigned], +// ); + +// const exportTeams = useCallback(() => { +// const payload = { teams, unassigned }; +// const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); +// const url = URL.createObjectURL(blob); +// const a = document.createElement('a'); +// a.href = url; +// a.download = `teams-export-${Date.now()}.json`; +// document.body.appendChild(a); +// a.click(); +// a.remove(); +// URL.revokeObjectURL(url); +// }, [teams, unassigned]); + +// const copyTeamsToCourse = useCallback(() => { +// alert(`Copying ${teams.length} team(s) to "${copyTarget || '(choose destination)'}"`); +// setShowCopyToModal(false); +// }, [copyTarget, teams.length]); + +// const copyTeamsFromCourse = useCallback(() => { +// alert(`Copying teams from "${copySource || '(choose source)'}" into this ${ctxType}`); +// setShowCopyFromModal(false); +// }, [copySource, ctxType]); + +// /* ------------------------------------------------------------------------- +// Render +// ------------------------------------------------------------------------- */ + +// return ( +// +// {/* Header */} +// +// +//

Teams For {ctxName}

+// +// +// setShowUsernames((prev) => !prev)} +// /> +// +//
+ +// {/* Toolbar (text-only links with pipes) */} +// +// +// setShowCreateModal(true)}>Create team +// | +// Import teams +// +// | +// Export teams +// | +// Delete all teams +// | +// setShowCopyToModal(true)}>Copy teams to course +// | +// setShowCopyFromModal(true)}> +// Copy teams from course +// +// | +// navigate(-1)}>Back +// +// + +// {/* Card wrapper */} +//
+// +// +//
+// {/* All table text: 15px / 1.428em */} +//
+//
+//
+//
Details
+//
Actions
+//
+ +// {teams.map((team) => { +// const open = !!expanded[team.id]; +// const visibleMembers = team.members.filter((m) => !isMentorMember(team, m)); +// return ( +//
+//
+//
+// +//
+ +//
+// {normalizedTeamName(team.name)} +// {team.mentor && ( +// <> +// +// : {displayName(team.mentor)}{' '} +// (Mentor) +// +// removeMentor(team.id)} /> +// +// )} +//
+ +// {/* Actions */} +//
+// +// +// +//
+//
+ +// {open && ( +//
+// {visibleMembers.length === 0 ? ( +// No students yet. +// ) : ( +// visibleMembers.map((m) => ( +// +// {displayName(m)} +// +// +// )) +// )} +//
+// )} +//
+// ); +// })} +//
+//
+// + +// +//
+//
+//
+//
Student
+//
+//
+// {studentsWithoutTeams.length === 0 ? ( +// All students are on a team. +// ) : ( +// studentsWithoutTeams.map((u) => ( +// +// {displayName(u)} +// +// )) +// )} +//
+//
+//
+//
+// +//
+ +// {/* Modals */} +// setShowAddModal(false)}> +// +// Add member +// +// +//
+// +// Select student +// setSelectedParticipantId(e.target.value)} +// > +// +// {unassigned.map((u) => ( +// +// ))} +// +// +//
+//
+// +// +// +// +//
+ +// setShowEditModal(false)}> +// +// Edit team name +// +// +//
+// +// Team name +// setEditTeamName(e.target.value)} +// /> +// +//
+//
+// +// +// +// +//
+ +// setShowCreateModal(false)}> +// +// Create team +// +// +//
+// +// Team name +// setNewTeamName(e.target.value)} +// /> +// +//
+//
+// +// +// +// +//
+ +// setShowCopyToModal(false)}> +// +// Copy teams to course +// +// +//
+// +// Destination course +// setCopyTarget(e.target.value)} +// /> +// +// (Stub) Wire this to your backend to copy teams to a course. +// +// +//
+//
+// +// +// +// +//
+ +// setShowCopyFromModal(false)}> +// +// Copy teams from course +// +// +//
+// +// Source course +// setCopySource(e.target.value)} +// /> +// +// (Stub) Wire this to your backend to pull teams from another course. +// +// +//
+//
+// +// +// +// +//
+// +// ); +// }; + +// export default CreateTeams; + + // src/pages/Assignments/CreateTeams.tsx -import React, { useMemo, useState, useCallback, useRef, memo } from 'react'; +import React, { useMemo, useState, useCallback, memo } from 'react'; import { Button, Container, @@ -14,6 +955,9 @@ import { } from 'react-bootstrap'; import { useLoaderData, useNavigate } from 'react-router-dom'; +import ImportModal from "../../components/Modals/ImportModal"; +import ExportModal from "../../components/Modals/ExportModal"; + /* ============================================================================= Types ============================================================================= */ @@ -45,14 +989,6 @@ interface LoaderPayload { Assets (icons used only where required) ============================================================================= */ -// const publicUrl = -// (import.meta as any)?.env?.BASE_URL ?? -// (typeof process !== 'undefined' ? (process as any)?.env?.PUBLIC_URL : '') ?? -// ''; -// -// const assetUrl = (rel: string) => -// `${publicUrl.replace(/\/$/, '')}/${rel.replace(/^\//, '')}`; - // Safe base URL (no import.meta) const getBaseUrl = (): string => { // 1) if present @@ -66,12 +1002,12 @@ const getBaseUrl = (): string => { // 3) CRA-style env if available in tests/builds const fromProcess = - (typeof process !== 'undefined' && (process as any)?.env?.PUBLIC_URL) || ''; + (typeof process !== 'undefined' && (process as any)?.env?.PUBLIC_URL) || ''; return String(fromProcess).replace(/\/$/, ''); }; const assetUrl = (rel: string) => - `${getBaseUrl()}/${rel.replace(/^\//, '')}`; + `${getBaseUrl()}/${rel.replace(/^\//, '')}`; const ICONS = { add: 'assets/icons/add-participant-24.png', @@ -406,6 +1342,10 @@ const CreateTeams: React.FC<{ contextType?: ContextType; contextName?: string }> const [showCopyToModal, setShowCopyToModal] = useState(false); const [showCopyFromModal, setShowCopyFromModal] = useState(false); + // 👇 NEW: local state to control the imported modals + const [showImportTeamsModal, setShowImportTeamsModal] = useState(false); + const [showExportTeamsModal, setShowExportTeamsModal] = useState(false); + const [selectedTeam, setSelectedTeam] = useState(null); const [selectedParticipantId, setSelectedParticipantId] = useState(''); const [editTeamName, setEditTeamName] = useState(''); @@ -413,8 +1353,6 @@ const CreateTeams: React.FC<{ contextType?: ContextType; contextName?: string }> const [copyTarget, setCopyTarget] = useState(''); const [copySource, setCopySource] = useState(''); - const fileInputRef = useRef(null); - /* ------------------------------------------------------------------------- Derived helpers ------------------------------------------------------------------------- */ @@ -553,48 +1491,6 @@ const CreateTeams: React.FC<{ contextType?: ContextType; contextName?: string }> setTeams([]); }, [teams]); - const triggerImportClick = useCallback(() => fileInputRef.current?.click(), []); - - const handleImportFile = useCallback( - (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - const reader = new FileReader(); - reader.onload = () => { - try { - const data = JSON.parse(String(reader.result)); - const newTeams: Team[] = Array.isArray(data?.teams) ? data.teams : teams; - const newUnassigned: Participant[] = Array.isArray(data?.unassigned) - ? data.unassigned - : unassigned; - const assigned = new Set( - newTeams.flatMap((t) => t.members.map((m) => String(m.id))), - ); - setTeams(newTeams); - setUnassigned(newUnassigned.filter((u) => !assigned.has(String(u.id)))); - } catch { - alert('Invalid JSON file.'); - } - }; - reader.readAsText(file); - e.target.value = ''; - }, - [teams, unassigned], - ); - - const exportTeams = useCallback(() => { - const payload = { teams, unassigned }; - const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `teams-export-${Date.now()}.json`; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); - }, [teams, unassigned]); - const copyTeamsToCourse = useCallback(() => { alert(`Copying ${teams.length} team(s) to "${copyTarget || '(choose destination)'}"`); setShowCopyToModal(false); @@ -635,16 +1531,9 @@ const CreateTeams: React.FC<{ contextType?: ContextType; contextName?: string }> > setShowCreateModal(true)}>Create team | - Import teams - + setShowImportTeamsModal(true)}>Import teams | - Export teams + setShowExportTeamsModal(true)}>Export teams | Delete all teams | @@ -788,7 +1677,19 @@ const CreateTeams: React.FC<{ contextType?: ContextType; contextName?: string }>
- {/* Modals */} + {/* Import / Export modals (from separate files) */} + setShowImportTeamsModal(false)} + modelClass="Team" + /> + setShowExportTeamsModal(false)} + modelClass="Team" + /> + + {/* Other Modals */} setShowAddModal(false)}> Add member @@ -937,3 +1838,4 @@ const CreateTeams: React.FC<{ contextType?: ContextType; contextName?: string }> }; export default CreateTeams; + diff --git a/src/pages/Authentication/Login.tsx b/src/pages/Authentication/Login.tsx index c99cb37f..2051b297 100644 --- a/src/pages/Authentication/Login.tsx +++ b/src/pages/Authentication/Login.tsx @@ -30,7 +30,7 @@ const Login: React.FC = () => { const onSubmit = (values: ILoginFormValues, submitProps: FormikHelpers) => { axios - .post("http://152.7.177.187:3002/login", values) + .post("http://localhost:3002/login", values) .then((response) => { const payload = setAuthToken(response.data.token); diff --git a/src/pages/EditQuestionnaire/Questionnaire.tsx b/src/pages/EditQuestionnaire/Questionnaire.tsx index 29a9d6c4..a0ac4c8b 100644 --- a/src/pages/EditQuestionnaire/Questionnaire.tsx +++ b/src/pages/EditQuestionnaire/Questionnaire.tsx @@ -1,5 +1,5 @@ import React, { useState } from "react"; -import ImportModal from "./ImportModal"; +import ImportModal from "../../components/Modals/ImportModal"; import ExportModal from "./ExportModal"; interface ImportedData { @@ -378,12 +378,18 @@ const Questionnaire = () => { {/* Render import and export modals conditionally */} - {showImportModal && ( - setShowImportModal(false)} - onImport={handleFileChange} - /> - )} + {/* Import / Export modals (from separate files) */} + setShowImportModal(false)} + modelClass="Item" + /> + {/*{showImportModal && (*/} + {/* setShowImportModal(false)}*/} + {/* onImport={handleFileChange}*/} + {/* />*/} + {/*)}*/} {showExportModal && ( setShowExportModal(false)} diff --git a/src/pages/Users/User.tsx b/src/pages/Users/User.tsx index b709e4bb..6a7204b4 100644 --- a/src/pages/Users/User.tsx +++ b/src/pages/Users/User.tsx @@ -1,103 +1,162 @@ -import { Row as TRow } from "@tanstack/react-table"; -import Table from "../../components/Table/Table"; -import useAPI from "../../hooks/useAPI"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { Button, Col, Container, Row } from "react-bootstrap"; -import { BsPersonFillAdd } from "react-icons/bs"; -import { useDispatch, useSelector } from "react-redux"; -import { Outlet, useLocation, useNavigate } from "react-router-dom"; -import { alertActions } from "../../store/slices/alertSlice"; -import { RootState } from "../../store/store"; -import { IUserResponse, ROLE } from "../../utils/interfaces"; -import DeleteUser from "./UserDelete"; -import { userColumns as USER_COLUMNS } from "./userColumns"; - -/** - * @author Ankur Mundra on April, 2023 - */ -const Users = () => { - const { error, isLoading, data: userResponse, sendRequest: fetchUsers } = useAPI(); - const auth = useSelector( - (state: RootState) => state.authentication, - (prev, next) => prev.isAuthenticated === next.isAuthenticated - ); - const navigate = useNavigate(); - const location = useLocation(); - const dispatch = useDispatch(); - - const [showDeleteConfirmation, setShowDeleteConfirmation] = useState<{ - visible: boolean; - data?: IUserResponse; - }>({ visible: false }); - - useEffect(() => { - if (!showDeleteConfirmation.visible) fetchUsers({ url: `/users/${auth.user.id}/managed` }); - }, [fetchUsers, location, showDeleteConfirmation.visible, auth.user.id]); - - // Error alert - useEffect(() => { - if (error) { - dispatch(alertActions.showAlert({ variant: "danger", message: error })); - } - }, [error, dispatch]); - - const onDeleteUserHandler = useCallback(() => setShowDeleteConfirmation({ visible: false }), []); - - const onEditHandle = useCallback( - (row: TRow) => navigate(`edit/${row.original.id}`), - [navigate] - ); - - const onDeleteHandle = useCallback( - (row: TRow) => setShowDeleteConfirmation({ visible: true, data: row.original }), - [] - ); - - const tableColumns = useMemo( - () => USER_COLUMNS(onEditHandle, onDeleteHandle), - [onDeleteHandle, onEditHandle] - ); - - const tableData = useMemo( - () => (isLoading || !userResponse?.data ? [] : userResponse.data), - [userResponse?.data, isLoading] - ); - - return ( - <> - -
- - - -

Manage Users

- -
-
- - - - - {showDeleteConfirmation.visible && ( - - )} - - - - - - - - ); -}; - -export default Users; +import { Row as TRow } from "@tanstack/react-table"; +import Table from "../../components/Table/Table"; +import useAPI from "../../hooks/useAPI"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Button, Col, Container, Row } from "react-bootstrap"; +import { BsPersonFillAdd } from "react-icons/bs"; +import { useDispatch, useSelector } from "react-redux"; +import { Outlet, useLocation, useNavigate } from "react-router-dom"; +import { alertActions } from "../../store/slices/alertSlice"; +import { RootState } from "../../store/store"; +import { IUserResponse, ROLE } from "../../utils/interfaces"; +import DeleteUser from "./UserDelete"; +import { userColumns as USER_COLUMNS } from "./userColumns"; +import ImportModal from "../../components/Modals/ImportModal"; +import ExportModal from "../../components/Modals/ExportModal"; + +/** + * @author Ankur Mundra on April, 2023 + */ +const Users = () => { + const { error, isLoading, data: userResponse, sendRequest: fetchUsers } = useAPI(); + const auth = useSelector( + (state: RootState) => state.authentication, + (prev, next) => prev.isAuthenticated === next.isAuthenticated + ); + + const [showImportUserModal, setShowImportUserModal] = useState(false); + const [showExportUserModal, setShowExportUserModal] = useState(false); + + const navigate = useNavigate(); + const location = useLocation(); + const dispatch = useDispatch(); + + const STANDARD_TEXT: React.CSSProperties = { + fontFamily: 'verdana, arial, helvetica, sans-serif', + color: '#333', + fontSize: '13px', + lineHeight: '30px', + }; + + const toolbarLinkBase: React.CSSProperties = { + ...STANDARD_TEXT, + color: '#8b5e3c', + background: 'transparent', + border: 'none', + padding: 0, + margin: 0, + cursor: 'pointer', + textDecoration: 'none', + }; + const pipe: React.CSSProperties = { margin: '0 8px', color: '#8b5e3c' }; + + const ToolbarLink: React.FC<{ + onClick: () => void; + children: React.ReactNode; + }> = ({ onClick, children }) => ( + + ); + + const [showDeleteConfirmation, setShowDeleteConfirmation] = useState<{ + visible: boolean; + data?: IUserResponse; + }>({ visible: false }); + + + + useEffect(() => { + if (!showDeleteConfirmation.visible) fetchUsers({ url: `/users/${auth.user.id}/managed` }); + }, [fetchUsers, location, showDeleteConfirmation.visible, auth.user.id]); + + // Error alert + useEffect(() => { + if (error) { + dispatch(alertActions.showAlert({ variant: "danger", message: error })); + } + }, [error, dispatch]); + + const onDeleteUserHandler = useCallback(() => setShowDeleteConfirmation({ visible: false }), []); + + const onEditHandle = useCallback( + (row: TRow) => navigate(`edit/${row.original.id}`), + [navigate] + ); + + const onDeleteHandle = useCallback( + (row: TRow) => setShowDeleteConfirmation({ visible: true, data: row.original }), + [] + ); + + const tableColumns = useMemo( + () => USER_COLUMNS(onEditHandle, onDeleteHandle), + [onDeleteHandle, onEditHandle] + ); + + const tableData = useMemo( + () => (isLoading || !userResponse?.data ? [] : userResponse.data), + [userResponse?.data, isLoading] + ); + + const handleHideImportModal = () => { + fetchUsers({ url: `/users/${auth.user.id}/managed` }); + setShowImportUserModal(false) + } + + return ( + <> + +
+ + +
+

Manage Users

+ +
+ + + + setShowImportUserModal(true)}>Import users + | + setShowExportUserModal(true)}>Export users + + + + + + {showDeleteConfirmation.visible && ( + + )} + + +
+ + + + + {/* Import / Export modals (from separate files) */} + handleHideImportModal()} + modelClass="User" + /> + setShowExportUserModal(false)} + modelClass="User" + /> + + ); +}; + +export default Users; diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..25e45323 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +// vitest.config.ts +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./setUp.ts"], + }, +});