From 21b7de57a123af9f289ab818575e2b03a666ca88 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 23 Jul 2026 18:48:15 -0300 Subject: [PATCH 1/3] chore(frontend): enforce maintainable text rendering --- docs/FRONTEND_MAINTAINABILITY.md | 66 +++++++++++++ eslint.config.js | 20 ++++ .../(auth)/_components/auth-card-header.tsx | 18 ++-- src/app/(auth)/login.tsx | 9 +- src/app/v/[token]/_layout.tsx | 11 ++- src/app/v/[token]/dashboard.tsx | 7 +- src/app/v/[token]/dispatch/[id].tsx | 27 +++-- src/app/v/[token]/pos.tsx | 7 +- src/components/ui/page-header.tsx | 28 +++--- src/components/ui/text.tsx | 99 +++++++++++++++++++ src/test/components/ui/text.test.tsx | 44 +++++++++ 11 files changed, 297 insertions(+), 39 deletions(-) create mode 100644 docs/FRONTEND_MAINTAINABILITY.md create mode 100644 src/components/ui/text.tsx create mode 100644 src/test/components/ui/text.test.tsx diff --git a/docs/FRONTEND_MAINTAINABILITY.md b/docs/FRONTEND_MAINTAINABILITY.md new file mode 100644 index 00000000..cd7863da --- /dev/null +++ b/docs/FRONTEND_MAINTAINABILITY.md @@ -0,0 +1,66 @@ +# Frontend maintainability conventions + +## Conditional rendering + +Use logical `&&` or the `when` prop on `Text` when JSX has only a rendered state and an empty +state. Use a ternary only when both branches render meaningful alternatives. + +```tsx +{ + error && {error.message}; +} + + + {description} +; +``` + +ESLint rejects `condition ? : null`. This keeps one-sided conditions visually +distinct from real either-or UI decisions. + +## Typography and feedback + +Use `Text` from `@/components/ui/text` for headings, paragraphs, descriptions, labels, captions, +code, and asynchronous feedback. It owns: + +- semantic HTML for each visual variant; +- the display, body, and monospace font families; +- default, muted, error, success, and warning tones; +- accessible `alert` and `status` live regions for error and feedback text; +- one-sided conditional text through `when`. + +ESLint rejects raw paragraph and heading elements. Existing MUI `Typography` usages remain valid, +but new shared UI should prefer `Text` so semantics and design tokens do not drift. + +## Forms and mutations + +Use the libraries already established in the application: + +- React Hook Form owns field registration, touched/dirty state, and client form lifecycle. +- Zod owns form validation and inferred form value types. +- TanStack Query owns server reads and mutations, including pending/error state, cache + invalidation, and retries where safe. + +Do not add TanStack Form alongside React Hook Form. It would create two form conventions without +removing any current dependency. Reconsider only as a deliberate repository-wide migration with +benchmarks, a codemod plan, and an approved deprecation path. + +File uploads are not ordinary form fields. Keep file selection and client validation in a focused +component, and use a TanStack Query mutation for upload progress, errors, completion refresh, and +retry state. Do not place upload orchestration in a route-sized page component. + +## Page state + +Pages should compose focused state components instead of accumulating unrelated booleans: + +- query loading, error, and empty states stay adjacent to the query result; +- mutation pending/error state belongs to the component that initiated the mutation; +- route pages coordinate sections and navigation; +- reusable sections own their interaction details; +- errors render inline with accessible feedback, with toasts reserved for cross-page outcomes. + +## Enforcement and rollout + +The lint rules are repository-wide and the initial violations were migrated in the same change. +`npm run lint`, `npm run build`, and the `Text` behavior tests are required gates. Future +maintainability rules must also land with a green migration rather than a warning-only backlog. diff --git a/eslint.config.js b/eslint.config.js index 8d1f29c2..f88e2a81 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -62,6 +62,26 @@ export default tseslint.config( "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], "@typescript-eslint/no-explicit-any": "warn", + "no-restricted-syntax": [ + "error", + { + selector: + "JSXExpressionContainer > ConditionalExpression[alternate.type='Literal'][alternate.value=null]", + message: + "Use logical AND for one-sided JSX rendering instead of `condition ? element : null`.", + }, + { + selector: + ":matches(JSXOpeningElement[name.name='p'], JSXOpeningElement[name.name='h1'], JSXOpeningElement[name.name='h2'], JSXOpeningElement[name.name='h3'], JSXOpeningElement[name.name='h4'], JSXOpeningElement[name.name='h5'], JSXOpeningElement[name.name='h6'])", + message: + "Use the shared Text component so typography semantics, family, tone, and feedback behavior stay consistent.", + }, + { + selector: + "JSXOpeningElement[name.name='div'] > JSXAttribute[name.name='className'][value.value='vp-error']", + message: 'Use Text variant="error" for accessible, consistent error feedback.', + }, + ], }, }, { diff --git a/src/app/(auth)/_components/auth-card-header.tsx b/src/app/(auth)/_components/auth-card-header.tsx index 795ca0a7..aaf95cf6 100644 --- a/src/app/(auth)/_components/auth-card-header.tsx +++ b/src/app/(auth)/_components/auth-card-header.tsx @@ -1,5 +1,6 @@ import type { ComponentPropsWithoutRef } from "react"; +import { Text } from "@/components/ui/text"; import { cn } from "@/lib/utils"; export type AuthCardHeaderProps = ComponentPropsWithoutRef<"div"> & { @@ -10,12 +11,17 @@ export type AuthCardHeaderProps = ComponentPropsWithoutRef<"div"> & { export function AuthCardHeader({ title, subtitle, className, ...props }: AuthCardHeaderProps) { return (
-

