Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 2 additions & 0 deletions apps/webapp/app/atoms/bulk-update-dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type { BulkDialogType } from "~/components/bulk-update-dialog/bulk-update
const DEFAULT_STATE: Record<BulkDialogType, boolean> = {
location: false,
category: false,
"asset-model": false,
"asset-model-remove": false,
"assign-custody": false,
"release-custody": false,
"tag-add": false,
Expand Down
20 changes: 20 additions & 0 deletions apps/webapp/app/components/assets/bulk-actions-dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { userHasPermission } from "~/utils/permissions/permission.validator.clie
import { tw } from "~/utils/tw";
import BulkAddToAuditDialog from "./bulk-add-to-audit-dialog";
import BulkAddToKitDialog from "./bulk-add-to-kit-dialog";
import BulkAssetModelRemoveDialog from "./bulk-asset-model-remove-dialog";
import BulkAssetModelUpdateDialog from "./bulk-asset-model-update-dialog";
import BulkAssignCustodyDialog from "./bulk-assign-custody-dialog";
import BulkAssignTagsDialog from "./bulk-assign-tags-dialog";
import BulkCategoryUpdateDialog from "./bulk-category-update-dialog";
Expand Down Expand Up @@ -156,6 +158,8 @@ function ConditionalDropdown() {
<BulkAssignTagsDialog />
<BulkRemoveTagsDialog />
<BulkCategoryUpdateDialog />
<BulkAssetModelUpdateDialog />
<BulkAssetModelRemoveDialog />
<BulkDeleteDialog />
<BulkMarkAvailabilityDialog type="available" />
<BulkMarkAvailabilityDialog type="unavailable" />
Expand Down Expand Up @@ -365,6 +369,22 @@ function ConditionalDropdown() {
disabled={isLoading}
/>
</DropdownMenuItem>
<DropdownMenuItem className="py-1 lg:p-0">
<BulkUpdateDialogTrigger
type="asset-model"
label="Update asset model"
onClick={closeMenu}
disabled={isLoading}
/>
</DropdownMenuItem>
<DropdownMenuItem className="py-1 lg:p-0">
<BulkUpdateDialogTrigger
type="asset-model-remove"
label="Remove from asset model"
onClick={closeMenu}
disabled={isLoading}
/>
</DropdownMenuItem>
<DropdownMenuItem className="border-t py-1 lg:p-0">
<BulkUpdateDialogTrigger
label="Add to kit"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* Bulk Asset Model Remove Dialog
*
* Takes the selected assets out of whatever asset model they belong to. Its
* own menu item and dialog rather than an option inside the assign picker, so
* that picker lists nothing but real asset models, and so the destructive
* direction has to be chosen deliberately. Same shape as the Remove tags and
* Remove from kit pair.
*
* Posts to the same endpoint as the assign dialog with an empty
* `assetModelId`, which the service reads as "unlink".
*
* @see {@link file://./bulk-asset-model-update-dialog.tsx} the assign half
* @see {@link file://./../../routes/api+/assets.bulk-update-asset-model.ts} action
*/
import { useAtomValue } from "jotai";
import { useLoaderData } from "react-router";
import { useZorm } from "react-zorm";
import { z } from "zod";
import { selectedBulkItemsAtom } from "~/atoms/list";
import type { AssetIndexLoaderData } from "~/routes/_layout+/assets._index";
import { isSelectingAllItems } from "~/utils/list";
import { BulkUpdateDialogContent } from "../bulk-update-dialog/bulk-update-dialog";
import { Button } from "../shared/button";

const BulkAssetModelRemoveSchema = z.object({
assetIds: z.array(z.string()).min(1),
});

export default function BulkAssetModelRemoveDialog() {
const { totalItems } = useLoaderData<AssetIndexLoaderData>();
const zo = useZorm("BulkAssetModelRemove", BulkAssetModelRemoveSchema);

const selectedItems = useAtomValue(selectedBulkItemsAtom);

/** Same reason as the assign dialog: the shared count atom would report the page size. */
const totalSelected = isSelectingAllItems(selectedItems)
? totalItems
: selectedItems.length;

return (
<BulkUpdateDialogContent
ref={zo.ref}
type="asset-model-remove"
arrayFieldId="assetIds"
/**
* The endpoint is named after the assign action, so it has to be passed
* explicitly here — the shared dialog otherwise derives
* `/api/assets/bulk-update-asset-model-remove`, which does not exist.
*/
actionUrl="/api/assets/bulk-update-asset-model"
title={`Remove (${totalSelected}) assets from their asset model`}
description="The assets keep everything else. Only the link to their asset model is removed."
>
{({ disabled, handleCloseDialog, fetcherError }) => (
<div>
{/* Empty is what the service reads as "unlink". */}
<input type="hidden" name="assetModelId" value="" />

{fetcherError ? (
<p role="alert" className="mb-4 text-sm text-error-500">
{fetcherError}
</p>
) : null}

<div className="flex gap-3">
<Button
type="button"
variant="secondary"
width="full"
disabled={disabled}
onClick={handleCloseDialog}
>
Cancel
</Button>
<Button
type="submit"
variant="primary"
width="full"
disabled={disabled}
>
Confirm
</Button>
</div>
</div>
)}
</BulkUpdateDialogContent>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Schema contract for the bulk asset model dialogs.
*
* The assign and remove dialogs post to the SAME endpoint and are told apart
* only by whether `assetModelId` is empty. A regression during development made
* the endpoint reject the empty value, which silently broke removal while every
* other gate stayed green, so the split is pinned here.
*
* @see {@link file://./bulk-asset-model-update-dialog.tsx}
* @see {@link file://./bulk-asset-model-remove-dialog.tsx}
* @see {@link file://./../../routes/api+/assets.bulk-update-asset-model.ts}
*/
import { describe, expect, it } from "vitest";
import {
BulkAssetModelActionSchema,
BulkAssetModelUpdateSchema,
} from "./bulk-asset-model-update-dialog";

describe("bulk asset model schemas", () => {
it("accepts an empty assetModelId on the wire, because that is how removal is requested", () => {
const result = BulkAssetModelActionSchema.safeParse({
assetIds: ["asset-1"],
assetModelId: "",
});

expect(result.success).toBe(true);
});

it("rejects an empty assetModelId in the assign form, so it cannot silently ungroup", () => {
const result = BulkAssetModelUpdateSchema.safeParse({
assetIds: ["asset-1"],
assetModelId: "",
});

expect(result.success).toBe(false);
expect(result.success ? null : result.error.issues[0]?.message).toBe(
"Please select an asset model"
);
});

it("accepts a model id in both", () => {
const payload = { assetIds: ["asset-1"], assetModelId: "model-1" };

expect(BulkAssetModelActionSchema.safeParse(payload).success).toBe(true);
expect(BulkAssetModelUpdateSchema.safeParse(payload).success).toBe(true);
});

it("requires at least one asset in both, so a stray POST cannot target everything", () => {
const payload = { assetIds: [], assetModelId: "model-1" };

expect(BulkAssetModelActionSchema.safeParse(payload).success).toBe(false);
expect(BulkAssetModelUpdateSchema.safeParse(payload).success).toBe(false);
});
});
187 changes: 187 additions & 0 deletions apps/webapp/app/components/assets/bulk-asset-model-update-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
/**
* Bulk Asset Model Update Dialog
*
* Lets a user group many existing assets into one AssetModel from the asset
* index "Actions" menu, instead of opening each asset's edit form or
* re-importing a CSV.
*
* Assign only. Ungrouping is its own menu item and dialog
* ({@link file://./bulk-asset-model-remove-dialog.tsx}) so that this picker
* contains nothing but real asset models: an in-list "remove" row reads as a
* model called "Remove from asset model" to anyone who has not made one yet.
* That also matches how the menu already treats tags and kits, which each have
* a separate Remove item.
*
* Reads the selection from `selectedBulkItemsAtom` and posts to
* `/api/assets/bulk-update-asset-model` via {@link BulkUpdateDialogContent},
* which also forwards the active index filters so a cross-page "select all"
* resolves server-side.
*
* @see {@link file://./../../routes/api+/assets.bulk-update-asset-model.ts} action
* @see {@link file://./../../modules/asset/service.server.ts} `bulkUpdateAssetModel`
* @see {@link file://./asset-model-form-row.tsx} the single-asset equivalent
*/
import { useAtomValue } from "jotai";
import { useLoaderData } from "react-router";
import { useZorm } from "react-zorm";
import { z } from "zod";
import { selectedBulkItemsAtom } from "~/atoms/list";
import { isQuantityTracked } from "~/modules/asset/utils";
import type { AssetIndexLoaderData } from "~/routes/_layout+/assets._index";
import { isSelectingAllItems } from "~/utils/list";
import { BulkUpdateDialogContent } from "../bulk-update-dialog/bulk-update-dialog";
import DynamicSelect from "../dynamic-select/dynamic-select";
import InlineEntityCreationDialog from "../inline-entity-creation-dialog/inline-entity-creation-dialog";
import { Button } from "../shared/button";
import { WarningBox } from "../shared/warning-box";

/**
* Wire format for `/api/assets/bulk-update-asset-model`, which serves BOTH the
* assign dialog and {@link file://./bulk-asset-model-remove-dialog.tsx}.
*
* `assetModelId` is deliberately not `.min(1)`: an empty value is how the
* remove dialog asks for an unlink. Keep it that way, or removal breaks.
*
* It lives here rather than in the route because a route file may only export
* `loader`/`action` (see .claude/rules/no-server-module-in-route-client-exports),
* and this is the same place the sibling bulk dialogs keep their schemas.
*/
export const BulkAssetModelActionSchema = z.object({
assetIds: z.array(z.string()).min(1),
assetModelId: z.string(),
});

/**
* What the assign form validates client-side. A model is required here: an
* empty submit from THIS dialog would silently ungroup, which is the other
* dialog's job, so it has to fail with a message instead.
*/
export const BulkAssetModelUpdateSchema = BulkAssetModelActionSchema.extend({
assetModelId: z.string().min(1, "Please select an asset model"),
});

export default function BulkAssetModelUpdateDialog() {
const { totalItems } = useLoaderData<AssetIndexLoaderData>();
const zo = useZorm("BulkAssetModelUpdate", BulkAssetModelUpdateSchema);

const selectedItems = useAtomValue(selectedBulkItemsAtom);
const isSelectingAll = isSelectingAllItems(selectedItems);

/**
* The shared count atom counts array entries, and a cross-page "select all"
* is stored as a single sentinel entry — so it would report the page size
* while the action changed every filtered asset. Use the loader's total in
* that case, the same way the bulk delete dialog does.
*/
const totalSelected = isSelectingAll ? totalItems : selectedItems.length;

/**
* Asset models describe N distinguishable units of one template, so they
* only apply to individually tracked assets, and `bulkUpdateAssetModel`
* skips quantity-tracked ones. Warn before the user commits rather than
* after.
*
* The gate is the current page's contents in both modes. Under select-all
* the client holds only the current page plus a sentinel, so the copy drops
* the number rather than stating one that understates the batch. Gating on
* the page (instead of "always warn when selecting all") keeps the warning
* out of workspaces that track nothing by quantity, where it would be noise
* on every sweep. If a later page turns out to hold quantity-tracked assets,
* the result toast still reports exactly how many were skipped.
*/
const quantityTrackedCount = selectedItems.filter((item) =>
isQuantityTracked(item)
).length;

return (
<BulkUpdateDialogContent
ref={zo.ref}
type="asset-model"
arrayFieldId="assetIds"
title={`Group (${totalSelected}) assets into an asset model`}
description="Pick the model these assets belong to. Their category, value and custody are not changed."
>
{({ disabled, handleCloseDialog, fetcherError }) => (
<div>
{quantityTrackedCount > 0 ? (
<div className="mb-4">
<WarningBox>
<span>
{isSelectingAll
? "Any quantity-tracked assets in your selection will be skipped."
: `${quantityTrackedCount} quantity-tracked asset(s) in your selection will be skipped.`}{" "}
Asset models can only be linked to individually tracked
assets.
</span>
</WarningBox>
</div>
) : null}

<div className="relative z-50 mb-8">
<DynamicSelect
disabled={disabled}
/**
* Handed to the picker rather than rendered here, so the message
* is announced and the trigger carries `aria-describedby` — a
* picker that silently refuses to submit tells a screen-reader
* user nothing.
*/
error={zo.errors.assetModelId()?.message}
model={{ name: "assetModel", queryKey: "name" }}
placeholder="Select asset model"
initialDataKey="assetModels"
countKey="totalAssetModels"
fieldName="assetModelId"
contentLabel="Asset models"
closeOnSelect
extraContent={({ onItemCreated, closePopover }) => (
<InlineEntityCreationDialog
type="assetModel"
title="Create new asset model"
buttonLabel="Create new asset model"
onCreated={(created) => {
if (created?.type !== "assetModel") return;
const assetModel = created.entity;
onItemCreated({
id: assetModel.id,
name: assetModel.name,
metadata: { ...assetModel },
});
closePopover();
}}
/>
)}
/>
{/* Form-level server error, not a field error — announced, but
deliberately not folded into the picker's aria-describedby. */}
{fetcherError ? (
<p role="alert" className="text-sm text-error-500">
{fetcherError}
</p>
) : null}
</div>

<div className="flex gap-3">
<Button
type="button"
variant="secondary"
width="full"
disabled={disabled}
onClick={handleCloseDialog}
>
Cancel
</Button>
<Button
type="submit"
variant="primary"
width="full"
disabled={disabled}
>
Confirm
</Button>
</div>
</div>
)}
</BulkUpdateDialogContent>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import {
type BulkDialogType =
| "location"
| "category"
| "asset-model"
| "asset-model-remove"
| "assign-custody"
| "release-custody"
| "trash"
Expand Down
Loading
Loading