snapshot of https://github.com/makeplane/plane.git @ d868349a55de79979eb8b98701bf1702c67ad634 (2026-09-15)
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

This commit is contained in:
upstream-mirror
2026-09-15 15:14:58 +05:00
commit 23059a1de3
5252 changed files with 573834 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// constants
import { NETWORK_CHOICES } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { CloseOutline } from "@makeplane/propel/icons";
type Props = {
handleRemove: (val: string) => void;
values: string[];
editable: boolean | undefined;
};
export const AppliedAccessFilters = observer(function AppliedAccessFilters(props: Props) {
const { handleRemove, values, editable } = props;
const { t } = useTranslation();
return (
<>
{values.map((status) => {
const accessDetails = NETWORK_CHOICES.find((s) => `${s.key}` === status);
return (
<div key={status} className="flex items-center gap-1 rounded-sm bg-layer-1 px-1.5 py-1 text-11">
{accessDetails && t(accessDetails?.i18n_label)}
{editable && (
<button
type="button"
className="grid place-items-center text-tertiary hover:text-secondary"
onClick={() => handleRemove(status)}
>
<CloseOutline height={10} width={10} />
</button>
)}
</div>
);
})}
</>
);
});

View File

@@ -0,0 +1,60 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { PROJECT_CREATED_AT_FILTER_OPTIONS } from "@plane/constants";
import { CloseOutline } from "@makeplane/propel/icons";
// helpers
import { renderFormattedDate, capitalizeFirstLetter } from "@plane/utils";
// constants
type Props = {
editable: boolean | undefined;
handleRemove: (val: string) => void;
values: string[];
};
export const AppliedDateFilters = observer(function AppliedDateFilters(props: Props) {
const { editable, handleRemove, values } = props;
const getDateLabel = (value: string): string => {
let dateLabel = "";
const dateDetails = PROJECT_CREATED_AT_FILTER_OPTIONS.find((d) => d.value === value);
if (dateDetails) dateLabel = dateDetails.name;
else {
const dateParts = value.split(";");
if (dateParts.length === 2) {
const [date, time] = dateParts;
dateLabel = `${capitalizeFirstLetter(time)} ${renderFormattedDate(date)}`;
}
}
return dateLabel;
};
return (
<>
{values.map((date) => (
<div key={date} className="flex items-center gap-1 rounded-sm bg-layer-1 px-1.5 py-1 text-11">
<span className="normal-case">{getDateLabel(date)}</span>
{editable && (
<button
type="button"
className="grid place-items-center text-tertiary hover:text-secondary"
onClick={() => handleRemove(date)}
>
<CloseOutline height={10} width={10} />
</button>
)}
</div>
))}
</>
);
});

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./root";

View File

@@ -0,0 +1,58 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { Avatar } from "@makeplane/propel/components/avatar";
import { CloseOutline } from "@makeplane/propel/icons";
// helpers
import { getFileURL } from "@plane/utils";
// types
import { useMember } from "@/hooks/store/use-member";
type Props = {
handleRemove: (val: string) => void;
values: string[];
editable: boolean | undefined;
};
export const AppliedMembersFilters = observer(function AppliedMembersFilters(props: Props) {
const { handleRemove, values, editable } = props;
// store hooks
const {
workspace: { getWorkspaceMemberDetails },
} = useMember();
return (
<>
{values.map((memberId) => {
const memberDetails = getWorkspaceMemberDetails(memberId)?.member;
if (!memberDetails) return null;
return (
<div key={memberId} className="flex items-center gap-1 rounded-sm bg-layer-1 px-1.5 py-1 text-11">
<Avatar
alt={memberDetails.display_name}
fallback={memberDetails.display_name?.[0]?.toUpperCase()}
src={getFileURL(memberDetails.avatar_url)}
size="2xs"
/>
<span className="normal-case">{memberDetails.display_name}</span>
{editable && (
<button
type="button"
className="grid place-items-center text-tertiary hover:text-secondary"
onClick={() => handleRemove(memberId)}
>
<CloseOutline height={10} width={10} />
</button>
)}
</div>
);
})}
</>
);
});

View File

@@ -0,0 +1,47 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// icons
// types
import { PROJECT_DISPLAY_FILTER_OPTIONS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { CloseOutline } from "@makeplane/propel/icons";
import type { TProjectAppliedDisplayFilterKeys } from "@plane/types";
// constants
type Props = {
handleRemove: (key: TProjectAppliedDisplayFilterKeys) => void;
values: TProjectAppliedDisplayFilterKeys[];
editable: boolean | undefined;
};
export const AppliedProjectDisplayFilters = observer(function AppliedProjectDisplayFilters(props: Props) {
const { handleRemove, values, editable } = props;
const { t } = useTranslation();
return (
<>
{values.map((key) => {
const filterLabel = PROJECT_DISPLAY_FILTER_OPTIONS.find((s) => s.key === key)?.i18n_label;
return (
<div key={key} className="flex items-center gap-1 rounded-sm bg-layer-1 px-1.5 py-1 text-11">
{filterLabel && t(filterLabel)}
{editable && (
<button
type="button"
className="grid place-items-center text-tertiary hover:text-secondary"
onClick={() => handleRemove(key)}
>
<CloseOutline height={10} width={10} />
</button>
)}
</div>
);
})}
</>
);
});

View File

@@ -0,0 +1,136 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useTranslation } from "@plane/i18n";
import { PillButton } from "@makeplane/propel/components/pill";
import { CloseOutline } from "@makeplane/propel/icons";
// plane imports
import { Tooltip } from "@makeplane/propel/components/tooltip";
import type { TProjectAppliedDisplayFilterKeys, TProjectFilters } from "@plane/types";
import { EHeaderVariant, Header } from "@plane/ui";
import { replaceUnderscoreIfSnakeCase } from "@plane/utils";
// local imports
import { AppliedAccessFilters } from "./access";
import { AppliedDateFilters } from "./date";
import { AppliedMembersFilters } from "./members";
import { AppliedProjectDisplayFilters } from "./project-display-filters";
type Props = {
appliedFilters: TProjectFilters;
appliedDisplayFilters: TProjectAppliedDisplayFilterKeys[];
handleClearAllFilters: () => void;
handleRemoveFilter: (key: keyof TProjectFilters, value: string | null) => void;
handleRemoveDisplayFilter: (key: TProjectAppliedDisplayFilterKeys) => void;
alwaysAllowEditing?: boolean;
filteredProjects: number;
totalProjects: number;
};
const MEMBERS_FILTERS = ["lead", "members"];
const DATE_FILTERS = ["created_at"];
export function ProjectAppliedFiltersList(props: Props) {
const { t } = useTranslation();
const {
appliedFilters,
appliedDisplayFilters,
handleClearAllFilters,
handleRemoveFilter,
handleRemoveDisplayFilter,
alwaysAllowEditing,
filteredProjects,
totalProjects,
} = props;
if (!appliedFilters && !appliedDisplayFilters) return null;
if (Object.keys(appliedFilters).length === 0 && appliedDisplayFilters.length === 0) return null;
const isEditingAllowed = alwaysAllowEditing;
return (
<Header variant={EHeaderVariant.TERNARY}>
<Header.LeftItem>
{/* Applied filters */}
{Object.entries(appliedFilters ?? {}).map(([key, value]) => {
const filterKey = key as keyof TProjectFilters;
if (!value) return;
if (Array.isArray(value) && value.length === 0) return;
return (
<div
key={filterKey}
className="my-auto flex min-h-9 cursor-pointer flex-wrap items-center gap-1.5 rounded-md border border-subtle p-1.5 text-11 text-tertiary capitalize hover:text-secondary"
>
<span className="text-11 text-tertiary">{replaceUnderscoreIfSnakeCase(filterKey)}</span>
{filterKey === "access" && (
<AppliedAccessFilters
editable={isEditingAllowed}
handleRemove={(val) => handleRemoveFilter("access", val)}
values={value}
/>
)}
{DATE_FILTERS.includes(filterKey) && (
<AppliedDateFilters
editable={isEditingAllowed}
handleRemove={(val) => handleRemoveFilter(filterKey, val)}
values={value}
/>
)}
{MEMBERS_FILTERS.includes(filterKey) && (
<AppliedMembersFilters
editable={isEditingAllowed}
handleRemove={(val) => handleRemoveFilter(filterKey, val)}
values={value}
/>
)}
{isEditingAllowed && (
<button
type="button"
className="grid place-items-center text-tertiary hover:text-secondary"
onClick={() => handleRemoveFilter(filterKey, null)}
>
<CloseOutline height={12} width={12} />
</button>
)}
</div>
);
})}
{/* Applied display filters */}
{appliedDisplayFilters.length > 0 && (
<div
key="project_display_filters"
className="my-auto flex min-h-9 cursor-pointer flex-wrap items-center gap-1.5 rounded-md border border-subtle p-1.5 text-11 text-tertiary capitalize hover:text-secondary"
>
<span className="text-11 text-tertiary">{t("common.projects")}</span>
<AppliedProjectDisplayFilters
editable={isEditingAllowed}
values={appliedDisplayFilters}
handleRemove={(key) => handleRemoveDisplayFilter(key)}
/>
</div>
)}
{isEditingAllowed && (
<PillButton
type="button"
size="md"
variant="outline"
label={t("common.clear_all")}
endIcon={<CloseOutline height={12} width={12} />}
onClick={handleClearAllFilters}
/>
)}
</Header.LeftItem>
<Header.RightItem>
<Tooltip label={`${filteredProjects} of ${totalProjects} projects match the applied filters.`} layout="stacked">
<span className="rounded-full bg-layer-1 px-2.5 py-1 text-13 font-medium">
{filteredProjects}/{totalProjects}
</span>
</Tooltip>
</Header.RightItem>
</Header>
);
}

View File

@@ -0,0 +1,116 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
// ui
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useAppRouter } from "@/hooks/use-app-router";
type Props = {
workspaceSlug: string;
projectId: string;
isOpen: boolean;
onClose: () => void;
archive: boolean;
};
export function ArchiveRestoreProjectModal(props: Props) {
const { workspaceSlug, projectId, isOpen, onClose, archive } = props;
// router
const router = useAppRouter();
// states
const [isLoading, setIsLoading] = useState(false);
// store hooks
const { getProjectById, archiveProject, restoreProject } = useProject();
const projectDetails = getProjectById(projectId);
if (!projectDetails) return null;
const handleClose = () => {
setIsLoading(false);
onClose();
};
const handleArchiveProject = async () => {
setIsLoading(true);
await archiveProject(workspaceSlug, projectId)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Archive success",
message: `${projectDetails.name} has been archived successfully`,
});
onClose();
router.push(`/${workspaceSlug}/projects/`);
return;
})
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Project could not be archived. Please try again.",
})
)
.finally(() => setIsLoading(false));
};
const handleRestoreProject = async () => {
setIsLoading(true);
await restoreProject(workspaceSlug, projectId)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Restore success",
message: `You can find ${projectDetails.name} in your projects.`,
});
onClose();
router.push(`/${workspaceSlug}/projects/`);
return;
})
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Project could not be restored. Please try again.",
})
)
.finally(() => setIsLoading(false));
};
return (
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.LG}>
<div className="px-5 py-4">
<h3 className="text-18 font-medium 2xl:text-20">
{archive ? "Archive" : "Restore"} {projectDetails.name}
</h3>
<p className="mt-3 text-13 text-secondary">
{archive
? "This project and its work items, cycles, modules, and pages will be archived. Its work items won't appear in search. Only project admins can restore the project."
: "Restoring a project will activate it and make it visible to all members of the project. Are you sure you want to continue?"}
</p>
<div className="mt-3 flex justify-end gap-2">
<Button variant="secondary" size="lg" onClick={onClose}>
Cancel
</Button>
<Button
variant="primary"
size="lg"
tabIndex={1}
onClick={archive ? handleArchiveProject : handleRestoreProject}
loading={isLoading}
>
{archive ? (isLoading ? "Archiving" : "Archive") : isLoading ? "Restoring" : "Restore"}
</Button>
</div>
</div>
</ModalCore>
);
}

View File

@@ -0,0 +1,114 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// plane imports
import { EUserPermissionsLevel, EUserPermissions } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EmptyStateDetailed } from "@plane/propel/empty-state";
import { ContentWrapper } from "@plane/ui";
// components
import { calculateTotalFilters } from "@plane/utils";
import { ProjectsLoader } from "@/components/ui/loader/projects-loader";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProject } from "@/hooks/store/use-project";
import { useProjectFilter } from "@/hooks/store/use-project-filter";
import { useUserPermissions } from "@/hooks/store/user";
// local imports
import { ProjectCard } from "./card";
type TProjectCardListProps = {
totalProjectIds?: string[];
filteredProjectIds?: string[];
};
export const ProjectCardList = observer(function ProjectCardList(props: TProjectCardListProps) {
const { totalProjectIds: totalProjectIdsProps, filteredProjectIds: filteredProjectIdsProps } = props;
// plane hooks
const { t } = useTranslation();
// store hooks
const { toggleCreateProjectModal } = useCommandPalette();
const {
loader,
fetchStatus,
workspaceProjectIds: storeWorkspaceProjectIds,
filteredProjectIds: storeFilteredProjectIds,
getProjectById,
} = useProject();
const { currentWorkspaceDisplayFilters, currentWorkspaceFilters } = useProjectFilter();
const { allowPermissions } = useUserPermissions();
// derived values
const workspaceProjectIds = totalProjectIdsProps ?? storeWorkspaceProjectIds;
const filteredProjectIds = filteredProjectIdsProps ?? storeFilteredProjectIds;
// permissions
const canPerformEmptyStateActions = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
if (!filteredProjectIds || !workspaceProjectIds || loader === "init-loader" || fetchStatus !== "complete")
return <ProjectsLoader />;
if (workspaceProjectIds?.length === 0 && !currentWorkspaceDisplayFilters?.archived_projects)
return (
<EmptyStateDetailed
title={t("workspace_projects.empty_state.general.title")}
description={t("workspace_projects.empty_state.general.description")}
assetKey="project"
assetClassName="size-40"
actions={[
{
label: t("workspace_projects.empty_state.general.primary_button.text"),
onClick: () => {
toggleCreateProjectModal(true);
},
disabled: !canPerformEmptyStateActions,
variant: "primary",
},
]}
/>
);
if (filteredProjectIds.length === 0)
return (
<EmptyStateDetailed
title={
currentWorkspaceDisplayFilters?.archived_projects &&
calculateTotalFilters(currentWorkspaceFilters ?? {}) === 0
? t("workspace_empty_state.projects_archived.title")
: t("common_empty_state.search.title")
}
description={
currentWorkspaceDisplayFilters?.archived_projects &&
calculateTotalFilters(currentWorkspaceFilters ?? {}) === 0
? t("workspace_empty_state.projects_archived.description")
: t("common_empty_state.search.description")
}
assetKey={
currentWorkspaceDisplayFilters?.archived_projects &&
calculateTotalFilters(currentWorkspaceFilters ?? {}) === 0
? "archived-work-item"
: "search"
}
assetClassName="size-40"
/>
);
return (
<ContentWrapper>
<div className="grid grid-cols-1 gap-8 md:grid-cols-2 lg:grid-cols-3">
{filteredProjectIds.map((projectId) => {
const projectDetails = getProjectById(projectId);
if (!projectDetails) return;
return <ProjectCard key={projectDetails.id} project={projectDetails} />;
})}
</div>
</ContentWrapper>
);
});

View File

