diff --git a/.eslintrc.json b/.eslintrc.json index 921c7998..a4c65297 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -4,17 +4,35 @@ "es2021": true }, "extends": "next/core-web-vitals", + "plugins": ["@typescript-eslint"], "parserOptions": { "ecmaVersion": "latest", "sourceType": "module" }, "rules": { + "@typescript-eslint/no-explicit-any": [ + "error", + { + "fixToUnknown": true + } + ], "no-unused-vars": [ "error", { "argsIgnorePattern": "^_", "ignoreRestSiblings": true } + ], + "no-restricted-syntax": [ + "error", + { + "selector": "TSAsExpression", + "message": "Do not use TypeScript type assertions; use declarations, generics, unions, or narrowing." + }, + { + "selector": "TSTypeAssertion", + "message": "Do not use TypeScript type assertions; use declarations, generics, unions, or narrowing." + } ] } } diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5b6b2cb3..ae1d1c8f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -34,6 +34,9 @@ jobs: - name: Run lint run: pnpm lint + - name: Reject authored JavaScript + run: pnpm check:javascript + - name: Run type check run: pnpm type-check diff --git a/.gitignore b/.gitignore index 66ca24f4..c1585ac8 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,7 @@ tsconfig.tsbuildinfo # Claude Code local settings .claude/ + +# Codex/agent-local skills +/.agents/ +pnpm-lock.yaml diff --git a/.prettierignore b/.prettierignore index f6befc51..70afe5e1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,3 @@ # Keep this minimal; Prettier already respects .gitignore # Add only exceptions not covered by .gitignore if needed pnpm-lock.yaml - diff --git a/Makefile b/Makefile index 09cbb9ad..45d0c3e3 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ SHELL := /bin/sh NPM ?= npm -.PHONY: help install dev build start lint format format-fix type-check check test docker-up docker-down docker-logs ci +.PHONY: help install dev build start lint format format-fix type-check check check-javascript test docker-up docker-down docker-logs ci help: @echo "Available targets:" @@ -15,6 +15,7 @@ help: @echo " format - prettier --check" @echo " format-fix - prettier --write" @echo " type-check - tsc --noEmit" + @echo " check-javascript - reject authored JavaScript/JSX" @echo " check - lint + type-check + format + build" @echo " docker-up - docker compose up -d" @echo " docker-down - docker compose down" @@ -45,7 +46,10 @@ format-fix: type-check: $(NPM) run type-check -check: format-fix lint type-check build +check-javascript: + $(NPM) run check:javascript + +check: format-fix lint type-check check-javascript build ci: check @@ -57,4 +61,3 @@ docker-down: docker-logs: docker compose logs -f - diff --git a/README.md b/README.md index 6b82355b..0f35274e 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,11 @@ pnpm dev Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. -You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file. +You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. -[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`. +[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. -The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. +The `pages/api` directory is mapped to `/api/*`. TypeScript files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. @@ -101,7 +101,7 @@ Next only substitutes `NEXT_PUBLIC_*` values that exist at build time, so `process.env.NEXT_PUBLIC_BASE_URL` survives in the compiled server output as a real runtime lookup and each environment supplies its own value through Coolify. -This works only because the value is read server-side (`utils/auditLogger.js`, +This works only because the value is read server-side (`utils/auditLogger.ts`, imported solely by `pages/api/*`). Reading it from client code would yield `undefined` in the browser — see the comment in the Dockerfile before changing this. @@ -153,12 +153,12 @@ must not be committed or uploaded. PAGES FOLDER: files requiring mods to API routes: -- batch > [id].jsx -- student > [id].jsx -- batches.jsx -- courses.jsx -- students.jsx -- users.jsx +- batch > [id].tsx +- student > [id].tsx +- batches.tsx +- courses.tsx +- students.tsx +- users.tsx .env file: @@ -166,19 +166,19 @@ PAGES FOLDER: files requiring mods to API routes: NOTE REGARDING BATCH ATTENDANCE DROPDOWN: -- This can be edited in /utils/tableHelper.js +- This can be edited in `/utils` TypeScript helpers. ## CSV download - related modifications - Components to modify: - Vercel - - /pages/users.jsx\*: name, accessor, htmlFor, id (input attribute) - - /pages/api/getuserdata.js (if necessary) + - /pages/users.tsx\*: name, accessor, htmlFor, id (input attribute) + - /pages/api/getuserdata.ts (if necessary) - DreamHost - [https://visionaid.dreamhosters.com/csv/staff.php](https://visionaid.dreamhosters.com/csv/staff.php): th tag list - /csv/csvfunctions.php: fputcsv(), while($row = mysqli_fetch_assoc($result)), get_all_user_records() -\*users.jsx file (staff) example: database and UI column names: +\*users.tsx file (staff) example: database and UI column names: id Id email Email @@ -203,7 +203,7 @@ action Action ## Errors - Can't log out? If you get an error preventing you from logging out: - please modify the /components/Navbar.jsx file per comments + please modify the `/components/Navbar.tsx` file per comments at the top of the file. ## Dreamhost-mtl folder (DB hosting) diff --git a/components/AccessibleSelectCellEditor.jsx b/components/AccessibleSelectCellEditor.tsx similarity index 80% rename from components/AccessibleSelectCellEditor.jsx rename to components/AccessibleSelectCellEditor.tsx index f510c2a4..05b14660 100644 --- a/components/AccessibleSelectCellEditor.jsx +++ b/components/AccessibleSelectCellEditor.tsx @@ -1,13 +1,24 @@ import React, { forwardRef, useRef, useState, useEffect } from "react"; -const AccessibleSelectCellEditor = forwardRef((props) => { +const AccessibleSelectCellEditor = forwardRef< + HTMLSelectElement, + { + onValueChange: (_value: string) => void; + value?: string | number; + values?: Array; + stopEditing?: (_cancel?: boolean) => void; + colDef?: { headerName?: string; field?: string }; + data?: { name?: string }; + formatter?: (_value: string | number | undefined) => string; + } +>((props, _ref) => { const { onValueChange, value, values, stopEditing, colDef, data, formatter } = props; // If a formatter is provided, use it to format the initial value for display const formattedValue = formatter ? formatter(value) : value; const [selectedValue, setSelectedValue] = useState(formattedValue || ""); - const selectRef = useRef(null); + const selectRef = useRef(null); const valueRef = useRef(formattedValue || ""); // Auto-focus and open the select element when the editor mounts @@ -29,19 +40,19 @@ const AccessibleSelectCellEditor = forwardRef((props) => { } }, []); - const handleChange = (e) => { + const handleChange = (e: React.ChangeEvent) => { const newValue = e.target.value; valueRef.current = newValue; setSelectedValue(newValue); onValueChange(newValue); - stopEditing(); + stopEditing?.(); }; - const handleKeyDown = (e) => { + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Escape") { e.stopPropagation(); - const resetValue = formatter ? formatter(value) : value; + const resetValue = formatter ? formatter(value) : (value ?? ""); valueRef.current = resetValue; setSelectedValue(resetValue); if (stopEditing) { diff --git a/components/Button.jsx b/components/Button.tsx similarity index 68% rename from components/Button.jsx rename to components/Button.tsx index a1ea3151..f410b1e8 100644 --- a/components/Button.jsx +++ b/components/Button.tsx @@ -1,6 +1,26 @@ import Image from "next/image"; -import PropTypes from "prop-types"; import styles from "@/styles/Button.module.css"; +import type { CSSProperties, KeyboardEventHandler, MouseEventHandler } from "react"; +import type { StaticImport } from "next/dist/shared/lib/get-img-props"; + +interface ButtonProps { + text?: string; + onClick?: MouseEventHandler; + onKeyDown?: KeyboardEventHandler; + iconSrc?: string | StaticImport; + iconAlt?: string; + isLight?: boolean; + style?: CSSProperties; + className?: string; + disabled?: boolean; + ariaControls?: string; + ariaExpanded?: boolean | "true" | "false"; + ariaLabel?: string; + courseName?: string; + batch?: string | number; + tabIndex?: number; + "aria-label"?: string; +} export default function Button({ text, @@ -17,7 +37,7 @@ export default function Button({ ariaLabel, // if needed, explicitly specify courseName, // if needed, pass only as data-attr batch, // if needed, pass only as data-attr -}) { +}: ButtonProps) { const buttonClassName = [isLight ? styles.genericButtonLight : styles.genericButtonDark, className] .filter(Boolean) .join(" "); @@ -40,7 +60,7 @@ export default function Button({ aria-controls={ariaControls || undefined} aria-label={computedAriaLabel} data-course-name={courseName ?? undefined} - data-batch={batch ?? undefined} + data-batch={batch == null ? undefined : String(batch)} > {iconSrc ? ( ); } - -Button.propTypes = { - text: PropTypes.string, - onClick: PropTypes.func, - onKeyDown: PropTypes.func, - iconSrc: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), // next/image StaticImport allow (next/image StaticImport allow) - iconAlt: PropTypes.string, - isLight: PropTypes.bool, - style: PropTypes.object, - className: PropTypes.string, - disabled: PropTypes.bool, - ariaControls: PropTypes.string, - ariaExpanded: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]), - ariaLabel: PropTypes.string, - courseName: PropTypes.string, - batch: PropTypes.string, -}; diff --git a/components/ConfirmationModal.jsx b/components/ConfirmationModal.tsx similarity index 100% rename from components/ConfirmationModal.jsx rename to components/ConfirmationModal.tsx diff --git a/components/DashboardSettingsPanel.jsx b/components/DashboardSettingsPanel.tsx similarity index 100% rename from components/DashboardSettingsPanel.jsx rename to components/DashboardSettingsPanel.tsx diff --git a/components/DataGrid.jsx b/components/DataGrid.tsx similarity index 75% rename from components/DataGrid.jsx rename to components/DataGrid.tsx index 340d7ed5..4d3ca1bd 100644 --- a/components/DataGrid.jsx +++ b/components/DataGrid.tsx @@ -1,10 +1,21 @@ import { getRowStyle } from "@/utils/get-row-style"; import { smartComparator } from "@/utils/grid-comparators"; +import type { CellKeyDownEvent, ColDef, GridReadyEvent, TabToNextCellParams } from "ag-grid-community"; import { AgGridReact } from "ag-grid-react"; import React from "react"; -export function DataGrid({ title, rowData, onGridReady, onCellKeyDown, exitTargetId }) { - const columnDefs = [ +type RowData = Record; + +interface DataGridProps { + title: string; + rowData: RowData[]; + onGridReady?: (_event: GridReadyEvent) => void; + onCellKeyDown?: (_event: CellKeyDownEvent) => void; + exitTargetId: string; +} + +export function DataGrid({ title, rowData, onGridReady, onCellKeyDown, exitTargetId }: DataGridProps) { + const columnDefs: ColDef[] = [ { field: "id", headerName: "ID", @@ -39,7 +50,7 @@ export function DataGrid({ title, rowData, onGridReady, onCellKeyDown, exitTarge comparator: smartComparator, }; - const tabToNextCell = (params) => { + const tabToNextCell = (params: TabToNextCellParams) => { const { previousCellPosition, backwards, api } = params; if (!previousCellPosition) return null; @@ -59,13 +70,17 @@ export function DataGrid({ title, rowData, onGridReady, onCellKeyDown, exitTarge return null; } + const firstColumn = api.getAllDisplayedColumns()[0]; + if (!firstColumn) return null; + return { rowIndex: nextRowIndex, - column: api.getAllDisplayedColumns()[0], + rowPinned: null, + column: firstColumn, }; }; - const rowSelection = { + const rowSelection: { mode: "multiRow" } = { mode: "multiRow", }; @@ -83,7 +98,7 @@ export function DataGrid({ title, rowData, onGridReady, onCellKeyDown, exitTarge Use arrow keys to navigate rows. Press space to select students.

