Files
upstream-plane/apps/web/core/components/project/multi-select-modal.tsx
upstream-mirror 23059a1de3
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
snapshot of https://github.com/makeplane/plane.git @ d868349a55de79979eb8b98701bf1702c67ad634 (2026-09-15)
2026-09-15 15:14:58 +05:00

199 lines
7.9 KiB
TypeScript

/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React, { useEffect, useMemo, useRef, useState } from "react";
import { xor } from "lodash-es";
import { observer } from "mobx-react";
import { useTheme } from "next-themes";
import { Combobox } from "@headlessui/react";
// plane ui
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { Logo } from "@plane/propel/emoji-icon-picker";
import { CloseOutline, SearchOutline } from "@makeplane/propel/icons";
import { Checkbox } from "@makeplane/propel/components/checkbox";
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
import { cn } from "@plane/utils";
// assets
import darkProjectAsset from "@/app/assets/empty-state/search/project-dark.webp?url";
import lightProjectAsset from "@/app/assets/empty-state/search/project-light.webp?url";
// components
import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root";
// hooks
import { useProject } from "@/hooks/store/use-project";
type Props = {
isOpen: boolean;
onClose: () => void;
selectedProjectIds: string[];
projectIds: string[];
onSubmit: (projectIds: string[]) => Promise<void>;
};
export const ProjectMultiSelectModal = observer(function ProjectMultiSelectModal(props: Props) {
const { isOpen, onClose, selectedProjectIds: selectedProjectIdsProp, projectIds, onSubmit } = props;
// states
const [searchTerm, setSearchTerm] = useState("");
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
// refs
const moveButtonRef = useRef<HTMLButtonElement>(null);
// theme hook
const { resolvedTheme } = useTheme();
// plane hooks
const { t } = useTranslation();
// store hooks
const { getProjectById } = useProject();
// derived values
const projectDetailsMap = useMemo(
() => new Map(projectIds.map((id) => [id, getProjectById(id)])),
[projectIds, getProjectById]
);
const areSelectedProjectsChanged = xor(selectedProjectIds, selectedProjectIdsProp).length > 0;
const filteredProjectIds = projectIds.filter((id) => {
const project = projectDetailsMap.get(id);
const projectQuery = `${project?.identifier} ${project?.name}`.toLowerCase();
return projectQuery.includes(searchTerm.toLowerCase());
});
const filteredProjectResolvedPath = resolvedTheme === "light" ? lightProjectAsset : darkProjectAsset;
useEffect(() => {
if (isOpen) setSelectedProjectIds(selectedProjectIdsProp);
}, [isOpen, selectedProjectIdsProp]);
const handleClose = () => {
onClose();
setTimeout(() => {
setSearchTerm("");
setSelectedProjectIds([]);
}, 300);
};
const handleSubmit = async () => {
setIsSubmitting(true);
await onSubmit(selectedProjectIds);
setIsSubmitting(false);
handleClose();
};
const handleSelectedProjectChange = (val: string[]) => {
setSelectedProjectIds(val);
setSearchTerm("");
moveButtonRef.current?.focus();
};
if (!isOpen) return null;
return (
<ModalCore isOpen={isOpen} width={EModalWidth.LG} position={EModalPosition.TOP} handleClose={handleClose}>
<Combobox as="div" multiple value={selectedProjectIds} onChange={handleSelectedProjectChange}>
<div className="flex items-center gap-2 border-b border-subtle px-4">
<SearchOutline className="size-4 flex-shrink-0 text-placeholder" aria-hidden="true" />
<Combobox.Input
className="h-12 w-full border-0 bg-transparent text-13 text-primary outline-none placeholder:text-placeholder focus:ring-0"
placeholder="Search for projects"
displayValue={() => ""}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
{selectedProjectIds.length > 0 && (
<div className="flex flex-wrap gap-2 px-4 pt-2">
{selectedProjectIds.map((projectId) => {
const projectDetails = projectDetailsMap.get(projectId);
if (!projectDetails) return null;
return (
<div
key={projectDetails.id}
className="group flex cursor-pointer items-center gap-1.5 rounded-sm bg-surface-2 px-2 py-1"
onClick={() => {
handleSelectedProjectChange(selectedProjectIds.filter((id) => id !== projectDetails.id));
}}
>
<Logo logo={projectDetails.logo_props} size={14} />
<p className="truncate text-11 text-tertiary transition-colors group-hover:text-secondary">
{projectDetails.identifier}
</p>
<CloseOutline className="size-3 flex-shrink-0 text-placeholder transition-colors group-hover:text-secondary" />
</div>
);
})}
</div>
)}
<Combobox.Options
as="ul"
static
className="vertical-scrollbar scrollbar-md max-h-80 scroll-py-2 overflow-y-auto py-2 transition-[height] duration-200 ease-in-out"
>
{filteredProjectIds.length === 0 ? (
<div className="flex flex-col items-center justify-center px-3 py-8 text-center">
<SimpleEmptyState
title={t("workspace_projects.empty_state.filter.title")}
description={t("workspace_projects.empty_state.filter.description")}
assetPath={filteredProjectResolvedPath}
/>
</div>
) : (
<ul
className={cn("text-primary", {
"px-2": filteredProjectIds.length > 0,
})}
>
{filteredProjectIds.map((projectId) => {
const projectDetails = projectDetailsMap.get(projectId);
if (!projectDetails) return null;
const isProjectSelected = selectedProjectIds.includes(projectDetails.id);
return (
<Combobox.Option
as="li"
key={projectDetails.id}
value={projectDetails.id}
className={({ active }) =>
cn(
"flex w-full cursor-pointer items-center justify-between gap-2 truncate rounded-md p-2 text-secondary transition-colors select-none",
{
"bg-layer-1": active,
"text-primary": isProjectSelected,
}
)
}
>
<div className="flex items-center gap-2 truncate">
<span className="flex flex-shrink-0 items-center gap-2.5">
<span className="pointer-events-none">
<Checkbox checked={isProjectSelected} aria-label={projectDetails.name} />
</span>
<Logo logo={projectDetails.logo_props} size={16} />
</span>
<span className="flex-shrink-0 text-10">{projectDetails.identifier}</span>
<p className="truncate text-13">{projectDetails.name}</p>
</div>
</Combobox.Option>
);
})}
</ul>
)}
</Combobox.Options>
</Combobox>
<div className="flex items-center justify-end gap-2 border-t border-subtle p-3">
<Button variant="secondary" size="lg" onClick={handleClose}>
{t("cancel")}
</Button>
<Button
ref={moveButtonRef}
variant="primary"
size="lg"
onClick={handleSubmit}
loading={isSubmitting}
disabled={!areSelectedProjectsChanged}
>
{isSubmitting ? t("confirming") : t("confirm")}
</Button>
</div>
</ModalCore>
);
});