@@ -0,0 +1,388 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React, { useRef, useState } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import {
DeleteOutline,
LinkOutline,
LockOutline,
NewTabOutline,
RestoreOutline,
SettingsOutline,
TickOutline,
UserPlusOutline,
} from "@makeplane/propel/icons";
// plane imports
import { EUserPermissions, EUserPermissionsLevel, IS_FAVORITE_MENU_OPEN } from "@plane/constants";
import { useLocalStorage } from "@plane/hooks";
import { Avatar } from "@makeplane/propel/components/avatar";
import { Button } from "@plane/propel/button";
import { Logo } from "@plane/propel/emoji-icon-picker";
import { setPromiseToast, setToast, TOAST_TYPE } from "@plane/propel/toast";
import { Tooltip } from "@makeplane/propel/components/tooltip";
import type { IProject } from "@plane/types";
import type { TContextMenuItem } from "@plane/ui";
import { ContextMenu, FavoriteStar } from "@plane/ui";
import { copyUrlToClipboard, cn, getFileURL, renderFormattedDate } from "@plane/utils";
// components
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os";
// local imports
import { AvatarGroupOverflow } from "@/components/common/avatar-group-overflow";
import { CoverImage } from "@/components/common/cover-image";
import { DeleteProjectModal } from "./delete-project-modal";
import { JoinProjectModal } from "./join-project-modal";
import { ArchiveRestoreProjectModal } from "./archive-restore-modal";
type Props = {
project: IProject;
};
export const ProjectCard = observer(function ProjectCard(props: Props) {
const { project } = props;
// states
const [deleteProjectModalOpen, setDeleteProjectModal] = useState(false);
const [joinProjectModalOpen, setJoinProjectModal] = useState(false);
const [restoreProject, setRestoreProject] = useState(false);
// refs
const projectCardRef = useRef(null);
// router
const router = useAppRouter();
const { workspaceSlug } = useParams();
// store hooks
const { getUserDetails } = useMember();
const { addProjectToFavorites, removeProjectFromFavorites } = useProject();
const { allowPermissions } = useUserPermissions();
// hooks
const { isMobile } = usePlatformOS();
// derived values
const projectMembersIds = project.members;
const shouldRenderFavorite = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
// auth
const isMemberOfProject = !!project.member_role;
const hasAdminRole = project.member_role === EUserPermissions.ADMIN;
const hasMemberRole = project.member_role === EUserPermissions.MEMBER;
// archive
const isArchived = !!project.archived_at;
// local storage
const { setValue: toggleFavoriteMenu, storedValue: isFavoriteMenuOpen } = useLocalStorage<boolean>(
IS_FAVORITE_MENU_OPEN,
false
);
const handleAddToFavorites = () => {
if (!workspaceSlug) return;
const addToFavoritePromise = addProjectToFavorites(workspaceSlug.toString(), project.id);
setPromiseToast(addToFavoritePromise, {
loading: "Adding project to favorites...",
success: {
title: "Success!",
message: () => "Project added to favorites.",
actionItems: () => {
if (!isFavoriteMenuOpen) toggleFavoriteMenu(true);
return <></>;
},
},
error: {
title: "Error!",
message: () => "Couldn't add the project to favorites. Please try again.",
},
});
};
const handleRemoveFromFavorites = () => {
if (!workspaceSlug) return;
const removeFromFavoritePromise = removeProjectFromFavorites(workspaceSlug.toString(), project.id);
setPromiseToast(removeFromFavoritePromise, {
loading: "Removing project from favorites...",
success: {
title: "Success!",
message: () => "Project removed from favorites.",
},
error: {
title: "Error!",
message: () => "Couldn't remove the project from favorites. Please try again.",
},
});
};
const projectLink = `${workspaceSlug}/projects/${project.id}/issues`;
const handleCopyText = () =>
copyUrlToClipboard(projectLink).then(() =>
setToast({
type: TOAST_TYPE.INFO,
title: "Link Copied!",
message: "Project link copied to clipboard.",
})
);
const handleOpenInNewTab = () => window.open(`/${projectLink}`, "_blank");
const MENU_ITEMS: TContextMenuItem[] = [
{
key: "settings",
action: () => router.push(`/${workspaceSlug}/settings/projects/${project.id}`),
title: "Settings",
icon: SettingsOutline,
shouldRender: !isArchived && (hasAdminRole || hasMemberRole),
},
{
key: "join",
action: () => setJoinProjectModal(true),
title: "Join",
icon: UserPlusOutline,
shouldRender: !isMemberOfProject && !isArchived,
},
{
key: "open-new-tab",
action: handleOpenInNewTab,
title: "Open in new tab",
icon: NewTabOutline,
shouldRender: !isMemberOfProject && !isArchived,
},
{
key: "copy-link",
action: handleCopyText,
title: "Copy link",
icon: LinkOutline,
shouldRender: !isArchived,
},
{
key: "restore",
action: () => setRestoreProject(true),
title: "Restore",
icon: RestoreOutline,
shouldRender: isArchived && hasAdminRole,
},
{
key: "delete",
action: () => setDeleteProjectModal(true),
title: "Delete",
icon: DeleteOutline,
shouldRender: isArchived && hasAdminRole,
},
];
return (
<>
{/* Delete Project Modal */}
<DeleteProjectModal
project={project}
isOpen={deleteProjectModalOpen}
onClose={() => setDeleteProjectModal(false)}
/>
{/* Join Project Modal */}
{workspaceSlug && (
<JoinProjectModal
workspaceSlug={workspaceSlug.toString()}
project={project}
isOpen={joinProjectModalOpen}
handleClose={() => setJoinProjectModal(false)}
/>
)}
{/* Restore project modal */}
{workspaceSlug && project && (
<ArchiveRestoreProjectModal
workspaceSlug={workspaceSlug.toString()}
projectId={project.id}
isOpen={restoreProject}
onClose={() => setRestoreProject(false)}
archive={false}
/>
)}
<Link
ref={projectCardRef}
href={`/${workspaceSlug}/projects/${project.id}/issues`}
onClick={(e) => {
if (!isMemberOfProject || isArchived) {
e.preventDefault();
e.stopPropagation();
if (!isArchived) setJoinProjectModal(true);
}
}}
data-prevent-progress={!isMemberOfProject || isArchived}
className={cn(
"group/project-card flex w-full flex-col justify-between overflow-hidden rounded-lg border border-subtle bg-layer-2 transition-all duration-300 hover:border-strong hover:shadow-raised-200"
)}
>
<ContextMenu parentRef={projectCardRef} items={MENU_ITEMS} />
<div className="relative h-[118px] w-full rounded-t">
<div className="absolute inset-0 z-[1] bg-gradient-to-t from-black/60 to-transparent" />
<CoverImage
src={project.cover_image_url}
alt={project.name}
className="absolute top-0 left-0 h-full w-full rounded-t"
/>
<div className="absolute bottom-4 z-[1] flex h-10 w-full items-center justify-between gap-3 px-4">
<div className="flex flex-grow items-center gap-2.5 truncate">
<div className="grid h-9 w-9 flex-shrink-0 place-items-center rounded-sm bg-white/10">
<Logo logo={project.logo_props} size={18} />
</div>
<div className="flex w-full flex-col justify-between gap-0.5 truncate">
<h3 className="truncate font-semibold text-on-color">{project.name}</h3>
<span className="flex items-center gap-1.5">
<p className="text-11 font-medium text-on-color">{project.identifier} </p>
{project.network === 0 && <LockOutline className="h-2.5 w-2.5 text-on-color" />}
</span>
</div>
</div>
{!isArchived && (
<div data-prevent-progress className="flex h-full flex-shrink-0 items-center gap-2">
<button
className="flex h-6 w-6 items-center justify-center rounded-sm bg-white/10"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleCopyText();
}}
>
<LinkOutline className="h-3 w-3 text-on-color" />
</button>
{shouldRenderFavorite && (
<FavoriteStar
buttonClassName="h-6 w-6 bg-white/10 rounded-sm"
iconClassName={cn("h-3 w-3", {
"text-on-color": !project.is_favorite,
})}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (project.is_favorite) handleRemoveFromFavorites();
else handleAddToFavorites();
}}
selected={!!project.is_favorite}
/>
)}
</div>
)}
</div>
</div>
<div
className={cn("flex h-[104px] w-full flex-col justify-between rounded-b-sm p-4", {
"opacity-90": isArchived,
})}
>
<p className="line-clamp-2 text-13 break-words text-tertiary">
{project.description && project.description.trim() !== ""
? project.description
: `Created on ${renderFormattedDate(project.created_at)}`}
</p>
<div className="item-center flex justify-between">
<div className="flex items-center justify-center gap-2">
<Tooltip
label={project.members?.length ? `Members: ${project.members.length}` : "No members"}
layout="stacked"
disabled={isMobile}
>
{projectMembersIds && projectMembersIds.length > 0 ? (
<div className="flex cursor-pointer items-center gap-2 text-secondary">
<AvatarGroupOverflow size="xs">
{projectMembersIds.map((memberId) => {
const member = getUserDetails(memberId);
if (!member) return null;
return (
<Avatar
key={member.id}
alt={member.display_name}
fallback={member.display_name?.[0]?.toUpperCase()}
src={getFileURL(member.avatar_url)}
/>
);
})}
</AvatarGroupOverflow>
</div>
) : (
<span className="text-13 text-placeholder italic">No Member Yet</span>
)}
</Tooltip>
{isArchived && <div className="text-11 font-medium text-placeholder">Archived</div>}
</div>
{isArchived ? (
hasAdminRole && (
<div className="flex items-center justify-center gap-2">
<div
className="flex items-center justify-center text-11 font-medium text-placeholder hover:text-secondary"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setRestoreProject(true);
}}
>
<div className="flex items-center gap-1.5">
<RestoreOutline className="h-3.5 w-3.5" />
Restore
</div>
</div>
<div
className="flex items-center justify-center text-11 font-medium text-placeholder hover:text-secondary"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setDeleteProjectModal(true);
}}
>
<DeleteOutline className="h-3.5 w-3.5" />
</div>
</div>
)
) : (
<>
{isMemberOfProject &&
(hasAdminRole || hasMemberRole ? (
<Link
className="flex items-center justify-center rounded-sm p-1 text-placeholder hover:bg-layer-1 hover:text-secondary"
onClick={(e) => {
e.stopPropagation();
}}
href={`/${workspaceSlug}/settings/projects/${project.id}`}
>
<SettingsOutline className="h-3.5 w-3.5" />
</Link>
) : (
<span className="flex items-center gap-1 text-13 text-placeholder">
<TickOutline className="h-3.5 w-3.5" />
Joined
</span>
))}
{!isMemberOfProject && (
<div className="flex items-center">
<Button
variant="link"
className="!p-0 font-semibold"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setJoinProjectModal(true);
}}
>
Join
</Button>
</div>
)}
</>
)}
</div>
</div>
</Link>
</>
);
});

View File

@@ -0,0 +1,94 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { WarningTriangleOutline } from "@makeplane/propel/icons";
// types
import { Button } from "@plane/propel/button";
import type { IUserLite } from "@plane/types";
// ui
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useUser } from "@/hooks/store/user";
type Props = {
data: Partial<IUserLite>;
onSubmit: () => Promise<void>;
isOpen: boolean;
onClose: () => void;
};
export const ConfirmProjectMemberRemove = observer(function ConfirmProjectMemberRemove(props: Props) {
const { data, onSubmit, isOpen, onClose } = props;
// router
const { projectId } = useParams();
// states
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
// store hooks
const { data: currentUser } = useUser();
const { getProjectById } = useProject();
const handleClose = () => {
onClose();
setIsDeleteLoading(false);
};
const handleDeletion = async () => {
setIsDeleteLoading(true);
await onSubmit();
handleClose();
};
if (!projectId) return <></>;
const isCurrentUser = currentUser?.id === data?.id;
const currentProjectDetails = getProjectById(projectId.toString());
return (
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.XXL}>
<div className="bg-surface-1 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-danger-subtle sm:mx-0 sm:h-10 sm:w-10">
<WarningTriangleOutline className="h-6 w-6 text-danger-primary" aria-hidden="true" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-16 leading-6 font-medium text-primary">
{isCurrentUser ? "Leave project?" : `Remove ${data?.display_name}?`}
</h3>
<div className="mt-2">
<p className="text-13 text-secondary">
{isCurrentUser ? (
<>
Are you sure you want to leave the <span className="font-bold">{currentProjectDetails?.name}</span>{" "}
project? You will be able to join the project if invited again or if it{"'"}s public.
</>
) : (
<>
Are you sure you want to remove member- <span className="font-bold">{data?.display_name}</span>?
They will no longer have access to this project. This action cannot be undone.
</>
)}
</p>
</div>
</div>
</div>
</div>
<div className="flex justify-end gap-2 p-4 sm:px-6">
<Button variant="secondary" size="lg" onClick={handleClose}>
Cancel
</Button>
<Button variant="error-fill" size="lg" tabIndex={1} onClick={handleDeletion} loading={isDeleteLoading}>
{isCurrentUser ? (isDeleteLoading ? "Leaving..." : "Leave") : isDeleteLoading ? "Removing..." : "Remove"}
</Button>
</div>
</ModalCore>
);
});

View File

@@ -0,0 +1,86 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect, useState } from "react";
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
import { getAssetIdFromUrl, checkURLValidity } from "@plane/utils";
// plane ui
// helpers
// hooks
import useKeypress from "@/hooks/use-keypress";
// plane web components
import { CreateProjectForm } from "@/components/projects/create/root";
// plane web types
import type { TProject } from "@plane/types";
// services
import { FileService } from "@/services/file.service";
const fileService = new FileService();
import { ProjectFeatureUpdate } from "./project-feature-update";
type Props = {
isOpen: boolean;
onClose: () => void;
setToFavorite?: boolean;
workspaceSlug: string;
data?: Partial<TProject>;
templateId?: string;
};
enum EProjectCreationSteps {
CREATE_PROJECT = "CREATE_PROJECT",
FEATURE_SELECTION = "FEATURE_SELECTION",
}
export function CreateProjectModal(props: Props) {
const { isOpen, onClose, setToFavorite = false, workspaceSlug, data, templateId } = props;
// states
const [currentStep, setCurrentStep] = useState<EProjectCreationSteps>(EProjectCreationSteps.CREATE_PROJECT);
const [createdProjectId, setCreatedProjectId] = useState<string | null>(null);
useEffect(() => {
if (isOpen) {
setCurrentStep(EProjectCreationSteps.CREATE_PROJECT);
setCreatedProjectId(null);
}
}, [isOpen]);
const handleNextStep = (projectId: string) => {
if (!projectId) return;
setCreatedProjectId(projectId);
setCurrentStep(EProjectCreationSteps.FEATURE_SELECTION);
};
const handleCoverImageStatusUpdate = async (projectId: string, coverImage: string) => {
if (!checkURLValidity(coverImage)) {
await fileService.updateBulkProjectAssetsUploadStatus(workspaceSlug, projectId, projectId, {
asset_ids: [getAssetIdFromUrl(coverImage)],
});
}
};
useKeypress("Escape", () => {
if (isOpen) onClose();
});
return (
<ModalCore isOpen={isOpen} position={EModalPosition.TOP} width={EModalWidth.XXXXL}>
{currentStep === EProjectCreationSteps.CREATE_PROJECT && (
<CreateProjectForm
setToFavorite={setToFavorite}
workspaceSlug={workspaceSlug}
onClose={onClose}
updateCoverImageStatus={handleCoverImageStatusUpdate}
handleNextStep={handleNextStep}
data={data}
templateId={templateId}
/>
)}
{currentStep === EProjectCreationSteps.FEATURE_SELECTION && (
<ProjectFeatureUpdate projectId={createdProjectId} workspaceSlug={workspaceSlug} onClose={onClose} />
)}
</ModalCore>
);
}

View File

@@ -0,0 +1,168 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { ChangeEvent } from "react";
import type { UseFormSetValue } from "react-hook-form";
import { Controller, useFormContext } from "react-hook-form";
import { Field } from "@makeplane/propel/components/field";
import { Input, InputGroup } from "@makeplane/propel/components/input";
import { TextArea, TextAreaGroup } from "@makeplane/propel/components/text-area";
import { InfoOutline } from "@makeplane/propel/icons";
// plane imports
import { ETabIndices } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import type { TProject } from "@plane/types";
// ui
import { Tooltip } from "@makeplane/propel/components/tooltip";
import { projectIdentifierSanitizer, getTabIndex } from "@plane/utils";
type Props = {
setValue: UseFormSetValue<TProject>;
isMobile: boolean;
shouldAutoSyncIdentifier: boolean;
setShouldAutoSyncIdentifier: (value: boolean) => void;
handleFormOnChange?: () => void;
};
function ProjectCommonAttributes(props: Props) {
const { setValue, isMobile, shouldAutoSyncIdentifier, setShouldAutoSyncIdentifier, handleFormOnChange } = props;
const {
formState: { errors },
control,
} = useFormContext<TProject>();
const { getIndex } = getTabIndex(ETabIndices.PROJECT_CREATE, isMobile);
const { t } = useTranslation();
const handleNameChange =
(onChange: (event: ChangeEvent<HTMLInputElement>) => void) => (e: ChangeEvent<HTMLInputElement>) => {
if (!shouldAutoSyncIdentifier) {
onChange(e);
return;
}
if (e.target.value === "") setValue("identifier", "");
else setValue("identifier", projectIdentifierSanitizer(e.target.value).substring(0, 10));
onChange(e);
handleFormOnChange?.();
};
const handleIdentifierChange = (onChange: (value: string) => void) => (e: ChangeEvent<HTMLInputElement>) => {
const { value } = e.target;
const alphanumericValue = projectIdentifierSanitizer(value);
setShouldAutoSyncIdentifier(false);
onChange(alphanumericValue);
handleFormOnChange?.();
};
return (
<div className="grid grid-cols-1 gap-x-2 gap-y-3 md:grid-cols-4">
<div className="md:col-span-3">
<Controller
control={control}
name="name"
rules={{
required: t("name_is_required"),
maxLength: {
value: 255,
message: t("title_should_be_less_than_255_characters"),
},
}}
render={({ field: { value, onChange } }) => (
<Field name="name" invalid={Boolean(errors.name)}>
<InputGroup size="2xl">
<Input
size="2xl"
id="name"
name="name"
type="text"
value={value}
onChange={handleNameChange(onChange)}
placeholder={t("project_name")}
tabIndex={getIndex("name")}
/>
</InputGroup>
</Field>
)}
/>
<span className="text-11 text-danger-primary">{errors?.name?.message}</span>
</div>
<div className="relative">
<Controller
control={control}
name="identifier"
rules={{
required: t("project_id_is_required"),
// allow only alphanumeric & non-latin characters
validate: (value) =>
/^[ÇŞĞIİÖÜA-Z0-9]+$/.test(value.toUpperCase()) || t("only_alphanumeric_non_latin_characters_allowed"),
minLength: {
value: 1,
message: t("project_id_min_char"),
},
maxLength: {
value: 10,
message: t("project_id_max_char"),
},
}}
render={({ field: { value, onChange } }) => (
<Field name="identifier" invalid={Boolean(errors.identifier)}>
<InputGroup size="2xl">
<Input
size="2xl"
id="identifier"
name="identifier"
type="text"
value={value}
onChange={handleIdentifierChange(onChange)}
placeholder={t("project_id")}
tabIndex={getIndex("identifier")}
/>
</InputGroup>
</Field>
)}
/>
<Tooltip
label={t("project_id_tooltip_content")}
layout="stacked"
side="right"
align="start"
disabled={isMobile}
>
<InfoOutline className="absolute top-2.5 right-2 h-3 w-3 text-placeholder" />
</Tooltip>
<span className="text-11 text-danger-primary">{errors?.identifier?.message}</span>
</div>
<div className="md:col-span-4">
<Controller
name="description"
control={control}
render={({ field: { value, onChange } }) => (
<Field name="description" invalid={Boolean(errors?.description)}>
<TextAreaGroup resize="none">
<TextArea
size="lg"
surface="field"
autoResize
maxRows={8}
id="description"
name="description"
value={value}
placeholder={t("description")}
onChange={(e) => {
onChange(e);
handleFormOnChange?.();
}}
tabIndex={getIndex("description")}
/>
</TextAreaGroup>
</Field>
)}
/>
</div>
</div>
);
}
export default ProjectCommonAttributes;

View File