{title}

- {subtitle && ( -

- {subtitle} -

- )} + + {title} + + + {subtitle} +
); } diff --git a/src/app/(auth)/login.tsx b/src/app/(auth)/login.tsx index 9149bc8d..7d1c4c74 100644 --- a/src/app/(auth)/login.tsx +++ b/src/app/(auth)/login.tsx @@ -5,6 +5,7 @@ import { AuthCardHeader } from "@/app/(auth)/_components/auth-card-header"; import { AuthPageShell } from "@/app/(auth)/_components/auth-page-shell"; import { LoginForm } from "@/app/(auth)/_components/login-form"; import { BrandLockup } from "@/components/common/brand-lockup"; +import { Text } from "@/components/ui/text"; import { loginSchema, type LoginFormValues } from "@/domain/auth/schemas/login-schema"; import { useAuthContext } from "@/providers/auth-context"; @@ -37,9 +38,13 @@ export default function LoginPage() { isLoggingIn={isLoggingIn} loginError={loginError} /> -

+ Having trouble? Contact your administrator. -

+ ); } diff --git a/src/app/v/[token]/_layout.tsx b/src/app/v/[token]/_layout.tsx index 548f4ed3..3b4e7880 100644 --- a/src/app/v/[token]/_layout.tsx +++ b/src/app/v/[token]/_layout.tsx @@ -1,6 +1,7 @@ import { NavLink, Outlet, useLocation, useParams } from "react-router"; import { useVendorPortal } from "@/app/v/_components/vendor-portal-context"; import { VendorPortalProvider } from "@/app/v/_components/vendor-portal-provider"; +import { Text } from "@/components/ui/text"; import "@/app/v/_components/vendor-portal.css"; function VendorPortalHeader() { @@ -55,13 +56,13 @@ function VendorPortalBody() { if (status === "error") { return (
-
-

Access Denied

-

+

+ Access Denied + {error?.message || "This link is invalid or has expired. Please contact your dispatcher for a new link."} -

-
+ +
); } diff --git a/src/app/v/[token]/dashboard.tsx b/src/app/v/[token]/dashboard.tsx index a7dc74f9..eeb222a2 100644 --- a/src/app/v/[token]/dashboard.tsx +++ b/src/app/v/[token]/dashboard.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from "react"; import { useNavigate } from "react-router"; import { useQuery } from "@tanstack/react-query"; import { useVendorPortal } from "@/app/v/_components/vendor-portal-context"; +import { Text } from "@/components/ui/text"; import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api"; import { formatVendorPortalDateTime, @@ -54,7 +55,7 @@ export default function VendorPortalDashboardPage() { return ( <>
-

Your Work

+ Your Work
Active dispatches and work orders assigned to your company.
@@ -76,7 +77,9 @@ export default function VendorPortalDashboardPage() {
{isLoading &&
Loading dispatches…
} - {error &&
{error.message}
} + + {error?.message} + {!isLoading && !error && filtered.length === 0 && (
No dispatches in this view.
diff --git a/src/app/v/[token]/dispatch/[id].tsx b/src/app/v/[token]/dispatch/[id].tsx index 2edae8a1..854631b9 100644 --- a/src/app/v/[token]/dispatch/[id].tsx +++ b/src/app/v/[token]/dispatch/[id].tsx @@ -3,6 +3,7 @@ import { Link, useParams } from "react-router"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { SignaturePad } from "@/app/v/_components/signature-pad"; import { useVendorPortal } from "@/app/v/_components/vendor-portal-context"; +import { Text } from "@/components/ui/text"; import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api"; import { formatVendorPortalDateTime, @@ -429,7 +430,11 @@ export default function VendorPortalDispatchPage() { } if (error && !data) { - return
{error.message}
; + return ( + + {error.message} + + ); } if (!data) return null; @@ -457,7 +462,7 @@ export default function VendorPortalDispatchPage() {
-

{data.dispatchNumber}

+ {data.dispatchNumber}
{data.workOrder?.workerOrderTitle}
{data.status} @@ -492,7 +497,9 @@ export default function VendorPortalDispatchPage() { {data.location && ( <> -

Location

+ + Location +
{data.location.name}
{[ @@ -509,7 +516,9 @@ export default function VendorPortalDispatchPage() { {(data.description || data.workOrder?.description) && ( <> -

Description

+ + Description +
{data.description || data.workOrder?.description}
)} @@ -570,7 +579,7 @@ export default function VendorPortalDispatchPage() {
-

Checklist

+ Checklist
-

NTE Uplift Requests

+ NTE Uplift Requests
-

Customer Signoff

+ Customer Signoff
-

Vendor Signoff

+ Vendor Signoff
-

Comments

+ Comments
-

Your Purchase Orders

+ Your Purchase Orders
Every PO issued to your company, with current status and dollar value.
@@ -113,7 +114,9 @@ export default function VendorPortalPosPage() {
{isLoading &&
Loading POs…
} - {error &&
{error.message}
} + + {error?.message} + {!isLoading && !error && visible.length === 0 && (
No POs match this view.
diff --git a/src/components/ui/page-header.tsx b/src/components/ui/page-header.tsx index a43b99e3..dd691a80 100644 --- a/src/components/ui/page-header.tsx +++ b/src/components/ui/page-header.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; -import { Stack, Typography } from "@mui/material"; +import { Stack } from "@mui/material"; +import { Text } from "@/components/ui/text"; import { cn } from "@/lib/utils"; type PageHeaderProps = { @@ -35,24 +36,25 @@ export function PageHeader({ }} > - {eyebrow && ( -

- {eyebrow} -

- )} -

+ {eyebrow} + + {title} -

- {subtitle && ( - - {subtitle} - - )} + + + {subtitle} +
{actions && ( diff --git a/src/components/ui/text.tsx b/src/components/ui/text.tsx new file mode 100644 index 00000000..2628d808 --- /dev/null +++ b/src/components/ui/text.tsx @@ -0,0 +1,99 @@ +import type { ElementType, ReactNode } from "react"; +import { + Typography as MuiTypography, + type TypographyProps as MuiTypographyProps, +} from "@mui/material"; + +type TextVariant = + | "display" + | "title" + | "heading" + | "body" + | "description" + | "label" + | "feedback" + | "error" + | "caption" + | "code"; + +type TextTone = "default" | "muted" | "error" | "success" | "warning"; +type TextFamily = "display" | "body" | "mono"; + +export type TextProps = Omit & { + children: ReactNode; + as?: ElementType; + family?: TextFamily; + tone?: TextTone; + variant?: TextVariant; + when?: boolean; +}; + +const variantConfig: Record< + TextVariant, + { element: ElementType; muiVariant: MuiTypographyProps["variant"]; family: TextFamily } +> = { + display: { element: "h1", muiVariant: "h3", family: "display" }, + title: { element: "h2", muiVariant: "h5", family: "display" }, + heading: { element: "h3", muiVariant: "h6", family: "display" }, + body: { element: "p", muiVariant: "body1", family: "body" }, + description: { element: "p", muiVariant: "body2", family: "body" }, + label: { element: "span", muiVariant: "subtitle2", family: "body" }, + feedback: { element: "p", muiVariant: "body2", family: "body" }, + error: { element: "p", muiVariant: "body2", family: "body" }, + caption: { element: "span", muiVariant: "caption", family: "body" }, + code: { element: "code", muiVariant: "body2", family: "mono" }, +}; + +const toneColor: Record = { + default: "text.primary", + muted: "text.secondary", + error: "error", + success: "success.main", + warning: "warning.main", +}; + +const familyValue: Record = { + display: "var(--font-display)", + body: "var(--font-sans)", + mono: "var(--font-mono)", +}; + +export function Text({ + as, + children, + family, + tone, + variant = "body", + when = true, + sx, + ...props +}: TextProps) { + if (!when) { + return null; + } + + const config = variantConfig[variant]; + const resolvedTone = tone ?? (variant === "error" ? "error" : "default"); + let liveProps = {}; + if (variant === "error") { + liveProps = { role: "alert", "aria-live": "assertive" as const }; + } else if (variant === "feedback") { + liveProps = { role: "status", "aria-live": "polite" as const }; + } + + return ( + + {children} + + ); +} diff --git a/src/test/components/ui/text.test.tsx b/src/test/components/ui/text.test.tsx new file mode 100644 index 00000000..59b4ce93 --- /dev/null +++ b/src/test/components/ui/text.test.tsx @@ -0,0 +1,44 @@ +import { screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { Text } from "@/components/ui/text"; +import { renderWithProviders } from "@/test/test-utils"; + +describe("Text", () => { + it("maps visual variants to semantic elements", () => { + renderWithProviders( + <> + Page title + Supporting copy + , + { withAuth: false }, + ); + + expect(screen.getByRole("heading", { level: 1, name: "Page title" })).toBeInTheDocument(); + expect(screen.getByText("Supporting copy").tagName).toBe("P"); + }); + + it("does not render conditional text when its condition is false", () => { + renderWithProviders( + + Saved + , + { withAuth: false }, + ); + + expect(screen.queryByText("Saved")).not.toBeInTheDocument(); + }); + + it("gives error and feedback messages accessible live-region semantics", () => { + renderWithProviders( + <> + Upload failed + Uploading + , + { withAuth: false }, + ); + + expect(screen.getByRole("alert")).toHaveTextContent("Upload failed"); + expect(screen.getByRole("status")).toHaveTextContent("Uploading"); + }); +}); From 392af77b911bbb4cf3b16e48dd514e2cb64f14aa Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 24 Jul 2026 11:32:58 -0300 Subject: [PATCH 2/3] refactor(ui): enforce maintainable conditional feedback --- docs/FRONTEND_MAINTAINABILITY.md | 49 ++++++++++ eslint-rules/no-non-boolean-jsx-and.js | 95 +++++++++++++++++++ eslint.config.js | 15 ++- package.json | 4 +- src/app/(auth)/_components/login-form.tsx | 4 +- .../accounts/_components/account-form.tsx | 2 +- src/app/(protected)/accounts/index.tsx | 2 +- .../assets/_components/asset-form.tsx | 2 +- src/app/(protected)/assets/index.tsx | 2 +- .../calendar/_components/event-form.tsx | 2 +- .../contacts/_components/contact-form.tsx | 2 +- src/app/(protected)/contacts/index.tsx | 2 +- src/app/(protected)/dashboard.tsx | 4 +- .../employees/_components/employee-form.tsx | 4 +- src/app/(protected)/employees/index.tsx | 2 +- .../followups/_components/follow-up-form.tsx | 2 +- src/app/(protected)/followups/index.tsx | 2 +- src/app/(protected)/index.tsx | 4 +- .../locations/_components/location-form.tsx | 2 +- src/app/(protected)/locations/index.tsx | 2 +- .../_components/pm-schedule-form.tsx | 4 +- src/app/(protected)/pmschedules/index.tsx | 2 +- src/app/(protected)/settings/dropdowns.tsx | 6 +- .../(protected)/settings/task-templates.tsx | 2 +- src/app/(protected)/uplifts/index.tsx | 4 +- src/app/(protected)/vendor-pos/[id].tsx | 8 +- src/app/(protected)/vendor-pos/index.tsx | 2 +- .../_components/vendor-create-modal.tsx | 4 +- .../_components/vendor-detail-drawer.tsx | 12 +-- .../vendors/_components/vendor-form.tsx | 4 +- src/app/(protected)/vendors/index.tsx | 4 +- src/app/(protected)/workorders/[id].tsx | 2 +- .../_components/dispatch-create-modal.tsx | 4 +- .../_components/dispatch-detail-modal.tsx | 18 ++-- src/app/(protected)/workorders/index.tsx | 2 +- src/app/v/[token]/_layout.tsx | 6 +- src/app/v/[token]/dispatch/[id].tsx | 14 +-- .../common/calendar/event-calendar.tsx | 2 +- src/components/common/signature-capture.tsx | 2 +- src/components/ui/collapsible-section.tsx | 2 +- src/components/ui/empty-state.tsx | 12 +-- src/components/ui/filter-popover.tsx | 2 +- src/components/ui/form-field.tsx | 2 +- src/components/ui/page-header.tsx | 6 +- src/components/ui/text.tsx | 17 ++-- src/routing/app-shell.tsx | 24 ++--- src/test/components/ui/text.test.tsx | 44 ++++++++- 47 files changed, 308 insertions(+), 106 deletions(-) create mode 100644 eslint-rules/no-non-boolean-jsx-and.js diff --git a/docs/FRONTEND_MAINTAINABILITY.md b/docs/FRONTEND_MAINTAINABILITY.md index cd7863da..9b125ecc 100644 --- a/docs/FRONTEND_MAINTAINABILITY.md +++ b/docs/FRONTEND_MAINTAINABILITY.md @@ -18,6 +18,33 @@ state. Use a ternary only when both branches render meaningful alternatives. ESLint rejects `condition ? : null`. This keeps one-sided conditions visually distinct from real either-or UI decisions. +The left operand of `&&` must be entirely boolean. `&&` renders its left operand when it is falsy +and non-boolean (notably `{count && }` renders `0`), so a type-aware ESLint rule, +`seahaven/no-non-boolean-jsx-and`, is enforced at `error` across the repository. It asks the +TypeScript checker for the type of the left operand and reports unless every union constituent is +boolean-like, so `boolean | undefined` and `string | null` fail just as `number` does. The rule +fails closed: if type services are unavailable in a governed TSX file it reports rather than +silently claiming safety. The `when` prop on `Text` is typed `boolean`, so TypeScript enforces the +same constraint at that component boundary. + +Approved guard forms (choose by semantics, not as a blind codemod): + +- Presence-only values where falsy means "absent" — errors, optional strings shown only when set + (descriptions, notes, reasons), and optional objects (locations, detail records): coerce with + `Boolean(value)` (or `Boolean(a || b)` for a composite presence test) before `&&`. + `{Boolean(error) && }` and `{Boolean(description) && }` are the canonical forms. +- Values where `0` or `""` is meaningful, or where a non-null value must flow into a typed prop or + helper inside the branch: use an explicit nullish/range comparison so the operand is boolean and + TypeScript can still narrow. `{count > 0 && ...}`, `{value != null && ...}`, and + `{isEdit && id != null && }` keep `0`/`""` semantics and preserve + narrowing. +- Element-slot props (`icon`, `action`, `actions`) are typed `ReactElement`, not `ReactNode`: the + slot holds one element (or fragment), and the render branch coerces with `Boolean(prop)`. Do not + widen these back to `ReactNode`, since a slot is never a meaningful `0`/`""`. + +Never weaken, disable, baseline, or add per-line exceptions to the rule. A new one-sided condition +that needs a non-boolean operand must be rewritten into one of the approved forms above. + ## Typography and feedback Use `Text` from `@/components/ui/text` for headings, paragraphs, descriptions, labels, captions, @@ -29,9 +56,31 @@ code, and asynchronous feedback. It owns: - accessible `alert` and `status` live regions for error and feedback text; - one-sided conditional text through `when`. +The variant contract is non-overridable: `component`, `role`, and `aria-live` are omitted from +`TextProps` (TypeScript blocks them) and the remaining props are spread before the variant-owned +attributes so the variant element, tone, and live-region role always win at runtime. Use `as` to +choose the rendered element and `tone` to choose the color; do not pass `component`, `role`, or +`aria-live` directly. + +Live-region behavior: + +- `feedback` (polite `status`) keeps the region mounted and toggles its text content via `when`, so + the polite region exists before its content changes and is announced reliably. Mounting an + already-populated status node on demand is not announceable on most screen-reader/browser pairs. +- `error` (assertive `alert`) mounts on demand (`when={false}` unmounts it). Alert-on-mount is the + expected error pattern, so the shipped `when={Boolean(error)}` usages are correct. + ESLint rejects raw paragraph and heading elements. Existing MUI `Typography` usages remain valid, but new shared UI should prefer `Text` so semantics and design tokens do not drift. +The `vp-error` CSS token is presentational chrome for vendor-portal error cards and may only appear +on the `Text` component. ESLint flags any non-`Text` JSX element whose `className` is a static +string literal containing `vp-error` (e.g. `
`, `
`); pair the error message with `variant="error"`. The rule enforces the +static surface only. It cannot resolve dynamic or composed class values +(`className={cn("vp-error", ...)}`, template literals with expressions, or expression-wrapped +strings), so do not compose `vp-error` dynamically to bypass it — prefer `Text variant="error"`. + ## Forms and mutations Use the libraries already established in the application: diff --git a/eslint-rules/no-non-boolean-jsx-and.js b/eslint-rules/no-non-boolean-jsx-and.js new file mode 100644 index 00000000..10408424 --- /dev/null +++ b/eslint-rules/no-non-boolean-jsx-and.js @@ -0,0 +1,95 @@ +import ts from "typescript"; + +/** + * Local type-aware rule: the left operand of `&&` in JSX must be entirely + * boolean. + * + * `{value && }` renders its left operand when it is falsy and + * non-boolean (notably `{count && }` renders `0`), so the left operand + * must be `boolean` (or boolean literals) across the whole union. The rule + * asks the TypeScript checker for the type of the left operand and reports + * unless every union constituent is boolean-like. + * + * Type-aware by design: a selector that cannot see operand types would give + * false confidence rather than enforcement. If type services are unexpectedly + * unavailable in a governed TSX file, the rule fails closed (reports) instead + * of silently claiming the expression is safe. + */ +const booleanLikeFlags = ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral; + +function isBooleanLikeType(type) { + return (type.flags & booleanLikeFlags) !== 0; +} + +function isEntirelyBoolean(type) { + if (type.isUnion()) { + return type.types.every((constituent) => isBooleanLikeType(constituent)); + } + return isBooleanLikeType(type); +} + +const transparentExpressionTypes = new Set([ + "ChainExpression", + "ParenthesizedExpression", + "TSAsExpression", + "TSNonNullExpression", + "TSTypeAssertion", +]); + +function isInRenderedPosition(node) { + let current = node; + while (current.parent) { + const parent = current.parent; + if (parent.type === "JSXExpressionContainer") { + return parent.parent?.type !== "JSXAttribute"; + } + if (parent.type === "LogicalExpression" || transparentExpressionTypes.has(parent.type)) { + current = parent; + continue; + } + return false; + } + return false; +} + +const rule = { + meta: { + type: "problem", + docs: { + description: "Require the left operand of `&&` in JSX to be entirely boolean", + }, + schema: [], + messages: { + nonBooleanLeft: + 'The left operand of `&&` in JSX must be boolean. Non-boolean falsy operands (e.g. `0`, `""`) render into the DOM; coerce with `Boolean(...)` or `!!` before `&&`.', + typeServicesUnavailable: + "Type-aware boolean-safety check is unavailable for this JSX expression. This file must be part of a TypeScript project with type information so the rule can enforce safely.", + }, + }, + create(context) { + const services = context.sourceCode?.parserServices ?? context.parserServices; + + return { + LogicalExpression(node) { + if (node.operator !== "&&") { + return; + } + if (!isInRenderedPosition(node)) { + return; + } + + if (!services || services.program === null) { + context.report({ node, messageId: "typeServicesUnavailable" }); + return; + } + + const leftType = services.getTypeAtLocation(node.left); + if (!isEntirelyBoolean(leftType)) { + context.report({ node: node.left, messageId: "nonBooleanLeft" }); + } + }, + }; + }, +}; + +export default rule; diff --git a/eslint.config.js b/eslint.config.js index f88e2a81..55ef4f71 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,6 +5,14 @@ import pluginReactRefresh from "eslint-plugin-react-refresh"; import globals from "globals"; import tseslint from "typescript-eslint"; +import noNonBooleanJsxAnd from "./eslint-rules/no-non-boolean-jsx-and.js"; + +const localRules = { + rules: { + "no-non-boolean-jsx-and": noNonBooleanJsxAnd, + }, +}; + const legacyIgnores = [ "src/pages/**", "src/app/store.js", @@ -54,6 +62,7 @@ export default tseslint.config( plugins: { "react-hooks": pluginReactHooks, "react-refresh": pluginReactRefresh, + seahaven: localRules, }, rules: { ...pluginReactHooks.configs.recommended.rules, @@ -62,6 +71,7 @@ export default tseslint.config( "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], "@typescript-eslint/no-explicit-any": "warn", + "seahaven/no-non-boolean-jsx-and": "error", "no-restricted-syntax": [ "error", { @@ -78,8 +88,9 @@ export default tseslint.config( }, { selector: - "JSXOpeningElement[name.name='div'] > JSXAttribute[name.name='className'][value.value='vp-error']", - message: 'Use Text variant="error" for accessible, consistent error feedback.', + "JSXOpeningElement:not([name.name='Text']) > JSXAttribute[name.name='className'][value.type='Literal'][value.value=/vp-error/]", + message: + 'Use the Text component for the vp-error class; pair the error message with variant="error" so feedback stays accessible and consistent.', }, ], }, diff --git a/package.json b/package.json index ee9bb861..170958c4 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,8 @@ "test:watch": "vitest", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", - "lint": "eslint .", - "lint:fix": "eslint . --fix", + "lint": "eslint . --max-warnings=0", + "lint:fix": "eslint . --fix --max-warnings=0", "format": "prettier --write .", "format:check": "prettier --check .", "prepare": "husky" diff --git a/src/app/(auth)/_components/login-form.tsx b/src/app/(auth)/_components/login-form.tsx index e06ba713..3e3d8456 100644 --- a/src/app/(auth)/_components/login-form.tsx +++ b/src/app/(auth)/_components/login-form.tsx @@ -75,7 +75,9 @@ export function LoginForm({ disabled={isLoggingIn} error={Boolean(errors.password)} /> - {loginError && } + {Boolean(loginError) && ( + + )} - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load accounts"} diff --git a/src/app/(protected)/assets/_components/asset-form.tsx b/src/app/(protected)/assets/_components/asset-form.tsx index d01ae284..513f1837 100644 --- a/src/app/(protected)/assets/_components/asset-form.tsx +++ b/src/app/(protected)/assets/_components/asset-form.tsx @@ -91,7 +91,7 @@ export default function AssetFormPage() { {isEdit ? "Edit Asset" : "Create Asset"} - {(error || optionsError) && ( + {Boolean(error || optionsError) && ( {error instanceof Error ? error.message diff --git a/src/app/(protected)/assets/index.tsx b/src/app/(protected)/assets/index.tsx index bea3dd35..e6d6451f 100644 --- a/src/app/(protected)/assets/index.tsx +++ b/src/app/(protected)/assets/index.tsx @@ -116,7 +116,7 @@ export default function AssetsListPage() { - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load assets"} diff --git a/src/app/(protected)/calendar/_components/event-form.tsx b/src/app/(protected)/calendar/_components/event-form.tsx index 183201d2..01134222 100644 --- a/src/app/(protected)/calendar/_components/event-form.tsx +++ b/src/app/(protected)/calendar/_components/event-form.tsx @@ -120,7 +120,7 @@ export default function EventFormPage() { {isEdit ? "Edit Event" : "Create Event"} - {error && Failed to load event} + {Boolean(error) && Failed to load event} diff --git a/src/app/(protected)/contacts/_components/contact-form.tsx b/src/app/(protected)/contacts/_components/contact-form.tsx index f2fefc39..401265d6 100644 --- a/src/app/(protected)/contacts/_components/contact-form.tsx +++ b/src/app/(protected)/contacts/_components/contact-form.tsx @@ -127,7 +127,7 @@ export default function ContactFormPage() { {isEdit ? "Edit Contact" : "Create Contact"} - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load contact"} diff --git a/src/app/(protected)/contacts/index.tsx b/src/app/(protected)/contacts/index.tsx index 54591c7e..e910c8f4 100644 --- a/src/app/(protected)/contacts/index.tsx +++ b/src/app/(protected)/contacts/index.tsx @@ -103,7 +103,7 @@ export default function ContactsListPage() { - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load contacts"} diff --git a/src/app/(protected)/dashboard.tsx b/src/app/(protected)/dashboard.tsx index 20be5454..e1af6bf9 100644 --- a/src/app/(protected)/dashboard.tsx +++ b/src/app/(protected)/dashboard.tsx @@ -70,7 +70,7 @@ function KpiCard({ title, description, value, icon, color, onClick, loading }: K {loading ? "..." : typeof value === "number" ? value.toLocaleString() : value} )} - {description && ( + {Boolean(description) && ( {isFetching && !isLoading && } - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load dashboard stats"} diff --git a/src/app/(protected)/employees/_components/employee-form.tsx b/src/app/(protected)/employees/_components/employee-form.tsx index 78ed8bc1..6d5413a8 100644 --- a/src/app/(protected)/employees/_components/employee-form.tsx +++ b/src/app/(protected)/employees/_components/employee-form.tsx @@ -83,7 +83,7 @@ function SelectField({ ))} - {helperText && ( + {Boolean(helperText) && ( {helperText} @@ -193,7 +193,7 @@ export default function EmployeeFormPage() { - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load employee"} diff --git a/src/app/(protected)/employees/index.tsx b/src/app/(protected)/employees/index.tsx index b570dbe3..ed56887b 100644 --- a/src/app/(protected)/employees/index.tsx +++ b/src/app/(protected)/employees/index.tsx @@ -124,7 +124,7 @@ export default function EmployeesListPage() { - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load employees"} diff --git a/src/app/(protected)/followups/_components/follow-up-form.tsx b/src/app/(protected)/followups/_components/follow-up-form.tsx index 603dd3f1..d7a6a86c 100644 --- a/src/app/(protected)/followups/_components/follow-up-form.tsx +++ b/src/app/(protected)/followups/_components/follow-up-form.tsx @@ -102,7 +102,7 @@ export default function FollowUpFormPage() { {isEdit ? "Edit Follow-up" : "Create Follow-up"} - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load follow-up"} diff --git a/src/app/(protected)/followups/index.tsx b/src/app/(protected)/followups/index.tsx index 69b42a59..30980fc0 100644 --- a/src/app/(protected)/followups/index.tsx +++ b/src/app/(protected)/followups/index.tsx @@ -129,7 +129,7 @@ export default function FollowUpsListPage() { - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load follow-ups"} diff --git a/src/app/(protected)/index.tsx b/src/app/(protected)/index.tsx index 20be5454..e1af6bf9 100644 --- a/src/app/(protected)/index.tsx +++ b/src/app/(protected)/index.tsx @@ -70,7 +70,7 @@ function KpiCard({ title, description, value, icon, color, onClick, loading }: K {loading ? "..." : typeof value === "number" ? value.toLocaleString() : value} )} - {description && ( + {Boolean(description) && ( {isFetching && !isLoading && } - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load dashboard stats"} diff --git a/src/app/(protected)/locations/_components/location-form.tsx b/src/app/(protected)/locations/_components/location-form.tsx index 79ef1acc..990fc75d 100644 --- a/src/app/(protected)/locations/_components/location-form.tsx +++ b/src/app/(protected)/locations/_components/location-form.tsx @@ -114,7 +114,7 @@ export default function LocationFormPage() { - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load location"} diff --git a/src/app/(protected)/locations/index.tsx b/src/app/(protected)/locations/index.tsx index df239574..097db2c8 100644 --- a/src/app/(protected)/locations/index.tsx +++ b/src/app/(protected)/locations/index.tsx @@ -118,7 +118,7 @@ export default function LocationsListPage() { - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load locations"} diff --git a/src/app/(protected)/pmschedules/_components/pm-schedule-form.tsx b/src/app/(protected)/pmschedules/_components/pm-schedule-form.tsx index d3b82f15..0253a149 100644 --- a/src/app/(protected)/pmschedules/_components/pm-schedule-form.tsx +++ b/src/app/(protected)/pmschedules/_components/pm-schedule-form.tsx @@ -80,8 +80,8 @@ export default function PmScheduleFormPage() { {isEdit ? "Edit PM Schedule" : "Create PM Schedule"} - {error && Failed to load PM schedule} - {optionsError && ( + {Boolean(error) && Failed to load PM schedule} + {Boolean(optionsError) && ( {optionsError instanceof Error ? optionsError.message : "Failed to load form options"} diff --git a/src/app/(protected)/pmschedules/index.tsx b/src/app/(protected)/pmschedules/index.tsx index 4517d4ed..826381e7 100644 --- a/src/app/(protected)/pmschedules/index.tsx +++ b/src/app/(protected)/pmschedules/index.tsx @@ -122,7 +122,7 @@ export default function PmSchedulesListPage() { )} - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load PM schedules"} diff --git a/src/app/(protected)/settings/dropdowns.tsx b/src/app/(protected)/settings/dropdowns.tsx index 4e2c0df7..e4aef562 100644 --- a/src/app/(protected)/settings/dropdowns.tsx +++ b/src/app/(protected)/settings/dropdowns.tsx @@ -158,7 +158,7 @@ export default function DropdownOptionsPage() { alignItems: "stretch", }} > - {parentCategory && ( + {Boolean(parentCategory) && ( Parent Trade - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load work orders"} diff --git a/src/app/v/[token]/_layout.tsx b/src/app/v/[token]/_layout.tsx index 3b4e7880..cd3bb545 100644 --- a/src/app/v/[token]/_layout.tsx +++ b/src/app/v/[token]/_layout.tsx @@ -10,7 +10,7 @@ function VendorPortalHeader() { return (
Sea Haven Industries — Vendor Portal
- {status === "ready" && vendor?.companyName && ( + {status === "ready" && vendor != null && Boolean(vendor.companyName) && (
{vendor.companyName}
)}
@@ -56,13 +56,13 @@ function VendorPortalBody() { if (status === "error") { return (
-
+ Access Denied {error?.message || "This link is invalid or has expired. Please contact your dispatcher for a new link."} -
+
); } diff --git a/src/app/v/[token]/dispatch/[id].tsx b/src/app/v/[token]/dispatch/[id].tsx index 854631b9..a7000843 100644 --- a/src/app/v/[token]/dispatch/[id].tsx +++ b/src/app/v/[token]/dispatch/[id].tsx @@ -64,7 +64,7 @@ function ChecklistSection({ - {item.isCompleted && item.completedAt && ( + {item.isCompleted && Boolean(item.completedAt) && ( {formatVendorPortalDateTime(item.completedAt)} @@ -225,7 +225,7 @@ function UpliftRequestsSection({
- {request.vendorReason && ( + {Boolean(request.vendorReason) && (
Reason {request.vendorReason} @@ -469,13 +469,13 @@ export default function VendorPortalDispatchPage() {
- {data.workOrder?.internalWONumber && ( + {data.workOrder != null && Boolean(data.workOrder.internalWONumber) && (
WO # {data.workOrder.internalWONumber}
)} - {data.poNumber && ( + {Boolean(data.poNumber) && (
PO # {data.poNumber} @@ -487,7 +487,7 @@ export default function VendorPortalDispatchPage() { ${Number(data.nteAmount).toFixed(2)}
)} - {data.scheduledDate && ( + {Boolean(data.scheduledDate) && (
Scheduled {formatVendorPortalDateTime(data.scheduledDate)} @@ -495,7 +495,7 @@ export default function VendorPortalDispatchPage() { )}
- {data.location && ( + {data.location != null && ( <> Location @@ -514,7 +514,7 @@ export default function VendorPortalDispatchPage() { )} - {(data.description || data.workOrder?.description) && ( + {Boolean(data.description || data.workOrder?.description) && ( <> Description diff --git a/src/components/common/calendar/event-calendar.tsx b/src/components/common/calendar/event-calendar.tsx index ca7d11aa..0616c81b 100644 --- a/src/components/common/calendar/event-calendar.tsx +++ b/src/components/common/calendar/event-calendar.tsx @@ -22,7 +22,7 @@ export const EventCalendar = forwardRef(functi ) { return ( - {loading && ( + {Boolean(loading) && ( diff --git a/src/components/common/signature-capture.tsx b/src/components/common/signature-capture.tsx index c08123c2..9c14cf18 100644 --- a/src/components/common/signature-capture.tsx +++ b/src/components/common/signature-capture.tsx @@ -185,7 +185,7 @@ export function SignatureCapture({ onClose, onSave, title }: SignatureCapturePro fullWidth size="small" /> - {typedName && ( + {Boolean(typedName) && (
- {Icon && } + {Icon != null && } {label}
diff --git a/src/components/ui/empty-state.tsx b/src/components/ui/empty-state.tsx index 944591a7..5cf52b5f 100644 --- a/src/components/ui/empty-state.tsx +++ b/src/components/ui/empty-state.tsx @@ -1,13 +1,13 @@ -import type { ReactNode } from "react"; +import type { ReactElement } from "react"; import { Box, Typography } from "@mui/material"; import { cn } from "@/lib/utils"; type EmptyStateProps = { - icon?: ReactNode; + icon?: ReactElement; title: string; description?: string; - action?: ReactNode; + action?: ReactElement; className?: string; }; @@ -19,11 +19,11 @@ export function EmptyState({ icon, title, description, action, className }: Empt className, )} > - {icon && {icon}} + {Boolean(icon) && {icon}} {title} - {description && ( + {Boolean(description) && ( )} - {action && {action}} + {Boolean(action) && {action}} ); } diff --git a/src/components/ui/filter-popover.tsx b/src/components/ui/filter-popover.tsx index 59824344..2d8d5e8a 100644 --- a/src/components/ui/filter-popover.tsx +++ b/src/components/ui/filter-popover.tsx @@ -34,7 +34,7 @@ export function FilterPopover({
Filters - {activeCount > 0 && onClear && ( + {activeCount > 0 && Boolean(onClear) && (