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
199 lines
7.9 KiB
TypeScript
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>
|
|
);
|
|
});
|