@@ -0,0 +1,121 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
// plane imports
import { ETabIndices } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EmojiPicker, EmojiIconPickerTypes, Logo } from "@plane/propel/emoji-icon-picker";
import { CloseOutline } from "@makeplane/propel/icons";
// plane types
import type { IProject } from "@plane/types";
// plane ui
import { getTabIndex } from "@plane/utils";
// components
import { CoverImage } from "@/components/common/cover-image";
import { ImagePickerPopover } from "@/components/core/image-picker-popover";
type Props = {
handleClose: () => void;
isMobile?: boolean;
handleFormOnChange?: () => void;
isClosable?: boolean;
handleTemplateSelect?: () => void;
showActionButtons?: boolean;
};
function ProjectCreateHeader(props: Props) {
const { handleClose, isMobile = false, handleFormOnChange, isClosable = true } = props;
const { watch, control, setValue } = useFormContext<IProject>();
const { t } = useTranslation();
// derived values
const coverImage = watch("cover_image_url");
const [isOpen, setIsOpen] = useState(false);
const { getIndex } = getTabIndex(ETabIndices.PROJECT_CREATE, isMobile);
return (
<div className="group relative h-44 w-full rounded-lg">
<CoverImage
src={coverImage}
alt={t("project_cover_image_alt")}
className="absolute top-0 left-0 h-full w-full rounded-lg"
/>
{isClosable && (
<div className="absolute top-2 right-2 p-2">
<button type="button" onClick={handleClose} tabIndex={getIndex("close")}>
<CloseOutline className="h-5 w-5 text-on-color" />
</button>
</div>
)}
<div className="absolute right-2 bottom-2">
<Controller
name="cover_image_url"
control={control}
render={({ field: { value, onChange } }) => (
<ImagePickerPopover
label={t("change_cover")}
onChange={(data) => {
onChange(data);
handleFormOnChange?.();
}}
control={control}
value={value ?? null}
tabIndex={getIndex("cover_image")}
/>
)}
/>
</div>
<div className="absolute -bottom-[22px] left-3">
<Controller
name="logo_props"
control={control}
render={({ field: { value, onChange } }) => (
<EmojiPicker
iconType="material"
isOpen={isOpen}
handleToggle={(val: boolean) => setIsOpen(val)}
className="flex items-center justify-center"
buttonClassName="flex items-center justify-center"
label={
<span className="grid h-11 w-11 place-items-center rounded-md border border-subtle bg-layer-2">
<Logo logo={value} size={20} />
</span>
}
onChange={(val: any) => {
let logoValue = {};
if (val?.type === "emoji")
logoValue = {
value: val.value,
};
else if (val?.type === "icon") logoValue = val.value;
const newLogoProps = {
in_use: val?.type,
[val?.type]: logoValue,
};
setValue("logo_props", newLogoProps, {
shouldDirty: true,
});
onChange(newLogoProps);
handleFormOnChange?.();
setIsOpen(false);
}}
defaultIconColor={value?.in_use && value.in_use === "icon" ? value.icon?.color : undefined}
defaultOpen={
value?.in_use && value.in_use === "emoji" ? EmojiIconPickerTypes.EMOJI : EmojiIconPickerTypes.ICON
}
/>
)}
/>
</div>
</div>
);
}
export default ProjectCreateHeader;

View File

@@ -0,0 +1,43 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useFormContext } from "react-hook-form";
// plane imports
import { ETabIndices } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import type { IProject } from "@plane/types";
// ui
// helpers
import { getTabIndex } from "@plane/utils";
type Props = {
handleClose: () => void;
isMobile?: boolean;
};
function ProjectCreateButtons(props: Props) {
const { t } = useTranslation();
const { handleClose, isMobile = false } = props;
const {
formState: { isSubmitting },
} = useFormContext<IProject>();
const { getIndex } = getTabIndex(ETabIndices.PROJECT_CREATE, isMobile);
return (
<div className="flex justify-end gap-2 border-t border-subtle py-4">
<Button variant="secondary" size="lg" onClick={handleClose} tabIndex={getIndex("cancel")}>
{t("common.cancel")}
</Button>
<Button variant="primary" size="lg" type="submit" loading={isSubmitting} tabIndex={getIndex("submit")}>
{isSubmitting ? t("creating") : t("create_project")}
</Button>
</div>
);
}
export default ProjectCreateButtons;

View File

@@ -0,0 +1,160 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useParams } from "next/navigation";
import { Controller, useForm } from "react-hook-form";
import { WarningTriangleOutline } from "@makeplane/propel/icons";
// Plane imports
import { Field } from "@makeplane/propel/components/field";
import { Input, InputGroup } from "@makeplane/propel/components/input";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IProject } from "@plane/types";
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useAppRouter } from "@/hooks/use-app-router";
type DeleteProjectModal = {
isOpen: boolean;
project: IProject;
onClose: () => void;
};
const defaultValues = {
projectName: "",
confirmDelete: "",
};
export function DeleteProjectModal(props: DeleteProjectModal) {
const { isOpen, project, onClose } = props;
// store hooks
const { deleteProject } = useProject();
// router
const router = useAppRouter();
const { workspaceSlug, projectId } = useParams();
// form info
const {
control,
formState: { errors, isSubmitting },
handleSubmit,
reset,
watch,
} = useForm({ defaultValues });
const canDelete = watch("projectName") === project?.name && watch("confirmDelete") === "delete my project";
const handleClose = () => {
const timer = setTimeout(() => {
reset(defaultValues);
clearTimeout(timer);
}, 350);
onClose();
};
const onSubmit = async () => {
if (!workspaceSlug || !canDelete) return;
try {
await deleteProject(workspaceSlug.toString(), project.id);
if (projectId && projectId.toString() === project.id) router.push(`/${workspaceSlug}/projects`);
handleClose();
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Project deleted successfully.",
});
} catch (_error) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Something went wrong. Please try again later.",
});
}
};
return (
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.XXL}>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-6 p-6">
<div className="flex w-full items-center justify-start gap-6">
<span className="place-items-center rounded-full bg-danger-subtle p-4">
<WarningTriangleOutline className="h-6 w-6 text-danger-primary" aria-hidden="true" />
</span>
<span className="flex items-center justify-start">
<h3 className="text-18 font-medium 2xl:text-20">Delete project</h3>
</span>
</div>
<span>
<p className="text-13 leading-7 text-secondary">
Are you sure you want to delete project <span className="font-semibold break-words">{project?.name}</span>?
All of the data related to the project will be permanently removed. This action cannot be undone
</p>
</span>
<div className="text-secondary">
<p className="text-13 break-words">
Enter the project name <span className="font-medium text-primary">{project?.name}</span> to continue:
</p>
<Controller
control={control}
name="projectName"
render={({ field: { value, onChange, ref } }) => (
<Field name="projectName" invalid={Boolean(errors.projectName)}>
<InputGroup size="2xl">
<Input
size="2xl"
id="projectName"
name="projectName"
type="text"
value={value}
onChange={onChange}
ref={ref}
placeholder="Project name"
autoComplete="off"
/>
</InputGroup>
</Field>
)}
/>
</div>
<div className="text-secondary">
<p className="text-13">
To confirm, type <span className="font-medium text-primary">delete my project</span> below:
</p>
<Controller
control={control}
name="confirmDelete"
render={({ field: { value, onChange, ref } }) => (
<Field name="confirmDelete" invalid={Boolean(errors.confirmDelete)}>
<InputGroup size="2xl">
<Input
size="2xl"
id="confirmDelete"
name="confirmDelete"
type="text"
value={value}
onChange={onChange}
ref={ref}
placeholder="Enter 'delete my project'"
autoComplete="off"
/>
</InputGroup>
</Field>
)}
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" size="lg" onClick={handleClose}>
Cancel
</Button>
<Button variant="error-fill" size="lg" type="submit" disabled={!canDelete} loading={isSubmitting}>
{isSubmitting ? "Deleting" : "Delete project"}
</Button>
</div>
</form>
</ModalCore>
);
}

View File

