[WEB-1883] chore: moved workspace settings to respective folders for CE and EE (#5151)
* chore: moved workspace settings to respective folders for ce and ee * chore: updated imports * chore: updated imports for ee * chore: resolved import error * chore: resolved import error * chore: ee imports in the issue sidebar * chore: updated file structure * chore: table UI * chore: resolved build errors * chore: added worklog on issue peekoverview
This commit is contained in:
parent
fff27c60e4
commit
482b363045
44 changed files with 440 additions and 285 deletions
5
packages/types/src/issues/activity/base.d.ts
vendored
5
packages/types/src/issues/activity/base.d.ts
vendored
|
|
@ -55,4 +55,9 @@ export type TIssueActivityComment =
|
||||||
id: string;
|
id: string;
|
||||||
activity_type: "ACTIVITY";
|
activity_type: "ACTIVITY";
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
activity_type: "WORKLOG";
|
||||||
|
created_at?: string;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
4
packages/types/src/workspace.d.ts
vendored
4
packages/types/src/workspace.d.ts
vendored
|
|
@ -69,12 +69,12 @@ export interface IWorkspaceMember {
|
||||||
id: string;
|
id: string;
|
||||||
member: IUserLite;
|
member: IUserLite;
|
||||||
role: EUserWorkspaceRoles;
|
role: EUserWorkspaceRoles;
|
||||||
created_at: string;
|
created_at?: string;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
first_name?: string;
|
first_name?: string;
|
||||||
last_name?: string;
|
last_name?: string;
|
||||||
joining_date: string;
|
joining_date?: string;
|
||||||
display_name?: string;
|
display_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ export const PopoverMenu = <T,>(props: TPopoverMenu<T>) => {
|
||||||
popperPadding = 0,
|
popperPadding = 0,
|
||||||
buttonClassName = "",
|
buttonClassName = "",
|
||||||
button,
|
button,
|
||||||
|
disabled,
|
||||||
panelClassName = "",
|
panelClassName = "",
|
||||||
data,
|
data,
|
||||||
popoverClassName = "",
|
popoverClassName = "",
|
||||||
|
|
@ -25,6 +26,7 @@ export const PopoverMenu = <T,>(props: TPopoverMenu<T>) => {
|
||||||
popperPadding={popperPadding}
|
popperPadding={popperPadding}
|
||||||
buttonClassName={buttonClassName}
|
buttonClassName={buttonClassName}
|
||||||
button={button}
|
button={button}
|
||||||
|
disabled={disabled}
|
||||||
panelClassName={cn(
|
panelClassName={cn(
|
||||||
"my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2 text-xs shadow-custom-shadow-rg focus:outline-none",
|
"my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2 text-xs shadow-custom-shadow-rg focus:outline-none",
|
||||||
panelClassName
|
panelClassName
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ export const Popover = (props: TPopover) => {
|
||||||
buttonClassName = "",
|
buttonClassName = "",
|
||||||
popoverClassName = "",
|
popoverClassName = "",
|
||||||
button,
|
button,
|
||||||
|
disabled = false,
|
||||||
panelClassName = "",
|
panelClassName = "",
|
||||||
children,
|
children,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
@ -36,19 +37,18 @@ export const Popover = (props: TPopover) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HeadlessReactPopover className={cn("relative flex h-full w-full items-center justify-center", popoverClassName)}>
|
<HeadlessReactPopover className={cn("relative flex h-full w-full items-center justify-center", popoverClassName)}>
|
||||||
<HeadlessReactPopover.Button ref={setReferenceElement} className="flex justify-center items-center">
|
<HeadlessReactPopover.Button
|
||||||
{button ? (
|
ref={setReferenceElement}
|
||||||
button
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex justify-center items-center text-base h-6 w-6 rounded transition-all bg-custom-background-90 hover:bg-custom-background-80",
|
{
|
||||||
|
"flex justify-center items-center text-base h-6 w-6 rounded transition-all bg-custom-background-90 hover:bg-custom-background-80":
|
||||||
|
!button,
|
||||||
|
},
|
||||||
buttonClassName
|
buttonClassName
|
||||||
)}
|
)}
|
||||||
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<EllipsisVertical className="h-3 w-3" />
|
{button ? button : <EllipsisVertical className="h-3 w-3" />}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</HeadlessReactPopover.Button>
|
</HeadlessReactPopover.Button>
|
||||||
|
|
||||||
<Transition
|
<Transition
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ export type TPopoverButtonDefaultOptions = {
|
||||||
// button and button styling
|
// button and button styling
|
||||||
button?: ReactNode;
|
button?: ReactNode;
|
||||||
buttonClassName?: string;
|
buttonClassName?: string;
|
||||||
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TPopoverDefaultOptions = TPopoverButtonDefaultOptions & {
|
export type TPopoverDefaultOptions = TPopoverButtonDefaultOptions & {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { useParams, usePathname } from "next/navigation";
|
import { useParams, usePathname } from "next/navigation";
|
||||||
// constants
|
|
||||||
import { WORKSPACE_SETTINGS_LINKS } from "@/constants/workspace";
|
|
||||||
// hooks
|
// hooks
|
||||||
import { useAppRouter } from "@/hooks/use-app-router";
|
import { useAppRouter } from "@/hooks/use-app-router";
|
||||||
|
// plane web constants
|
||||||
|
import { WORKSPACE_SETTINGS_LINKS } from "@/plane-web/constants/workspace";
|
||||||
|
|
||||||
export const MobileWorkspaceSettingsTabs = () => {
|
export const MobileWorkspaceSettingsTabs = () => {
|
||||||
const router = useAppRouter();
|
const router = useAppRouter();
|
||||||
|
|
@ -12,7 +12,8 @@ export const MobileWorkspaceSettingsTabs = () => {
|
||||||
<div className="flex-shrink-0 md:hidden sticky inset-0 flex overflow-x-auto bg-custom-background-100 z-10">
|
<div className="flex-shrink-0 md:hidden sticky inset-0 flex overflow-x-auto bg-custom-background-100 z-10">
|
||||||
{WORKSPACE_SETTINGS_LINKS.map((item, index) => (
|
{WORKSPACE_SETTINGS_LINKS.map((item, index) => (
|
||||||
<div
|
<div
|
||||||
className={`${item.highlight(pathname, `/${workspaceSlug}`)
|
className={`${
|
||||||
|
item.highlight(pathname, `/${workspaceSlug}`)
|
||||||
? "text-custom-primary-100 text-sm py-2 px-3 whitespace-nowrap flex flex-grow cursor-pointer justify-around border-b border-custom-primary-200"
|
? "text-custom-primary-100 text-sm py-2 px-3 whitespace-nowrap flex flex-grow cursor-pointer justify-around border-b border-custom-primary-200"
|
||||||
: "text-custom-text-200 flex flex-grow cursor-pointer justify-around border-b border-custom-border-200 text-sm py-2 px-3 whitespace-nowrap"
|
: "text-custom-text-200 flex flex-grow cursor-pointer justify-around border-b border-custom-border-200 text-sm py-2 px-3 whitespace-nowrap"
|
||||||
}`}
|
}`}
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,11 @@ import { observer } from "mobx-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams, usePathname } from "next/navigation";
|
import { useParams, usePathname } from "next/navigation";
|
||||||
// constants
|
// constants
|
||||||
import { EUserWorkspaceRoles, WORKSPACE_SETTINGS_LINKS } from "@/constants/workspace";
|
import { EUserWorkspaceRoles } from "@/constants/workspace";
|
||||||
// hooks
|
// hooks
|
||||||
import { useUser } from "@/hooks/store";
|
import { useUser } from "@/hooks/store";
|
||||||
|
// plane web constants
|
||||||
|
import { WORKSPACE_SETTINGS_LINKS } from "@/plane-web/constants/workspace";
|
||||||
|
|
||||||
export const WorkspaceSettingsSidebar = observer(() => {
|
export const WorkspaceSettingsSidebar = observer(() => {
|
||||||
// router
|
// router
|
||||||
|
|
@ -31,7 +33,8 @@ export const WorkspaceSettingsSidebar = observer(() => {
|
||||||
<Link key={link.key} href={`/${workspaceSlug}${link.href}`}>
|
<Link key={link.key} href={`/${workspaceSlug}${link.href}`}>
|
||||||
<span>
|
<span>
|
||||||
<div
|
<div
|
||||||
className={`rounded-md px-4 py-2 text-sm font-medium ${link.highlight(pathname, `/${workspaceSlug}`)
|
className={`rounded-md px-4 py-2 text-sm font-medium ${
|
||||||
|
link.highlight(pathname, `/${workspaceSlug}`)
|
||||||
? "bg-custom-primary-100/10 text-custom-primary-100"
|
? "bg-custom-primary-100/10 text-custom-primary-100"
|
||||||
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
|
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
|
||||||
}`}
|
}`}
|
||||||
|
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
export * from "./bulk-operations";
|
export * from "./bulk-operations";
|
||||||
|
export * from "./worklog";
|
||||||
|
|
|
||||||
2
web/ce/components/issues/worklog/activity/index.ts
Normal file
2
web/ce/components/issues/worklog/activity/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from "./root";
|
||||||
|
export * from "./worklog-create-button";
|
||||||
13
web/ce/components/issues/worklog/activity/root.tsx
Normal file
13
web/ce/components/issues/worklog/activity/root.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FC } from "react";
|
||||||
|
import { TIssueActivityComment } from "@plane/types";
|
||||||
|
|
||||||
|
type TIssueActivityWorklog = {
|
||||||
|
workspaceSlug: string;
|
||||||
|
projectId: string;
|
||||||
|
issueId: string;
|
||||||
|
activityComment: TIssueActivityComment;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IssueActivityWorklog: FC<TIssueActivityWorklog> = () => <></>;
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FC } from "react";
|
||||||
|
|
||||||
|
type TIssueActivityWorklogCreateButton = {
|
||||||
|
workspaceSlug: string;
|
||||||
|
projectId: string;
|
||||||
|
issueId: string;
|
||||||
|
disabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IssueActivityWorklogCreateButton: FC<TIssueActivityWorklogCreateButton> = () => <></>;
|
||||||
2
web/ce/components/issues/worklog/index.ts
Normal file
2
web/ce/components/issues/worklog/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from "./property";
|
||||||
|
export * from "./activity";
|
||||||
1
web/ce/components/issues/worklog/property/index.ts
Normal file
1
web/ce/components/issues/worklog/property/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./root";
|
||||||
12
web/ce/components/issues/worklog/property/root.tsx
Normal file
12
web/ce/components/issues/worklog/property/root.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FC } from "react";
|
||||||
|
|
||||||
|
type TIssueWorklogProperty = {
|
||||||
|
workspaceSlug: string;
|
||||||
|
projectId: string;
|
||||||
|
issueId: string;
|
||||||
|
disabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IssueWorklogProperty: FC<TIssueWorklogProperty> = () => <></>;
|
||||||
|
|
@ -66,7 +66,7 @@ const useProjectColumns = () => {
|
||||||
{
|
{
|
||||||
key: "Joining Date",
|
key: "Joining Date",
|
||||||
content: "Joining Date",
|
content: "Joining Date",
|
||||||
tdRender: (rowData: RowData) => <div>{getFormattedDate(rowData.member.joining_date)}</div>,
|
tdRender: (rowData: RowData) => <div>{getFormattedDate(rowData?.member?.joining_date || "")}</div>,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
return { columns, workspaceSlug, projectId, removeMemberModal, setRemoveMemberModal };
|
return { columns, workspaceSlug, projectId, removeMemberModal, setRemoveMemberModal };
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ const useMemberColumns = () => {
|
||||||
{
|
{
|
||||||
key: "Joining Date",
|
key: "Joining Date",
|
||||||
content: "Joining Date",
|
content: "Joining Date",
|
||||||
tdRender: (rowData: RowData) => <div>{getFormattedDate(rowData.member.joining_date)}</div>,
|
tdRender: (rowData: RowData) => <div>{getFormattedDate(rowData?.member?.joining_date || "")}</div>,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
return { columns, workspaceSlug, removeMemberModal, setRemoveMemberModal };
|
return { columns, workspaceSlug, removeMemberModal, setRemoveMemberModal };
|
||||||
|
|
|
||||||
23
web/ce/constants/issues.ts
Normal file
23
web/ce/constants/issues.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { TIssueActivityComment } from "@plane/types";
|
||||||
|
|
||||||
|
export enum EActivityFilterType {
|
||||||
|
ACTIVITY = "activity",
|
||||||
|
COMMENT = "comment",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TActivityFilters = EActivityFilterType;
|
||||||
|
|
||||||
|
export const ACTIVITY_FILTER_TYPE_OPTIONS: Record<EActivityFilterType, { label: string }> = {
|
||||||
|
[EActivityFilterType.ACTIVITY]: {
|
||||||
|
label: "Updates",
|
||||||
|
},
|
||||||
|
[EActivityFilterType.COMMENT]: {
|
||||||
|
label: "Comments",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const filterActivityOnSelectedFilters = (
|
||||||
|
activity: TIssueActivityComment[],
|
||||||
|
filter: TActivityFilters[]
|
||||||
|
): TIssueActivityComment[] =>
|
||||||
|
activity.filter((activity) => filter.includes(activity.activity_type as TActivityFilters));
|
||||||
63
web/ce/constants/workspace.ts
Normal file
63
web/ce/constants/workspace.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
// icons
|
||||||
|
import { SettingIcon } from "@/components/icons/attachment";
|
||||||
|
import { Props } from "@/components/icons/types";
|
||||||
|
// constants
|
||||||
|
import { EUserWorkspaceRoles } from "@/constants/workspace";
|
||||||
|
|
||||||
|
export const WORKSPACE_SETTINGS_LINKS: {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
access: EUserWorkspaceRoles;
|
||||||
|
highlight: (pathname: string, baseUrl: string) => boolean;
|
||||||
|
Icon: React.FC<Props>;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
key: "general",
|
||||||
|
label: "General",
|
||||||
|
href: `/settings`,
|
||||||
|
access: EUserWorkspaceRoles.GUEST,
|
||||||
|
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/`,
|
||||||
|
Icon: SettingIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "members",
|
||||||
|
label: "Members",
|
||||||
|
href: `/settings/members`,
|
||||||
|
access: EUserWorkspaceRoles.GUEST,
|
||||||
|
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members/`,
|
||||||
|
Icon: SettingIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "billing-and-plans",
|
||||||
|
label: "Billing and plans",
|
||||||
|
href: `/settings/billing`,
|
||||||
|
access: EUserWorkspaceRoles.ADMIN,
|
||||||
|
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/billing/`,
|
||||||
|
Icon: SettingIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "export",
|
||||||
|
label: "Exports",
|
||||||
|
href: `/settings/exports`,
|
||||||
|
access: EUserWorkspaceRoles.MEMBER,
|
||||||
|
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/exports/`,
|
||||||
|
Icon: SettingIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "webhooks",
|
||||||
|
label: "Webhooks",
|
||||||
|
href: `/settings/webhooks`,
|
||||||
|
access: EUserWorkspaceRoles.ADMIN,
|
||||||
|
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/webhooks/`,
|
||||||
|
Icon: SettingIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "api-tokens",
|
||||||
|
label: "API tokens",
|
||||||
|
href: `/settings/api-tokens`,
|
||||||
|
access: EUserWorkspaceRoles.ADMIN,
|
||||||
|
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/api-tokens/`,
|
||||||
|
Icon: SettingIcon,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -5,10 +5,12 @@ import { Command } from "cmdk";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
// constants
|
// constants
|
||||||
import { EUserWorkspaceRoles, WORKSPACE_SETTINGS_LINKS } from "@/constants/workspace";
|
import { EUserWorkspaceRoles } from "@/constants/workspace";
|
||||||
// hooks
|
// hooks
|
||||||
import { useUser } from "@/hooks/store";
|
import { useUser } from "@/hooks/store";
|
||||||
import { useAppRouter } from "@/hooks/use-app-router";
|
import { useAppRouter } from "@/hooks/use-app-router";
|
||||||
|
// plane wev constants
|
||||||
|
import { WORKSPACE_SETTINGS_LINKS } from "@/plane-web/constants/workspace";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
closePalette: () => void;
|
closePalette: () => void;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
// hooks
|
// hooks
|
||||||
import { EActivityFilterType } from "@/constants/issue";
|
|
||||||
import { useIssueDetail } from "@/hooks/store";
|
import { useIssueDetail } from "@/hooks/store";
|
||||||
|
// plane wev components
|
||||||
|
import { IssueActivityWorklog } from "@/plane-web/components/issues/worklog/activity/root";
|
||||||
|
// plane web constants
|
||||||
|
import { TActivityFilters, filterActivityOnSelectedFilters } from "@/plane-web/constants/issues";
|
||||||
// components
|
// components
|
||||||
import { IssueActivityItem } from "./activity/activity-list";
|
import { IssueActivityItem } from "./activity/activity-list";
|
||||||
import { IssueCommentCard } from "./comments/comment-card";
|
import { IssueCommentCard } from "./comments/comment-card";
|
||||||
|
|
@ -13,7 +16,7 @@ type TIssueActivityCommentRoot = {
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
issueId: string;
|
issueId: string;
|
||||||
selectedFilters: EActivityFilterType[];
|
selectedFilters: TActivityFilters[];
|
||||||
activityOperations: TActivityOperations;
|
activityOperations: TActivityOperations;
|
||||||
showAccessSpecifier?: boolean;
|
showAccessSpecifier?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
|
@ -32,14 +35,7 @@ export const IssueActivityCommentRoot: FC<TIssueActivityCommentRoot> = observer(
|
||||||
|
|
||||||
if (!activityComments || (activityComments && activityComments.length <= 0)) return <></>;
|
if (!activityComments || (activityComments && activityComments.length <= 0)) return <></>;
|
||||||
|
|
||||||
const isCommentFilterSelected = selectedFilters.includes(EActivityFilterType.COMMENT);
|
const filteredActivityComments = filterActivityOnSelectedFilters(activityComments, selectedFilters);
|
||||||
const isActivityFilterSelected = selectedFilters.includes(EActivityFilterType.ACTIVITY);
|
|
||||||
|
|
||||||
const filteredActivityComments = activityComments.filter(
|
|
||||||
(activityComment) =>
|
|
||||||
(activityComment.activity_type === "COMMENT" && isCommentFilterSelected) ||
|
|
||||||
(activityComment.activity_type === "ACTIVITY" && isActivityFilterSelected)
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -60,6 +56,13 @@ export const IssueActivityCommentRoot: FC<TIssueActivityCommentRoot> = observer(
|
||||||
activityId={activityComment.id}
|
activityId={activityComment.id}
|
||||||
ends={index === 0 ? "top" : index === filteredActivityComments.length - 1 ? "bottom" : undefined}
|
ends={index === 0 ? "top" : index === filteredActivityComments.length - 1 ? "bottom" : undefined}
|
||||||
/>
|
/>
|
||||||
|
) : activityComment.activity_type === "WORKLOG" ? (
|
||||||
|
<IssueActivityWorklog
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
projectId={projectId}
|
||||||
|
issueId={issueId}
|
||||||
|
activityComment={activityComment}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<></>
|
<></>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,14 @@ import { Check, ListFilter } from "lucide-react";
|
||||||
import { Popover, Transition } from "@headlessui/react";
|
import { Popover, Transition } from "@headlessui/react";
|
||||||
// ui
|
// ui
|
||||||
import { Button } from "@plane/ui";
|
import { Button } from "@plane/ui";
|
||||||
// constants
|
|
||||||
import { ACTIVITY_FILTER_TYPE_OPTIONS, EActivityFilterType } from "@/constants/issue";
|
|
||||||
// helper
|
// helper
|
||||||
import { cn } from "@/helpers/common.helper";
|
import { cn } from "@/helpers/common.helper";
|
||||||
|
// constants
|
||||||
|
import { TActivityFilters, ACTIVITY_FILTER_TYPE_OPTIONS } from "@/plane-web/constants/issues";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
selectedFilters: EActivityFilterType[];
|
selectedFilters: TActivityFilters[];
|
||||||
toggleFilter: (filter: EActivityFilterType) => void;
|
toggleFilter: (filter: TActivityFilters) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ActivityFilter: FC<Props> = observer((props) => {
|
export const ActivityFilter: FC<Props> = observer((props) => {
|
||||||
|
|
@ -42,13 +42,15 @@ export const ActivityFilter: FC<Props> = observer((props) => {
|
||||||
>
|
>
|
||||||
<Popover.Panel className="absolute mt-2 right-0 z-10 min-w-40">
|
<Popover.Panel className="absolute mt-2 right-0 z-10 min-w-40">
|
||||||
<div className="p-2 rounded-md border border-custom-border-200 bg-custom-background-100">
|
<div className="p-2 rounded-md border border-custom-border-200 bg-custom-background-100">
|
||||||
{ACTIVITY_FILTER_TYPE_OPTIONS.map((filter) => {
|
{Object.keys(ACTIVITY_FILTER_TYPE_OPTIONS).map((key) => {
|
||||||
const isSelected = selectedFilters.includes(filter.value);
|
const filterKey = key as TActivityFilters;
|
||||||
|
const filter = ACTIVITY_FILTER_TYPE_OPTIONS[filterKey];
|
||||||
|
const isSelected = selectedFilters.includes(filterKey);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={filter.value}
|
key={filterKey}
|
||||||
className="flex items-center gap-2 text-sm cursor-pointer px-2 p-1 transition-all hover:bg-custom-background-80 rounded-sm"
|
className="flex items-center gap-2 text-sm cursor-pointer px-2 p-1 transition-all hover:bg-custom-background-80 rounded-sm"
|
||||||
onClick={() => toggleFilter(filter.value)}
|
onClick={() => toggleFilter(filterKey)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,14 @@ import { TIssueComment } from "@plane/types";
|
||||||
// ui
|
// ui
|
||||||
import { TOAST_TYPE, setToast } from "@plane/ui";
|
import { TOAST_TYPE, setToast } from "@plane/ui";
|
||||||
// components
|
// components
|
||||||
import { ActivityFilter, IssueActivityCommentRoot, IssueCommentCreate } from "@/components/issues";
|
import { ActivityFilter, IssueCommentCreate } from "@/components/issues";
|
||||||
// constants
|
import { IssueActivityCommentRoot } from "@/components/issues/issue-detail";
|
||||||
import { EActivityFilterType } from "@/constants/issue";
|
|
||||||
// hooks
|
// hooks
|
||||||
import { useIssueDetail, useProject } from "@/hooks/store";
|
import { useIssueDetail, useProject } from "@/hooks/store";
|
||||||
|
// plane web components
|
||||||
|
import { IssueActivityWorklogCreateButton } from "@/plane-web/components/issues/worklog";
|
||||||
|
// plane web constants
|
||||||
|
import { TActivityFilters, EActivityFilterType } from "@/plane-web/constants/issues";
|
||||||
|
|
||||||
type TIssueActivity = {
|
type TIssueActivity = {
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
|
|
@ -32,9 +35,12 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
|
||||||
const { createComment, updateComment, removeComment } = useIssueDetail();
|
const { createComment, updateComment, removeComment } = useIssueDetail();
|
||||||
const { getProjectById } = useProject();
|
const { getProjectById } = useProject();
|
||||||
// state
|
// state
|
||||||
const [selectedFilters, setSelectedFilters] = useState([EActivityFilterType.COMMENT, EActivityFilterType.ACTIVITY]);
|
const [selectedFilters, setSelectedFilters] = useState<TActivityFilters[]>([
|
||||||
|
EActivityFilterType.ACTIVITY,
|
||||||
|
EActivityFilterType.COMMENT,
|
||||||
|
]);
|
||||||
// toggle filter
|
// toggle filter
|
||||||
const toggleFilter = (filter: EActivityFilterType) => {
|
const toggleFilter = (filter: TActivityFilters) => {
|
||||||
setSelectedFilters((prevFilters) => {
|
setSelectedFilters((prevFilters) => {
|
||||||
if (prevFilters.includes(filter)) {
|
if (prevFilters.includes(filter)) {
|
||||||
if (prevFilters.length === 1) return prevFilters; // Ensure at least one filter is applied
|
if (prevFilters.length === 1) return prevFilters; // Ensure at least one filter is applied
|
||||||
|
|
@ -110,8 +116,16 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
|
||||||
{/* header */}
|
{/* header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="text-lg text-custom-text-100">Activity</div>
|
<div className="text-lg text-custom-text-100">Activity</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<IssueActivityWorklogCreateButton
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
projectId={projectId}
|
||||||
|
issueId={issueId}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
<ActivityFilter selectedFilters={selectedFilters} toggleFilter={toggleFilter} />
|
<ActivityFilter selectedFilters={selectedFilters} toggleFilter={toggleFilter} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* rendering activity */}
|
{/* rendering activity */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"
|
||||||
import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
|
import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
|
||||||
// hooks
|
// hooks
|
||||||
import { useProjectEstimates, useIssueDetail, useProject, useProjectState, useMember } from "@/hooks/store";
|
import { useProjectEstimates, useIssueDetail, useProject, useProjectState, useMember } from "@/hooks/store";
|
||||||
|
// plane web components
|
||||||
|
import { IssueWorklogProperty } from "@/plane-web/components/issues";
|
||||||
// components
|
// components
|
||||||
import type { TIssueOperations } from "./root";
|
import type { TIssueOperations } from "./root";
|
||||||
|
|
||||||
|
|
@ -279,6 +281,13 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<IssueWorklogProperty
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
projectId={projectId}
|
||||||
|
issueId={issueId}
|
||||||
|
disabled={!isEditable}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ import { cn } from "@/helpers/common.helper";
|
||||||
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
|
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
|
||||||
import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
|
import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
|
||||||
import { useIssueDetail, useMember, useProject, useProjectState } from "@/hooks/store";
|
import { useIssueDetail, useMember, useProject, useProjectState } from "@/hooks/store";
|
||||||
|
// plane web components
|
||||||
|
import { IssueWorklogProperty } from "@/plane-web/components/issues";
|
||||||
|
|
||||||
interface IPeekOverviewProperties {
|
interface IPeekOverviewProperties {
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
|
|
@ -279,6 +281,13 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
||||||
<IssueLabel workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} disabled={disabled} />
|
<IssueLabel workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} disabled={disabled} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<IssueWorklogProperty
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
projectId={projectId}
|
||||||
|
issueId={issueId}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { Button } from "@plane/ui";
|
||||||
import { useProject, useUser } from "@/hooks/store";
|
import { useProject, useUser } from "@/hooks/store";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data: IUserLite;
|
data: Partial<IUserLite>;
|
||||||
onSubmit: () => Promise<void>;
|
onSubmit: () => Promise<void>;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ export const ProjectMemberListItem: React.FC<Props> = observer((props) => {
|
||||||
)}
|
)}
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={memberDetails?.filter((member): member is IProjectMemberDetails => member !== null) ?? []}
|
data={(memberDetails?.filter((member): member is IProjectMemberDetails => member !== null) ?? []) as any}
|
||||||
keyExtractor={(rowData) => rowData?.member.id ?? ""}
|
keyExtractor={(rowData) => rowData?.member.id ?? ""}
|
||||||
thClassName="text-left font-medium divide-x-0 border-b border-t divide-custom-border-200"
|
thClassName="text-left font-medium divide-x-0 border-b border-t divide-custom-border-200"
|
||||||
tBodyClassName="divide-y-0"
|
tBodyClassName="divide-y-0"
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import { WORKSPACE_MEMBER_LEAVE } from "@/constants/event-tracker";
|
||||||
// hooks
|
// hooks
|
||||||
import { useEventTracker, useMember, useUser } from "@/hooks/store";
|
import { useEventTracker, useMember, useUser } from "@/hooks/store";
|
||||||
import { useAppRouter } from "@/hooks/use-app-router";
|
import { useAppRouter } from "@/hooks/use-app-router";
|
||||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
|
||||||
import useMemberColumns from "@/plane-web/components/workspace/settings/useMemberColumns";
|
import useMemberColumns from "@/plane-web/components/workspace/settings/useMemberColumns";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -33,7 +32,6 @@ export const WorkspaceMembersListItem: FC<Props> = observer((props) => {
|
||||||
workspace: { removeMemberFromWorkspace },
|
workspace: { removeMemberFromWorkspace },
|
||||||
} = useMember();
|
} = useMember();
|
||||||
const { captureEvent } = useEventTracker();
|
const { captureEvent } = useEventTracker();
|
||||||
const { isMobile } = usePlatformOS();
|
|
||||||
// derived values
|
// derived values
|
||||||
|
|
||||||
const handleLeaveWorkspace = async () => {
|
const handleLeaveWorkspace = async () => {
|
||||||
|
|
@ -96,9 +94,10 @@ export const WorkspaceMembersListItem: FC<Props> = observer((props) => {
|
||||||
)}
|
)}
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={memberDetails?.filter((member): member is IWorkspaceMember => member !== null) ?? []}
|
data={(memberDetails?.filter((member): member is IWorkspaceMember => member !== null) ?? []) as any}
|
||||||
keyExtractor={(rowData) => rowData?.member.id ?? ""}
|
keyExtractor={(rowData) => rowData?.member.id ?? ""}
|
||||||
thClassName="text-left font-medium divide-x-0 border-b border-t divide-custom-border-200"
|
tHeadClassName="border-b border-custom-border-100"
|
||||||
|
thClassName="text-left font-medium divide-x-0"
|
||||||
tBodyClassName="divide-y-0"
|
tBodyClassName="divide-y-0"
|
||||||
tBodyTrClassName="divide-x-0"
|
tBodyTrClassName="divide-x-0"
|
||||||
tHeadTrClassName="divide-x-0"
|
tHeadTrClassName="divide-x-0"
|
||||||
|
|
|
||||||
|
|
@ -456,19 +456,3 @@ export const groupReactionEmojis = (reactions: any) => {
|
||||||
|
|
||||||
return groupedEmojis;
|
return groupedEmojis;
|
||||||
};
|
};
|
||||||
|
|
||||||
export enum EActivityFilterType {
|
|
||||||
COMMENT = "COMMENT",
|
|
||||||
ACTIVITY = "ACTIVITY",
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ACTIVITY_FILTER_TYPE_OPTIONS = [
|
|
||||||
{
|
|
||||||
value: EActivityFilterType.COMMENT,
|
|
||||||
label: "Comments",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: EActivityFilterType.ACTIVITY,
|
|
||||||
label: "Updates",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
// types
|
// types
|
||||||
import { TStaticViewTypes } from "@plane/types";
|
import { TStaticViewTypes } from "@plane/types";
|
||||||
// icons
|
|
||||||
import { SettingIcon } from "@/components/icons/attachment";
|
|
||||||
import { Props } from "@/components/icons/types";
|
|
||||||
// services images
|
// services images
|
||||||
import CSVLogo from "@/public/services/csv.svg";
|
import CSVLogo from "@/public/services/csv.svg";
|
||||||
import ExcelLogo from "@/public/services/excel.svg";
|
import ExcelLogo from "@/public/services/excel.svg";
|
||||||
|
|
@ -135,61 +132,3 @@ export const RESTRICTED_URLS = [
|
||||||
"spaces",
|
"spaces",
|
||||||
"workspace-invitations",
|
"workspace-invitations",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const WORKSPACE_SETTINGS_LINKS: {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
href: string;
|
|
||||||
access: EUserWorkspaceRoles;
|
|
||||||
highlight: (pathname: string, baseUrl: string) => boolean;
|
|
||||||
Icon: React.FC<Props>;
|
|
||||||
}[] = [
|
|
||||||
{
|
|
||||||
key: "general",
|
|
||||||
label: "General",
|
|
||||||
href: `/settings`,
|
|
||||||
access: EUserWorkspaceRoles.GUEST,
|
|
||||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/`,
|
|
||||||
Icon: SettingIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "members",
|
|
||||||
label: "Members",
|
|
||||||
href: `/settings/members`,
|
|
||||||
access: EUserWorkspaceRoles.GUEST,
|
|
||||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members/`,
|
|
||||||
Icon: SettingIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "billing-and-plans",
|
|
||||||
label: "Billing and plans",
|
|
||||||
href: `/settings/billing`,
|
|
||||||
access: EUserWorkspaceRoles.ADMIN,
|
|
||||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/billing/`,
|
|
||||||
Icon: SettingIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "export",
|
|
||||||
label: "Exports",
|
|
||||||
href: `/settings/exports`,
|
|
||||||
access: EUserWorkspaceRoles.MEMBER,
|
|
||||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/exports/`,
|
|
||||||
Icon: SettingIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "webhooks",
|
|
||||||
label: "Webhooks",
|
|
||||||
href: `/settings/webhooks`,
|
|
||||||
access: EUserWorkspaceRoles.ADMIN,
|
|
||||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/webhooks/`,
|
|
||||||
Icon: SettingIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "api-tokens",
|
|
||||||
label: "API tokens",
|
|
||||||
href: `/settings/api-tokens`,
|
|
||||||
access: EUserWorkspaceRoles.ADMIN,
|
|
||||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/api-tokens/`,
|
|
||||||
Icon: SettingIcon,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
|
||||||
2
web/ee/components/issues/activity/index.ts
Normal file
2
web/ee/components/issues/activity/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from "./root";
|
||||||
|
export * from "./worklog-create-button";
|
||||||
1
web/ee/components/issues/activity/root.tsx
Normal file
1
web/ee/components/issues/activity/root.tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "@/plane-web/components/issues/worklog/activity/root";
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "@/plane-web/components/issues/worklog/activity/worklog-create-button";
|
||||||
1
web/ee/components/issues/properties/index.ts
Normal file
1
web/ee/components/issues/properties/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./root";
|
||||||
1
web/ee/components/issues/properties/root.tsx
Normal file
1
web/ee/components/issues/properties/root.tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "@/plane-web/components/issues/worklog/property/root";
|
||||||
1
web/ee/constants/issues.ts
Normal file
1
web/ee/constants/issues.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "ce/constants/issues";
|
||||||
1
web/ee/constants/workspace.ts
Normal file
1
web/ee/constants/workspace.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "ce/constants/workspace";
|
||||||
2
web/ee/issues/index.ts
Normal file
2
web/ee/issues/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from "./worklog/activity";
|
||||||
|
export * from "./worklog/property";
|
||||||
2
web/ee/issues/worklog/activity/index.ts
Normal file
2
web/ee/issues/worklog/activity/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from "./root";
|
||||||
|
export * from "./worklog-create-button";
|
||||||
1
web/ee/issues/worklog/activity/root.tsx
Normal file
1
web/ee/issues/worklog/activity/root.tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "@/plane-web/components/issues/worklog/activity/root";
|
||||||
1
web/ee/issues/worklog/activity/worklog-create-button.tsx
Normal file
1
web/ee/issues/worklog/activity/worklog-create-button.tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "@/plane-web/components/issues/worklog/activity/worklog-create-button";
|
||||||
2
web/ee/issues/worklog/index.ts
Normal file
2
web/ee/issues/worklog/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from "./property";
|
||||||
|
export * from "./activity";
|
||||||
1
web/ee/issues/worklog/property/index.ts
Normal file
1
web/ee/issues/worklog/property/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./root";
|
||||||
1
web/ee/issues/worklog/property/root.tsx
Normal file
1
web/ee/issues/worklog/property/root.tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "@/plane-web/components/issues/worklog/property/root";
|
||||||
|
|
@ -299,3 +299,38 @@ export const getCurrentDateTimeInISO = () => {
|
||||||
const date = new Date();
|
const date = new Date();
|
||||||
return date.toISOString();
|
return date.toISOString();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description converts hours and minutes to minutes
|
||||||
|
* @param { number } hours
|
||||||
|
* @param { number } minutes
|
||||||
|
* @returns { number } minutes
|
||||||
|
* @example convertHoursMinutesToMinutes(2, 30) // Output: 150
|
||||||
|
*/
|
||||||
|
export const convertHoursMinutesToMinutes = (hours: number, minutes: number): number => hours * 60 + minutes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description converts minutes to hours and minutes
|
||||||
|
* @param { number } mins
|
||||||
|
* @returns { number, number } hours and minutes
|
||||||
|
* @example convertMinutesToHoursAndMinutes(150) // Output: { hours: 2, minutes: 30 }
|
||||||
|
*/
|
||||||
|
export const convertMinutesToHoursAndMinutes = (mins: number): { hours: number; minutes: number } => {
|
||||||
|
const hours = Math.floor(mins / 60);
|
||||||
|
const minutes = Math.floor(mins % 60);
|
||||||
|
|
||||||
|
return { hours: hours, minutes: minutes };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description converts minutes to days, hours and minutes
|
||||||
|
* @param { number } totalMinutes
|
||||||
|
* @returns { string } days, hours and minutes
|
||||||
|
*/
|
||||||
|
export const convertMinutesToDaysHoursMinutes = (totalMinutes: number): string => {
|
||||||
|
const days = Math.floor(totalMinutes / (60 * 24));
|
||||||
|
const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
|
||||||
|
const minutes = totalMinutes % 60;
|
||||||
|
|
||||||
|
return `${days ? `${days}d ` : ``}${hours ? `${hours}h ` : ``}${minutes ? `${minutes}m ` : ``} `;
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue