Skip to content

Commit

Permalink
Merge pull request #1316 from Shelf-nu/1208-feature-request-bulk-tagging
Browse files Browse the repository at this point in the history
feat: bulk add and remove tags from assets
  • Loading branch information
DonKoko authored Sep 19, 2024
2 parents 0450467 + fac6019 commit 05950dc
Show file tree
Hide file tree
Showing 13 changed files with 386 additions and 15 deletions.
2 changes: 2 additions & 0 deletions app/atoms/bulk-update-dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export const bulkDialogAtom = atom<Record<BulkDialogType, boolean>>({
category: false,
"assign-custody": false,
"release-custody": false,
"tag-add": false,
"tag-remove": false,
trash: false,
activate: false,
deactivate: false,
Expand Down
21 changes: 21 additions & 0 deletions app/components/assets/bulk-actions-dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -93,6 +95,8 @@ function ConditionalDropdown() {
/>
)}
<BulkLocationUpdateDialog />
<BulkAssignTagsDialog />
<BulkRemoveTagsDialog />
<BulkCategoryUpdateDialog />
<BulkAssignCustodyDialog />
<BulkReleaseCustodyDialog />
Expand Down Expand Up @@ -184,6 +188,23 @@ function ConditionalDropdown() {
}
/>
</DropdownMenuItem>
<DropdownMenuItem className="py-1 lg:p-0">
<BulkUpdateDialogTrigger
type="tag-add"
onClick={closeMenu}
disabled={isLoading}
label="Assign tags"
/>
</DropdownMenuItem>

<DropdownMenuItem className="py-1 lg:p-0">
<BulkUpdateDialogTrigger
type="tag-remove"
onClick={closeMenu}
disabled={isLoading}
label="Remove tags"
/>
</DropdownMenuItem>

<DropdownMenuItem className="py-1 lg:p-0">
<BulkUpdateDialogTrigger
Expand Down
80 changes: 80 additions & 0 deletions app/components/assets/bulk-assign-tags-dialog.tsx
Original file line number Diff line number Diff line change
@@ -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 BulkAssignTagsSchema = z.object({
assetIds: z.array(z.string()).min(1),
tags: z.string(),
});

export default function BulkAssignTagsDialog() {
const zo = useZorm("BulkAssignTags", BulkAssignTagsSchema);

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 (
<BulkUpdateDialogContent
ref={zo.ref}
type="tag-add"
title="Assign tags to assets"
description="Assign tags to selected assets. Assets that already have any of the selected tags, will be skipped."
actionUrl="/api/assets/bulk-assign-tags"
arrayFieldId="assetIds"
>
{({ disabled, handleCloseDialog, fetcherError }) => (
<div className="modal-content-wrapper">
<div className="relative z-50 mb-8">
<TagsAutocomplete existingTags={[]} suggestions={suggestions} />

{zo.errors.tags()?.message ? (
<p className="text-sm text-error-500">
{zo.errors.tags()?.message}
</p>
) : null}
{fetcherError ? (
<p className="text-sm text-error-500">{fetcherError}</p>
) : null}
</div>

<div className="flex gap-3">
<Button
variant="secondary"
width="full"
disabled={disabled}
onClick={handleCloseDialog}
>
Cancel
</Button>
<Button variant="primary" width="full" disabled={disabled}>
Confirm
</Button>
</div>
</div>
)}
</BulkUpdateDialogContent>
);
}
80 changes: 80 additions & 0 deletions app/components/assets/bulk-remove-tags-dialog.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<BulkUpdateDialogContent
ref={zo.ref}
type="tag-remove"
title="Remove tags from assets"
description="Remove tags to selected assets. Assets that don't have any of the selected tags, will be skipped."
actionUrl="/api/assets/bulk-assign-tags?remove=true"
arrayFieldId="assetIds"
>
{({ disabled, handleCloseDialog, fetcherError }) => (
<div className="modal-content-wrapper">
<div className="relative z-50 mb-8">
<TagsAutocomplete existingTags={[]} suggestions={suggestions} />

{zo.errors.tags()?.message ? (
<p className="text-sm text-error-500">
{zo.errors.tags()?.message}
</p>
) : null}
{fetcherError ? (
<p className="text-sm text-error-500">{fetcherError}</p>
) : null}
</div>

<div className="flex gap-3">
<Button
variant="secondary"
width="full"
disabled={disabled}
onClick={handleCloseDialog}
>
Cancel
</Button>
<Button variant="primary" width="full" disabled={disabled}>
Confirm
</Button>
</div>
</div>
)}
</BulkUpdateDialogContent>
);
}
11 changes: 10 additions & 1 deletion app/components/assets/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,12 @@ export const AssetForm = ({
};
}>();

/** Get the tags from the loader */
const tagsSuggestions = useLoaderData<typeof loader>().tags.map((tag) => ({
label: tag.name,
value: tag.id,
}));