@@ -0,0 +1,58 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React, { useState } from "react";
import { observer } from "mobx-react";
// plane imports
import { NETWORK_CHOICES } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// components
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
// local imports
import { ProjectNetworkIcon } from "../../project-network-icon";
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string) => void;
searchQuery: string;
};
export const FilterAccess = observer(function FilterAccess(props: Props) {
const { appliedFilters, handleUpdate, searchQuery } = props;
// states
const [previewEnabled, setPreviewEnabled] = useState(true);
const { t } = useTranslation();
const appliedFiltersCount = appliedFilters?.length ?? 0;
const filteredOptions = NETWORK_CHOICES.filter((a) => a.i18n_label.includes(searchQuery.toLowerCase()));
return (
<>
<FilterHeader
title={`Access${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{filteredOptions.length > 0 ? (
filteredOptions.map((access) => (
<FilterOption
key={access.key}
isChecked={appliedFilters?.includes(`${access.key}`) ? true : false}
onClick={() => handleUpdate(`${access.key}`)}
icon={<ProjectNetworkIcon iconKey={access.iconKey} />}
title={t(access.i18n_label)}
/>
))
) : (
<p className="text-11 text-placeholder italic">No matches found</p>
)}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,88 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React, { useState } from "react";
import { observer } from "mobx-react";
// plane constants
import { PROJECT_CREATED_AT_FILTER_OPTIONS } from "@plane/constants";
// components
import { isInDateFormat } from "@plane/utils";
import { DateFilterModal } from "@/components/core/filters/date-filter-modal";
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
// helpers
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string | string[]) => void;
searchQuery: string;
};
export const FilterCreatedDate = observer(function FilterCreatedDate(props: Props) {
const { appliedFilters, handleUpdate, searchQuery } = props;
// state
const [previewEnabled, setPreviewEnabled] = useState(true);
const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false);
// derived values
const appliedFiltersCount = appliedFilters?.length ?? 0;
const filteredOptions = PROJECT_CREATED_AT_FILTER_OPTIONS.filter((d) =>
d.name.toLowerCase().includes(searchQuery.toLowerCase())
);
const isCustomDateSelected = () => {
const isValidDateSelected = appliedFilters?.filter((f) => isInDateFormat(f.split(";")[0])) || [];
return isValidDateSelected.length > 0 ? true : false;
};
const handleCustomDate = () => {
if (isCustomDateSelected()) {
const updateAppliedFilters = appliedFilters?.filter((f) => f.includes("-")) || [];
handleUpdate(updateAppliedFilters);
} else setIsDateFilterModalOpen(true);
};
return (
<>
{isDateFilterModalOpen && (
<DateFilterModal
handleClose={() => setIsDateFilterModalOpen(false)}
isOpen={isDateFilterModalOpen}
onSelect={(val) => handleUpdate(val)}
title="Created date"
/>
)}
<FilterHeader
title={`Created date${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{filteredOptions.length > 0 ? (
<>
{filteredOptions.map((option) => (
<FilterOption
key={option.value}
isChecked={appliedFilters?.includes(option.value) ? true : false}
onClick={() => handleUpdate(option.value)}
title={option.name}
multiple={false}
/>
))}
<FilterOption
isChecked={isCustomDateSelected()}
onClick={handleCustomDate}
title="Custom"
multiple={false}
/>
</>
) : (
<p className="text-11 text-placeholder italic">No matches found</p>
)}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./root";

View File

@@ -0,0 +1,116 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useMemo, useState } from "react";
import { sortBy } from "lodash-es";
import { observer } from "mobx-react";
// plane ui
import { Avatar } from "@makeplane/propel/components/avatar";
import { Loader } from "@plane/ui";
// components
import { getFileURL } from "@plane/utils";
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
// helpers
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useUser } from "@/hooks/store/user";
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string) => void;
memberIds: string[] | undefined;
searchQuery: string;
};
export const FilterLead = observer(function FilterLead(props: Props) {
const { appliedFilters, handleUpdate, memberIds, searchQuery } = props;
// states
const [itemsToRender, setItemsToRender] = useState(5);
const [previewEnabled, setPreviewEnabled] = useState(true);
// store hooks
const { getUserDetails } = useMember();
const { data: currentUser } = useUser();
const appliedFiltersCount = appliedFilters?.length ?? 0;
const sortedOptions = useMemo(() => {
const filteredOptions = (memberIds || []).filter((memberId) =>
getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase())
);
return sortBy(filteredOptions, [
(memberId) => !(appliedFilters ?? []).includes(memberId),
(memberId) => memberId !== currentUser?.id,
(memberId) => getUserDetails(memberId)?.display_name.toLowerCase(),
]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery]);
const handleViewToggle = () => {
if (!sortedOptions) return;
if (itemsToRender === sortedOptions.length) setItemsToRender(5);
else setItemsToRender(sortedOptions.length);
};
return (
<>
<FilterHeader
title={`Lead${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{sortedOptions ? (
sortedOptions.length > 0 ? (
<>
{sortedOptions.slice(0, itemsToRender).map((memberId) => {
const member = getUserDetails(memberId);
if (!member) return null;
return (
<FilterOption
key={`lead-${member.id}`}
isChecked={appliedFilters?.includes(member.id) ? true : false}
onClick={() => handleUpdate(member.id)}
icon={
<Avatar
alt={member.display_name}
fallback={member.display_name?.[0]?.toUpperCase()}
src={getFileURL(member.avatar_url)}
size="xs"
/>
}
title={currentUser?.id === member.id ? "You" : member?.display_name}
/>
);
})}
{sortedOptions.length > 5 && (
<button
type="button"
className="ml-8 text-11 font-medium text-accent-primary"
onClick={handleViewToggle}
>
{itemsToRender === sortedOptions.length ? "View less" : "View all"}
</button>
)}
</>
) : (
<p className="text-11 text-placeholder italic">No matches found</p>
)
) : (
<Loader className="space-y-2">
<Loader.Item height="20px" />
<Loader.Item height="20px" />
<Loader.Item height="20px" />
</Loader>
)}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,118 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
// plane imports
import { Button } from "@plane/propel/button";
import { ChevronDownOutline } from "@makeplane/propel/icons";
import { EUserProjectRoles, EUserWorkspaceRoles } from "@plane/types";
// plane ui
import { CustomMenu } from "@plane/ui";
// components
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
interface IRoleOption {
value: string;
label: string;
}
type Props = {
appliedFilters: string[] | null;
handleUpdate: (role: string) => void;
memberType: "project" | "workspace";
};
const PROJECT_ROLE_OPTIONS: IRoleOption[] = [
{ value: String(EUserProjectRoles.ADMIN), label: "Admin" },
{ value: String(EUserProjectRoles.MEMBER), label: "Member" },
{ value: String(EUserProjectRoles.GUEST), label: "Guest" },
];
const WORKSPACE_ROLE_OPTIONS: IRoleOption[] = [
{ value: String(EUserWorkspaceRoles.ADMIN), label: "Admin" },
{ value: String(EUserWorkspaceRoles.MEMBER), label: "Member" },
{ value: String(EUserWorkspaceRoles.GUEST), label: "Guest" },
{ value: "suspended", label: "Suspended" },
];
// Role filter group component
const RoleFilterGroup = observer(function RoleFilterGroup({
appliedFilters,
handleUpdate,
memberType,
}: {
appliedFilters: string[] | null;
handleUpdate: (role: string) => void;
memberType: "project" | "workspace";
}) {
const [isExpanded, setIsExpanded] = useState(true);
const appliedFiltersCount = appliedFilters?.length ?? 0;
const roleOptions = memberType === "project" ? PROJECT_ROLE_OPTIONS : WORKSPACE_ROLE_OPTIONS;
return (
<div className="space-y-2">
<FilterHeader
title={`Roles${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={isExpanded}
handleIsPreviewEnabled={() => setIsExpanded(!isExpanded)}
/>
{isExpanded && (
<div className="space-y-1">
{roleOptions.map((role) => {
const isSelected = appliedFilters?.includes(role.value) ?? false;
return (
<FilterOption
key={`role-${role.value}`}
isChecked={isSelected}
title={role.label}
onClick={() => handleUpdate(role.value)}
/>
);
})}
</div>
)}
</div>
);
});
export const MemberListFilters = observer(function MemberListFilters(props: Props) {
const { appliedFilters, handleUpdate, memberType } = props;
return (
<div className="space-y-4">
{/* Role Filter Group */}
<RoleFilterGroup appliedFilters={appliedFilters} handleUpdate={handleUpdate} memberType={memberType} />
</div>
);
});
// Dropdown component for member list filters
export const MemberListFiltersDropdown = observer(function MemberListFiltersDropdown(props: Props) {
const { appliedFilters, handleUpdate, memberType } = props;
const appliedFiltersCount = appliedFilters?.length ?? 0;
return (
<CustomMenu
customButton={
<div className="relative">
<Button variant="secondary" size="lg" className="flex items-center gap-2">
<span>Filters</span>
<ChevronDownOutline className="h-3 w-3" />
</Button>
{appliedFiltersCount > 0 && (
<div className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-accent-primary" />
)}
</div>
}
placement="bottom-start"
>
<MemberListFilters appliedFilters={appliedFilters} handleUpdate={handleUpdate} memberType={memberType} />
</CustomMenu>
);
});

View File

@@ -0,0 +1,116 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useMemo, useState } from "react";
import { sortBy } from "lodash-es";
import { observer } from "mobx-react";
// plane ui
import { Avatar } from "@makeplane/propel/components/avatar";
import { Loader } from "@plane/ui";
// components
import { getFileURL } from "@plane/utils";
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
// helpers
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useUser } from "@/hooks/store/user";
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string) => void;
memberIds: string[] | undefined;
searchQuery: string;
};
export const FilterMembers = observer(function FilterMembers(props: Props) {
const { appliedFilters, handleUpdate, memberIds, searchQuery } = props;
// states
const [itemsToRender, setItemsToRender] = useState(5);
const [previewEnabled, setPreviewEnabled] = useState(true);
// store hooks
const { getUserDetails } = useMember();
const { data: currentUser } = useUser();
const appliedFiltersCount = appliedFilters?.length ?? 0;
const sortedOptions = useMemo(() => {
const filteredOptions = (memberIds || []).filter((memberId) =>
getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase())
);
return sortBy(filteredOptions, [
(memberId) => !(appliedFilters ?? []).includes(memberId),
(memberId) => memberId !== currentUser?.id,
(memberId) => getUserDetails(memberId)?.display_name.toLowerCase(),
]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery]);
const handleViewToggle = () => {
if (!sortedOptions) return;
if (itemsToRender === sortedOptions.length) setItemsToRender(5);
else setItemsToRender(sortedOptions.length);
};
return (
<>
<FilterHeader
title={`Members${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{sortedOptions ? (
sortedOptions.length > 0 ? (
<>
{sortedOptions.slice(0, itemsToRender).map((memberId) => {
const member = getUserDetails(memberId);
if (!member) return null;
return (
<FilterOption
key={`member-${member.id}`}
isChecked={appliedFilters?.includes(member.id) ? true : false}
onClick={() => handleUpdate(member.id)}
icon={
<Avatar
alt={member.display_name}
fallback={member.display_name?.[0]?.toUpperCase()}
src={getFileURL(member.avatar_url)}
size="xs"
/>
}
title={currentUser?.id === member.id ? "You" : member?.display_name}
/>
);
})}
{sortedOptions.length > 5 && (
<button
type="button"
className="ml-8 text-11 font-medium text-accent-primary"
onClick={handleViewToggle}
>
{itemsToRender === sortedOptions.length ? "View less" : "View all"}
</button>
)}
</>
) : (
<p className="text-11 text-placeholder italic">No matches found</p>
)
) : (
<Loader className="space-y-2">
<Loader.Item height="20px" />
<Loader.Item height="20px" />
<Loader.Item height="20px" />
</Loader>
)}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,110 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import { CloseOutline, SearchOutline } from "@makeplane/propel/icons";
// plane imports
import type { TProjectDisplayFilters, TProjectFilters } from "@plane/types";
// components
import { FilterOption } from "@/components/issues/issue-layouts/filters";
// hooks
import { usePlatformOS } from "@/hooks/use-platform-os";
// local imports
import { FilterAccess } from "./access";
import { FilterCreatedDate } from "./created-at";
import { FilterLead } from "./lead";
import { FilterMembers } from "./members";
type Props = {
displayFilters: TProjectDisplayFilters;
filters: TProjectFilters;
handleFiltersUpdate: (key: keyof TProjectFilters, value: string | string[]) => void;
handleDisplayFiltersUpdate: (updatedDisplayProperties: Partial<TProjectDisplayFilters>) => void;
memberIds?: string[] | undefined;
};
export const ProjectFiltersSelection = observer(function ProjectFiltersSelection(props: Props) {
const { displayFilters, filters, handleFiltersUpdate, handleDisplayFiltersUpdate, memberIds } = props;
// states
const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
// store
const { isMobile } = usePlatformOS();
return (
<div className="flex h-full w-full flex-col overflow-hidden">
<div className="bg-surface-1 p-2.5 pb-0">
<div className="flex items-center gap-1.5 rounded-sm border-[0.5px] border-subtle bg-surface-2 px-1.5 py-1 text-11">
<SearchOutline className="text-placeholder" width={12} height={12} />
<input
type="text"
className="w-full bg-surface-2 outline-none placeholder:text-placeholder"
placeholder="Search"
value={filtersSearchQuery}
onChange={(e) => setFiltersSearchQuery(e.target.value)}
autoFocus={!isMobile}
/>
{filtersSearchQuery !== "" && (
<button type="button" className="grid place-items-center" onClick={() => setFiltersSearchQuery("")}>
<CloseOutline className="text-tertiary" height={12} width={12} />
</button>
)}
</div>
</div>
<div className="vertical-scrollbar scrollbar-sm h-full w-full divide-y divide-subtle-1 overflow-y-auto px-2.5">
<div className="py-2">
<FilterOption
isChecked={!!displayFilters.my_projects}
onClick={() =>
handleDisplayFiltersUpdate({
my_projects: !displayFilters.my_projects,
})
}
title="My projects"
/>
</div>
{/* access */}
<div className="py-2">
<FilterAccess
appliedFilters={filters.access ?? null}
handleUpdate={(val) => handleFiltersUpdate("access", val)}
searchQuery={filtersSearchQuery}
/>
</div>
{/* lead */}
<div className="py-2">
<FilterLead
appliedFilters={filters.lead ?? null}
handleUpdate={(val) => handleFiltersUpdate("lead", val)}
searchQuery={filtersSearchQuery}
memberIds={memberIds}
/>
</div>
{/* members */}
<div className="py-2">
<FilterMembers
appliedFilters={filters.members ?? null}
handleUpdate={(val) => handleFiltersUpdate("members", val)}
searchQuery={filtersSearchQuery}
memberIds={memberIds}
/>
</div>
{/* created date */}
<div className="py-2">
<FilterCreatedDate
appliedFilters={filters.created_at ?? null}
handleUpdate={(val) => handleFiltersUpdate("created_at", val)}
searchQuery={filtersSearchQuery}
/>
</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,90 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { SortDescendingOutline, TickOutline } from "@makeplane/propel/icons";
// plane imports
import { PROJECT_ORDER_BY_OPTIONS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { getButtonStyling } from "@plane/propel/button";
import type { TProjectOrderByOptions } from "@plane/types";
import { CustomMenu } from "@plane/ui";
type Props = {
onChange: (value: TProjectOrderByOptions) => void;
value: TProjectOrderByOptions | undefined;
isMobile?: boolean;
};
const DISABLED_ORDERING_OPTIONS = ["sort_order"];
export function ProjectOrderByDropdown(props: Props) {
const { onChange, value, isMobile = false } = props;
const { t } = useTranslation();
const orderByDetails = PROJECT_ORDER_BY_OPTIONS.find((option) => value?.includes(option.key));
const isDescending = value?.[0] === "-";
const isOrderingDisabled = !!value && DISABLED_ORDERING_OPTIONS.includes(value);
return (
<CustomMenu
className={`${isMobile ? "flex w-full justify-center" : ""}`}
customButton={
<>
{isMobile ? (
<div className={getButtonStyling("secondary", "lg")}>
<SortDescendingOutline className="size-3.5 shrink-0" />
{orderByDetails && t(orderByDetails?.i18n_label)}
</div>
) : (
<div className={getButtonStyling("secondary", "lg")}>
<SortDescendingOutline className="size-3.5 shrink-0" />
{orderByDetails && t(orderByDetails?.i18n_label)}
</div>
)}
</>
}
placement="bottom-end"
closeOnSelect
>
{PROJECT_ORDER_BY_OPTIONS.map((option) => (
<CustomMenu.MenuItem
key={option.key}
className="flex items-center justify-between gap-2"
onClick={() => {
if (isDescending)
onChange(option.key == "sort_order" ? option.key : (`-${option.key}` as TProjectOrderByOptions));
else onChange(option.key);
}}
>
{option && t(option?.i18n_label)}
{value?.includes(option.key) && <TickOutline className="h-3 w-3" />}
</CustomMenu.MenuItem>
))}
<hr className="my-2 border-subtle" />
<CustomMenu.MenuItem
className="flex items-center justify-between gap-2"
onClick={() => {
if (isDescending) onChange(value.slice(1) as TProjectOrderByOptions);
}}
disabled={isOrderingDisabled}
>
Ascending
{!isOrderingDisabled && !isDescending && <TickOutline className="h-3 w-3" />}
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
className="flex items-center justify-between gap-2"
onClick={() => {
if (!isDescending) onChange(`-${value}` as TProjectOrderByOptions);
}}
disabled={isOrderingDisabled}
>
Descending
{!isOrderingDisabled && isDescending && <TickOutline className="h-3 w-3" />}
</CustomMenu.MenuItem>
</CustomMenu>
);
}

View File

@@ -0,0 +1,50 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
// ui
import { Button } from "@plane/propel/button";
type Props = {
title: string;
description?: React.ReactNode;
image: any;
primaryButton?: {
icon?: any;
text: string;
onClick: () => void;
};
secondaryButton?: React.ReactNode;
disabled?: boolean;
};
export function EmptyState({ title, description, image, primaryButton, secondaryButton, disabled = false }: Props) {
return (
<div className="flex h-full w-full items-center justify-center px-5 md:px-10 lg:p-20">
<div className="relative h-full w-full max-w-6xl">
<img src={image} className="w-52 object-cover sm:w-60" alt={primaryButton?.text ?? ""} />
</div>
<div className="absolute flex w-full flex-col items-center pt-[30vh] text-center md:pt-[35vh] lg:pt-[45vh]">
<h6 className="mt-6 text-18 font-semibold">{title}</h6>
{description && <p className="mb-7 text-tertiary">{description}</p>}
<div className="flex items-center gap-4">
{primaryButton && (
<Button
size="xl"
variant="primary"
prependIcon={primaryButton.icon}
onClick={primaryButton.onClick}
disabled={disabled}
>
{primaryButton.text}
</Button>
)}
{secondaryButton}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,109 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { FilterOutline } from "@makeplane/propel/icons";
// plane imports
import { useTranslation } from "@plane/i18n";
import type { TProjectFilters } from "@plane/types";
import { cn, calculateTotalFilters } from "@plane/utils";
// components
import { FiltersDropdown } from "@/components/issues/issue-layouts/filters";
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useProjectFilter } from "@/hooks/store/use-project-filter";
// local imports
import { ProjectFiltersSelection } from "./dropdowns/filters";
import { ProjectOrderByDropdown } from "./dropdowns/order-by";
type Props = {
filterMenuButton?: React.ReactNode;
classname?: string;
filterClassname?: string;
isMobile?: boolean;
};
const HeaderFilters = observer(function HeaderFilters({
filterMenuButton,
isMobile,
classname = "",
filterClassname = "",
}: Props) {
// i18n
const { t } = useTranslation();
// router
const { workspaceSlug } = useParams();
const {
currentWorkspaceDisplayFilters: displayFilters,
currentWorkspaceFilters: filters,
updateFilters,
updateDisplayFilters,
} = useProjectFilter();
const {
workspace: { workspaceMemberIds },
} = useMember();
const handleFilters = useCallback(
(key: keyof TProjectFilters, value: string | string[]) => {
if (!workspaceSlug) return;
let newValues = filters?.[key] ?? [];
if (Array.isArray(value)) {
if (key === "created_at" && newValues.find((v) => v.includes("custom"))) newValues = [];
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
else newValues.splice(newValues.indexOf(val), 1);
});
} else {
if (filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else {
if (key === "created_at") newValues = [value];
else newValues.push(value);
}
}
updateFilters(workspaceSlug.toString(), { [key]: newValues });
},
[filters, updateFilters, workspaceSlug]
);
const isFiltersApplied = calculateTotalFilters(filters ?? {}) !== 0;
return (
<div className={cn("flex gap-3", classname)}>
<ProjectOrderByDropdown
value={displayFilters?.order_by}
onChange={(val) => {
if (!workspaceSlug || val === displayFilters?.order_by) return;
updateDisplayFilters(workspaceSlug.toString(), {
order_by: val,
});
}}
isMobile={isMobile}
/>
<div className={cn(filterClassname)}>
<FiltersDropdown
icon={<FilterOutline className="h-3 w-3" />}
title={t("common.filters")}
placement="bottom-end"
isFiltersApplied={isFiltersApplied}
menuButton={filterMenuButton || null}
>
<ProjectFiltersSelection
displayFilters={displayFilters ?? {}}
filters={filters ?? {}}
handleFiltersUpdate={handleFilters}
handleDisplayFiltersUpdate={(val) => {
if (!workspaceSlug) return;
updateDisplayFilters(workspaceSlug.toString(), val);
}}
memberIds={workspaceMemberIds ?? undefined}
/>
</FiltersDropdown>
</div>
</div>
);
});
export default HeaderFilters;

View File

@@ -0,0 +1,67 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// components
import { Loader } from "@plane/ui";
export function ProjectDetailsFormLoader() {
return (
<>
<div className="relative mt-6 h-44 w-full">
<Loader>
<Loader.Item height="auto" width="46px" />
</Loader>
<div className="absolute bottom-4 flex w-full items-end justify-between gap-3 px-4">
<div className="flex flex-grow gap-3 truncate">
<div className="flex h-[52px] w-[52px] flex-shrink-0 items-center justify-center rounded-lg bg-surface-2">
<Loader>
<Loader.Item height="46px" width="46px" />
</Loader>
</div>
</div>
<div className="flex flex-shrink-0 justify-center">
<Loader>
<Loader.Item height="32px" width="108px" />
</Loader>
</div>
</div>
</div>
<div className="my-8 flex flex-col gap-8">
<div className="flex flex-col gap-1">
<h4 className="text-13">Project name</h4>
<Loader>
<Loader.Item height="46px" width="100%" />
</Loader>
</div>
<div className="flex flex-col gap-1">
<h4 className="text-13">Description</h4>
<Loader className="w-full">
<Loader.Item height="102px" width="full" />
</Loader>
</div>
<div className="flex w-full items-center justify-between gap-10">
<div className="flex w-1/2 flex-col gap-1">
<h4 className="text-13">Identifier</h4>
<Loader>
<Loader.Item height="36px" width="100%" />
</Loader>
</div>
<div className="flex w-1/2 flex-col gap-1">
<h4 className="text-13">Network</h4>
<Loader className="w-full">
<Loader.Item height="46px" width="100%" />
</Loader>
</div>
</div>
<div className="flex items-center justify-between py-2">
<Loader className="mt-2 w-full">
<Loader.Item height="34px" width="100px" />
</Loader>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,466 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { InfoOutline, LockOutline } from "@makeplane/propel/icons";
import { Field } from "@makeplane/propel/components/field";
import { Input, InputGroup } from "@makeplane/propel/components/input";
import { TextArea, TextAreaGroup } from "@makeplane/propel/components/text-area";
import { NETWORK_CHOICES } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// plane imports
import { Button } from "@plane/propel/button";
import { EmojiPicker, EmojiIconPickerTypes, Logo } from "@plane/propel/emoji-icon-picker";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@makeplane/propel/components/tooltip";
import { EFileAssetType } from "@plane/types";
import type { IProject, IWorkspace } from "@plane/types";
import { CustomSelect } from "@plane/ui";
import { renderFormattedDate } from "@plane/utils";
import { CoverImage } from "@/components/common/cover-image";
import { ImagePickerPopover } from "@/components/core/image-picker-popover";
import { TimezoneSelect } from "@/components/global";
// helpers
import { handleCoverImageChange } from "@/helpers/cover-image.helper";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { usePlatformOS } from "@/hooks/use-platform-os";
// services
import { ProjectService } from "@/services/project";
// local imports
import { ProjectNetworkIcon } from "./project-network-icon";
export interface IProjectDetailsForm {
project: IProject;
workspaceSlug: string;
projectId: string;
isAdmin: boolean;
}
const projectService = new ProjectService();
export function ProjectDetailsForm(props: IProjectDetailsForm) {
const { project, workspaceSlug, projectId, isAdmin } = props;
const { t } = useTranslation();
// states
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
// store hooks
const { updateProject } = useProject();
const { isMobile } = usePlatformOS();
// form info
const {
handleSubmit,
watch,
control,
setValue,
setError,
reset,
formState: { errors },
getValues,
} = useForm<IProject>({
defaultValues: {
...project,
workspace: (project.workspace as IWorkspace).id,
},
});
// derived values
const currentNetwork = NETWORK_CHOICES.find((n) => n.key === project?.network);
const coverImage = watch("cover_image_url");
useEffect(() => {
if (project && projectId !== getValues("id")) {
reset({
...project,
workspace: (project.workspace as IWorkspace).id,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [project, projectId]);
// handlers
const handleIdentifierChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
const alphanumericValue = value.replace(/[^a-zA-Z0-9]/g, "");
const formattedValue = alphanumericValue.toUpperCase();
setValue("identifier", formattedValue);
};
const handleUpdateChange = async (payload: Partial<IProject>) => {
if (!workspaceSlug || !project) return;
return updateProject(workspaceSlug.toString(), project.id, payload)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("toast.success"),
message: t("project_settings.general.toast.success"),
});
})
.catch((err) => {
try {
// Handle the new error format where codes are nested in arrays under field names
const errorData = err ?? {};
const nameError = errorData.name?.includes("PROJECT_NAME_ALREADY_EXIST");
const identifierError = errorData?.identifier?.includes("PROJECT_IDENTIFIER_ALREADY_EXIST");
const nameSpecialCharError = errorData?.name?.includes("PROJECT_NAME_CANNOT_CONTAIN_SPECIAL_CHARACTERS");
if (nameError || identifierError || nameSpecialCharError) {
if (nameError) {
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("project_name_already_taken"),
});
}
if (identifierError) {
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("project_identifier_already_taken"),
});
}
if (nameSpecialCharError) {
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("project_name_cannot_contain_special_characters"),
});
}
} else {
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("something_went_wrong"),
});
}
} catch (error) {
// Fallback error handling if the error processing fails
console.error("Error processing API error:", error);
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("something_went_wrong"),
});
}
});
};
const onSubmit = async (formData: IProject) => {
if (!workspaceSlug) return;
setIsLoading(true);
const payload: Partial<IProject> = {
name: formData.name,
network: formData.network,
identifier: formData.identifier,
description: formData.description,
logo_props: formData.logo_props,
timezone: formData.timezone,
};
// Handle cover image changes
try {
const coverImagePayload = await handleCoverImageChange(project.cover_image_url, formData.cover_image_url, {
workspaceSlug: workspaceSlug.toString(),
entityIdentifier: project.id,
entityType: EFileAssetType.PROJECT_COVER,
isUserAsset: false,
});
if (coverImagePayload) {
Object.assign(payload, coverImagePayload);
}
} catch (error) {
console.error("Error handling cover image:", error);
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: error instanceof Error ? error.message : "Failed to process cover image",
});
setIsLoading(false);
return;
}
if (project.identifier !== formData.identifier)
await projectService
.checkProjectIdentifierAvailability(workspaceSlug, payload.identifier ?? "")
.then(async (res) => {
if (res.exists) setError("identifier", { message: t("common.identifier_already_exists") });
else await handleUpdateChange(payload);
});
else await handleUpdateChange(payload);
setTimeout(() => {
setIsLoading(false);
}, 300);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="relative h-44 w-full">
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
<CoverImage src={coverImage} alt="Project cover image" className="h-44 w-full rounded-md" />
<div className="absolute bottom-4 z-5 flex w-full items-end justify-between gap-3 px-4">
<div className="flex flex-grow gap-3 truncate">
<Controller
control={control}
name="logo_props"
render={({ field: { value, onChange } }) => (
<EmojiPicker
iconType="material"
closeOnSelect={false}
isOpen={isOpen}
handleToggle={(val: boolean) => setIsOpen(val)}
className="flex items-center justify-center"
buttonClassName="flex h-[52px] w-[52px] flex-shrink-0 items-center justify-center rounded-lg bg-white/10"
label={<Logo logo={value} size={28} />}
// TODO: fix types
onChange={(val: any) => {
let logoValue = {};
if (val?.type === "emoji")
logoValue = {
value: val.value,
};
else if (val?.type === "icon") logoValue = val.value;
onChange({
in_use: val?.type,
[val?.type]: logoValue,
});
setIsOpen(false);
}}
defaultIconColor={value?.in_use && value.in_use === "icon" ? value?.icon?.color : undefined}
defaultOpen={
value.in_use && value.in_use === "emoji" ? EmojiIconPickerTypes.EMOJI : EmojiIconPickerTypes.ICON
}
disabled={!isAdmin}
/>
)}
/>
<div className="flex flex-col gap-1 truncate text-on-color">
<span className="truncate text-16 font-semibold">{watch("name")}</span>
<span className="flex items-center gap-2 text-13">
<span>{watch("identifier")} .</span>
<span className="flex items-center gap-1.5">
{project.network === 0 && <LockOutline className="h-2.5 w-2.5 text-on-color" />}
{currentNetwork && t(currentNetwork?.i18n_label)}
</span>
</span>
</div>
</div>
<div className="flex flex-shrink-0 justify-center">
<div>
<Controller
control={control}
name="cover_image_url"
render={({ field: { value, onChange } }) => (
<ImagePickerPopover
label={t("change_cover")}
control={control}
onChange={onChange}
value={value ?? null}
disabled={!isAdmin}
projectId={project.id}
/>
)}
/>
</div>
</div>
</div>
</div>
<div className="mt-8 flex flex-col gap-8">
<div className="flex flex-col gap-1">
<h4 className="text-13">{t("common.project_name")}</h4>
<Controller
control={control}
name="name"
rules={{
required: t("name_is_required"),
maxLength: {
value: 255,
message: "Project name should be less than 255 characters",
},
}}
render={({ field: { value, onChange, ref } }) => (
<Field name="name" invalid={Boolean(errors.name)}>
<InputGroup size="2xl">
<Input
size="2xl"
id="name"
name="name"
type="text"
ref={ref}
value={value}
onChange={onChange}
placeholder={t("common.project_name")}
disabled={!isAdmin}
/>
</InputGroup>
</Field>
)}
/>
<span className="text-11 text-danger-primary">{errors?.name?.message}</span>
</div>
<div className="flex flex-col gap-1">
<h4 className="text-13">{t("description")}</h4>
<Controller
name="description"
control={control}
render={({ field: { value, onChange } }) => (
<Field name="description" invalid={Boolean(errors?.description)} disabled={!isAdmin}>
<TextAreaGroup resize="none">
<TextArea
size="lg"
surface="field"
autoResize
maxRows={8}
id="description"
name="description"
value={value}
placeholder={t("project_description_placeholder")}
onChange={onChange}
/>
</TextAreaGroup>
</Field>
)}
/>
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="flex flex-col gap-1">
<h4 className="text-13">Project ID</h4>
<div className="relative">
<Controller
control={control}
name="identifier"
rules={{
required: t("project_id_is_required"),
validate: (value) => /^[ÇŞĞIİÖÜA-Z0-9]+$/.test(value.toUpperCase()) || t("project_id_allowed_char"),
minLength: {
value: 1,
message: t("project_id_min_char"),
},
maxLength: {
value: 10,
message: t("project_id_max_char"),
},
}}
render={({ field: { value, ref } }) => (
<Field name="identifier" invalid={Boolean(errors.identifier)}>
<InputGroup size="2xl">
<Input
size="2xl"
id="identifier"
name="identifier"
type="text"
value={value}
onChange={handleIdentifierChange}
ref={ref}
placeholder={t("project_settings.general.enter_project_id")}
disabled={!isAdmin}
/>
</InputGroup>
</Field>
)}
/>
<Tooltip
label={t("project_id_tooltip_content")}
layout="stacked"
side="right"
align="start"
disabled={isMobile}
>
<InfoOutline className="absolute top-2.5 right-2 h-4 w-4 text-placeholder" />
</Tooltip>
</div>
<span className="text-11 text-danger-primary">
<>{errors?.identifier?.message}</>
</span>
</div>
<div className="flex flex-col gap-1">
<h4 className="text-13">{t("workspace_projects.network.label")}</h4>
<Controller
name="network"
control={control}
render={({ field: { value, onChange } }) => {
const selectedNetwork = NETWORK_CHOICES.find((n) => n.key === value);
return (
<CustomSelect
value={value}
onChange={onChange}
label={
<div className="flex items-center gap-1">
{selectedNetwork ? (
<>
<ProjectNetworkIcon iconKey={selectedNetwork.iconKey} className="h-3.5 w-3.5" />
{t(selectedNetwork.i18n_label)}
</>
) : (
<span className="text-placeholder">{t("select_network")}</span>
)}
</div>
}
buttonClassName="!border-subtle !shadow-none font-medium rounded-md"
input
disabled={!isAdmin}
// optionsClassName="w-full"
>
{NETWORK_CHOICES.map((network) => (
<CustomSelect.Option key={network.key} value={network.key}>
<div className="flex items-start gap-2">
<ProjectNetworkIcon iconKey={network.iconKey} className="h-3.5 w-3.5" />
<div className="-mt-1">
<p>{t(network.i18n_label)}</p>
<p className="text-11 text-placeholder">{t(network.description)}</p>
</div>
</div>
</CustomSelect.Option>
))}
</CustomSelect>
);
}}
/>
</div>
<div className="col-span-1 flex flex-col gap-1 sm:col-span-2 xl:col-span-1">
<h4 className="text-13">{t("common.project_timezone")}</h4>
<Controller
name="timezone"
control={control}
rules={{ required: t("project_settings.general.please_select_a_timezone") }}
render={({ field: { value, onChange } }) => (
<>
<TimezoneSelect
value={value}
onChange={(value: string) => {
onChange(value);
}}
error={Boolean(errors.timezone)}
buttonClassName="!border-subtle !shadow-none font-medium rounded-md"
disabled={!isAdmin}
/>
</>
)}
/>
{errors.timezone && <span className="text-11 text-danger-primary">{errors.timezone.message}</span>}
</div>
</div>
<div className="flex items-center justify-between py-2">
<>
<Button variant="primary" size="lg" type="submit" loading={isLoading} disabled={!isAdmin}>
{isLoading ? t("updating") : t("common.update_project")}
</Button>
<span className="text-13 text-placeholder italic">
{t("common.created_on")} {renderFormattedDate(project?.created_at)}
</span>
</>
</div>
</div>
</form>
);
}

View File

@@ -0,0 +1,79 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
// i18n
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// ui
import { Button } from "@plane/propel/button";
import { ProjectsOutline } from "@makeplane/propel/icons";
import { Breadcrumbs, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useUserPermissions } from "@/hooks/store/user";
// plane web constants
// components
import HeaderFilters from "./filters";
import { ProjectSearch } from "./search-projects";
export const ProjectsBaseHeader = observer(function ProjectsBaseHeader() {
// i18n
const { t } = useTranslation();
// store hooks
const { toggleCreateProjectModal } = useCommandPalette();
const { allowPermissions } = useUserPermissions();
const pathname = usePathname();
// auth
const isAuthorizedUser = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
const isArchived = pathname.includes("/archives");
return (
<Header>
<Header.LeftItem>
<Breadcrumbs>
<Breadcrumbs.Item
component={
<BreadcrumbLink
label={t("workspace_projects.label", { count: 2 })}
icon={<ProjectsOutline className="h-4 w-4 text-tertiary" />}
/>
}
/>
{isArchived && <Breadcrumbs.Item component={<BreadcrumbLink label="Archived" />} />}
</Breadcrumbs>
</Header.LeftItem>
<Header.RightItem>
<ProjectSearch />
<div className="hidden md:flex">
<HeaderFilters />
</div>
{isAuthorizedUser && !isArchived ? (
<Button
variant="primary"
size="lg"
onClick={() => {
toggleCreateProjectModal(true);
}}
className="items-center gap-1"
>
<span className="hidden sm:inline-block">{t("workspace_projects.create.label")}</span>
<span className="inline-block sm:hidden">{t("workspace_projects.label", { count: 1 })}</span>
</Button>
) : (
<></>
)}
</Header.RightItem>
</Header>
);
});

View File

@@ -0,0 +1,125 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useParams } from "next/navigation";
import useSWR, { mutate } from "swr";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IWorkspaceIntegration } from "@plane/types";
// assets
import GithubLogo from "@/app/assets/logos/github-square.png?url";
import SlackLogo from "@/app/assets/services/slack.png?url";
// components
import { SelectChannel } from "@/components/integration/slack/select-channel";
import { SelectRepository } from "@/components/integration/github/select-repository";
// constants
import { PROJECT_GITHUB_REPOSITORY } from "@plane/constants";
// services
import { ProjectService } from "@/services/project";
type Props = {
integration: IWorkspaceIntegration;
};
const integrationDetails: { [key: string]: any } = {
github: {
logo: GithubLogo,
description: "Select GitHub repository to enable sync.",
},
slack: {
logo: SlackLogo,
description: "Get regular updates and control which notification you want to receive.",
},
};
// services
const projectService = new ProjectService();
export function IntegrationCard({ integration }: Props) {
const { workspaceSlug, projectId } = useParams();
const { data: syncedGithubRepository } = useSWR(projectId ? PROJECT_GITHUB_REPOSITORY(projectId) : null, () =>
workspaceSlug && projectId && integration
? projectService.getProjectGithubRepository(workspaceSlug, projectId, integration.id)
: null
);
const handleChange = (repo: any) => {
if (!workspaceSlug || !projectId || !integration) return;
const {
html_url,
owner: { login },
id,
name,
} = repo;
projectService
.syncGithubRepository(workspaceSlug, projectId, integration.id, {
name,
owner: login,
repository_id: id,
url: html_url,
})
.then(() => {
mutate(PROJECT_GITHUB_REPOSITORY(projectId));
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: `${login}/${name} repository synced with the project successfully.`,
});
})
.catch((err) => {
console.error(err);
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Repository could not be synced with the project. Please try again.",
});
});
};
return (
<>
{integration && (
<div className="flex items-center justify-between gap-2 border-b border-subtle bg-surface-1 px-4 py-6">
<div className="flex items-start gap-4">
<div className="h-10 w-10 flex-shrink-0">
<img
src={integrationDetails[integration.integration_detail.provider].logo}
className="h-full w-full object-cover"
alt={`${integration.integration_detail.title} Logo`}
/>
</div>
<div>
<h3 className="flex items-center gap-4 text-13 font-medium">{integration.integration_detail.title}</h3>
<p className="text-13 tracking-tight text-secondary">
{integrationDetails[integration.integration_detail.provider].description}
</p>
</div>
</div>
{integration.integration_detail.provider === "github" && (
<SelectRepository
integration={integration}
value={
syncedGithubRepository && syncedGithubRepository.length > 0
? `${syncedGithubRepository[0].repo_detail.owner}/${syncedGithubRepository[0].repo_detail.name}`
: null
}
label={
syncedGithubRepository && syncedGithubRepository.length > 0
? `${syncedGithubRepository[0].repo_detail.owner}/${syncedGithubRepository[0].repo_detail.name}`
: "Select Repository"
}
onChange={handleChange}
/>
)}
{integration.integration_detail.provider === "slack" && <SelectChannel integration={integration} />}
</div>
)}
</>
);
}

View File

@@ -0,0 +1,71 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
// types
import { Button } from "@plane/propel/button";
import type { IProject } from "@plane/types";
// ui
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// hooks
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
// type
type TJoinProjectModalProps = {
isOpen: boolean;
workspaceSlug: string;
project: IProject;
handleClose: () => void;
};
export function JoinProjectModal(props: TJoinProjectModalProps) {
const { handleClose, isOpen, project, workspaceSlug } = props;
// states
const [isJoiningLoading, setIsJoiningLoading] = useState(false);
// store hooks
const { joinProject } = useUserPermissions();
// router
const router = useAppRouter();
const handleJoin = async () => {
setIsJoiningLoading(true);
await joinProject(workspaceSlug, project.id)
.then(() => {
router.push(`/${workspaceSlug}/projects/${project.id}/issues`);
handleClose();
return;
})
.catch(() => {
console.error("Error joining project");
})
.finally(() => {
setIsJoiningLoading(false);
});
};
return (
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.XL}>
<div className="space-y-5 px-5 py-8 sm:p-6">
<h3 className="text-16 leading-6 font-medium text-primary">Join Project?</h3>
<p>
Are you sure you want to join the project <span className="font-semibold break-words">{project?.name}</span>?
Please click the &apos;Join Project&apos; button below to continue.
</p>
<div className="space-y-3" />
</div>
<div className="mt-5 flex justify-end gap-2 px-5 pb-8 sm:px-6 sm:pb-6">
<Button variant="secondary" size="lg" onClick={handleClose}>
Cancel
</Button>
<Button variant="primary" size="lg" tabIndex={1} type="submit" onClick={handleJoin} loading={isJoiningLoading}>
{isJoiningLoading ? "Joining..." : "Join Project"}
</Button>
</div>
</ModalCore>
);
}

View File

@@ -0,0 +1,184 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Controller, useForm } from "react-hook-form";
import { WarningTriangleOutline } from "@makeplane/propel/icons";
// Plane imports
import { Field } from "@makeplane/propel/components/field";
import { Input, InputGroup } from "@makeplane/propel/components/input";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IProject } from "@plane/types";
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// hooks
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
type FormData = {
projectName: string;
confirmLeave: string;
};
const defaultValues: FormData = {
projectName: "",
confirmLeave: "",
};
export interface ILeaveProjectModal {
project: IProject;
isOpen: boolean;
onClose: () => void;
}
export const LeaveProjectModal = observer(function LeaveProjectModal(props: ILeaveProjectModal) {
const { project, isOpen, onClose } = props;
// router
const router = useAppRouter();
const { workspaceSlug } = useParams();
// store hooks
const { leaveProject } = useUserPermissions();
const {
control,
formState: { errors, isSubmitting },
handleSubmit,
reset,
} = useForm({ defaultValues });
const handleClose = () => {
reset({ ...defaultValues });
onClose();
};
const onSubmit = async (data: any) => {
if (!workspaceSlug) return;
if (data) {
if (data.projectName === project?.name) {
if (data.confirmLeave === "Leave Project") {
router.push(`/${workspaceSlug}/projects`);
return leaveProject(workspaceSlug.toString(), project.id)
.then(() => {
handleClose();
})
.catch((_err) => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Something went wrong please try again later.",
});
});
} else {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Please confirm leaving the project by typing the 'Leave Project'.",
});
}
} else {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Please enter the project name as shown in the description.",
});
}
} else {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Please fill all fields.",
});
}
};
return (
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.XXL}>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-6 p-6">
<div className="flex w-full items-center justify-start gap-6">
<span className="place-items-center rounded-full bg-danger-subtle p-4">
<WarningTriangleOutline className="h-6 w-6 text-danger-primary" aria-hidden="true" />
</span>
<span className="flex items-center justify-start">
<h3 className="text-18 font-medium 2xl:text-20">Leave Project</h3>
</span>
</div>
<span>
<p className="text-13 leading-7 text-secondary">
Are you sure you want to leave the project -
<span className="font-medium text-primary">{` "${project?.name}" `}</span>? All of the work items associated
with you will become inaccessible.
</p>
</span>
<div className="text-secondary">
<p className="text-13 break-words">
Enter the project name <span className="font-medium text-primary">{project?.name}</span> to continue:
</p>
<Controller
control={control}
name="projectName"
rules={{
required: "Label title is required",
}}
render={({ field: { value, onChange, ref } }) => (
<Field name="projectName" invalid={Boolean(errors.projectName)}>
<InputGroup size="2xl">
<Input
size="2xl"
id="projectName"
name="projectName"
type="text"
value={value}
onChange={onChange}
ref={ref}
placeholder="Enter project name"
/>
</InputGroup>
</Field>
)}
/>
</div>
<div className="text-secondary">
<p className="text-13">
To confirm, type <span className="font-medium text-primary">Leave Project</span> below:
</p>
<Controller
control={control}
name="confirmLeave"
render={({ field: { value, onChange, ref } }) => (
<Field name="confirmLeave" invalid={Boolean(errors.confirmLeave)}>
<InputGroup size="2xl">
<Input
size="2xl"
id="confirmLeave"
name="confirmLeave"
type="text"
value={value}
onChange={onChange}
ref={ref}
placeholder="Enter 'leave project'"
/>
</InputGroup>
</Field>
)}
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" size="lg" onClick={handleClose}>
Cancel
</Button>
<Button variant="error-fill" size="lg" type="submit" loading={isSubmitting}>
{isSubmitting ? "Leaving..." : "Leave Project"}
</Button>
</div>
</form>
</ModalCore>
);
});

View File

@@ -0,0 +1,128 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// ui
import { observer } from "mobx-react";
import { Eraser } from "lucide-react";
import {
ArrowNarrowRightOutline,
ChevronDownOutline,
SortAscendingOutline,
SortDescendingOutline,
TickOutline,
} from "@makeplane/propel/icons";
// constants
import type { IProjectMemberDisplayProperties, TMemberOrderByOptions } from "@plane/constants";
import { MEMBER_PROPERTY_DETAILS } from "@plane/constants";
// i18n
import { useTranslation } from "@plane/i18n";
// types
import { CustomMenu } from "@plane/ui";
import type { IMemberFilters } from "@/store/member/utils";
interface Props {
property: keyof IProjectMemberDisplayProperties;
displayFilters?: IMemberFilters;
handleDisplayFilterUpdate: (data: Partial<IMemberFilters>) => void;
}
export const MemberHeaderColumn = observer(function MemberHeaderColumn(props: Props) {
const { displayFilters, handleDisplayFilterUpdate, property } = props;
// i18n
const { t } = useTranslation();
const propertyDetails = MEMBER_PROPERTY_DETAILS[property];
const activeSortingProperty = displayFilters?.order_by;
const handleOrderBy = (order: TMemberOrderByOptions, _itemKey: keyof IProjectMemberDisplayProperties) => {
handleDisplayFilterUpdate({ order_by: order });
};
const handleClearSorting = () => {
handleDisplayFilterUpdate({ order_by: undefined });
};
if (!propertyDetails) return null;
return (
<CustomMenu
customButtonClassName="clickable !w-full"
customButtonTabIndex={-1}
className="!w-full"
customButton={
<div className="flex w-full cursor-pointer items-center justify-between gap-1.5 py-2 text-13 text-secondary hover:text-primary">
<span>{t(propertyDetails.i18n_title)}</span>
<div className="ml-3 flex">
{(activeSortingProperty === propertyDetails.ascendingOrderKey ||
activeSortingProperty === propertyDetails.descendingOrderKey) && (
<div className="flex h-3.5 w-3.5 items-center justify-center rounded-full">
{propertyDetails.ascendingOrderKey === activeSortingProperty ? (
<SortDescendingOutline className="h-3 w-3" />
) : (
<SortAscendingOutline className="h-3 w-3" />
)}
</div>
)}
<ChevronDownOutline className="h-3 w-3" aria-hidden="true" />
</div>
</div>
}
placement="bottom-end"
closeOnSelect
>
{propertyDetails.isSortingAllowed && (
<>
<CustomMenu.MenuItem onClick={() => handleOrderBy(propertyDetails.ascendingOrderKey, property)}>
<div
className={`flex items-center justify-between gap-1.5 px-1 ${
activeSortingProperty === propertyDetails.ascendingOrderKey
? "text-primary"
: "text-secondary hover:text-primary"
}`}
>
<div className="flex items-center gap-2">
<SortDescendingOutline className="h-3 w-3 stroke-[1.5]" />
<span>{propertyDetails.ascendingOrderTitle}</span>
<ArrowNarrowRightOutline className="h-3 w-3" />
<span>{propertyDetails.descendingOrderTitle}</span>
</div>
{activeSortingProperty === propertyDetails.ascendingOrderKey && <TickOutline className="h-3 w-3" />}
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => handleOrderBy(propertyDetails.descendingOrderKey, property)}>
<div
className={`flex items-center justify-between gap-1.5 px-1 ${
activeSortingProperty === propertyDetails.descendingOrderKey
? "text-primary"
: "text-secondary hover:text-primary"
}`}
>
<div className="flex items-center gap-2">
<SortAscendingOutline className="h-3 w-3 stroke-[1.5]" />
<span>{propertyDetails.descendingOrderTitle}</span>
<ArrowNarrowRightOutline className="h-3 w-3" />
<span>{propertyDetails.ascendingOrderTitle}</span>
</div>
{activeSortingProperty === propertyDetails.descendingOrderKey && <TickOutline className="h-3 w-3" />}
</div>
</CustomMenu.MenuItem>
{(activeSortingProperty === propertyDetails.ascendingOrderKey ||
activeSortingProperty === propertyDetails.descendingOrderKey) && (
<CustomMenu.MenuItem className="mt-0.5" key={property} onClick={handleClearSorting}>
<div className="flex items-center gap-2 px-1">
<Eraser className="h-3 w-3" />
<span>{t("common.actions.clear_sorting")}</span>
</div>
</CustomMenu.MenuItem>
)}
</>
)}
</CustomMenu>
);
});

View File

@@ -0,0 +1,93 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// plane imports
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Table } from "@plane/ui";
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useUser, useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
// plane web imports
import { useProjectColumns } from "@/components/projects/settings/useProjectColumns";
// store
import type { IProjectMemberDetails } from "@/store/member/project/base-project-member.store";
// local imports
import { ConfirmProjectMemberRemove } from "./confirm-project-member-remove";
type Props = {
memberDetails: (IProjectMemberDetails | null)[];
projectId: string;
workspaceSlug: string;
};
export const ProjectMemberListItem = observer(function ProjectMemberListItem(props: Props) {
const { memberDetails, projectId, workspaceSlug } = props;
// router
const router = useAppRouter();
// store hooks
const { leaveProject } = useUserPermissions();
const { data: currentUser } = useUser();
const {
project: { removeMemberFromProject },
} = useMember();
// helper hooks
const { columns, removeMemberModal, setRemoveMemberModal } = useProjectColumns({
projectId,
workspaceSlug,
});
const handleRemove = async (memberId: string) => {
if (!workspaceSlug || !projectId || !memberId) return;
if (memberId === currentUser?.id) {
await leaveProject(workspaceSlug.toString(), projectId.toString())
// oxlint-disable-next-line promise/always-return
.then(async () => {
router.push(`/${workspaceSlug}/projects`);
})
.catch((err) => {
setToast({
type: TOAST_TYPE.ERROR,
title: "You can’t leave this project yet.",
message: err?.error || "Something went wrong. Please try again.",
});
});
} else
await removeMemberFromProject(workspaceSlug.toString(), projectId.toString(), memberId).catch((err) =>
setToast({
type: TOAST_TYPE.ERROR,
title: "You can't remove the member from this project yet.",
message: err?.error || "Something went wrong. Please try again.",
})
);
};
if (!memberDetails) return null;
return (
<>
{removeMemberModal && (
<ConfirmProjectMemberRemove
isOpen={removeMemberModal !== null}
onClose={() => setRemoveMemberModal(null)}
data={{ id: removeMemberModal.member.id, display_name: removeMemberModal.member.display_name || "" }}
onSubmit={() => handleRemove(removeMemberModal.member.id)}
/>
)}
<Table
columns={columns}
data={(memberDetails?.filter((member): member is IProjectMemberDetails => member !== null) ?? []) as any}
keyExtractor={(rowData) => rowData?.member.id ?? ""}
tHeadClassName="border-b border-subtle"
thClassName="text-left font-medium divide-x-0 text-placeholder"
tBodyClassName="divide-y-0"
tBodyTrClassName="divide-x-0 p-4 h-[40px] text-secondary"
tHeadTrClassName="divide-x-0"
/>
</>
);
});

View File

@@ -0,0 +1,132 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { SearchOutline } from "@makeplane/propel/icons";
// components
import { MembersSettingsLoader } from "@/components/ui/loader/settings/members";
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useUserPermissions } from "@/hooks/store/user";
// local imports
import { MemberListFiltersDropdown } from "./dropdowns/filters/member-list";
import { ProjectMemberListItem } from "./member-list-item";
import { SendProjectInvitationModal } from "./send-project-invitation-modal";
type TProjectMemberListProps = {
projectId: string;
workspaceSlug: string;
};
export const ProjectMemberList = observer(function ProjectMemberList(props: TProjectMemberListProps) {
const { projectId, workspaceSlug } = props;
// states
const [inviteModal, setInviteModal] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const {
project: { projectMemberIds, getFilteredProjectMemberDetails, filters },
} = useMember();
const { allowPermissions } = useUserPermissions();
const { t } = useTranslation();
const searchedProjectMembers = (projectMemberIds ?? []).filter((userId) => {
const memberDetails = projectId ? getFilteredProjectMemberDetails(userId, projectId.toString()) : null;
if (!memberDetails?.member || !memberDetails.original_role) return false;
const fullName = `${memberDetails?.member.first_name} ${memberDetails?.member.last_name}`.toLowerCase();
const displayName = memberDetails?.member.display_name.toLowerCase();
return displayName?.includes(searchQuery.toLowerCase()) || fullName.includes(searchQuery.toLowerCase());
});
const memberDetails = searchedProjectMembers?.map((memberId) =>
projectId ? getFilteredProjectMemberDetails(memberId, projectId.toString()) : null
);
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
// Handler for role filter updates
const handleRoleFilterUpdate = (role: string) => {
if (projectId) {
const currentFilters = filters.getFilters(projectId);
const currentRoles = currentFilters?.roles || [];
const updatedRoles = currentRoles.includes(role)
? currentRoles.filter((r) => r !== role)
: [...currentRoles, role];
filters.updateFilters(projectId, {
roles: updatedRoles.length > 0 ? updatedRoles : undefined,
});
}
};
// Get current role filters
const appliedRoleFilters = projectId ? filters.getFilters(projectId)?.roles || [] : [];
return (
<>
<SendProjectInvitationModal
isOpen={inviteModal}
onClose={() => setInviteModal(false)}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
<div className="flex items-center justify-between gap-4 overflow-x-hidden border-b border-subtle py-2">
<div className="text-14 font-semibold">{t("common.members")}</div>
<div className="flex items-center gap-2">
<div className="flex items-center justify-start gap-1.5 rounded-md border border-subtle bg-surface-1 px-2 py-1">
<SearchOutline className="h-3.5 w-3.5" />
<input
className="w-full max-w-[234px] border-none bg-transparent text-13 placeholder:text-placeholder focus:outline-none"
placeholder="Search"
value={searchQuery}
autoFocus
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<MemberListFiltersDropdown
appliedFilters={appliedRoleFilters}
handleUpdate={handleRoleFilterUpdate}
memberType="project"
/>
{isAdmin && (
<Button
variant="primary"
onClick={() => {
setInviteModal(true);
}}
>
{t("add_member")}
</Button>
)}
</div>
</div>
{!projectMemberIds ? (
<MembersSettingsLoader />
) : (
<div className="divide-y divide-subtle overflow-scroll">
{searchedProjectMembers.length !== 0 && (
<ProjectMemberListItem
memberDetails={memberDetails ?? []}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
)}
{searchedProjectMembers.length === 0 && (
<h4 className="mt-16 text-center text-13 text-placeholder">{t("no_matching_members")}</h4>
)}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,113 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Ban } from "lucide-react";
import { Avatar } from "@makeplane/propel/components/avatar";
import { EUserProjectRoles } from "@plane/types";
// plane ui
import { CustomSearchSelect } from "@plane/ui";
// helpers
import { getFileURL } from "@plane/utils";
// hooks
import { useMember } from "@/hooks/store/use-member";
type Props = {
value: any;
onChange: (val: string) => void;
isDisabled?: boolean;
};
export const MemberSelect = observer(function MemberSelect(props: Props) {
const { value, onChange, isDisabled = false } = props;
// router
const { projectId } = useParams();
// store hooks
const {
project: { projectMemberIds, getProjectMemberDetails },
} = useMember();
const options = projectMemberIds
?.map((userId) => {
const memberDetails = projectId ? getProjectMemberDetails(userId, projectId.toString()) : null;
if (!memberDetails?.member) return;
const isGuest = memberDetails.role === EUserProjectRoles.GUEST;
if (isGuest) return;
return {
value: `${memberDetails?.member.id}`,
query: `${memberDetails?.member.display_name}`,
content: (
<div className="flex items-center gap-2">
<Avatar
alt={memberDetails?.member.display_name}
fallback={memberDetails?.member.display_name?.[0]?.toUpperCase()}
src={getFileURL(memberDetails?.member.avatar_url)}
size="xs"
/>
{memberDetails?.member.display_name}
</div>
),
};
})
.filter((option) => !!option) as
| {
value: string;
query: string;
content: React.ReactNode;
}[]
| undefined;
const selectedOption = projectId ? getProjectMemberDetails(value, projectId.toString()) : null;
return (
<CustomSearchSelect
value={value}
label={
<div className="flex h-3.5 items-center gap-2">
{selectedOption && (
<Avatar
alt={selectedOption.member?.display_name}
fallback={selectedOption.member?.display_name?.[0]?.toUpperCase()}
src={getFileURL(selectedOption.member?.avatar_url)}
size="xs"
/>
)}
{selectedOption ? (
selectedOption.member?.display_name
) : (
<div className="flex items-center gap-2">
<Ban className="h-3.5 w-3.5 rotate-90 text-placeholder" />
<span className="text-13 text-placeholder">None</span>
</div>
)}
</div>
}
buttonClassName="!px-3 !py-2 bg-surface-1"
options={
options &&
options && [
...options,
{
value: "none",
query: "none",
content: (
<div className="flex items-center gap-2">
<Ban className="h-3.5 w-3.5 rotate-90 text-placeholder" />
<span className="py-0.5 text-13 text-placeholder">None</span>
</div>
),
},
]
}
maxHeight="md"
onChange={onChange}
disabled={isDisabled}
/>
);
});

View File

@@ -0,0 +1,198 @@
/**
* 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>
);
});

View File

@@ -0,0 +1,65 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import Link from "next/link";
import { useTranslation } from "@plane/i18n";
// ui
import { Button, getButtonStyling } from "@plane/propel/button";
import { Logo } from "@plane/propel/emoji-icon-picker";
import { Row } from "@plane/ui";
// components
// hooks
import { useProject } from "@/hooks/store/use-project";
// plane web imports
import { ProjectFeaturesList } from "@/components/project/settings/features-list";
type Props = {
workspaceSlug: string;
projectId: string | null;
onClose: () => void;
};
export const ProjectFeatureUpdate = observer(function ProjectFeatureUpdate(props: Props) {
const { workspaceSlug, projectId, onClose } = props;
// store hooks
const { t } = useTranslation();
const { getProjectById } = useProject();
if (!workspaceSlug || !projectId) return null;
const currentProjectDetails = getProjectById(projectId);
if (!currentProjectDetails) return null;
return (
<>
<Row className="py-6">
<ProjectFeaturesList workspaceSlug={workspaceSlug} projectId={projectId} isAdmin />
</Row>
<div className="mt-4 flex items-center justify-between gap-2 border-t border-subtle px-6 py-4">
<div className="flex gap-1 text-13 font-medium text-tertiary">
{t("congrats")}
<Logo logo={currentProjectDetails.logo_props} /> <p className="break-all">{currentProjectDetails.name}</p>{" "}
{t("created").toLowerCase()}.
</div>
<div className="flex gap-2">
{/* eslint-disable-next-line jsx-a11y/tabindex-no-positive */}
<Button variant="secondary" size="lg" onClick={onClose} tabIndex={1}>
{t("close")}
</Button>
<Link
href={`/${workspaceSlug}/projects/${projectId}/issues`}
onClick={onClose}
className={getButtonStyling("primary", "lg")}
// oxlint-disable-next-line jsx-a11y/tabindex-no-positive
tabIndex={2}
>
{t("open_project")}
</Link>
</div>
</div>
</>
);
});

View File

@@ -0,0 +1,36 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { TNetworkChoiceIconKey } from "@plane/constants";
// plane imports
import { GlobeOutline, LockOutline } from "@makeplane/propel/icons";
import { cn } from "@plane/utils";
type Props = {
iconKey: TNetworkChoiceIconKey;
className?: string;
};
export function ProjectNetworkIcon(props: Props) {
const { iconKey, className } = props;
// Get the icon key
const getProjectNetworkIcon = () => {
switch (iconKey) {
case "Lock":
return LockOutline;
case "Globe2":
return GlobeOutline;
default:
return null;
}
};
// Get the icon
const Icon = getProjectNetworkIcon();
if (!Icon) return null;
return <Icon className={cn("h-3 w-3", className)} />;
}

View File

@@ -0,0 +1,203 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { ReactNode } from "react";
import { useEffect } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
import useSWR from "swr";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IProject, IUserLite, IWorkspace } from "@plane/types";
import { Switch } from "@makeplane/propel/components/switch";
import { Loader } from "@plane/ui";
// constants
import { PROJECT_DETAILS } from "@plane/constants";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
// local imports
import { MemberSelect } from "./member-select";
const defaultValues: Partial<IProject> = {
project_lead: null,
default_assignee: null,
};
type TDefaultSettingItemProps = {
title: string;
description: string;
children: ReactNode;
};
function DefaultSettingItem({ title, description, children }: TDefaultSettingItemProps) {
return (
<div className="flex items-center justify-between gap-x-2">
<div className="flex flex-col gap-0.5">
<h4 className="text-13 font-medium">{title}</h4>
<p className="text-11 text-tertiary">{description}</p>
</div>
<div className="w-full max-w-48 sm:max-w-64">{children}</div>
</div>
);
}
type TProjectSettingsMemberDefaultsProps = {
workspaceSlug: string;
projectId: string;
};
export const ProjectSettingsMemberDefaults = observer(function ProjectSettingsMemberDefaults(
props: TProjectSettingsMemberDefaultsProps
) {
const { workspaceSlug, projectId } = props;
// plane hooks
const { t } = useTranslation();
// store hooks
const { allowPermissions } = useUserPermissions();
const { currentProjectDetails, fetchProjectDetails, updateProject } = useProject();
// derived values
const isAdmin = allowPermissions(
[EUserPermissions.ADMIN],
EUserPermissionsLevel.PROJECT,
workspaceSlug,
currentProjectDetails?.id
);
// form info
const { reset, control } = useForm<IProject>({ defaultValues });
// fetching user members
useSWR(
workspaceSlug && projectId ? PROJECT_DETAILS(workspaceSlug, projectId) : null,
workspaceSlug && projectId ? () => fetchProjectDetails(workspaceSlug, projectId) : null
);
useEffect(() => {
if (!currentProjectDetails) return;
reset({
...currentProjectDetails,
default_assignee:
(currentProjectDetails.default_assignee as IUserLite)?.id ?? currentProjectDetails.default_assignee,
project_lead: (currentProjectDetails.project_lead as IUserLite)?.id ?? currentProjectDetails.project_lead,
workspace: (currentProjectDetails.workspace as IWorkspace).id,
});
}, [currentProjectDetails, reset]);
const submitChanges = async (formData: Partial<IProject>) => {
if (!workspaceSlug || !projectId) return;
reset({
...currentProjectDetails,
default_assignee:
(currentProjectDetails?.default_assignee as IUserLite)?.id ?? currentProjectDetails?.default_assignee,
project_lead: (currentProjectDetails?.project_lead as IUserLite)?.id ?? currentProjectDetails?.project_lead,
...formData,
});
await updateProject(workspaceSlug, projectId, {
default_assignee:
formData.default_assignee === "none"
? null
: (formData.default_assignee ?? currentProjectDetails?.default_assignee),
project_lead:
formData.project_lead === "none" ? null : (formData.project_lead ?? currentProjectDetails?.project_lead),
})
.then(() => {
setToast({
title: `${t("success")}!`,
type: TOAST_TYPE.SUCCESS,
message: t("project_settings.general.toast.success"),
});
})
.catch((err) => {
console.error(err);
});
};
const toggleGuestViewAllIssues = async (value: boolean) => {
if (!workspaceSlug || !projectId) return;
updateProject(workspaceSlug, projectId, {
guest_view_all_features: value,
})
.then(() => {
setToast({
title: `${t("success")}!`,
type: TOAST_TYPE.SUCCESS,
message: t("project_settings.general.toast.success"),
});
})
.catch((err) => {
console.error(err);
});
};
return (
<div className="my-6 flex flex-col gap-y-6">
<DefaultSettingItem title="Project Lead" description="Select the project lead for the project.">
{currentProjectDetails ? (
<Controller
control={control}
name="project_lead"
render={({ field: { value } }) => (
<MemberSelect
value={value}
onChange={(val: string) => {
submitChanges({ project_lead: val });
}}
isDisabled={!isAdmin}
/>
)}
/>
) : (
<Loader className="h-9 w-full">
<Loader.Item width="100%" height="100%" />
</Loader>
)}
</DefaultSettingItem>
<DefaultSettingItem title="Default Assignee" description="Select the default assignee for the project.">
{currentProjectDetails ? (
<Controller
control={control}
name="default_assignee"
render={({ field: { value } }) => (
<MemberSelect
value={value}
onChange={(val: string) => {
submitChanges({ default_assignee: val });
}}
isDisabled={!isAdmin}
/>
)}
/>
) : (
<Loader className="h-9 w-full">
<Loader.Item width="100%" height="100%" />
</Loader>
)}
</DefaultSettingItem>
{currentProjectDetails && (
<DefaultSettingItem
title="Guest access"
description="This will allow guests to have view access to all the project work items."
>
<div className="flex items-center justify-end">
<Switch
size="sm"
checked={!!currentProjectDetails?.guest_view_all_features}
onCheckedChange={toggleGuestViewAllIssues}
disabled={!isAdmin}
aria-label="Guest access"
/>
</div>
</DefaultSettingItem>
)}
</div>
);
});

View File

@@ -0,0 +1,343 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Controller, useForm } from "react-hook-form";
// types
import { SPACE_BASE_PATH, SPACE_BASE_URL } from "@plane/constants";
import { Button } from "@plane/propel/button";
import { GlobeOutline, NewTabOutline, TickOutline } from "@makeplane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TProjectPublishLayouts, TProjectPublishSettings } from "@plane/types";
// ui
import { Switch } from "@makeplane/propel/components/switch";
import { Loader, CustomSelect, ModalCore, EModalWidth } from "@plane/ui";
// helpers
import { copyTextToClipboard } from "@plane/utils";
// hooks
import { useProjectPublish } from "@/hooks/store/use-project-publish";
type Props = {
isOpen: boolean;
projectId: string;
onClose: () => void;
};
const defaultValues: Partial<TProjectPublishSettings> = {
is_comments_enabled: false,
is_reactions_enabled: false,
is_votes_enabled: false,
inbox: null,
view_props: {
list: true,
kanban: true,
},
};
const VIEW_OPTIONS: {
key: TProjectPublishLayouts;
label: string;
}[] = [
{ key: "list", label: "List" },
{ key: "kanban", label: "Kanban" },
];
export const PublishProjectModal = observer(function PublishProjectModal(props: Props) {
const { isOpen, onClose, projectId } = props;
// states
const [isUnPublishing, setIsUnPublishing] = useState(false);
// router
const { workspaceSlug } = useParams();
// store hooks
const {
fetchPublishSettings,
getPublishSettingsByProjectID,
publishProject,
updatePublishSettings,
unPublishProject,
fetchSettingsLoader,
} = useProjectPublish();
// derived values
const projectPublishSettings = getPublishSettingsByProjectID(projectId);
const isProjectPublished = !!projectPublishSettings?.anchor;
// form info
const {
control,
formState: { isDirty, isSubmitting },
handleSubmit,
reset,
watch,
} = useForm({
defaultValues,
});
const handleClose = () => {
onClose();
};
// fetch publish settings
useEffect(() => {
if (!workspaceSlug || !isOpen) return;
if (!projectPublishSettings) {
fetchPublishSettings(workspaceSlug.toString(), projectId);
}
}, [fetchPublishSettings, isOpen, projectId, projectPublishSettings, workspaceSlug]);
const handlePublishProject = async (payload: Partial<TProjectPublishSettings>) => {
if (!workspaceSlug) return;
await publishProject(workspaceSlug.toString(), projectId, payload);
};
const handleUpdatePublishSettings = async (payload: Partial<TProjectPublishSettings>) => {
if (!workspaceSlug || !payload.id) return;
await updatePublishSettings(workspaceSlug.toString(), projectId, payload.id, payload).then((res) => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Publish settings updated successfully!",
});
handleClose();
return res;
});
};
const handleUnPublishProject = async (publishId: string) => {
if (!workspaceSlug || !publishId) return;
setIsUnPublishing(true);
await unPublishProject(workspaceSlug.toString(), projectId, publishId)
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Something went wrong while unpublishing the project.",
})
)
.finally(() => setIsUnPublishing(false));
};
const selectedLayouts = Object.entries(watch("view_props") ?? {})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.filter(([key, value]) => value)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.map(([key, value]) => key)
.filter((l) => VIEW_OPTIONS.find((o) => o.key === l));
const handleFormSubmit = async (formData: Partial<TProjectPublishSettings>) => {
if (!selectedLayouts || selectedLayouts.length === 0) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Please select at least one view layout to publish the project.",
});
return;
}
const payload: Partial<TProjectPublishSettings> = {
id: formData.id,
is_comments_enabled: formData.is_comments_enabled,
is_reactions_enabled: formData.is_reactions_enabled,
is_votes_enabled: formData.is_votes_enabled,
view_props: formData.view_props,
};
if (formData.id && isProjectPublished) await handleUpdatePublishSettings(payload);
else await handlePublishProject(payload);
};
// prefill form values for already published projects
useEffect(() => {
if (!projectPublishSettings?.anchor) return;
reset({
...defaultValues,
...projectPublishSettings,
});
}, [projectPublishSettings, reset]);
const SPACE_APP_URL = (SPACE_BASE_URL.trim() === "" ? window.location.origin : SPACE_BASE_URL) + SPACE_BASE_PATH;
const publishLink = `${SPACE_APP_URL}/issues/${projectPublishSettings?.anchor}`;
const handleCopyLink = () =>
copyTextToClipboard(publishLink).then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "",
message: "Published page link copied successfully.",
})
);
return (
<ModalCore isOpen={isOpen} handleClose={handleClose} width={EModalWidth.XXL}>
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div className="flex items-center justify-between gap-2 p-5">
<h5 className="text-18 font-medium text-secondary">Publish project</h5>
{isProjectPublished && (
<Button
variant="error-fill"
size="lg"
onClick={() => handleUnPublishProject(watch("id") ?? "")}
loading={isUnPublishing}
>
{isUnPublishing ? "Unpublishing" : "Unpublish"}
</Button>
)}
</div>
{/* content */}
{fetchSettingsLoader ? (
<Loader className="space-y-4 px-5">
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
</Loader>
) : (
<div className="space-y-4 px-5">
{isProjectPublished && projectPublishSettings && (
<>
<div className="flex items-center justify-between gap-2 rounded-md border border-strong py-1.5 pr-1 pl-4">
<a
href={publishLink}
className="truncate text-13 text-secondary"
target="_blank"
rel="noopener noreferrer"
>
{publishLink}
</a>
<div className="flex flex-shrink-0 items-center gap-1">
<a
href={publishLink}
className="grid size-8 place-items-center rounded-sm bg-layer-3 hover:bg-layer-3-hover"
target="_blank"
rel="noopener noreferrer"
>
<NewTabOutline className="size-4" />
</a>
<button
type="button"
className="h-8 rounded-sm bg-layer-3 px-3 py-2 text-11 font-medium hover:bg-layer-3-hover"
onClick={handleCopyLink}
>
Copy link
</button>
</div>
</div>
<p className="mt-3 flex items-center gap-1 text-13 font-medium text-accent-primary">
<span className="relative grid size-2.5 place-items-center">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-accent-primary opacity-75" />
<span className="relative inline-flex size-1.5 rounded-full bg-accent-primary" />
</span>
This project is now live on web
</p>
</>
)}
<div className="space-y-4">
<div className="relative flex items-center justify-between gap-2">
<div className="text-13">Views</div>
<Controller
control={control}
name="view_props"
render={({ field: { onChange, value } }) => (
<CustomSelect
value={value}
label={VIEW_OPTIONS.filter((o) => selectedLayouts.includes(o.key))
.map((o) => o.label)
.join(", ")}
onChange={(val: TProjectPublishLayouts) => {
if (selectedLayouts.length === 1 && selectedLayouts[0] === val) return;
onChange({
...value,
[val]: !value?.[val],
});
}}
buttonClassName="border-none"
placement="bottom-end"
>
{VIEW_OPTIONS.map((option) => (
<CustomSelect.Option
key={option.key}
value={option.key}
className="flex items-center justify-between gap-2"
>
{option.label}
{selectedLayouts.includes(option.key) && <TickOutline className="size-3.5 flex-shrink-0" />}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
<div className="relative flex items-center justify-between gap-2">
<div className="text-13">Allow comments</div>
<Controller
control={control}
name="is_comments_enabled"
render={({ field: { onChange, value } }) => (
<Switch size="sm" checked={!!value} onCheckedChange={onChange} aria-label="Allow comments" />
)}
/>
</div>
<div className="relative flex items-center justify-between gap-2">
<div className="text-13">Allow reactions</div>
<Controller
control={control}
name="is_reactions_enabled"
render={({ field: { onChange, value } }) => (
<Switch size="sm" checked={!!value} onCheckedChange={onChange} aria-label="Allow reactions" />
)}
/>
</div>
<div className="relative flex items-center justify-between gap-2">
<div className="text-13">Allow voting</div>
<Controller
control={control}
name="is_votes_enabled"
render={({ field: { onChange, value } }) => (
<Switch size="sm" checked={!!value} onCheckedChange={onChange} aria-label="Allow voting" />
)}
/>
</div>
</div>
</div>
)}
{/* modal handlers */}
<div className="relative mt-4 flex items-center justify-between border-t border-subtle px-5 py-4">
<div className="flex items-center gap-1 text-13 text-placeholder">
<GlobeOutline className="size-3.5" />
<div className="text-13">Anyone with the link can access</div>
</div>
{!fetchSettingsLoader && (
<div className="relative flex items-center gap-2">
<Button variant="secondary" size="lg" onClick={handleClose}>
Cancel
</Button>
{isProjectPublished ? (
isDirty && (
<Button variant="primary" size="lg" type="submit" loading={isSubmitting}>
{isSubmitting ? "Updating" : "Update settings"}
</Button>
)
) : (
<Button variant="primary" size="lg" type="submit" loading={isSubmitting}>
{isSubmitting ? "Publishing" : "Publish"}
</Button>
)}
</div>
)}
</div>
</form>
</ModalCore>
);
});

View File

@@ -0,0 +1,101 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback, useEffect } from "react";
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
// plane imports
import { useTranslation } from "@plane/i18n";
import type { TProjectAppliedDisplayFilterKeys, TProjectFilters } from "@plane/types";
import { calculateTotalFilters } from "@plane/utils";
// components
import { PageHead } from "@/components/core/page-title";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useProjectFilter } from "@/hooks/store/use-project-filter";
import { useWorkspace } from "@/hooks/store/use-workspace";
// local imports
import { ProjectAppliedFiltersList } from "./applied-filters";
import { ProjectCardList } from "./card-list";
export const ProjectRoot = observer(function ProjectRoot() {
const { currentWorkspace } = useWorkspace();
const { workspaceSlug } = useParams();
const pathname = usePathname();
const { t } = useTranslation();
// store
const { totalProjectIds, filteredProjectIds } = useProject();
const {
currentWorkspaceFilters,
currentWorkspaceAppliedDisplayFilters,
clearAllFilters,
clearAllAppliedDisplayFilters,
updateFilters,
updateDisplayFilters,
} = useProjectFilter();
// derived values
const pageTitle = currentWorkspace?.name
? `${currentWorkspace?.name} - ${t("workspace_projects.label", { count: 2 })}`
: undefined;
const isArchived = pathname.includes("/archives");
const allowedDisplayFilters =
currentWorkspaceAppliedDisplayFilters?.filter((filter) => filter !== "archived_projects") ?? [];
const handleRemoveFilter = useCallback(
(key: keyof TProjectFilters, value: string | null) => {
if (!workspaceSlug) return;
let newValues = currentWorkspaceFilters?.[key] ?? [];
if (!value) newValues = [];
else newValues = newValues.filter((val) => val !== value);
updateFilters(workspaceSlug.toString(), { [key]: newValues });
},
[currentWorkspaceFilters, updateFilters, workspaceSlug]
);
const handleRemoveDisplayFilter = useCallback(
(key: TProjectAppliedDisplayFilterKeys) => {
if (!workspaceSlug) return;
updateDisplayFilters(workspaceSlug.toString(), { [key]: false });
},
[updateDisplayFilters, workspaceSlug]
);
const handleClearAllFilters = useCallback(() => {
if (!workspaceSlug) return;
clearAllFilters(workspaceSlug.toString());
clearAllAppliedDisplayFilters(workspaceSlug.toString());
if (isArchived) updateDisplayFilters(workspaceSlug.toString(), { archived_projects: true });
}, [clearAllFilters, clearAllAppliedDisplayFilters, workspaceSlug]);
useEffect(() => {
updateDisplayFilters(workspaceSlug.toString(), { archived_projects: isArchived });
}, [pathname]);
return (
<>
<PageHead title={pageTitle} />
<div className="flex h-full w-full flex-col">
{(calculateTotalFilters(currentWorkspaceFilters ?? {}) !== 0 || allowedDisplayFilters.length > 0) && (
<ProjectAppliedFiltersList
appliedFilters={currentWorkspaceFilters ?? {}}
appliedDisplayFilters={allowedDisplayFilters}
handleClearAllFilters={handleClearAllFilters}
handleRemoveFilter={handleRemoveFilter}
handleRemoveDisplayFilter={handleRemoveDisplayFilter}
filteredProjects={filteredProjectIds?.length ?? 0}
totalProjects={totalProjectIds?.length ?? 0}
alwaysAllowEditing
/>
)}
<ProjectCardList />
</div>
</>
);
});

View File

@@ -0,0 +1,87 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useRef, useState } from "react";
import { observer } from "mobx-react";
// plane hooks
import { useOutsideClickDetector } from "@plane/hooks";
// i18n
import { useTranslation } from "@plane/i18n";
import { CloseOutline, SearchOutline } from "@makeplane/propel/icons";
// helpers
import { cn } from "@plane/utils";
// hooks
import { useProjectFilter } from "@/hooks/store/use-project-filter";
import { IconButton } from "@plane/propel/icon-button";
export const ProjectSearch = observer(function ProjectSearch() {
// i18n
const { t } = useTranslation();
// hooks
const { searchQuery, updateSearchQuery } = useProjectFilter();
// refs
const inputRef = useRef<HTMLInputElement>(null);
// states
const [isSearchOpen, setIsSearchOpen] = useState(false);
// outside click detector hook
useOutsideClickDetector(inputRef, () => {
if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false);
});
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
if (searchQuery && searchQuery.trim() !== "") updateSearchQuery("");
else setIsSearchOpen(false);
}
};
return (
<div className="flex items-center">
{!isSearchOpen && (
<IconButton
variant="ghost"
size="lg"
className="-mr-1"
onClick={() => {
setIsSearchOpen(true);
inputRef.current?.focus();
}}
icon={SearchOutline}
/>
)}
<div
className={cn(
"ml-auto flex w-0 items-center justify-start gap-1 overflow-hidden rounded-md border border-transparent bg-surface-1 text-placeholder opacity-0 transition-[width] ease-linear",
{
"w-30 border-subtle px-2.5 py-1.5 opacity-100 md:w-64": isSearchOpen,
}
)}
>
<SearchOutline className="h-3.5 w-3.5" />
<input
ref={inputRef}
className="w-full max-w-[234px] border-none bg-transparent text-13 text-primary placeholder:text-placeholder focus:outline-none"
placeholder={t("common.search.label")}
value={searchQuery}
onChange={(e) => updateSearchQuery(e.target.value)}
onKeyDown={handleInputKeyDown}
/>
{isSearchOpen && (
<button
type="button"
className="grid place-items-center"
onClick={() => {
updateSearchQuery("");
setIsSearchOpen(false);
}}
>
<CloseOutline className="h-3 w-3" />
</button>
)}
</div>
</div>
);
});

View File

@@ -0,0 +1,321 @@
/**
* 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 } from "react";
import { observer } from "mobx-react";
import { useForm, Controller, useFieldArray } from "react-hook-form";
// plane imports
import { Avatar } from "@makeplane/propel/components/avatar";
import { ROLE, EUserPermissions } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { AddOutline, ChevronDownOutline, CloseOutline } from "@makeplane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { CustomSelect, CustomSearchSelect, EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// helpers
import { getFileURL } from "@plane/utils";
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useUserPermissions } from "@/hooks/store/user";
type Props = {
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
projectId: string;
workspaceSlug: string;
};
type member = {
role: EUserPermissions;
member_id: string;
};
type FormValues = {
members: member[];
};
const defaultValues: FormValues = {
members: [
{
role: 5,
member_id: "",
},
],
};
export const SendProjectInvitationModal = observer(function SendProjectInvitationModal(props: Props) {
const { isOpen, onClose, onSuccess, projectId, workspaceSlug } = props;
// plane hooks
const { t } = useTranslation();
// store hooks
const { getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
const {
project: { getProjectMemberDetails, bulkAddMembersToProject },
workspace: { workspaceMemberIds, getWorkspaceMemberDetails },
} = useMember();
// form info
const {
formState: { errors, isSubmitting },
watch,
setValue,
reset,
handleSubmit,
control,
} = useForm<FormValues>();
const { fields, append, remove } = useFieldArray({
control,
name: "members",
});
// derived values
const currentProjectRole = getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, projectId);
const uninvitedPeople = workspaceMemberIds?.filter((userId) => {
const projectMemberDetails = getProjectMemberDetails(userId, projectId);
const isInvited = projectMemberDetails?.member.id && projectMemberDetails?.original_role;
return !isInvited;
});
const onSubmit = async (formData: FormValues) => {
if (!workspaceSlug || !projectId || isSubmitting) return;
const payload = { ...formData };
await bulkAddMembersToProject(workspaceSlug.toString(), projectId.toString(), payload)
.then(() => {
if (onSuccess) onSuccess();
onClose();
setToast({
title: "Success!",
type: TOAST_TYPE.SUCCESS,
message: "Members added successfully.",
});
})
.catch((error) => {
console.error(error);
})
.finally(() => {
reset(defaultValues);
});
};
const handleClose = () => {
onClose();
const timeout = setTimeout(() => {
reset(defaultValues);
clearTimeout(timeout);
}, 500);
};
const appendField = () => {
append({
role: 5,
member_id: "",
});
};
useEffect(() => {
if (fields.length === 0) {
append([
{
role: 5,
member_id: "",
},
]);
}
}, [fields, append]);
const options = uninvitedPeople
?.map((userId) => {
const memberDetails = getWorkspaceMemberDetails(userId);
if (!memberDetails?.member) return;
return {
value: `${memberDetails?.member.id}`,
query: `${memberDetails?.member.first_name} ${
memberDetails?.member.last_name
} ${memberDetails?.member.display_name.toLowerCase()}`,
content: (
<div className="flex w-full items-center gap-2">
<div className="shrink-0 pt-0.5">
<Avatar
alt={memberDetails?.member.display_name}
fallback={memberDetails?.member.display_name?.[0]?.toUpperCase()}
src={getFileURL(memberDetails?.member.avatar_url)}
size="xs"
/>
</div>
<div className="truncate">
{memberDetails?.member.display_name} (
{memberDetails?.member.first_name + " " + memberDetails?.member.last_name})
</div>
</div>
),
};
})
.filter((option) => !!option) as
| {
value: string;
query: string;
content: React.ReactNode;
}[]
| undefined;
const checkCurrentOptionWorkspaceRole = (value: string) => {
const currentMemberWorkspaceRole = getWorkspaceMemberDetails(value)?.role;
if (!value || !currentMemberWorkspaceRole) return ROLE;
const isGuestOROwner = [EUserPermissions.ADMIN, EUserPermissions.GUEST].includes(
currentMemberWorkspaceRole as EUserPermissions
);
return Object.fromEntries(
Object.entries(ROLE).filter(([key]) => !isGuestOROwner || [currentMemberWorkspaceRole].includes(parseInt(key)))
);
};
return (
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.XXL}>
<form onSubmit={handleSubmit(onSubmit)} className="p-5">
<div className="space-y-5">
<h3 className="text-16 leading-6 font-medium text-primary">
{t("project_settings.members.invite_members.title")}
</h3>
<div className="mt-2">
<p className="text-13 text-secondary">{t("project_settings.members.invite_members.sub_heading")}</p>
</div>
<div className="mb-3 space-y-4">
{fields.map((field, index) => (
<div key={field.id} className="group mb-1 flex w-full items-start justify-between gap-x-4 text-13">
<div className="flex w-full grow flex-col gap-1">
<Controller
control={control}
name={`members.${index}.member_id`}
rules={{ required: "Please select a member" }}
render={({ field: { value, onChange } }) => {
const selectedMember = getWorkspaceMemberDetails(value);
return (
<CustomSearchSelect
value={value}
customButton={
<button className="shadow-sm flex w-full items-center justify-between gap-1 rounded-md border border-subtle px-3 py-2 text-left text-13 text-secondary duration-300 hover:bg-layer-1 hover:text-primary focus:outline-none">
{value && value !== "" ? (
<div className="flex items-center gap-2">
<Avatar
alt={selectedMember?.member.display_name}
fallback={selectedMember?.member.display_name?.[0]?.toUpperCase()}
src={getFileURL(selectedMember?.member.avatar_url ?? "")}
size="xs"
/>
{selectedMember?.member.display_name}
</div>
) : (
<div className="flex items-center gap-2 py-0.5">Select co-worker</div>
)}
<ChevronDownOutline className="h-3 w-3" aria-hidden="true" />
</button>
}
onChange={(val: string) => {
onChange(val);
// Update the role to the workspace role when member ID changes
const workspaceMemberDetails = getWorkspaceMemberDetails(val);
const workspaceRole = workspaceMemberDetails?.role ?? 5;
const newValue = ROLE[workspaceRole].toUpperCase();
setValue(
`members.${index}.role`,
EUserPermissions[newValue as keyof typeof EUserPermissions]
);
}}
options={options}
optionsClassName="w-48"
/>
);
}}
/>
{errors.members && errors.members[index]?.member_id && (
<span className="px-1 text-13 text-danger-primary">
{errors.members[index]?.member_id?.message}
</span>
)}
</div>
<div className="flex shrink-0 items-center justify-between gap-2">
<div className="flex flex-col gap-1">
<Controller
name={`members.${index}.role`}
control={control}
rules={{ required: "Select Role" }}
render={({ field }) => (
<CustomSelect
{...field}
customButton={
<div className="shadow-sm flex w-24 items-center justify-between gap-1 rounded-md border border-subtle px-3 py-2.5 text-left text-13 text-secondary duration-300 hover:bg-layer-1 hover:text-primary focus:outline-none">
<span className="capitalize">{field.value ? ROLE[field.value] : "Select role"}</span>
<ChevronDownOutline className="h-3 w-3" aria-hidden="true" />
</div>
}
input
>
{Object.entries(checkCurrentOptionWorkspaceRole(watch(`members.${index}.member_id`))).map(
([key, label]) => {
if (parseInt(key) > (currentProjectRole ?? EUserPermissions.GUEST)) return null;
return (
<CustomSelect.Option key={key} value={key}>
{label}
</CustomSelect.Option>
);
}
)}
</CustomSelect>
)}
/>
{errors.members && errors.members[index]?.role && (
<span className="px-1 text-13 text-danger-primary">{errors.members[index]?.role?.message}</span>
)}
</div>
{fields.length > 1 && (
<div className="flex-item flex w-6">
<button
type="button"
className="place-items-center self-center rounded-sm"
onClick={() => remove(index)}
>
<CloseOutline className="h-4 w-4 text-secondary" />
</button>
</div>
)}
</div>
</div>
))}
</div>
</div>
<div className="mt-5 flex items-center justify-between gap-2">
<button
type="button"
className="flex items-center gap-2 bg-transparent py-2 pr-3 text-13 font-medium text-accent-primary outline-accent-strong"
onClick={appendField}
>
<AddOutline className="h-4 w-4" />
{t("common.add_more")}
</button>
<div className="flex items-center gap-2">
<Button variant="secondary" size="lg" onClick={handleClose}>
{t("cancel")}
</Button>
<Button variant="primary" size="lg" type="submit" loading={isSubmitting}>
{isSubmitting
? `${fields && fields.length > 1 ? `${t("add_members")}...` : `${t("add_member")}...`}`
: `${fields && fields.length > 1 ? t("add_members") : t("add_member")}`}
</Button>
</div>
</div>
</form>
</ModalCore>
);
});

View File

@@ -0,0 +1,83 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "react-router";
// plane imports
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
// components
import { SettingsBoxedControlItem } from "@/components/settings/boxed-control-item";
// hooks
import { useProject } from "@/hooks/store/use-project";
// local imports
import { ArchiveRestoreProjectModal } from "../archive-restore-modal";
import { DeleteProjectModal } from "../delete-project-modal";
type Props = {
projectId: string;
};
export const GeneralProjectSettingsControlSection = observer(function GeneralProjectSettingsControlSection(
props: Props
) {
const { projectId } = props;
// states
const [selectProject, setSelectedProject] = useState<string | null>(null);
const [archiveProject, setArchiveProject] = useState<boolean>(false);
// params
const { workspaceSlug } = useParams();
// store hooks
const { currentProjectDetails } = useProject();
// translation
const { t } = useTranslation();
if (!currentProjectDetails) return null;
return (
<div className="mt-10">
{workspaceSlug && (
<ArchiveRestoreProjectModal
workspaceSlug={workspaceSlug}
projectId={projectId}
isOpen={archiveProject}
onClose={() => setArchiveProject(false)}
archive
/>
)}
<DeleteProjectModal
project={currentProjectDetails}
isOpen={Boolean(selectProject)}
onClose={() => setSelectedProject(null)}
/>
<div className="rounded-lg border border-subtle bg-layer-2">
{/* Project Selector */}
<SettingsBoxedControlItem
className="rounded-b-none border-0 border-b"
title={t("archive")}
description="Archiving a project will unlist your project from your side navigation although you will still be able to access it from your projects page. You can restore the project or delete it whenever you want."
control={
<Button variant="secondary" onClick={() => setArchiveProject(true)}>
{t("archive")}
</Button>
}
/>
{/* Format Selector */}
<SettingsBoxedControlItem
className="rounded-t-none border-0"
title={t("delete")}
description="When deleting a project, all of the data and resources within that project will be permanently removed and cannot be recovered."
control={
<Button variant="error-outline" onClick={() => setSelectedProject(currentProjectDetails.id ?? null)}>
{t("delete")}
</Button>
}
/>
</div>
</div>
);
});

View File

@@ -0,0 +1,150 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { setPromiseToast } from "@plane/propel/toast";
import { Tooltip } from "@makeplane/propel/components/tooltip";
import type { IProject } from "@plane/types";
import { CyclesOutline, IntakeOutline, ModuleOutline, PagesOutline, ViewsOutline } from "@makeplane/propel/icons";
// components
import { SettingsBoxedControlItem } from "@/components/settings/boxed-control-item";
import { SettingsHeading } from "@/components/settings/heading";
// hooks
import { useProject } from "@/hooks/store/use-project";
// plane web imports
import { UpgradeBadge } from "@/components/workspace/upgrade-badge";
// local imports
import { ProjectFeatureToggle } from "./helper";
type Props = {
workspaceSlug: string;
projectId: string;
isAdmin: boolean;
};
const PROJECT_FEATURES_LIST = {
cycles: {
key: "cycles",
property: "cycle_view",
title: "Cycles",
description: "Timebox work as you see fit per project and change frequency from one period to the next.",
icon: <CyclesOutline className="h-5 w-5 flex-shrink-0 rotate-180 text-tertiary" />,
isPro: false,
isEnabled: true,
},
modules: {
key: "modules",
property: "module_view",
title: "Modules",
description: "Group work into sub-project-like set-ups with their own leads and assignees.",
icon: <ModuleOutline width={20} height={20} className="flex-shrink-0 text-tertiary" />,
isPro: false,
isEnabled: true,
},
views: {
key: "views",
property: "issue_views_view",
title: "Views",
description: "Save sorts, filters, and display options for later or share them.",
icon: <ViewsOutline className="h-5 w-5 flex-shrink-0 text-tertiary" />,
isPro: false,
isEnabled: true,
},
pages: {
key: "pages",
property: "page_view",
title: "Pages",
description: "Write anything like you write anything.",
icon: <PagesOutline className="h-5 w-5 flex-shrink-0 text-tertiary" />,
isPro: false,
isEnabled: true,
},
inbox: {
key: "intake",
property: "inbox_view",
title: "Intake",
description: "Consider and discuss work items before you add them to your project.",
icon: <IntakeOutline className="h-5 w-5 flex-shrink-0 text-tertiary" />,
isPro: false,
isEnabled: true,
},
};
export const ProjectFeaturesList = observer(function ProjectFeaturesList(props: Props) {
const { workspaceSlug, projectId, isAdmin } = props;
// store hooks
const { t } = useTranslation();
const { getProjectById, updateProject } = useProject();
// derived values
const currentProjectDetails = getProjectById(projectId);
const handleSubmit = (_featureKey: string, featureProperty: string) => {
if (!workspaceSlug || !projectId || !currentProjectDetails) return;
// making the request to update the project feature
const settingsPayload = {
[featureProperty]: !currentProjectDetails?.[featureProperty as keyof IProject],
};
const updateProjectPromise = updateProject(workspaceSlug, projectId, settingsPayload);
setPromiseToast(updateProjectPromise, {
loading: "Updating project feature...",
success: {
title: "Success!",
message: () => "Project feature updated successfully.",
},
error: {
title: "Error!",
message: () => "Something went wrong while updating project feature. Please try again.",
},
});
void updateProjectPromise.then(() => {
return undefined;
});
};
return (
<>
<div>
<SettingsHeading title={t("projects_and_issues")} description={t("projects_and_issues_description")} />
<div className="mt-6 flex flex-col gap-y-4">
{Object.entries(PROJECT_FEATURES_LIST).map(([featureItemKey, featureItem]) => (
<div key={featureItemKey}>
<SettingsBoxedControlItem
title={
<span className="flex items-center gap-2">
{t(featureItem.key)}
{featureItem.isPro && (
<Tooltip label="Pro feature">
<UpgradeBadge className="rounded-sm" />
</Tooltip>
)}
</span>
}
description={t(`${featureItem.key}_description`)}
control={
<ProjectFeatureToggle
workspaceSlug={workspaceSlug}
projectId={projectId}
featureItem={featureItem}
value={Boolean(currentProjectDetails?.[featureItem.property as keyof IProject])}
handleSubmit={handleSubmit}
disabled={!isAdmin}
/>
}
/>
{/* {currentProjectDetails?.[featureItem.property as keyof IProject] && (
<div className="pl-14">{featureItem.renderChildren?.(currentProjectDetails, workspaceSlug)}</div>
)} */}
</div>
))}
</div>
</div>
</>
);
});

View File

@@ -0,0 +1,46 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import Link from "next/link";
import { ChevronRightOutline } from "@makeplane/propel/icons";
import { EPillVariant, Pill, EPillSize } from "@plane/propel/pill";
import { Switch } from "@makeplane/propel/components/switch";
import { joinUrlPath } from "@plane/utils";
type Props = {
workspaceSlug: string;
projectId: string;
featureItem: any;
value: boolean;
handleSubmit: (featureKey: string, featureProperty: string) => void;
disabled?: boolean;
};
export function ProjectFeatureToggle(props: Props) {
const { workspaceSlug, projectId, featureItem, value, handleSubmit, disabled } = props;
return featureItem?.href ? (
<Link href={joinUrlPath(workspaceSlug, "settings", "projects", projectId, "features", featureItem?.href)}>
<div className="flex items-center gap-2">
<Pill
variant={value ? EPillVariant.PRIMARY : EPillVariant.DEFAULT}
size={EPillSize.SM}
className="rounded-lg border-none"
>
{value ? "Enabled" : "Disabled"}
</Pill>
<ChevronRightOutline className="h-4 w-4 text-tertiary" />
</div>
</Link>
) : (
<Switch
size="sm"
checked={value}
onCheckedChange={() => handleSubmit(featureItem?.key, featureItem?.property)}
disabled={disabled}
aria-label="Toggle project feature"
/>
);
}

View File

@@ -0,0 +1,194 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import Link from "next/link";
import { Controller, useForm } from "react-hook-form";
import { CircleMinus } from "lucide-react";
import { Disclosure } from "@headlessui/react";
// plane imports
import { ROLE, EUserPermissions } from "@plane/constants";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { EUserProjectRoles, IUser, IWorkspaceMember, TProjectMembership } from "@plane/types";
import { CustomMenu, CustomSelect } from "@plane/ui";
import { getFileURL } from "@plane/utils";
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useUser, useUserPermissions } from "@/hooks/store/user";
export interface RowData extends Pick<TProjectMembership, "original_role"> {
member: IWorkspaceMember;
}
type NameProps = {
rowData: RowData;
workspaceSlug: string;
isAdmin: boolean;
currentUser: IUser | undefined;
setRemoveMemberModal: (rowData: RowData) => void;
};
type AccountTypeProps = {
rowData: RowData;
currentProjectRole: EUserPermissions | undefined;
workspaceSlug: string;
projectId: string;
};
export function NameColumn(props: NameProps) {
const { rowData, workspaceSlug, isAdmin, currentUser, setRemoveMemberModal } = props;
// derived values
const { avatar_url, display_name, email, first_name, id, last_name } = rowData.member;
return (
<Disclosure>
{({}) => (
<div className="group relative">
<div className="flex w-72 items-center gap-2">
<div className="flex flex-1 items-center gap-x-2 gap-y-2">
{avatar_url && avatar_url.trim() !== "" ? (
<Link href={`/${workspaceSlug}/profile/${id}`}>
<span className="relative flex size-6 items-center justify-center rounded-full text-on-color capitalize">
<img
src={getFileURL(avatar_url)}
className="absolute top-0 left-0 h-full w-full rounded-full object-cover"
alt={display_name || email}
/>
</span>
</Link>
) : (
<Link href={`/${workspaceSlug}/profile/${id}`}>
<span className="relative flex size-6 items-center justify-center rounded-full bg-layer-3 text-11 text-on-color capitalize">
{(email ?? display_name ?? "?")[0]}
</span>
</Link>
)}
{first_name} {last_name}
</div>
{(isAdmin || id === currentUser?.id) && (
<CustomMenu
ellipsis
buttonClassName="p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
optionsClassName="p-1.5"
placement="bottom-end"
>
<CustomMenu.MenuItem>
<div
className="flex cursor-pointer items-center gap-x-1 font-medium text-danger-primary"
onClick={() => setRemoveMemberModal(rowData)}
>
<CircleMinus className="size-3.5 flex-shrink-0" />
{rowData.member?.id === currentUser?.id ? "Leave " : "Remove "}
</div>
</CustomMenu.MenuItem>
</CustomMenu>
)}
</div>
</div>
)}
</Disclosure>
);
}
export const AccountTypeColumn = observer(function AccountTypeColumn(props: AccountTypeProps) {
const { rowData, projectId, workspaceSlug } = props;
// store hooks
const {
project: { updateMemberRole },
workspace: { getWorkspaceMemberDetails },
} = useMember();
const { data: currentUser } = useUser();
const { getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
// form info
const {
control,
formState: { errors },
} = useForm();
// derived values
const roleLabel = ROLE[rowData.original_role ?? EUserPermissions.GUEST];
const isCurrentUser = currentUser?.id === rowData.member.id;
const isRowDataWorkspaceAdmin = [EUserPermissions.ADMIN].includes(
Number(getWorkspaceMemberDetails(rowData.member.id)?.role) ?? EUserPermissions.GUEST
);
const isCurrentUserWorkspaceAdmin = currentUser
? [EUserPermissions.ADMIN].includes(
Number(getWorkspaceMemberDetails(currentUser.id)?.role) ?? EUserPermissions.GUEST
)
: false;
const currentProjectRole = getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, projectId);
const isCurrentUserProjectAdmin = currentProjectRole
? ![EUserPermissions.MEMBER, EUserPermissions.GUEST].includes(Number(currentProjectRole) ?? EUserPermissions.GUEST)
: false;
// logic
// Workspace admin can change his own role
// Project admin can change any role except his own and workspace admin's role
const isRoleEditable =
(isCurrentUserWorkspaceAdmin && isCurrentUser) ||
(isCurrentUserProjectAdmin && !isRowDataWorkspaceAdmin && !isCurrentUser);
const checkCurrentOptionWorkspaceRole = (value: string) => {
const currentMemberWorkspaceRole = getWorkspaceMemberDetails(value)?.role as EUserPermissions | undefined;
if (!value || !currentMemberWorkspaceRole) return ROLE;
const isGuest = [EUserPermissions.GUEST].includes(currentMemberWorkspaceRole);
return Object.fromEntries(
Object.entries(ROLE).filter(([key]) => !isGuest || parseInt(key) === EUserPermissions.GUEST)
);
};
return (
<>
{isRoleEditable ? (
<Controller
name="role"
control={control}
rules={{ required: "Role is required." }}
render={() => (
<CustomSelect
value={rowData.original_role}
onChange={async (value: EUserProjectRoles) => {
if (!workspaceSlug) return;
await updateMemberRole(workspaceSlug.toString(), projectId.toString(), rowData.member.id, value).catch(
(err) => {
console.log(err, "err");
const error = err.error;
const errorString = Array.isArray(error) ? error[0] : error;
setToast({
type: TOAST_TYPE.ERROR,
title: "You can’t change this role yet.",
message: errorString ?? "An error occurred while updating member role. Please try again.",
});
}
);
}}
label={
<div className="flex">
<span>{roleLabel}</span>
</div>
}
buttonClassName={`!px-0 !justify-start hover:bg-surface-1 ${errors.role ? "border-danger-strong" : "border-none"}`}
className="w-32 rounded-md p-0"
input
>
{Object.entries(checkCurrentOptionWorkspaceRole(rowData.member.id)).map(([key, label]) => (
<CustomSelect.Option key={key} value={key}>
{label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
) : (
<div className="flex w-32">
<span>{roleLabel}</span>
</div>
)}
</>
);
});