- enableCellTextSelection={true} rowData={rowData} columnDefs={columnDefs} diff --git a/components/DropdownMenuPm.js b/components/DropdownMenuPm.tsx similarity index 67% rename from components/DropdownMenuPm.js rename to components/DropdownMenuPm.tsx index 507ec08b..db96f553 100644 --- a/components/DropdownMenuPm.js +++ b/components/DropdownMenuPm.tsx @@ -1,16 +1,28 @@ // Part of page group that populate a dynamic dropdown from MySQL: -// /db.js -// /pages/api/dropdownDataPm.js -// /pages/api/dropdownDataStaff.js -// /components/DropdownMenuStaff.js +// /lib/db.ts +// /pages/api/dropdownDataPm.ts +// /pages/api/dropdownDataStaff.ts +// /components/DropdownMenuStaff.tsx // Author: Dante Ciolfi // Last update: 5/4/2024 import React, { useState, useEffect } from "react"; import styles from "../styles/Home.module.css"; -const DropdownMenuPm = ({ id = "PM", name = "PM", required = false, className = "", selectedValue = "" }) => { - const [options, setOptions] = useState([]); +const DropdownMenuPm = ({ + id = "PM", + name = "PM", + required = false, + className = "", + selectedValue = "", +}: { + id?: string; + name?: string; + required?: boolean; + className?: string; + selectedValue?: string; +}) => { + const [options, setOptions] = useState>([]); useEffect(() => { const fetchData = async () => { diff --git a/components/DropdownMenuStaff.js b/components/DropdownMenuStaff.tsx similarity index 50% rename from components/DropdownMenuStaff.js rename to components/DropdownMenuStaff.tsx index 401e5f40..bc9ba1d3 100644 --- a/components/DropdownMenuStaff.js +++ b/components/DropdownMenuStaff.tsx @@ -1,16 +1,28 @@ // Part of page group that populate a dynamic dropdown from MySQL: -// /db.js -// /pages/api/dropdownDataPm.js -// /pages/api/dropdownDataStaff.js -// /components/DropdownMenuStaff.js +// /lib/db.ts +// /pages/api/dropdownDataPm.ts +// /pages/api/dropdownDataStaff.ts +// /components/DropdownMenuStaff.tsx // Author: Dante Ciolfi // Last update: 5/4/2024 import React, { useState, useEffect } from "react"; import styles from "../styles/Home.module.css"; -const DropdownMenuStaff = ({ id, name }) => { - const [options, setOptions] = useState([]); +const DropdownMenuStaff = ({ + id, + name, + className = "", + selectedValue = "", + required = false, +}: { + id?: string; + name?: string; + className?: string; + selectedValue?: string; + required?: boolean; +}) => { + const [options, setOptions] = useState>([]); useEffect(() => { const fetchData = async () => { const response = await fetch("/api/dropdownDataStaff"); @@ -21,7 +33,13 @@ const DropdownMenuStaff = ({ id, name }) => { }, []); return ( - {options.map((option) => (