return (
<Card className="w-full lg:w-min">
<Form
Expand Down Expand Up @@ -286,7 +292,10 @@ export const AssetForm = ({
required={zodFieldIsRequired(FormSchema.shape.tags)}
>
<InnerLabel hideLg={true}>Tags</InnerLabel>
<TagsAutocomplete existingTags={tags ?? []} />
<TagsAutocomplete
existingTags={tags ?? []}
suggestions={tagsSuggestions}
/>
</FormRow>

<FormRow
Expand Down
4 changes: 3 additions & 1 deletion app/components/bulk-update-dialog/bulk-update-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ type BulkDialogType =
| "activate"
| "deactivate"
| "archive"
| "tag-add"
| "tag-remove"
| "cancel";

type CommonBulkDialogProps = {
Expand Down Expand Up @@ -234,7 +236,7 @@ const BulkUpdateDialogContent = forwardRef<
<Dialog
open={isDialogOpen}
onClose={handleCloseDialog}
className="lg:w-[400px]"
className="bulk-tagging-dialog lg:w-[400px]"
title={
<div className="w-full">
{type !== "cancel" ? (
Expand Down
52 changes: 52 additions & 0 deletions app/components/icons/library.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -867,6 +867,58 @@ export const TagsIcon = (props: SVGProps<SVGSVGElement>) => (
</svg>
);

export const AddTagsIcon = (props: SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={"100%"}
height={"100%"}
viewBox="0 0 24 24"
fill="none"
{...props}
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="m21 10.923-7.594-7.53c-.519-.514-.778-.77-1.081-.955a3.015 3.015 0 0 0-.867-.356C11.112 2 10.745 2 10.012 2H6M3 8.643v1.958c0 .485 0 .727.055.955.05.203.13.396.24.574.123.2.296.371.642.714l7.8 7.734c.792.785 1.188 1.178 1.645 1.325.402.13.834.13 1.236 0 .457-.147.853-.54 1.645-1.325l2.474-2.454c.792-.785 1.188-1.177 1.337-1.63.13-.399.13-.828 0-1.226-.149-.453-.545-.845-1.337-1.63l-7.3-7.238c-.346-.343-.519-.515-.72-.638a2.011 2.011 0 0 0-.579-.237c-.23-.055-.474-.055-.963-.055H6.2c-1.12 0-1.68 0-2.108.216-.376.19-.682.494-.874.867C3 6.977 3 7.533 3 8.643Z"
/>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.3}
d="M22.714 3.357H18M20.357 1v4.714"
/>
</svg>
);

export const RemoveTagsIcon = (props: SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={"100%"}
height={"100%"}
viewBox="0 0 24 24"
fill="none"
{...props}
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="m20 11-7.594-7.594c-.519-.519-.778-.778-1.081-.964a3.001 3.001 0 0 0-.867-.36C10.112 2 9.746 2 9.012 2H5M2 8.7v1.975c0 .489 0 .733.055.963.05.204.13.4.24.579.123.201.296.374.642.72l7.8 7.8c.792.792 1.188 1.188 1.645 1.337a2 2 0 0 0 1.236 0c.457-.149.853-.545 1.645-1.337l2.474-2.474c.792-.792 1.188-1.188 1.337-1.645a2 2 0 0 0 0-1.236c-.149-.457-.545-.853-1.337-1.645l-7.3-7.3c-.346-.346-.519-.519-.72-.642a2 2 0 0 0-.579-.24c-.23-.055-.474-.055-.963-.055H5.2c-1.12 0-1.68 0-2.108.218a2 2 0 0 0-.874.874C2 7.02 2 7.58 2 8.7Z"
/>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.3}
d="M22.024 5.024 18.69 1.69m3.334 0L18.69 5.024"
/>
</svg>
);

export const LocationMarkerIcon = (props: SVGProps<SVGSVGElement>) => (
<svg
width="18"
Expand Down
2 changes: 1 addition & 1 deletion app/components/layout/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export const Dialog = ({
<XIcon />
</Button>
</div>
<div className="grow overflow-auto">{children}</div>
<div className="dialog-body grow overflow-auto">{children}</div>
</div>
</dialog>
</div>
Expand Down
6 changes: 6 additions & 0 deletions app/components/shared/icons-map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ import {
ScanIcon,
MapIcon,
ToolIcon,
AddTagsIcon,
RemoveTagsIcon,
} from "../icons/library";

/** The possible options for icons to be rendered in the button */
Expand All @@ -68,6 +70,8 @@ export type IconType =
| "question"
| "write"
| "tag"
| "tag-remove"
| "tag-add"
| "category"
| "location"
| "gps"
Expand Down Expand Up @@ -122,6 +126,8 @@ export const iconsMap: IconsMap = {
question: <QuestionsIcon />,
write: <WriteIcon />,
tag: <TagsIcon />,
"tag-add": <AddTagsIcon />,
"tag-remove": <RemoveTagsIcon />,
category: <CategoriesIcon />,
location: <LocationMarkerIcon />,
gps: <GpsMarkerIcon />,
Expand Down
20 changes: 8 additions & 12 deletions app/components/tag/tags-autocomplete.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof loader>().tags.map((tag) => ({
label: tag.name,
value: tag.id,
}));

const [selected, setSelected] = useState<Tag[]>([]);

useEffect(() => {
Expand Down
Loading

0 comments on commit 05950dc

Please sign in to comment.