Skip to content
Merged
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
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