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
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:
44
apps/web/core/components/project/applied-filters/access.tsx
Normal file
44
apps/web/core/components/project/applied-filters/access.tsx
Normal 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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
60
apps/web/core/components/project/applied-filters/date.tsx
Normal file
60
apps/web/core/components/project/applied-filters/date.tsx
Normal 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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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";
|
||||
58
apps/web/core/components/project/applied-filters/members.tsx
Normal file
58
apps/web/core/components/project/applied-filters/members.tsx
Normal 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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
136
apps/web/core/components/project/applied-filters/root.tsx
Normal file
136
apps/web/core/components/project/applied-filters/root.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
apps/web/core/components/project/archive-restore-modal.tsx
Normal file
116
apps/web/core/components/project/archive-restore-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
apps/web/core/components/project/card-list.tsx
Normal file
114
apps/web/core/components/project/card-list.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
388
apps/web/core/components/project/card.tsx
Normal file
388
apps/web/core/components/project/card.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
86
apps/web/core/components/project/create-project-modal.tsx
Normal file
86
apps/web/core/components/project/create-project-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
168
apps/web/core/components/project/create/common-attributes.tsx
Normal file
168
apps/web/core/components/project/create/common-attributes.tsx
Normal 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;
|
||||
121
apps/web/core/components/project/create/header.tsx
Normal file
121
apps/web/core/components/project/create/header.tsx
Normal 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;
|
||||
@@ -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;
|
||||
160
apps/web/core/components/project/delete-project-modal.tsx
Normal file
160
apps/web/core/components/project/delete-project-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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";
|
||||
116
apps/web/core/components/project/dropdowns/filters/lead.tsx
Normal file
116
apps/web/core/components/project/dropdowns/filters/lead.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
116
apps/web/core/components/project/dropdowns/filters/members.tsx
Normal file
116
apps/web/core/components/project/dropdowns/filters/members.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
110
apps/web/core/components/project/dropdowns/filters/root.tsx
Normal file
110
apps/web/core/components/project/dropdowns/filters/root.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
90
apps/web/core/components/project/dropdowns/order-by.tsx
Normal file
90
apps/web/core/components/project/dropdowns/order-by.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
apps/web/core/components/project/empty-state.tsx
Normal file
50
apps/web/core/components/project/empty-state.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
apps/web/core/components/project/filters.tsx
Normal file
109
apps/web/core/components/project/filters.tsx
Normal 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;
|
||||
67
apps/web/core/components/project/form-loader.tsx
Normal file
67
apps/web/core/components/project/form-loader.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
466
apps/web/core/components/project/form.tsx
Normal file
466
apps/web/core/components/project/form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
apps/web/core/components/project/header.tsx
Normal file
79
apps/web/core/components/project/header.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
125
apps/web/core/components/project/integration-card.tsx
Normal file
125
apps/web/core/components/project/integration-card.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
71
apps/web/core/components/project/join-project-modal.tsx
Normal file
71
apps/web/core/components/project/join-project-modal.tsx
Normal 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 'Join Project' 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>
|
||||
);
|
||||
}
|
||||
184
apps/web/core/components/project/leave-project-modal.tsx
Normal file
184
apps/web/core/components/project/leave-project-modal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
128
apps/web/core/components/project/member-header-column.tsx
Normal file
128
apps/web/core/components/project/member-header-column.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
93
apps/web/core/components/project/member-list-item.tsx
Normal file
93
apps/web/core/components/project/member-list-item.tsx
Normal 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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
132
apps/web/core/components/project/member-list.tsx
Normal file
132
apps/web/core/components/project/member-list.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
113
apps/web/core/components/project/member-select.tsx
Normal file
113
apps/web/core/components/project/member-select.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
198
apps/web/core/components/project/multi-select-modal.tsx
Normal file
198
apps/web/core/components/project/multi-select-modal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
65
apps/web/core/components/project/project-feature-update.tsx
Normal file
65
apps/web/core/components/project/project-feature-update.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
36
apps/web/core/components/project/project-network-icon.tsx
Normal file
36
apps/web/core/components/project/project-network-icon.tsx
Normal 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)} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
343
apps/web/core/components/project/publish-project/modal.tsx
Normal file
343
apps/web/core/components/project/publish-project/modal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
101
apps/web/core/components/project/root.tsx
Normal file
101
apps/web/core/components/project/root.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
87
apps/web/core/components/project/search-projects.tsx
Normal file
87
apps/web/core/components/project/search-projects.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
150
apps/web/core/components/project/settings/features-list.tsx
Normal file
150
apps/web/core/components/project/settings/features-list.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
46
apps/web/core/components/project/settings/helper.tsx
Normal file
46
apps/web/core/components/project/settings/helper.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
194
apps/web/core/components/project/settings/member-columns.tsx
Normal file
194
apps/web/core/components/project/settings/member-columns.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user