From 7ff7074c083fd67d1d2177ceb18ac51d97d11d9c Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:29:06 +0200 Subject: [PATCH 1/9] feat: show a visually different page in case of client errors at loader instead of the generic error page --- .changeset/smart-stamps-hope.md | 5 + fdm-app/app/components/custom/error.tsx | 144 +++++++++++----- fdm-app/app/lib/error.ts | 12 +- fdm-app/app/root.tsx | 15 +- fdm-app/app/routes/farm.tsx | 216 +++++++++++++++++------- fdm-app/app/routes/organization.tsx | 102 +++++++---- fdm-app/app/routes/support.tsx | 76 ++++++--- fdm-app/app/routes/user.tsx | 78 ++++++--- 8 files changed, 454 insertions(+), 194 deletions(-) create mode 100644 .changeset/smart-stamps-hope.md diff --git a/.changeset/smart-stamps-hope.md b/.changeset/smart-stamps-hope.md new file mode 100644 index 000000000..eb26398a4 --- /dev/null +++ b/.changeset/smart-stamps-hope.md @@ -0,0 +1,5 @@ +--- +"@nmi-agro/fdm-app": minor +--- + +In case of a client error (400, 403 and 404) show a visually distinct page than for an error (500) to make it more clear to the user. diff --git a/fdm-app/app/components/custom/error.tsx b/fdm-app/app/components/custom/error.tsx index 875fe01cb..df28eaf1e 100644 --- a/fdm-app/app/components/custom/error.tsx +++ b/fdm-app/app/components/custom/error.tsx @@ -1,19 +1,87 @@ import * as Sentry from "@sentry/react-router" -import { ArrowLeft, Copy, Home } from "lucide-react" +import { ArrowLeft, Compass, Copy, Home, LifeBuoy } from "lucide-react" import { useEffect, useState } from "react" import { NavLink, useNavigate } from "react-router" import { Button } from "~/components/ui/button" import { clientConfig } from "~/lib/config" import { normalizePage } from "~/lib/url-utils" +export const CLIENT_ERROR_STATUSES = [400, 403, 404] + +/** + * Full-screen, generic "page unavailable" state for client error statuses (400, 403, 404). + * + * Deliberately visually distinct from the 5xx/unexpected-error UI below (no illustration, no + * grey background, no stack trace) — this is an expected, everyday state a user might land on by + * accident, not a bug. It borrows the brand-mark treatment already used on the sign-in/auth + * surfaces (a solid `#122023` badge) to give the moment real presence, with a short reassuring + * message and a clearly prioritized set of next steps, none of which reveal whether the specific + * page/resource exists or the user simply lacks permission for it. + */ +export function ClientErrorPage() { + const navigate = useNavigate() + + return ( +
+
+
+ +
+ +

+ Deze pagina is niet beschikbaar +

+

+ Het lijkt erop dat deze pagina niet bestaat, of dat je er geen toegang tot hebt. + Controleer het adres en of je toegang hebt, of neem contact op als je denkt dat dit niet + klopt. +

+ +
+ +
+ + +
+
+
+
+ ) +} + /** * Displays a full-screen error block with tailored messaging and navigation options. * * Depending on the provided error status, this component renders: - * - A specific message and navigation buttons for a 404 error, indicating that the page does not exist. - * - A generic error message along with a button to copy the formatted error details (including status, message, stack trace, page, and timestamp) to the clipboard for other errors. + * - One unified, generic "page unavailable" message for client error statuses (400, 403, 404), + * via {@link ClientErrorPage}. This deliberately never reveals whether a specific resource + * exists or the user simply lacks permission for it — both cases look identical to the user. + * - A visually distinct, diagnostic error message for other (server/unexpected) errors, along + * with a button to copy the formatted error details (including status, message, stack trace, + * page, and timestamp) to the clipboard. * - * If an error message is available, the component also displays the error details formatted as pretty-printed JSON. Otherwise, it shows a fallback message for non-404 errors. + * If an error message is available, the component also displays the error details formatted as + * pretty-printed JSON for the non-client-error case. * * @param status - HTTP status code of the error or null. * @param message - Detailed error message, or null if not available. @@ -35,7 +103,6 @@ export function ErrorBlock({ timestamp: string }) { const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle") - const navigate = useNavigate() useEffect(() => { if (clientConfig.analytics.sentry) { @@ -54,6 +121,12 @@ export function ErrorBlock({ } }, [copyState]) + const isClientError = status !== null && CLIENT_ERROR_STATUSES.includes(status) + + if (isClientError) { + return + } + const errorDetails = JSON.stringify( { status, @@ -81,6 +154,7 @@ export function ErrorBlock({ setCopyState("failed") } } + return (
@@ -91,50 +165,28 @@ export function ErrorBlock({ />

- {status === 404 ? "Aii, deze pagina bestaat niet." : "Oeps, er lijkt iets mis te zijn."} + Oeps, er lijkt iets mis te zijn.

- {status === 404 - ? "Het lijkt erop dat de pagina die je zoekt niet bestaat." - : "Er is onverwachts wat fout gegaan. Probeer eerst opnieuw. Als het niet opnieuw lukt, kopieer dan de foutmelding en neem contact op met Ondersteuning."} + Er is onverwachts wat fout gegaan. Probeer eerst opnieuw. Als het niet opnieuw lukt, kopieer + dan de foutmelding en neem contact op met Ondersteuning.

- {status === 404 ? ( -
- - -
- ) : ( -
- - -
- )} +
+ + +
{message ? (

@@ -144,7 +196,7 @@ export function ErrorBlock({ {errorDetails}

- ) : status === 404 ? null : ( + ) : (

Er zijn helaas geen details over de fout beschikbaar.

diff --git a/fdm-app/app/lib/error.ts b/fdm-app/app/lib/error.ts index c1606d7d7..1d9208c47 100644 --- a/fdm-app/app/lib/error.ts +++ b/fdm-app/app/lib/error.ts @@ -8,6 +8,12 @@ const errorIdSize = 8 // Number of characters in ID export const createErrorId = customAlphabet(customErrorAlphabet, errorIdSize) +// Thrown by fdm-core's checkPermission for any resource the principal can't access, whether it +// exists or not. Shared here so route loaders that need to distinguish this specific failure +// (e.g. to keep the app shell up and show a friendly in-app message instead of throwing) don't +// duplicate the string. +export const PERMISSION_DENIED_MESSAGE = "Principal does not have permission to perform this action" + /** * Extracts a human-readable error message from any thrown value. * @@ -120,7 +126,7 @@ export function handleLoaderError(error: unknown) { } // Permission denied error - if (containsErrorMessage(error, "Principal does not have permission to perform this action")) { + if (containsErrorMessage(error, PERMISSION_DENIED_MESSAGE)) { console.warn("Permission denied: ", error) return data( { @@ -183,7 +189,7 @@ export function handleLoaderError(error: unknown) { * Recursively checks whether an error or any error in its cause chain * contains the given substring in its message. */ -function containsErrorMessage(error: unknown, message: string): boolean { +export function containsErrorMessage(error: unknown, message: string): boolean { if (!(error instanceof Error)) return false if (error.message.includes(message)) return true return containsErrorMessage(error.cause, message) @@ -267,7 +273,7 @@ export function handleActionError(error: unknown) { } // Permission denied error - if (containsErrorMessage(error, "Principal does not have permission to perform this action")) { + if (containsErrorMessage(error, PERMISSION_DENIED_MESSAGE)) { console.warn("Permission denied: ", error) return dataWithWarning( { diff --git a/fdm-app/app/root.tsx b/fdm-app/app/root.tsx index 1aaa770e3..43072ff8e 100644 --- a/fdm-app/app/root.tsx +++ b/fdm-app/app/root.tsx @@ -19,7 +19,7 @@ import { import { getToast } from "remix-toast" import { toast as notify } from "sonner" import { Banner } from "~/components/custom/banner" -import { ErrorBlock } from "~/components/custom/error" +import { CLIENT_ERROR_STATUSES, ErrorBlock } from "~/components/custom/error" import { NavigationProgress } from "~/components/custom/navigation-progress" import { Toaster } from "~/components/ui/sonner" import { auth } from "~/lib/auth.server" @@ -216,7 +216,9 @@ export default function App() { * This component distinguishes between route error responses and generic errors: * - For route errors: * - Redirects to the signin page if the error status is 401. - * - Renders a 404 error block for client errors with status 400, 403, or 404. + * - Renders one unified, generic "page unavailable" error block for client errors with status + * 400, 403, or 404 — deliberately without the specific message/data, so a user can never tell + * whether a resource doesn't exist or they simply lack permission for it. * - Logs other route errors to the error tracking service and renders an error block reflecting the specific status. * - For generic Error instances, it logs the error and renders a 500 error block with the error message and stack trace. * - If the error is null, no error UI is rendered. @@ -240,13 +242,12 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { throw redirect(signInUrl) } - const clientErrors = [400, 403, 404] - if (clientErrors.includes(error.status)) { + if (CLIENT_ERROR_STATUSES.includes(error.status)) { return ( diff --git a/fdm-app/app/routes/farm.tsx b/fdm-app/app/routes/farm.tsx index 0a91c73a6..5fb9b6952 100644 --- a/fdm-app/app/routes/farm.tsx +++ b/fdm-app/app/routes/farm.tsx @@ -8,22 +8,25 @@ import { } from "@nmi-agro/fdm-helpdesk" import posthog from "posthog-js" import { useEffect } from "react" -import { Outlet, redirect, useLoaderData, useMatches } from "react-router" +import { Outlet, redirect, useLoaderData, useMatches, useParams } from "react-router" import { SidebarApps } from "~/components/blocks/sidebar/apps" import { SidebarFarm, SidebarLabs } from "~/components/blocks/sidebar/farm" import { SidebarSupport } from "~/components/blocks/sidebar/support" import { SidebarTitle } from "~/components/blocks/sidebar/title" import { SidebarUser } from "~/components/blocks/sidebar/user" +import { ClientErrorPage } from "~/components/custom/error" import { Sidebar, SidebarContent, SidebarInset, SidebarProvider } from "~/components/ui/sidebar" import { checkSession, getSession } from "~/lib/auth.server" import { getTimeframe } from "~/lib/calendar" import { clientConfig } from "~/lib/config" -import { handleLoaderError } from "~/lib/error" +import { containsErrorMessage, handleLoaderError, PERMISSION_DENIED_MESSAGE } from "~/lib/error" import { fdm } from "~/lib/fdm.server" import { useCalendarStore } from "~/store/calendar" import { useFarmStore } from "~/store/farm" import { useSelectedFieldStore } from "~/store/selected-field" +type FarmLoaderData = ReturnType> + export const meta: MetaFunction = () => { return [ { title: `Dashboard | ${clientConfig.name}` }, @@ -41,6 +44,13 @@ export const meta: MetaFunction = () => { * If the session does not contain a user, the function redirects to the "/signin" route. * Any errors encountered during session retrieval are processed by the designated error handler. * + * If `b_id_farm` is present but doesn't resolve to a farm the principal can access (whether it + * doesn't exist or the principal simply lacks permission — both look identical to the caller), + * this does NOT throw: it returns `farmAccessDenied: true` instead, so the layout can keep the + * sidebar/app shell in place and show a friendly, generic message in the content pane rather + * than losing the entire shell to the root error page. Any other, unexpected error still throws + * normally. + * * @param request - The HTTP request used for obtaining session data. * @returns An object with a "user" property when a valid session is found, and a "farm" property when b_id_farm is found in the URL. * @@ -56,22 +66,35 @@ export async function loader({ params, request }: LoaderFunctionArgs) { return sessionCheckResponse } - const farm = - params.b_id_farm && params.b_id_farm !== "undefined" - ? await getFarm(fdm, session.principal_id, params.b_id_farm) - : undefined + const hasFarmParam = Boolean(params.b_id_farm && params.b_id_farm !== "undefined") - // Minimal farm list for the sidebar's farm picker dialog; only needed when no farm is selected yet - const isFarmSelected = params.b_id_farm && params.b_id_farm !== "undefined" - const farmOptions = isFarmSelected - ? [] - : (await getFarms(fdm, session.principal_id)).map((f) => ({ - b_id_farm: f.b_id_farm, - b_name_farm: f.b_name_farm, - })) + let farm: Awaited> | undefined + let farmAccessDenied = false + if (hasFarmParam && params.b_id_farm) { + try { + farm = await getFarm(fdm, session.principal_id, params.b_id_farm) + } catch (err) { + if (containsErrorMessage(err, PERMISSION_DENIED_MESSAGE)) { + farmAccessDenied = true + } else { + throw err + } + } + } + + // Minimal farm list for the sidebar's farm picker dialog; needed whenever no valid farm is + // selected — either none was requested, or the requested one couldn't be accessed — so the + // user always has a real way to get back into the app. + const farmOptions = + !hasFarmParam || farmAccessDenied + ? (await getFarms(fdm, session.principal_id)).map((f) => ({ + b_id_farm: f.b_id_farm, + b_name_farm: f.b_name_farm, + })) + : [] const farmWritePermission = - params.b_id_farm && params.b_id_farm !== "undefined" + hasFarmParam && !farmAccessDenied && params.b_id_farm ? await checkPermission( fdm, "farm", @@ -86,7 +109,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) { const timeframe = getTimeframe(params) const fields = - params.b_id_farm && params.b_id_farm !== "undefined" + hasFarmParam && !farmAccessDenied && params.b_id_farm ? await getFields(fdm, session.principal_id, params.b_id_farm, timeframe) : [] @@ -124,6 +147,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) { // Return user information from loader return { farm: farm, + farmAccessDenied: farmAccessDenied, farmOptions: farmOptions, user: session.user, userName: session.userName, @@ -147,6 +171,82 @@ export async function loader({ params, request }: LoaderFunctionArgs) { } } +/** + * Renders the farm app shell: sidebar and a content pane. Shared between the normal route + * render, the in-loader "farm inaccessible" state, and this route's `ErrorBoundary` for + * descendant-route errors, so none of those cases ever lose the sidebar — only the content + * pane changes. + */ +function FarmShell({ + loaderData, + activeFieldId, + fieldWritePermission, + children, +}: { + loaderData: FarmLoaderData + activeFieldId: string | undefined + fieldWritePermission: boolean + children: React.ReactNode +}) { + return ( + + + + + + + + + + + + {children} + + ) +} + +/** + * Keeps the sidebar's farm/calendar context (zustand stores) in sync with the current URL. Used + * by both the normal render and the `ErrorBoundary` below — a descendant-route error (e.g. an + * invalid field id) must not leave the sidebar showing a stale or "no farm selected" state when + * the farm itself is perfectly valid. + */ +function useFarmContextSync(farmId: string | undefined, calendar: string | undefined) { + const setFarmId = useFarmStore((state) => state.setFarmId) + useEffect(() => { + setFarmId(farmId) + }, [farmId, setFarmId]) + + const setCalendar = useCalendarStore((state) => state.setCalendar) + useEffect(() => { + if (calendar !== undefined) { + setCalendar(calendar) + } + }, [calendar, setCalendar]) + + const syncContext = useSelectedFieldStore((state) => state.syncContext) + useEffect(() => { + // Expire stale fields across farm or calendar change + syncContext(farmId, calendar) + }, [farmId, calendar, syncContext]) +} + /** * Renders the main application layout. * @@ -159,30 +259,17 @@ export default function App() { (match) => match.pathname.startsWith("/farm/") && match.params.b_id_farm, ) const initialFarmId = farmMatch?.params.b_id_farm as string | undefined - const setFarmId = useFarmStore((state) => state.setFarmId) - - useEffect(() => { - setFarmId(initialFarmId) - }, [initialFarmId, setFarmId]) const calendarMatch = matches.find( (match) => match.pathname.startsWith("/farm/") && match.params.calendar, ) const initialCalendar = calendarMatch?.params.calendar as string | undefined - const setCalendar = useCalendarStore((state) => state.setCalendar) - useEffect(() => { - if (initialCalendar !== undefined) { - setCalendar(initialCalendar) - } - }, [initialCalendar, setCalendar]) - - const { b_id: storedFieldId, setSelectedField, syncContext } = useSelectedFieldStore() + // When the requested farm couldn't be accessed, don't leave the store pointing at it — the + // sidebar should fall back to its normal "no farm selected" state instead of a broken one. + useFarmContextSync(loaderData.farmAccessDenied ? undefined : initialFarmId, initialCalendar) - // Expire stale fields across farm or calendar change - useEffect(() => { - syncContext(initialFarmId, initialCalendar) - }, [initialFarmId, initialCalendar, syncContext]) + const { b_id: storedFieldId, setSelectedField } = useSelectedFieldStore() // Sync store from any route scoped to a specific field (b_id), not just /field/*, so pages // like nutrient advice, norms, balance, indicators, and measures also keep the sidebar's @@ -229,36 +316,37 @@ export default function App() { }, [loaderData.farm?.b_id_farm, loaderData.farm?.b_name_farm]) return ( - - - - - - - - - - - - - - - + + {loaderData.farmAccessDenied ? : } + + ) +} + +/** + * Renders when a descendant route throws (e.g. a field, cultivation, or other resource that + * doesn't exist or can't be accessed). This route's own loader already succeeded — the requested + * farm itself is fine — so the sidebar shell can render normally via {@link FarmShell}; only the + * content pane is replaced with the generic, friendly {@link ClientErrorPage}. The app never + * appears to have been "left". + * + * The farm/calendar context is synced from `loaderData.farm` and the URL just like in {@link App}, + * so e.g. an invalid field id under a perfectly valid farm still shows that farm as active in the + * sidebar instead of falling back to "no farm selected". + */ +export function ErrorBoundary() { + const loaderData = useLoaderData() + const params = useParams() + const calendar = params.calendar + + useFarmContextSync(loaderData.farm?.b_id_farm, calendar) + + return ( + + + ) } diff --git a/fdm-app/app/routes/organization.tsx b/fdm-app/app/routes/organization.tsx index 3e9282679..3265b8b3c 100644 --- a/fdm-app/app/routes/organization.tsx +++ b/fdm-app/app/routes/organization.tsx @@ -15,12 +15,15 @@ import { SidebarOrganizationApps } from "~/components/blocks/sidebar/organizatio import { SidebarSupport } from "~/components/blocks/sidebar/support" import { SidebarTitle } from "~/components/blocks/sidebar/title" import { SidebarUser } from "~/components/blocks/sidebar/user" +import { ClientErrorPage } from "~/components/custom/error" import { Sidebar, SidebarContent, SidebarInset, SidebarProvider } from "~/components/ui/sidebar" import { auth, checkSession, getSession } from "~/lib/auth.server" import { clientConfig } from "~/lib/config" import { handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" +type OrganizationLoaderData = ReturnType> + /** * Retrieves the session from the HTTP request and returns user information if available. * @@ -112,46 +115,28 @@ export async function loader({ request, params }: LoaderFunctionArgs) { } /** - * Renders the main application layout. - * - * This component retrieves user data from the loader using React Router's useLoaderData hook and passes it to the SidebarApp component within a SidebarProvider context. It also renders an Outlet to display nested routes. + * Renders the organization app shell: sidebar, header, and a content pane. Shared between the + * normal route render (`` in the content pane) and this route's `ErrorBoundary` (a + * friendly `` in the content pane) so a descendant route error never leaves + * the user with a bare, out-of-app-feeling page — the sidebar and header stay in place. */ -export default function App() { - const loaderData = useLoaderData() - +function OrganizationShell({ + loaderData, + children, +}: { + loaderData: OrganizationLoaderData + children: React.ReactNode +}) { const organization = loaderData.organizations.find( (org) => org.slug === loaderData.selectedOrganizationSlug, ) - // Identify user if PostHog is configured - useEffect(() => { - if (clientConfig.analytics.posthog && loaderData.user) { - posthog.identify(loaderData.user.id, { - id: loaderData.user.id, - email: loaderData.user.email, - name: loaderData.user.name, - }) - } - }, [loaderData.user]) - - // Register organization group so org-level dashboards aggregate events - useEffect(() => { - if (clientConfig.analytics.posthog && organization) { - posthog.group("organization", organization.slug, { - name: organization.name, - }) - } - }, [loaderData.selectedOrganizationSlug, organization?.name]) - return ( - + - + {children} ) } + +/** + * Renders the main application layout. + * + * This component retrieves user data from the loader using React Router's useLoaderData hook and passes it to the SidebarApp component within a SidebarProvider context. It also renders an Outlet to display nested routes. + */ +export default function App() { + const loaderData = useLoaderData() + + const organization = loaderData.organizations.find( + (org) => org.slug === loaderData.selectedOrganizationSlug, + ) + + // Identify user if PostHog is configured + useEffect(() => { + if (clientConfig.analytics.posthog && loaderData.user) { + posthog.identify(loaderData.user.id, { + id: loaderData.user.id, + email: loaderData.user.email, + name: loaderData.user.name, + }) + } + }, [loaderData.user]) + + // Register organization group so org-level dashboards aggregate events + useEffect(() => { + if (clientConfig.analytics.posthog && organization) { + posthog.group("organization", organization.slug, { + name: organization.name, + }) + } + }, [loaderData.selectedOrganizationSlug, organization?.name]) + + return ( + + + + ) +} + +/** + * Renders when a descendant route throws (e.g. a farm/settings page the user can't access, or a + * resource that doesn't exist). This route's own loader already succeeded, so the sidebar/header + * shell can render normally via {@link OrganizationShell}; only the content pane is replaced with + * the generic, friendly {@link ClientErrorPage} — the app never appears to have been "left". + */ +export function ErrorBoundary() { + const loaderData = useLoaderData() + + return ( + + + + ) +} diff --git a/fdm-app/app/routes/support.tsx b/fdm-app/app/routes/support.tsx index 62d3faed8..34c5cb636 100644 --- a/fdm-app/app/routes/support.tsx +++ b/fdm-app/app/routes/support.tsx @@ -13,6 +13,7 @@ import { SidebarAdminHelpdesk } from "~/components/blocks/sidebar/admin-helpdesk import { SidebarHelpdesk } from "~/components/blocks/sidebar/helpdesk" import { SidebarTitle } from "~/components/blocks/sidebar/title" import { SidebarUser } from "~/components/blocks/sidebar/user" +import { ClientErrorPage } from "~/components/custom/error" import { Sidebar, SidebarContent, SidebarInset, SidebarProvider } from "~/components/ui/sidebar" import { checkSession, getSession } from "~/lib/auth.server" import { clientConfig } from "~/lib/config" @@ -20,6 +21,8 @@ import { handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" import type { Route } from "./+types/support" +type SupportLoaderData = ReturnType> + /** * Retrieves the session from the HTTP request and returns user information if available. * @@ -84,24 +87,18 @@ export async function loader({ request }: Route.LoaderArgs) { } /** - * Renders the main application layout. - * - * This component retrieves user data from the loader using React Router's useLoaderData hook and passes it to the SidebarApp component within a SidebarProvider context. It also renders an Outlet to display nested routes. + * Renders the support/helpdesk app shell: sidebar, header, and a content pane. Shared between + * the normal route render and this route's `ErrorBoundary`, so a descendant route error (e.g. a + * ticket that doesn't exist or can't be accessed) never leaves the user with a bare, + * out-of-app-feeling page — the sidebar and header stay in place. */ -export default function App() { - const loaderData = useLoaderData() - - // Identify user if PostHog is configured - useEffect(() => { - if (clientConfig.analytics.posthog && loaderData.user) { - posthog.identify(loaderData.user.id, { - id: loaderData.user.id, - email: loaderData.user.email, - name: loaderData.user.name, - }) - } - }, [loaderData.user]) - +function SupportShell({ + loaderData, + children, +}: { + loaderData: SupportLoaderData + children: React.ReactNode +}) { return ( @@ -128,10 +125,49 @@ export default function App() {
-
- -
+
{children}
) } + +/** + * Renders the main application layout. + * + * This component retrieves user data from the loader using React Router's useLoaderData hook and passes it to the SidebarApp component within a SidebarProvider context. It also renders an Outlet to display nested routes. + */ +export default function App() { + const loaderData = useLoaderData() + + // Identify user if PostHog is configured + useEffect(() => { + if (clientConfig.analytics.posthog && loaderData.user) { + posthog.identify(loaderData.user.id, { + id: loaderData.user.id, + email: loaderData.user.email, + name: loaderData.user.name, + }) + } + }, [loaderData.user]) + + return ( + + + + ) +} + +/** + * Renders when a descendant route throws. This route's own loader already succeeded, so the + * sidebar/header shell can render normally via {@link SupportShell}; only the content pane is + * replaced with the generic, friendly {@link ClientErrorPage}. + */ +export function ErrorBoundary() { + const loaderData = useLoaderData() + + return ( + + + + ) +} diff --git a/fdm-app/app/routes/user.tsx b/fdm-app/app/routes/user.tsx index 2c29100f7..33bd6a2eb 100644 --- a/fdm-app/app/routes/user.tsx +++ b/fdm-app/app/routes/user.tsx @@ -6,11 +6,14 @@ import { SidebarPlatform } from "~/components/blocks/sidebar/platform" import { SidebarSupport } from "~/components/blocks/sidebar/support" import { SidebarTitle } from "~/components/blocks/sidebar/title" import { SidebarUser } from "~/components/blocks/sidebar/user" +import { ClientErrorPage } from "~/components/custom/error" import { Sidebar, SidebarContent, SidebarInset, SidebarProvider } from "~/components/ui/sidebar" import { checkSession, getSession } from "~/lib/auth.server" import { clientConfig } from "~/lib/config" import { handleLoaderError } from "~/lib/error" +type UserLoaderData = ReturnType> + /** * Retrieves the session from the HTTP request and returns user information if available. * @@ -43,6 +46,40 @@ export async function loader({ request }: LoaderFunctionArgs) { } } +/** + * Renders the user-settings app shell: sidebar and a content pane. Shared between the normal + * route render (`` in the content pane) and this route's `ErrorBoundary` (a friendly + * `` in the content pane), so a descendant route error (e.g. a settings + * sub-page that fails) never leaves the user with a bare, out-of-app-feeling page. + */ +function UserShell({ + loaderData, + children, +}: { + loaderData: UserLoaderData + children: React.ReactNode +}) { + return ( + + + + + + + + + + {children} + + ) +} + /** * Renders the main application layout. * @@ -63,28 +100,23 @@ export default function App() { }, [loaderData.user]) return ( - - - - - - - - - - - - - + + + + ) +} + +/** + * Renders when a descendant route throws. This route's own loader already succeeded, so the + * sidebar shell can render normally via {@link UserShell}; only the content pane is replaced + * with the generic, friendly {@link ClientErrorPage}. + */ +export function ErrorBoundary() { + const loaderData = useLoaderData() + + return ( + + + ) } From 802848b22ee1834c2a9c1c045031a05325af10e4 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:32:59 +0200 Subject: [PATCH 2/9] fix: Guard useLoaderData() in these ErrorBoundarys. --- fdm-app/app/routes/farm.tsx | 12 ++++++++++-- fdm-app/app/routes/organization.tsx | 9 ++++++++- fdm-app/app/routes/support.tsx | 9 ++++++++- fdm-app/app/routes/user.tsx | 9 ++++++++- 4 files changed, 34 insertions(+), 5 deletions(-) diff --git a/fdm-app/app/routes/farm.tsx b/fdm-app/app/routes/farm.tsx index 5fb9b6952..4a2257e9c 100644 --- a/fdm-app/app/routes/farm.tsx +++ b/fdm-app/app/routes/farm.tsx @@ -336,13 +336,21 @@ export default function App() { * The farm/calendar context is synced from `loaderData.farm` and the URL just like in {@link App}, * so e.g. an invalid field id under a perfectly valid farm still shows that farm as active in the * sidebar instead of falling back to "no farm selected". + * + * If instead this route's *own* loader threw (an unexpected error, not the handled + * `farmAccessDenied` case), `useLoaderData()` has nothing to return — there's no session/farm + * data to build the sidebar from, so fall back to the bare, shell-less page rather than crash. */ export function ErrorBoundary() { - const loaderData = useLoaderData() + const loaderData = useLoaderData() as FarmLoaderData | undefined const params = useParams() const calendar = params.calendar - useFarmContextSync(loaderData.farm?.b_id_farm, calendar) + useFarmContextSync(loaderData?.farm?.b_id_farm, calendar) + + if (!loaderData) { + return + } return ( diff --git a/fdm-app/app/routes/organization.tsx b/fdm-app/app/routes/organization.tsx index 3265b8b3c..b2adfcda9 100644 --- a/fdm-app/app/routes/organization.tsx +++ b/fdm-app/app/routes/organization.tsx @@ -209,9 +209,16 @@ export default function App() { * resource that doesn't exist). This route's own loader already succeeded, so the sidebar/header * shell can render normally via {@link OrganizationShell}; only the content pane is replaced with * the generic, friendly {@link ClientErrorPage} — the app never appears to have been "left". + * + * If instead this route's *own* loader threw, `useLoaderData()` has nothing to return — fall + * back to the bare, shell-less page rather than crash on undefined loader data. */ export function ErrorBoundary() { - const loaderData = useLoaderData() + const loaderData = useLoaderData() as OrganizationLoaderData | undefined + + if (!loaderData) { + return + } return ( diff --git a/fdm-app/app/routes/support.tsx b/fdm-app/app/routes/support.tsx index 34c5cb636..67e0a7324 100644 --- a/fdm-app/app/routes/support.tsx +++ b/fdm-app/app/routes/support.tsx @@ -161,9 +161,16 @@ export default function App() { * Renders when a descendant route throws. This route's own loader already succeeded, so the * sidebar/header shell can render normally via {@link SupportShell}; only the content pane is * replaced with the generic, friendly {@link ClientErrorPage}. + * + * If instead this route's *own* loader threw, `useLoaderData()` has nothing to return — fall + * back to the bare, shell-less page rather than crash on undefined loader data. */ export function ErrorBoundary() { - const loaderData = useLoaderData() + const loaderData = useLoaderData() as SupportLoaderData | undefined + + if (!loaderData) { + return + } return ( diff --git a/fdm-app/app/routes/user.tsx b/fdm-app/app/routes/user.tsx index 33bd6a2eb..45c043f0f 100644 --- a/fdm-app/app/routes/user.tsx +++ b/fdm-app/app/routes/user.tsx @@ -110,9 +110,16 @@ export default function App() { * Renders when a descendant route throws. This route's own loader already succeeded, so the * sidebar shell can render normally via {@link UserShell}; only the content pane is replaced * with the generic, friendly {@link ClientErrorPage}. + * + * If instead this route's *own* loader threw, `useLoaderData()` has nothing to return — fall + * back to the bare, shell-less page rather than crash on undefined loader data. */ export function ErrorBoundary() { - const loaderData = useLoaderData() + const loaderData = useLoaderData() as UserLoaderData | undefined + + if (!loaderData) { + return + } return ( From f87c02f44a5913f606a2eda799b5f8a96cd4deb7 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:00:12 +0200 Subject: [PATCH 3/9] fix: layout for ticket viewer --- fdm-app/app/components/blocks/helpdesk/ticket-viewer.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/fdm-app/app/components/blocks/helpdesk/ticket-viewer.tsx b/fdm-app/app/components/blocks/helpdesk/ticket-viewer.tsx index d811d4230..8d11dc1db 100644 --- a/fdm-app/app/components/blocks/helpdesk/ticket-viewer.tsx +++ b/fdm-app/app/components/blocks/helpdesk/ticket-viewer.tsx @@ -276,10 +276,7 @@ export function TicketViewer({ } return ( -
+
{/* Static sidebar — only rendered on xl+ screens */} {isXl && (
) } + +/** + * Displays a full-screen error block with tailored messaging and navigation options. + * + * Depending on the provided error status, this component renders: + * - One unified, generic "page unavailable" message for client error statuses (400, 403, 404), + * via {@link ClientErrorPage}. This deliberately never reveals whether a specific resource + * exists or the user simply lacks permission for it — both cases look identical to the user. + * - The diagnostic {@link UnexpectedErrorPage} for anything else. + * + * @param status - HTTP status code of the error or null. + * @param message - Detailed error message, or null if not available. + * @param stacktrace - Optional stack trace providing additional error context. + * @param page - The page where the error occurred. + * @param timestamp - The timestamp when the error was recorded. + */ +export function ErrorBlock({ + status, + message, + stacktrace, + page, + timestamp, +}: { + status: number | null + message: string | null + stacktrace: string | null | undefined + page: string + timestamp: string +}) { + const isClientError = status !== null && CLIENT_ERROR_STATUSES.includes(status) + + if (isClientError) { + return + } + + return ( + + ) +} + +/** + * Classifies the current route error and renders the right UI for it. Meant to be used from a + * route module's `ErrorBoundary` export (pass its `error` prop straight through) — including + * ones nested under a layout route that wants to keep its own shell (sidebar/header) around this + * component rather than losing it entirely, as `root.tsx`'s top-level boundary would. + * + * - Redirects to sign-in for a `401` route error response. + * - Renders the generic, friendly {@link ClientErrorPage} for expected client errors (400, 403, + * 404) — a route/resource that doesn't exist or one the user lacks permission for. + * - Renders the diagnostic {@link UnexpectedErrorPage} for everything else: `5xx` route error + * responses, thrown `Error` instances (e.g. a component that throws while rendering — a real + * bug, not an expected state), or any other unknown thrown value. Client-side errors that + * weren't already captured server-side are reported to Sentry here. + */ +export function RouteErrorFallback({ error }: { error: unknown }) { + const location = useLocation() + const page = location.pathname + const timestamp = new Date().toISOString() + + if (isRouteErrorResponse(error)) { + // Redirect to signin page if authentication is not provided + if (error.status === 401) { + const currentPath = location.pathname + location.search + location.hash + const signInUrl = `./signin?redirectTo=${encodeURIComponent(currentPath)}` + throw redirect(signInUrl) + } + + if (CLIENT_ERROR_STATUSES.includes(error.status)) { + return + } + + // Server-side errors are already captured in Sentry via handleError / reportError. + // No need to capture again client-side. + return ( + + ) + } + + if (error instanceof Error) { + // Client-side JS error (e.g. a component that threw while rendering) — not captured + // server-side, so capture here. + Sentry.captureException(error) + return ( + + ) + } + + if (error === null) { + return null + } + + Sentry.captureException(error) + return ( + + ) +} diff --git a/fdm-app/app/lib/error.ts b/fdm-app/app/lib/error.ts index 1d9208c47..2b6d3ca3e 100644 --- a/fdm-app/app/lib/error.ts +++ b/fdm-app/app/lib/error.ts @@ -14,6 +14,49 @@ export const createErrorId = customAlphabet(customErrorAlphabet, errorIdSize) // duplicate the string. export const PERMISSION_DENIED_MESSAGE = "Principal does not have permission to perform this action" +/** + * Extracts `{ status, statusText }` from a thrown/returned route error, if it has one. + * + * Route modules in this codebase almost always use `throw data(message, { status, statusText })` + * rather than `throw new Response(...)`. `data()` returns a `DataWithResponseInit` instance whose + * `status`/`statusText` live under `.init`, NOT as direct properties — so a naive + * `"status" in error` check (which only matches a real `Response`) silently fails for it, and the + * error falls through to the generic 500 branch below regardless of the status it was thrown + * with. This helper checks both shapes so callers don't need to know which one they're dealing + * with. + */ +function getThrownStatus( + error: unknown, +): { status: number; statusText: string | undefined } | null { + if (typeof error !== "object" || error === null) return null + + // `data(value, init)` — status/statusText live under `.init`. + if ( + "type" in error && + error.type === "DataWithResponseInit" && + "init" in error && + typeof error.init === "object" && + error.init !== null && + "status" in error.init && + typeof error.init.status === "number" + ) { + const statusText = + "statusText" in error.init && typeof error.init.statusText === "string" + ? error.init.statusText + : undefined + return { status: error.init.status, statusText } + } + + // A real thrown `Response` (or Response-like object) — status/statusText are direct properties. + if ("status" in error && typeof error.status === "number") { + const statusText = + "statusText" in error && typeof error.statusText === "string" ? error.statusText : undefined + return { status: error.status, statusText } + } + + return null +} + /** * Extracts a human-readable error message from any thrown value. * @@ -89,40 +132,39 @@ export function reportError( } export function handleLoaderError(error: unknown) { - // Handle 'data' thrown errors - if (typeof error === "object" && error !== null && "status" in error && "statusText" in error) { - // Type guard to check if it's a 'data' object - if (typeof error.status === "number" && typeof error.statusText === "string") { - console.warn(`Loader error: ${error.status} - ${error.statusText}`) + // Handle 'data' thrown errors (and real thrown `Response` objects) + const thrownStatus = getThrownStatus(error) + if (thrownStatus) { + const { status, statusText } = thrownStatus + console.warn(`Loader error: ${status} - ${statusText}`, error) - // Customize the user-facing message based on the status code - let userMessage: string - switch (error.status) { - case 400: - userMessage = error.statusText - break - case 401: - return redirect("/signin") - case 403: - userMessage = "U heeft geen rechten om deze actie uit te voeren." - break - case 404: - userMessage = "De gevraagde data kon niet worden gevonden." - break - // case 500: - default: { - const errorId = reportError(error, { scope: "loader" }) - userMessage = `Er is een onverwachte fout opgetreden. Probeer het later opnieuw of neem contact op met Ondersteuning en meldt de volgende foutcode: ${errorId}.` - break - } + // Customize the user-facing message based on the status code + let userMessage: string + switch (status) { + case 400: + userMessage = statusText ?? "Ongeldige waarde" + break + case 401: + return redirect("/signin") + case 403: + userMessage = "U heeft geen rechten om deze actie uit te voeren." + break + case 404: + userMessage = "De gevraagde data kon niet worden gevonden." + break + // case 500: + default: { + const errorId = reportError(error, { scope: "loader" }) + userMessage = `Er is een onverwachte fout opgetreden. Probeer het later opnieuw of neem contact op met Ondersteuning en meldt de volgende foutcode: ${errorId}.` + break } - return data( - { - warning: error, - }, - { status: error.status, statusText: userMessage }, - ) } + return data( + { + warning: error, + }, + { status, statusText: userMessage }, + ) } // Permission denied error @@ -144,7 +186,7 @@ export function handleLoaderError(error: unknown) { error instanceof Error && (error.message.startsWith("missing: ") || error.message.startsWith("invalid: ")) ) { - console.warn(error.message) + console.warn(error.message, error) return data( { warning: error, @@ -157,7 +199,7 @@ export function handleLoaderError(error: unknown) { } // Not found errors if (error instanceof Error && error.message.startsWith("not found")) { - console.warn(error.message) + console.warn(error.message, error) return data( { warning: error, @@ -195,7 +237,23 @@ export function containsErrorMessage(error: unknown, message: string): boolean { return containsErrorMessage(error.cause, message) } -export function handleActionError(error: unknown) { +/** + * Handles an error thrown/caught inside a route `action`, returning a toast-bearing response for + * expected failures (permission denied, validation, not found), or a generic error toast for + * anything else. + * + * Always use `return handleActionError(error)` — never `throw` it. The `dataWithWarning` / + * `dataWithError` responses this produces are meant to keep the user on the same page with a + * toast notification (and any inline, action-data-driven UI still rendering), not to trigger the + * route's `ErrorBoundary`. Throwing it instead would replace the whole page with the generic + * error/no-access screen and the user would never see the toast. + * + * This function is `async` because several branches delegate to `remix-toast`'s + * `dataWithWarning` / `dataWithError` (which read/write a flash-message cookie and are themselves + * `async`) — but callers don't need to `await` it themselves: returning a `Promise` from an + * `async` action function is automatically awaited by the caller. + */ +export async function handleActionError(error: unknown) { // Spam prevention: inviter exceeded hourly limit if (containsErrorMessage(error, "Rate limit exceeded")) { console.warn("Invitation rate limit hit:", error) @@ -223,53 +281,53 @@ export function handleActionError(error: unknown) { ) } - // Handle 'data' thrown errors - if (typeof error === "object" && error !== null && "status" in error && "statusText" in error) { - // Type guard to check if it's a 'data' object - if (typeof error.status === "number" && typeof error.statusText === "string") { - console.warn(`Action error: ${error.status} - ${error.statusText}`) + // Handle 'data' thrown errors (and real thrown `Response` objects) + const thrownStatus = getThrownStatus(error) + if (thrownStatus) { + const { status, statusText } = thrownStatus + console.warn(`Action error: ${status} - ${statusText}`, error) - // Customize the user-facing message based on the status code - let userMessage: string - let dataStatus = "error" - switch (error.status) { - case 400: - userMessage = error.statusText - dataStatus = "warning" - break - case 401: - return redirect("/signin") - case 403: - userMessage = "U heeft geen rechten om deze actie uit te voeren." - dataStatus = "warning" - break - case 404: - userMessage = "De gevraagde data kon niet worden gevonden." - dataStatus = "warning" - break - // case 500: - default: { - const errorId = reportError(error, { scope: "action" }) - userMessage = `Er is een onverwachte fout opgetreden. Probeer het later opnieuw of neem contact op met Ondersteuning en meldt de volgende foutcode: ${errorId}.` - dataStatus = "error" - break - } - } - if (dataStatus === "warning") { - return dataWithWarning( - { - warning: error, - }, - userMessage, - ) + let userMessage: string + let dataStatus = "error" + let errorId: string | undefined + switch (status) { + case 400: + userMessage = + "Ongeldige invoergegevens. Controleer de ingevoerde gegevens en probeer het opnieuw." + dataStatus = "warning" + break + case 401: + return redirect("/signin") + case 403: + userMessage = "U heeft geen rechten om deze actie uit te voeren." + dataStatus = "warning" + break + case 404: + userMessage = "De gevraagde data kon niet worden gevonden." + dataStatus = "warning" + break + // case 500: + default: { + errorId = reportError(error, { scope: "action" }) + userMessage = `Er is een onverwachte fout opgetreden. Probeer het later opnieuw of neem contact op met Ondersteuning en meldt de volgende foutcode: ${errorId}.` + dataStatus = "error" + break } - return dataWithError( + } + if (dataStatus === "warning") { + return dataWithWarning( { warning: error, }, userMessage, ) } + return dataWithError( + { + warning: error, + }, + { message: userMessage, status, errorId }, + ) } // Permission denied error @@ -283,17 +341,21 @@ export function handleActionError(error: unknown) { ) } - // Missing or invalid parameters errors if ( error instanceof Error && (error.message.startsWith("missing: ") || error.message.startsWith("invalid: ")) ) { - console.warn(error.message) - return dataWithWarning( + console.warn(error.message, error) + const errorId = reportError(error, { scope: "action" }) + return dataWithError( { warning: error, }, - error.message, + { + message: `Er is helaas iets misgegaan. Vernieuw de pagina en probeer het opnieuw. Blijft dit gebeuren, neem dan contact op met Ondersteuning en meld de volgende foutcode: ${errorId}.`, + status: 400, + errorId, + }, ) } @@ -301,8 +363,9 @@ export function handleActionError(error: unknown) { const errorId = reportError(error, { scope: "action", }) - return dataWithError( - error instanceof Error ? error.message : "Unknown error", - `Er is helaas iets fout gegaan. Probeer het later opnieuw of neem contact op met Ondersteuning en meldt de volgende foutcode: ${errorId}.`, - ) + return dataWithError(error instanceof Error ? error.message : "Unknown error", { + message: `Er is helaas iets fout gegaan. Probeer het later opnieuw of neem contact op met Ondersteuning en meldt de volgende foutcode: ${errorId}.`, + status: 500, + errorId, + }) } diff --git a/fdm-app/app/lib/form.ts b/fdm-app/app/lib/form.ts index 9db2c031a..74cc4ccd6 100644 --- a/fdm-app/app/lib/form.ts +++ b/fdm-app/app/lib/form.ts @@ -72,6 +72,6 @@ export async function extractFormValuesFromRequest> return parsedData.data } catch (error) { - throw handleActionError(error) + throw await handleActionError(error) } } diff --git a/fdm-app/app/root.tsx b/fdm-app/app/root.tsx index 43072ff8e..a7109b459 100644 --- a/fdm-app/app/root.tsx +++ b/fdm-app/app/root.tsx @@ -1,16 +1,13 @@ import type { LinksFunction, LoaderFunctionArgs } from "react-router" import { withAuditContext } from "@nmi-agro/fdm-core" -import * as Sentry from "@sentry/react-router" import mapLibreStyle from "maplibre-gl/dist/maplibre-gl.css?url" import posthog from "posthog-js" import { useEffect } from "react" import { data, - isRouteErrorResponse, Links, Meta, Outlet, - redirect, Scripts, ScrollRestoration, useLoaderData, @@ -19,7 +16,7 @@ import { import { getToast } from "remix-toast" import { toast as notify } from "sonner" import { Banner } from "~/components/custom/banner" -import { CLIENT_ERROR_STATUSES, ErrorBlock } from "~/components/custom/error" +import { RouteErrorFallback } from "~/components/custom/error" import { NavigationProgress } from "~/components/custom/navigation-progress" import { Toaster } from "~/components/ui/sonner" import { auth } from "~/lib/auth.server" @@ -138,8 +135,35 @@ export function Layout() { // Hook to show the toasts useEffect(() => { if (toast && toast.type === "error") { + const status = typeof toast.status === "number" ? toast.status : null + const errorId = typeof toast.errorId === "string" ? toast.errorId : null + notify.error(toast.message, { duration: 30000, + action: { + label: "Kopieer", + onClick: () => { + const errorDetails = JSON.stringify( + { + status, + message: toast.message, + stacktrace: errorId ? `ErrorId: ${errorId}` : null, + page: window.location.pathname, + timestamp: new Date().toISOString(), + }, + null, + 2, + ) + navigator.clipboard + .writeText(errorDetails) + .then(() => notify.success("Foutmelding gekopieerd naar klembord")) + .catch(() => + notify.error( + `Kopiëren niet gelukt. Stuur foutcode ${errorId} op naar Ondersteuning.`, + ), + ) + }, + }, }) } if (toast && toast.type === "warning") { @@ -211,86 +235,13 @@ export default function App() { } /** - * Renders an error boundary that handles and displays error information based on the provided error. - * - * This component distinguishes between route error responses and generic errors: - * - For route errors: - * - Redirects to the signin page if the error status is 401. - * - Renders one unified, generic "page unavailable" error block for client errors with status - * 400, 403, or 404 — deliberately without the specific message/data, so a user can never tell - * whether a resource doesn't exist or they simply lack permission for it. - * - Logs other route errors to the error tracking service and renders an error block reflecting the specific status. - * - For generic Error instances, it logs the error and renders a 500 error block with the error message and stack trace. - * - If the error is null, no error UI is rendered. - * - For any other cases, it logs the error and displays an error block with a 500 status and a generic message. + * Renders an error boundary that classifies and displays the current route error. Delegates all + * classification to {@link RouteErrorFallback} (redirect for 401, the friendly, generic page for + * client errors 400/403/404, or the diagnostic page for anything else, including a `null` error + * — this component is also rendered unconditionally inside `Layout` below with `error={null}`). * * @param error - The error encountered during route processing, either as a route error response or a generic Error. */ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { - const location = useLocation() - const page = location.pathname - const timestamp = new Date().toISOString() - - if (isRouteErrorResponse(error)) { - // Redirect to signin page if authentication is not provided - if (error.status === 401) { - // Get the current path the user tried to access - const currentPath = location.pathname + location.search + location.hash - // Construct the sign-in URL with the redirectTo parameter - const signInUrl = `./signin?redirectTo=${encodeURIComponent(currentPath)}` - // Throw the redirect response to be caught by React Router - throw redirect(signInUrl) - } - - if (CLIENT_ERROR_STATUSES.includes(error.status)) { - return ( - - ) - } - - // Server-side errors are already captured in Sentry via handleError / reportError. - // No need to capture again client-side. - return ( - - ) - } - if (error instanceof Error) { - // Client-side JS error — not captured server-side, so capture here. - Sentry.captureException(error) - return ( - - ) - } - if (error === null) { - return null - } - - Sentry.captureException(error) - return ( - - ) + return } diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.atlas.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.atlas.tsx index ad115de41..26205c307 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.atlas.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.atlas.tsx @@ -15,7 +15,7 @@ import { Skeleton } from "~/components/ui/skeleton" import { getMapStyle } from "~/integrations/map" import { getSession } from "~/lib/auth.server" import { clientConfig } from "~/lib/config" -import { handleActionError } from "~/lib/error" +import { handleActionError, handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" export const meta: MetaFunction = () => { @@ -90,7 +90,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { mapStyle: mapStyle, } } catch (error) { - throw handleActionError(error) + throw handleLoaderError(error) } } @@ -175,6 +175,6 @@ export async function action({ params }: ActionFunctionArgs) { throw new Error("Missing field ID.") } } catch (error) { - throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.bcs.$a_id.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.bcs.$a_id.tsx index 10079911b..fd8e7dc1f 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.bcs.$a_id.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.bcs.$a_id.tsx @@ -262,6 +262,6 @@ export async function action({ request, params }: ActionFunctionArgs) { message: "BodemConditieScore is verwijderd!", }) } catch (error) { - throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.bcs._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.bcs._index.tsx index 832156382..02ba29cfd 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.bcs._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.bcs._index.tsx @@ -280,6 +280,6 @@ export async function action({ request, params }: ActionFunctionArgs) { message: "BodemConditieScore is verwijderd!", }) } catch (error) { - throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.bcs.new.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.bcs.new.tsx index 0fd547233..f53cbadda 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.bcs.new.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.bcs.new.tsx @@ -265,6 +265,6 @@ export async function action({ request, params }: ActionFunctionArgs) { // arbitrary objects. Orphaned GCS uploads from failed saves are acceptable; a // separate cleanup job can remove them. - throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.cultivation.$b_lu.harvest.$b_id_harvesting.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.cultivation.$b_lu.harvest.$b_id_harvesting.tsx index eb91cdf2b..d31a27091 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.cultivation.$b_lu.harvest.$b_id_harvesting.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.cultivation.$b_lu.harvest.$b_id_harvesting.tsx @@ -303,6 +303,6 @@ export async function action({ request, params }: ActionFunctionArgs) { ) } } catch (error) { - throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.cultivation.$b_lu.harvest.new.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.cultivation.$b_lu.harvest.new.tsx index 5286798f6..de405445c 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.cultivation.$b_lu.harvest.new.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.cultivation.$b_lu.harvest.new.tsx @@ -248,7 +248,7 @@ export async function action({ request, params }: ActionFunctionArgs) { message: `${term} succesvol toegevoegd! 🎉`, }) } catch (error) { - throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.cultivation.$b_lu.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.cultivation.$b_lu.tsx index 8060f6a6c..48a9dd0ef 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.cultivation.$b_lu.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.cultivation.$b_lu.tsx @@ -267,6 +267,6 @@ export async function action({ request, params }: ActionFunctionArgs) { ) } } catch (error) { - throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.cultivation.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.cultivation.tsx index 0c954207a..6fdd54ed7 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.cultivation.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.cultivation.tsx @@ -284,6 +284,6 @@ export async function action({ request, params }: ActionFunctionArgs) { }) } } catch (error) { - throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.fertilizer._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.fertilizer._index.tsx index 795413d44..87a79ed59 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.fertilizer._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.fertilizer._index.tsx @@ -37,10 +37,10 @@ import { getNmiApiKey } from "~/integrations/nmi.server" import { getSession } from "~/lib/auth.server" import { getCalendar, getTimeframe } from "~/lib/calendar" import { clientConfig } from "~/lib/config" -import { getMainCultivation } from "~/lib/hoofdteelt.server" import { handleActionError, handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" import { extractFormValuesFromRequest } from "~/lib/form" +import { getMainCultivation } from "~/lib/hoofdteelt.server" // Meta export const meta: MetaFunction = () => { @@ -427,6 +427,6 @@ export async function action({ request, params }: ActionFunctionArgs) { }) } } catch (error) { - throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.soil._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.soil._index.tsx index 6293741c4..4e655305e 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.soil._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.soil._index.tsx @@ -261,6 +261,6 @@ export async function action({ request, params }: ActionFunctionArgs) { statusText: "Method not allowed", }) } catch (error) { - throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.soil.analysis.$a_id.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.soil.analysis.$a_id.tsx index 87e443b29..45de3afd9 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.soil.analysis.$a_id.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.soil.analysis.$a_id.tsx @@ -214,6 +214,6 @@ export async function action({ request, params }: ActionFunctionArgs) { message: "Bodemanalyse is bijgewerkt! 🎉", }) } catch (error) { - throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.soil.analysis.new.$analysis_type.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.soil.analysis.new.$analysis_type.tsx index f6c98aec2..0ed731837 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.soil.analysis.new.$analysis_type.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.soil.analysis.new.$analysis_type.tsx @@ -202,6 +202,6 @@ export async function action({ request, params }: ActionFunctionArgs) { message: "Bodemanalyse is toegevoegd! 🎉", }) } catch (error) { - throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.soil.analysis.new.upload.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.soil.analysis.new.upload.tsx index c816229da..1d3d35a66 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.soil.analysis.new.upload.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.soil.analysis.new.upload.tsx @@ -244,6 +244,6 @@ export async function action({ request, params }: ActionFunctionArgs) { ) } - throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.fertilizer._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.fertilizer._index.tsx index 47815509a..2067b0486 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.fertilizer._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.fertilizer._index.tsx @@ -604,6 +604,6 @@ export async function action({ request, params }: ActionFunctionArgs) { if (error instanceof z.ZodError) { return dataWithError(null, "Invoer is ongeldig. Controleer het formulier.") } - throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.new._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.new._index.tsx index f499f4869..36e7302ad 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.new._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.new._index.tsx @@ -533,6 +533,6 @@ export async function action({ request, params }: ActionFunctionArgs) { }, ) } catch (error) { - throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.new.fields.$b_id._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.new.fields.$b_id._index.tsx index 86cb40f93..92319e36f 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.new.fields.$b_id._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.new.fields.$b_id._index.tsx @@ -241,6 +241,6 @@ export async function action({ request, params }: ActionFunctionArgs) { throw new Error("invalid method") } catch (error) { - throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.tsx index 411c1e65d..0e8a0fc0d 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.tsx @@ -423,6 +423,6 @@ export async function action({ request }: ActionFunctionArgs) { message: "Bufferstrook status bijgewerkt.", }) } catch (error) { - throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.measures.$b_id.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.measures.$b_id.tsx index 557aaa98a..e9faaa9b2 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.measures.$b_id.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.measures.$b_id.tsx @@ -293,7 +293,7 @@ export async function action({ request, params }: ActionFunctionArgs) { return dataWithError("unknown intent", "Onbekende actie.") } catch (error) { - throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.measures._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.measures._index.tsx index 69d5c5005..78e8f7a18 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.measures._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.measures._index.tsx @@ -324,7 +324,7 @@ export async function action({ request, params }: ActionFunctionArgs) { return dataWithError("unknown intent", "Onbekende actie.") } catch (error) { - throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id.tsx index 7c0007bcf..ab334239e 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.modify_fertilizer.$p_id.tsx @@ -162,6 +162,6 @@ export async function action({ request }: Route.ActionArgs) { throw Error(`invalid intent: ${formData.intent}`) } catch (e) { - throw handleActionError(e) + return handleActionError(e) } } diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.modify_harvest.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.modify_harvest.tsx index 0e6591d83..19cd1e1a5 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.modify_harvest.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.modify_harvest.tsx @@ -361,6 +361,6 @@ export async function action({ request, params }: Route.ActionArgs) { statusText: "Method Not Allowed", }) } catch (error) { - throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.tsx index e08b0fe0b..645fb8396 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation.tsx @@ -515,6 +515,6 @@ export async function action({ params, request }: Route.ActionArgs) { message: "Succesvol bijgewerkt.", }) } catch (e) { - throw handleActionError(e) + return handleActionError(e) } } diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation_.fertilizer._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation_.fertilizer._index.tsx index 1b2e3d06a..2fbf6078f 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation_.fertilizer._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation_.fertilizer._index.tsx @@ -569,6 +569,6 @@ export async function action({ request, params }: ActionFunctionArgs) { if (error instanceof z.ZodError) { return dataWithError(null, "Invoer is ongeldig. Controleer het formulier.") } - throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation_.harvest._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation_.harvest._index.tsx index e42c58417..8274a7e4c 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation_.harvest._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation_.harvest._index.tsx @@ -900,7 +900,7 @@ export async function action({ request, params }: ActionFunctionArgs) { if (error instanceof z.ZodError) { return dataWithWarning(null, "Invoer is ongeldig. Controleer het formulier.") } - throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/farm.$b_id_farm.fertilizers.$p_id.tsx b/fdm-app/app/routes/farm.$b_id_farm.fertilizers.$p_id.tsx index 04c75eebe..8fd8e7ec0 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.fertilizers.$p_id.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.fertilizers.$p_id.tsx @@ -238,6 +238,6 @@ export async function action({ request, params }: ActionFunctionArgs) { message: `${formValues.p_name_nl} is toegevoegd! 🎉`, }) } catch (error) { - throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/farm.$b_id_farm.fertilizers.new.custom.tsx b/fdm-app/app/routes/farm.$b_id_farm.fertilizers.new.custom.tsx index a71b2ccbd..3da238f6f 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.fertilizers.new.custom.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.fertilizers.new.custom.tsx @@ -155,6 +155,6 @@ export async function action({ request, params }: ActionFunctionArgs) { }, ) } catch (error) { - throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/farm.$b_id_farm.settings.derogation.tsx b/fdm-app/app/routes/farm.$b_id_farm.settings.derogation.tsx index e1adf2722..5c1757708 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.settings.derogation.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.settings.derogation.tsx @@ -71,7 +71,7 @@ export async function action({ request, params }: ActionFunctionArgs) { } return dataWithError({}, `Het is niet gelukt derogatie voor ${year} aan te passen.`) } catch (error) { - throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/farm.$b_id_farm.settings.grazing-intention.tsx b/fdm-app/app/routes/farm.$b_id_farm.settings.grazing-intention.tsx index bd7b97d49..c0be8fd1c 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.settings.grazing-intention.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.settings.grazing-intention.tsx @@ -65,7 +65,7 @@ export async function action({ request, params }: ActionFunctionArgs) { } return dataWithSuccess({}, `Beweiding voor ${year} ingeschakeld.`) } catch (error) { - throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/farm.$b_id_farm.settings.properties.tsx b/fdm-app/app/routes/farm.$b_id_farm.settings.properties.tsx index ab52a32fa..b761135fc 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.settings.properties.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.settings.properties.tsx @@ -281,7 +281,7 @@ export async function action({ request, params }: ActionFunctionArgs) { message: `${formValues.b_name_farm} is bijgewerkt! 🎉`, }) } catch (error) { - throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/farm.$b_id_farm.tsx b/fdm-app/app/routes/farm.$b_id_farm.tsx index 9109ff7e4..4e2ed1ad8 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.tsx @@ -2,7 +2,7 @@ import { getFarm } from "@nmi-agro/fdm-core" import { data, type LoaderFunctionArgs, type MetaFunction } from "react-router" import { getSession } from "~/lib/auth.server" import { clientConfig } from "~/lib/config" -import { handleActionError } from "~/lib/error" +import { handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" // Meta @@ -51,6 +51,6 @@ export async function loader({ request, params }: LoaderFunctionArgs) { session, } } catch (error) { - throw handleActionError(error) + throw handleLoaderError(error) } } diff --git a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.atlas.tsx b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.atlas.tsx index 12be5c19f..79e9cf10d 100644 --- a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.atlas.tsx +++ b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.atlas.tsx @@ -477,6 +477,6 @@ export async function action({ request, params }: ActionFunctionArgs) { message: "Percelen zijn toegevoegd! 🎉", }) } catch (error) { - throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.fields.$b_id._index.tsx b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.fields.$b_id._index.tsx index 098cdd9c4..19474251a 100644 --- a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.fields.$b_id._index.tsx +++ b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.fields.$b_id._index.tsx @@ -255,6 +255,6 @@ export async function action({ request, params }: ActionFunctionArgs) { throw new Error("invalid method") } } catch (error) { - throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.fields.$b_id.soil.analysis.$analysis_type.tsx b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.fields.$b_id.soil.analysis.$analysis_type.tsx index 217691a03..e41fae869 100644 --- a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.fields.$b_id.soil.analysis.$analysis_type.tsx +++ b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.fields.$b_id.soil.analysis.$analysis_type.tsx @@ -189,6 +189,6 @@ export async function action({ request, params }: ActionFunctionArgs) { message: "Bodemanalyse is toegevoegd! 🎉", }) } catch (error) { - throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.fields.$b_id.soil.analysis.upload.tsx b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.fields.$b_id.soil.analysis.upload.tsx index d9cbdc057..d2560e615 100644 --- a/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.fields.$b_id.soil.analysis.upload.tsx +++ b/fdm-app/app/routes/farm.create.$b_id_farm.$calendar.fields.$b_id.soil.analysis.upload.tsx @@ -233,6 +233,6 @@ export async function action({ request, params }: ActionFunctionArgs) { ) } - throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/farm.create._index.tsx b/fdm-app/app/routes/farm.create._index.tsx index c9fcff96c..2e3377527 100644 --- a/fdm-app/app/routes/farm.create._index.tsx +++ b/fdm-app/app/routes/farm.create._index.tsx @@ -617,6 +617,6 @@ export async function action({ request }: ActionFunctionArgs) { message: "Bedrijf is toegevoegd! 🎉 Selecteer nu de importmethode.", }) } catch (error) { - throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/farm.tsx b/fdm-app/app/routes/farm.tsx index 4a2257e9c..52205f70b 100644 --- a/fdm-app/app/routes/farm.tsx +++ b/fdm-app/app/routes/farm.tsx @@ -14,7 +14,7 @@ import { SidebarFarm, SidebarLabs } from "~/components/blocks/sidebar/farm" import { SidebarSupport } from "~/components/blocks/sidebar/support" import { SidebarTitle } from "~/components/blocks/sidebar/title" import { SidebarUser } from "~/components/blocks/sidebar/user" -import { ClientErrorPage } from "~/components/custom/error" +import { ClientErrorPage, RouteErrorFallback } from "~/components/custom/error" import { Sidebar, SidebarContent, SidebarInset, SidebarProvider } from "~/components/ui/sidebar" import { checkSession, getSession } from "~/lib/auth.server" import { getTimeframe } from "~/lib/calendar" @@ -24,6 +24,7 @@ import { fdm } from "~/lib/fdm.server" import { useCalendarStore } from "~/store/calendar" import { useFarmStore } from "~/store/farm" import { useSelectedFieldStore } from "~/store/selected-field" +import type { Route } from "./+types/farm" type FarmLoaderData = ReturnType> @@ -328,10 +329,11 @@ export default function App() { /** * Renders when a descendant route throws (e.g. a field, cultivation, or other resource that - * doesn't exist or can't be accessed). This route's own loader already succeeded — the requested - * farm itself is fine — so the sidebar shell can render normally via {@link FarmShell}; only the - * content pane is replaced with the generic, friendly {@link ClientErrorPage}. The app never - * appears to have been "left". + * doesn't exist or can't be accessed — or a genuine component bug). This route's own loader + * already succeeded — the requested farm itself is fine — so the sidebar shell can render + * normally via {@link FarmShell}; only the content pane is replaced with the classified + * {@link RouteErrorFallback} (the friendly page for an expected client error, or the diagnostic + * page for a real bug). The app never appears to have been "left". * * The farm/calendar context is synced from `loaderData.farm` and the URL just like in {@link App}, * so e.g. an invalid field id under a perfectly valid farm still shows that farm as active in the @@ -339,9 +341,9 @@ export default function App() { * * If instead this route's *own* loader threw (an unexpected error, not the handled * `farmAccessDenied` case), `useLoaderData()` has nothing to return — there's no session/farm - * data to build the sidebar from, so fall back to the bare, shell-less page rather than crash. + * data to build the sidebar from, so fall back to the bare, shell-less fallback rather than crash. */ -export function ErrorBoundary() { +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { const loaderData = useLoaderData() as FarmLoaderData | undefined const params = useParams() const calendar = params.calendar @@ -349,12 +351,12 @@ export function ErrorBoundary() { useFarmContextSync(loaderData?.farm?.b_id_farm, calendar) if (!loaderData) { - return + return } return ( - + ) } diff --git a/fdm-app/app/routes/logout.tsx b/fdm-app/app/routes/logout.tsx index 2662a6cbc..74a188a77 100644 --- a/fdm-app/app/routes/logout.tsx +++ b/fdm-app/app/routes/logout.tsx @@ -29,6 +29,6 @@ export async function action({ request }: ActionFunctionArgs) { }) return redirect("/signin") } catch (error) { - throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/organization.$slug.members.tsx b/fdm-app/app/routes/organization.$slug.members.tsx index 01465de1b..73296b4a7 100644 --- a/fdm-app/app/routes/organization.$slug.members.tsx +++ b/fdm-app/app/routes/organization.$slug.members.tsx @@ -369,7 +369,7 @@ const FormSchema = z.object({ export async function action({ request, params }: Route.ActionArgs) { try { if (!params.slug) { - throw handleActionError("not found: organization") + return handleActionError("not found: organization") } const session = await getSession(request) @@ -399,7 +399,7 @@ export async function action({ request, params }: Route.ActionArgs) { }) const organization = organizations.find((org) => org.slug === params.slug) if (!organization) { - throw handleActionError("not found: organization") + return handleActionError("not found: organization") } const organizationId = organization.id const organizationName = organization.name @@ -536,6 +536,6 @@ export async function action({ request, params }: Route.ActionArgs) { } } } - throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/organization.$slug.settings.tsx b/fdm-app/app/routes/organization.$slug.settings.tsx index 7172e0370..c6070a520 100644 --- a/fdm-app/app/routes/organization.$slug.settings.tsx +++ b/fdm-app/app/routes/organization.$slug.settings.tsx @@ -141,6 +141,6 @@ export async function action({ params, request }: Route.ActionArgs) { ) } - throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/organization._index.tsx b/fdm-app/app/routes/organization._index.tsx index 2cba32974..39d7566f0 100644 --- a/fdm-app/app/routes/organization._index.tsx +++ b/fdm-app/app/routes/organization._index.tsx @@ -166,6 +166,6 @@ export async function action({ request }: Route.ActionArgs) { } throw new Error("invalid intent") } catch (error) { - throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/organization.new.tsx b/fdm-app/app/routes/organization.new.tsx index 57b3063ac..1a3278b5a 100644 --- a/fdm-app/app/routes/organization.new.tsx +++ b/fdm-app/app/routes/organization.new.tsx @@ -80,6 +80,6 @@ export async function action({ request }: Route.ActionArgs) { throw e } } catch (error) { - throw handleActionError(error) + return handleActionError(error) } } diff --git a/fdm-app/app/routes/organization.tsx b/fdm-app/app/routes/organization.tsx index b2adfcda9..58d7b18b9 100644 --- a/fdm-app/app/routes/organization.tsx +++ b/fdm-app/app/routes/organization.tsx @@ -15,12 +15,13 @@ import { SidebarOrganizationApps } from "~/components/blocks/sidebar/organizatio import { SidebarSupport } from "~/components/blocks/sidebar/support" import { SidebarTitle } from "~/components/blocks/sidebar/title" import { SidebarUser } from "~/components/blocks/sidebar/user" -import { ClientErrorPage } from "~/components/custom/error" +import { RouteErrorFallback } from "~/components/custom/error" import { Sidebar, SidebarContent, SidebarInset, SidebarProvider } from "~/components/ui/sidebar" import { auth, checkSession, getSession } from "~/lib/auth.server" import { clientConfig } from "~/lib/config" import { handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" +import type { Route } from "./+types/organization" type OrganizationLoaderData = ReturnType> @@ -205,24 +206,25 @@ export default function App() { } /** - * Renders when a descendant route throws (e.g. a farm/settings page the user can't access, or a - * resource that doesn't exist). This route's own loader already succeeded, so the sidebar/header - * shell can render normally via {@link OrganizationShell}; only the content pane is replaced with - * the generic, friendly {@link ClientErrorPage} — the app never appears to have been "left". + * Renders when a descendant route throws (e.g. a farm/settings page the user can't access, a + * resource that doesn't exist, or a genuine component bug). This route's own loader already + * succeeded, so the sidebar/header shell can render normally via {@link OrganizationShell}; only + * the content pane is replaced with the classified {@link RouteErrorFallback} — the app never + * appears to have been "left". * * If instead this route's *own* loader threw, `useLoaderData()` has nothing to return — fall - * back to the bare, shell-less page rather than crash on undefined loader data. + * back to the bare, shell-less fallback rather than crash on undefined loader data. */ -export function ErrorBoundary() { +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { const loaderData = useLoaderData() as OrganizationLoaderData | undefined if (!loaderData) { - return + return } return ( - + ) } diff --git a/fdm-app/app/routes/support._ticketviewer.ticket.$ticket_id.tsx b/fdm-app/app/routes/support._ticketviewer.ticket.$ticket_id.tsx index c63dcf256..d77b2e239 100644 --- a/fdm-app/app/routes/support._ticketviewer.ticket.$ticket_id.tsx +++ b/fdm-app/app/routes/support._ticketviewer.ticket.$ticket_id.tsx @@ -388,7 +388,7 @@ export async function action({ params, request }: Args) { } } - throw handleActionError(err) + return handleActionError(err) } } diff --git a/fdm-app/app/routes/support.new.tsx b/fdm-app/app/routes/support.new.tsx index 00fb4c42d..6840d29dc 100644 --- a/fdm-app/app/routes/support.new.tsx +++ b/fdm-app/app/routes/support.new.tsx @@ -113,7 +113,7 @@ export async function action({ request }: Route.ActionArgs) { "We hebben uw vraag ontvangen. Een collega neemt binnenkort contact met u op.", ) } catch (err) { - throw handleActionError(err) + return handleActionError(err) } } diff --git a/fdm-app/app/routes/support.settings.agents.tsx b/fdm-app/app/routes/support.settings.agents.tsx index bcd7eb8f4..eabb71df0 100644 --- a/fdm-app/app/routes/support.settings.agents.tsx +++ b/fdm-app/app/routes/support.settings.agents.tsx @@ -117,7 +117,7 @@ export async function action({ request }: Route.ActionArgs) { ) } } catch (err) { - throw handleActionError(err) + return handleActionError(err) } } diff --git a/fdm-app/app/routes/support.settings.blocked-emails.tsx b/fdm-app/app/routes/support.settings.blocked-emails.tsx index cce35cf6e..f222dd3ef 100644 --- a/fdm-app/app/routes/support.settings.blocked-emails.tsx +++ b/fdm-app/app/routes/support.settings.blocked-emails.tsx @@ -108,7 +108,7 @@ export async function action({ request }: Route.ActionArgs) { }) } } catch (err) { - throw handleActionError(err) + return handleActionError(err) } } diff --git a/fdm-app/app/routes/support.settings.tags.tsx b/fdm-app/app/routes/support.settings.tags.tsx index e564ae3dd..136d86a59 100644 --- a/fdm-app/app/routes/support.settings.tags.tsx +++ b/fdm-app/app/routes/support.settings.tags.tsx @@ -143,7 +143,7 @@ export async function action({ request }: Route.ActionArgs) { }) } } catch (err) { - throw handleActionError(err) + return handleActionError(err) } } diff --git a/fdm-app/app/routes/support.tsx b/fdm-app/app/routes/support.tsx index 67e0a7324..9a3295405 100644 --- a/fdm-app/app/routes/support.tsx +++ b/fdm-app/app/routes/support.tsx @@ -13,7 +13,7 @@ import { SidebarAdminHelpdesk } from "~/components/blocks/sidebar/admin-helpdesk import { SidebarHelpdesk } from "~/components/blocks/sidebar/helpdesk" import { SidebarTitle } from "~/components/blocks/sidebar/title" import { SidebarUser } from "~/components/blocks/sidebar/user" -import { ClientErrorPage } from "~/components/custom/error" +import { RouteErrorFallback } from "~/components/custom/error" import { Sidebar, SidebarContent, SidebarInset, SidebarProvider } from "~/components/ui/sidebar" import { checkSession, getSession } from "~/lib/auth.server" import { clientConfig } from "~/lib/config" @@ -125,7 +125,10 @@ function SupportShell({
-
{children}
+ {/* min-h-0 lets this flex child actually shrink to its fair share of the available + height (the flexbox "min-height: auto" default otherwise lets tall descendants — + e.g. TicketViewer's own h-full split-pane — stretch this box past the viewport). */} +
{children}
) @@ -158,23 +161,24 @@ export default function App() { } /** - * Renders when a descendant route throws. This route's own loader already succeeded, so the - * sidebar/header shell can render normally via {@link SupportShell}; only the content pane is - * replaced with the generic, friendly {@link ClientErrorPage}. + * Renders when a descendant route throws (e.g. a ticket that doesn't exist or can't be accessed, + * or a genuine component bug). This route's own loader already succeeded, so the sidebar/header + * shell can render normally via {@link SupportShell}; only the content pane is replaced with the + * classified {@link RouteErrorFallback}. * * If instead this route's *own* loader threw, `useLoaderData()` has nothing to return — fall - * back to the bare, shell-less page rather than crash on undefined loader data. + * back to the bare, shell-less fallback rather than crash on undefined loader data. */ -export function ErrorBoundary() { +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { const loaderData = useLoaderData() as SupportLoaderData | undefined if (!loaderData) { - return + return } return ( - + ) } diff --git a/fdm-app/app/routes/user.tsx b/fdm-app/app/routes/user.tsx index 45c043f0f..b69e83431 100644 --- a/fdm-app/app/routes/user.tsx +++ b/fdm-app/app/routes/user.tsx @@ -6,11 +6,12 @@ import { SidebarPlatform } from "~/components/blocks/sidebar/platform" import { SidebarSupport } from "~/components/blocks/sidebar/support" import { SidebarTitle } from "~/components/blocks/sidebar/title" import { SidebarUser } from "~/components/blocks/sidebar/user" -import { ClientErrorPage } from "~/components/custom/error" +import { RouteErrorFallback } from "~/components/custom/error" import { Sidebar, SidebarContent, SidebarInset, SidebarProvider } from "~/components/ui/sidebar" import { checkSession, getSession } from "~/lib/auth.server" import { clientConfig } from "~/lib/config" import { handleLoaderError } from "~/lib/error" +import type { Route } from "./+types/user" type UserLoaderData = ReturnType> @@ -107,23 +108,24 @@ export default function App() { } /** - * Renders when a descendant route throws. This route's own loader already succeeded, so the - * sidebar shell can render normally via {@link UserShell}; only the content pane is replaced - * with the generic, friendly {@link ClientErrorPage}. + * Renders when a descendant route throws (e.g. a settings sub-page that fails, or a genuine + * component bug). This route's own loader already succeeded, so the sidebar shell can render + * normally via {@link UserShell}; only the content pane is replaced with the classified + * {@link RouteErrorFallback}. * * If instead this route's *own* loader threw, `useLoaderData()` has nothing to return — fall - * back to the bare, shell-less page rather than crash on undefined loader data. + * back to the bare, shell-less fallback rather than crash on undefined loader data. */ -export function ErrorBoundary() { +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { const loaderData = useLoaderData() as UserLoaderData | undefined if (!loaderData) { - return + return } return ( - + ) } From 225f3b9e120e3cf8d6bc487b3219b69a1748eda0 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:57:17 +0200 Subject: [PATCH 5/9] feat: capture events when error pages or toasts are shown --- fdm-app/app/components/custom/error.tsx | 25 +++++++++++++++++++++---- fdm-app/app/lib/error.ts | 4 ++-- fdm-app/app/root.tsx | 15 +++++++++++++++ fdm-app/app/routes/farm.tsx | 2 +- 4 files changed, 39 insertions(+), 7 deletions(-) diff --git a/fdm-app/app/components/custom/error.tsx b/fdm-app/app/components/custom/error.tsx index 8cabfc065..f10d38217 100644 --- a/fdm-app/app/components/custom/error.tsx +++ b/fdm-app/app/components/custom/error.tsx @@ -3,6 +3,7 @@ import { ArrowLeft, Compass, Copy, Home, LifeBuoy } from "lucide-react" import { useEffect, useState } from "react" import { isRouteErrorResponse, NavLink, redirect, useLocation, useNavigate } from "react-router" import { Button } from "~/components/ui/button" +import { useAnalytics } from "~/hooks/use-analytics" import { clientConfig } from "~/lib/config" import { normalizePage } from "~/lib/url-utils" @@ -17,9 +18,20 @@ export const CLIENT_ERROR_STATUSES = [400, 403, 404] * surfaces (a solid `#122023` badge) to give the moment real presence, with a short reassuring * message and a clearly prioritized set of next steps, none of which reveal whether the specific * page/resource exists or the user simply lacks permission for it. + * + * @param status - The real HTTP-like status (400/403/404), for analytics only — never rendered, + * so the page itself still never reveals to the *user* which case they hit. */ -export function ClientErrorPage() { +export function ClientErrorPage({ status }: { status?: number | null } = {}) { const navigate = useNavigate() + const { capture } = useAnalytics() + + useEffect(() => { + capture("client_error_page_viewed", { + status: status ?? null, + page: normalizePage(window.location.pathname), + }) + }, [capture, status]) return (
@@ -89,6 +101,7 @@ export function UnexpectedErrorPage({ timestamp: string }) { const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle") + const { capture } = useAnalytics() useEffect(() => { if (clientConfig.analytics.sentry) { @@ -98,7 +111,11 @@ export function UnexpectedErrorPage({ Sentry.metrics.count("error_block.shown", 1) }) } - }, [status, page]) + capture("unexpected_error_page_viewed", { + status: status ?? null, + page: normalizePage(page), + }) + }, [status, page, capture]) useEffect(() => { if (copyState !== "idle") { @@ -216,7 +233,7 @@ export function ErrorBlock({ const isClientError = status !== null && CLIENT_ERROR_STATUSES.includes(status) if (isClientError) { - return + return } return ( @@ -258,7 +275,7 @@ export function RouteErrorFallback({ error }: { error: unknown }) { } if (CLIENT_ERROR_STATUSES.includes(error.status)) { - return + return } // Server-side errors are already captured in Sentry via handleError / reportError. diff --git a/fdm-app/app/lib/error.ts b/fdm-app/app/lib/error.ts index 2b6d3ca3e..2652060ec 100644 --- a/fdm-app/app/lib/error.ts +++ b/fdm-app/app/lib/error.ts @@ -319,7 +319,7 @@ export async function handleActionError(error: unknown) { { warning: error, }, - userMessage, + { message: userMessage, status }, ) } return dataWithError( @@ -337,7 +337,7 @@ export async function handleActionError(error: unknown) { { warning: error, }, - "U heeft helaas geen rechten om dit te doen.", + { message: "U heeft helaas geen rechten om dit te doen.", status: 403 }, ) } diff --git a/fdm-app/app/root.tsx b/fdm-app/app/root.tsx index a7109b459..06cde465a 100644 --- a/fdm-app/app/root.tsx +++ b/fdm-app/app/root.tsx @@ -138,6 +138,14 @@ export function Layout() { const status = typeof toast.status === "number" ? toast.status : null const errorId = typeof toast.errorId === "string" ? toast.errorId : null + if (clientConfig.analytics.posthog) { + posthog.capture("unexpected_error_toast_shown", { + status, + errorId, + page: window.location.pathname, + }) + } + notify.error(toast.message, { duration: 30000, action: { @@ -167,6 +175,13 @@ export function Layout() { }) } if (toast && toast.type === "warning") { + const status = typeof toast.status === "number" ? toast.status : null + if (status !== null && clientConfig.analytics.posthog) { + posthog.capture("client_error_toast_shown", { + status, + page: window.location.pathname, + }) + } notify.warning(toast.message) } if (toast && toast.type === "success") { diff --git a/fdm-app/app/routes/farm.tsx b/fdm-app/app/routes/farm.tsx index 52205f70b..aa3ecf303 100644 --- a/fdm-app/app/routes/farm.tsx +++ b/fdm-app/app/routes/farm.tsx @@ -322,7 +322,7 @@ export default function App() { activeFieldId={activeFieldId} fieldWritePermission={fieldWritePermission} > - {loaderData.farmAccessDenied ? : } + {loaderData.farmAccessDenied ? : } ) } From 625a51e6875375a23122e881ed04f3053db220f2 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:44:34 +0200 Subject: [PATCH 6/9] refactor: implement review feedback --- fdm-app/app/components/custom/error.tsx | 24 ++++++++++++++----- fdm-app/app/lib/error.ts | 23 +++++++++++------- .../app/routes/organization.$slug.members.tsx | 10 ++++++-- 3 files changed, 41 insertions(+), 16 deletions(-) diff --git a/fdm-app/app/components/custom/error.tsx b/fdm-app/app/components/custom/error.tsx index f10d38217..a6af8abcb 100644 --- a/fdm-app/app/components/custom/error.tsx +++ b/fdm-app/app/components/custom/error.tsx @@ -1,7 +1,7 @@ import * as Sentry from "@sentry/react-router" import { ArrowLeft, Compass, Copy, Home, LifeBuoy } from "lucide-react" import { useEffect, useState } from "react" -import { isRouteErrorResponse, NavLink, redirect, useLocation, useNavigate } from "react-router" +import { isRouteErrorResponse, NavLink, useLocation, useNavigate } from "react-router" import { Button } from "~/components/ui/button" import { useAnalytics } from "~/hooks/use-analytics" import { clientConfig } from "~/lib/config" @@ -45,7 +45,7 @@ export function ClientErrorPage({ status }: { status?: number | null } = {}) {

Het lijkt erop dat deze pagina niet bestaat, of dat je er geen toegang tot hebt. - Controleer het adres en of je toegang hebt, of neem contact op als je denkt dat dit niet + Controleer het webadres en of je toegang hebt, of neem contact op als je denkt dat dit niet klopt.

@@ -263,17 +263,29 @@ export function ErrorBlock({ */ export function RouteErrorFallback({ error }: { error: unknown }) { const location = useLocation() + const navigate = useNavigate() const page = location.pathname const timestamp = new Date().toISOString() - if (isRouteErrorResponse(error)) { - // Redirect to signin page if authentication is not provided - if (error.status === 401) { + const isUnauthorized = isRouteErrorResponse(error) && error.status === 401 + + // Redirect to signin page if authentication is not provided. Navigation must happen after + // render (in an effect), not by throwing `redirect()` here — this component renders inside an + // `ErrorBoundary`, not a loader/action, so a thrown redirect isn't a supported navigation + // mechanism in that position. + useEffect(() => { + if (isUnauthorized) { const currentPath = location.pathname + location.search + location.hash const signInUrl = `./signin?redirectTo=${encodeURIComponent(currentPath)}` - throw redirect(signInUrl) + void navigate(signInUrl, { replace: true }) } + }, [isUnauthorized, location, navigate]) + if (isUnauthorized) { + return null + } + + if (isRouteErrorResponse(error)) { if (CLIENT_ERROR_STATUSES.includes(error.status)) { return } diff --git a/fdm-app/app/lib/error.ts b/fdm-app/app/lib/error.ts index 2652060ec..281be846e 100644 --- a/fdm-app/app/lib/error.ts +++ b/fdm-app/app/lib/error.ts @@ -288,7 +288,7 @@ export async function handleActionError(error: unknown) { console.warn(`Action error: ${status} - ${statusText}`, error) let userMessage: string - let dataStatus = "error" + let dataStatus: "error" | "warning" let errorId: string | undefined switch (status) { case 400: @@ -306,6 +306,10 @@ export async function handleActionError(error: unknown) { userMessage = "De gevraagde data kon niet worden gevonden." dataStatus = "warning" break + case 405: + userMessage = "Deze actie is niet toegestaan." + dataStatus = "warning" + break // case 500: default: { errorId = reportError(error, { scope: "action" }) @@ -319,14 +323,17 @@ export async function handleActionError(error: unknown) { { warning: error, }, - { message: userMessage, status }, + userMessage, + { status }, ) } return dataWithError( { warning: error, + errorId, }, - { message: userMessage, status, errorId }, + userMessage, + { status }, ) } @@ -363,9 +370,9 @@ export async function handleActionError(error: unknown) { const errorId = reportError(error, { scope: "action", }) - return dataWithError(error instanceof Error ? error.message : "Unknown error", { - message: `Er is helaas iets fout gegaan. Probeer het later opnieuw of neem contact op met Ondersteuning en meldt de volgende foutcode: ${errorId}.`, - status: 500, - errorId, - }) + return dataWithError( + { warning: error instanceof Error ? error.message : "Unknown error", errorId }, + `Er is helaas iets fout gegaan. Probeer het later opnieuw of neem contact op met Ondersteuning en meldt de volgende foutcode: ${errorId}.`, + { status: 500 }, + ) } diff --git a/fdm-app/app/routes/organization.$slug.members.tsx b/fdm-app/app/routes/organization.$slug.members.tsx index 73296b4a7..e113e3575 100644 --- a/fdm-app/app/routes/organization.$slug.members.tsx +++ b/fdm-app/app/routes/organization.$slug.members.tsx @@ -369,7 +369,10 @@ const FormSchema = z.object({ export async function action({ request, params }: Route.ActionArgs) { try { if (!params.slug) { - return handleActionError("not found: organization") + throw data("not found: organization", { + status: 404, + statusText: "not found: organization", + }) } const session = await getSession(request) @@ -399,7 +402,10 @@ export async function action({ request, params }: Route.ActionArgs) { }) const organization = organizations.find((org) => org.slug === params.slug) if (!organization) { - return handleActionError("not found: organization") + throw data("not found: organization", { + status: 404, + statusText: "not found: organization", + }) } const organizationId = organization.id const organizationName = organization.name From ba870fff60c564578c257e8326e84277ec4d56be Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:12:36 +0200 Subject: [PATCH 7/9] refactor: implement review feedback --- fdm-app/app/components/custom/error.tsx | 2 +- fdm-app/app/root.tsx | 4 +++- ...upport._ticketviewer.ticket.$ticket_id.tsx | 19 ++++++++++++------- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/fdm-app/app/components/custom/error.tsx b/fdm-app/app/components/custom/error.tsx index a6af8abcb..50ded9823 100644 --- a/fdm-app/app/components/custom/error.tsx +++ b/fdm-app/app/components/custom/error.tsx @@ -276,7 +276,7 @@ export function RouteErrorFallback({ error }: { error: unknown }) { useEffect(() => { if (isUnauthorized) { const currentPath = location.pathname + location.search + location.hash - const signInUrl = `./signin?redirectTo=${encodeURIComponent(currentPath)}` + const signInUrl = `/signin?redirectTo=${encodeURIComponent(currentPath)}` void navigate(signInUrl, { replace: true }) } }, [isUnauthorized, location, navigate]) diff --git a/fdm-app/app/root.tsx b/fdm-app/app/root.tsx index 06cde465a..b0c5a606e 100644 --- a/fdm-app/app/root.tsx +++ b/fdm-app/app/root.tsx @@ -167,7 +167,9 @@ export function Layout() { .then(() => notify.success("Foutmelding gekopieerd naar klembord")) .catch(() => notify.error( - `Kopiëren niet gelukt. Stuur foutcode ${errorId} op naar Ondersteuning.`, + errorId + ? `Kopiëren niet gelukt. Stuur foutcode ${errorId} op naar Ondersteuning.` + : "Kopiëren niet gelukt. Neem contact op met Ondersteuning.", ), ) }, diff --git a/fdm-app/app/routes/support._ticketviewer.ticket.$ticket_id.tsx b/fdm-app/app/routes/support._ticketviewer.ticket.$ticket_id.tsx index d77b2e239..ffbe441c5 100644 --- a/fdm-app/app/routes/support._ticketviewer.ticket.$ticket_id.tsx +++ b/fdm-app/app/routes/support._ticketviewer.ticket.$ticket_id.tsx @@ -379,13 +379,18 @@ export async function action({ params, request }: Args) { }) } } catch (err) { - // extractFormValuesFromRequest calls handleActionError itself, so if that is detected to be the case, - // return the response returned from it directly. - if (err instanceof Promise) { - const awaited = await (err as Promise).catch(() => {}) - if (awaited.type === "DataWithResponseInit") { - return awaited - } + // extractFormValuesFromRequest awaits handleActionError before throwing (see + // fdm-app/app/lib/form.ts), so `err` here is already a resolved `DataWithResponseInit` + // toast-bearing response, not an Error. Return it directly instead of re-classifying it + // through handleActionError, which would overwrite its specific validation message with a + // generic one. + if ( + typeof err === "object" && + err !== null && + "type" in err && + (err as { type: unknown }).type === "DataWithResponseInit" + ) { + return err } return handleActionError(err) From 329c1671169481e6111128caa8eb9a77f69dbc3a Mon Sep 17 00:00:00 2001 From: Sven Verweij <37927107+SvenVw@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:10:38 +0200 Subject: [PATCH 8/9] Update fdm-app/app/routes/support.tsx Co-authored-by: BoraIneviNMI Signed-off-by: Sven Verweij <37927107+SvenVw@users.noreply.github.com> --- fdm-app/app/routes/support.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fdm-app/app/routes/support.tsx b/fdm-app/app/routes/support.tsx index 9a3295405..c845c06ab 100644 --- a/fdm-app/app/routes/support.tsx +++ b/fdm-app/app/routes/support.tsx @@ -121,7 +121,7 @@ function SupportShell({ userName={loaderData.userName} /> - +
From 3083a9837fadc782324bec061699672b92c8add9 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:18:25 +0200 Subject: [PATCH 9/9] fix: import --- fdm-app/app/routes/organization.new.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fdm-app/app/routes/organization.new.tsx b/fdm-app/app/routes/organization.new.tsx index 502a9623f..c705ed283 100644 --- a/fdm-app/app/routes/organization.new.tsx +++ b/fdm-app/app/routes/organization.new.tsx @@ -1,7 +1,7 @@ import { FileUpload, parseFormData } from "@remix-run/form-data-parser" import imageSize from "image-size" import crypto from "node:crypto" -import { dataWithError, redirectWithSuccess } from "remix-toast" +import { redirectWithSuccess } from "remix-toast" import z from "zod" import { FarmTitle } from "~/components/blocks/farm/farm-title" import { OrganizationSettingsForm } from "~/components/blocks/organization/form"