Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 101 additions & 35 deletions apps/webapp/app/atoms/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,47 +9,99 @@ import { verifyAccept } from "~/utils/verify-file-accept";

export const fileErrorAtom = atom<string | undefined>(undefined);

export const createValidateFileAtom = (options: {
/** Size + type limits and the copy shown when a file violates them. */
type ValidateFileOptions = {
maxSize: number;
sizeErrorMessage: string;
allowedTypesErrorMessage: string;
}) =>
};

/**
* Validates the picked file and normalises the input in place.
*
* Clears the input on a rejected type or size (so an invalid file is never
* submitted) and rewrites the `FileList` when the filename had to be sanitised
* — a raw filename breaks the content-disposition header on upload.
*
* Pure with respect to state: it returns the message instead of writing it, so
* the same rule can back both the shared and the scoped atoms below.
*
* @param event - Change event from the file input
* @param options - Size/type limits and their messages
* @returns The validation message, or undefined when the file is acceptable
*/
function validateSelectedFile(
event: ChangeEvent<HTMLInputElement>,
options: ValidateFileOptions
): string | undefined {
const file = event?.target?.files?.[0];
if (!file) {
return undefined;
}

const allowedType = verifyAccept(file.type, event.target.accept);
const allowedSize = file.size < options.maxSize;

if (!allowedType) {
event.target.value = "";
return options.allowedTypesErrorMessage;
}

if (!allowedSize) {
/** Clean the field */
event.target.value = "";
return options.sizeErrorMessage;
}

// Sanitize the filename to prevent content-disposition header issues
if (event.target.files) {
const sanitizedFile = sanitizeFile(file);

// If the filename was changed, we need to update the file input
if (sanitizedFile.name !== file.name) {
// Create a new DataTransfer to replace the file in the input
const dataTransfer = new DataTransfer();
dataTransfer.items.add(sanitizedFile);
event.target.files = dataTransfer.files;
}
}

return undefined;
}

export const createValidateFileAtom = (options: ValidateFileOptions) =>
atom(null, (_get, set, event: ChangeEvent<HTMLInputElement>) => {
set(fileErrorAtom, () => {
const file = event?.target?.files?.[0];
if (file) {
const allowedType = verifyAccept(file.type, event.target.accept);
const allowedSize = file.size < options.maxSize;

if (!allowedType) {
event.target.value = "";
return options.allowedTypesErrorMessage;
}

if (!allowedSize) {
/** Clean the field */
event.target.value = "";
return options.sizeErrorMessage;
}

// Sanitize the filename to prevent content-disposition header issues
if (event.target.files) {
const sanitizedFile = sanitizeFile(file);

// If the filename was changed, we need to update the file input
if (sanitizedFile.name !== file.name) {
// Create a new DataTransfer to replace the file in the input
const dataTransfer = new DataTransfer();
dataTransfer.items.add(sanitizedFile);
event.target.files = dataTransfer.files;
}
}

return undefined;
}
});
set(fileErrorAtom, () => validateSelectedFile(event, options));
});

/**
* Builds a validator with its OWN error atom, for file inputs that can be on
* screen at the same time as another one.
*
* {@link fileErrorAtom} is module-scoped, so every consumer of
* {@link createValidateFileAtom} shares one error slot. That is fine while only
* one such form is mounted (asset, kit and audit forms never coexist), but the
* inline "create asset model" dialog opens *inside* the asset form — with a
* shared slot, rejecting a file in the dialog would also light up the asset's
* own image field, and picking a valid one would clear an error the user still
* needs to see.
*
* @param options - Same size/type limits as the shared factory
* @returns `errorAtom` to read the message from, `validateAtom` to pass to `onChange`
*/
export const createScopedValidateFile = (options: ValidateFileOptions) => {
const errorAtom = atom<string | undefined>(undefined);

const validateAtom = atom(
null,
(_get, set, event: ChangeEvent<HTMLInputElement>) => {
set(errorAtom, () => validateSelectedFile(event, options));
}
);

return { errorAtom, validateAtom };
};

