Refactor folder structure (#4759)
This commit is contained in:
parent
a0e16692da
commit
346bc2afe2
1218 changed files with 187 additions and 177 deletions
742
web/core/components/core/activity.tsx
Normal file
742
web/core/components/core/activity.tsx
Normal file
|
|
@ -0,0 +1,742 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// store hooks
|
||||
// icons
|
||||
import {
|
||||
TagIcon,
|
||||
CopyPlus,
|
||||
Calendar,
|
||||
Link2Icon,
|
||||
Users2Icon,
|
||||
ArchiveIcon,
|
||||
PaperclipIcon,
|
||||
ContrastIcon,
|
||||
TriangleIcon,
|
||||
LayoutGridIcon,
|
||||
SignalMediumIcon,
|
||||
MessageSquareIcon,
|
||||
UsersIcon,
|
||||
Inbox,
|
||||
} from "lucide-react";
|
||||
import { IIssueActivity } from "@plane/types";
|
||||
import { Tooltip, BlockedIcon, BlockerIcon, RelatedIcon, LayersIcon, DiceIcon } from "@plane/ui";
|
||||
// helpers
|
||||
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
import { capitalizeFirstLetter } from "@/helpers/string.helper";
|
||||
import { useLabel } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// types
|
||||
|
||||
export const IssueLink = ({ activity }: { activity: IIssueActivity }) => {
|
||||
// router params
|
||||
const { workspaceSlug } = useParams();
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
tooltipContent={activity?.issue_detail ? activity.issue_detail.name : "This issue has been deleted"}
|
||||
isMobile={isMobile}
|
||||
>
|
||||
{activity?.issue_detail ? (
|
||||
<a
|
||||
aria-disabled={activity.issue === null}
|
||||
href={`${`/${workspaceSlug ?? activity.workspace_detail?.slug}/projects/${activity.project}/issues/${
|
||||
activity.issue
|
||||
}`}`}
|
||||
target={activity.issue === null ? "_self" : "_blank"}
|
||||
rel={activity.issue === null ? "" : "noopener noreferrer"}
|
||||
className="inline items-center gap-1 font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
<span className="whitespace-nowrap">{`${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}`}</span>{" "}
|
||||
<span className="font-normal break-all">{activity.issue_detail?.name}</span>
|
||||
</a>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 font-medium text-custom-text-100 whitespace-nowrap">
|
||||
{" an Issue"}{" "}
|
||||
</span>
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const UserLink = ({ activity }: { activity: IIssueActivity }) => {
|
||||
// router params
|
||||
const { workspaceSlug } = useParams();
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`/${workspaceSlug ?? activity.workspace_detail?.slug}/profile/${
|
||||
activity.new_identifier ?? activity.old_identifier
|
||||
}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
{activity.new_value && activity.new_value !== "" ? activity.new_value : activity.old_value}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
const LabelPill = observer(({ labelId, workspaceSlug }: { labelId: string; workspaceSlug: string }) => {
|
||||
// store hooks
|
||||
const { workspaceLabels, fetchWorkspaceLabels } = useLabel();
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceLabels) fetchWorkspaceLabels(workspaceSlug);
|
||||
}, [fetchWorkspaceLabels, workspaceLabels, workspaceSlug]);
|
||||
|
||||
return (
|
||||
<span
|
||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: workspaceLabels?.find((l) => l.id === labelId)?.color ?? "#000000",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const inboxActivityMessage = {
|
||||
declined: {
|
||||
showIssue: "declined issue",
|
||||
noIssue: "declined this issue from inbox.",
|
||||
},
|
||||
snoozed: {
|
||||
showIssue: "snoozed issue",
|
||||
noIssue: "snoozed this issue.",
|
||||
},
|
||||
accepted: {
|
||||
showIssue: "accepted issue",
|
||||
noIssue: "accepted this issue from inbox.",
|
||||
},
|
||||
markedDuplicate: {
|
||||
showIssue: "declined issue",
|
||||
noIssue: "declined this issue from inbox by marking a duplicate issue.",
|
||||
},
|
||||
};
|
||||
|
||||
const getInboxUserActivityMessage = (activity: IIssueActivity, showIssue: boolean) => {
|
||||
switch (activity.verb) {
|
||||
case "-1":
|
||||
return showIssue ? inboxActivityMessage.declined.showIssue : inboxActivityMessage.declined.noIssue;
|
||||
case "0":
|
||||
return showIssue ? inboxActivityMessage.snoozed.showIssue : inboxActivityMessage.snoozed.noIssue;
|
||||
case "1":
|
||||
return showIssue ? inboxActivityMessage.accepted.showIssue : inboxActivityMessage.accepted.noIssue;
|
||||
case "2":
|
||||
return showIssue ? inboxActivityMessage.markedDuplicate.showIssue : inboxActivityMessage.markedDuplicate.noIssue;
|
||||
default:
|
||||
return "updated inbox issue status.";
|
||||
}
|
||||
};
|
||||
|
||||
const activityDetails: {
|
||||
[key: string]: {
|
||||
message: (activity: IIssueActivity, showIssue: boolean, workspaceSlug: string) => React.ReactNode;
|
||||
icon: React.ReactNode;
|
||||
};
|
||||
} = {
|
||||
assignees: {
|
||||
message: (activity, showIssue) => {
|
||||
if (activity.old_value === "")
|
||||
return (
|
||||
<>
|
||||
added a new assignee <UserLink activity={activity} />
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
to <IssueLink activity={activity} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<>
|
||||
removed the assignee <UserLink activity={activity} />
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
from <IssueLink activity={activity} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <Users2Icon size={12} color="#6b7280" aria-hidden="true" />,
|
||||
},
|
||||
archived_at: {
|
||||
message: (activity) => {
|
||||
if (activity.new_value === "restore")
|
||||
return (
|
||||
<>
|
||||
restored <IssueLink activity={activity} />
|
||||
</>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<>
|
||||
archived <IssueLink activity={activity} />
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <ArchiveIcon size={12} color="#6b7280" aria-hidden="true" />,
|
||||
},
|
||||
attachment: {
|
||||
message: (activity, showIssue) => {
|
||||
if (activity.verb === "created")
|
||||
return (
|
||||
<>
|
||||
uploaded a new{" "}
|
||||
<a
|
||||
href={`${activity.new_value}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
attachment
|
||||
</a>
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
to <IssueLink activity={activity} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<>
|
||||
removed an attachment
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
from <IssueLink activity={activity} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <PaperclipIcon size={12} color="#6b7280" aria-hidden="true" />,
|
||||
},
|
||||
description: {
|
||||
message: (activity, showIssue) => (
|
||||
<>
|
||||
updated the description
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
of <IssueLink activity={activity} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
icon: <MessageSquareIcon size={12} color="#6b7280" aria-hidden="true" />,
|
||||
},
|
||||
estimate_point: {
|
||||
message: (activity, showIssue) => {
|
||||
if (!activity.new_value)
|
||||
return (
|
||||
<>
|
||||
removed the estimate point
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
from <IssueLink activity={activity} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<>
|
||||
set the estimate point to {activity.new_value}
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
for <IssueLink activity={activity} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <TriangleIcon size={12} color="#6b7280" aria-hidden="true" />,
|
||||
},
|
||||
issue: {
|
||||
message: (activity) => {
|
||||
if (activity.verb === "created")
|
||||
return (
|
||||
<>
|
||||
created <IssueLink activity={activity} />
|
||||
</>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<>
|
||||
deleted <IssueLink activity={activity} />
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <LayersIcon width={12} height={12} color="#6b7280" aria-hidden="true" />,
|
||||
},
|
||||
labels: {
|
||||
message: (activity, showIssue, workspaceSlug) => {
|
||||
if (activity.old_value === "")
|
||||
return (
|
||||
<span className="overflow-hidden">
|
||||
added a new label{" "}
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
|
||||
<LabelPill labelId={activity.new_identifier ?? ""} workspaceSlug={workspaceSlug} />
|
||||
<span className="flex-shrink font-medium text-custom-text-100 break-all line-clamp-1">
|
||||
{activity.new_value}
|
||||
</span>
|
||||
</span>
|
||||
{showIssue && (
|
||||
<span className="">
|
||||
{" "}
|
||||
to <IssueLink activity={activity} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<>
|
||||
removed the label{" "}
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
|
||||
<LabelPill labelId={activity.old_identifier ?? ""} workspaceSlug={workspaceSlug} />
|
||||
<span className="flex-shrink font-medium text-custom-text-100 break-all line-clamp-1">
|
||||
{activity.old_value}
|
||||
</span>
|
||||
</span>
|
||||
{showIssue && (
|
||||
<span>
|
||||
{" "}
|
||||
from <IssueLink activity={activity} />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <TagIcon size={12} color="#6b7280" aria-hidden="true" />,
|
||||
},
|
||||
link: {
|
||||
message: (activity, showIssue) => {
|
||||
if (activity.verb === "created")
|
||||
return (
|
||||
<>
|
||||
added this{" "}
|
||||
<a
|
||||
href={`${activity.new_value}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
link
|
||||
</a>
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
to <IssueLink activity={activity} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
else if (activity.verb === "updated")
|
||||
return (
|
||||
<>
|
||||
updated the{" "}
|
||||
<a
|
||||
href={`${activity.old_value}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
link
|
||||
</a>
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
from <IssueLink activity={activity} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<>
|
||||
removed this{" "}
|
||||
<a
|
||||
href={`${activity.old_value}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
link
|
||||
</a>
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
from <IssueLink activity={activity} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <Link2Icon size={12} color="#6b7280" aria-hidden="true" />,
|
||||
},
|
||||
cycles: {
|
||||
message: (activity, showIssue, workspaceSlug) => {
|
||||
if (activity.verb === "created")
|
||||
return (
|
||||
<>
|
||||
<span className="flex-shrink-0">
|
||||
added {showIssue ? <IssueLink activity={activity} /> : "this issue"}{" "}
|
||||
<span className="whitespace-nowrap">to the cycle</span>{" "}
|
||||
</span>
|
||||
<a
|
||||
href={`/${workspaceSlug}/projects/${activity.project}/cycles/${activity.new_identifier}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline items-center gap-1 font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
<span className="break-all">{activity.new_value}</span>
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
else if (activity.verb === "updated")
|
||||
return (
|
||||
<>
|
||||
<span className="flex-shrink-0 whitespace-nowrap">set the cycle to </span>
|
||||
<a
|
||||
href={`/${workspaceSlug}/projects/${activity.project}/cycles/${activity.new_identifier}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline items-center gap-1 font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
<span className="break-all">{activity.new_value}</span>
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<>
|
||||
removed <IssueLink activity={activity} /> from the cycle{" "}
|
||||
<a
|
||||
href={`/${workspaceSlug}/projects/${activity.project}/cycles/${activity.old_identifier}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline items-center gap-1 font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
<span className="break-all">{activity.old_value}</span>
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <ContrastIcon size={12} color="#6b7280" aria-hidden="true" />,
|
||||
},
|
||||
modules: {
|
||||
message: (activity, showIssue, workspaceSlug) => {
|
||||
if (activity.verb === "created")
|
||||
return (
|
||||
<>
|
||||
added {showIssue ? <IssueLink activity={activity} /> : "this issue"} to the module{" "}
|
||||
<a
|
||||
href={`/${workspaceSlug}/projects/${activity.project}/modules/${activity.new_identifier}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline items-center gap-1 font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
<span className="break-all">{activity.new_value}</span>
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
else if (activity.verb === "updated")
|
||||
return (
|
||||
<>
|
||||
set the module to{" "}
|
||||
<a
|
||||
href={`/${workspaceSlug}/projects/${activity.project}/modules/${activity.new_identifier}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline items-center gap-1 font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
<span className="break-all">{activity.new_value}</span>
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<>
|
||||
removed <IssueLink activity={activity} /> from the module{" "}
|
||||
<a
|
||||
href={`/${workspaceSlug}/projects/${activity.project}/modules/${activity.old_identifier}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline items-center gap-1 font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
<span className="break-all">{activity.old_value}</span>
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <DiceIcon className="h-3 w-3 !text-[#6b7280]" aria-hidden="true" />,
|
||||
},
|
||||
name: {
|
||||
message: (activity, showIssue) => (
|
||||
<>
|
||||
set the title to <span className="break-all">{activity.new_value}</span>
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
of <IssueLink activity={activity} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
icon: <MessageSquareIcon size={12} color="#6b7280" aria-hidden="true" />,
|
||||
},
|
||||
parent: {
|
||||
message: (activity, showIssue) => {
|
||||
if (!activity.new_value)
|
||||
return (
|
||||
<>
|
||||
removed the parent{" "}
|
||||
<span className="font-medium text-custom-text-100 whitespace-nowrap">{activity.old_value}</span>
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
from <IssueLink activity={activity} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<>
|
||||
set the parent to{" "}
|
||||
<span className="font-medium text-custom-text-100 whitespace-nowrap">{activity.new_value}</span>
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
for <IssueLink activity={activity} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <UsersIcon className="h-3 w-3 !text-[#6b7280]" aria-hidden="true" />,
|
||||
},
|
||||
priority: {
|
||||
message: (activity, showIssue) => (
|
||||
<>
|
||||
set the priority to{" "}
|
||||
<span className="font-medium text-custom-text-100">
|
||||
{activity.new_value ? capitalizeFirstLetter(activity.new_value) : "None"}
|
||||
</span>
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
for <IssueLink activity={activity} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
icon: <SignalMediumIcon size={12} color="#6b7280" aria-hidden="true" />,
|
||||
},
|
||||
relates_to: {
|
||||
message: (activity, showIssue) => {
|
||||
if (activity.old_value === "")
|
||||
return (
|
||||
<>
|
||||
marked that {showIssue ? <IssueLink activity={activity} /> : "this issue"} relates to{" "}
|
||||
<span className="font-medium text-custom-text-100 whitespace-nowrap">{activity.new_value}</span>.
|
||||
</>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<>
|
||||
removed the relation from{" "}
|
||||
<span className="font-medium text-custom-text-100 whitespace-nowrap">{activity.old_value}</span>.
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <RelatedIcon height="12" width="12" color="#6b7280" />,
|
||||
},
|
||||
blocking: {
|
||||
message: (activity, showIssue) => {
|
||||
if (activity.old_value === "")
|
||||
return (
|
||||
<>
|
||||
marked {showIssue ? <IssueLink activity={activity} /> : "this issue"} is blocking issue{" "}
|
||||
<span className="font-medium text-custom-text-100 whitespace-nowrap">{activity.new_value}</span>.
|
||||
</>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<>
|
||||
removed the blocking issue{" "}
|
||||
<span className="font-medium text-custom-text-100 whitespace-nowrap">{activity.old_value}</span>.
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <BlockerIcon height="12" width="12" color="#6b7280" />,
|
||||
},
|
||||
blocked_by: {
|
||||
message: (activity, showIssue) => {
|
||||
if (activity.old_value === "")
|
||||
return (
|
||||
<>
|
||||
marked {showIssue ? <IssueLink activity={activity} /> : "this issue"} is being blocked by{" "}
|
||||
<span className="font-medium text-custom-text-100 whitespace-nowrap">{activity.new_value}</span>.
|
||||
</>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<>
|
||||
removed {showIssue ? <IssueLink activity={activity} /> : "this issue"} being blocked by issue{" "}
|
||||
<span className="font-medium text-custom-text-100 whitespace-nowrap">{activity.old_value}</span>.
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <BlockedIcon height="12" width="12" color="#6b7280" />,
|
||||
},
|
||||
duplicate: {
|
||||
message: (activity, showIssue) => {
|
||||
if (activity.old_value === "")
|
||||
return (
|
||||
<>
|
||||
marked {showIssue ? <IssueLink activity={activity} /> : "this issue"} as duplicate of{" "}
|
||||
<span className="font-medium text-custom-text-100 whitespace-nowrap">{activity.new_value}</span>.
|
||||
</>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<>
|
||||
removed {showIssue ? <IssueLink activity={activity} /> : "this issue"} as a duplicate of{" "}
|
||||
<span className="font-medium text-custom-text-100 whitespace-nowrap">{activity.old_value}</span>.
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <CopyPlus size={12} color="#6b7280" />,
|
||||
},
|
||||
state: {
|
||||
message: (activity, showIssue) => (
|
||||
<>
|
||||
set the state to <span className="font-medium text-custom-text-100 break-all">{activity.new_value}</span>
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
for <IssueLink activity={activity} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
icon: <LayoutGridIcon size={12} color="#6b7280" aria-hidden="true" />,
|
||||
},
|
||||
start_date: {
|
||||
message: (activity, showIssue) => {
|
||||
if (!activity.new_value)
|
||||
return (
|
||||
<>
|
||||
removed the start date
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
from <IssueLink activity={activity} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<>
|
||||
set the start date to{" "}
|
||||
<span className="font-medium text-custom-text-100 whitespace-nowrap">
|
||||
{renderFormattedDate(activity.new_value)}
|
||||
</span>
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
for <IssueLink activity={activity} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <Calendar size={12} color="#6b7280" aria-hidden="true" />,
|
||||
},
|
||||
target_date: {
|
||||
message: (activity, showIssue) => {
|
||||
if (!activity.new_value)
|
||||
return (
|
||||
<>
|
||||
removed the due date
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
from <IssueLink activity={activity} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<>
|
||||
set the due date to{" "}
|
||||
<span className="font-medium text-custom-text-100 whitespace-nowrap">
|
||||
{renderFormattedDate(activity.new_value)}
|
||||
</span>
|
||||
{showIssue && (
|
||||
<>
|
||||
<IssueLink activity={activity} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <Calendar size={12} color="#6b7280" aria-hidden="true" />,
|
||||
},
|
||||
inbox: {
|
||||
message: (activity, showIssue) => (
|
||||
<>
|
||||
{getInboxUserActivityMessage(activity, showIssue)}
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
<IssueLink activity={activity} />
|
||||
</>
|
||||
)}
|
||||
{activity.verb === "2" && ` from inbox by marking a duplicate issue.`}
|
||||
</>
|
||||
),
|
||||
icon: <Inbox size={12} color="#6b7280" aria-hidden="true" />,
|
||||
},
|
||||
};
|
||||
|
||||
export const ActivityIcon = ({ activity }: { activity: IIssueActivity }) => (
|
||||
<>{activityDetails[activity.field as keyof typeof activityDetails]?.icon}</>
|
||||
);
|
||||
|
||||
type ActivityMessageProps = {
|
||||
activity: IIssueActivity;
|
||||
showIssue?: boolean;
|
||||
};
|
||||
|
||||
export const ActivityMessage = ({ activity, showIssue = false }: ActivityMessageProps) => {
|
||||
// router params
|
||||
const { workspaceSlug } = useParams();
|
||||
|
||||
return (
|
||||
<>
|
||||
{activityDetails[activity.field as keyof typeof activityDetails]?.message(
|
||||
activity,
|
||||
showIssue,
|
||||
workspaceSlug ? workspaceSlug.toString() : activity.workspace_detail?.slug ?? ""
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
28
web/core/components/core/app-header.tsx
Normal file
28
web/core/components/core/app-header.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
// components
|
||||
import { SidebarHamburgerToggle } from "@/components/core";
|
||||
|
||||
export interface AppHeaderProps {
|
||||
header: ReactNode;
|
||||
mobileHeader?: ReactNode;
|
||||
}
|
||||
|
||||
export const AppHeader = (props: AppHeaderProps) => {
|
||||
const { header, mobileHeader } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="z-[15]">
|
||||
<div className="z-10 flex w-full items-center border-b border-custom-border-200">
|
||||
<div className="block bg-custom-sidebar-background-100 py-4 pl-5 md:hidden">
|
||||
<SidebarHamburgerToggle />
|
||||
</div>
|
||||
<div className="w-full">{header}</div>
|
||||
</div>
|
||||
{mobileHeader && mobileHeader}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
13
web/core/components/core/content-wrapper.tsx
Normal file
13
web/core/components/core/content-wrapper.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export interface ContentWrapperProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ContentWrapper = ({ children }: ContentWrapperProps) => (
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<div className="relative h-full w-full overflow-x-hidden overflow-y-scroll">{children}</div>
|
||||
</div>
|
||||
);
|
||||
28
web/core/components/core/favorite-star.tsx
Normal file
28
web/core/components/core/favorite-star.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { Star } from "lucide-react";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
|
||||
type Props = {
|
||||
buttonClassName?: string;
|
||||
iconClassName?: string;
|
||||
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
export const FavoriteStar: React.FC<Props> = (props) => {
|
||||
const { buttonClassName, iconClassName, onClick, selected } = props;
|
||||
|
||||
return (
|
||||
<button type="button" className={cn("h-4 w-4 grid place-items-center", buttonClassName)} onClick={onClick}>
|
||||
<Star
|
||||
className={cn(
|
||||
"h-4 w-4 text-custom-text-300 transition-all",
|
||||
{
|
||||
"fill-yellow-500 stroke-yellow-500": selected,
|
||||
},
|
||||
iconClassName
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
167
web/core/components/core/filters/date-filter-modal.tsx
Normal file
167
web/core/components/core/filters/date-filter-modal.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
"use client";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { DayPicker } from "react-day-picker";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
|
||||
import { Button } from "@plane/ui";
|
||||
|
||||
import { renderFormattedPayloadDate, renderFormattedDate, getDate } from "@/helpers/date-time.helper";
|
||||
import { DateFilterSelect } from "./date-filter-select";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
handleClose: () => void;
|
||||
isOpen: boolean;
|
||||
onSelect: (val: string[]) => void;
|
||||
};
|
||||
|
||||
type TFormValues = {
|
||||
filterType: "before" | "after" | "range";
|
||||
date1: Date;
|
||||
date2: Date;
|
||||
};
|
||||
|
||||
const defaultValues: TFormValues = {
|
||||
filterType: "range",
|
||||
date1: new Date(),
|
||||
date2: new Date(new Date().getFullYear(), new Date().getMonth() + 1, new Date().getDate()),
|
||||
};
|
||||
|
||||
export const DateFilterModal: React.FC<Props> = ({ title, handleClose, isOpen, onSelect }) => {
|
||||
const { handleSubmit, watch, control } = useForm<TFormValues>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const handleFormSubmit = (formData: TFormValues) => {
|
||||
const { filterType, date1, date2 } = formData;
|
||||
|
||||
if (filterType === "range")
|
||||
onSelect([`${renderFormattedPayloadDate(date1)};after`, `${renderFormattedPayloadDate(date2)};before`]);
|
||||
else onSelect([`${renderFormattedPayloadDate(date1)};${filterType}`]);
|
||||
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const date1 = getDate(watch("date1"));
|
||||
const date2 = getDate(watch("date1"));
|
||||
|
||||
const isInvalid = watch("filterType") === "range" && date1 && date2 ? date1 > date2 : false;
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-20" onClose={handleClose}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
|
||||
</Transition.Child>
|
||||
<div className="fixed inset-0 z-20 flex w-full justify-center overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative flex transform rounded-lg bg-custom-background-100 px-5 py-8 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
|
||||
<form className="space-y-4">
|
||||
<div className="flex w-full justify-between">
|
||||
<Controller
|
||||
control={control}
|
||||
name="filterType"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<DateFilterSelect title={title} value={value} onChange={onChange} />
|
||||
)}
|
||||
/>
|
||||
<X className="h-4 w-4 cursor-pointer" onClick={handleClose} />
|
||||
</div>
|
||||
<div className="flex w-full justify-between gap-4">
|
||||
<Controller
|
||||
control={control}
|
||||
name="date1"
|
||||
render={({ field: { value, onChange } }) => {
|
||||
const dateValue = getDate(value);
|
||||
const date2Value = getDate(watch("date2"));
|
||||
return (
|
||||
<DayPicker
|
||||
selected={dateValue}
|
||||
defaultMonth={dateValue}
|
||||
onSelect={(date) => {
|
||||
if (!date) return;
|
||||
onChange(date);
|
||||
}}
|
||||
mode="single"
|
||||
disabled={date2Value ? [{ after: date2Value }] : undefined}
|
||||
className="border border-custom-border-200 p-3 rounded-md"
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{watch("filterType") === "range" && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="date2"
|
||||
render={({ field: { value, onChange } }) => {
|
||||
const dateValue = getDate(value);
|
||||
const date1Value = getDate(watch("date1"));
|
||||
return (
|
||||
<DayPicker
|
||||
selected={dateValue}
|
||||
defaultMonth={dateValue}
|
||||
onSelect={(date) => {
|
||||
if (!date) return;
|
||||
onChange(date);
|
||||
}}
|
||||
mode="single"
|
||||
disabled={date1Value ? [{ before: date1Value }] : undefined}
|
||||
className="border border-custom-border-200 p-3 rounded-md"
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{watch("filterType") === "range" && (
|
||||
<h6 className="flex items-center gap-1 text-xs">
|
||||
<span className="text-custom-text-200">After:</span>
|
||||
<span>{renderFormattedDate(watch("date1"))}</span>
|
||||
<span className="ml-1 text-custom-text-200">Before:</span>
|
||||
{!isInvalid && <span>{renderFormattedDate(watch("date2"))}</span>}
|
||||
</h6>
|
||||
)}
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={handleSubmit(handleFormSubmit)}
|
||||
disabled={isInvalid}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
59
web/core/components/core/filters/date-filter-select.tsx
Normal file
59
web/core/components/core/filters/date-filter-select.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"use client";
|
||||
import React from "react";
|
||||
import { CalendarDays } from "lucide-react";
|
||||
// ui
|
||||
import { CustomSelect, CalendarAfterIcon, CalendarBeforeIcon } from "@plane/ui";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
type DueDate = {
|
||||
name: string;
|
||||
value: string;
|
||||
icon: any;
|
||||
};
|
||||
|
||||
const dueDateRange: DueDate[] = [
|
||||
{
|
||||
name: "before",
|
||||
value: "before",
|
||||
icon: <CalendarBeforeIcon className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
name: "after",
|
||||
value: "after",
|
||||
icon: <CalendarAfterIcon className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
name: "range",
|
||||
value: "range",
|
||||
icon: <CalendarDays className="h-4 w-4" />,
|
||||
},
|
||||
];
|
||||
|
||||
export const DateFilterSelect: React.FC<Props> = ({ title, value, onChange }) => (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
label={
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{dueDateRange.find((item) => item.value === value)?.icon}
|
||||
<span>
|
||||
{title} {dueDateRange.find((item) => item.value === value)?.name}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
onChange={onChange}
|
||||
>
|
||||
{dueDateRange.map((option, index) => (
|
||||
<CustomSelect.Option key={index} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{option.icon}</span>
|
||||
{title} {option.name}
|
||||
</div>
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
);
|
||||
2
web/core/components/core/filters/index.ts
Normal file
2
web/core/components/core/filters/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./date-filter-modal";
|
||||
export * from "./date-filter-select";
|
||||
385
web/core/components/core/image-picker-popover.tsx
Normal file
385
web/core/components/core/image-picker-popover.tsx
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { Control, Controller } from "react-hook-form";
|
||||
import useSWR from "swr";
|
||||
// headless ui
|
||||
import { Tab, Popover } from "@headlessui/react";
|
||||
// ui
|
||||
import { Button, Input, Loader } from "@plane/ui";
|
||||
// constants
|
||||
import { MAX_FILE_SIZE } from "@/constants/common";
|
||||
// hooks
|
||||
import { useWorkspace, useInstance } from "@/hooks/store";
|
||||
import { useDropdownKeyDown } from "@/hooks/use-dropdown-key-down";
|
||||
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
|
||||
// services
|
||||
import { FileService } from "@/services/file.service";
|
||||
|
||||
const tabOptions = [
|
||||
{
|
||||
key: "unsplash",
|
||||
title: "Unsplash",
|
||||
},
|
||||
{
|
||||
key: "images",
|
||||
title: "Images",
|
||||
},
|
||||
{
|
||||
key: "upload",
|
||||
title: "Upload",
|
||||
},
|
||||
];
|
||||
|
||||
type Props = {
|
||||
label: string | React.ReactNode;
|
||||
value: string | null;
|
||||
control: Control<any>;
|
||||
onChange: (data: string) => void;
|
||||
disabled?: boolean;
|
||||
tabIndex?: number;
|
||||
isProfileCover?: boolean;
|
||||
};
|
||||
|
||||
// services
|
||||
const fileService = new FileService();
|
||||
|
||||
export const ImagePickerPopover: React.FC<Props> = observer((props) => {
|
||||
const { label, value, control, onChange, disabled = false, tabIndex, isProfileCover = false } = props;
|
||||
// states
|
||||
const [image, setImage] = useState<File | null>(null);
|
||||
const [isImageUploading, setIsImageUploading] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchParams, setSearchParams] = useState("");
|
||||
const [formData, setFormData] = useState({
|
||||
search: "",
|
||||
});
|
||||
// refs
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
// router params
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { config } = useInstance();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const { data: unsplashImages, error: unsplashError } = useSWR(
|
||||
`UNSPLASH_IMAGES_${searchParams}`,
|
||||
() => fileService.getUnsplashImages(searchParams),
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
}
|
||||
);
|
||||
|
||||
const { data: projectCoverImages } = useSWR(`PROJECT_COVER_IMAGES`, () => fileService.getProjectCoverImages(), {
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
});
|
||||
|
||||
const imagePickerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
setImage(acceptedFiles[0]);
|
||||
}, []);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
"image/*": [".png", ".jpg", ".jpeg", ".svg", ".webp"],
|
||||
},
|
||||
maxSize: config?.file_size_limit ?? MAX_FILE_SIZE,
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsImageUploading(true);
|
||||
|
||||
if (!image) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("asset", image);
|
||||
formData.append("attributes", JSON.stringify({}));
|
||||
|
||||
const oldValue = value;
|
||||
const isUnsplashImage = oldValue?.split("/")[2] === "images.unsplash.com";
|
||||
|
||||
const uploadCallback = (res: any) => {
|
||||
const imageUrl = res.asset;
|
||||
onChange(imageUrl);
|
||||
setIsImageUploading(false);
|
||||
setImage(null);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
if (isProfileCover) {
|
||||
fileService
|
||||
.uploadUserFile(formData)
|
||||
.then((res) => {
|
||||
uploadCallback(res);
|
||||
if (isUnsplashImage) return;
|
||||
if (oldValue && currentWorkspace) fileService.deleteUserFile(oldValue);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
} else {
|
||||
if (!workspaceSlug) return;
|
||||
fileService
|
||||
.uploadFile(workspaceSlug.toString(), formData)
|
||||
.then((res) => {
|
||||
uploadCallback(res);
|
||||
if (isUnsplashImage) return;
|
||||
if (oldValue && currentWorkspace) fileService.deleteFile(currentWorkspace.id, oldValue);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!unsplashImages || value !== null) return;
|
||||
|
||||
onChange(unsplashImages[0]?.urls.regular);
|
||||
}, [value, onChange, unsplashImages]);
|
||||
|
||||
const handleClose = () => {
|
||||
if (isOpen) setIsOpen(false);
|
||||
};
|
||||
|
||||
const toggleDropdown = () => {
|
||||
setIsOpen((prevIsOpen) => !prevIsOpen);
|
||||
};
|
||||
|
||||
const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose);
|
||||
|
||||
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
toggleDropdown();
|
||||
};
|
||||
|
||||
useOutsideClickDetector(ref, handleClose);
|
||||
|
||||
return (
|
||||
<Popover className="relative z-20" ref={ref} tabIndex={tabIndex} onKeyDown={handleKeyDown}>
|
||||
<Popover.Button
|
||||
className="rounded border border-custom-border-300 bg-custom-background-100 px-2 py-1 text-xs text-custom-text-200 hover:text-custom-text-100"
|
||||
onClick={handleOnClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{label}
|
||||
</Popover.Button>
|
||||
|
||||
{isOpen && (
|
||||
<Popover.Panel
|
||||
className="absolute right-0 z-20 mt-2 rounded-md border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-sm"
|
||||
static
|
||||
>
|
||||
<div
|
||||
ref={imagePickerRef}
|
||||
className="flex h-96 w-80 flex-col overflow-auto rounded border border-custom-border-300 bg-custom-background-100 p-3 shadow-2xl md:h-[28rem] md:w-[36rem]"
|
||||
>
|
||||
<Tab.Group>
|
||||
<Tab.List as="span" className="inline-block rounded bg-custom-background-80 p-1">
|
||||
{tabOptions.map((tab) => {
|
||||
if (!unsplashImages && unsplashError && tab.key === "unsplash") return null;
|
||||
if (projectCoverImages && projectCoverImages.length === 0 && tab.key === "images") return null;
|
||||
|
||||
return (
|
||||
<Tab
|
||||
key={tab.key}
|
||||
className={({ selected }) =>
|
||||
`rounded px-4 py-1 text-center text-sm outline-none transition-colors ${
|
||||
selected ? "bg-custom-primary text-white" : "text-custom-text-100"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{tab.title}
|
||||
</Tab>
|
||||
);
|
||||
})}
|
||||
</Tab.List>
|
||||
<Tab.Panels className="vertical-scrollbar scrollbar-md h-full w-full flex-1 overflow-y-auto overflow-x-hidden">
|
||||
{(unsplashImages || !unsplashError) && (
|
||||
<Tab.Panel className="mt-4 h-full w-full space-y-4">
|
||||
<div className="flex gap-x-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="search"
|
||||
render={({ field: { value, ref } }) => (
|
||||
<Input
|
||||
id="search"
|
||||
name="search"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => setFormData({ ...formData, search: e.target.value })}
|
||||
ref={ref}
|
||||
placeholder="Search for images"
|
||||
className="w-full text-sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Button variant="primary" onClick={() => setSearchParams(formData.search)} size="sm">
|
||||
Search
|
||||
</Button>
|
||||
</div>
|
||||
{unsplashImages ? (
|
||||
unsplashImages.length > 0 ? (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{unsplashImages.map((image) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className="relative col-span-2 aspect-video md:col-span-1"
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onChange(image.urls.regular);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={image.urls.small}
|
||||
alt={image.alt_description}
|
||||
className="absolute left-0 top-0 h-full w-full cursor-pointer rounded object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="pt-7 text-center text-xs text-custom-text-300">No images found.</p>
|
||||
)
|
||||
) : (
|
||||
<Loader className="grid grid-cols-4 gap-4">
|
||||
<Loader.Item height="80px" width="100%" />
|
||||
<Loader.Item height="80px" width="100%" />
|
||||
<Loader.Item height="80px" width="100%" />
|
||||
<Loader.Item height="80px" width="100%" />
|
||||
<Loader.Item height="80px" width="100%" />
|
||||
<Loader.Item height="80px" width="100%" />
|
||||
<Loader.Item height="80px" width="100%" />
|
||||
<Loader.Item height="80px" width="100%" />
|
||||
</Loader>
|
||||
)}
|
||||
</Tab.Panel>
|
||||
)}
|
||||
{(!projectCoverImages || projectCoverImages.length !== 0) && (
|
||||
<Tab.Panel className="mt-4 h-full w-full space-y-4">
|
||||
{projectCoverImages ? (
|
||||
projectCoverImages.length > 0 ? (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{projectCoverImages.map((image, index) => (
|
||||
<div
|
||||
key={image}
|
||||
className="relative col-span-2 aspect-video md:col-span-1"
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onChange(image);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={image}
|
||||
alt={`Default project cover image- ${index}`}
|
||||
className="absolute left-0 top-0 h-full w-full cursor-pointer rounded object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="pt-7 text-center text-xs text-custom-text-300">No images found.</p>
|
||||
)
|
||||
) : (
|
||||
<Loader className="grid grid-cols-4 gap-4 pt-4">
|
||||
<Loader.Item height="80px" width="100%" />
|
||||
<Loader.Item height="80px" width="100%" />
|
||||
<Loader.Item height="80px" width="100%" />
|
||||
<Loader.Item height="80px" width="100%" />
|
||||
<Loader.Item height="80px" width="100%" />
|
||||
<Loader.Item height="80px" width="100%" />
|
||||
<Loader.Item height="80px" width="100%" />
|
||||
<Loader.Item height="80px" width="100%" />
|
||||
</Loader>
|
||||
)}
|
||||
</Tab.Panel>
|
||||
)}
|
||||
<Tab.Panel className="mt-4 h-full w-full">
|
||||
<div className="flex h-full w-full flex-col gap-y-2">
|
||||
<div className="flex w-full flex-1 items-center gap-3">
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`relative grid h-full w-full cursor-pointer place-items-center rounded-lg p-12 text-center focus:outline-none focus:ring-2 focus:ring-custom-primary focus:ring-offset-2 ${
|
||||
(image === null && isDragActive) || !value
|
||||
? "border-2 border-dashed border-custom-border-200 hover:bg-custom-background-90"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-0 top-0 z-40 -translate-y-1/2 rounded bg-custom-background-90 px-2 py-0.5 text-xs font-medium text-custom-text-200"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
{image !== null || (value && value !== "") ? (
|
||||
<>
|
||||
<Image
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
src={image ? URL.createObjectURL(image) : value ? value : ""}
|
||||
alt="image"
|
||||
className="rounded-lg"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<span className="mt-2 block text-sm font-medium text-custom-text-200">
|
||||
{isDragActive ? "Drop image here to upload" : "Drag & drop image here"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input {...getInputProps()} type="text" />
|
||||
</div>
|
||||
</div>
|
||||
{fileRejections.length > 0 && (
|
||||
<p className="text-sm text-red-500">
|
||||
{fileRejections[0].errors[0].code === "file-too-large"
|
||||
? "The image size cannot exceed 5 MB."
|
||||
: "Please upload a file in a valid format."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-custom-text-200">
|
||||
File formats supported- .jpeg, .jpg, .png, .webp, .svg
|
||||
</p>
|
||||
|
||||
<div className="flex h-12 items-start justify-end gap-2">
|
||||
<Button
|
||||
variant="neutral-primary"
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
setImage(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={handleSubmit}
|
||||
disabled={!image}
|
||||
loading={isImageUploading}
|
||||
>
|
||||
{isImageUploading ? "Uploading..." : "Upload & Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
11
web/core/components/core/index.ts
Normal file
11
web/core/components/core/index.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export * from "./filters";
|
||||
export * from "./modals";
|
||||
export * from "./multiple-select";
|
||||
export * from "./sidebar";
|
||||
export * from "./activity";
|
||||
export * from "./favorite-star";
|
||||
export * from "./theme";
|
||||
export * from "./image-picker-popover";
|
||||
export * from "./page-title";
|
||||
export * from "./app-header";
|
||||
export * from "./content-wrapper";
|
||||
2
web/core/components/core/list/index.ts
Normal file
2
web/core/components/core/list/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./list-item";
|
||||
export * from "./list-root";
|
||||
77
web/core/components/core/list/list-item.tsx
Normal file
77
web/core/components/core/list/list-item.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"use client";
|
||||
import React, { FC } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
// ui
|
||||
import { ControlLink, Tooltip } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
|
||||
interface IListItemProps {
|
||||
title: string;
|
||||
itemLink: string;
|
||||
onItemClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;
|
||||
prependTitleElement?: JSX.Element;
|
||||
appendTitleElement?: JSX.Element;
|
||||
actionableItems?: JSX.Element;
|
||||
isMobile?: boolean;
|
||||
parentRef: React.RefObject<HTMLDivElement>;
|
||||
disableLink?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ListItem: FC<IListItemProps> = (props) => {
|
||||
const {
|
||||
title,
|
||||
prependTitleElement,
|
||||
appendTitleElement,
|
||||
actionableItems,
|
||||
itemLink,
|
||||
onItemClick,
|
||||
isMobile = false,
|
||||
parentRef,
|
||||
disableLink = false,
|
||||
className = "",
|
||||
} = props;
|
||||
|
||||
// router
|
||||
const router = useRouter();
|
||||
|
||||
// handlers
|
||||
const handleControlLinkClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (onItemClick) onItemClick(e);
|
||||
else router.push(itemLink);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={parentRef} className="relative">
|
||||
<ControlLink href={itemLink} onClick={handleControlLinkClick} disabled={disableLink}>
|
||||
<div
|
||||
className={cn(
|
||||
"group h-24 sm:h-[52px] flex w-full flex-col items-center justify-between gap-3 sm:gap-5 px-6 py-4 sm:py-0 text-sm border-b border-custom-border-200 bg-custom-background-100 hover:bg-custom-background-90 sm:flex-row",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="relative flex w-full items-center justify-between gap-3 overflow-hidden">
|
||||
<div className="relative flex w-full items-center gap-3 overflow-hidden">
|
||||
<div className="flex items-center gap-4 truncate">
|
||||
{prependTitleElement && <span className="flex items-center flex-shrink-0">{prependTitleElement}</span>}
|
||||
<Tooltip tooltipContent={title} position="top" isMobile={isMobile}>
|
||||
<span className="truncate text-sm">{title}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{appendTitleElement && <span className="flex items-center flex-shrink-0">{appendTitleElement}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<span className="h-6 w-96 flex-shrink-0" />
|
||||
</div>
|
||||
</ControlLink>
|
||||
{actionableItems && (
|
||||
<div className="absolute right-5 bottom-4 flex items-center gap-1.5">
|
||||
<div className="relative flex items-center gap-4 sm:w-auto sm:flex-shrink-0 sm:justify-end">
|
||||
{actionableItems}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
10
web/core/components/core/list/list-root.tsx
Normal file
10
web/core/components/core/list/list-root.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import React, { FC } from "react";
|
||||
|
||||
interface IListContainer {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ListLayout: FC<IListContainer> = (props) => {
|
||||
const { children } = props;
|
||||
return <div className="flex h-full w-full flex-col overflow-y-auto vertical-scrollbar scrollbar-lg">{children}</div>;
|
||||
};
|
||||
94
web/core/components/core/modals/alert-modal.tsx
Normal file
94
web/core/components/core/modals/alert-modal.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"use client";
|
||||
import { AlertTriangle, Info, LucideIcon } from "lucide-react";
|
||||
// ui
|
||||
import { Button, TButtonVariant } from "@plane/ui";
|
||||
// components
|
||||
import { EModalPosition, EModalWidth, ModalCore } from "@/components/core";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
|
||||
export type TModalVariant = "danger" | "primary";
|
||||
|
||||
type Props = {
|
||||
content: React.ReactNode | string;
|
||||
handleClose: () => void;
|
||||
handleSubmit: () => Promise<void>;
|
||||
hideIcon?: boolean;
|
||||
isSubmitting: boolean;
|
||||
isOpen: boolean;
|
||||
position?: EModalPosition;
|
||||
primaryButtonText?: {
|
||||
loading: string;
|
||||
default: string;
|
||||
};
|
||||
secondaryButtonText?: string;
|
||||
title: string;
|
||||
variant?: TModalVariant;
|
||||
width?: EModalWidth;
|
||||
};
|
||||
|
||||
const VARIANT_ICONS: Record<TModalVariant, LucideIcon> = {
|
||||
danger: AlertTriangle,
|
||||
primary: Info,
|
||||
};
|
||||
|
||||
const BUTTON_VARIANTS: Record<TModalVariant, TButtonVariant> = {
|
||||
danger: "danger",
|
||||
primary: "primary",
|
||||
};
|
||||
|
||||
const VARIANT_CLASSES: Record<TModalVariant, string> = {
|
||||
danger: "bg-red-500/20 text-red-500",
|
||||
primary: "bg-custom-primary-100/20 text-custom-primary-100",
|
||||
};
|
||||
|
||||
export const AlertModalCore: React.FC<Props> = (props) => {
|
||||
const {
|
||||
content,
|
||||
handleClose,
|
||||
handleSubmit,
|
||||
hideIcon = false,
|
||||
isSubmitting,
|
||||
isOpen,
|
||||
position = EModalPosition.CENTER,
|
||||
primaryButtonText = {
|
||||
loading: "Deleting",
|
||||
default: "Delete",
|
||||
},
|
||||
secondaryButtonText = "Cancel",
|
||||
title,
|
||||
variant = "danger",
|
||||
width = EModalWidth.XL,
|
||||
} = props;
|
||||
|
||||
const Icon = VARIANT_ICONS[variant];
|
||||
|
||||
return (
|
||||
<ModalCore isOpen={isOpen} handleClose={handleClose} position={position} width={width}>
|
||||
<div className="p-5 flex flex-col sm:flex-row items-center sm:items-start gap-4">
|
||||
{!hideIcon && (
|
||||
<span
|
||||
className={cn(
|
||||
"flex-shrink-0 grid place-items-center rounded-full size-12 sm:size-10",
|
||||
VARIANT_CLASSES[variant]
|
||||
)}
|
||||
>
|
||||
<Icon className="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
<div className="text-center sm:text-left">
|
||||
<h3 className="text-lg font-medium">{title}</h3>
|
||||
<p className="mt-1 text-sm text-custom-text-200">{content}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 py-4 flex flex-col-reverse sm:flex-row sm:justify-end gap-2 border-t-[0.5px] border-custom-border-200">
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||
{secondaryButtonText}
|
||||
</Button>
|
||||
<Button variant={BUTTON_VARIANTS[variant]} size="sm" tabIndex={1} onClick={handleSubmit} loading={isSubmitting}>
|
||||
{isSubmitting ? primaryButtonText.loading : primaryButtonText.default}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalCore>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { observer } from "mobx-react";
|
||||
import { Combobox } from "@headlessui/react";
|
||||
// hooks
|
||||
import { ISearchIssueResponse } from "@plane/types";
|
||||
|
||||
interface Props {
|
||||
issue: ISearchIssueResponse;
|
||||
canDeleteIssueIds: boolean;
|
||||
identifier: string | undefined;
|
||||
}
|
||||
|
||||
export const BulkDeleteIssuesModalItem: React.FC<Props> = observer((props: Props) => {
|
||||
const { issue, canDeleteIssueIds, identifier } = props;
|
||||
|
||||
const color = issue.state__color;
|
||||
|
||||
return (
|
||||
<Combobox.Option
|
||||
key={issue.id}
|
||||
as="div"
|
||||
value={issue.id}
|
||||
className={({ active }) =>
|
||||
`flex cursor-pointer select-none items-center justify-between rounded-md px-3 py-2 ${
|
||||
active ? "bg-custom-background-80 text-custom-text-100" : ""
|
||||
}`
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" checked={canDeleteIssueIds} readOnly />
|
||||
<span
|
||||
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-xs">
|
||||
{identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
<span>{issue.name}</span>
|
||||
</div>
|
||||
</Combobox.Option>
|
||||
);
|
||||
});
|
||||
223
web/core/components/core/modals/bulk-delete-issues-modal.tsx
Normal file
223
web/core/components/core/modals/bulk-delete-issues-modal.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import { Search } from "lucide-react";
|
||||
import { Combobox, Dialog, Transition } from "@headlessui/react";
|
||||
//plane
|
||||
import { ISearchIssueResponse, IUser } from "@plane/types";
|
||||
import { Button, Loader, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
//components
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
//constants
|
||||
import { EmptyStateType } from "@/constants/empty-state";
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
//hooks
|
||||
import { useIssues, useProject } from "@/hooks/store";
|
||||
import useDebounce from "@/hooks/use-debounce";
|
||||
// services
|
||||
import { ProjectService } from "@/services/project";
|
||||
// ui
|
||||
// icons
|
||||
// components
|
||||
import { BulkDeleteIssuesModalItem } from "./bulk-delete-issues-modal-item";
|
||||
|
||||
type FormInput = {
|
||||
delete_issue_ids: string[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
user: IUser | undefined;
|
||||
};
|
||||
|
||||
const projectService = new ProjectService();
|
||||
|
||||
export const BulkDeleteIssuesModal: React.FC<Props> = observer((props) => {
|
||||
const { isOpen, onClose } = props;
|
||||
// router params
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// hooks
|
||||
const { getProjectById } = useProject();
|
||||
const {
|
||||
issues: { removeBulkIssues },
|
||||
} = useIssues(EIssuesStoreType.PROJECT);
|
||||
// states
|
||||
const [query, setQuery] = useState("");
|
||||
const [issues, setIssues] = useState<ISearchIssueResponse[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
const debouncedSearchTerm: string = useDebounce(query, 500);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !workspaceSlug || !projectId) return;
|
||||
|
||||
setIsSearching(true);
|
||||
projectService
|
||||
.projectIssuesSearch(workspaceSlug.toString(), projectId.toString(), {
|
||||
search: debouncedSearchTerm,
|
||||
workspace_search: false,
|
||||
})
|
||||
.then((res: ISearchIssueResponse[]) => setIssues(res))
|
||||
.finally(() => setIsSearching(false));
|
||||
}, [debouncedSearchTerm, isOpen, projectId, workspaceSlug]);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
watch,
|
||||
reset,
|
||||
setValue,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<FormInput>({
|
||||
defaultValues: {
|
||||
delete_issue_ids: [],
|
||||
},
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
setQuery("");
|
||||
reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleDelete: SubmitHandler<FormInput> = async (data) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
if (!data.delete_issue_ids || data.delete_issue_ids.length === 0) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Please select at least one issue.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(data.delete_issue_ids)) data.delete_issue_ids = [data.delete_issue_ids];
|
||||
|
||||
await removeBulkIssues(workspaceSlug as string, projectId as string, data.delete_issue_ids)
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Issues deleted successfully!",
|
||||
});
|
||||
handleClose();
|
||||
})
|
||||
.catch(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Something went wrong. Please try again.",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const projectDetails = getProjectById(projectId as string);
|
||||
|
||||
const issueList =
|
||||
issues.length > 0 ? (
|
||||
<li className="p-2">
|
||||
{query === "" && (
|
||||
<h2 className="mb-2 mt-4 px-3 text-xs font-semibold text-custom-text-100">Select issues to delete</h2>
|
||||
)}
|
||||
<ul className="text-sm text-custom-text-200">
|
||||
{issues.map((issue) => (
|
||||
<BulkDeleteIssuesModalItem
|
||||
issue={issue}
|
||||
identifier={projectDetails?.identifier}
|
||||
canDeleteIssueIds={watch("delete_issue_ids").includes(issue.id)}
|
||||
key={issue.id}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center px-3 py-8 text-center">
|
||||
<EmptyState
|
||||
type={
|
||||
query === "" ? EmptyStateType.ISSUE_RELATION_EMPTY_STATE : EmptyStateType.ISSUE_RELATION_SEARCH_EMPTY_STATE
|
||||
}
|
||||
layout="screen-simple"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear>
|
||||
<Dialog as="div" className="relative z-20" onClose={handleClose}>
|
||||
<div className="fixed inset-0 z-20 overflow-y-auto bg-custom-backdrop p-4 transition-opacity sm:p-6 md:p-20">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative flex w-full items-center justify-center ">
|
||||
<div className="w-full max-w-2xl transform divide-y divide-custom-border-200 divide-opacity-10 rounded-lg bg-custom-background-100 shadow-custom-shadow-md transition-all">
|
||||
<form>
|
||||
<Combobox
|
||||
onChange={(val: string) => {
|
||||
const selectedIssues = watch("delete_issue_ids");
|
||||
if (selectedIssues.includes(val))
|
||||
setValue(
|
||||
"delete_issue_ids",
|
||||
selectedIssues.filter((i) => i !== val)
|
||||
);
|
||||
else setValue("delete_issue_ids", [...selectedIssues, val]);
|
||||
}}
|
||||
>
|
||||
<div className="relative m-1">
|
||||
<Search
|
||||
className="pointer-events-none absolute left-4 top-3.5 h-5 w-5 text-custom-text-100 text-opacity-40"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-custom-text-100 outline-none focus:ring-0 sm:text-sm"
|
||||
placeholder="Search..."
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Combobox.Options
|
||||
static
|
||||
className="max-h-80 scroll-py-2 divide-y divide-custom-border-200 overflow-y-auto"
|
||||
>
|
||||
{isSearching ? (
|
||||
<Loader className="space-y-3 p-3">
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
</Loader>
|
||||
) : (
|
||||
<>{issueList}</>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
</Combobox>
|
||||
|
||||
{issues.length > 0 && (
|
||||
<div className="flex items-center justify-end gap-2 p-3">
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={handleSubmit(handleDelete)} loading={isSubmitting}>
|
||||
{isSubmitting ? "Deleting..." : "Delete selected issues"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
});
|
||||
288
web/core/components/core/modals/existing-issues-list-modal.tsx
Normal file
288
web/core/components/core/modals/existing-issues-list-modal.tsx
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Rocket, Search, X } from "lucide-react";
|
||||
import { Combobox, Dialog, Transition } from "@headlessui/react";
|
||||
// types
|
||||
import { ISearchIssueResponse, TProjectIssuesSearchParams } from "@plane/types";
|
||||
// ui
|
||||
import { Button, Loader, ToggleSwitch, Tooltip, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// hooks
|
||||
import useDebounce from "@/hooks/use-debounce";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// services
|
||||
import { ProjectService } from "@/services/project";
|
||||
// components
|
||||
import { IssueSearchModalEmptyState } from "./issue-search-modal-empty-state";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string | undefined;
|
||||
projectId: string | undefined;
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
searchParams: Partial<TProjectIssuesSearchParams>;
|
||||
handleOnSubmit: (data: ISearchIssueResponse[]) => Promise<void>;
|
||||
workspaceLevelToggle?: boolean;
|
||||
shouldHideIssue?: (issue: ISearchIssueResponse) => boolean;
|
||||
};
|
||||
|
||||
const projectService = new ProjectService();
|
||||
|
||||
export const ExistingIssuesListModal: React.FC<Props> = (props) => {
|
||||
const {
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
isOpen,
|
||||
handleClose: onClose,
|
||||
searchParams,
|
||||
handleOnSubmit,
|
||||
workspaceLevelToggle = false,
|
||||
shouldHideIssue,
|
||||
} = props;
|
||||
// states
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [issues, setIssues] = useState<ISearchIssueResponse[]>([]);
|
||||
const [selectedIssues, setSelectedIssues] = useState<ISearchIssueResponse[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false);
|
||||
const { isMobile } = usePlatformOS();
|
||||
const debouncedSearchTerm: string = useDebounce(searchTerm, 500);
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setSearchTerm("");
|
||||
setSelectedIssues([]);
|
||||
setIsWorkspaceLevel(false);
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (selectedIssues.length === 0) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Please select at least one issue.",
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
await handleOnSubmit(selectedIssues).finally(() => setIsSubmitting(false));
|
||||
|
||||
handleClose();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !workspaceSlug || !projectId) return;
|
||||
setIsLoading(true);
|
||||
projectService
|
||||
.projectIssuesSearch(workspaceSlug as string, projectId as string, {
|
||||
search: debouncedSearchTerm,
|
||||
...searchParams,
|
||||
workspace_search: isWorkspaceLevel,
|
||||
})
|
||||
.then((res) => setIssues(res))
|
||||
.finally(() => {
|
||||
setIsSearching(false);
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [debouncedSearchTerm, isOpen, isWorkspaceLevel, projectId, workspaceSlug]);
|
||||
|
||||
const filteredIssues = issues.filter((issue) => !shouldHideIssue?.(issue));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setSearchTerm("")} appear>
|
||||
<Dialog as="div" className="relative z-20" onClose={handleClose}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto p-4 sm:p-6 md:p-20">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative mx-auto max-w-2xl transform rounded-lg bg-custom-background-100 shadow-custom-shadow-md transition-all">
|
||||
<Combobox
|
||||
as="div"
|
||||
onChange={(val: ISearchIssueResponse) => {
|
||||
if (selectedIssues.some((i) => i.id === val.id))
|
||||
setSelectedIssues((prevData) => prevData.filter((i) => i.id !== val.id));
|
||||
else setSelectedIssues((prevData) => [...prevData, val]);
|
||||
}}
|
||||
>
|
||||
<div className="relative m-1">
|
||||
<Search
|
||||
className="pointer-events-none absolute left-4 top-3.5 h-5 w-5 text-custom-text-100 text-opacity-40"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Combobox.Input
|
||||
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-sm text-custom-text-100 outline-none placeholder:text-custom-text-400 focus:ring-0"
|
||||
placeholder="Type to search..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-reverse gap-4 p-2 text-[0.825rem] text-custom-text-200 sm:flex-row sm:items-center sm:justify-between">
|
||||
{selectedIssues.length > 0 ? (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
{selectedIssues.map((issue) => (
|
||||
<div
|
||||
key={issue.id}
|
||||
className="flex items-center gap-1 whitespace-nowrap rounded-md border border-custom-border-200 bg-custom-background-80 py-1 pl-2 text-xs text-custom-text-100"
|
||||
>
|
||||
{issue.project__identifier}-{issue.sequence_id}
|
||||
<button
|
||||
type="button"
|
||||
className="group p-1"
|
||||
onClick={() => setSelectedIssues((prevData) => prevData.filter((i) => i.id !== issue.id))}
|
||||
>
|
||||
<X className="h-3 w-3 text-custom-text-200 group-hover:text-custom-text-100" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-min whitespace-nowrap rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
|
||||
No issues selected
|
||||
</div>
|
||||
)}
|
||||
{workspaceLevelToggle && (
|
||||
<Tooltip tooltipContent="Toggle workspace level search" isMobile={isMobile}>
|
||||
<div
|
||||
className={`flex flex-shrink-0 cursor-pointer items-center gap-1 text-xs ${
|
||||
isWorkspaceLevel ? "text-custom-text-100" : "text-custom-text-200"
|
||||
}`}
|
||||
>
|
||||
<ToggleSwitch
|
||||
value={isWorkspaceLevel}
|
||||
onChange={() => setIsWorkspaceLevel((prevData) => !prevData)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsWorkspaceLevel((prevData) => !prevData)}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
Workspace Level
|
||||
</button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Combobox.Options
|
||||
static
|
||||
className="vertical-scrollbar scrollbar-md max-h-80 scroll-py-2 overflow-y-auto"
|
||||
>
|
||||
{searchTerm !== "" && (
|
||||
<h5 className="mx-2 text-[0.825rem] text-custom-text-200">
|
||||
Search results for{" "}
|
||||
<span className="text-custom-text-100">
|
||||
{'"'}
|
||||
{searchTerm}
|
||||
{'"'}
|
||||
</span>{" "}
|
||||
in project:
|
||||
</h5>
|
||||
)}
|
||||
|
||||
{isSearching || isLoading ? (
|
||||
<Loader className="space-y-3 p-3">
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
</Loader>
|
||||
) : (
|
||||
<>
|
||||
{filteredIssues.length === 0 ? (
|
||||
<IssueSearchModalEmptyState
|
||||
debouncedSearchTerm={debouncedSearchTerm}
|
||||
isSearching={isSearching}
|
||||
issues={filteredIssues}
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
) : (
|
||||
<ul className={`text-sm text-custom-text-100 ${filteredIssues.length > 0 ? "p-2" : ""}`}>
|
||||
{filteredIssues.map((issue) => {
|
||||
const selected = selectedIssues.some((i) => i.id === issue.id);
|
||||
|
||||
return (
|
||||
<Combobox.Option
|
||||
key={issue.id}
|
||||
as="label"
|
||||
htmlFor={`issue-${issue.id}`}
|
||||
value={issue}
|
||||
className={({ active }) =>
|
||||
`group flex w-full cursor-pointer select-none items-center justify-between gap-2 rounded-md px-3 py-2 text-custom-text-200 ${
|
||||
active ? "bg-custom-background-80 text-custom-text-100" : ""
|
||||
} ${selected ? "text-custom-text-100" : ""}`
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" checked={selected} readOnly />
|
||||
<span
|
||||
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue.state__color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-xs">
|
||||
{issue.project__identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
{issue.name}
|
||||
</div>
|
||||
<a
|
||||
href={`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`}
|
||||
target="_blank"
|
||||
className="z-1 relative hidden text-custom-text-200 hover:text-custom-text-100 group-hover:block"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Rocket className="h-4 w-4" />
|
||||
</a>
|
||||
</Combobox.Option>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
</Combobox>
|
||||
<div className="flex items-center justify-end gap-2 p-3">
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
{selectedIssues.length > 0 && (
|
||||
<Button variant="primary" size="sm" onClick={onSubmit} loading={isSubmitting}>
|
||||
{isSubmitting ? "Adding..." : "Add selected issues"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
</>
|
||||
);
|
||||
};
|
||||
267
web/core/components/core/modals/gpt-assistant-popover.tsx
Normal file
267
web/core/components/core/modals/gpt-assistant-popover.tsx
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useRef, Fragment, Ref } from "react";
|
||||
import { Placement } from "@popperjs/core";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Controller, useForm } from "react-hook-form"; // services
|
||||
import { usePopper } from "react-popper";
|
||||
// ui
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { RichTextReadOnlyEditor } from "@/components/editor/rich-text-editor/rich-text-read-only-editor";
|
||||
// icons
|
||||
// components
|
||||
// hooks
|
||||
import { AIService } from "@/services/ai.service";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
projectId: string;
|
||||
handleClose: () => void;
|
||||
onResponse: (response: any) => void;
|
||||
onError?: (error: any) => void;
|
||||
placement?: Placement;
|
||||
prompt?: string;
|
||||
button: JSX.Element;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type FormData = {
|
||||
prompt: string;
|
||||
task: string;
|
||||
};
|
||||
|
||||
const aiService = new AIService();
|
||||
|
||||
export const GptAssistantPopover: React.FC<Props> = (props) => {
|
||||
const { isOpen, projectId, handleClose, onResponse, onError, placement, prompt, button, className = "" } = props;
|
||||
// states
|
||||
const [response, setResponse] = useState("");
|
||||
const [invalidResponse, setInvalidResponse] = useState(false);
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
const editorRef = useRef<any>(null);
|
||||
const responseRef = useRef<any>(null);
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// popper
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: placement ?? "auto",
|
||||
});
|
||||
// form
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
setFocus,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<FormData>({
|
||||
defaultValues: {
|
||||
prompt: prompt || "",
|
||||
task: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onClose = () => {
|
||||
handleClose();
|
||||
setResponse("");
|
||||
setInvalidResponse(false);
|
||||
reset();
|
||||
};
|
||||
|
||||
const handleServiceError = (err: any) => {
|
||||
const error = err?.data?.error;
|
||||
const errorMessage =
|
||||
err?.status === 429
|
||||
? error || "You have reached the maximum number of requests of 50 requests per month per user."
|
||||
: error || "Some error occurred. Please try again.";
|
||||
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: errorMessage,
|
||||
});
|
||||
|
||||
if (onError) onError(err);
|
||||
};
|
||||
|
||||
const callAIService = async (formData: FormData) => {
|
||||
try {
|
||||
const res = await aiService.createGptTask(workspaceSlug as string, projectId, {
|
||||
prompt: prompt || "",
|
||||
task: formData.task,
|
||||
});
|
||||
|
||||
setResponse(res.response_html);
|
||||
setFocus("task");
|
||||
|
||||
setInvalidResponse(res.response === "");
|
||||
} catch (err) {
|
||||
handleServiceError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInvalidTask = () => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Please enter some task to get AI assistance.",
|
||||
});
|
||||
};
|
||||
|
||||
const handleAIResponse = async (formData: FormData) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
if (formData.task === "") {
|
||||
handleInvalidTask();
|
||||
return;
|
||||
}
|
||||
|
||||
await callAIService(formData);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) setFocus("task");
|
||||
}, [isOpen, setFocus]);
|
||||
|
||||
useEffect(() => {
|
||||
editorRef.current?.setEditorValue(prompt || "");
|
||||
}, [editorRef, prompt]);
|
||||
|
||||
useEffect(() => {
|
||||
responseRef.current?.setEditorValue(`<p>${response}</p>`);
|
||||
}, [response, responseRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEnterKeyPress = (event: KeyboardEvent) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleSubmit(handleAIResponse)();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscapeKeyPress = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
window.addEventListener("keydown", handleEnterKeyPress);
|
||||
window.addEventListener("keydown", handleEscapeKeyPress);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleEnterKeyPress);
|
||||
window.removeEventListener("keydown", handleEscapeKeyPress);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen, handleSubmit, onClose]);
|
||||
|
||||
const responseActionButton = response !== "" && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
onResponse(response);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Use this response
|
||||
</Button>
|
||||
);
|
||||
|
||||
const generateResponseButtonText = isSubmitting
|
||||
? "Generating response..."
|
||||
: response === ""
|
||||
? "Generate response"
|
||||
: "Generate again";
|
||||
|
||||
return (
|
||||
<Popover as="div" className={`relative w-min text-left`}>
|
||||
<Popover.Button as={Fragment}>
|
||||
<button ref={setReferenceElement} className="flex items-center">
|
||||
{button}
|
||||
</button>
|
||||
</Popover.Button>
|
||||
<Transition
|
||||
show={isOpen}
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Popover.Panel
|
||||
as="div"
|
||||
className={`fixed z-10 flex w-full min-w-[50rem] max-w-full flex-col space-y-4 overflow-hidden rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-4 shadow ${className}`}
|
||||
ref={setPopperElement as Ref<HTMLDivElement>}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<div className="vertical-scroll-enable max-h-72 space-y-4 overflow-y-auto">
|
||||
{prompt && (
|
||||
<div className="text-sm">
|
||||
Content:
|
||||
<RichTextReadOnlyEditor initialValue={prompt} containerClassName="-m-3" ref={editorRef} />
|
||||
</div>
|
||||
)}
|
||||
{response !== "" && (
|
||||
<div className="page-block-section max-h-[8rem] text-sm">
|
||||
Response:
|
||||
<RichTextReadOnlyEditor initialValue={`<p>${response}</p>`} ref={responseRef} />
|
||||
</div>
|
||||
)}
|
||||
{invalidResponse && (
|
||||
<div className="text-sm text-red-500">
|
||||
No response could be generated. This may be due to insufficient content or task information. Please try
|
||||
again.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="task"
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="task"
|
||||
name="task"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
placeholder={`${
|
||||
prompt && prompt !== "" ? "Tell AI what action to perform on this content..." : "Ask AI anything..."
|
||||
}`}
|
||||
className="w-full"
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-2 justify-between">
|
||||
{responseActionButton ? (
|
||||
<>{responseActionButton}</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-start justify-center gap-2 text-sm text-custom-primary">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<p>By using this feature, you consent to sharing the message with a 3rd party service. </p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="neutral-primary" size="sm" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" onClick={handleSubmit(handleAIResponse)} loading={isSubmitting}>
|
||||
{generateResponseButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
9
web/core/components/core/modals/index.ts
Normal file
9
web/core/components/core/modals/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export * from "./alert-modal";
|
||||
export * from "./bulk-delete-issues-modal";
|
||||
export * from "./existing-issues-list-modal";
|
||||
export * from "./gpt-assistant-popover";
|
||||
export * from "./link-modal";
|
||||
export * from "./modal-core";
|
||||
export * from "./user-image-upload-modal";
|
||||
export * from "./workspace-image-upload-modal";
|
||||
export * from "./issue-search-modal-empty-state";
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import React from "react";
|
||||
// components
|
||||
import { ISearchIssueResponse } from "@plane/types";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
// types
|
||||
import { EmptyStateType } from "@/constants/empty-state";
|
||||
// constants
|
||||
|
||||
interface EmptyStateProps {
|
||||
issues: ISearchIssueResponse[];
|
||||
searchTerm: string;
|
||||
debouncedSearchTerm: string;
|
||||
isSearching: boolean;
|
||||
}
|
||||
|
||||
export const IssueSearchModalEmptyState: React.FC<EmptyStateProps> = ({
|
||||
issues,
|
||||
searchTerm,
|
||||
debouncedSearchTerm,
|
||||
isSearching,
|
||||
}) => {
|
||||
const renderEmptyState = (type: EmptyStateType) => (
|
||||
<div className="flex flex-col items-center justify-center px-3 py-8 text-center">
|
||||
<EmptyState type={type} layout="screen-simple" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const emptyState =
|
||||
issues.length === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && !isSearching
|
||||
? renderEmptyState(EmptyStateType.ISSUE_RELATION_SEARCH_EMPTY_STATE)
|
||||
: issues.length === 0
|
||||
? renderEmptyState(EmptyStateType.ISSUE_RELATION_EMPTY_STATE)
|
||||
: null;
|
||||
|
||||
return emptyState;
|
||||
};
|
||||
175
web/core/components/core/modals/link-modal.tsx
Normal file
175
web/core/components/core/modals/link-modal.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
"use client";
|
||||
|
||||
import { FC, useEffect, Fragment } from "react";
|
||||
// react-hook-form
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import type { IIssueLink, ILinkDetails, ModuleLink } from "@plane/types";
|
||||
// ui
|
||||
import { Button, Input } from "@plane/ui";
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
data?: ILinkDetails | null;
|
||||
status: boolean;
|
||||
createIssueLink: (formData: IIssueLink | ModuleLink) => Promise<ILinkDetails> | Promise<void> | void;
|
||||
updateIssueLink: (formData: IIssueLink | ModuleLink, linkId: string) => Promise<ILinkDetails> | Promise<void> | void;
|
||||
};
|
||||
|
||||
const defaultValues: IIssueLink | ModuleLink = {
|
||||
title: "",
|
||||
url: "",
|
||||
};
|
||||
|
||||
export const LinkModal: FC<Props> = (props) => {
|
||||
const { isOpen, handleClose, createIssueLink, updateIssueLink, status, data } = props;
|
||||
// form info
|
||||
const {
|
||||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
} = useForm<IIssueLink | ModuleLink>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const onClose = () => {
|
||||
handleClose();
|
||||
const timeout = setTimeout(() => {
|
||||
reset(defaultValues);
|
||||
clearTimeout(timeout);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (formData: IIssueLink | ModuleLink) => {
|
||||
if (!data) await createIssueLink({ title: formData.title, url: formData.url });
|
||||
else await updateIssueLink({ title: formData.title, url: formData.url }, data.id);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCreateUpdatePage = async (formData: IIssueLink | ModuleLink) => {
|
||||
await handleFormSubmit(formData);
|
||||
|
||||
reset({
|
||||
...defaultValues,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reset({
|
||||
...defaultValues,
|
||||
...data,
|
||||
});
|
||||
}, [data, reset]);
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-20" onClose={onClose}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 px-5 py-8 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
|
||||
<form onSubmit={handleSubmit(handleCreateUpdatePage)}>
|
||||
<div>
|
||||
<div className="space-y-5">
|
||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
|
||||
{status ? "Update Link" : "Add Link"}
|
||||
</Dialog.Title>
|
||||
<div className="mt-2 space-y-3">
|
||||
<div>
|
||||
<label htmlFor="url" className="mb-2 text-custom-text-200">
|
||||
URL
|
||||
</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="url"
|
||||
rules={{
|
||||
required: "URL is required",
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="url"
|
||||
name="url"
|
||||
type="url"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.url)}
|
||||
placeholder="https://..."
|
||||
pattern="^(https?://).*"
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="title" className="mb-2 text-custom-text-200">
|
||||
{`Title (optional)`}
|
||||
</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="title"
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.title)}
|
||||
placeholder="Enter title"
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<Button variant="neutral-primary" size="sm" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
|
||||
{status
|
||||
? isSubmitting
|
||||
? "Updating Link..."
|
||||
: "Update Link"
|
||||
: isSubmitting
|
||||
? "Adding Link..."
|
||||
: "Add Link"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
68
web/core/components/core/modals/modal-core.tsx
Normal file
68
web/core/components/core/modals/modal-core.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { Fragment } from "react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
|
||||
export enum EModalPosition {
|
||||
TOP = "flex items-center justify-center text-center mx-4 my-10 md:my-20",
|
||||
CENTER = "flex items-end sm:items-center justify-center p-4 min-h-full",
|
||||
}
|
||||
|
||||
export enum EModalWidth {
|
||||
XL = "sm:max-w-xl",
|
||||
XXL = "sm:max-w-2xl",
|
||||
XXXL = "sm:max-w-3xl",
|
||||
XXXXL = "sm:max-w-4xl",
|
||||
}
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
handleClose?: () => void;
|
||||
isOpen: boolean;
|
||||
position?: EModalPosition;
|
||||
width?: EModalWidth;
|
||||
};
|
||||
export const ModalCore: React.FC<Props> = (props) => {
|
||||
const { children, handleClose, isOpen, position = EModalPosition.CENTER, width = EModalWidth.XXL } = props;
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-20" onClose={() => handleClose && handleClose}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-20 overflow-y-auto">
|
||||
<div className={position}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel
|
||||
className={cn(
|
||||
"relative transform rounded-lg bg-custom-background-100 text-left shadow-custom-shadow-md transition-all w-full",
|
||||
width
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
190
web/core/components/core/modals/user-image-upload-modal.tsx
Normal file
190
web/core/components/core/modals/user-image-upload-modal.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { UserCircle2 } from "lucide-react";
|
||||
import { Transition, Dialog } from "@headlessui/react";
|
||||
// hooks
|
||||
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// constants
|
||||
import { MAX_FILE_SIZE } from "@/constants/common";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
// services
|
||||
import { FileService } from "@/services/file.service";
|
||||
|
||||
type Props = {
|
||||
handleDelete?: () => void;
|
||||
isOpen: boolean;
|
||||
isRemoving: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: (url: string) => void;
|
||||
value: string | null;
|
||||
};
|
||||
|
||||
// services
|
||||
const fileService = new FileService();
|
||||
|
||||
export const UserImageUploadModal: React.FC<Props> = observer((props) => {
|
||||
const { value, onSuccess, isOpen, onClose, isRemoving, handleDelete } = props;
|
||||
// states
|
||||
const [image, setImage] = useState<File | null>(null);
|
||||
const [isImageUploading, setIsImageUploading] = useState(false);
|
||||
// store hooks
|
||||
const { config } = useInstance();
|
||||
|
||||
const onDrop = (acceptedFiles: File[]) => setImage(acceptedFiles[0]);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
"image/*": [".png", ".jpg", ".jpeg", ".svg", ".webp"],
|
||||
},
|
||||
maxSize: config?.file_size_limit ?? MAX_FILE_SIZE,
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
setImage(null);
|
||||
setIsImageUploading(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!image) return;
|
||||
|
||||
setIsImageUploading(true);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("asset", image);
|
||||
formData.append("attributes", JSON.stringify({}));
|
||||
|
||||
fileService
|
||||
.uploadUserFile(formData)
|
||||
.then((res) => {
|
||||
const imageUrl = res.asset;
|
||||
|
||||
onSuccess(imageUrl);
|
||||
setImage(null);
|
||||
|
||||
if (value) fileService.deleteUserFile(value);
|
||||
})
|
||||
.catch((err) =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: err?.error ?? "Something went wrong. Please try again.",
|
||||
})
|
||||
)
|
||||
.finally(() => setIsImageUploading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-30" onClose={handleClose}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-30 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 px-5 py-8 text-left shadow-custom-shadow-md transition-all sm:w-full sm:max-w-xl sm:p-6">
|
||||
<div className="space-y-5">
|
||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
|
||||
Upload Image
|
||||
</Dialog.Title>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`relative grid h-80 w-80 cursor-pointer place-items-center rounded-lg p-12 text-center focus:outline-none focus:ring-2 focus:ring-custom-primary focus:ring-offset-2 ${
|
||||
(image === null && isDragActive) || !value
|
||||
? "border-2 border-dashed border-custom-border-200 hover:bg-custom-background-90"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{image !== null || (value && value !== "") ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-0 top-0 z-40 -translate-y-1/2 translate-x-1/2 rounded bg-custom-background-90 px-2 py-0.5 text-xs font-medium text-custom-text-200"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<img
|
||||
src={image ? URL.createObjectURL(image) : value ? value : ""}
|
||||
alt="image"
|
||||
className="absolute left-0 top-0 h-full w-full rounded-md object-cover"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<UserCircle2 className="mx-auto h-16 w-16 text-custom-text-200" />
|
||||
<span className="mt-2 block text-sm font-medium text-custom-text-200">
|
||||
{isDragActive ? "Drop image here to upload" : "Drag & drop image here"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input {...getInputProps()} type="text" />
|
||||
</div>
|
||||
</div>
|
||||
{fileRejections.length > 0 && (
|
||||
<p className="text-sm text-red-500">
|
||||
{fileRejections[0].errors[0].code === "file-too-large"
|
||||
? "The image size cannot exceed 5 MB."
|
||||
: "Please upload a file in a valid format."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="my-4 text-sm text-custom-text-200">
|
||||
File formats supported- .jpeg, .jpg, .png, .webp, .svg
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
{handleDelete && (
|
||||
<Button variant="danger" size="sm" onClick={handleDelete} disabled={!value}>
|
||||
{isRemoving ? "Removing..." : "Remove"}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={!image}
|
||||
loading={isImageUploading}
|
||||
>
|
||||
{isImageUploading ? "Uploading..." : "Upload & Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
});
|
||||
196
web/core/components/core/modals/workspace-image-upload-modal.tsx
Normal file
196
web/core/components/core/modals/workspace-image-upload-modal.tsx
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
"use client";
|
||||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { UserCircle2 } from "lucide-react";
|
||||
import { Transition, Dialog } from "@headlessui/react";
|
||||
// hooks
|
||||
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// constants
|
||||
import { MAX_FILE_SIZE } from "@/constants/common";
|
||||
// hooks
|
||||
import { useWorkspace, useInstance } from "@/hooks/store";
|
||||
// services
|
||||
import { FileService } from "@/services/file.service";
|
||||
|
||||
type Props = {
|
||||
handleRemove?: () => void;
|
||||
isOpen: boolean;
|
||||
isRemoving: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: (url: string) => void;
|
||||
value: string | null;
|
||||
};
|
||||
|
||||
// services
|
||||
const fileService = new FileService();
|
||||
|
||||
export const WorkspaceImageUploadModal: React.FC<Props> = observer((props) => {
|
||||
const { value, onSuccess, isOpen, onClose, isRemoving, handleRemove } = props;
|
||||
// states
|
||||
const [image, setImage] = useState<File | null>(null);
|
||||
const [isImageUploading, setIsImageUploading] = useState(false);
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
const pathname = usePathname();
|
||||
// store hooks
|
||||
const { config } = useInstance();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const onDrop = (acceptedFiles: File[]) => setImage(acceptedFiles[0]);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
"image/*": [".png", ".jpg", ".jpeg", ".svg", ".webp"],
|
||||
},
|
||||
maxSize: config?.file_size_limit ?? MAX_FILE_SIZE,
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
setImage(null);
|
||||
setIsImageUploading(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!image || (!workspaceSlug && pathname !== "/onboarding")) return;
|
||||
|
||||
setIsImageUploading(true);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("asset", image);
|
||||
formData.append("attributes", JSON.stringify({}));
|
||||
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
fileService
|
||||
.uploadFile(workspaceSlug.toString(), formData)
|
||||
.then((res) => {
|
||||
const imageUrl = res.asset;
|
||||
|
||||
onSuccess(imageUrl);
|
||||
setImage(null);
|
||||
|
||||
if (value && currentWorkspace) fileService.deleteFile(currentWorkspace.id, value);
|
||||
})
|
||||
.catch((err) =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: err?.error ?? "Something went wrong. Please try again.",
|
||||
})
|
||||
)
|
||||
.finally(() => setIsImageUploading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-30" onClose={handleClose}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-30 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 px-5 py-8 text-left shadow-custom-shadow-md transition-all sm:w-full sm:max-w-xl sm:p-6">
|
||||
<div className="space-y-5">
|
||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
|
||||
Upload Image
|
||||
</Dialog.Title>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`relative grid h-80 w-80 cursor-pointer place-items-center rounded-lg p-12 text-center focus:outline-none focus:ring-2 focus:ring-custom-primary focus:ring-offset-2 ${
|
||||
(image === null && isDragActive) || !value
|
||||
? "border-2 border-dashed border-custom-border-200 hover:bg-custom-background-90"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{image !== null || (value && value !== "") ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-0 top-0 z-40 -translate-y-1/2 translate-x-1/2 rounded bg-custom-background-90 px-2 py-0.5 text-xs font-medium text-custom-text-200"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<img
|
||||
src={image ? URL.createObjectURL(image) : value ? value : ""}
|
||||
alt="image"
|
||||
className="absolute left-0 top-0 h-full w-full rounded-md object-cover"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<UserCircle2 className="mx-auto h-16 w-16 text-custom-text-200" />
|
||||
<span className="mt-2 block text-sm font-medium text-custom-text-200">
|
||||
{isDragActive ? "Drop image here to upload" : "Drag & drop image here"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input {...getInputProps()} type="text" />
|
||||
</div>
|
||||
</div>
|
||||
{fileRejections.length > 0 && (
|
||||
<p className="text-sm text-red-500">
|
||||
{fileRejections[0].errors[0].code === "file-too-large"
|
||||
? "The image size cannot exceed 5 MB."
|
||||
: "Please upload a file in a valid format."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="my-4 text-sm text-custom-text-200">
|
||||
File formats supported- .jpeg, .jpg, .png, .webp, .svg
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
{handleRemove && (
|
||||
<Button variant="danger" size="sm" onClick={handleRemove} disabled={!value}>
|
||||
{isRemoving ? "Removing..." : "Remove"}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={!image}
|
||||
loading={isImageUploading}
|
||||
>
|
||||
{isImageUploading ? "Uploading..." : "Upload & Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
"use client";
|
||||
// ui
|
||||
import { Checkbox } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { TSelectionHelper } from "@/hooks/use-multiple-select";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
groupId: string;
|
||||
id: string;
|
||||
selectionHelpers: TSelectionHelper;
|
||||
};
|
||||
|
||||
export const MultipleSelectEntityAction: React.FC<Props> = (props) => {
|
||||
const { className, disabled = false, groupId, id, selectionHelpers } = props;
|
||||
// derived values
|
||||
const isSelected = selectionHelpers.getIsEntitySelected(id);
|
||||
|
||||
if (selectionHelpers.isSelectionDisabled) return null;
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
className={cn("!outline-none size-3.5", className)}
|
||||
iconClassName="size-3"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
selectionHelpers.handleEntityClick(e, id, groupId);
|
||||
}}
|
||||
checked={isSelected}
|
||||
data-entity-group-id={groupId}
|
||||
data-entity-id={id}
|
||||
disabled={disabled}
|
||||
readOnly
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
"use client";
|
||||
// ui
|
||||
import { Checkbox } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { TSelectionHelper } from "@/hooks/use-multiple-select";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
groupID: string;
|
||||
selectionHelpers: TSelectionHelper;
|
||||
};
|
||||
|
||||
export const MultipleSelectGroupAction: React.FC<Props> = (props) => {
|
||||
const { className, disabled = false, groupID, selectionHelpers } = props;
|
||||
// derived values
|
||||
const groupSelectionStatus = selectionHelpers.isGroupSelected(groupID);
|
||||
|
||||
if (selectionHelpers.isSelectionDisabled) return null;
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
className={cn("size-3.5 !outline-none", className)}
|
||||
iconClassName="size-3"
|
||||
onClick={() => selectionHelpers.handleGroupClick(groupID)}
|
||||
checked={groupSelectionStatus === "complete"}
|
||||
indeterminate={groupSelectionStatus === "partial"}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
3
web/core/components/core/multiple-select/index.ts
Normal file
3
web/core/components/core/multiple-select/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from "./entity-select-action";
|
||||
export * from "./group-select-action";
|
||||
export * from "./select-group";
|
||||
24
web/core/components/core/multiple-select/select-group.tsx
Normal file
24
web/core/components/core/multiple-select/select-group.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { TSelectionHelper, useMultipleSelect } from "@/hooks/use-multiple-select";
|
||||
|
||||
type Props = {
|
||||
children: (helpers: TSelectionHelper) => React.ReactNode;
|
||||
containerRef: React.MutableRefObject<HTMLElement | null>;
|
||||
disabled?: boolean;
|
||||
entities: Record<string, string[]>; // { groupID: entityIds[] }
|
||||
};
|
||||
|
||||
export const MultipleSelectGroup: React.FC<Props> = observer((props) => {
|
||||
const { children, containerRef, disabled = false, entities } = props;
|
||||
|
||||
const helpers = useMultipleSelect({
|
||||
containerRef,
|
||||
disabled,
|
||||
entities,
|
||||
});
|
||||
|
||||
return <>{children(helpers)}</>;
|
||||
});
|
||||
|
||||
MultipleSelectGroup.displayName = "MultipleSelectGroup";
|
||||
18
web/core/components/core/page-title.tsx
Normal file
18
web/core/components/core/page-title.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import Head from "next/head";
|
||||
|
||||
type PageHeadTitleProps = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export const PageHead: React.FC<PageHeadTitleProps> = (props) => {
|
||||
const { title } = props;
|
||||
|
||||
if (!title) return null;
|
||||
|
||||
return (
|
||||
<Head>
|
||||
<title>{title}</title>
|
||||
</Head>
|
||||
);
|
||||
};
|
||||
76
web/core/components/core/render-if-visible-HOC.tsx
Normal file
76
web/core/components/core/render-if-visible-HOC.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import React, { useState, useRef, useEffect, ReactNode, MutableRefObject } from "react";
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
|
||||
type Props = {
|
||||
defaultHeight?: string;
|
||||
verticalOffset?: number;
|
||||
horizontalOffset?: number;
|
||||
root?: MutableRefObject<HTMLElement | null>;
|
||||
children: ReactNode;
|
||||
as?: keyof JSX.IntrinsicElements;
|
||||
classNames?: string;
|
||||
placeholderChildren?: ReactNode;
|
||||
};
|
||||
|
||||
const RenderIfVisible: React.FC<Props> = (props) => {
|
||||
const {
|
||||
defaultHeight = "300px",
|
||||
root,
|
||||
verticalOffset = 50,
|
||||
horizontalOffset = 0,
|
||||
as = "div",
|
||||
children,
|
||||
classNames = "",
|
||||
placeholderChildren = null, //placeholder children
|
||||
} = props;
|
||||
const [shouldVisible, setShouldVisible] = useState<boolean>();
|
||||
const placeholderHeight = useRef<string>(defaultHeight);
|
||||
const intersectionRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const isVisible = shouldVisible;
|
||||
|
||||
// Set visibility with intersection observer
|
||||
useEffect(() => {
|
||||
if (intersectionRef.current) {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
//DO no remove comments for future
|
||||
// if (typeof window !== undefined && window.requestIdleCallback) {
|
||||
// window.requestIdleCallback(() => setShouldVisible(entries[0].isIntersecting), {
|
||||
// timeout: 300,
|
||||
// });
|
||||
// } else {
|
||||
// setShouldVisible(entries[0].isIntersecting);
|
||||
// }
|
||||
setShouldVisible(entries[entries.length - 1].isIntersecting);
|
||||
},
|
||||
{
|
||||
root: root?.current,
|
||||
rootMargin: `${verticalOffset}% ${horizontalOffset}% ${verticalOffset}% ${horizontalOffset}%`,
|
||||
}
|
||||
);
|
||||
observer.observe(intersectionRef.current);
|
||||
return () => {
|
||||
if (intersectionRef.current) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
observer.unobserve(intersectionRef.current);
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [intersectionRef, children, root, verticalOffset, horizontalOffset]);
|
||||
|
||||
//Set height after render
|
||||
useEffect(() => {
|
||||
if (intersectionRef.current && isVisible) {
|
||||
placeholderHeight.current = `${intersectionRef.current.offsetHeight}px`;
|
||||
}
|
||||
}, [isVisible, intersectionRef]);
|
||||
|
||||
const child = isVisible ? <>{children}</> : placeholderChildren;
|
||||
const style = isVisible ? {} : { height: placeholderHeight.current, width: "100%" };
|
||||
const className = isVisible ? classNames : cn(classNames, "bg-custom-background-80");
|
||||
|
||||
return React.createElement(as, { ref: intersectionRef, style, className }, child);
|
||||
};
|
||||
|
||||
export default RenderIfVisible;
|
||||
4
web/core/components/core/sidebar/index.ts
Normal file
4
web/core/components/core/sidebar/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from "./links-list";
|
||||
export * from "./sidebar-progress-stats";
|
||||
export * from "./single-progress-stats";
|
||||
export * from "./sidebar-menu-hamburger-toggle";
|
||||
118
web/core/components/core/sidebar/links-list.tsx
Normal file
118
web/core/components/core/sidebar/links-list.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
"use client";
|
||||
import { observer } from "mobx-react";
|
||||
// icons
|
||||
import { Pencil, Trash2, LinkIcon, ExternalLink } from "lucide-react";
|
||||
import { ILinkDetails, UserAuth } from "@plane/types";
|
||||
// ui
|
||||
import { Tooltip, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// helpers
|
||||
import { calculateTimeAgo } from "@/helpers/date-time.helper";
|
||||
// hooks
|
||||
import { useMember, useModule } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
moduleId: string;
|
||||
|
||||
handleDeleteLink: (linkId: string) => void;
|
||||
handleEditLink: (link: ILinkDetails) => void;
|
||||
userAuth: UserAuth;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const LinksList: React.FC<Props> = observer((props) => {
|
||||
const { moduleId, handleDeleteLink, handleEditLink, userAuth, disabled } = props;
|
||||
// hooks
|
||||
const { getUserDetails } = useMember();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { getModuleById } = useModule();
|
||||
// derived values
|
||||
const currentModule = getModuleById(moduleId);
|
||||
const moduleLinks = currentModule?.link_module || undefined;
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Copied to clipboard",
|
||||
message: "The URL has been successfully copied to your clipboard",
|
||||
});
|
||||
};
|
||||
|
||||
if (!moduleLinks) return <></>;
|
||||
return (
|
||||
<>
|
||||
{moduleLinks.map((link) => {
|
||||
const createdByDetails = getUserDetails(link.created_by);
|
||||
return (
|
||||
<div key={link.id} className="relative flex flex-col rounded-md bg-custom-background-90 p-2.5">
|
||||
<div className="flex w-full items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2 truncate">
|
||||
<span className="py-1">
|
||||
<LinkIcon className="h-3 w-3 flex-shrink-0" />
|
||||
</span>
|
||||
<Tooltip tooltipContent={link.title && link.title !== "" ? link.title : link.url} isMobile={isMobile}>
|
||||
<span
|
||||
className="cursor-pointer truncate text-xs"
|
||||
onClick={() => copyToClipboard(link.title && link.title !== "" ? link.title : link.url)}
|
||||
>
|
||||
{link.title && link.title !== "" ? link.title : link.url}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{!isNotAllowed && (
|
||||
<div className="z-[1] flex flex-shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleEditLink(link);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-3 w-3 stroke-[1.5] text-custom-text-200" />
|
||||
</button>
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 stroke-[1.5] text-custom-text-200" />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleDeleteLink(link.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-5">
|
||||
<p className="mt-0.5 stroke-[1.5] text-xs text-custom-text-300">
|
||||
Added {calculateTimeAgo(link.created_at)}
|
||||
<br />
|
||||
{createdByDetails && (
|
||||
<>
|
||||
by{" "}
|
||||
{createdByDetails?.is_bot ? createdByDetails?.first_name + " Bot" : createdByDetails?.display_name}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
152
web/core/components/core/sidebar/progress-chart.tsx
Normal file
152
web/core/components/core/sidebar/progress-chart.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import React from "react";
|
||||
import { eachDayOfInterval, isValid } from "date-fns";
|
||||
import { TCompletionChartDistribution } from "@plane/types";
|
||||
// ui
|
||||
import { LineGraph } from "@/components/ui";
|
||||
// helpers
|
||||
import { getDate, renderFormattedDateWithoutYear } from "@/helpers/date-time.helper";
|
||||
//types
|
||||
|
||||
type Props = {
|
||||
distribution: TCompletionChartDistribution;
|
||||
startDate: string | Date;
|
||||
endDate: string | Date;
|
||||
totalIssues: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const styleById = {
|
||||
ideal: {
|
||||
strokeDasharray: "6, 3",
|
||||
strokeWidth: 1,
|
||||
},
|
||||
default: {
|
||||
strokeWidth: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const DashedLine = ({ series, lineGenerator, xScale, yScale }: any) =>
|
||||
series.map(({ id, data, color }: any) => (
|
||||
<path
|
||||
key={id}
|
||||
d={lineGenerator(
|
||||
data.map((d: any) => ({
|
||||
x: xScale(d.data.x),
|
||||
y: yScale(d.data.y),
|
||||
}))
|
||||
)}
|
||||
fill="none"
|
||||
stroke={color ?? "#ddd"}
|
||||
style={styleById[id as keyof typeof styleById] || styleById.default}
|
||||
/>
|
||||
));
|
||||
|
||||
const ProgressChart: React.FC<Props> = ({ distribution, startDate, endDate, totalIssues, className = "" }) => {
|
||||
const chartData = Object.keys(distribution ?? []).map((key) => ({
|
||||
currentDate: renderFormattedDateWithoutYear(key),
|
||||
pending: distribution[key],
|
||||
}));
|
||||
|
||||
const generateXAxisTickValues = () => {
|
||||
const start = getDate(startDate);
|
||||
const end = getDate(endDate);
|
||||
|
||||
let dates: Date[] = [];
|
||||
if (start && end && isValid(start) && isValid(end)) {
|
||||
dates = eachDayOfInterval({ start, end });
|
||||
}
|
||||
|
||||
if (dates.length === 0) return [];
|
||||
|
||||
const formattedDates = dates.map((d) => renderFormattedDateWithoutYear(d));
|
||||
const firstDate = formattedDates[0];
|
||||
const lastDate = formattedDates[formattedDates.length - 1];
|
||||
|
||||
if (formattedDates.length <= 2) return [firstDate, lastDate];
|
||||
|
||||
const middleDateIndex = Math.floor(formattedDates.length / 2);
|
||||
const middleDate = formattedDates[middleDateIndex];
|
||||
|
||||
return [firstDate, middleDate, lastDate];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex w-full items-center justify-center ${className}`}>
|
||||
<LineGraph
|
||||
animate
|
||||
curve="monotoneX"
|
||||
height="160px"
|
||||
width="100%"
|
||||
enableGridY={false}
|
||||
lineWidth={1}
|
||||
margin={{ top: 30, right: 30, bottom: 30, left: 30 }}
|
||||
data={[
|
||||
{
|
||||
id: "pending",
|
||||
color: "#3F76FF",
|
||||
data:
|
||||
chartData.length > 0
|
||||
? chartData.map((item, index) => ({
|
||||
index,
|
||||
x: item.currentDate,
|
||||
y: item.pending,
|
||||
color: "#3F76FF",
|
||||
}))
|
||||
: [],
|
||||
enableArea: true,
|
||||
},
|
||||
{
|
||||
id: "ideal",
|
||||
color: "#a9bbd0",
|
||||
fill: "transparent",
|
||||
data:
|
||||
chartData.length > 0
|
||||
? [
|
||||
{
|
||||
x: chartData[0].currentDate,
|
||||
y: totalIssues,
|
||||
},
|
||||
{
|
||||
x: chartData[chartData.length - 1].currentDate,
|
||||
y: 0,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
]}
|
||||
layers={["grid", "markers", "areas", DashedLine, "slices", "points", "axes", "legends"]}
|
||||
axisBottom={{
|
||||
tickValues: generateXAxisTickValues(),
|
||||
}}
|
||||
enablePoints={false}
|
||||
enableArea
|
||||
colors={(datum) => datum.color ?? "#3F76FF"}
|
||||
customYAxisTickValues={[0, totalIssues]}
|
||||
gridXValues={
|
||||
chartData.length > 0 ? chartData.map((item, index) => (index % 2 === 0 ? item.currentDate : "")) : undefined
|
||||
}
|
||||
enableSlices="x"
|
||||
sliceTooltip={(datum) => (
|
||||
<div className="rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
|
||||
{datum.slice.points[0].data.yFormatted}
|
||||
<span className="text-custom-text-200"> issues pending on </span>
|
||||
{datum.slice.points[0].data.xFormatted}
|
||||
</div>
|
||||
)}
|
||||
theme={{
|
||||
background: "transparent",
|
||||
axis: {
|
||||
domain: {
|
||||
line: {
|
||||
stroke: "rgb(var(--color-border))",
|
||||
strokeWidth: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProgressChart;
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Menu } from "lucide-react";
|
||||
import { useAppTheme } from "@/hooks/store";
|
||||
|
||||
export const SidebarHamburgerToggle: FC = observer(() => {
|
||||
// store hooks
|
||||
const { toggleSidebar } = useAppTheme();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group flex h-7 w-7 flex-shrink-0 cursor-pointer items-center justify-center rounded bg-custom-background-80 transition-all hover:bg-custom-background-90 md:hidden"
|
||||
onClick={() => toggleSidebar()}
|
||||
>
|
||||
<Menu size={14} className="text-custom-text-200 transition-all group-hover:text-custom-text-100" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
285
web/core/components/core/sidebar/sidebar-progress-stats.tsx
Normal file
285
web/core/components/core/sidebar/sidebar-progress-stats.tsx
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
"use client";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
// headless ui
|
||||
import { Tab } from "@headlessui/react";
|
||||
import {
|
||||
IIssueFilterOptions,
|
||||
IIssueFilters,
|
||||
IModule,
|
||||
TAssigneesDistribution,
|
||||
TCompletionChartDistribution,
|
||||
TLabelsDistribution,
|
||||
TStateGroups,
|
||||
} from "@plane/types";
|
||||
// hooks
|
||||
import { Avatar, StateGroupIcon } from "@plane/ui";
|
||||
import { SingleProgressStats } from "@/components/core";
|
||||
import { useProjectState } from "@/hooks/store";
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
// images
|
||||
import emptyLabel from "@/public/empty-state/empty_label.svg";
|
||||
import emptyMembers from "@/public/empty-state/empty_members.svg";
|
||||
// components
|
||||
// ui
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
distribution:
|
||||
| {
|
||||
assignees: TAssigneesDistribution[];
|
||||
completion_chart: TCompletionChartDistribution;
|
||||
labels: TLabelsDistribution[];
|
||||
}
|
||||
| undefined;
|
||||
groupedIssues: {
|
||||
[key: string]: number;
|
||||
};
|
||||
totalIssues: number;
|
||||
module?: IModule;
|
||||
roundedTab?: boolean;
|
||||
noBackground?: boolean;
|
||||
isPeekView?: boolean;
|
||||
isCompleted?: boolean;
|
||||
filters?: IIssueFilters | undefined;
|
||||
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
|
||||
};
|
||||
|
||||
export const SidebarProgressStats: React.FC<Props> = observer((props) => {
|
||||
const {
|
||||
distribution,
|
||||
groupedIssues,
|
||||
totalIssues,
|
||||
module,
|
||||
roundedTab,
|
||||
noBackground,
|
||||
isPeekView = false,
|
||||
isCompleted = false,
|
||||
filters,
|
||||
handleFiltersUpdate,
|
||||
} = props;
|
||||
const { storedValue: tab, setValue: setTab } = useLocalStorage("tab", "Assignees");
|
||||
|
||||
const { groupedProjectStates } = useProjectState();
|
||||
|
||||
const currentValue = (tab: string | null) => {
|
||||
switch (tab) {
|
||||
case "Assignees":
|
||||
return 0;
|
||||
case "Labels":
|
||||
return 1;
|
||||
case "States":
|
||||
return 2;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const getStateGroupState = (stateGroup: string) => {
|
||||
const stateGroupStates = groupedProjectStates?.[stateGroup];
|
||||
const stateGroupStatesId = stateGroupStates?.map((state) => state.id);
|
||||
return stateGroupStatesId;
|
||||
};
|
||||
|
||||
return (
|
||||
<Tab.Group
|
||||
defaultIndex={currentValue(tab)}
|
||||
onChange={(i) => {
|
||||
switch (i) {
|
||||
case 0:
|
||||
return setTab("Assignees");
|
||||
case 1:
|
||||
return setTab("Labels");
|
||||
case 2:
|
||||
return setTab("States");
|
||||
default:
|
||||
return setTab("Assignees");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tab.List
|
||||
as="div"
|
||||
className={`flex w-full items-center justify-between gap-2 rounded-md ${
|
||||
noBackground ? "" : "bg-custom-background-90"
|
||||
} p-0.5
|
||||
${module ? "text-xs" : "text-sm"}`}
|
||||
>
|
||||
<Tab
|
||||
className={({ selected }) =>
|
||||
`w-full ${
|
||||
roundedTab ? "rounded-3xl border border-custom-border-200" : "rounded"
|
||||
} px-3 py-1 text-custom-text-100 ${
|
||||
selected
|
||||
? "bg-custom-background-100 text-custom-text-300 shadow-custom-shadow-2xs"
|
||||
: "text-custom-text-400 hover:text-custom-text-300"
|
||||
}`
|
||||
}
|
||||
>
|
||||
Assignees
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({ selected }) =>
|
||||
`w-full ${
|
||||
roundedTab ? "rounded-3xl border border-custom-border-200" : "rounded"
|
||||
} px-3 py-1 text-custom-text-100 ${
|
||||
selected
|
||||
? "bg-custom-background-100 text-custom-text-300 shadow-custom-shadow-2xs"
|
||||
: "text-custom-text-400 hover:text-custom-text-300"
|
||||
}`
|
||||
}
|
||||
>
|
||||
Labels
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({ selected }) =>
|
||||
`w-full ${
|
||||
roundedTab ? "rounded-3xl border border-custom-border-200" : "rounded"
|
||||
} px-3 py-1 text-custom-text-100 ${
|
||||
selected
|
||||
? "bg-custom-background-100 text-custom-text-300 shadow-custom-shadow-2xs"
|
||||
: "text-custom-text-400 hover:text-custom-text-300"
|
||||
}`
|
||||
}
|
||||
>
|
||||
States
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels className="flex w-full items-center justify-between text-custom-text-200">
|
||||
<Tab.Panel
|
||||
as="div"
|
||||
className="flex w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm"
|
||||
>
|
||||
{distribution && distribution?.assignees.length > 0 ? (
|
||||
distribution.assignees.map((assignee, index) => {
|
||||
if (assignee.assignee_id)
|
||||
return (
|
||||
<SingleProgressStats
|
||||
key={assignee.assignee_id}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar name={assignee?.display_name ?? undefined} src={assignee?.avatar ?? undefined} />
|
||||
<span>{assignee?.display_name ?? ""}</span>
|
||||
</div>
|
||||
}
|
||||
completed={assignee.completed_issues}
|
||||
total={assignee.total_issues}
|
||||
{...(!isPeekView &&
|
||||
!isCompleted && {
|
||||
onClick: () => handleFiltersUpdate("assignees", assignee.assignee_id ?? ""),
|
||||
selected: filters?.filters?.assignees?.includes(assignee.assignee_id ?? ""),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<SingleProgressStats
|
||||
key={`unassigned-${index}`}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 rounded-full border-2 border-custom-border-200 bg-custom-background-80">
|
||||
<img src="/user.png" height="100%" width="100%" className="rounded-full" alt="User" />
|
||||
</div>
|
||||
<span>No assignee</span>
|
||||
</div>
|
||||
}
|
||||
completed={assignee.completed_issues}
|
||||
total={assignee.total_issues}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-custom-background-80">
|
||||
<Image src={emptyMembers} className="h-12 w-12" alt="empty members" />
|
||||
</div>
|
||||
<h6 className="text-base text-custom-text-300">No assignees yet</h6>
|
||||
</div>
|
||||
)}
|
||||
</Tab.Panel>
|
||||
<Tab.Panel
|
||||
as="div"
|
||||
className="flex w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm"
|
||||
>
|
||||
{distribution && distribution?.labels.length > 0 ? (
|
||||
distribution.labels.map((label, index) => {
|
||||
if (label.label_id) {
|
||||
return (
|
||||
<SingleProgressStats
|
||||
key={label.label_id}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="block h-3 w-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label.color ?? "transparent",
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs">{label.label_name ?? "No labels"}</span>
|
||||
</div>
|
||||
}
|
||||
completed={label.completed_issues}
|
||||
total={label.total_issues}
|
||||
{...(!isPeekView &&
|
||||
!isCompleted && {
|
||||
onClick: () => handleFiltersUpdate("labels", label.label_id ?? ""),
|
||||
selected: filters?.filters?.labels?.includes(label.label_id ?? `no-label-${index}`),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<SingleProgressStats
|
||||
key={`no-label-${index}`}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="block h-3 w-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label.color ?? "transparent",
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs">{label.label_name ?? "No labels"}</span>
|
||||
</div>
|
||||
}
|
||||
completed={label.completed_issues}
|
||||
total={label.total_issues}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-custom-background-80">
|
||||
<Image src={emptyLabel} className="h-12 w-12" alt="empty label" />
|
||||
</div>
|
||||
<h6 className="text-base text-custom-text-300">No labels yet</h6>
|
||||
</div>
|
||||
)}
|
||||
</Tab.Panel>
|
||||
<Tab.Panel
|
||||
as="div"
|
||||
className="flex w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm"
|
||||
>
|
||||
{Object.keys(groupedIssues).map((group, index) => (
|
||||
<SingleProgressStats
|
||||
key={index}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<StateGroupIcon stateGroup={group as TStateGroups} />
|
||||
<span className="text-xs capitalize">{group}</span>
|
||||
</div>
|
||||
}
|
||||
completed={groupedIssues[group]}
|
||||
total={totalIssues}
|
||||
{...(!isPeekView &&
|
||||
!isCompleted && {
|
||||
onClick: () => handleFiltersUpdate("state", getStateGroupState(group) ?? []),
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
);
|
||||
});
|
||||
34
web/core/components/core/sidebar/single-progress-stats.tsx
Normal file
34
web/core/components/core/sidebar/single-progress-stats.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import React from "react";
|
||||
|
||||
type TSingleProgressStatsProps = {
|
||||
title: any;
|
||||
completed: number;
|
||||
total: number;
|
||||
onClick?: () => void;
|
||||
selected?: boolean;
|
||||
};
|
||||
|
||||
export const SingleProgressStats: React.FC<TSingleProgressStatsProps> = ({
|
||||
title,
|
||||
completed,
|
||||
total,
|
||||
onClick,
|
||||
selected = false,
|
||||
}) => (
|
||||
<div
|
||||
className={`flex w-full items-center justify-between gap-4 rounded-sm p-1 text-xs ${
|
||||
onClick ? "cursor-pointer hover:bg-custom-background-90" : ""
|
||||
} ${selected ? "bg-custom-background-90" : ""}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="w-1/2">{title}</div>
|
||||
<div className="flex w-1/2 items-center justify-end gap-1 px-2">
|
||||
<div className="flex h-5 items-center justify-center gap-1">
|
||||
<span className="w-8 text-right">
|
||||
{isNaN(Math.round((completed / total) * 100)) ? "0" : Math.round((completed / total) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<span>of {total}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
129
web/core/components/core/theme/color-picker-input.tsx
Normal file
129
web/core/components/core/theme/color-picker-input.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
"use client";
|
||||
import { FC, Fragment } from "react";
|
||||
// react-form
|
||||
import { ColorResult, SketchPicker } from "react-color";
|
||||
import {
|
||||
Control,
|
||||
Controller,
|
||||
FieldError,
|
||||
FieldErrorsImpl,
|
||||
Merge,
|
||||
UseFormRegister,
|
||||
UseFormSetValue,
|
||||
UseFormWatch,
|
||||
} from "react-hook-form";
|
||||
// react-color
|
||||
// component
|
||||
import { Palette } from "lucide-react";
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
import { IUserTheme } from "@plane/types";
|
||||
import { Input } from "@plane/ui";
|
||||
// icons
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
name: keyof IUserTheme;
|
||||
position?: "left" | "right";
|
||||
watch: UseFormWatch<any>;
|
||||
setValue: UseFormSetValue<any>;
|
||||
control: Control<IUserTheme, any>;
|
||||
error: FieldError | Merge<FieldError, FieldErrorsImpl<any>> | undefined;
|
||||
register: UseFormRegister<any>;
|
||||
};
|
||||
|
||||
export const ColorPickerInput: FC<Props> = (props) => {
|
||||
const { name, position = "left", watch, setValue, error, control } = props;
|
||||
|
||||
const handleColorChange = (newColor: ColorResult) => {
|
||||
const { hex } = newColor;
|
||||
setValue(name, hex);
|
||||
};
|
||||
|
||||
const getColorText = (colorName: keyof IUserTheme) => {
|
||||
switch (colorName) {
|
||||
case "background":
|
||||
return "Background";
|
||||
case "text":
|
||||
return "Text";
|
||||
case "primary":
|
||||
return "Primary(Theme)";
|
||||
case "sidebarBackground":
|
||||
return "Sidebar Background";
|
||||
case "sidebarText":
|
||||
return "Sidebar Text";
|
||||
default:
|
||||
return "Color";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Controller
|
||||
control={control}
|
||||
name={name}
|
||||
rules={{
|
||||
required: `${getColorText(name)} color is required`,
|
||||
pattern: {
|
||||
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
|
||||
message: `${getColorText(name)} color should be hex format`,
|
||||
},
|
||||
}}
|
||||
render={({ field: { onChange, ref } }) => (
|
||||
<Input
|
||||
id={name}
|
||||
name={name}
|
||||
type="text"
|
||||
value={watch("name")}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(error)}
|
||||
placeholder="#FFFFFF"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="absolute right-4 top-2.5">
|
||||
<Popover className="relative grid place-items-center">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
type="button"
|
||||
className={`group inline-flex items-center outline-none ${
|
||||
open ? "text-custom-text-100" : "text-custom-text-200"
|
||||
}`}
|
||||
>
|
||||
{watch(name) && watch(name) !== "" ? (
|
||||
<span
|
||||
className="h-4 w-4 rounded border border-custom-border-200"
|
||||
style={{
|
||||
backgroundColor: `${watch(name)}`,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Palette className="h-3.5 w-3.5 text-custom-text-100" />
|
||||
)}
|
||||
</Popover.Button>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel
|
||||
className={`absolute bottom-8 z-20 mt-1 max-w-xs px-2 sm:px-0 ${
|
||||
position === "right" ? "left-0" : "right-0"
|
||||
}`}
|
||||
>
|
||||
<SketchPicker color={watch(name)} onChange={handleColorChange} />
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
235
web/core/components/core/theme/custom-theme-selector.tsx
Normal file
235
web/core/components/core/theme/custom-theme-selector.tsx
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// types
|
||||
import { IUserTheme } from "@plane/types";
|
||||
// ui
|
||||
import { Button, InputColorPicker, setPromiseToast } from "@plane/ui";
|
||||
// hooks
|
||||
import { useUserProfile } from "@/hooks/store";
|
||||
|
||||
const inputRules = {
|
||||
minLength: {
|
||||
value: 7,
|
||||
message: "Enter a valid hex code of 6 characters",
|
||||
},
|
||||
maxLength: {
|
||||
value: 7,
|
||||
message: "Enter a valid hex code of 6 characters",
|
||||
},
|
||||
pattern: {
|
||||
value: /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/,
|
||||
message: "Enter a valid hex code of 6 characters",
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomThemeSelector: React.FC = observer(() => {
|
||||
const { setTheme } = useTheme();
|
||||
// hooks
|
||||
const { data: userProfile, updateUserTheme } = useUserProfile();
|
||||
|
||||
const {
|
||||
control,
|
||||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
watch,
|
||||
} = useForm<IUserTheme>({
|
||||
defaultValues: {
|
||||
background: userProfile?.theme?.background !== "" ? userProfile?.theme?.background : "#0d101b",
|
||||
text: userProfile?.theme?.text !== "" ? userProfile?.theme?.text : "#c5c5c5",
|
||||
primary: userProfile?.theme?.primary !== "" ? userProfile?.theme?.primary : "#3f76ff",
|
||||
sidebarBackground:
|
||||
userProfile?.theme?.sidebarBackground !== "" ? userProfile?.theme?.sidebarBackground : "#0d101b",
|
||||
sidebarText: userProfile?.theme?.sidebarText !== "" ? userProfile?.theme?.sidebarText : "#c5c5c5",
|
||||
darkPalette: userProfile?.theme?.darkPalette || false,
|
||||
palette: userProfile?.theme?.palette !== "" ? userProfile?.theme?.palette : "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleUpdateTheme = async (formData: Partial<IUserTheme>) => {
|
||||
const payload: IUserTheme = {
|
||||
background: formData.background,
|
||||
text: formData.text,
|
||||
primary: formData.primary,
|
||||
sidebarBackground: formData.sidebarBackground,
|
||||
sidebarText: formData.sidebarText,
|
||||
darkPalette: false,
|
||||
palette: `${formData.background},${formData.text},${formData.primary},${formData.sidebarBackground},${formData.sidebarText}`,
|
||||
theme: "custom",
|
||||
};
|
||||
setTheme("custom");
|
||||
|
||||
const updateCurrentUserThemePromise = updateUserTheme(payload);
|
||||
setPromiseToast(updateCurrentUserThemePromise, {
|
||||
loading: "Updating theme...",
|
||||
success: {
|
||||
title: "Success!",
|
||||
message: () => "Theme updated successfully!",
|
||||
},
|
||||
error: {
|
||||
title: "Error!",
|
||||
message: () => "Failed to Update the theme",
|
||||
},
|
||||
});
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
const handleValueChange = (val: string | undefined, onChange: any) => {
|
||||
let hex = val;
|
||||
// prepend a hashtag if it doesn't exist
|
||||
if (val && val[0] !== "#") hex = `#${val}`;
|
||||
|
||||
onChange(hex);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleUpdateTheme)}>
|
||||
<div className="space-y-5">
|
||||
<h3 className="text-lg font-semibold text-custom-text-100">Customize your theme</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-x-8 gap-y-4 sm:grid-cols-2 md:grid-cols-3">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<h3 className="text-left text-sm font-medium text-custom-text-200">Background color</h3>
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
control={control}
|
||||
name="background"
|
||||
rules={{ ...inputRules, required: "Background color is required" }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<InputColorPicker
|
||||
name="background"
|
||||
value={value}
|
||||
onChange={(val) => handleValueChange(val, onChange)}
|
||||
placeholder="#0d101b"
|
||||
className="w-full placeholder:text-custom-text-400/60"
|
||||
style={{
|
||||
backgroundColor: watch("background"),
|
||||
color: watch("text"),
|
||||
}}
|
||||
hasError={Boolean(errors?.background)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.background && <p className="mt-1 text-xs text-red-500">{errors.background.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<h3 className="text-left text-sm font-medium text-custom-text-200">Text color</h3>
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
control={control}
|
||||
name="text"
|
||||
rules={{ ...inputRules, required: "Text color is required" }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<InputColorPicker
|
||||
name="text"
|
||||
value={value}
|
||||
onChange={(val) => handleValueChange(val, onChange)}
|
||||
placeholder="#c5c5c5"
|
||||
className="w-full placeholder:text-custom-text-400/60"
|
||||
style={{
|
||||
backgroundColor: watch("text"),
|
||||
color: watch("background"),
|
||||
}}
|
||||
hasError={Boolean(errors?.text)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.text && <p className="mt-1 text-xs text-red-500">{errors.text.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<h3 className="text-left text-sm font-medium text-custom-text-200">Primary(Theme) color</h3>
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
control={control}
|
||||
name="primary"
|
||||
rules={{ ...inputRules, required: "Primary color is required" }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<InputColorPicker
|
||||
name="primary"
|
||||
value={value}
|
||||
onChange={(val) => handleValueChange(val, onChange)}
|
||||
placeholder="#3f76ff"
|
||||
className="w-full placeholder:text-custom-text-400/60"
|
||||
style={{
|
||||
backgroundColor: watch("primary"),
|
||||
color: watch("text"),
|
||||
}}
|
||||
hasError={Boolean(errors?.primary)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.primary && <p className="mt-1 text-xs text-red-500">{errors.primary.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<h3 className="text-left text-sm font-medium text-custom-text-200">Sidebar background color</h3>
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
control={control}
|
||||
name="sidebarBackground"
|
||||
rules={{ ...inputRules, required: "Sidebar background color is required" }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<InputColorPicker
|
||||
name="sidebarBackground"
|
||||
value={value}
|
||||
onChange={(val) => handleValueChange(val, onChange)}
|
||||
placeholder="#0d101b"
|
||||
className="w-full placeholder:text-custom-text-400/60"
|
||||
style={{
|
||||
backgroundColor: watch("sidebarBackground"),
|
||||
color: watch("sidebarText"),
|
||||
}}
|
||||
hasError={Boolean(errors?.sidebarBackground)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.sidebarBackground && (
|
||||
<p className="mt-1 text-xs text-red-500">{errors.sidebarBackground.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<h3 className="text-left text-sm font-medium text-custom-text-200">Sidebar text color</h3>
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
control={control}
|
||||
name="sidebarText"
|
||||
rules={{ ...inputRules, required: "Sidebar text color is required" }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<InputColorPicker
|
||||
name="sidebarText"
|
||||
value={value}
|
||||
onChange={(val) => handleValueChange(val, onChange)}
|
||||
placeholder="#c5c5c5"
|
||||
className="w-full placeholder:text-custom-text-400/60"
|
||||
style={{
|
||||
backgroundColor: watch("sidebarText"),
|
||||
color: watch("sidebarBackground"),
|
||||
}}
|
||||
hasError={Boolean(errors?.sidebarText)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.sidebarText && <p className="mt-1 text-xs text-red-500">{errors.sidebarText.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<Button variant="primary" type="submit" loading={isSubmitting}>
|
||||
{isSubmitting ? "Creating Theme..." : "Set Theme"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
3
web/core/components/core/theme/index.ts
Normal file
3
web/core/components/core/theme/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from "./color-picker-input";
|
||||
export * from "./custom-theme-selector";
|
||||
export * from "./theme-switch";
|
||||
81
web/core/components/core/theme/theme-switch.tsx
Normal file
81
web/core/components/core/theme/theme-switch.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
// constants
|
||||
import { CustomSelect } from "@plane/ui";
|
||||
import { THEME_OPTIONS, I_THEME_OPTION } from "@/constants/themes";
|
||||
// ui
|
||||
|
||||
type Props = {
|
||||
value: I_THEME_OPTION | null;
|
||||
onChange: (value: I_THEME_OPTION) => void;
|
||||
};
|
||||
|
||||
export const ThemeSwitch: FC<Props> = (props) => {
|
||||
const { value, onChange } = props;
|
||||
|
||||
return (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
label={
|
||||
value ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="border-1 relative flex h-4 w-4 rotate-45 transform items-center justify-center rounded-full border"
|
||||
style={{
|
||||
borderColor: value.icon.border,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="h-full w-1/2 rounded-l-full"
|
||||
style={{
|
||||
background: value.icon.color1,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="h-full w-1/2 rounded-r-full border-l"
|
||||
style={{
|
||||
borderLeftColor: value.icon.border,
|
||||
background: value.icon.color2,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{value.label}
|
||||
</div>
|
||||
) : (
|
||||
"Select your theme"
|
||||
)
|
||||
}
|
||||
onChange={onChange}
|
||||
input
|
||||
>
|
||||
{THEME_OPTIONS.map((themeOption) => (
|
||||
<CustomSelect.Option key={themeOption.value} value={themeOption}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="border-1 relative flex h-4 w-4 rotate-45 transform items-center justify-center rounded-full border"
|
||||
style={{
|
||||
borderColor: themeOption.icon.border,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="h-full w-1/2 rounded-l-full"
|
||||
style={{
|
||||
background: themeOption.icon.color1,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="h-full w-1/2 rounded-r-full border-l"
|
||||
style={{
|
||||
borderLeftColor: themeOption.icon.border,
|
||||
background: themeOption.icon.color2,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{themeOption.label}
|
||||
</div>
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
);
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue