diff --git a/app/atoms/bulk-update-dialog.ts b/app/atoms/bulk-update-dialog.ts index ad560f2df..eb38df1d7 100644 --- a/app/atoms/bulk-update-dialog.ts +++ b/app/atoms/bulk-update-dialog.ts @@ -10,6 +10,8 @@ export const bulkDialogAtom = atom>({ category: false, "assign-custody": false, "release-custody": false, + "tag-add": false, + "tag-remove": false, trash: false, activate: false, deactivate: false, diff --git a/app/components/assets/bulk-actions-dropdown.tsx b/app/components/assets/bulk-actions-dropdown.tsx index d3660c340..1ad67217b 100644 --- a/app/components/assets/bulk-actions-dropdown.tsx +++ b/app/components/assets/bulk-actions-dropdown.tsx @@ -7,10 +7,12 @@ import { isFormProcessing } from "~/utils/form"; import { isSelectingAllItems } from "~/utils/list"; import { tw } from "~/utils/tw"; import BulkAssignCustodyDialog from "./bulk-assign-custody-dialog"; +import BulkAssignTagsDialog from "./bulk-assign-tags-dialog"; import BulkCategoryUpdateDialog from "./bulk-category-update-dialog"; import BulkDeleteDialog from "./bulk-delete-dialog"; import BulkLocationUpdateDialog from "./bulk-location-update-dialog"; import BulkReleaseCustodyDialog from "./bulk-release-custody-dialog"; +import BulkRemoveTagsDialog from "./bulk-remove-tags-dialog"; import { BulkUpdateDialogTrigger } from "../bulk-update-dialog/bulk-update-dialog"; import { ChevronRight } from "../icons/library"; import { Button } from "../shared/button"; @@ -93,6 +95,8 @@ function ConditionalDropdown() { /> )} + + @@ -184,6 +188,23 @@ function ConditionalDropdown() { } /> + + + + + + + ({ + label: tagResponse.name, + value: tagResponse.id, + })) as TagSuggestion[]; + + useEffect(() => { + fetcher.submit( + { + name: "tag", + queryKey: "name", + queryValue: "", + }, + { + method: "GET", + action: "/api/model-filters", + } + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + {({ disabled, handleCloseDialog, fetcherError }) => ( +
+
+ + + {zo.errors.tags()?.message ? ( +

+ {zo.errors.tags()?.message} +

+ ) : null} + {fetcherError ? ( +

{fetcherError}

+ ) : null} +
+ +
+ + +
+
+ )} +
+ ); +} diff --git a/app/components/assets/bulk-remove-tags-dialog.tsx b/app/components/assets/bulk-remove-tags-dialog.tsx new file mode 100644 index 000000000..d80596b50 --- /dev/null +++ b/app/components/assets/bulk-remove-tags-dialog.tsx @@ -0,0 +1,80 @@ +import { useEffect } from "react"; +import { useFetcher } from "@remix-run/react"; +import { useZorm } from "react-zorm"; +import { z } from "zod"; +import { BulkUpdateDialogContent } from "../bulk-update-dialog/bulk-update-dialog"; +import { Button } from "../shared/button"; +import { TagsAutocomplete, type TagSuggestion } from "../tag/tags-autocomplete"; + +export const BulkRemoveTagsSchema = z.object({ + assetIds: z.array(z.string()).min(1), + tags: z.string(), +}); + +export default function BulkRemoveTagsDialog() { + const zo = useZorm("BulkRemoveTags", BulkRemoveTagsSchema); + + const fetcher = useFetcher(); + // @ts-ignore + const suggestions = fetcher.data?.filters.map((tagResponse) => ({ + label: tagResponse.name, + value: tagResponse.id, + })) as TagSuggestion[]; + + useEffect(() => { + fetcher.submit( + { + name: "tag", + queryKey: "name", + queryValue: "", + }, + { + method: "GET", + action: "/api/model-filters", + } + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + {({ disabled, handleCloseDialog, fetcherError }) => ( +
+
+ + + {zo.errors.tags()?.message ? ( +

+ {zo.errors.tags()?.message} +

+ ) : null} + {fetcherError ? ( +

{fetcherError}

+ ) : null} +
+ +
+ + +
+
+ )} +
+ ); +} diff --git a/app/components/assets/form.tsx b/app/components/assets/form.tsx index 540105497..56fde4c9d 100644 --- a/app/components/assets/form.tsx +++ b/app/components/assets/form.tsx @@ -124,6 +124,12 @@ export const AssetForm = ({ }; }>(); + /** Get the tags from the loader */ + const tagsSuggestions = useLoaderData().tags.map((tag) => ({ + label: tag.name, + value: tag.id, + })); + return (
Tags - + {type !== "cancel" ? ( diff --git a/app/components/icons/library.tsx b/app/components/icons/library.tsx index a0c32cc34..da405e60b 100644 --- a/app/components/icons/library.tsx +++ b/app/components/icons/library.tsx @@ -867,6 +867,58 @@ export const TagsIcon = (props: SVGProps) => ( ); +export const AddTagsIcon = (props: SVGProps) => ( + + + + +); + +export const RemoveTagsIcon = (props: SVGProps) => ( + + + + +); + export const LocationMarkerIcon = (props: SVGProps) => ( -
{children}
+
{children}
diff --git a/app/components/shared/icons-map.tsx b/app/components/shared/icons-map.tsx index 032da3190..688256d0c 100644 --- a/app/components/shared/icons-map.tsx +++ b/app/components/shared/icons-map.tsx @@ -45,6 +45,8 @@ import { ScanIcon, MapIcon, ToolIcon, + AddTagsIcon, + RemoveTagsIcon, } from "../icons/library"; /** The possible options for icons to be rendered in the button */ @@ -68,6 +70,8 @@ export type IconType = | "question" | "write" | "tag" + | "tag-remove" + | "tag-add" | "category" | "location" | "gps" @@ -122,6 +126,8 @@ export const iconsMap: IconsMap = { question: , write: , tag: , + "tag-add": , + "tag-remove": , category: , location: , gps: , diff --git a/app/components/tag/tags-autocomplete.tsx b/app/components/tag/tags-autocomplete.tsx index 30878c8b5..257030de3 100644 --- a/app/components/tag/tags-autocomplete.tsx +++ b/app/components/tag/tags-autocomplete.tsx @@ -1,27 +1,23 @@ import React, { useCallback, useEffect, useState } from "react"; -import { useLoaderData } from "@remix-run/react"; import type { Tag } from "react-tag-autocomplete"; import { ReactTags } from "react-tag-autocomplete"; -import type { loader } from "~/routes/_layout+/assets.$assetId_.edit"; -export interface Suggestion { +export interface TagSuggestion { label: string; value: string; } -export const TagsAutocomplete = ({ existingTags }: { existingTags: Tag[] }) => { +export const TagsAutocomplete = ({ + existingTags, + suggestions, +}: { + existingTags: Tag[]; + suggestions: TagSuggestion[]; +}) => { /* This is a workaround for the SSR issue with react-tag-autocomplete */ if (typeof document === "undefined") { React.useLayoutEffect = React.useEffect; } - - /** Get the tags from the loader */ - - const suggestions = useLoaderData().tags.map((tag) => ({ - label: tag.name, - value: tag.id, - })); - const [selected, setSelected] = useState([]); useEffect(() => { diff --git a/app/modules/asset/service.server.ts b/app/modules/asset/service.server.ts index da7b22b0f..016fdccad 100644 --- a/app/modules/asset/service.server.ts +++ b/app/modules/asset/service.server.ts @@ -2651,3 +2651,54 @@ export async function bulkUpdateAssetCategory({ }); } } + +export async function bulkAssignAssetTags({ + userId, + assetIds, + organizationId, + tagsIds, + currentSearchParams, + remove, +}: { + userId: string; + assetIds: Asset["id"][]; + organizationId: Asset["organizationId"]; + tagsIds: string[]; + currentSearchParams?: string | null; + remove: boolean; +}) { + try { + const shouldUpdateAll = assetIds.includes(ALL_SELECTED_KEY); + let _assetIds = assetIds; + + if (shouldUpdateAll) { + const allOrgAssetIds = await db.asset.findMany({ + where: getAssetsWhereInput({ organizationId, currentSearchParams }), + select: { id: true }, + }); + _assetIds = allOrgAssetIds.map((a) => a.id); + } + + const updatePromises = _assetIds.map((id) => + db.asset.update({ + where: { id }, + data: { + tags: { + [remove ? "disconnect" : "connect"]: tagsIds.map((id) => ({ id })), // IDs of tags you want to connect + }, + }, + }) + ); + + await Promise.all(updatePromises); + + return true; + } catch (cause) { + throw new ShelfError({ + cause, + message: "Something went wrong while bulk updating category.", + additionalData: { userId, assetIds, organizationId, tagsIds }, + label, + }); + } +} diff --git a/app/routes/api+/assets.bulk-assign-tags.ts b/app/routes/api+/assets.bulk-assign-tags.ts new file mode 100644 index 000000000..1298cd1ac --- /dev/null +++ b/app/routes/api+/assets.bulk-assign-tags.ts @@ -0,0 +1,64 @@ +import { json, type ActionFunctionArgs } from "@remix-run/node"; +import { BulkAssignTagsSchema } from "~/components/assets/bulk-assign-tags-dialog"; +import { bulkAssignAssetTags } from "~/modules/asset/service.server"; +import { CurrentSearchParamsSchema } from "~/modules/asset/utils.server"; +import { sendNotification } from "~/utils/emitter/send-notification.server"; +import { makeShelfError } from "~/utils/error"; +import { + assertIsPost, + data, + error, + getCurrentSearchParams, + parseData, +} from "~/utils/http.server"; +import { + PermissionAction, + PermissionEntity, +} from "~/utils/permissions/permission.data"; +import { requirePermission } from "~/utils/roles.server"; + +export async function action({ context, request }: ActionFunctionArgs) { + const authSession = context.getSession(); + const userId = authSession.userId; + + try { + assertIsPost(request); + const searchParams = getCurrentSearchParams(request); + const remove = searchParams.get("remove") === "true"; + + const formData = await request.formData(); + + const { organizationId } = await requirePermission({ + userId, + request, + entity: PermissionEntity.asset, + action: PermissionAction.update, + }); + + const { assetIds, tags, currentSearchParams } = parseData( + formData, + BulkAssignTagsSchema.and(CurrentSearchParamsSchema) + ); + + await bulkAssignAssetTags({ + userId, + assetIds, + tagsIds: tags?.split(","), + organizationId, + currentSearchParams, + remove, + }); + + sendNotification({ + title: "Assets updated", + message: "Your asset's categories have been successfully updated", + icon: { name: "success", variant: "success" }, + senderId: authSession.userId, + }); + + return json(data({ success: true })); + } catch (cause) { + const reason = makeShelfError(cause, { userId }); + return json(error(reason), { status: reason.status }); + } +} diff --git a/app/styles/global.css b/app/styles/global.css index 9ac41d02d..2f88b69d7 100644 --- a/app/styles/global.css +++ b/app/styles/global.css @@ -316,3 +316,11 @@ dialog { clip-path: inset(0 -1ch 0 0); } } + +.bulk-tagging-dialog .react-tags__listbox { + z-index: 1000; +} + +.bulk-tagging-dialog .dialog-body { + @apply overflow-visible; +}