// Default instance with 4MB limit
export const defaultValidateFileAtom = createValidateFileAtom({
maxSize: DEFAULT_MAX_IMAGE_UPLOAD_SIZE, // 4MB
Expand All @@ -70,3 +122,17 @@ export const auditImageValidateFileAtom = createValidateFileAtom({
sizeErrorMessage: "Max file size is 4MB",
allowedTypesErrorMessage: "Allowed file types are: PNG, JPG or JPEG",
});

/**
* Asset-model cover image — same 8MB limit as asset images, but scoped, because
* the inline create-model dialog is rendered inside the asset form and would
* otherwise share its error slot.
*/
export const {
errorAtom: assetModelImageErrorAtom,
validateAtom: assetModelImageValidateFileAtom,
} = createScopedValidateFile({
maxSize: ASSET_MAX_IMAGE_UPLOAD_SIZE, // 8MB
sizeErrorMessage: "Max file size is 8MB",
allowedTypesErrorMessage: "Allowed file types are: PNG, JPG, JPEG, or WebP",
});
131 changes: 129 additions & 2 deletions apps/webapp/app/components/asset-model/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,34 @@
*/
import { useEffect } from "react";
import type { AssetModel } from "@prisma/client";
import { useAtom, useAtomValue } from "jotai";
import { useActionData, useLoaderData } from "react-router";
import { useZorm } from "react-zorm";
import z from "zod";
import {
assetModelImageErrorAtom,
assetModelImageValidateFileAtom,
} from "~/atoms/file";
import { useAutoFocus } from "~/hooks/use-auto-focus";
import { useDisabled } from "~/hooks/use-disabled";
import useFetcherWithReset from "~/hooks/use-fetcher-with-reset";
import type { action } from "~/routes/_layout+/settings.asset-models.new";
import { ACCEPT_SUPPORTED_IMAGES } from "~/utils/constants";
import { getValidationErrors } from "~/utils/http";
import type { DataOrErrorResponse } from "~/utils/http.server";
import { zodFieldIsRequired } from "~/utils/zod";
import { Form } from "../custom-form";
import DynamicSelect from "../dynamic-select/dynamic-select";
import FormRow from "../forms/form-row";
import Input from "../forms/input";
import ImageWithPreview from "../image-with-preview/image-with-preview";
import { Button } from "../shared/button";
import { Card } from "../shared/card";

/** Links each image input to the <p> stating its accepted formats and size. */
const INLINE_IMAGE_HELP_ID = "asset-model-image-help-inline";
const PAGE_IMAGE_HELP_ID = "asset-model-image-help";

/** Zod schema for creating/editing an asset model. */
export const AssetModelFormSchema = z.object({
name: z.string().min(2, "Name is required"),
Expand All @@ -47,7 +58,7 @@ type AssetModelFormProps = {
/** Pre-filled values for edit mode */
assetModel?: Pick<
AssetModel,
"name" | "description" | "defaultCategoryId" | "defaultValuation"
"name" | "description" | "defaultCategoryId" | "defaultValuation" | "image"
>;
/** The API URL to submit the form to (used in inline/dialog mode). */
apiUrl?: string;
Expand Down Expand Up @@ -105,6 +116,22 @@ export default function AssetModelForm({
const nameError =
fetcherValidationErrors?.name?.message || zo.errors.name()?.message;

// Client-side file guard (type + 8MB cap). Deliberately SCOPED, not the shared
// `fileErrorAtom`: this dialog opens inside the asset form, whose own image
// input would otherwise surface — and clear — this field's error.
const [, validateFile] = useAtom(assetModelImageValidateFileAtom);
const fileError = useAtomValue(assetModelImageErrorAtom);

/**
* The image has no zod field (a File can't be parsed by the text schema), so
* its errors arrive either from the client-side guard or as the server's
* `field: "image"` ShelfError on the fetcher.
*/
const inlineImageError =
(fetcher.data?.error?.additionalData?.field === "image"
? fetcher.data.error.message
: undefined) ?? fileError;

/* ------------------------------------------------------------------ */
/* Inline / dialog mode */
/* ------------------------------------------------------------------ */
Expand All @@ -116,6 +143,13 @@ export default function AssetModelForm({
className="w-full rounded border border-gray-200 bg-white px-6 py-5"
ref={zo.ref}
action={apiUrl}
/**
* Multipart so this dialog can carry the cover image too. Without it the
* file input silently posts nothing and a model created from the asset
* form would have no image — leaving its assets nothing to inherit,
* which reads as "the feature doesn't work".
*/
encType="multipart/form-data"
>
<div className="gap-4 md:flex md:items-end">
<Input
Expand All @@ -141,6 +175,31 @@ export default function AssetModelForm({
<input type="hidden" name="preventRedirect" value="true" />
</div>

{/* Same cover-image field as the settings form, in a compact layout. */}
<div className="mt-4">
<Input
label="Image"
// Input spreads unknown props straight onto the <input> and adds no
// describedby of its own, so this is the only link between the field
// and its format/size requirements for assistive tech.
aria-describedby={INLINE_IMAGE_HELP_ID}
disabled={disabled}
accept={ACCEPT_SUPPORTED_IMAGES}
name="image"
type="file"
onChange={validateFile}
error={inlineImageError}
inputClassName="border-0 shadow-none p-0 rounded-none"
/>
<p
id={INLINE_IMAGE_HELP_ID}
className="mt-1 text-[12px] text-gray-500"
>
Optional. Shown on every asset of this model that has no image of
its own. PNG, JPG, JPEG or WebP, max. 8 MB.
</p>
</div>

Comment thread
coderabbitai[bot] marked this conversation as resolved.
<div className="mt-4">
<div className="flex items-center gap-1">
{onCancel ? (
Expand Down Expand Up @@ -213,9 +272,29 @@ function FullPageForm({
actionData?.error
);

// Client-side file guard (type + 8MB cap) shared with the asset/kit image
// inputs, so all three surfaces reject the same files with the same copy.
const [, validateFile] = useAtom(assetModelImageValidateFileAtom);
const fileError = useAtomValue(assetModelImageErrorAtom);

/**
* The image upload has no zod field (a File can't be parsed by the text
* schema), so its errors arrive either as the client-side file guard's
* message or as the server's `field: "image"` ShelfError.
*/
const imageError =
(actionData?.error?.additionalData?.field === "image"
? actionData?.error?.message
: undefined) ?? fileError;

return (
<Card className="w-full lg:w-min">
<Form ref={zo.ref} method="post" className="flex w-full flex-col gap-2">
<Form
ref={zo.ref}
method="post"
className="flex w-full flex-col gap-2"
encType="multipart/form-data"
>
{/* -- Top action bar (visible on md+) -- */}
<div className="flex items-start justify-between border-b pb-5">
<div>
Expand Down Expand Up @@ -323,6 +402,54 @@ function FullPageForm({
</div>
</FormRow>

{/* -- Image -- */}
<FormRow
rowLabel="Image"
subHeading="Uploaded once and shown on every asset of this model that has no image of its own."
className="border-b-0 pt-[10px]"
>
<div>
{assetModel?.image ? (
// `imageUrl` is required alongside `withPreview`: the preview
// trigger is keyboard-focusable and labelled "Open preview for …",
// but its handler no-ops without a full-size URL — a dead control.
<ImageWithPreview
imageUrl={assetModel.image}
thumbnailUrl={assetModel.image}
alt={`${assetModel.name} image`}
className="mb-2 size-16 rounded border object-cover"
withPreview
/>
) : null}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<p id={PAGE_IMAGE_HELP_ID} className="hidden lg:block">
Accepts PNG, JPG, JPEG, or WebP (max.8 MB)
</p>
<Input
disabled={disabled}
accept={ACCEPT_SUPPORTED_IMAGES}
name="image"
type="file"
onChange={validateFile}
label="Image"
hideLabel
/**
* The requirements text is duplicated for the two breakpoints, so
* both ids are referenced — whichever copy is display:none is
* absent from the accessibility tree, leaving exactly one
* description. `Input` spreads unknown props onto the <input> and
* adds no describedby of its own, so nothing is being overridden.
*/
aria-describedby={`${PAGE_IMAGE_HELP_ID} ${PAGE_IMAGE_HELP_ID}-sm`}
error={imageError}
className="mt-2"
inputClassName="border-0 shadow-none p-0 rounded-none"
/>
<p id={`${PAGE_IMAGE_HELP_ID}-sm`} className="mt-2 lg:hidden">
Accepts PNG, JPG, JPEG, or WebP (max.8 MB)
</p>
</div>
</FormRow>

{/* -- Bottom action bar -- */}
<FormRow className="border-y-0 pb-0 pt-5" rowLabel="">
<div className="flex flex-1 justify-end gap-2">
Expand Down
Loading
Loading