Merge branch 'develop' of github.com:makeplane/plane into chore/issue_activity_epoch

This commit is contained in:
NarayanBavisetti 2023-09-19 14:37:57 +05:30
commit 15beaf571e
84 changed files with 3669 additions and 1431 deletions

View File

@ -293,12 +293,12 @@ class IssueLabelSerializer(BaseSerializer):
class IssueRelationSerializer(BaseSerializer): class IssueRelationSerializer(BaseSerializer):
related_issue_detail = IssueProjectLiteSerializer(read_only=True, source="related_issue") issue_detail = IssueProjectLiteSerializer(read_only=True, source="related_issue")
class Meta: class Meta:
model = IssueRelation model = IssueRelation
fields = [ fields = [
"related_issue_detail", "issue_detail",
"relation_type", "relation_type",
"related_issue", "related_issue",
"issue", "issue",

View File

@ -54,6 +54,7 @@ from plane.api.serializers import (
CommentReactionSerializer, CommentReactionSerializer,
IssueVoteSerializer, IssueVoteSerializer,
IssueRelationSerializer, IssueRelationSerializer,
RelatedIssueSerializer,
IssuePublicSerializer, IssuePublicSerializer,
) )
from plane.api.permissions import ( from plane.api.permissions import (
@ -2112,9 +2113,10 @@ class IssueRelationViewSet(BaseViewSet):
def create(self, request, slug, project_id, issue_id): def create(self, request, slug, project_id, issue_id):
try: try:
related_list = request.data.get("related_list", []) related_list = request.data.get("related_list", [])
relation = request.data.get("relation", None)
project = Project.objects.get(pk=project_id) project = Project.objects.get(pk=project_id)
issueRelation = IssueRelation.objects.bulk_create( issue_relation = IssueRelation.objects.bulk_create(
[ [
IssueRelation( IssueRelation(
issue_id=related_issue["issue"], issue_id=related_issue["issue"],
@ -2140,11 +2142,17 @@ class IssueRelationViewSet(BaseViewSet):
current_instance=None, current_instance=None,
epoch = int(timezone.now().timestamp()) epoch = int(timezone.now().timestamp())
) )
return Response( if relation == "blocking":
IssueRelationSerializer(issueRelation, many=True).data, return Response(
status=status.HTTP_201_CREATED, RelatedIssueSerializer(issue_relation, many=True).data,
) status=status.HTTP_201_CREATED,
)
else:
return Response(
IssueRelationSerializer(issue_relation, many=True).data,
status=status.HTTP_201_CREATED,
)
except IntegrityError as e: except IntegrityError as e:
if "already exists" in str(e): if "already exists" in str(e):
return Response( return Response(

View File

@ -1100,6 +1100,25 @@ def create_issue_relation_activity(
) )
if current_instance is None and requested_data.get("related_list") is not None: if current_instance is None and requested_data.get("related_list") is not None:
for issue_relation in requested_data.get("related_list"): for issue_relation in requested_data.get("related_list"):
if issue_relation.get("relation_type") == "blocked_by":
relation_type = "blocking"
else:
relation_type = issue_relation.get("relation_type")
issue = Issue.objects.get(pk=issue_relation.get("issue"))
issue_activities.append(
IssueActivity(
issue_id=issue_relation.get("related_issue"),
actor=actor,
verb="created",
old_value="",
new_value=f"{project.identifier}-{issue.sequence_id}",
field=relation_type,
project=project,
workspace=project.workspace,
comment=f'added {relation_type} relation',
old_identifier=issue_relation.get("issue"),
)
)
issue = Issue.objects.get(pk=issue_relation.get("related_issue")) issue = Issue.objects.get(pk=issue_relation.get("related_issue"))
issue_activities.append( issue_activities.append(
IssueActivity( IssueActivity(
@ -1112,7 +1131,7 @@ def create_issue_relation_activity(
project=project, project=project,
workspace=project.workspace, workspace=project.workspace,
comment=f'added {issue_relation.get("relation_type")} relation', comment=f'added {issue_relation.get("relation_type")} relation',
old_identifier=issue_relation.get("issue"), old_identifier=issue_relation.get("related_issue"),
epoch=epoch, epoch=epoch,
) )
) )
@ -1126,22 +1145,42 @@ def delete_issue_relation_activity(
json.loads(current_instance) if current_instance is not None else None json.loads(current_instance) if current_instance is not None else None
) )
if current_instance is not None and requested_data.get("related_list") is None: if current_instance is not None and requested_data.get("related_list") is None:
issue = Issue.objects.get(pk=current_instance.get("issue")) if current_instance.get("relation_type") == "blocked_by":
issue_activities.append( relation_type = "blocking"
IssueActivity( else:
issue_id=current_instance.get("issue"), relation_type = current_instance.get("relation_type")
actor=actor, issue = Issue.objects.get(pk=current_instance.get("issue"))
verb="deleted", issue_activities.append(
old_value=f"{project.identifier}-{issue.sequence_id}", IssueActivity(
new_value="", issue_id=current_instance.get("related_issue"),
field=f'{current_instance.get("relation_type")}', actor=actor,
project=project, verb="deleted",
workspace=project.workspace, old_value=f"{project.identifier}-{issue.sequence_id}",
comment=f'deleted the {current_instance.get("relation_type")} relation', new_value="",
old_identifier=current_instance.get("issue"), field=relation_type,
epoch=epoch, project=project,
workspace=project.workspace,
comment=f'deleted {relation_type} relation',
old_identifier=current_instance.get("issue"),
epoch=epoch,
)
)
issue = Issue.objects.get(pk=current_instance.get("related_issue"))
issue_activities.append(
IssueActivity(
issue_id=current_instance.get("issue"),
actor=actor,
verb="deleted",
old_value=f"{project.identifier}-{issue.sequence_id}",
new_value="",
field=f'{current_instance.get("relation_type")}',
project=project,
workspace=project.workspace,
comment=f'deleted {current_instance.get("relation_type")} relation',
old_identifier=current_instance.get("related_issue"),
epoch=epoch,
)
) )
)
def create_draft_issue_activity( def create_draft_issue_activity(

View File

@ -0,0 +1,23 @@
# Generated by Django 4.2.3 on 2023-09-15 06:55
from django.db import migrations
def update_issue_activity(apps, schema_editor):
IssueActivityModel = apps.get_model("db", "IssueActivity")
updated_issue_activity = []
for obj in IssueActivityModel.objects.all():
if obj.field == "blocks":
obj.field = "blocked_by"
updated_issue_activity.append(obj)
IssueActivityModel.objects.bulk_update(updated_issue_activity, ["field"], batch_size=100)
class Migration(migrations.Migration):
dependencies = [
('db', '0044_auto_20230913_0709'),
]
operations = [
migrations.RunPython(update_issue_activity),
]

View File

@ -103,8 +103,8 @@ export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleCha
{projectDetails?.close_in !== 0 && ( {projectDetails?.close_in !== 0 && (
<div className="ml-12"> <div className="ml-12">
<div className="flex flex-col gap-4"> <div className="flex flex-col rounded bg-custom-background-90 border border-custom-border-200 p-2">
<div className="flex items-center justify-between rounded px-5 py-4 bg-custom-background-90 border border-custom-border-200 gap-2 w-full"> <div className="flex items-center justify-between px-5 py-4 gap-2 w-full">
<div className="w-1/2 text-sm font-medium"> <div className="w-1/2 text-sm font-medium">
Auto-close issues that are inactive for Auto-close issues that are inactive for
</div> </div>
@ -138,7 +138,7 @@ export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleCha
</div> </div>
</div> </div>
<div className="flex items-center justify-between rounded px-5 py-4 bg-custom-background-90 border border-custom-border-200 gap-2 w-full"> <div className="flex items-center justify-between px-5 py-4 gap-2 w-full">
<div className="w-1/2 text-sm font-medium">Auto-close Status</div> <div className="w-1/2 text-sm font-medium">Auto-close Status</div>
<div className="w-1/2 "> <div className="w-1/2 ">
<CustomSearchSelect <CustomSearchSelect

View File

@ -41,7 +41,7 @@ export const CommandPalette: React.FC = observer(() => {
const [isCreateUpdatePageModalOpen, setIsCreateUpdatePageModalOpen] = useState(false); const [isCreateUpdatePageModalOpen, setIsCreateUpdatePageModalOpen] = useState(false);
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, issueId, inboxId } = router.query; const { workspaceSlug, projectId, issueId, inboxId, cycleId, moduleId } = router.query;
const { user } = useUser(); const { user } = useUser();
@ -183,6 +183,13 @@ export const CommandPalette: React.FC = observer(() => {
isOpen={isIssueModalOpen} isOpen={isIssueModalOpen}
handleClose={() => setIsIssueModalOpen(false)} handleClose={() => setIsIssueModalOpen(false)}
fieldsToShow={inboxId ? ["name", "description", "priority"] : ["all"]} fieldsToShow={inboxId ? ["name", "description", "priority"] : ["all"]}
prePopulateData={
cycleId
? { cycle: cycleId.toString() }
: moduleId
? { module: moduleId.toString() }
: undefined
}
/> />
<BulkDeleteIssuesModal <BulkDeleteIssuesModal
isOpen={isBulkDeleteIssuesModalOpen} isOpen={isBulkDeleteIssuesModalOpen}

View File

@ -2,8 +2,9 @@ import { useRouter } from "next/router";
// icons // icons
import { Icon, Tooltip } from "components/ui"; import { Icon, Tooltip } from "components/ui";
import { CopyPlus } from "lucide-react";
import { Squares2X2Icon } from "@heroicons/react/24/outline"; import { Squares2X2Icon } from "@heroicons/react/24/outline";
import { BlockedIcon, BlockerIcon } from "components/icons"; import { BlockedIcon, BlockerIcon, RelatedIcon } from "components/icons";
// helpers // helpers
import { renderShortDateWithYearFormat } from "helpers/date-time.helper"; import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
import { capitalizeFirstLetter } from "helpers/string.helper"; import { capitalizeFirstLetter } from "helpers/string.helper";
@ -90,14 +91,14 @@ const activityDetails: {
</> </>
); );
}, },
icon: <Icon iconName="group" className="!text-sm" aria-hidden="true" />, icon: <Icon iconName="group" className="!text-2xl" aria-hidden="true" />,
}, },
archived_at: { archived_at: {
message: (activity) => { message: (activity) => {
if (activity.new_value === "restore") return "restored the issue."; if (activity.new_value === "restore") return "restored the issue.";
else return "archived the issue."; else return "archived the issue.";
}, },
icon: <Icon iconName="archive" className="!text-sm" aria-hidden="true" />, icon: <Icon iconName="archive" className="!text-2xl" aria-hidden="true" />,
}, },
attachment: { attachment: {
message: (activity, showIssue) => { message: (activity, showIssue) => {
@ -136,7 +137,7 @@ const activityDetails: {
</> </>
); );
}, },
icon: <Icon iconName="attach_file" className="!text-sm" aria-hidden="true" />, icon: <Icon iconName="attach_file" className="!text-2xl" aria-hidden="true" />,
}, },
blocking: { blocking: {
message: (activity) => { message: (activity) => {
@ -157,7 +158,7 @@ const activityDetails: {
}, },
icon: <BlockerIcon height="12" width="12" color="#6b7280" />, icon: <BlockerIcon height="12" width="12" color="#6b7280" />,
}, },
blocks: { blocked_by: {
message: (activity) => { message: (activity) => {
if (activity.old_value === "") if (activity.old_value === "")
return ( return (
@ -176,6 +177,44 @@ const activityDetails: {
}, },
icon: <BlockedIcon height="12" width="12" color="#6b7280" />, icon: <BlockedIcon height="12" width="12" color="#6b7280" />,
}, },
duplicate: {
message: (activity) => {
if (activity.old_value === "")
return (
<>
marked this issue as duplicate of{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
</>
);
else
return (
<>
removed this issue as a duplicate of{" "}
<span className="font-medium text-custom-text-100">{activity.old_value}</span>.
</>
);
},
icon: <CopyPlus size={12} color="#6b7280" />,
},
relates_to: {
message: (activity) => {
if (activity.old_value === "")
return (
<>
marked that this issue relates to{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
</>
);
else
return (
<>
removed the relation from{" "}
<span className="font-medium text-custom-text-100">{activity.old_value}</span>.
</>
);
},
icon: <RelatedIcon height="12" width="12" color="#6b7280" />,
},
cycles: { cycles: {
message: (activity, showIssue, workspaceSlug) => { message: (activity, showIssue, workspaceSlug) => {
if (activity.verb === "created") if (activity.verb === "created")
@ -224,7 +263,7 @@ const activityDetails: {
</> </>
); );
}, },
icon: <Icon iconName="contrast" className="!text-sm" aria-hidden="true" />, icon: <Icon iconName="contrast" className="!text-2xl" aria-hidden="true" />,
}, },
description: { description: {
message: (activity, showIssue) => ( message: (activity, showIssue) => (
@ -239,7 +278,7 @@ const activityDetails: {
. .
</> </>
), ),
icon: <Icon iconName="chat" className="!text-sm" aria-hidden="true" />, icon: <Icon iconName="chat" className="!text-2xl" aria-hidden="true" />,
}, },
estimate_point: { estimate_point: {
message: (activity, showIssue) => { message: (activity, showIssue) => {
@ -271,14 +310,14 @@ const activityDetails: {
</> </>
); );
}, },
icon: <Icon iconName="change_history" className="!text-sm" aria-hidden="true" />, icon: <Icon iconName="change_history" className="!text-2xl" aria-hidden="true" />,
}, },
issue: { issue: {
message: (activity) => { message: (activity) => {
if (activity.verb === "created") return "created the issue."; if (activity.verb === "created") return "created the issue.";
else return "deleted an issue."; else return "deleted an issue.";
}, },
icon: <Icon iconName="stack" className="!text-sm" aria-hidden="true" />, icon: <Icon iconName="stack" className="!text-2xl" aria-hidden="true" />,
}, },
labels: { labels: {
message: (activity, showIssue) => { message: (activity, showIssue) => {
@ -327,7 +366,7 @@ const activityDetails: {
</> </>
); );
}, },
icon: <Icon iconName="sell" className="!text-sm" aria-hidden="true" />, icon: <Icon iconName="sell" className="!text-2xl" aria-hidden="true" />,
}, },
link: { link: {
message: (activity, showIssue) => { message: (activity, showIssue) => {
@ -398,7 +437,7 @@ const activityDetails: {
</> </>
); );
}, },
icon: <Icon iconName="link" className="!text-sm" aria-hidden="true" />, icon: <Icon iconName="link" className="!text-2xl" aria-hidden="true" />,
}, },
modules: { modules: {
message: (activity, showIssue, workspaceSlug) => { message: (activity, showIssue, workspaceSlug) => {
@ -448,7 +487,7 @@ const activityDetails: {
</> </>
); );
}, },
icon: <Icon iconName="dataset" className="!text-sm" aria-hidden="true" />, icon: <Icon iconName="dataset" className="!text-2xl" aria-hidden="true" />,
}, },
name: { name: {
message: (activity, showIssue) => ( message: (activity, showIssue) => (
@ -463,7 +502,7 @@ const activityDetails: {
. .
</> </>
), ),
icon: <Icon iconName="chat" className="!text-sm" aria-hidden="true" />, icon: <Icon iconName="chat" className="!text-2xl" aria-hidden="true" />,
}, },
parent: { parent: {
message: (activity, showIssue) => { message: (activity, showIssue) => {
@ -496,7 +535,7 @@ const activityDetails: {
</> </>
); );
}, },
icon: <Icon iconName="supervised_user_circle" className="!text-sm" aria-hidden="true" />, icon: <Icon iconName="supervised_user_circle" className="!text-2xl" aria-hidden="true" />,
}, },
priority: { priority: {
message: (activity, showIssue) => ( message: (activity, showIssue) => (
@ -514,7 +553,7 @@ const activityDetails: {
. .
</> </>
), ),
icon: <Icon iconName="signal_cellular_alt" className="!text-sm" aria-hidden="true" />, icon: <Icon iconName="signal_cellular_alt" className="!text-2xl" aria-hidden="true" />,
}, },
start_date: { start_date: {
message: (activity, showIssue) => { message: (activity, showIssue) => {
@ -548,7 +587,7 @@ const activityDetails: {
</> </>
); );
}, },
icon: <Icon iconName="calendar_today" className="!text-sm" aria-hidden="true" />, icon: <Icon iconName="calendar_today" className="!text-2xl" aria-hidden="true" />,
}, },
state: { state: {
message: (activity, showIssue) => ( message: (activity, showIssue) => (
@ -564,7 +603,7 @@ const activityDetails: {
. .
</> </>
), ),
icon: <Squares2X2Icon className="h-3 w-3" aria-hidden="true" />, icon: <Squares2X2Icon className="h-6 w-6 text-custom-sidebar-200" aria-hidden="true" />,
}, },
target_date: { target_date: {
message: (activity, showIssue) => { message: (activity, showIssue) => {
@ -598,7 +637,7 @@ const activityDetails: {
</> </>
); );
}, },
icon: <Icon iconName="calendar_today" className="!text-sm" aria-hidden="true" />, icon: <Icon iconName="calendar_today" className="!text-2xl" aria-hidden="true" />,
}, },
}; };

View File

@ -52,10 +52,22 @@ const issueViewOptions: { type: TIssueViewOptions; Icon: any }[] = [
}, },
]; ];
const issueViewForDraftIssues: { type: TIssueViewOptions; Icon: any }[] = [
{
type: "list",
Icon: FormatListBulletedOutlined,
},
{
type: "kanban",
Icon: GridViewOutlined,
},
];
export const IssuesFilterView: React.FC = () => { export const IssuesFilterView: React.FC = () => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, viewId } = router.query; const { workspaceSlug, projectId, viewId } = router.query;
const isArchivedIssues = router.pathname.includes("archived-issues"); const isArchivedIssues = router.pathname.includes("archived-issues");
const isDraftIssues = router.pathname.includes("draft-issues");
const { const {
displayFilters, displayFilters,
@ -75,7 +87,7 @@ export const IssuesFilterView: React.FC = () => {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{!isArchivedIssues && ( {!isArchivedIssues && !isDraftIssues && (
<div className="flex items-center gap-x-1"> <div className="flex items-center gap-x-1">
{issueViewOptions.map((option) => ( {issueViewOptions.map((option) => (
<Tooltip <Tooltip
@ -105,6 +117,36 @@ export const IssuesFilterView: React.FC = () => {
))} ))}
</div> </div>
)} )}
{isDraftIssues && (
<div className="flex items-center gap-x-1">
{issueViewForDraftIssues.map((option) => (
<Tooltip
key={option.type}
tooltipContent={
<span className="capitalize">{replaceUnderscoreIfSnakeCase(option.type)} View</span>
}
position="bottom"
>
<button
type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-80 duration-300 ${
displayFilters.layout === option.type
? "bg-custom-sidebar-background-80"
: "text-custom-sidebar-text-200"
}`}
onClick={() => setDisplayFilters({ layout: option.type })}
>
<option.Icon
sx={{
fontSize: 16,
}}
className={option.type === "gantt_chart" ? "rotate-90" : ""}
/>
</button>
</Tooltip>
))}
</div>
)}
<SelectFilters <SelectFilters
filters={filters} filters={filters}
onSelect={(option) => { onSelect={(option) => {

View File

@ -1,6 +1,5 @@
import React, { useCallback, useState } from "react"; import React, { useCallback, useState } from "react";
import NextImage from "next/image";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// react-dropzone // react-dropzone
@ -12,7 +11,7 @@ import fileServices from "services/file.service";
// hooks // hooks
import useWorkspaceDetails from "hooks/use-workspace-details"; import useWorkspaceDetails from "hooks/use-workspace-details";
// ui // ui
import { PrimaryButton, SecondaryButton } from "components/ui"; import { DangerButton, PrimaryButton, SecondaryButton } from "components/ui";
// icons // icons
import { UserCircleIcon } from "components/icons"; import { UserCircleIcon } from "components/icons";
@ -21,6 +20,8 @@ type Props = {
onClose: () => void; onClose: () => void;
isOpen: boolean; isOpen: boolean;
onSuccess: (url: string) => void; onSuccess: (url: string) => void;
isRemoving: boolean;
handleDelete: () => void;
userImage?: boolean; userImage?: boolean;
}; };
@ -29,6 +30,8 @@ export const ImageUploadModal: React.FC<Props> = ({
onSuccess, onSuccess,
isOpen, isOpen,
onClose, onClose,
isRemoving,
handleDelete,
userImage, userImage,
}) => { }) => {
const [image, setImage] = useState<File | null>(null); const [image, setImage] = useState<File | null>(null);
@ -148,12 +151,10 @@ export const ImageUploadModal: React.FC<Props> = ({
> >
Edit Edit
</button> </button>
<NextImage <img
layout="fill"
objectFit="cover"
src={image ? URL.createObjectURL(image) : value ? value : ""} src={image ? URL.createObjectURL(image) : value ? value : ""}
alt="image" alt="image"
className="rounded-lg" className="absolute top-0 left-0 h-full w-full object-cover rounded-md"
/> />
</> </>
) : ( ) : (
@ -182,15 +183,22 @@ export const ImageUploadModal: React.FC<Props> = ({
<p className="my-4 text-custom-text-200 text-sm"> <p className="my-4 text-custom-text-200 text-sm">
File formats supported- .jpeg, .jpg, .png, .webp, .svg File formats supported- .jpeg, .jpg, .png, .webp, .svg
</p> </p>
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-between">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton> <div className="flex items-center">
<PrimaryButton <DangerButton onClick={handleDelete} outline disabled={!value}>
onClick={handleSubmit} {isRemoving ? "Removing..." : "Remove"}
disabled={!image} </DangerButton>
loading={isImageUploading} </div>
> <div className="flex items-center gap-2">
{isImageUploading ? "Uploading..." : "Upload & Save"} <SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
</PrimaryButton> <PrimaryButton
onClick={handleSubmit}
disabled={!image}
loading={isImageUploading}
>
{isImageUploading ? "Uploading..." : "Upload & Save"}
</PrimaryButton>
</div>
</div> </div>
</Dialog.Panel> </Dialog.Panel>
</Transition.Child> </Transition.Child>

View File

@ -50,6 +50,7 @@ type Props = {
secondaryButton?: React.ReactNode; secondaryButton?: React.ReactNode;
}; };
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
handleDraftIssueAction?: (issue: IIssue, action: "edit" | "delete") => void;
handleOnDragEnd: (result: DropResult) => Promise<void>; handleOnDragEnd: (result: DropResult) => Promise<void>;
openIssuesListModal: (() => void) | null; openIssuesListModal: (() => void) | null;
removeIssue: ((bridgeId: string, issueId: string) => void) | null; removeIssue: ((bridgeId: string, issueId: string) => void) | null;
@ -66,6 +67,7 @@ export const AllViews: React.FC<Props> = ({
dragDisabled = false, dragDisabled = false,
emptyState, emptyState,
handleIssueAction, handleIssueAction,
handleDraftIssueAction,
handleOnDragEnd, handleOnDragEnd,
openIssuesListModal, openIssuesListModal,
removeIssue, removeIssue,
@ -132,6 +134,7 @@ export const AllViews: React.FC<Props> = ({
states={states} states={states}
addIssueToGroup={addIssueToGroup} addIssueToGroup={addIssueToGroup}
handleIssueAction={handleIssueAction} handleIssueAction={handleIssueAction}
handleDraftIssueAction={handleDraftIssueAction}
openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null} openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null}
removeIssue={removeIssue} removeIssue={removeIssue}
myIssueProjectId={myIssueProjectId} myIssueProjectId={myIssueProjectId}
@ -149,6 +152,7 @@ export const AllViews: React.FC<Props> = ({
disableAddIssueOption={disableAddIssueOption} disableAddIssueOption={disableAddIssueOption}
dragDisabled={dragDisabled} dragDisabled={dragDisabled}
handleIssueAction={handleIssueAction} handleIssueAction={handleIssueAction}
handleDraftIssueAction={handleDraftIssueAction}
handleTrashBox={handleTrashBox} handleTrashBox={handleTrashBox}
openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null} openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null}
myIssueProjectId={myIssueProjectId} myIssueProjectId={myIssueProjectId}

View File

@ -20,6 +20,7 @@ type Props = {
disableAddIssueOption?: boolean; disableAddIssueOption?: boolean;
dragDisabled: boolean; dragDisabled: boolean;
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
handleDraftIssueAction?: (issue: IIssue, action: "edit" | "delete") => void;
handleTrashBox: (isDragging: boolean) => void; handleTrashBox: (isDragging: boolean) => void;
openIssuesListModal?: (() => void) | null; openIssuesListModal?: (() => void) | null;
removeIssue: ((bridgeId: string, issueId: string) => void) | null; removeIssue: ((bridgeId: string, issueId: string) => void) | null;
@ -37,6 +38,7 @@ export const AllBoards: React.FC<Props> = ({
disableAddIssueOption = false, disableAddIssueOption = false,
dragDisabled, dragDisabled,
handleIssueAction, handleIssueAction,
handleDraftIssueAction,
handleTrashBox, handleTrashBox,
openIssuesListModal, openIssuesListModal,
myIssueProjectId, myIssueProjectId,
@ -94,6 +96,7 @@ export const AllBoards: React.FC<Props> = ({
dragDisabled={dragDisabled} dragDisabled={dragDisabled}
groupTitle={singleGroup} groupTitle={singleGroup}
handleIssueAction={handleIssueAction} handleIssueAction={handleIssueAction}
handleDraftIssueAction={handleDraftIssueAction}
handleTrashBox={handleTrashBox} handleTrashBox={handleTrashBox}
openIssuesListModal={openIssuesListModal ?? null} openIssuesListModal={openIssuesListModal ?? null}
handleMyIssueOpen={handleMyIssueOpen} handleMyIssueOpen={handleMyIssueOpen}

View File

@ -24,6 +24,7 @@ type Props = {
dragDisabled: boolean; dragDisabled: boolean;
groupTitle: string; groupTitle: string;
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
handleDraftIssueAction?: (issue: IIssue, action: "edit" | "delete") => void;
handleTrashBox: (isDragging: boolean) => void; handleTrashBox: (isDragging: boolean) => void;
openIssuesListModal?: (() => void) | null; openIssuesListModal?: (() => void) | null;
handleMyIssueOpen?: (issue: IIssue) => void; handleMyIssueOpen?: (issue: IIssue) => void;
@ -41,6 +42,7 @@ export const SingleBoard: React.FC<Props> = ({
disableAddIssueOption = false, disableAddIssueOption = false,
dragDisabled, dragDisabled,
handleIssueAction, handleIssueAction,
handleDraftIssueAction,
handleTrashBox, handleTrashBox,
openIssuesListModal, openIssuesListModal,
handleMyIssueOpen, handleMyIssueOpen,
@ -136,6 +138,16 @@ export const SingleBoard: React.FC<Props> = ({
editIssue={() => handleIssueAction(issue, "edit")} editIssue={() => handleIssueAction(issue, "edit")}
makeIssueCopy={() => handleIssueAction(issue, "copy")} makeIssueCopy={() => handleIssueAction(issue, "copy")}
handleDeleteIssue={() => handleIssueAction(issue, "delete")} handleDeleteIssue={() => handleIssueAction(issue, "delete")}
handleDraftIssueEdit={
handleDraftIssueAction
? () => handleDraftIssueAction(issue, "edit")
: undefined
}
handleDraftIssueDelete={() =>
handleDraftIssueAction
? handleDraftIssueAction(issue, "delete")
: undefined
}
handleTrashBox={handleTrashBox} handleTrashBox={handleTrashBox}
handleMyIssueOpen={handleMyIssueOpen} handleMyIssueOpen={handleMyIssueOpen}
removeIssue={() => { removeIssue={() => {
@ -155,7 +167,7 @@ export const SingleBoard: React.FC<Props> = ({
display: displayFilters?.order_by === "sort_order" ? "inline" : "none", display: displayFilters?.order_by === "sort_order" ? "inline" : "none",
}} }}
> >
{provided.placeholder} <>{provided.placeholder}</>
</span> </span>
</div> </div>
{displayFilters?.group_by !== "created_by" && ( {displayFilters?.group_by !== "created_by" && (

View File

@ -60,6 +60,8 @@ type Props = {
handleMyIssueOpen?: (issue: IIssue) => void; handleMyIssueOpen?: (issue: IIssue) => void;
removeIssue?: (() => void) | null; removeIssue?: (() => void) | null;
handleDeleteIssue: (issue: IIssue) => void; handleDeleteIssue: (issue: IIssue) => void;
handleDraftIssueEdit?: () => void;
handleDraftIssueDelete?: () => void;
handleTrashBox: (isDragging: boolean) => void; handleTrashBox: (isDragging: boolean) => void;
disableUserActions: boolean; disableUserActions: boolean;
user: ICurrentUserResponse | undefined; user: ICurrentUserResponse | undefined;
@ -79,6 +81,8 @@ export const SingleBoardIssue: React.FC<Props> = ({
removeIssue, removeIssue,
groupTitle, groupTitle,
handleDeleteIssue, handleDeleteIssue,
handleDraftIssueEdit,
handleDraftIssueDelete,
handleTrashBox, handleTrashBox,
disableUserActions, disableUserActions,
user, user,
@ -99,6 +103,8 @@ export const SingleBoardIssue: React.FC<Props> = ({
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query; const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const isDraftIssue = router.pathname.includes("draft-issues");
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const partialUpdateIssue = useCallback( const partialUpdateIssue = useCallback(
@ -211,29 +217,47 @@ export const SingleBoardIssue: React.FC<Props> = ({
> >
{!isNotAllowed && ( {!isNotAllowed && (
<> <>
<ContextMenu.Item Icon={PencilIcon} onClick={editIssue}> <ContextMenu.Item
Icon={PencilIcon}
onClick={() => {
if (isDraftIssue && handleDraftIssueEdit) handleDraftIssueEdit();
else editIssue();
}}
>
Edit issue Edit issue
</ContextMenu.Item> </ContextMenu.Item>
<ContextMenu.Item Icon={ClipboardDocumentCheckIcon} onClick={makeIssueCopy}> {!isDraftIssue && (
Make a copy... <ContextMenu.Item Icon={ClipboardDocumentCheckIcon} onClick={makeIssueCopy}>
</ContextMenu.Item> Make a copy...
<ContextMenu.Item Icon={TrashIcon} onClick={() => handleDeleteIssue(issue)}> </ContextMenu.Item>
)}
<ContextMenu.Item
Icon={TrashIcon}
onClick={() => {
if (isDraftIssue && handleDraftIssueDelete) handleDraftIssueDelete();
else handleDeleteIssue(issue);
}}
>
Delete issue Delete issue
</ContextMenu.Item> </ContextMenu.Item>
</> </>
)} )}
<ContextMenu.Item Icon={LinkIcon} onClick={handleCopyText}> {!isDraftIssue && (
Copy issue link <ContextMenu.Item Icon={LinkIcon} onClick={handleCopyText}>
</ContextMenu.Item> Copy issue link
<a
href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}
target="_blank"
rel="noreferrer noopener"
>
<ContextMenu.Item Icon={ArrowTopRightOnSquareIcon}>
Open issue in new tab
</ContextMenu.Item> </ContextMenu.Item>
</a> )}
{!isDraftIssue && (
<a
href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}
target="_blank"
rel="noreferrer noopener"
>
<ContextMenu.Item Icon={ArrowTopRightOnSquareIcon}>
Open issue in new tab
</ContextMenu.Item>
</a>
)}
</ContextMenu> </ContextMenu>
<div <div
className={`mb-3 rounded bg-custom-background-100 shadow ${ className={`mb-3 rounded bg-custom-background-100 shadow ${
@ -268,13 +292,18 @@ export const SingleBoardIssue: React.FC<Props> = ({
</button> </button>
} }
> >
<CustomMenu.MenuItem onClick={editIssue}> <CustomMenu.MenuItem
onClick={() => {
if (isDraftIssue && handleDraftIssueEdit) handleDraftIssueEdit();
else editIssue();
}}
>
<div className="flex items-center justify-start gap-2"> <div className="flex items-center justify-start gap-2">
<PencilIcon className="h-4 w-4" /> <PencilIcon className="h-4 w-4" />
<span>Edit issue</span> <span>Edit issue</span>
</div> </div>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
{type !== "issue" && removeIssue && ( {type !== "issue" && removeIssue && !isDraftIssue && (
<CustomMenu.MenuItem onClick={removeIssue}> <CustomMenu.MenuItem onClick={removeIssue}>
<div className="flex items-center justify-start gap-2"> <div className="flex items-center justify-start gap-2">
<XMarkIcon className="h-4 w-4" /> <XMarkIcon className="h-4 w-4" />
@ -282,18 +311,25 @@ export const SingleBoardIssue: React.FC<Props> = ({
</div> </div>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
)} )}
<CustomMenu.MenuItem onClick={() => handleDeleteIssue(issue)}> <CustomMenu.MenuItem
onClick={() => {
if (isDraftIssue && handleDraftIssueDelete) handleDraftIssueDelete();
else handleDeleteIssue(issue);
}}
>
<div className="flex items-center justify-start gap-2"> <div className="flex items-center justify-start gap-2">
<TrashIcon className="h-4 w-4" /> <TrashIcon className="h-4 w-4" />
<span>Delete issue</span> <span>Delete issue</span>
</div> </div>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyText}> {!isDraftIssue && (
<div className="flex items-center justify-start gap-2"> <CustomMenu.MenuItem onClick={handleCopyText}>
<LinkIcon className="h-4 w-4" /> <div className="flex items-center justify-start gap-2">
<span>Copy issue Link</span> <LinkIcon className="h-4 w-4" />
</div> <span>Copy issue Link</span>
</CustomMenu.MenuItem> </div>
</CustomMenu.MenuItem>
)}
</CustomMenu> </CustomMenu>
)} )}
</div> </div>
@ -308,7 +344,10 @@ export const SingleBoardIssue: React.FC<Props> = ({
<button <button
type="button" type="button"
className="text-sm text-left break-words line-clamp-2" className="text-sm text-left break-words line-clamp-2"
onClick={openPeekOverview} onClick={() => {
if (isDraftIssue && handleDraftIssueEdit) handleDraftIssueEdit();
else openPeekOverview();
}}
> >
{issue.name} {issue.name}
</button> </button>

View File

@ -19,7 +19,12 @@ import useIssuesProperties from "hooks/use-issue-properties";
import useProjectMembers from "hooks/use-project-members"; import useProjectMembers from "hooks/use-project-members";
// components // components
import { FiltersList, AllViews } from "components/core"; import { FiltersList, AllViews } from "components/core";
import { CreateUpdateIssueModal, DeleteIssueModal, IssuePeekOverview } from "components/issues"; import {
CreateUpdateIssueModal,
DeleteIssueModal,
DeleteDraftIssueModal,
CreateUpdateDraftIssueModal,
} from "components/issues";
import { CreateUpdateViewModal } from "components/views"; import { CreateUpdateViewModal } from "components/views";
// ui // ui
import { PrimaryButton, SecondaryButton } from "components/ui"; import { PrimaryButton, SecondaryButton } from "components/ui";
@ -70,8 +75,13 @@ export const IssuesView: React.FC<Props> = ({
// trash box // trash box
const [trashBox, setTrashBox] = useState(false); const [trashBox, setTrashBox] = useState(false);
// selected draft issue
const [selectedDraftIssue, setSelectedDraftIssue] = useState<IIssue | null>(null);
const [selectedDraftForDelete, setSelectDraftForDelete] = useState<IIssue | null>(null);
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
const isDraftIssues = router.asPath.includes("draft-issues");
const { user } = useUserAuth(); const { user } = useUserAuth();
@ -106,6 +116,9 @@ export const IssuesView: React.FC<Props> = ({
[setDeleteIssueModal, setIssueToDelete] [setDeleteIssueModal, setIssueToDelete]
); );
const handleDraftIssueClick = useCallback((issue: any) => setSelectedDraftIssue(issue), []);
const handleDraftIssueDelete = useCallback((issue: any) => setSelectDraftForDelete(issue), []);
const handleOnDragEnd = useCallback( const handleOnDragEnd = useCallback(
async (result: DropResult) => { async (result: DropResult) => {
setTrashBox(false); setTrashBox(false);
@ -343,6 +356,14 @@ export const IssuesView: React.FC<Props> = ({
[makeIssueCopy, handleEditIssue, handleDeleteIssue] [makeIssueCopy, handleEditIssue, handleDeleteIssue]
); );
const handleDraftIssueAction = useCallback(
(issue: IIssue, action: "edit" | "delete") => {
if (action === "edit") handleDraftIssueClick(issue);
else if (action === "delete") handleDraftIssueDelete(issue);
},
[handleDraftIssueClick, handleDraftIssueDelete]
);
const removeIssueFromCycle = useCallback( const removeIssueFromCycle = useCallback(
(bridgeId: string, issueId: string) => { (bridgeId: string, issueId: string) => {
if (!workspaceSlug || !projectId || !cycleId) return; if (!workspaceSlug || !projectId || !cycleId) return;
@ -451,6 +472,19 @@ export const IssuesView: React.FC<Props> = ({
...preloadedData, ...preloadedData,
}} }}
/> />
<CreateUpdateDraftIssueModal
isOpen={selectedDraftIssue !== null}
handleClose={() => setSelectedDraftIssue(null)}
data={
selectedDraftIssue
? {
...selectedDraftIssue,
is_draft: true,
}
: null
}
fieldsToShow={["all"]}
/>
<CreateUpdateIssueModal <CreateUpdateIssueModal
isOpen={editIssueModal && issueToEdit?.actionType !== "delete"} isOpen={editIssueModal && issueToEdit?.actionType !== "delete"}
handleClose={() => setEditIssueModal(false)} handleClose={() => setEditIssueModal(false)}
@ -462,6 +496,11 @@ export const IssuesView: React.FC<Props> = ({
data={issueToDelete} data={issueToDelete}
user={user} user={user}
/> />
<DeleteDraftIssueModal
data={selectedDraftForDelete}
isOpen={selectedDraftForDelete !== null}
handleClose={() => setSelectDraftForDelete(null)}
/>
{areFiltersApplied && ( {areFiltersApplied && (
<> <>
@ -518,23 +557,28 @@ export const IssuesView: React.FC<Props> = ({
displayFilters.group_by === "assignees" displayFilters.group_by === "assignees"
} }
emptyState={{ emptyState={{
title: cycleId title: isDraftIssues
? "Draft issues will appear here"
: cycleId
? "Cycle issues will appear here" ? "Cycle issues will appear here"
: moduleId : moduleId
? "Module issues will appear here" ? "Module issues will appear here"
: "Project issues will appear here", : "Project issues will appear here",
description: description: isDraftIssues
"Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done.", ? "Draft issues are issues that are not yet created."
primaryButton: { : "Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done.",
icon: <PlusIcon className="h-4 w-4" />, primaryButton: !isDraftIssues
text: "New Issue", ? {
onClick: () => { icon: <PlusIcon className="h-4 w-4" />,
const e = new KeyboardEvent("keydown", { text: "New Issue",
key: "c", onClick: () => {
}); const e = new KeyboardEvent("keydown", {
document.dispatchEvent(e); key: "c",
}, });
}, document.dispatchEvent(e);
},
}
: undefined,
secondaryButton: secondaryButton:
cycleId || moduleId ? ( cycleId || moduleId ? (
<SecondaryButton <SecondaryButton
@ -548,6 +592,7 @@ export const IssuesView: React.FC<Props> = ({
}} }}
handleOnDragEnd={handleOnDragEnd} handleOnDragEnd={handleOnDragEnd}
handleIssueAction={handleIssueAction} handleIssueAction={handleIssueAction}
handleDraftIssueAction={handleDraftIssueAction}
openIssuesListModal={openIssuesListModal ?? null} openIssuesListModal={openIssuesListModal ?? null}
removeIssue={cycleId ? removeIssueFromCycle : moduleId ? removeIssueFromModule : null} removeIssue={cycleId ? removeIssueFromCycle : moduleId ? removeIssueFromModule : null}
trashBox={trashBox} trashBox={trashBox}

View File

@ -15,6 +15,7 @@ type Props = {
states: IState[] | undefined; states: IState[] | undefined;
addIssueToGroup: (groupTitle: string) => void; addIssueToGroup: (groupTitle: string) => void;
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
handleDraftIssueAction?: (issue: IIssue, action: "edit" | "delete") => void;
openIssuesListModal?: (() => void) | null; openIssuesListModal?: (() => void) | null;
myIssueProjectId?: string | null; myIssueProjectId?: string | null;
handleMyIssueOpen?: (issue: IIssue) => void; handleMyIssueOpen?: (issue: IIssue) => void;
@ -36,6 +37,7 @@ export const AllLists: React.FC<Props> = ({
myIssueProjectId, myIssueProjectId,
removeIssue, removeIssue,
states, states,
handleDraftIssueAction,
user, user,
userAuth, userAuth,
viewProps, viewProps,
@ -82,6 +84,7 @@ export const AllLists: React.FC<Props> = ({
groupTitle={singleGroup} groupTitle={singleGroup}
currentState={currentState} currentState={currentState}
addIssueToGroup={() => addIssueToGroup(singleGroup)} addIssueToGroup={() => addIssueToGroup(singleGroup)}
handleDraftIssueAction={handleDraftIssueAction}
handleIssueAction={handleIssueAction} handleIssueAction={handleIssueAction}
handleMyIssueOpen={handleMyIssueOpen} handleMyIssueOpen={handleMyIssueOpen}
openIssuesListModal={openIssuesListModal} openIssuesListModal={openIssuesListModal}

View File

@ -1,6 +1,5 @@
import React, { useCallback, useState } from "react"; import React, { useCallback, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { mutate } from "swr"; import { mutate } from "swr";
@ -18,6 +17,7 @@ import {
ViewPrioritySelect, ViewPrioritySelect,
ViewStartDateSelect, ViewStartDateSelect,
ViewStateSelect, ViewStateSelect,
CreateUpdateDraftIssueModal,
} from "components/issues"; } from "components/issues";
// ui // ui
import { Tooltip, CustomMenu, ContextMenu } from "components/ui"; import { Tooltip, CustomMenu, ContextMenu } from "components/ui";
@ -61,6 +61,8 @@ type Props = {
makeIssueCopy: () => void; makeIssueCopy: () => void;
removeIssue?: (() => void) | null; removeIssue?: (() => void) | null;
handleDeleteIssue: (issue: IIssue) => void; handleDeleteIssue: (issue: IIssue) => void;
handleDraftIssueSelect?: (issue: IIssue) => void;
handleDraftIssueDelete?: (issue: IIssue) => void;
handleMyIssueOpen?: (issue: IIssue) => void; handleMyIssueOpen?: (issue: IIssue) => void;
disableUserActions: boolean; disableUserActions: boolean;
user: ICurrentUserResponse | undefined; user: ICurrentUserResponse | undefined;
@ -76,12 +78,14 @@ export const SingleListIssue: React.FC<Props> = ({
makeIssueCopy, makeIssueCopy,
removeIssue, removeIssue,
groupTitle, groupTitle,
handleDraftIssueDelete,
handleDeleteIssue, handleDeleteIssue,
handleMyIssueOpen, handleMyIssueOpen,
disableUserActions, disableUserActions,
user, user,
userAuth, userAuth,
viewProps, viewProps,
handleDraftIssueSelect,
}) => { }) => {
// context menu // context menu
const [contextMenu, setContextMenu] = useState(false); const [contextMenu, setContextMenu] = useState(false);
@ -90,6 +94,7 @@ export const SingleListIssue: React.FC<Props> = ({
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId, userId } = router.query; const { workspaceSlug, projectId, cycleId, moduleId, userId } = router.query;
const isArchivedIssues = router.pathname.includes("archived-issues"); const isArchivedIssues = router.pathname.includes("archived-issues");
const isDraftIssues = router.pathname?.split("/")?.[4] === "draft-issues";
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
@ -178,6 +183,8 @@ export const SingleListIssue: React.FC<Props> = ({
const issuePath = isArchivedIssues const issuePath = isArchivedIssues
? `/${workspaceSlug}/projects/${issue.project}/archived-issues/${issue.id}` ? `/${workspaceSlug}/projects/${issue.project}/archived-issues/${issue.id}`
: isDraftIssues
? `#`
: `/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`; : `/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`;
const openPeekOverview = (issue: IIssue) => { const openPeekOverview = (issue: IIssue) => {
@ -203,26 +210,45 @@ export const SingleListIssue: React.FC<Props> = ({
> >
{!isNotAllowed && ( {!isNotAllowed && (
<> <>
<ContextMenu.Item Icon={PencilIcon} onClick={editIssue}> <ContextMenu.Item
Icon={PencilIcon}
onClick={() => {
if (isDraftIssues && handleDraftIssueSelect) handleDraftIssueSelect(issue);
else editIssue();
}}
>
Edit issue Edit issue
</ContextMenu.Item> </ContextMenu.Item>
<ContextMenu.Item Icon={ClipboardDocumentCheckIcon} onClick={makeIssueCopy}> {!isDraftIssues && (
Make a copy... <ContextMenu.Item Icon={ClipboardDocumentCheckIcon} onClick={makeIssueCopy}>
</ContextMenu.Item> Make a copy...
<ContextMenu.Item Icon={TrashIcon} onClick={() => handleDeleteIssue(issue)}> </ContextMenu.Item>
)}
<ContextMenu.Item
Icon={TrashIcon}
onClick={() => {
if (isDraftIssues && handleDraftIssueDelete) handleDraftIssueDelete(issue);
else handleDeleteIssue(issue);
}}
>
Delete issue Delete issue
</ContextMenu.Item> </ContextMenu.Item>
</> </>
)} )}
<ContextMenu.Item Icon={LinkIcon} onClick={handleCopyText}> {!isDraftIssues && (
Copy issue link <>
</ContextMenu.Item> <ContextMenu.Item Icon={LinkIcon} onClick={handleCopyText}>
<a href={issuePath} target="_blank" rel="noreferrer noopener"> Copy issue link
<ContextMenu.Item Icon={ArrowTopRightOnSquareIcon}> </ContextMenu.Item>
Open issue in new tab <a href={issuePath} target="_blank" rel="noreferrer noopener">
</ContextMenu.Item> <ContextMenu.Item Icon={ArrowTopRightOnSquareIcon}>
</a> Open issue in new tab
</ContextMenu.Item>
</a>
</>
)}
</ContextMenu> </ContextMenu>
<div <div
className="flex items-center justify-between px-4 py-2.5 gap-10 border-b border-custom-border-200 bg-custom-background-100 last:border-b-0" className="flex items-center justify-between px-4 py-2.5 gap-10 border-b border-custom-border-200 bg-custom-background-100 last:border-b-0"
onContextMenu={(e) => { onContextMenu={(e) => {
@ -247,7 +273,10 @@ export const SingleListIssue: React.FC<Props> = ({
<button <button
type="button" type="button"
className="truncate text-[0.825rem] text-custom-text-100" className="truncate text-[0.825rem] text-custom-text-100"
onClick={() => openPeekOverview(issue)} onClick={() => {
if (!isDraftIssues) openPeekOverview(issue);
if (isDraftIssues && handleDraftIssueSelect) handleDraftIssueSelect(issue);
}}
> >
{issue.name} {issue.name}
</button> </button>
@ -345,7 +374,12 @@ export const SingleListIssue: React.FC<Props> = ({
)} )}
{type && !isNotAllowed && ( {type && !isNotAllowed && (
<CustomMenu width="auto" ellipsis> <CustomMenu width="auto" ellipsis>
<CustomMenu.MenuItem onClick={editIssue}> <CustomMenu.MenuItem
onClick={() => {
if (isDraftIssues && handleDraftIssueSelect) handleDraftIssueSelect(issue);
else editIssue();
}}
>
<div className="flex items-center justify-start gap-2"> <div className="flex items-center justify-start gap-2">
<PencilIcon className="h-4 w-4" /> <PencilIcon className="h-4 w-4" />
<span>Edit issue</span> <span>Edit issue</span>
@ -359,18 +393,25 @@ export const SingleListIssue: React.FC<Props> = ({
</div> </div>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
)} )}
<CustomMenu.MenuItem onClick={() => handleDeleteIssue(issue)}> <CustomMenu.MenuItem
onClick={() => {
if (isDraftIssues && handleDraftIssueDelete) handleDraftIssueDelete(issue);
else handleDeleteIssue(issue);
}}
>
<div className="flex items-center justify-start gap-2"> <div className="flex items-center justify-start gap-2">
<TrashIcon className="h-4 w-4" /> <TrashIcon className="h-4 w-4" />
<span>Delete issue</span> <span>Delete issue</span>
</div> </div>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyText}> {!isDraftIssues && (
<div className="flex items-center justify-start gap-2"> <CustomMenu.MenuItem onClick={handleCopyText}>
<LinkIcon className="h-4 w-4" /> <div className="flex items-center justify-start gap-2">
<span>Copy issue link</span> <LinkIcon className="h-4 w-4" />
</div> <span>Copy issue link</span>
</CustomMenu.MenuItem> </div>
</CustomMenu.MenuItem>
)}
</CustomMenu> </CustomMenu>
)} )}
</div> </div>

View File

@ -40,6 +40,7 @@ type Props = {
groupTitle: string; groupTitle: string;
addIssueToGroup: () => void; addIssueToGroup: () => void;
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
handleDraftIssueAction?: (issue: IIssue, action: "edit" | "delete") => void;
openIssuesListModal?: (() => void) | null; openIssuesListModal?: (() => void) | null;
handleMyIssueOpen?: (issue: IIssue) => void; handleMyIssueOpen?: (issue: IIssue) => void;
removeIssue: ((bridgeId: string, issueId: string) => void) | null; removeIssue: ((bridgeId: string, issueId: string) => void) | null;
@ -56,6 +57,7 @@ export const SingleList: React.FC<Props> = ({
addIssueToGroup, addIssueToGroup,
handleIssueAction, handleIssueAction,
openIssuesListModal, openIssuesListModal,
handleDraftIssueAction,
handleMyIssueOpen, handleMyIssueOpen,
removeIssue, removeIssue,
disableUserActions, disableUserActions,
@ -253,6 +255,16 @@ export const SingleList: React.FC<Props> = ({
editIssue={() => handleIssueAction(issue, "edit")} editIssue={() => handleIssueAction(issue, "edit")}
makeIssueCopy={() => handleIssueAction(issue, "copy")} makeIssueCopy={() => handleIssueAction(issue, "copy")}
handleDeleteIssue={() => handleIssueAction(issue, "delete")} handleDeleteIssue={() => handleIssueAction(issue, "delete")}
handleDraftIssueSelect={
handleDraftIssueAction
? () => handleDraftIssueAction(issue, "edit")
: undefined
}
handleDraftIssueDelete={
handleDraftIssueAction
? () => handleDraftIssueAction(issue, "delete")
: undefined
}
handleMyIssueOpen={handleMyIssueOpen} handleMyIssueOpen={handleMyIssueOpen}
removeIssue={() => { removeIssue={() => {
if (removeIssue !== null && issue.bridge_id) if (removeIssue !== null && issue.bridge_id)

View File

@ -46,32 +46,38 @@ const IntegrationGuide = () => {
return ( return (
<> <>
<div className="h-full space-y-2"> <div className="h-full w-full">
<> <>
<div className="space-y-2"> <div>
{EXPORTERS_LIST.map((service) => ( {EXPORTERS_LIST.map((service) => (
<div <div
key={service.provider} key={service.provider}
className="rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-4" className="flex items-center justify-between gap-2 border-b border-custom-border-200 bg-custom-background-100 px-4 py-6"
> >
<div className="flex items-center gap-4 whitespace-nowrap"> <div className="flex items-start justify-between gap-4 w-full">
<div className="relative h-10 w-10 flex-shrink-0"> <div className="flex item-center gap-2.5">
<Image <div className="relative h-10 w-10 flex-shrink-0">
src={service.logo} <Image
layout="fill" src={service.logo}
objectFit="cover" layout="fill"
alt={`${service.title} Logo`} objectFit="cover"
/> alt={`${service.title} Logo`}
</div> />
<div className="w-full"> </div>
<h3>{service.title}</h3> <div>
<p className="text-sm text-custom-text-200">{service.description}</p> <h3 className="flex items-center gap-4 text-sm font-medium">
{service.title}
</h3>
<p className="text-sm text-custom-text-200 tracking-tight">
{service.description}
</p>
</div>
</div> </div>
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<Link href={`/${workspaceSlug}/settings/exports?provider=${service.provider}`}> <Link href={`/${workspaceSlug}/settings/exports?provider=${service.provider}`}>
<a> <a>
<PrimaryButton> <PrimaryButton>
<span className="capitalize">{service.type}</span> now <span className="capitalize">{service.type}</span>
</PrimaryButton> </PrimaryButton>
</a> </a>
</Link> </Link>
@ -80,10 +86,11 @@ const IntegrationGuide = () => {
</div> </div>
))} ))}
</div> </div>
<div className="rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-4"> <div>
<h3 className="mb-2 flex gap-2 text-lg font-medium justify-between"> <div className="flex items-center justify-between pt-7 pb-3.5 border-b border-custom-border-200">
<div className="flex gap-2"> <div className="flex gap-2 items-center">
<div className="">Previous Exports</div> <h3 className="flex gap-2 text-xl font-medium">Previous Exports</h3>
<button <button
type="button" type="button"
className="flex flex-shrink-0 items-center gap-1 rounded bg-custom-background-80 py-1 px-1.5 text-xs outline-none" className="flex flex-shrink-0 items-center gap-1 rounded bg-custom-background-80 py-1 px-1.5 text-xs outline-none"
@ -128,27 +135,31 @@ const IntegrationGuide = () => {
<Icon iconName="keyboard_arrow_right" className="!text-lg" /> <Icon iconName="keyboard_arrow_right" className="!text-lg" />
</button> </button>
</div> </div>
</h3> </div>
{exporterServices && exporterServices?.results ? ( <div className="flex flex-col">
exporterServices?.results?.length > 0 ? ( {exporterServices && exporterServices?.results ? (
<div className="space-y-2"> exporterServices?.results?.length > 0 ? (
<div className="divide-y divide-custom-border-200"> <div>
{exporterServices?.results.map((service) => ( <div className="divide-y divide-custom-border-200">
<SingleExport key={service.id} service={service} refreshing={refreshing} /> {exporterServices?.results.map((service) => (
))} <SingleExport key={service.id} service={service} refreshing={refreshing} />
))}
</div>
</div> </div>
</div> ) : (
<p className="text-sm text-custom-text-200 px-4 py-6">
No previous export available.
</p>
)
) : ( ) : (
<p className="py-2 text-sm text-custom-text-200">No previous export available.</p> <Loader className="mt-6 grid grid-cols-1 gap-3">
) <Loader.Item height="40px" width="100%" />
) : ( <Loader.Item height="40px" width="100%" />
<Loader className="mt-6 grid grid-cols-1 gap-3"> <Loader.Item height="40px" width="100%" />
<Loader.Item height="40px" width="100%" /> <Loader.Item height="40px" width="100%" />
<Loader.Item height="40px" width="100%" /> </Loader>
<Loader.Item height="40px" width="100%" /> )}
<Loader.Item height="40px" width="100%" /> </div>
</Loader>
)}
</div> </div>
</> </>
{provider && ( {provider && (

View File

@ -23,7 +23,7 @@ export const SingleExport: React.FC<Props> = ({ service, refreshing }) => {
}; };
return ( return (
<div className="flex items-center justify-between gap-2 py-3"> <div className="flex items-center justify-between gap-2 px-4 py-3">
<div> <div>
<h4 className="flex items-center gap-2 text-sm"> <h4 className="flex items-center gap-2 text-sm">
<span> <span>

View File

@ -21,7 +21,6 @@ import {
import { Loader, PrimaryButton } from "components/ui"; import { Loader, PrimaryButton } from "components/ui";
// icons // icons
import { ArrowPathIcon } from "@heroicons/react/24/outline"; import { ArrowPathIcon } from "@heroicons/react/24/outline";
import { ArrowRightIcon } from "components/icons";
// types // types
import { IImporterService } from "types"; import { IImporterService } from "types";
// fetch-keys // fetch-keys
@ -57,10 +56,10 @@ const IntegrationGuide = () => {
data={importToDelete} data={importToDelete}
user={user} user={user}
/> />
<div className="h-full space-y-2"> <div className="h-full">
{(!provider || provider === "csv") && ( {(!provider || provider === "csv") && (
<> <>
<div className="mb-5 flex items-center gap-2"> {/* <div className="mb-5 flex items-center gap-2">
<div className="h-full w-full space-y-1"> <div className="h-full w-full space-y-1">
<div className="text-lg font-medium">Relocation Guide</div> <div className="text-lg font-medium">Relocation Guide</div>
<div className="text-sm"> <div className="text-sm">
@ -78,85 +77,87 @@ const IntegrationGuide = () => {
<ArrowRightIcon width={"18px"} color={"#3F76FF"} /> <ArrowRightIcon width={"18px"} color={"#3F76FF"} />
</div> </div>
</a> </a>
</div> </div> */}
<div className="space-y-2"> {IMPORTERS_EXPORTERS_LIST.map((service) => (
{IMPORTERS_EXPORTERS_LIST.map((service) => ( <div
<div key={service.provider}
key={service.provider} className="flex items-center justify-between gap-2 border-b border-custom-border-200 bg-custom-background-100 px-4 py-6"
className="rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-4" >
> <div className="flex items-start gap-4">
<div className="flex items-center gap-4 whitespace-nowrap"> <div className="relative h-10 w-10 flex-shrink-0">
<div className="relative h-10 w-10 flex-shrink-0"> <Image
<Image src={service.logo}
src={service.logo} layout="fill"
layout="fill" objectFit="cover"
objectFit="cover" alt={`${service.title} Logo`}
alt={`${service.title} Logo`} />
/> </div>
</div> <div>
<div className="w-full"> <h3 className="flex items-center gap-4 text-sm font-medium">{service.title}</h3>
<h3>{service.title}</h3> <p className="text-sm text-custom-text-200 tracking-tight">
<p className="text-sm text-custom-text-200">{service.description}</p> {service.description}
</div> </p>
<div className="flex-shrink-0">
<Link
href={`/${workspaceSlug}/settings/imports?provider=${service.provider}`}
>
<a>
<PrimaryButton>
<span className="capitalize">{service.type}</span> now
</PrimaryButton>
</a>
</Link>
</div>
</div> </div>
</div> </div>
))} <div className="flex-shrink-0">
</div> <Link href={`/${workspaceSlug}/settings/imports?provider=${service.provider}`}>
<div className="rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-4"> <a>
<h3 className="mb-2 flex gap-2 text-lg font-medium"> <PrimaryButton>
Previous Imports <span className="capitalize">{service.type}</span>
<button </PrimaryButton>
type="button" </a>
className="flex flex-shrink-0 items-center gap-1 rounded bg-custom-background-80 py-1 px-1.5 text-xs outline-none" </Link>
onClick={() => { </div>
setRefreshing(true); </div>
mutate(IMPORTER_SERVICES_LIST(workspaceSlug as string)).then(() => ))}
setRefreshing(false) <div>
); <div className="flex items-center pt-7 pb-3.5 border-b border-custom-border-200">
}} <h3 className="flex gap-2 text-xl font-medium">
> Previous Imports
<ArrowPathIcon className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`} />{" "} <button
{refreshing ? "Refreshing..." : "Refresh status"} type="button"
</button> className="flex flex-shrink-0 items-center gap-1 rounded bg-custom-background-80 py-1 px-1.5 text-xs outline-none"
</h3> onClick={() => {
{importerServices ? ( setRefreshing(true);
importerServices.length > 0 ? ( mutate(IMPORTER_SERVICES_LIST(workspaceSlug as string)).then(() =>
<div className="space-y-2"> setRefreshing(false)
<div className="divide-y divide-custom-border-200"> );
{importerServices.map((service) => ( }}
<SingleImport >
key={service.id} <ArrowPathIcon className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`} />{" "}
service={service} {refreshing ? "Refreshing..." : "Refresh status"}
refreshing={refreshing} </button>
handleDelete={() => handleDeleteImport(service)} </h3>
/> </div>
))} <div className="flex flex-col">
{importerServices ? (
importerServices.length > 0 ? (
<div className="space-y-2">
<div className="divide-y divide-custom-border-200">
{importerServices.map((service) => (
<SingleImport
key={service.id}
service={service}
refreshing={refreshing}
handleDelete={() => handleDeleteImport(service)}
/>
))}
</div>
</div> </div>
</div> ) : (
<p className="text-sm text-custom-text-200 px-4 py-6">
No previous imports available.
</p>
)
) : ( ) : (
<p className="py-2 text-sm text-custom-text-200"> <Loader className="mt-6 grid grid-cols-1 gap-3">
No previous imports available. <Loader.Item height="40px" width="100%" />
</p> <Loader.Item height="40px" width="100%" />
) <Loader.Item height="40px" width="100%" />
) : ( <Loader.Item height="40px" width="100%" />
<Loader className="mt-6 grid grid-cols-1 gap-3"> </Loader>
<Loader.Item height="40px" width="100%" /> )}
<Loader.Item height="40px" width="100%" /> </div>
<Loader.Item height="40px" width="100%" />
<Loader.Item height="40px" width="100%" />
</Loader>
)}
</div> </div>
</> </>
)} )}

View File

@ -16,7 +16,7 @@ type Props = {
}; };
export const SingleImport: React.FC<Props> = ({ service, refreshing, handleDelete }) => ( export const SingleImport: React.FC<Props> = ({ service, refreshing, handleDelete }) => (
<div className="flex items-center justify-between gap-2 py-3"> <div className="flex items-center justify-between gap-2 px-4 py-3">
<div> <div>
<h4 className="flex items-center gap-2 text-sm"> <h4 className="flex items-center gap-2 text-sm">
<span> <span>

View File

@ -15,6 +15,7 @@ import { DangerButton, Loader, PrimaryButton } from "components/ui";
// icons // icons
import GithubLogo from "public/services/github.png"; import GithubLogo from "public/services/github.png";
import SlackLogo from "public/services/slack.png"; import SlackLogo from "public/services/slack.png";
import { CheckCircle2 } from "lucide-react";
// types // types
import { IAppIntegration, IWorkspaceIntegration } from "types"; import { IAppIntegration, IWorkspaceIntegration } from "types";
// fetch-keys // fetch-keys
@ -27,13 +28,12 @@ type Props = {
const integrationDetails: { [key: string]: any } = { const integrationDetails: { [key: string]: any } = {
github: { github: {
logo: GithubLogo, logo: GithubLogo,
installed: installed: "Activate GitHub on individual projects to sync with specific repositories.",
"Activate GitHub integrations on individual projects to sync with specific repositories.",
notInstalled: "Connect with GitHub with your Plane workspace to sync project issues.", notInstalled: "Connect with GitHub with your Plane workspace to sync project issues.",
}, },
slack: { slack: {
logo: SlackLogo, logo: SlackLogo,
installed: "Activate Slack integrations on individual projects to sync with specific channels.", installed: "Activate Slack on individual projects to sync with specific channels.",
notInstalled: "Connect with Slack with your Plane workspace to sync project issues.", notInstalled: "Connect with Slack with your Plane workspace to sync project issues.",
}, },
}; };
@ -99,31 +99,22 @@ export const SingleIntegrationCard: React.FC<Props> = ({ integration }) => {
); );
return ( return (
<div className="flex items-center justify-between gap-2 rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-5"> <div className="flex items-center justify-between gap-2 border-b border-custom-border-200 bg-custom-background-100 px-4 py-6">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="h-12 w-12 flex-shrink-0"> <div className="h-10 w-10 flex-shrink-0">
<Image <Image
src={integrationDetails[integration.provider].logo} src={integrationDetails[integration.provider].logo}
alt={`${integration.title} Logo`} alt={`${integration.title} Logo`}
/> />
</div> </div>
<div> <div>
<h3 className="flex items-center gap-4 text-xl font-semibold"> <h3 className="flex items-center gap-2 text-sm font-medium">
{integration.title} {integration.title}
{workspaceIntegrations ? ( {workspaceIntegrations
isInstalled ? ( ? isInstalled && <CheckCircle2 className="h-3.5 w-3.5 text-white fill-green-500" />
<span className="flex items-center gap-1 text-sm font-normal text-green-500"> : null}
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-green-500" /> Installed
</span>
) : (
<span className="flex items-center gap-1 text-sm font-normal text-custom-text-200">
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-custom-background-80" />{" "}
Not Installed
</span>
)
) : null}
</h3> </h3>
<p className="text-sm text-custom-text-200"> <p className="text-sm text-custom-text-200 tracking-tight">
{workspaceIntegrations {workspaceIntegrations
? isInstalled ? isInstalled
? integrationDetails[integration.provider].installed ? integrationDetails[integration.provider].installed
@ -135,12 +126,12 @@ export const SingleIntegrationCard: React.FC<Props> = ({ integration }) => {
{workspaceIntegrations ? ( {workspaceIntegrations ? (
isInstalled ? ( isInstalled ? (
<DangerButton onClick={handleRemoveIntegration} loading={deletingIntegration}> <DangerButton onClick={handleRemoveIntegration} loading={deletingIntegration} outline>
{deletingIntegration ? "Removing..." : "Remove installation"} {deletingIntegration ? "Uninstalling..." : "Uninstall"}
</DangerButton> </DangerButton>
) : ( ) : (
<PrimaryButton onClick={startAuth} loading={isInstalling}> <PrimaryButton onClick={startAuth} loading={isInstalling}>
{isInstalling ? "Installing..." : "Add installation"} {isInstalling ? "Installing..." : "Install"}
</PrimaryButton> </PrimaryButton>
) )
) : ( ) : (

View File

@ -0,0 +1,93 @@
import React, { useState } from "react";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// ui
import { SecondaryButton, PrimaryButton } from "components/ui";
type Props = {
isOpen: boolean;
handleClose: () => void;
onDiscard: () => void;
onConfirm: () => Promise<void>;
};
export const ConfirmIssueDiscard: React.FC<Props> = (props) => {
const { isOpen, handleClose, onDiscard, onConfirm } = props;
const [isLoading, setIsLoading] = useState(false);
const onClose = () => {
handleClose();
setIsLoading(false);
};
const handleDeletion = async () => {
setIsLoading(true);
await onConfirm();
setIsLoading(false);
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<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 bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="my-10 flex items-center justify-center p-4 text-center sm:p-0 md:my-32">
<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 border border-custom-border-200 bg-custom-background-100 text-left shadow-xl transition-all sm:my-8 sm:w-[40rem]">
<div className="px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<Dialog.Title
as="h3"
className="text-lg font-medium leading-6 text-custom-text-100"
>
Draft Issue
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-custom-text-200">
Would you like to save this issue in drafts?
</p>
</div>
</div>
</div>
</div>
<div className="flex justify-between gap-2 p-4 sm:px-6">
<div>
<SecondaryButton onClick={onDiscard}>Discard</SecondaryButton>
</div>
<div className="space-x-2">
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
<PrimaryButton onClick={handleDeletion} loading={isLoading}>
{isLoading ? "Saving..." : "Save Draft"}
</PrimaryButton>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -0,0 +1,145 @@
import React, { useEffect, useState } from "react";
import { useRouter } from "next/router";
import { mutate } from "swr";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// services
import issueServices from "services/issues.service";
// hooks
import useIssuesView from "hooks/use-issues-view";
import useToast from "hooks/use-toast";
// icons
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// ui
import { SecondaryButton, DangerButton } from "components/ui";
// types
import type { IIssue, ICurrentUserResponse } from "types";
// fetch-keys
import { PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS } from "constants/fetch-keys";
type Props = {
isOpen: boolean;
handleClose: () => void;
data: IIssue | null;
user?: ICurrentUserResponse;
onSubmit?: () => Promise<void> | void;
};
export const DeleteDraftIssueModal: React.FC<Props> = (props) => {
const { isOpen, handleClose, data, user, onSubmit } = props;
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { params } = useIssuesView();
const { setToastAlert } = useToast();
useEffect(() => {
setIsDeleteLoading(false);
}, [isOpen]);
const onClose = () => {
setIsDeleteLoading(false);
handleClose();
};
const handleDeletion = async () => {
if (!workspaceSlug || !data || !user) return;
setIsDeleteLoading(true);
await issueServices
.deleteDraftIssue(workspaceSlug as string, data.project, data.id, user)
.then(() => {
setIsDeleteLoading(false);
handleClose();
mutate(PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS(projectId as string, params));
setToastAlert({
title: "Success",
message: "Draft Issue deleted successfully",
type: "success",
});
})
.catch((error) => {
console.log(error);
handleClose();
setToastAlert({
title: "Error",
message: "Something went wrong",
type: "error",
});
setIsDeleteLoading(false);
});
if (onSubmit) await onSubmit();
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={onClose}>
<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 bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-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 border border-custom-border-200 bg-custom-background-100 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl">
<div className="flex flex-col gap-6 p-6">
<div className="flex w-full items-center justify-start gap-6">
<span className="place-items-center rounded-full bg-red-500/20 p-4">
<ExclamationTriangleIcon
className="h-6 w-6 text-red-600"
aria-hidden="true"
/>
</span>
<span className="flex items-center justify-start">
<h3 className="text-xl font-medium 2xl:text-2xl">Delete Draft Issue</h3>
</span>
</div>
<span>
<p className="text-sm text-custom-text-200">
Are you sure you want to delete issue{" "}
<span className="break-words font-medium text-custom-text-100">
{data?.project_detail.identifier}-{data?.sequence_id}
</span>
{""}? All of the data related to the draft issue will be permanently removed.
This action cannot be undone.
</p>
</span>
<div className="flex justify-end gap-2">
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
<DangerButton onClick={handleDeletion} loading={isDeleteLoading}>
{isDeleteLoading ? "Deleting..." : "Delete Issue"}
</DangerButton>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -0,0 +1,580 @@
import React, { FC, useState, useEffect, useRef } from "react";
import { useRouter } from "next/router";
// react-hook-form
import { Controller, useForm } from "react-hook-form";
// services
import aiService from "services/ai.service";
// hooks
import useToast from "hooks/use-toast";
// components
import { GptAssistantModal } from "components/core";
import { ParentIssuesListModal } from "components/issues";
import {
IssueAssigneeSelect,
IssueDateSelect,
IssueEstimateSelect,
IssueLabelSelect,
IssuePrioritySelect,
IssueProjectSelect,
IssueStateSelect,
} from "components/issues/select";
import { CreateStateModal } from "components/states";
import { CreateLabelModal } from "components/labels";
// ui
import { CustomMenu, Input, PrimaryButton, SecondaryButton, ToggleSwitch } from "components/ui";
import { TipTapEditor } from "components/tiptap";
// icons
import { SparklesIcon, XMarkIcon } from "@heroicons/react/24/outline";
// types
import type { ICurrentUserResponse, IIssue, ISearchIssueResponse } from "types";
const defaultValues: Partial<IIssue> = {
project: "",
name: "",
description: {
type: "doc",
content: [
{
type: "paragraph",
},
],
},
description_html: "<p></p>",
estimate_point: null,
state: "",
parent: null,
priority: "none",
assignees: [],
assignees_list: [],
labels: [],
labels_list: [],
start_date: null,
target_date: null,
};
interface IssueFormProps {
handleFormSubmit: (formData: Partial<IIssue>) => Promise<void>;
data?: Partial<IIssue> | null;
prePopulatedData?: Partial<IIssue> | null;
projectId: string;
setActiveProject: React.Dispatch<React.SetStateAction<string | null>>;
createMore: boolean;
setCreateMore: React.Dispatch<React.SetStateAction<boolean>>;
handleClose: () => void;
status: boolean;
user: ICurrentUserResponse | undefined;
fieldsToShow: (
| "project"
| "name"
| "description"
| "state"
| "priority"
| "assignee"
| "label"
| "startDate"
| "dueDate"
| "estimate"
| "parent"
| "all"
)[];
}
export const DraftIssueForm: FC<IssueFormProps> = (props) => {
const {
handleFormSubmit,
data,
prePopulatedData,
projectId,
setActiveProject,
createMore,
setCreateMore,
handleClose,
status,
user,
fieldsToShow,
} = props;
const [stateModal, setStateModal] = useState(false);
const [labelModal, setLabelModal] = useState(false);
const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false);
const [selectedParentIssue, setSelectedParentIssue] = useState<ISearchIssueResponse | null>(null);
const [gptAssistantModal, setGptAssistantModal] = useState(false);
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
const editorRef = useRef<any>(null);
const router = useRouter();
const { workspaceSlug } = router.query;
const { setToastAlert } = useToast();
const {
register,
formState: { errors, isSubmitting },
handleSubmit,
reset,
watch,
control,
getValues,
setValue,
setFocus,
} = useForm<IIssue>({
defaultValues: prePopulatedData ?? defaultValues,
reValidateMode: "onChange",
});
const issueName = watch("name");
const onClose = () => {
handleClose();
};
const handleCreateUpdateIssue = async (
formData: Partial<IIssue>,
action: "saveDraft" | "createToNewIssue" = "saveDraft"
) => {
await handleFormSubmit({
...formData,
is_draft: action === "saveDraft",
});
setGptAssistantModal(false);
reset({
...defaultValues,
project: projectId,
description: {
type: "doc",
content: [
{
type: "paragraph",
},
],
},
description_html: "<p></p>",
});
editorRef?.current?.clearEditor();
};
const handleAiAssistance = async (response: string) => {
if (!workspaceSlug || !projectId) return;
setValue("description", {});
setValue("description_html", `${watch("description_html")}<p>${response}</p>`);
editorRef.current?.setEditorValue(`${watch("description_html")}`);
};
const handleAutoGenerateDescription = async () => {
if (!workspaceSlug || !projectId) return;
setIAmFeelingLucky(true);
aiService
.createGptTask(
workspaceSlug as string,
projectId as string,
{
prompt: issueName,
task: "Generate a proper description for this issue.",
},
user
)
.then((res) => {
if (res.response === "")
setToastAlert({
type: "error",
title: "Error!",
message:
"Issue title isn't informative enough to generate the description. Please try with a different title.",
});
else handleAiAssistance(res.response_html);
})
.catch((err) => {
const error = err?.data?.error;
if (err.status === 429)
setToastAlert({
type: "error",
title: "Error!",
message:
error ||
"You have reached the maximum number of requests of 50 requests per month per user.",
});
else
setToastAlert({
type: "error",
title: "Error!",
message: error || "Some error occurred. Please try again.",
});
})
.finally(() => setIAmFeelingLucky(false));
};
useEffect(() => {
setFocus("name");
reset({
...defaultValues,
...(prePopulatedData ?? {}),
...(data ?? {}),
});
}, [setFocus, prePopulatedData, reset, data]);
// update projectId in form when projectId changes
useEffect(() => {
reset({
...getValues(),
project: projectId,
});
}, [getValues, projectId, reset]);
const startDate = watch("start_date");
const targetDate = watch("target_date");
const minDate = startDate ? new Date(startDate) : null;
minDate?.setDate(minDate.getDate());
const maxDate = targetDate ? new Date(targetDate) : null;
maxDate?.setDate(maxDate.getDate());
return (
<>
{projectId && (
<>
<CreateStateModal
isOpen={stateModal}
handleClose={() => setStateModal(false)}
projectId={projectId}
user={user}
/>
<CreateLabelModal
isOpen={labelModal}
handleClose={() => setLabelModal(false)}
projectId={projectId}
user={user}
onSuccess={(response) => {
setValue("labels", [...watch("labels"), response.id]);
setValue("labels_list", [...watch("labels_list"), response.id]);
}}
/>
</>
)}
<form
onSubmit={handleSubmit((formData) => handleCreateUpdateIssue(formData, "createToNewIssue"))}
>
<div className="space-y-5">
<div className="flex items-center gap-x-2">
{(fieldsToShow.includes("all") || fieldsToShow.includes("project")) && (
<Controller
control={control}
name="project"
render={({ field: { value, onChange } }) => (
<IssueProjectSelect
value={value}
onChange={(val: string) => {
onChange(val);
setActiveProject(val);
}}
/>
)}
/>
)}
<h3 className="text-xl font-semibold leading-6 text-custom-text-100">
{status ? "Update" : "Create"} Issue
</h3>
</div>
{watch("parent") &&
(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) &&
selectedParentIssue && (
<div className="flex w-min items-center gap-2 whitespace-nowrap rounded bg-custom-background-80 p-2 text-xs">
<div className="flex items-center gap-2">
<span
className="block h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: selectedParentIssue.state__color,
}}
/>
<span className="flex-shrink-0 text-custom-text-200">
{selectedParentIssue.project__identifier}-{selectedParentIssue.sequence_id}
</span>
<span className="truncate font-medium">
{selectedParentIssue.name.substring(0, 50)}
</span>
<XMarkIcon
className="h-3 w-3 cursor-pointer"
onClick={() => {
setValue("parent", null);
setSelectedParentIssue(null);
}}
/>
</div>
</div>
)}
<div className="space-y-3">
<div className="mt-2 space-y-3">
{(fieldsToShow.includes("all") || fieldsToShow.includes("name")) && (
<div>
<Input
id="name"
name="name"
className="resize-none text-xl"
placeholder="Title"
autoComplete="off"
error={errors.name}
register={register}
validations={{
required: "Title is required",
maxLength: {
value: 255,
message: "Title should be less than 255 characters",
},
}}
/>
</div>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("description")) && (
<div className="relative">
<div className="flex justify-end">
{issueName && issueName !== "" && (
<button
type="button"
className={`flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90 ${
iAmFeelingLucky ? "cursor-wait" : ""
}`}
onClick={handleAutoGenerateDescription}
disabled={iAmFeelingLucky}
>
{iAmFeelingLucky ? (
"Generating response..."
) : (
<>
<SparklesIcon className="h-4 w-4" />I{"'"}m feeling lucky
</>
)}
</button>
)}
<button
type="button"
className="flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90"
onClick={() => setGptAssistantModal((prevData) => !prevData)}
>
<SparklesIcon className="h-4 w-4" />
AI
</button>
</div>
<Controller
name="description_html"
control={control}
render={({ field: { value, onChange } }) => {
if (!value && !watch("description_html")) return <></>;
return (
<TipTapEditor
workspaceSlug={workspaceSlug as string}
ref={editorRef}
debouncedUpdatesEnabled={false}
value={
!value ||
value === "" ||
(typeof value === "object" && Object.keys(value).length === 0)
? watch("description_html")
: value
}
customClassName="min-h-[150px]"
onChange={(description: Object, description_html: string) => {
onChange(description_html);
setValue("description", description);
}}
/>
);
}}
/>
<GptAssistantModal
isOpen={gptAssistantModal}
handleClose={() => {
setGptAssistantModal(false);
// this is done so that the title do not reset after gpt popover closed
reset(getValues());
}}
inset="top-2 left-0"
content=""
htmlContent={watch("description_html")}
onResponse={(response) => {
handleAiAssistance(response);
}}
projectId={projectId}
/>
</div>
)}
<div className="flex flex-wrap items-center gap-2">
{(fieldsToShow.includes("all") || fieldsToShow.includes("state")) && (
<Controller
control={control}
name="state"
render={({ field: { value, onChange } }) => (
<IssueStateSelect
setIsOpen={setStateModal}
value={value}
onChange={onChange}
projectId={projectId}
/>
)}
/>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("priority")) && (
<Controller
control={control}
name="priority"
render={({ field: { value, onChange } }) => (
<IssuePrioritySelect value={value} onChange={onChange} />
)}
/>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("assignee")) && (
<Controller
control={control}
name="assignees"
render={({ field: { value, onChange } }) => (
<IssueAssigneeSelect
projectId={projectId}
value={value}
onChange={onChange}
/>
)}
/>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("label")) && (
<Controller
control={control}
name="labels"
render={({ field: { value, onChange } }) => (
<IssueLabelSelect
setIsOpen={setLabelModal}
value={value}
onChange={onChange}
projectId={projectId}
/>
)}
/>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("startDate")) && (
<div>
<Controller
control={control}
name="start_date"
render={({ field: { value, onChange } }) => (
<IssueDateSelect
label="Start date"
maxDate={maxDate ?? undefined}
onChange={onChange}
value={value}
/>
)}
/>
</div>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("dueDate")) && (
<div>
<Controller
control={control}
name="target_date"
render={({ field: { value, onChange } }) => (
<IssueDateSelect
label="Due date"
minDate={minDate ?? undefined}
onChange={onChange}
value={value}
/>
)}
/>
</div>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("estimate")) && (
<div>
<Controller
control={control}
name="estimate_point"
render={({ field: { value, onChange } }) => (
<IssueEstimateSelect value={value} onChange={onChange} />
)}
/>
</div>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && (
<Controller
control={control}
name="parent"
render={({ field: { onChange } }) => (
<ParentIssuesListModal
isOpen={parentIssueListModalOpen}
handleClose={() => setParentIssueListModalOpen(false)}
onChange={(issue) => {
onChange(issue.id);
setSelectedParentIssue(issue);
}}
projectId={projectId}
/>
)}
/>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && (
<CustomMenu ellipsis>
{watch("parent") ? (
<>
<CustomMenu.MenuItem
renderAs="button"
onClick={() => setParentIssueListModalOpen(true)}
>
Change parent issue
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
renderAs="button"
onClick={() => setValue("parent", null)}
>
Remove parent issue
</CustomMenu.MenuItem>
</>
) : (
<CustomMenu.MenuItem
renderAs="button"
onClick={() => setParentIssueListModalOpen(true)}
>
Select Parent Issue
</CustomMenu.MenuItem>
)}
</CustomMenu>
)}
</div>
</div>
</div>
</div>
<div className="-mx-5 mt-5 flex items-center justify-between gap-2 border-t border-custom-border-200 px-5 pt-5">
<div
className="flex cursor-pointer items-center gap-1"
onClick={() => setCreateMore((prevData) => !prevData)}
>
<span className="text-xs">Create more</span>
<ToggleSwitch value={createMore} onChange={() => {}} size="md" />
</div>
<div className="flex items-center gap-2">
<SecondaryButton onClick={onClose}>Discard</SecondaryButton>
<SecondaryButton
loading={isSubmitting}
onClick={handleSubmit((formData) => handleCreateUpdateIssue(formData, "saveDraft"))}
>
{isSubmitting ? "Saving..." : "Save Draft"}
</SecondaryButton>
{data && (
<PrimaryButton type="submit" loading={isSubmitting}>
{isSubmitting ? "Saving..." : "Add Issue"}
</PrimaryButton>
)}
</div>
</div>
</form>
</>
);
};

View File

@ -0,0 +1,293 @@
import React, { useEffect, useState } from "react";
import { useRouter } from "next/router";
import { mutate } from "swr";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// services
import issuesService from "services/issues.service";
// hooks
import useUser from "hooks/use-user";
import useIssuesView from "hooks/use-issues-view";
import useCalendarIssuesView from "hooks/use-calendar-issues-view";
import useToast from "hooks/use-toast";
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
import useLocalStorage from "hooks/use-local-storage";
import useProjects from "hooks/use-projects";
import useMyIssues from "hooks/my-issues/use-my-issues";
// components
import { DraftIssueForm } from "components/issues";
// types
import type { IIssue } from "types";
// fetch-keys
import {
PROJECT_ISSUES_DETAILS,
USER_ISSUE,
SUB_ISSUES,
PROJECT_ISSUES_LIST_WITH_PARAMS,
CYCLE_ISSUES_WITH_PARAMS,
MODULE_ISSUES_WITH_PARAMS,
VIEW_ISSUES,
PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS,
} from "constants/fetch-keys";
interface IssuesModalProps {
data?: IIssue | null;
handleClose: () => void;
isOpen: boolean;
isUpdatingSingleIssue?: boolean;
prePopulateData?: Partial<IIssue>;
fieldsToShow?: (
| "project"
| "name"
| "description"
| "state"
| "priority"
| "assignee"
| "label"
| "startDate"
| "dueDate"
| "estimate"
| "parent"
| "all"
)[];
onSubmit?: (data: Partial<IIssue>) => Promise<void> | void;
}
export const CreateUpdateDraftIssueModal: React.FC<IssuesModalProps> = ({
data,
handleClose,
isOpen,
isUpdatingSingleIssue = false,
prePopulateData,
fieldsToShow = ["all"],
onSubmit,
}) => {
// states
const [createMore, setCreateMore] = useState(false);
const [activeProject, setActiveProject] = useState<string | null>(null);
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
const { displayFilters, params } = useIssuesView();
const { params: calendarParams } = useCalendarIssuesView();
const { ...viewGanttParams } = params;
const { params: spreadsheetParams } = useSpreadsheetIssuesView();
const { user } = useUser();
const { projects } = useProjects();
const { clearValue: clearDraftIssueLocalStorage } = useLocalStorage("draftedIssue", {});
const { groupedIssues, mutateMyIssues } = useMyIssues(workspaceSlug?.toString());
const { setToastAlert } = useToast();
if (cycleId) prePopulateData = { ...prePopulateData, cycle: cycleId as string };
if (moduleId) prePopulateData = { ...prePopulateData, module: moduleId as string };
if (router.asPath.includes("my-issues") || router.asPath.includes("assigned"))
prePopulateData = {
...prePopulateData,
assignees: [...(prePopulateData?.assignees ?? []), user?.id ?? ""],
};
const onClose = () => {
handleClose();
setActiveProject(null);
};
useEffect(() => {
// if modal is closed, reset active project to null
// and return to avoid activeProject being set to some other project
if (!isOpen) {
setActiveProject(null);
return;
}
// if data is present, set active project to the project of the
// issue. This has more priority than the project in the url.
if (data && data.project) {
setActiveProject(data.project);
return;
}
if (prePopulateData && prePopulateData.project)
return setActiveProject(prePopulateData.project);
// if data is not present, set active project to the project
// in the url. This has the least priority.
if (projects && projects.length > 0 && !activeProject)
setActiveProject(projects?.find((p) => p.id === projectId)?.id ?? projects?.[0].id ?? null);
}, [activeProject, data, projectId, projects, isOpen, prePopulateData]);
const calendarFetchKey = cycleId
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), calendarParams)
: moduleId
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), calendarParams)
: viewId
? VIEW_ISSUES(viewId.toString(), calendarParams)
: PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject?.toString() ?? "", calendarParams);
const spreadsheetFetchKey = cycleId
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), spreadsheetParams)
: moduleId
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), spreadsheetParams)
: viewId
? VIEW_ISSUES(viewId.toString(), spreadsheetParams)
: PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject?.toString() ?? "", spreadsheetParams);
const ganttFetchKey = cycleId
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString())
: moduleId
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString())
: viewId
? VIEW_ISSUES(viewId.toString(), viewGanttParams)
: PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject?.toString() ?? "");
const createIssue = async (payload: Partial<IIssue>) => {
if (!workspaceSlug || !activeProject || !user) return;
await issuesService
.createDraftIssue(workspaceSlug as string, activeProject ?? "", payload, user)
.then(async () => {
mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params));
mutate(PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params));
if (displayFilters.layout === "calendar") mutate(calendarFetchKey);
if (displayFilters.layout === "gantt_chart")
mutate(ganttFetchKey, {
start_target_date: true,
order_by: "sort_order",
});
if (displayFilters.layout === "spreadsheet") mutate(spreadsheetFetchKey);
if (groupedIssues) mutateMyIssues();
setToastAlert({
type: "success",
title: "Success!",
message: "Issue created successfully.",
});
if (payload.assignees_list?.some((assignee) => assignee === user?.id))
mutate(USER_ISSUE(workspaceSlug as string));
if (payload.parent && payload.parent !== "") mutate(SUB_ISSUES(payload.parent));
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Issue could not be created. Please try again.",
});
});
if (!createMore) onClose();
};
const updateIssue = async (payload: Partial<IIssue>) => {
if (!user) return;
await issuesService
.updateDraftIssue(workspaceSlug as string, activeProject ?? "", data?.id ?? "", payload, user)
.then((res) => {
if (isUpdatingSingleIssue) {
mutate<IIssue>(PROJECT_ISSUES_DETAILS, (prevData) => ({ ...prevData, ...res }), false);
} else {
if (displayFilters.layout === "calendar") mutate(calendarFetchKey);
if (displayFilters.layout === "spreadsheet") mutate(spreadsheetFetchKey);
if (payload.parent) mutate(SUB_ISSUES(payload.parent.toString()));
mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params));
mutate(PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params));
}
if (!createMore) onClose();
setToastAlert({
type: "success",
title: "Success!",
message: "Issue updated successfully.",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Issue could not be updated. Please try again.",
});
});
};
const handleFormSubmit = async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !activeProject) return;
const payload: Partial<IIssue> = {
...formData,
assignees_list: formData.assignees ?? [],
labels_list: formData.labels ?? [],
description: formData.description ?? "",
description_html: formData.description_html ?? "<p></p>",
};
if (!data) await createIssue(payload);
else await updateIssue(payload);
clearDraftIssueLocalStorage();
if (onSubmit) await onSubmit(payload);
};
if (!projects || projects.length === 0) return null;
return (
<>
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={onClose}>
<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 bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="my-10 flex items-center justify-center p-4 text-center sm:p-0 md:my-20">
<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 rounded-lg border border-custom-border-200 bg-custom-background-100 p-5 text-left shadow-xl transition-all sm:w-full sm:max-w-2xl">
<DraftIssueForm
handleFormSubmit={handleFormSubmit}
prePopulatedData={prePopulateData}
data={data}
createMore={createMore}
setCreateMore={setCreateMore}
handleClose={onClose}
projectId={activeProject ?? ""}
setActiveProject={setActiveProject}
status={data ? true : false}
user={user}
fieldsToShow={fieldsToShow}
/>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
</>
);
};

View File

@ -61,9 +61,10 @@ export interface IssueFormProps {
setActiveProject: React.Dispatch<React.SetStateAction<string | null>>; setActiveProject: React.Dispatch<React.SetStateAction<string | null>>;
createMore: boolean; createMore: boolean;
setCreateMore: React.Dispatch<React.SetStateAction<boolean>>; setCreateMore: React.Dispatch<React.SetStateAction<boolean>>;
handleClose: () => void; handleDiscardClose: () => void;
status: boolean; status: boolean;
user: ICurrentUserResponse | undefined; user: ICurrentUserResponse | undefined;
handleFormDirty: (payload: Partial<IIssue> | null) => void;
fieldsToShow: ( fieldsToShow: (
| "project" | "project"
| "name" | "name"
@ -80,18 +81,21 @@ export interface IssueFormProps {
)[]; )[];
} }
export const IssueForm: FC<IssueFormProps> = ({ export const IssueForm: FC<IssueFormProps> = (props) => {
handleFormSubmit, const {
initialData, handleFormSubmit,
projectId, initialData,
setActiveProject, projectId,
createMore, setActiveProject,
setCreateMore, createMore,
handleClose, setCreateMore,
status, handleDiscardClose,
user, status,
fieldsToShow, user,
}) => { fieldsToShow,
handleFormDirty,
} = props;
const [stateModal, setStateModal] = useState(false); const [stateModal, setStateModal] = useState(false);
const [labelModal, setLabelModal] = useState(false); const [labelModal, setLabelModal] = useState(false);
const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false); const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false);
@ -109,7 +113,7 @@ export const IssueForm: FC<IssueFormProps> = ({
const { const {
register, register,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting, isDirty },
handleSubmit, handleSubmit,
reset, reset,
watch, watch,
@ -124,6 +128,25 @@ export const IssueForm: FC<IssueFormProps> = ({
const issueName = watch("name"); const issueName = watch("name");
const payload: Partial<IIssue> = {
name: getValues("name"),
description: getValues("description"),
state: getValues("state"),
priority: getValues("priority"),
assignees: getValues("assignees"),
labels: getValues("labels"),
start_date: getValues("start_date"),
target_date: getValues("target_date"),
project: getValues("project"),
parent: getValues("parent"),
};
useEffect(() => {
if (isDirty) handleFormDirty(payload);
else handleFormDirty(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(payload), isDirty]);
const handleCreateUpdateIssue = async (formData: Partial<IIssue>) => { const handleCreateUpdateIssue = async (formData: Partial<IIssue>) => {
await handleFormSubmit(formData); await handleFormSubmit(formData);
@ -543,7 +566,13 @@ export const IssueForm: FC<IssueFormProps> = ({
<ToggleSwitch value={createMore} onChange={() => {}} size="md" /> <ToggleSwitch value={createMore} onChange={() => {}} size="md" />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<SecondaryButton onClick={handleClose}>Discard</SecondaryButton> <SecondaryButton
onClick={() => {
handleDiscardClose();
}}
>
Discard
</SecondaryButton>
<PrimaryButton type="submit" loading={isSubmitting}> <PrimaryButton type="submit" loading={isSubmitting}>
{status {status
? isSubmitting ? isSubmitting

View File

@ -16,3 +16,9 @@ export * from "./sub-issues-list";
export * from "./label"; export * from "./label";
export * from "./issue-reaction"; export * from "./issue-reaction";
export * from "./peek-overview"; export * from "./peek-overview";
export * from "./confirm-issue-discard";
// draft issue
export * from "./draft-issue-form";
export * from "./draft-issue-modal";
export * from "./delete-draft-issue-modal";

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState, useCallback } from "react"; import React, { useEffect, useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@ -19,8 +19,9 @@ import useInboxView from "hooks/use-inbox-view";
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view"; import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
import useProjects from "hooks/use-projects"; import useProjects from "hooks/use-projects";
import useMyIssues from "hooks/my-issues/use-my-issues"; import useMyIssues from "hooks/my-issues/use-my-issues";
import useLocalStorage from "hooks/use-local-storage";
// components // components
import { IssueForm } from "components/issues"; import { IssueForm, ConfirmIssueDiscard } from "components/issues";
// types // types
import type { IIssue } from "types"; import type { IIssue } from "types";
// fetch-keys // fetch-keys
@ -35,6 +36,7 @@ import {
MODULE_DETAILS, MODULE_DETAILS,
VIEW_ISSUES, VIEW_ISSUES,
INBOX_ISSUES, INBOX_ISSUES,
PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS,
} from "constants/fetch-keys"; } from "constants/fetch-keys";
// constants // constants
import { INBOX_ISSUE_SOURCE } from "constants/inbox"; import { INBOX_ISSUE_SOURCE } from "constants/inbox";
@ -73,6 +75,8 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
}) => { }) => {
// states // states
const [createMore, setCreateMore] = useState(false); const [createMore, setCreateMore] = useState(false);
const [formDirtyState, setFormDirtyState] = useState<any>(null);
const [showConfirmDiscard, setShowConfirmDiscard] = useState(false);
const [activeProject, setActiveProject] = useState<string | null>(null); const [activeProject, setActiveProject] = useState<string | null>(null);
const router = useRouter(); const router = useRouter();
@ -80,7 +84,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
const { displayFilters, params } = useIssuesView(); const { displayFilters, params } = useIssuesView();
const { params: calendarParams } = useCalendarIssuesView(); const { params: calendarParams } = useCalendarIssuesView();
const { order_by, group_by, ...viewGanttParams } = params; const { ...viewGanttParams } = params;
const { params: inboxParams } = useInboxView(); const { params: inboxParams } = useInboxView();
const { params: spreadsheetParams } = useSpreadsheetIssuesView(); const { params: spreadsheetParams } = useSpreadsheetIssuesView();
@ -89,20 +93,36 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
const { groupedIssues, mutateMyIssues } = useMyIssues(workspaceSlug?.toString()); const { groupedIssues, mutateMyIssues } = useMyIssues(workspaceSlug?.toString());
const { setValue: setValueInLocalStorage, clearValue: clearLocalStorageValue } =
useLocalStorage<any>("draftedIssue", {});
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
if (cycleId) prePopulateData = { ...prePopulateData, cycle: cycleId as string };
if (moduleId) prePopulateData = { ...prePopulateData, module: moduleId as string };
if (router.asPath.includes("my-issues") || router.asPath.includes("assigned")) if (router.asPath.includes("my-issues") || router.asPath.includes("assigned"))
prePopulateData = { prePopulateData = {
...prePopulateData, ...prePopulateData,
assignees: [...(prePopulateData?.assignees ?? []), user?.id ?? ""], assignees: [...(prePopulateData?.assignees ?? []), user?.id ?? ""],
}; };
const onClose = useCallback(() => { const onClose = () => {
handleClose(); if (!showConfirmDiscard) handleClose();
setActiveProject(null); if (formDirtyState === null) return setActiveProject(null);
}, [handleClose]); const data = JSON.stringify(formDirtyState);
setValueInLocalStorage(data);
};
const onDiscardClose = () => {
if (formDirtyState !== null) {
setShowConfirmDiscard(true);
} else {
handleClose();
setActiveProject(null);
}
};
const handleFormDirty = (data: any) => {
setFormDirtyState(data);
};
useEffect(() => { useEffect(() => {
// if modal is closed, reset active project to null // if modal is closed, reset active project to null
@ -275,10 +295,50 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
}); });
}); });
if (!createMore) onClose(); if (!createMore) onDiscardClose();
};
const createDraftIssue = async () => {
if (!workspaceSlug || !activeProject || !user) return;
const payload: Partial<IIssue> = {
...formDirtyState,
};
await issuesService
.createDraftIssue(workspaceSlug as string, activeProject ?? "", payload, user)
.then(() => {
mutate(PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params));
if (groupedIssues) mutateMyIssues();
setToastAlert({
type: "success",
title: "Success!",
message: "Draft Issue created successfully.",
});
handleClose();
setActiveProject(null);
setFormDirtyState(null);
setShowConfirmDiscard(false);
if (payload.assignees_list?.some((assignee) => assignee === user?.id))
mutate(USER_ISSUE(workspaceSlug as string));
if (payload.parent && payload.parent !== "") mutate(SUB_ISSUES(payload.parent));
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Issue could not be created. Please try again.",
});
});
}; };
const updateIssue = async (payload: Partial<IIssue>) => { const updateIssue = async (payload: Partial<IIssue>) => {
if (!user) return;
await issuesService await issuesService
.patchIssue(workspaceSlug as string, activeProject ?? "", data?.id ?? "", payload, user) .patchIssue(workspaceSlug as string, activeProject ?? "", data?.id ?? "", payload, user)
.then((res) => { .then((res) => {
@ -294,7 +354,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
if (payload.cycle && payload.cycle !== "") addIssueToCycle(res.id, payload.cycle); if (payload.cycle && payload.cycle !== "") addIssueToCycle(res.id, payload.cycle);
if (payload.module && payload.module !== "") addIssueToModule(res.id, payload.module); if (payload.module && payload.module !== "") addIssueToModule(res.id, payload.module);
if (!createMore) onClose(); if (!createMore) onDiscardClose();
setToastAlert({ setToastAlert({
type: "success", type: "success",
@ -331,49 +391,65 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
if (!projects || projects.length === 0) return null; if (!projects || projects.length === 0) return null;
return ( return (
<Transition.Root show={isOpen} as={React.Fragment}> <>
<Dialog as="div" className="relative z-20" onClose={onClose}> <ConfirmIssueDiscard
<Transition.Child isOpen={showConfirmDiscard}
as={React.Fragment} handleClose={() => setShowConfirmDiscard(false)}
enter="ease-out duration-300" onConfirm={createDraftIssue}
enterFrom="opacity-0" onDiscard={() => {
enterTo="opacity-100" handleClose();
leave="ease-in duration-200" setActiveProject(null);
leaveFrom="opacity-100" setFormDirtyState(null);
leaveTo="opacity-0" setShowConfirmDiscard(false);
> clearLocalStorageValue();
<div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" /> }}
</Transition.Child> />
<div className="fixed inset-0 z-10 overflow-y-auto"> <Transition.Root show={isOpen} as={React.Fragment}>
<div className="my-10 flex items-center justify-center p-4 text-center sm:p-0 md:my-20"> <Dialog as="div" className="relative z-20" onClose={onClose}>
<Transition.Child <Transition.Child
as={React.Fragment} as={React.Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" enterFrom="opacity-0"
enterTo="opacity-100 translate-y-0 sm:scale-100" enterTo="opacity-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveFrom="opacity-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" leaveTo="opacity-0"
> >
<Dialog.Panel className="relative transform rounded-lg border border-custom-border-200 bg-custom-background-100 p-5 text-left shadow-xl transition-all sm:w-full sm:max-w-2xl"> <div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" />
<IssueForm </Transition.Child>
handleFormSubmit={handleFormSubmit}
initialData={data ?? prePopulateData} <div className="fixed inset-0 z-10 overflow-y-auto">
createMore={createMore} <div className="my-10 flex items-center justify-center p-4 text-center sm:p-0 md:my-20">
setCreateMore={setCreateMore} <Transition.Child
handleClose={onClose} as={React.Fragment}
projectId={activeProject ?? ""} enter="ease-out duration-300"
setActiveProject={setActiveProject} enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
status={data ? true : false} enterTo="opacity-100 translate-y-0 sm:scale-100"
user={user} leave="ease-in duration-200"
fieldsToShow={fieldsToShow} leaveFrom="opacity-100 translate-y-0 sm:scale-100"
/> leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
</Dialog.Panel> >
</Transition.Child> <Dialog.Panel className="relative transform rounded-lg border border-custom-border-200 bg-custom-background-100 p-5 text-left shadow-xl transition-all sm:w-full sm:max-w-2xl">
<IssueForm
handleFormSubmit={handleFormSubmit}
initialData={data ?? prePopulateData}
createMore={createMore}
setCreateMore={setCreateMore}
handleDiscardClose={onDiscardClose}
projectId={activeProject ?? ""}
setActiveProject={setActiveProject}
status={data ? true : false}
user={user}
fieldsToShow={fieldsToShow}
handleFormDirty={handleFormDirty}
/>
</Dialog.Panel>
</Transition.Child>
</div>
</div> </div>
</div> </Dialog>
</Dialog> </Transition.Root>
</Transition.Root> </>
); );
}; };

View File

@ -205,7 +205,7 @@ export const MyIssuesView: React.FC<Props> = ({
); );
const handleIssueAction = useCallback( const handleIssueAction = useCallback(
(issue: IIssue, action: "copy" | "edit" | "delete") => { (issue: IIssue, action: "copy" | "edit" | "delete" | "updateDraft") => {
if (action === "copy") makeIssueCopy(issue); if (action === "copy") makeIssueCopy(issue);
else if (action === "edit") handleEditIssue(issue); else if (action === "edit") handleEditIssue(issue);
else if (action === "delete") handleDeleteIssue(issue); else if (action === "delete") handleDeleteIssue(issue);

View File

@ -73,7 +73,7 @@ export const SidebarBlockedSelect: React.FC<Props> = ({
...selectedIssues.map((issue) => ({ ...selectedIssues.map((issue) => ({
issue: issueId as string, issue: issueId as string,
relation_type: "blocked_by" as const, relation_type: "blocked_by" as const,
related_issue_detail: issue.blocked_issue_detail, issue_detail: issue.blocked_issue_detail,
related_issue: issue.blocked_issue_detail.id, related_issue: issue.blocked_issue_detail.id,
})), })),
], ],
@ -111,17 +111,17 @@ export const SidebarBlockedSelect: React.FC<Props> = ({
{blockedByIssue && blockedByIssue.length > 0 {blockedByIssue && blockedByIssue.length > 0
? blockedByIssue.map((relation) => ( ? blockedByIssue.map((relation) => (
<div <div
key={relation.related_issue_detail?.id} key={relation?.id}
className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-red-500 duration-300 hover:border-red-500/20 hover:bg-red-500/20" className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-red-500 duration-300 hover:border-red-500/20 hover:bg-red-500/20"
> >
<a <a
href={`/${workspaceSlug}/projects/${relation.related_issue_detail?.project_detail.id}/issues/${relation.related_issue_detail?.id}`} href={`/${workspaceSlug}/projects/${relation.issue_detail?.project_detail.id}/issues/${relation.issue_detail?.id}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-1" className="flex items-center gap-1"
> >
<BlockedIcon height={10} width={10} /> <BlockedIcon height={10} width={10} />
{`${relation.related_issue_detail?.project_detail.identifier}-${relation.related_issue_detail?.sequence_id}`} {`${relation.issue_detail?.project_detail.identifier}-${relation.issue_detail?.sequence_id}`}
</a> </a>
<button <button
type="button" type="button"

View File

@ -81,6 +81,7 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
related_issue_detail: issue.blocker_issue_detail, related_issue_detail: issue.blocker_issue_detail,
})), })),
], ],
relation: "blocking",
}) })
.then((response) => { .then((response) => {
submitChanges({ submitChanges({
@ -89,7 +90,7 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
...(response ?? []).map((i: any) => ({ ...(response ?? []).map((i: any) => ({
id: i.id, id: i.id,
relation_type: i.relation_type, relation_type: i.relation_type,
issue_detail: i.related_issue_detail, issue_detail: i.issue_detail,
issue: i.related_issue, issue: i.related_issue,
})), })),
], ],
@ -118,7 +119,7 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
{blockerIssue && blockerIssue.length > 0 {blockerIssue && blockerIssue.length > 0
? blockerIssue.map((relation) => ( ? blockerIssue.map((relation) => (
<div <div
key={relation.issue_detail?.id} key={relation.id}
className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-yellow-500 duration-300 hover:border-yellow-500/20 hover:bg-yellow-500/20" className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-yellow-500 duration-300 hover:border-yellow-500/20 hover:bg-yellow-500/20"
> >
<a <a
@ -134,9 +135,7 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
type="button" type="button"
className="opacity-0 duration-300 group-hover:opacity-100" className="opacity-0 duration-300 group-hover:opacity-100"
onClick={() => { onClick={() => {
const updatedBlockers = blockerIssue.filter( const updatedBlockers = blockerIssue.filter((i) => i.id !== relation.id);
(i) => i.issue_detail?.id !== relation.issue_detail?.id
);
submitChanges({ submitChanges({
issue_relations: updatedBlockers, issue_relations: updatedBlockers,

View File

@ -18,7 +18,7 @@ import { BlockeIssueDetail, IIssue, ISearchIssueResponse } from "types";
type Props = { type Props = {
issueId?: string; issueId?: string;
submitChanges: (formData: Partial<IIssue>) => void; submitChanges: (formData?: Partial<IIssue>) => void;
watch: UseFormWatch<IIssue>; watch: UseFormWatch<IIssue>;
disabled?: boolean; disabled?: boolean;
}; };
@ -69,7 +69,7 @@ export const SidebarDuplicateSelect: React.FC<Props> = (props) => {
related_list: [ related_list: [
...selectedIssues.map((issue) => ({ ...selectedIssues.map((issue) => ({
issue: issueId as string, issue: issueId as string,
related_issue_detail: issue.blocker_issue_detail, issue_detail: issue.blocker_issue_detail,
related_issue: issue.blocker_issue_detail.id, related_issue: issue.blocker_issue_detail.id,
relation_type: "duplicate" as const, relation_type: "duplicate" as const,
})), })),
@ -90,7 +90,7 @@ export const SidebarDuplicateSelect: React.FC<Props> = (props) => {
?.filter((i) => i.relation_type === "duplicate") ?.filter((i) => i.relation_type === "duplicate")
.map((i) => ({ .map((i) => ({
...i, ...i,
related_issue_detail: i.issue_detail, issue_detail: i.issue_detail,
related_issue: i.issue_detail?.id, related_issue: i.issue_detail?.id,
})), })),
]; ];
@ -114,39 +114,35 @@ export const SidebarDuplicateSelect: React.FC<Props> = (props) => {
{duplicateIssuesRelation && duplicateIssuesRelation.length > 0 {duplicateIssuesRelation && duplicateIssuesRelation.length > 0
? duplicateIssuesRelation.map((relation) => ( ? duplicateIssuesRelation.map((relation) => (
<div <div
key={relation.related_issue_detail?.id} key={relation.issue_detail?.id}
className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-yellow-500 duration-300 hover:border-yellow-500/20 hover:bg-yellow-500/20" className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-yellow-500 duration-300 hover:border-yellow-500/20 hover:bg-yellow-500/20"
> >
<a <a
href={`/${workspaceSlug}/projects/${relation.related_issue_detail?.project_detail.id}/issues/${relation.related_issue_detail?.id}`} href={`/${workspaceSlug}/projects/${relation.issue_detail?.project_detail.id}/issues/${relation.issue_detail?.id}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-1" className="flex items-center gap-1"
> >
<BlockerIcon height={10} width={10} /> <BlockerIcon height={10} width={10} />
{`${relation.related_issue_detail?.project_detail.identifier}-${relation.related_issue_detail?.sequence_id}`} {`${relation.issue_detail?.project_detail.identifier}-${relation.issue_detail?.sequence_id}`}
</a> </a>
<button <button
type="button" type="button"
className="opacity-0 duration-300 group-hover:opacity-100" className="opacity-0 duration-300 group-hover:opacity-100"
onClick={() => { onClick={() => {
const updatedBlockers = duplicateIssuesRelation.filter(
(i) => i.related_issue_detail?.id !== relation.related_issue_detail?.id
);
submitChanges({
related_issues: updatedBlockers,
});
if (!user) return; if (!user) return;
issuesService.deleteIssueRelation( issuesService
workspaceSlug as string, .deleteIssueRelation(
projectId as string, workspaceSlug as string,
issueId as string, projectId as string,
relation.id, issueId as string,
user relation.id,
); user
)
.then(() => {
submitChanges();
});
}} }}
> >
<X className="h-2 w-2" /> <X className="h-2 w-2" />

View File

@ -18,7 +18,7 @@ import { BlockeIssueDetail, IIssue, ISearchIssueResponse } from "types";
type Props = { type Props = {
issueId?: string; issueId?: string;
submitChanges: (formData: Partial<IIssue>) => void; submitChanges: (formData?: Partial<IIssue>) => void;
watch: UseFormWatch<IIssue>; watch: UseFormWatch<IIssue>;
disabled?: boolean; disabled?: boolean;
}; };
@ -69,7 +69,7 @@ export const SidebarRelatesSelect: React.FC<Props> = (props) => {
related_list: [ related_list: [
...selectedIssues.map((issue) => ({ ...selectedIssues.map((issue) => ({
issue: issueId as string, issue: issueId as string,
related_issue_detail: issue.blocker_issue_detail, issue_detail: issue.blocker_issue_detail,
related_issue: issue.blocker_issue_detail.id, related_issue: issue.blocker_issue_detail.id,
relation_type: "relates_to" as const, relation_type: "relates_to" as const,
})), })),
@ -90,7 +90,7 @@ export const SidebarRelatesSelect: React.FC<Props> = (props) => {
?.filter((i) => i.relation_type === "relates_to") ?.filter((i) => i.relation_type === "relates_to")
.map((i) => ({ .map((i) => ({
...i, ...i,
related_issue_detail: i.issue_detail, issue_detail: i.issue_detail,
related_issue: i.issue_detail?.id, related_issue: i.issue_detail?.id,
})), })),
]; ];
@ -114,39 +114,35 @@ export const SidebarRelatesSelect: React.FC<Props> = (props) => {
{relatedToIssueRelation && relatedToIssueRelation.length > 0 {relatedToIssueRelation && relatedToIssueRelation.length > 0
? relatedToIssueRelation.map((relation) => ( ? relatedToIssueRelation.map((relation) => (
<div <div
key={relation.related_issue_detail?.id} key={relation.issue_detail?.id}
className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-yellow-500 duration-300 hover:border-yellow-500/20 hover:bg-yellow-500/20" className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-yellow-500 duration-300 hover:border-yellow-500/20 hover:bg-yellow-500/20"
> >
<a <a
href={`/${workspaceSlug}/projects/${relation.related_issue_detail?.project_detail.id}/issues/${relation.related_issue_detail?.id}`} href={`/${workspaceSlug}/projects/${relation.issue_detail?.project_detail.id}/issues/${relation.issue_detail?.id}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-1" className="flex items-center gap-1"
> >
<BlockerIcon height={10} width={10} /> <BlockerIcon height={10} width={10} />
{`${relation.related_issue_detail?.project_detail.identifier}-${relation.related_issue_detail?.sequence_id}`} {`${relation.issue_detail?.project_detail.identifier}-${relation.issue_detail?.sequence_id}`}
</a> </a>
<button <button
type="button" type="button"
className="opacity-0 duration-300 group-hover:opacity-100" className="opacity-0 duration-300 group-hover:opacity-100"
onClick={() => { onClick={() => {
const updatedBlockers = relatedToIssueRelation.filter(
(i) => i.related_issue_detail?.id !== relation.related_issue_detail?.id
);
submitChanges({
related_issues: updatedBlockers,
});
if (!user) return; if (!user) return;
issuesService.deleteIssueRelation( issuesService
workspaceSlug as string, .deleteIssueRelation(
projectId as string, workspaceSlug as string,
issueId as string, projectId as string,
relation.id, issueId as string,
user relation.id,
); user
)
.then(() => {
submitChanges();
});
}} }}
> >
<X className="h-2 w-2" /> <X className="h-2 w-2" />

View File

@ -509,17 +509,14 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
<SidebarDuplicateSelect <SidebarDuplicateSelect
issueId={issueId as string} issueId={issueId as string}
submitChanges={(data: any) => { submitChanges={(data: any) => {
mutate<IIssue>( if (!data) return mutate(ISSUE_DETAILS(issueId as string));
ISSUE_DETAILS(issueId as string), mutate<IIssue>(ISSUE_DETAILS(issueId as string), (prevData) => {
(prevData) => { if (!prevData) return prevData;
if (!prevData) return prevData; return {
return { ...prevData,
...prevData, ...data,
...data, };
}; });
},
false
);
}} }}
watch={watchIssue} watch={watchIssue}
disabled={memberRole.isGuest || memberRole.isViewer || uneditable} disabled={memberRole.isGuest || memberRole.isViewer || uneditable}
@ -529,17 +526,14 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
<SidebarRelatesSelect <SidebarRelatesSelect
issueId={issueId as string} issueId={issueId as string}
submitChanges={(data: any) => { submitChanges={(data: any) => {
mutate<IIssue>( if (!data) return mutate(ISSUE_DETAILS(issueId as string));
ISSUE_DETAILS(issueId as string), mutate<IIssue>(ISSUE_DETAILS(issueId as string), (prevData) => {
(prevData) => { if (!prevData) return prevData;
if (!prevData) return prevData; return {
return { ...prevData,
...prevData, ...data,
...data, };
}; });
},
false
);
}} }}
watch={watchIssue} watch={watchIssue}
disabled={memberRole.isGuest || memberRole.isViewer || uneditable} disabled={memberRole.isGuest || memberRole.isViewer || uneditable}

View File

@ -16,8 +16,6 @@ import { Popover, Transition } from "@headlessui/react";
import issuesService from "services/issues.service"; import issuesService from "services/issues.service";
// ui // ui
import { Input, PrimaryButton, SecondaryButton } from "components/ui"; import { Input, PrimaryButton, SecondaryButton } from "components/ui";
// icons
import { Component } from "lucide-react";
// types // types
import { IIssueLabels } from "types"; import { IIssueLabels } from "types";
// fetch-keys // fetch-keys
@ -146,10 +144,10 @@ export const CreateUpdateLabelInline = forwardRef<HTMLDivElement, Props>(
open ? "text-custom-text-100" : "text-custom-text-200" open ? "text-custom-text-100" : "text-custom-text-200"
}`} }`}
> >
<Component <span
className="h-4 w-4 text-custom-text-100 flex-shrink-0" className="h-4 w-4 rounded-full"
style={{ style={{
color: watch("color"), backgroundColor: watch("color"),
}} }}
/> />
</Popover.Button> </Popover.Button>

View File

@ -43,7 +43,7 @@ export const SingleLabel: React.FC<Props> = ({
> >
<CustomMenu.MenuItem onClick={() => addLabelToGroup(label)}> <CustomMenu.MenuItem onClick={() => addLabelToGroup(label)}>
<span className="flex items-center justify-start gap-2"> <span className="flex items-center justify-start gap-2">
<RectangleGroupIcon className="h-4 w-4" /> <Component className="h-4 w-4 leading-4 text-custom-sidebar-text-400 flex-shrink-0" />
<span>Convert to group</span> <span>Convert to group</span>
</span> </span>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>

View File

@ -204,7 +204,7 @@ export const ProfileIssuesView = () => {
); );
const handleIssueAction = useCallback( const handleIssueAction = useCallback(
(issue: IIssue, action: "copy" | "edit" | "delete") => { (issue: IIssue, action: "copy" | "edit" | "delete" | "updateDraft") => {
if (action === "copy") makeIssueCopy(issue); if (action === "copy") makeIssueCopy(issue);
else if (action === "edit") handleEditIssue(issue); else if (action === "edit") handleEditIssue(issue);
else if (action === "delete") handleDeleteIssue(issue); else if (action === "delete") handleDeleteIssue(issue);

View File

@ -79,7 +79,7 @@ const ConfirmProjectMemberRemove: React.FC<Props> = ({ isOpen, onClose, data, ha
</div> </div>
</div> </div>
</div> </div>
<div className="flex justify-end gap-2 bg-custom-background-90 p-4 sm:px-6"> <div className="flex justify-end gap-2 p-4 sm:px-6">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton> <SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
<DangerButton onClick={handleDeletion} loading={isDeleteLoading}> <DangerButton onClick={handleDeletion} loading={isDeleteLoading}>
{isDeleteLoading ? "Removing..." : "Remove"} {isDeleteLoading ? "Removing..." : "Remove"}

View File

@ -8,6 +8,8 @@ import useSWR from "swr";
import projectService from "services/project.service"; import projectService from "services/project.service";
// ui // ui
import { Avatar, CustomSearchSelect } from "components/ui"; import { Avatar, CustomSearchSelect } from "components/ui";
// icon
import { Ban } from "lucide-react";
// fetch-keys // fetch-keys
import { PROJECT_MEMBERS } from "constants/fetch-keys"; import { PROJECT_MEMBERS } from "constants/fetch-keys";
@ -49,7 +51,10 @@ export const MemberSelect: React.FC<Props> = ({ value, onChange }) => {
{selectedOption ? ( {selectedOption ? (
selectedOption?.display_name selectedOption?.display_name
) : ( ) : (
<span className="text-sm py-0.5 text-custom-text-200">Select</span> <div className="flex items-center gap-2">
<Ban className="h-3.5 w-3.5 text-custom-sidebar-text-400 rotate-90" />
<span className="text-sm py-0.5 text-custom-sidebar-text-400">None</span>
</div>
)} )}
</div> </div>
} }
@ -61,7 +66,12 @@ export const MemberSelect: React.FC<Props> = ({ value, onChange }) => {
{ {
value: "none", value: "none",
query: "none", query: "none",
content: <div className="flex items-center gap-2">None</div>, content: (
<div className="flex items-center gap-2">
<Ban className="h-3.5 w-3.5 text-custom-sidebar-text-400 rotate-90" />
<span className="text-sm py-0.5 text-custom-sidebar-text-400">None</span>
</div>
),
}, },
] ]
} }

View File

@ -219,7 +219,9 @@ const SendProjectInvitationModal: React.FC<Props> = (props) => {
} }
</div> </div>
) : ( ) : (
<div>Select co-worker</div> <div className="flex items-center gap-2 py-0.5">
Select co-worker
</div>
)} )}
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" /> <ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</button> </button>
@ -249,10 +251,13 @@ const SendProjectInvitationModal: React.FC<Props> = (props) => {
render={({ field }) => ( render={({ field }) => (
<CustomSelect <CustomSelect
{...field} {...field}
label={ customButton={
<span className="capitalize"> <button className="flex w-full items-center justify-between gap-1 rounded-md border border-custom-border-200 shadow-sm duration-300 text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-80 focus:outline-none px-3 py-2.5 text-sm text-left">
{field.value ? ROLE[field.value] : "Select role"} <span className="capitalize">
</span> {field.value ? ROLE[field.value] : "Select role"}
</span>
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</button>
} }
input input
width="w-full" width="w-full"

View File

@ -43,30 +43,107 @@ export const SettingsSidebar = () => {
href: `/${workspaceSlug}/projects/${projectId}/settings/automations`, href: `/${workspaceSlug}/projects/${projectId}/settings/automations`,
}, },
]; ];
const workspaceLinks: Array<{
label: string;
href: string;
}> = [
{
label: "General",
href: `/${workspaceSlug}/settings`,
},
{
label: "Members",
href: `/${workspaceSlug}/settings/members`,
},
{
label: "Billing & Plans",
href: `/${workspaceSlug}/settings/billing`,
},
{
label: "Integrations",
href: `/${workspaceSlug}/settings/integrations`,
},
{
label: "Imports",
href: `/${workspaceSlug}/settings/imports`,
},
{
label: "Exports",
href: `/${workspaceSlug}/settings/exports`,
},
];
const profileLinks: Array<{
label: string;
href: string;
}> = [
{
label: "Profile",
href: `/${workspaceSlug}/me/profile`,
},
{
label: "Activity",
href: `/${workspaceSlug}/me/profile/activity`,
},
{
label: "Preferences",
href: `/${workspaceSlug}/me/profile/preferences`,
},
];
return ( return (
<div className="flex flex-col gap-2 w-80 px-9"> <div className="flex flex-col gap-6 w-80 px-5">
<span className="text-xs text-custom-sidebar-text-400 font-semibold">SETTINGS</span> <div className="flex flex-col gap-2">
<div className="flex flex-col gap-1 w-full"> <span className="text-xs text-custom-sidebar-text-400 font-semibold">SETTINGS</span>
{projectLinks.map((link) => ( <div className="flex flex-col gap-1 w-full">
<Link key={link.href} href={link.href}> {(projectId ? projectLinks : workspaceLinks).map((link) => (
<a> <Link key={link.href} href={link.href}>
<div <a>
className={`px-4 py-2 text-sm font-medium rounded-md ${ <div
( className={`px-4 py-2 text-sm font-medium rounded-md ${
link.label === "Import" (
? router.asPath.includes(link.href) link.label === "Import"
: router.asPath === link.href ? router.asPath.includes(link.href)
) : router.asPath === link.href
? "bg-custom-primary-100/10 text-custom-primary-100" )
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80" ? "bg-custom-primary-100/10 text-custom-primary-100"
}`} : "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
> }`}
{link.label} >
</div> {link.label}
</a> </div>
</Link> </a>
))} </Link>
))}
</div>
</div> </div>
{!projectId && (
<div className="flex flex-col gap-2">
<span className="text-xs text-custom-sidebar-text-400 font-semibold">My Account</span>
<div className="flex flex-col gap-1 w-full">
{profileLinks.map((link) => (
<Link key={link.href} href={link.href}>
<a>
<div
className={`px-4 py-2 text-sm font-medium rounded-md ${
(
link.label === "Import"
? router.asPath.includes(link.href)
: router.asPath === link.href
)
? "bg-custom-primary-100/10 text-custom-primary-100"
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
}`}
>
{link.label}
</div>
</a>
</Link>
))}
</div>
</div>
)}
</div> </div>
); );
}; };

View File

@ -25,6 +25,7 @@ import {
PhotoFilterOutlined, PhotoFilterOutlined,
SettingsOutlined, SettingsOutlined,
} from "@mui/icons-material"; } from "@mui/icons-material";
import { PenSquare } from "lucide-react";
// helpers // helpers
import { renderEmoji } from "helpers/emoji.helper"; import { renderEmoji } from "helpers/emoji.helper";
// types // types
@ -288,6 +289,16 @@ export const SingleSidebarProject: React.FC<Props> = observer((props) => {
</div> </div>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
)} )}
<CustomMenu.MenuItem
onClick={() =>
router.push(`/${workspaceSlug}/projects/${project?.id}/draft-issues`)
}
>
<div className="flex items-center justify-start gap-2">
<PenSquare className="!text-base !leading-4 w-[14px] h-[14px] text-custom-text-300" />
<span>Draft Issues</span>
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem <CustomMenu.MenuItem
onClick={() => router.push(`/${workspaceSlug}/projects/${project?.id}/settings`)} onClick={() => router.push(`/${workspaceSlug}/projects/${project?.id}/settings`)}
> >

View File

@ -184,19 +184,20 @@ export const SingleState: React.FC<Props> = ({
<ArrowDownIcon className="h-4 w-4" /> <ArrowDownIcon className="h-4 w-4" />
</button> </button>
)} )}
{state.default ? (
<span className="text-xs text-custom-text-200">Default</span>
) : (
<button
type="button"
className="hidden text-xs text-custom-sidebar-text-400 group-hover:inline-block"
onClick={handleMakeDefault}
disabled={isSubmitting}
>
Mark as default
</button>
)}
<div className=" items-center gap-2.5 hidden group-hover:flex"> <div className=" items-center gap-2.5 hidden group-hover:flex">
{state.default ? (
<span className="text-xs text-custom-text-200">Default</span>
) : (
<button
type="button"
className="hidden text-xs text-custom-sidebar-text-400 group-hover:inline-block"
onClick={handleMakeDefault}
disabled={isSubmitting}
>
Mark as default
</button>
)}
<button <button
type="button" type="button"
className="grid place-items-center group-hover:opacity-100 opacity-0" className="grid place-items-center group-hover:opacity-100 opacity-0"
@ -215,14 +216,26 @@ export const SingleState: React.FC<Props> = ({
> >
{state.default ? ( {state.default ? (
<Tooltip tooltipContent="Cannot delete the default state."> <Tooltip tooltipContent="Cannot delete the default state.">
<X className="h-3.5 w-3.5 text-red-500" /> <X
className={`h-4 w-4 ${
groupLength < 1 ? "text-custom-sidebar-text-400" : "text-red-500"
}`}
/>
</Tooltip> </Tooltip>
) : groupLength === 1 ? ( ) : groupLength === 1 ? (
<Tooltip tooltipContent="Cannot have an empty group."> <Tooltip tooltipContent="Cannot have an empty group.">
<X className="h-3.5 w-3.5 text-red-500" /> <X
className={`h-4 w-4 ${
groupLength < 1 ? "text-custom-sidebar-text-400" : "text-red-500"
}`}
/>
</Tooltip> </Tooltip>
) : ( ) : (
<X className="h-3.5 w-3.5 text-red-500" /> <X
className={`h-4 w-4 ${
groupLength < 1 ? "text-custom-sidebar-text-400" : "text-red-500"
}`}
/>
)} )}
</button> </button>
</div> </div>

View File

@ -1,7 +1,7 @@
export type ButtonProps = { export type ButtonProps = {
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
onClick?: () => void; onClick?: (e: any) => void;
type?: "button" | "submit" | "reset"; type?: "button" | "submit" | "reset";
disabled?: boolean; disabled?: boolean;
loading?: boolean; loading?: boolean;

View File

@ -6,7 +6,7 @@ type Props = {
}; };
export const IntegrationAndImportExportBanner: React.FC<Props> = ({ bannerName, description }) => ( export const IntegrationAndImportExportBanner: React.FC<Props> = ({ bannerName, description }) => (
<div className="flex flex-col items-start gap-3 py-3.5 border-b border-custom-border-200"> <div className="flex items-start gap-3 py-3.5 border-b border-custom-border-200">
<h3 className="text-xl font-medium">{bannerName}</h3> <h3 className="text-xl font-medium">{bannerName}</h3>
{description && ( {description && (
<div className="flex items-center gap-3 rounded-[10px] border border-custom-primary/75 bg-custom-primary/5 p-4 text-sm text-custom-text-100"> <div className="flex items-center gap-3 rounded-[10px] border border-custom-primary/75 bg-custom-primary/5 p-4 text-sm text-custom-text-100">

View File

@ -1,9 +1,10 @@
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// icons // icons
import { CopyPlus } from "lucide-react";
import { Icon, Tooltip } from "components/ui"; import { Icon, Tooltip } from "components/ui";
import { Squares2X2Icon } from "@heroicons/react/24/outline"; import { Squares2X2Icon } from "@heroicons/react/24/outline";
import { BlockedIcon, BlockerIcon } from "components/icons"; import { BlockedIcon, BlockerIcon, RelatedIcon } from "components/icons";
// helpers // helpers
import { renderShortDateWithYearFormat } from "helpers/date-time.helper"; import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
import { capitalizeFirstLetter } from "helpers/string.helper"; import { capitalizeFirstLetter } from "helpers/string.helper";
@ -73,7 +74,7 @@ const activityDetails: {
. .
</> </>
), ),
icon: <Icon iconName="group" className="!text-sm" aria-hidden="true" />, icon: <Icon iconName="group" className="!text-2xl" aria-hidden="true" />,
}, },
archived_at: { archived_at: {
@ -81,7 +82,7 @@ const activityDetails: {
if (activity.new_value === "restore") return "restored the issue."; if (activity.new_value === "restore") return "restored the issue.";
else return "archived the issue."; else return "archived the issue.";
}, },
icon: <Icon iconName="archive" className="!text-sm" aria-hidden="true" />, icon: <Icon iconName="archive" className="!text-2xl" aria-hidden="true" />,
}, },
attachment: { attachment: {
@ -99,7 +100,7 @@ const activityDetails: {
{showIssue && <IssueLink activity={activity} />} {showIssue && <IssueLink activity={activity} />}
</> </>
), ),
icon: <Icon iconName="attach_file" className="!text-sm" aria-hidden="true" />, icon: <Icon iconName="attach_file" className="!text-2xl" aria-hidden="true" />,
}, },
blocking: { blocking: {
@ -117,7 +118,7 @@ const activityDetails: {
icon: <BlockerIcon height="12" width="12" color="#6b7280" />, icon: <BlockerIcon height="12" width="12" color="#6b7280" />,
}, },
blocks: { blocked_by: {
message: (activity) => ( message: (activity) => (
<> <>
{activity.old_value === "" {activity.old_value === ""
@ -132,6 +133,36 @@ const activityDetails: {
icon: <BlockedIcon height="12" width="12" color="#6b7280" />, icon: <BlockedIcon height="12" width="12" color="#6b7280" />,
}, },
duplicate: {
message: (activity) => (
<>
{activity.old_value === ""
? "marked this issue as duplicate of "
: "removed this issue as a duplicate of "}
<span className="font-medium text-custom-text-100">
{activity.verb === "created" ? activity.new_value : activity.old_value}
</span>
.
</>
),
icon: <CopyPlus size={12} color="#6b7280" />,
},
relates_to: {
message: (activity) => (
<>
{activity.old_value === ""
? "marked that this issue relates to "
: "removed the relation from "}
<span className="font-medium text-custom-text-100">
{activity.old_value === "" ? activity.new_value : activity.old_value}
</span>
.
</>
),
icon: <RelatedIcon height="12" width="12" color="#6b7280" />,
},
cycles: { cycles: {
message: (activity) => ( message: (activity) => (
<> <>
@ -156,7 +187,7 @@ const activityDetails: {
</button> </button>
</> </>
), ),
icon: <Icon iconName="contrast" className="!text-sm" aria-hidden="true" />, icon: <Icon iconName="contrast" className="!text-2xl" aria-hidden="true" />,
}, },
description: { description: {
@ -172,7 +203,7 @@ const activityDetails: {
. .
</> </>
), ),
icon: <Icon iconName="chat" className="!text-sm" aria-hidden="true" />, icon: <Icon iconName="chat" className="!text-2xl" aria-hidden="true" />,
}, },
estimate_point: { estimate_point: {
@ -190,7 +221,7 @@ const activityDetails: {
)} )}
</> </>
), ),
icon: <Icon iconName="change_history" className="!text-sm" aria-hidden="true" />, icon: <Icon iconName="change_history" className="!text-2xl" aria-hidden="true" />,
}, },
issue: { issue: {
@ -198,7 +229,7 @@ const activityDetails: {
if (activity.verb === "created") return "created the issue."; if (activity.verb === "created") return "created the issue.";
else return "deleted an issue."; else return "deleted an issue.";
}, },
icon: <Icon iconName="stack" className="!text-sm" aria-hidden="true" />, icon: <Icon iconName="stack" className="!text-2xl" aria-hidden="true" />,
}, },
labels: { labels: {
@ -225,7 +256,7 @@ const activityDetails: {
)} )}
</> </>
), ),
icon: <Icon iconName="sell" className="!text-sm" aria-hidden="true" />, icon: <Icon iconName="sell" className="!text-2xl" aria-hidden="true" />,
}, },
link: { link: {
@ -255,7 +286,7 @@ const activityDetails: {
. .
</> </>
), ),
icon: <Icon iconName="link" className="!text-sm" aria-hidden="true" />, icon: <Icon iconName="link" className="!text-2xl" aria-hidden="true" />,
}, },
modules: { modules: {
@ -279,7 +310,7 @@ const activityDetails: {
. .
</> </>
), ),
icon: <Icon iconName="dataset" className="!text-sm" aria-hidden="true" />, icon: <Icon iconName="dataset" className="!text-2xl" aria-hidden="true" />,
}, },
name: { name: {
@ -295,7 +326,7 @@ const activityDetails: {
. .
</> </>
), ),
icon: <Icon iconName="chat" className="!text-sm" aria-hidden="true" />, icon: <Icon iconName="chat" className="!text-2xl" aria-hidden="true" />,
}, },
parent: { parent: {
@ -314,7 +345,7 @@ const activityDetails: {
. .
</> </>
), ),
icon: <Icon iconName="supervised_user_circle" className="!text-sm" aria-hidden="true" />, icon: <Icon iconName="supervised_user_circle" className="!text-2xl" aria-hidden="true" />,
}, },
priority: { priority: {
@ -333,7 +364,7 @@ const activityDetails: {
. .
</> </>
), ),
icon: <Icon iconName="signal_cellular_alt" className="!text-sm" aria-hidden="true" />, icon: <Icon iconName="signal_cellular_alt" className="!text-2xl" aria-hidden="true" />,
}, },
start_date: { start_date: {
@ -351,7 +382,7 @@ const activityDetails: {
)} )}
</> </>
), ),
icon: <Icon iconName="calendar_today" className="!text-sm" aria-hidden="true" />, icon: <Icon iconName="calendar_today" className="!text-2xl" aria-hidden="true" />,
}, },
state: { state: {
@ -389,7 +420,7 @@ const activityDetails: {
)} )}
</> </>
), ),
icon: <Icon iconName="calendar_today" className="!text-sm" aria-hidden="true" />, icon: <Icon iconName="calendar_today" className="!text-2xl" aria-hidden="true" />,
}, },
}; };

View File

@ -351,25 +351,23 @@ export const IssuePropertiesDetail: React.FC<Props> = (props) => {
{blockedIssue && {blockedIssue &&
blockedIssue.map((issue) => ( blockedIssue.map((issue) => (
<div <div
key={issue.related_issue_detail?.id} key={issue.issue_detail?.id}
className="group inline-flex mr-1 cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-red-500 duration-300 hover:border-red-500/20 hover:bg-red-500/20" className="group inline-flex mr-1 cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-red-500 duration-300 hover:border-red-500/20 hover:bg-red-500/20"
> >
<a <a
href={`/${workspaceSlug}/projects/${issue.related_issue_detail?.project_detail.id}/issues/${issue.related_issue_detail?.id}`} href={`/${workspaceSlug}/projects/${issue.issue_detail?.project_detail.id}/issues/${issue.issue_detail?.id}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-1" className="flex items-center gap-1"
> >
<BlockedIcon height={10} width={10} /> <BlockedIcon height={10} width={10} />
{`${issue?.related_issue_detail?.project_detail?.identifier}-${issue?.related_issue_detail?.sequence_id}`} {`${issue?.issue_detail?.project_detail?.identifier}-${issue?.issue_detail?.sequence_id}`}
</a> </a>
<button <button
type="button" type="button"
className="duration-300" className="duration-300"
onClick={() => { onClick={() => {
const updatedBlocked = blockedIssue.filter( const updatedBlocked = blockedIssue.filter((i) => i?.id !== issue?.id);
(i) => i.related_issue_detail?.id !== issue.related_issue_detail?.id
);
if (!user) return; if (!user) return;

View File

@ -79,7 +79,7 @@ const ConfirmWorkspaceMemberRemove: React.FC<Props> = ({ isOpen, onClose, data,
</div> </div>
</div> </div>
</div> </div>
<div className="flex justify-end gap-2 bg-custom-background-90 p-4 sm:px-6"> <div className="flex justify-end gap-2 p-4 sm:px-6">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton> <SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
<DangerButton onClick={handleDeletion} loading={isDeleteLoading}> <DangerButton onClick={handleDeletion} loading={isDeleteLoading}>
{isDeleteLoading ? "Removing..." : "Remove"} {isDeleteLoading ? "Removing..." : "Remove"}

View File

@ -6,7 +6,6 @@ export * from "./help-section";
export * from "./issues-list"; export * from "./issues-list";
export * from "./issues-pie-chart"; export * from "./issues-pie-chart";
export * from "./issues-stats"; export * from "./issues-stats";
export * from "./settings-header";
export * from "./sidebar-dropdown"; export * from "./sidebar-dropdown";
export * from "./sidebar-menu"; export * from "./sidebar-menu";
export * from "./sidebar-quick-action"; export * from "./sidebar-quick-action";

View File

@ -1,13 +0,0 @@
import SettingsNavbar from "layouts/settings-navbar";
export const SettingsHeader = () => (
<div className="mb-8 space-y-6">
<div>
<h3 className="text-2xl font-semibold">Workspace Settings</h3>
<p className="mt-1 text-sm text-custom-text-200">
This information will be displayed to every member of the workspace.
</p>
</div>
<SettingsNavbar />
</div>
);

View File

@ -1,47 +1,137 @@
import React from "react"; import React, { useState } from "react";
// ui // ui
import { Icon } from "components/ui"; import { Icon } from "components/ui";
import { ChevronDown, PenSquare } from "lucide-react";
// headless ui
import { Menu, Transition } from "@headlessui/react";
// hooks
import useLocalStorage from "hooks/use-local-storage";
// components
import { CreateUpdateDraftIssueModal } from "components/issues";
// mobx store // mobx store
import { useMobxStore } from "lib/mobx/store-provider"; import { useMobxStore } from "lib/mobx/store-provider";
export const WorkspaceSidebarQuickAction = () => { export const WorkspaceSidebarQuickAction = () => {
const store: any = useMobxStore(); const store: any = useMobxStore();
return ( const [isDraftIssueModalOpen, setIsDraftIssueModalOpen] = useState(false);
<div
className={`flex items-center justify-between w-full cursor-pointer px-4 mt-4 ${
store?.theme?.sidebarCollapsed ? "flex-col gap-1" : "gap-2"
}`}
>
<button
className={`flex items-center gap-2 flex-grow rounded flex-shrink-0 py-1.5 ${
store?.theme?.sidebarCollapsed
? "px-2 hover:bg-custom-sidebar-background-80"
: "px-3 shadow border-[0.5px] border-custom-border-300"
}`}
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "c" });
document.dispatchEvent(e);
}}
>
<Icon iconName="edit_square" className="!text-lg !leading-4 text-custom-sidebar-text-300" />
{!store?.theme?.sidebarCollapsed && <span className="text-sm font-medium">New Issue</span>}
</button>
<button const { storedValue, clearValue } = useLocalStorage<any>(
className={`flex items-center justify-center rounded flex-shrink-0 p-2 ${ "draftedIssue",
store?.theme?.sidebarCollapsed JSON.stringify(undefined)
? "hover:bg-custom-sidebar-background-80" );
: "shadow border-[0.5px] border-custom-border-300"
}`} return (
onClick={() => { <>
const e = new KeyboardEvent("keydown", { key: "k", ctrlKey: true, metaKey: true }); <CreateUpdateDraftIssueModal
document.dispatchEvent(e); isOpen={isDraftIssueModalOpen}
handleClose={() => setIsDraftIssueModalOpen(false)}
prePopulateData={storedValue ? JSON.parse(storedValue) : {}}
onSubmit={() => {
localStorage.removeItem("draftedIssue");
clearValue();
setIsDraftIssueModalOpen(false);
}} }}
fieldsToShow={["all"]}
/>
<div
className={`relative flex items-center justify-between w-full cursor-pointer px-4 mt-4 ${
store?.theme?.sidebarCollapsed ? "flex-col gap-1" : "gap-2"
}`}
> >
<Icon iconName="search" className="!text-lg !leading-4 text-custom-sidebar-text-300" /> <div
</button> className={`flex items-center justify-between w-full rounded cursor-pointer px-2 gap-1 group ${
</div> store?.theme?.sidebarCollapsed
? "px-2 hover:bg-custom-sidebar-background-80"
: "px-3 shadow border-[0.5px] border-custom-border-300"
}`}
>
<button
type="button"
className="flex items-center gap-2 flex-grow rounded flex-shrink-0 py-1.5"
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "c" });
document.dispatchEvent(e);
}}
>
<Icon
iconName="edit_square"
className="!text-lg !leading-4 text-custom-sidebar-text-300"
/>
{!store?.theme?.sidebarCollapsed && (
<span className="text-sm font-medium">New Issue</span>
)}
</button>
{storedValue && <div className="h-8 w-0.5 bg-custom-sidebar-background-80" />}
{storedValue && (
<div className="relative">
<Menu as={React.Fragment}>
{({ open }) => (
<>
<div>
<Menu.Button
type="button"
className={`flex items-center justify-center rounded flex-shrink-0 p-1.5 ${
open ? "rotate-180 pl-0" : "rotate-0 pr-0"
}`}
>
<ChevronDown
size={16}
className="!text-custom-sidebar-text-300 transform transition-transform duration-300"
/>
</Menu.Button>
</div>
<Transition
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"
>
<Menu.Items className="absolute -right-4 mt-1 w-52 bg-custom-background-300">
<div className="px-1 py-1 ">
<Menu.Item>
<button
onClick={() => setIsDraftIssueModalOpen(true)}
className="w-full flex text-sm items-center rounded flex-shrink-0 py-[10px] px-3 bg-custom-background-100 shadow border-[0.5px] border-custom-border-300 text-custom-text-300"
>
<PenSquare
size={16}
className="!text-lg !leading-4 text-custom-sidebar-text-300 mr-2"
/>
Last Drafted Issue
</button>
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</>
)}
</Menu>
</div>
)}
</div>
<button
className={`flex items-center justify-center rounded flex-shrink-0 p-2 ${
store?.theme?.sidebarCollapsed
? "hover:bg-custom-sidebar-background-80"
: "shadow border-[0.5px] border-custom-border-300"
}`}
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "k", ctrlKey: true, metaKey: true });
document.dispatchEvent(e);
}}
>
<Icon iconName="search" className="!text-lg !leading-4 text-custom-sidebar-text-300" />
</button>
</div>
</>
); );
}; };

View File

@ -140,6 +140,15 @@ export const PROJECT_ARCHIVED_ISSUES_LIST_WITH_PARAMS = (projectId: string, para
return `PROJECT_ARCHIVED_ISSUES_LIST_WITH_PARAMS_${projectId.toUpperCase()}_${paramsKey}`; return `PROJECT_ARCHIVED_ISSUES_LIST_WITH_PARAMS_${projectId.toUpperCase()}_${paramsKey}`;
}; };
export const PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS = (projectId: string, params?: any) => {
if (!params) return `PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS${projectId.toUpperCase()}`;
const paramsKey = paramsToKey(params);
return `PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS${projectId.toUpperCase()}_${paramsKey}`;
};
export const PROJECT_ISSUES_DETAILS = (issueId: string) => export const PROJECT_ISSUES_DETAILS = (issueId: string) =>
`PROJECT_ISSUES_DETAILS_${issueId.toUpperCase()}`; `PROJECT_ISSUES_DETAILS_${issueId.toUpperCase()}`;
export const PROJECT_ISSUES_PROPERTIES = (projectId: string) => export const PROJECT_ISSUES_PROPERTIES = (projectId: string) =>

View File

@ -20,6 +20,7 @@ import {
CYCLE_ISSUES_WITH_PARAMS, CYCLE_ISSUES_WITH_PARAMS,
MODULE_ISSUES_WITH_PARAMS, MODULE_ISSUES_WITH_PARAMS,
PROJECT_ARCHIVED_ISSUES_LIST_WITH_PARAMS, PROJECT_ARCHIVED_ISSUES_LIST_WITH_PARAMS,
PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS,
PROJECT_ISSUES_LIST_WITH_PARAMS, PROJECT_ISSUES_LIST_WITH_PARAMS,
STATES_LIST, STATES_LIST,
VIEW_ISSUES, VIEW_ISSUES,
@ -38,6 +39,7 @@ const useIssuesView = () => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId, viewId, archivedIssueId } = router.query; const { workspaceSlug, projectId, cycleId, moduleId, viewId, archivedIssueId } = router.query;
const isArchivedIssues = router.pathname.includes("archived-issues"); const isArchivedIssues = router.pathname.includes("archived-issues");
const isDraftIssues = router.pathname.includes("draft-issues");
const params: any = { const params: any = {
order_by: displayFilters?.order_by, order_by: displayFilters?.order_by,
@ -72,6 +74,15 @@ const useIssuesView = () => {
: null : null
); );
const { data: draftIssues, mutate: mutateDraftIssues } = useSWR(
workspaceSlug && projectId && params && isDraftIssues && !archivedIssueId
? PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS(projectId as string, params)
: null,
workspaceSlug && projectId && params && isDraftIssues && !archivedIssueId
? () => issuesService.getDraftIssues(workspaceSlug as string, projectId as string, params)
: null
);
const { data: cycleIssues, mutate: mutateCycleIssues } = useSWR( const { data: cycleIssues, mutate: mutateCycleIssues } = useSWR(
workspaceSlug && projectId && cycleId && params workspaceSlug && projectId && cycleId && params
? CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params) ? CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params)
@ -151,6 +162,8 @@ const useIssuesView = () => {
? viewIssues ? viewIssues
: isArchivedIssues : isArchivedIssues
? projectArchivedIssues ? projectArchivedIssues
: isDraftIssues
? draftIssues
: projectIssues; : projectIssues;
if (Array.isArray(issuesToGroup)) return { allIssues: issuesToGroup }; if (Array.isArray(issuesToGroup)) return { allIssues: issuesToGroup };
@ -169,6 +182,8 @@ const useIssuesView = () => {
moduleId, moduleId,
viewId, viewId,
isArchivedIssues, isArchivedIssues,
isDraftIssues,
draftIssues,
emptyStatesObject, emptyStatesObject,
]); ]);
@ -191,6 +206,8 @@ const useIssuesView = () => {
? mutateViewIssues ? mutateViewIssues
: isArchivedIssues : isArchivedIssues
? mutateProjectArchivedIssues ? mutateProjectArchivedIssues
: isDraftIssues
? mutateDraftIssues
: mutateProjectIssues, : mutateProjectIssues,
filters, filters,
setFilters, setFilters,

View File

@ -1,127 +0,0 @@
import Link from "next/link";
import { useRouter } from "next/router";
type Props = {
profilePage?: boolean;
};
const SettingsNavbar: React.FC<Props> = ({ profilePage = false }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const workspaceLinks: Array<{
label: string;
href: string;
}> = [
{
label: "General",
href: `/${workspaceSlug}/settings`,
},
{
label: "Members",
href: `/${workspaceSlug}/settings/members`,
},
{
label: "Billing & Plans",
href: `/${workspaceSlug}/settings/billing`,
},
{
label: "Integrations",
href: `/${workspaceSlug}/settings/integrations`,
},
{
label: "Imports",
href: `/${workspaceSlug}/settings/imports`,
},
{
label: "Exports",
href: `/${workspaceSlug}/settings/exports`,
},
];
const projectLinks: Array<{
label: string;
href: string;
}> = [
{
label: "General",
href: `/${workspaceSlug}/projects/${projectId}/settings`,
},
{
label: "Control",
href: `/${workspaceSlug}/projects/${projectId}/settings/control`,
},
{
label: "Members",
href: `/${workspaceSlug}/projects/${projectId}/settings/members`,
},
{
label: "Features",
href: `/${workspaceSlug}/projects/${projectId}/settings/features`,
},
{
label: "States",
href: `/${workspaceSlug}/projects/${projectId}/settings/states`,
},
{
label: "Labels",
href: `/${workspaceSlug}/projects/${projectId}/settings/labels`,
},
{
label: "Integrations",
href: `/${workspaceSlug}/projects/${projectId}/settings/integrations`,
},
{
label: "Estimates",
href: `/${workspaceSlug}/projects/${projectId}/settings/estimates`,
},
{
label: "Automations",
href: `/${workspaceSlug}/projects/${projectId}/settings/automations`,
},
];
const profileLinks: Array<{
label: string;
href: string;
}> = [
{
label: "General",
href: `/${workspaceSlug}/me/profile`,
},
{
label: "Activity",
href: `/${workspaceSlug}/me/profile/activity`,
},
{
label: "Preferences",
href: `/${workspaceSlug}/me/profile/preferences`,
},
];
return (
<div className="flex flex-wrap gap-4">
{(profilePage ? profileLinks : projectId ? projectLinks : workspaceLinks).map((link) => (
<Link key={link.href} href={link.href}>
<a>
<div
className={`rounded-full border px-5 py-1.5 text-sm outline-none ${
(
link.label === "Import"
? router.asPath.includes(link.href)
: router.asPath === link.href
)
? "border-custom-primary bg-custom-primary text-white"
: "border-custom-border-200 bg-custom-background-100 hover:bg-custom-background-90"
}`}
>
{link.label}
</div>
</a>
</Link>
))}
</div>
);
};
export default SettingsNavbar;

View File

@ -7,7 +7,6 @@ import Link from "next/link";
import userService from "services/user.service"; import userService from "services/user.service";
// layouts // layouts
import { WorkspaceAuthorizationLayout } from "layouts/auth-layout"; import { WorkspaceAuthorizationLayout } from "layouts/auth-layout";
import SettingsNavbar from "layouts/settings-navbar";
// components // components
import { ActivityIcon, ActivityMessage } from "components/core"; import { ActivityIcon, ActivityMessage } from "components/core";
import { TipTapEditor } from "components/tiptap"; import { TipTapEditor } from "components/tiptap";
@ -20,6 +19,7 @@ import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
import { USER_ACTIVITY } from "constants/fetch-keys"; import { USER_ACTIVITY } from "constants/fetch-keys";
// helper // helper
import { timeAgo } from "helpers/date-time.helper"; import { timeAgo } from "helpers/date-time.helper";
import { SettingsSidebar } from "components/project";
const ProfileActivity = () => { const ProfileActivity = () => {
const router = useRouter(); const router = useRouter();
@ -38,186 +38,179 @@ const ProfileActivity = () => {
</Breadcrumbs> </Breadcrumbs>
} }
> >
<div className="p-8"> <div className="flex flex-row gap-2 h-full">
<div className="mb-8 space-y-6"> <div className="w-80 pt-8 overflow-y-hidden flex-shrink-0">
<div> <SettingsSidebar />
<h3 className="text-3xl font-semibold">Profile Settings</h3>
<p className="mt-1 text-custom-text-200">
This information will be visible to only you.
</p>
</div>
<SettingsNavbar profilePage />
</div> </div>
{userActivity ? (
<div>
<ul role="list" className="-mb-4">
{userActivity.results.map((activityItem: any, activityIdx: number) => {
if (activityItem.field === "comment") {
return (
<div key={activityItem.id} className="mt-2">
<div className="relative flex items-start space-x-3">
<div className="relative px-1">
{activityItem.field ? (
activityItem.new_value === "restore" && (
<Icon iconName="history" className="text-sm text-custom-text-200" />
)
) : activityItem.actor_detail.avatar &&
activityItem.actor_detail.avatar !== "" ? (
<img
src={activityItem.actor_detail.avatar}
alt={activityItem.actor_detail.display_name}
height={30}
width={30}
className="grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white"
/>
) : (
<div
className={`grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white`}
>
{activityItem.actor_detail.display_name?.charAt(0)}
</div>
)}
<span className="ring-6 flex h-7 w-7 items-center justify-center rounded-full bg-custom-background-80 text-custom-text-200 ring-white"> {userActivity ? (
<ChatBubbleLeftEllipsisIcon <section className="pr-9 py-8 w-full overflow-y-auto">
className="h-3.5 w-3.5 text-custom-text-200" <div className="flex items-center py-3.5 border-b border-custom-border-200">
aria-hidden="true" <h3 className="text-xl font-medium">Acitivity</h3>
/> </div>
</span> <div className={`flex flex-col gap-2 py-4 w-full`}>
</div> <ul role="list" className="-mb-4">
<div className="min-w-0 flex-1"> {userActivity.results.map((activityItem: any, activityIdx: number) => {
<div> if (activityItem.field === "comment") {
<div className="text-xs"> return (
{activityItem.actor_detail.is_bot <div key={activityItem.id} className="mt-2">
? activityItem.actor_detail.first_name + " Bot" <div className="relative flex items-start space-x-3">
: activityItem.actor_detail.display_name} <div className="relative px-1">
</div> {activityItem.field ? (
<p className="mt-0.5 text-xs text-custom-text-200"> activityItem.new_value === "restore" && (
Commented {timeAgo(activityItem.created_at)} <Icon iconName="history" className="text-sm text-custom-text-200" />
</p> )
) : activityItem.actor_detail.avatar &&
activityItem.actor_detail.avatar !== "" ? (
<img
src={activityItem.actor_detail.avatar}
alt={activityItem.actor_detail.display_name}
height={30}
width={30}
className="grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white"
/>
) : (
<div
className={`grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white`}
>
{activityItem.actor_detail.display_name?.charAt(0)}
</div>
)}
<span className="ring-6 flex h-6 w-6 items-center justify-center rounded-full bg-custom-background-80 text-custom-text-200 ring-white">
<ChatBubbleLeftEllipsisIcon
className="h-6 w-6 !text-2xl text-custom-text-200"
aria-hidden="true"
/>
</span>
</div> </div>
<div className="issue-comments-section p-0"> <div className="min-w-0 flex-1">
<TipTapEditor <div>
workspaceSlug={workspaceSlug as string} <div className="text-xs">
value={ {activityItem.actor_detail.is_bot
activityItem?.new_value !== "" ? activityItem.actor_detail.first_name + " Bot"
? activityItem.new_value : activityItem.actor_detail.display_name}
: activityItem.old_value </div>
} <p className="mt-0.5 text-xs text-custom-text-200">
customClassName="text-xs border border-custom-border-200 bg-custom-background-100" Commented {timeAgo(activityItem.created_at)}
noBorder </p>
borderOnFocus={false} </div>
editable={false} <div className="issue-comments-section p-0">
/> <TipTapEditor
workspaceSlug={workspaceSlug as string}
value={
activityItem?.new_value !== ""
? activityItem.new_value
: activityItem.old_value
}
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
noBorder
borderOnFocus={false}
editable={false}
/>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> );
); }
}
const message = const message =
activityItem.verb === "created" && activityItem.verb === "created" &&
activityItem.field !== "cycles" && activityItem.field !== "cycles" &&
activityItem.field !== "modules" && activityItem.field !== "modules" &&
activityItem.field !== "attachment" && activityItem.field !== "attachment" &&
activityItem.field !== "link" && activityItem.field !== "link" &&
activityItem.field !== "estimate" ? ( activityItem.field !== "estimate" ? (
<span className="text-custom-text-200"> <span className="text-custom-text-200">
created{" "} created{" "}
<Link <Link
href={`/${workspaceSlug}/projects/${activityItem.project}/issues/${activityItem.issue}`} href={`/${workspaceSlug}/projects/${activityItem.project}/issues/${activityItem.issue}`}
> >
<a className="inline-flex items-center hover:underline"> <a className="inline-flex items-center hover:underline">
this issue. <ArrowTopRightOnSquareIcon className="ml-1 h-3.5 w-3.5" /> this issue. <ArrowTopRightOnSquareIcon className="ml-1 h-3.5 w-3.5" />
</a> </a>
</Link> </Link>
</span> </span>
) : activityItem.field ? ( ) : activityItem.field ? (
<ActivityMessage activity={activityItem} showIssue /> <ActivityMessage activity={activityItem} showIssue />
) : ( ) : (
"created the issue." "created the issue."
); );
if ("field" in activityItem && activityItem.field !== "updated_by") { if ("field" in activityItem && activityItem.field !== "updated_by") {
return ( return (
<li key={activityItem.id}> <li key={activityItem.id}>
<div className="relative pb-1"> <div className="relative pb-1">
{userActivity.results.length > 1 && <div className="relative flex items-center space-x-2">
activityIdx !== userActivity.results.length - 1 ? ( <>
<span <div>
className="absolute top-5 left-5 -ml-px h-full w-0.5 bg-custom-background-80" <div className="relative px-1.5">
aria-hidden="true" <div className="mt-1.5">
/> <div className="flex h-6 w-6 items-center justify-center">
) : null} {activityItem.field ? (
<div className="relative flex items-start space-x-2"> activityItem.new_value === "restore" ? (
<> <Icon
<div> iconName="history"
<div className="relative px-1.5"> className="!text-2xl text-custom-text-200"
<div className="mt-1.5"> />
<div className="ring-6 flex h-7 w-7 items-center justify-center rounded-full bg-custom-background-80 text-custom-text-200 ring-white"> ) : (
{activityItem.field ? ( <ActivityIcon activity={activityItem} />
activityItem.new_value === "restore" ? ( )
<Icon ) : activityItem.actor_detail.avatar &&
iconName="history" activityItem.actor_detail.avatar !== "" ? (
className="text-sm text-custom-text-200" <img
src={activityItem.actor_detail.avatar}
alt={activityItem.actor_detail.display_name}
height={24}
width={24}
className="rounded-full"
/> />
) : ( ) : (
<ActivityIcon activity={activityItem} /> <div
) className={`grid h-6 w-6 place-items-center rounded-full border-2 border-white bg-gray-700 text-xs text-white`}
) : activityItem.actor_detail.avatar && >
activityItem.actor_detail.avatar !== "" ? ( {activityItem.actor_detail.display_name?.charAt(0)}
<img </div>
src={activityItem.actor_detail.avatar} )}
alt={activityItem.actor_detail.display_name} </div>
height={24}
width={24}
className="rounded-full"
/>
) : (
<div
className={`grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-700 text-xs text-white`}
>
{activityItem.actor_detail.display_name?.charAt(0)}
</div>
)}
</div> </div>
</div> </div>
</div> </div>
</div> <div className="min-w-0 flex-1 py-4 border-b border-custom-border-200">
<div className="min-w-0 flex-1 py-3"> <div className="text-sm text-custom-text-200 break-words">
<div className="text-xs text-custom-text-200 break-words"> {activityItem.field === "archived_at" &&
{activityItem.field === "archived_at" && activityItem.new_value !== "restore" ? (
activityItem.new_value !== "restore" ? ( <span className="text-gray font-medium">Plane</span>
<span className="text-gray font-medium">Plane</span> ) : activityItem.actor_detail.is_bot ? (
) : activityItem.actor_detail.is_bot ? ( <span className="text-gray font-medium">
<span className="text-gray font-medium"> {activityItem.actor_detail.first_name} Bot
{activityItem.actor_detail.first_name} Bot </span>
) : (
<Link
href={`/${workspaceSlug}/profile/${activityItem.actor_detail.id}`}
>
<a className="text-gray font-medium">
{activityItem.actor_detail.display_name}
</a>
</Link>
)}{" "}
{message}{" "}
<span className="whitespace-nowrap">
{timeAgo(activityItem.created_at)}
</span> </span>
) : ( </div>
<Link
href={`/${workspaceSlug}/profile/${activityItem.actor_detail.id}`}
>
<a className="text-gray font-medium">
{activityItem.actor_detail.display_name}
</a>
</Link>
)}{" "}
{message}{" "}
<span className="whitespace-nowrap">
{timeAgo(activityItem.created_at)}
</span>
</div> </div>
</div> </>
</> </div>
</div> </div>
</div> </li>
</li> );
); }
} })}
})} </ul>
</ul> </div>
</div> </section>
) : ( ) : (
<Loader className="space-y-5"> <Loader className="space-y-5">
<Loader.Item height="40px" /> <Loader.Item height="40px" />

View File

@ -1,4 +1,6 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useRouter } from "next/router";
import Link from "next/link";
// react-hook-form // react-hook-form
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
@ -10,21 +12,15 @@ import useUserAuth from "hooks/use-user-auth";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// layouts // layouts
import { WorkspaceAuthorizationLayout } from "layouts/auth-layout"; import { WorkspaceAuthorizationLayout } from "layouts/auth-layout";
import SettingsNavbar from "layouts/settings-navbar";
// components // components
import { ImagePickerPopover, ImageUploadModal } from "components/core"; import { ImagePickerPopover, ImageUploadModal } from "components/core";
import { SettingsSidebar } from "components/project";
// ui // ui
import { import { CustomSearchSelect, CustomSelect, Input, PrimaryButton, Spinner } from "components/ui";
CustomSearchSelect,
CustomSelect,
DangerButton,
Input,
SecondaryButton,
Spinner,
} from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// icons // icons
import { UserIcon } from "@heroicons/react/24/outline"; import { UserIcon } from "@heroicons/react/24/outline";
import { UserCircle } from "lucide-react";
// types // types
import type { NextPage } from "next"; import type { NextPage } from "next";
import type { IUser } from "types"; import type { IUser } from "types";
@ -46,6 +42,9 @@ const Profile: NextPage = () => {
const [isRemoving, setIsRemoving] = useState(false); const [isRemoving, setIsRemoving] = useState(false);
const [isImageUploadModalOpen, setIsImageUploadModalOpen] = useState(false); const [isImageUploadModalOpen, setIsImageUploadModalOpen] = useState(false);
const router = useRouter();
const { workspaceSlug } = router.query;
const { const {
register, register,
handleSubmit, handleSubmit,
@ -126,6 +125,7 @@ const Profile: NextPage = () => {
if (!prevData) return prevData; if (!prevData) return prevData;
return { ...prevData, avatar: "" }; return { ...prevData, avatar: "" };
}, false); }, false);
setIsRemoving(false);
}) })
.catch(() => { .catch(() => {
setToastAlert({ setToastAlert({
@ -155,6 +155,8 @@ const Profile: NextPage = () => {
<ImageUploadModal <ImageUploadModal
isOpen={isImageUploadModalOpen} isOpen={isImageUploadModalOpen}
onClose={() => setIsImageUploadModalOpen(false)} onClose={() => setIsImageUploadModalOpen(false)}
isRemoving={isRemoving}
handleDelete={() => handleDelete(myProfile?.avatar, true)}
onSuccess={(url) => { onSuccess={(url) => {
setValue("avatar", url); setValue("avatar", url);
handleSubmit(onSubmit)(); handleSubmit(onSubmit)();
@ -164,81 +166,49 @@ const Profile: NextPage = () => {
userImage userImage
/> />
{myProfile ? ( {myProfile ? (
<div className="p-8"> <form onSubmit={handleSubmit(onSubmit)}>
<div className="mb-8 space-y-6"> <div className="flex flex-row gap-2 h-full">
<div> <div className="w-80 pt-8 overflow-y-hidden flex-shrink-0">
<h3 className="text-3xl font-semibold">Profile Settings</h3> <SettingsSidebar />
<p className="mt-1 text-custom-text-200">
This information will be visible to only you.
</p>
</div> </div>
<SettingsNavbar profilePage /> <div className={`flex flex-col gap-8 pr-9 py-9 w-full overflow-y-auto`}>
</div> <div className="relative h-44 w-full mt-6">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8 sm:space-y-12"> <img
<div className="grid grid-cols-12 gap-4 sm:gap-16"> src={
<div className="col-span-12 sm:col-span-6"> watch("cover_image") ??
<h4 className="text-lg font-semibold text-custom-text-100">Profile Picture</h4> "https://images.unsplash.com/photo-1506383796573-caf02b4a79ab"
<p className="text-sm text-custom-text-200"> }
Max file size is 5MB. Supported file types are .jpg and .png. className="h-44 w-full rounded-lg object-cover"
</p> alt={myProfile?.name ?? "Cover image"}
</div> />
<div className="col-span-12 sm:col-span-6"> <div className="flex items-end justify-between absolute left-8 -bottom-6">
<div className="flex items-center gap-4"> <div className="flex gap-3">
<button type="button" onClick={() => setIsImageUploadModalOpen(true)}> <div className="flex items-center justify-center bg-custom-background-90 h-16 w-16 rounded-lg">
{!watch("avatar") || watch("avatar") === "" ? ( <button type="button" onClick={() => setIsImageUploadModalOpen(true)}>
<div className="h-12 w-12 rounded-md bg-custom-background-80 p-2"> {!watch("avatar") || watch("avatar") === "" ? (
<UserIcon className="h-full w-full text-custom-text-200" /> <div className="h-16 w-16 rounded-md bg-custom-background-80 p-2">
</div> <UserIcon className="h-full w-full text-custom-text-200" />
) : ( </div>
<div className="relative h-12 w-12 overflow-hidden"> ) : (
<img <div className="relative h-16 w-16 overflow-hidden">
src={watch("avatar")} <img
className="absolute top-0 left-0 h-full w-full object-cover rounded-md" src={watch("avatar")}
onClick={() => setIsImageUploadModalOpen(true)} className="absolute top-0 left-0 h-full w-full object-cover rounded-lg"
alt={myProfile.display_name} onClick={() => setIsImageUploadModalOpen(true)}
/> alt={myProfile.display_name}
</div> />
)} </div>
</button> )}
<div className="flex items-center gap-2"> </button>
<SecondaryButton </div>
onClick={() => {
setIsImageUploadModalOpen(true);
}}
>
Upload
</SecondaryButton>
{myProfile.avatar && myProfile.avatar !== "" && (
<DangerButton
onClick={() => handleDelete(myProfile.avatar, true)}
loading={isRemoving}
>
{isRemoving ? "Removing..." : "Remove"}
</DangerButton>
)}
</div> </div>
</div> </div>
</div>
</div> <div className="flex absolute right-3 bottom-3">
<div className="grid grid-cols-12 gap-4 sm:gap-16"> <Controller
<div className="col-span-12 sm:col-span-6"> control={control}
<h4 className="text-lg font-semibold">Cover Photo</h4> name="cover_image"
<p className="text-sm text-custom-text-200"> render={() => (
Select your cover photo from the given library.
</p>
</div>
<div className="col-span-12 sm:col-span-6">
<div className="h-32 w-full rounded border border-custom-border-200 p-1">
<div className="relative h-full w-full rounded">
<img
src={
watch("cover_image") ??
"https://images.unsplash.com/photo-1506383796573-caf02b4a79ab"
}
className="absolute top-0 left-0 h-full w-full object-cover rounded"
alt={myProfile?.name ?? "Cover image"}
/>
<div className="absolute bottom-0 flex w-full justify-end">
<ImagePickerPopover <ImagePickerPopover
label={"Change cover"} label={"Change cover"}
onChange={(imageUrl) => { onChange={(imageUrl) => {
@ -249,157 +219,167 @@ const Profile: NextPage = () => {
"https://images.unsplash.com/photo-1506383796573-caf02b4a79ab" "https://images.unsplash.com/photo-1506383796573-caf02b4a79ab"
} }
/> />
</div> )}
/>
</div>
</div>
<div className="flex item-center justify-between px-8 mt-4">
<div className="flex flex-col">
<div className="flex item-center text-lg font-semibold text-custom-text-100">
<span>{`${watch("first_name")} ${watch("last_name")}`}</span>
</div> </div>
<span className="text-sm tracking-tight">{watch("email")}</span>
</div>
<Link href={`/${workspaceSlug}/profile/${myProfile.id}`}>
<a className="flex item-center cursor-pointer gap-2 h-4 leading-4 text-sm text-custom-primary-100">
<span className="h-4 w-4">
<UserCircle className="h-4 w-4" />
</span>
View Profile
</a>
</Link>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3 gap-6 px-8">
<div className="flex flex-col gap-1">
<h4 className="text-sm">First Name</h4>
<Input
name="first_name"
id="first_name"
register={register}
error={errors.first_name}
placeholder="Enter your first name"
className="!px-3 !py-2 rounded-md font-medium"
autoComplete="off"
/>
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm">Last Name</h4>
<Input
name="last_name"
register={register}
error={errors.last_name}
id="last_name"
placeholder="Enter your last name"
autoComplete="off"
className="!px-3 !py-2 rounded-md font-medium"
/>
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm">Email</h4>
<Input
id="email"
name="email"
autoComplete="off"
register={register}
className="!px-3 !py-2 rounded-md font-medium"
error={errors.name}
disabled
/>
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm">Role</h4>
<Controller
name="role"
control={control}
rules={{ required: "This field is required" }}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
onChange={onChange}
label={value ? value.toString() : "Select your role"}
buttonClassName={errors.role ? "border-red-500 bg-red-500/10" : ""}
width="w-full"
input
verticalPosition="top"
position="right"
>
{USER_ROLES.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
{errors.role && (
<span className="text-xs text-red-500">Please select a role</span>
)}
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm">Display name </h4>
<Input
id="display_name"
name="display_name"
autoComplete="off"
register={register}
error={errors.display_name}
className="w-full"
placeholder="Enter your display name"
validations={{
required: "Display name is required.",
validate: (value) => {
if (value.trim().length < 1) return "Display name can't be empty.";
if (value.split(" ").length > 1)
return "Display name can't have two consecutive spaces.";
if (value.replace(/\s/g, "").length < 1)
return "Display name must be at least 1 characters long.";
if (value.replace(/\s/g, "").length > 20)
return "Display name must be less than 20 characters long.";
return true;
},
}}
/>
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm">Timezone </h4>
<Controller
name="user_timezone"
control={control}
rules={{ required: "This field is required" }}
render={({ field: { value, onChange } }) => (
<CustomSearchSelect
value={value}
label={
value
? TIME_ZONES.find((t) => t.value === value)?.label ?? value
: "Select a timezone"
}
options={timeZoneOptions}
onChange={onChange}
verticalPosition="top"
optionsClassName="w-full"
input
/>
)}
/>
{errors.role && (
<span className="text-xs text-red-500">Please select a role</span>
)}
</div>
<div className="flex items-center justify-between py-2">
<PrimaryButton type="submit" loading={isSubmitting}>
{isSubmitting ? "Updating Project..." : "Update Project"}
</PrimaryButton>
</div> </div>
</div> </div>
</div> </div>
<div className="grid grid-cols-12 gap-4 sm:gap-16"> </div>
<div className="col-span-12 sm:col-span-6"> </form>
<h4 className="text-lg font-semibold text-custom-text-100">Full Name</h4>
</div>
<div className="col-span-12 flex items-center gap-2 sm:col-span-6">
<Input
name="first_name"
id="first_name"
register={register}
error={errors.first_name}
placeholder="Enter your first name"
autoComplete="off"
/>
<Input
name="last_name"
register={register}
error={errors.last_name}
id="last_name"
placeholder="Enter your last name"
autoComplete="off"
/>
</div>
</div>
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold text-custom-text-100">Display Name</h4>
<p className="text-sm text-custom-text-200">
This could be your first name, or a nickname however you{"'"}d like people to
refer to you in Plane.
</p>
</div>
<div className="col-span-12 sm:col-span-6">
<Input
id="display_name"
name="display_name"
autoComplete="off"
register={register}
error={errors.display_name}
className="w-full"
placeholder="Enter your display name"
validations={{
required: "Display name is required.",
validate: (value) => {
if (value.trim().length < 1) return "Display name can't be empty.";
if (value.split(" ").length > 1)
return "Display name can't have two consecutive spaces.";
if (value.replace(/\s/g, "").length < 1)
return "Display name must be at least 1 characters long.";
if (value.replace(/\s/g, "").length > 20)
return "Display name must be less than 20 characters long.";
return true;
},
}}
/>
</div>
</div>
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold text-custom-text-100">Email</h4>
<p className="text-sm text-custom-text-200">
The email address that you are using.
</p>
</div>
<div className="col-span-12 sm:col-span-6">
<Input
id="email"
name="email"
autoComplete="off"
register={register}
error={errors.name}
className="w-full"
disabled
/>
</div>
</div>
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold text-custom-text-100">Role</h4>
<p className="text-sm text-custom-text-200">Add your role.</p>
</div>
<div className="col-span-12 sm:col-span-6">
<Controller
name="role"
control={control}
rules={{ required: "This field is required" }}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
onChange={onChange}
label={value ? value.toString() : "Select your role"}
buttonClassName={errors.role ? "border-red-500 bg-red-500/10" : ""}
width="w-full"
input
position="right"
>
{USER_ROLES.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
{errors.role && <span className="text-xs text-red-500">Please select a role</span>}
</div>
</div>
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold text-custom-text-100">Timezone</h4>
<p className="text-sm text-custom-text-200">Select a timezone</p>
</div>
<div className="col-span-12 sm:col-span-6">
<Controller
name="user_timezone"
control={control}
rules={{ required: "This field is required" }}
render={({ field: { value, onChange } }) => (
<CustomSearchSelect
value={value}
label={
value
? TIME_ZONES.find((t) => t.value === value)?.label ?? value
: "Select a timezone"
}
options={timeZoneOptions}
onChange={onChange}
verticalPosition="top"
optionsClassName="w-full"
input
/>
)}
/>
{errors.role && <span className="text-xs text-red-500">Please select a role</span>}
</div>
</div>
<div className="sm:text-right">
<SecondaryButton type="submit" loading={isSubmitting}>
{isSubmitting ? "Updating..." : "Update profile"}
</SecondaryButton>
</div>
</form>
</div>
) : ( ) : (
<div className="grid h-full w-full place-items-center px-4 sm:px-0"> <div className="grid h-full w-full place-items-center px-4 sm:px-0">
<Spinner /> <Spinner />

View File

@ -3,7 +3,6 @@ import { useEffect, useState } from "react";
import useUserAuth from "hooks/use-user-auth"; import useUserAuth from "hooks/use-user-auth";
// layouts // layouts
import { WorkspaceAuthorizationLayout } from "layouts/auth-layout"; import { WorkspaceAuthorizationLayout } from "layouts/auth-layout";
import SettingsNavbar from "layouts/settings-navbar";
// components // components
import { CustomThemeSelector, ThemeSwitch } from "components/core"; import { CustomThemeSelector, ThemeSwitch } from "components/core";
// ui // ui
@ -15,6 +14,7 @@ import { ICustomTheme } from "types";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// mobx store // mobx store
import { useMobxStore } from "lib/mobx/store-provider"; import { useMobxStore } from "lib/mobx/store-provider";
import { SettingsSidebar } from "components/project";
const ProfilePreferences = observer(() => { const ProfilePreferences = observer(() => {
const { user: myProfile } = useUserAuth(); const { user: myProfile } = useUserAuth();
@ -59,18 +59,16 @@ const ProfilePreferences = observer(() => {
} }
> >
{myProfile ? ( {myProfile ? (
<div className="p-8"> <div className="flex flex-row gap-2 h-full">
<div className="mb-8 space-y-6"> <div className="w-80 pt-8 overflow-y-hidden flex-shrink-0">
<div> <SettingsSidebar />
<h3 className="text-3xl font-semibold">Profile Settings</h3>
<p className="mt-1 text-custom-text-200">
This information will be visible to only you.
</p>
</div>
<SettingsNavbar profilePage />
</div> </div>
<div className="space-y-8 sm:space-y-12">
<div className="grid grid-cols-12 gap-4 sm:gap-16"> <div className="pr-9 py-8 w-full overflow-y-auto">
<div className="flex items-center py-3.5 border-b border-custom-border-200">
<h3 className="text-xl font-medium">Acitivity</h3>
</div>
<div className="grid grid-cols-12 gap-4 sm:gap-16 py-6">
<div className="col-span-12 sm:col-span-6"> <div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold text-custom-text-100">Theme</h4> <h4 className="text-lg font-semibold text-custom-text-100">Theme</h4>
<p className="text-sm text-custom-text-200"> <p className="text-sm text-custom-text-200">

View File

@ -0,0 +1,73 @@
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import projectService from "services/project.service";
// layouts
import { ProjectAuthorizationWrapper } from "layouts/auth-layout";
// contexts
import { IssueViewContextProvider } from "contexts/issue-view.context";
// helper
import { truncateText } from "helpers/string.helper";
// components
import { IssuesFilterView, IssuesView } from "components/core";
// ui
import { Icon } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// icons
import { X, PenSquare } from "lucide-react";
// types
import type { NextPage } from "next";
// fetch-keys
import { PROJECT_DETAILS } from "constants/fetch-keys";
const ProjectDraftIssues: NextPage = () => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: projectDetails } = useSWR(
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.getProject(workspaceSlug as string, projectId as string)
: null
);
return (
<IssueViewContextProvider>
<ProjectAuthorizationWrapper
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem title="Projects" link={`/${workspaceSlug}/projects`} />
<BreadcrumbItem
title={`${truncateText(projectDetails?.name ?? "Project", 32)} Draft Issues`}
/>
</Breadcrumbs>
}
right={
<div className="flex items-center gap-2">
<IssuesFilterView />
</div>
}
>
<div className="h-full w-full flex flex-col">
<div className="flex items-center ga-1 px-4 py-2.5 shadow-sm border-b border-custom-border-200">
<button
type="button"
onClick={() => router.push(`/${workspaceSlug}/projects/${projectId}/issues/`)}
className="flex items-center gap-1.5 rounded border border-custom-border-200 px-3 py-1.5 text-xs"
>
<PenSquare className="h-3 w-3 text-custom-text-300" />
<span>Draft Issues</span>
<X className="h-3 w-3" />
</button>
</div>
<IssuesView />
</div>
</ProjectAuthorizationWrapper>
</IssueViewContextProvider>
);
};
export default ProjectDraftIssues;

View File

@ -75,11 +75,11 @@ const AutomationsSettings: NextPage = () => {
</Breadcrumbs> </Breadcrumbs>
} }
> >
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2 h-full">
<div className="w-80 py-8"> <div className="w-80 pt-8 overflow-y-hidden flex-shrink-0">
<SettingsSidebar /> <SettingsSidebar />
</div> </div>
<section className="pr-9 py-8 w-full"> <section className="pr-9 py-8 w-full overflow-y-auto">
<div className="flex items-center py-3.5 border-b border-custom-border-200"> <div className="flex items-center py-3.5 border-b border-custom-border-200">
<h3 className="text-xl font-medium">Automations</h3> <h3 className="text-xl font-medium">Automations</h3>
</div> </div>

View File

@ -125,11 +125,11 @@ const EstimatesSettings: NextPage = () => {
</Breadcrumbs> </Breadcrumbs>
} }
> >
<div className="flex flex-row gap-2 h-full overflow-hidden"> <div className="flex flex-row gap-2 h-full">
<div className="w-80 py-8"> <div className="w-80 pt-8 overflow-y-hidden flex-shrink-0">
<SettingsSidebar /> <SettingsSidebar />
</div> </div>
<div className="pr-9 py-8 flex flex-col w-full"> <div className="pr-9 py-8 flex flex-col w-full overflow-y-auto">
<section className="flex items-center justify-between pt-2 pb-3.5 border-b border-custom-border-200"> <section className="flex items-center justify-between pt-2 pb-3.5 border-b border-custom-border-200">
<h3 className="text-xl font-medium">Estimates</h3> <h3 className="text-xl font-medium">Estimates</h3>
<div className="col-span-12 space-y-5 sm:col-span-7"> <div className="col-span-12 space-y-5 sm:col-span-7">

View File

@ -19,7 +19,8 @@ import { ToggleSwitch } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// icons // icons
import { ModuleIcon } from "components/icons"; import { ModuleIcon } from "components/icons";
import { Contrast, FileText, Inbox, Layers } from "lucide-react"; import { FileText, Inbox, Layers } from "lucide-react";
import { ContrastOutlined } from "@mui/icons-material";
// types // types
import { IProject } from "types"; import { IProject } from "types";
import type { NextPage } from "next"; import type { NextPage } from "next";
@ -33,7 +34,10 @@ const featuresList = [
title: "Cycles", title: "Cycles",
description: description:
"Cycles are enabled for all the projects in this workspace. Access them from the sidebar.", "Cycles are enabled for all the projects in this workspace. Access them from the sidebar.",
icon: <Contrast className="h-4 w-4 text-custom-primary-100 flex-shrink-0" />, icon: (
<ContrastOutlined className="!text-base !leading-4 text-purple-500 flex-shrink-0 rotate-180" />
),
property: "cycle_view", property: "cycle_view",
}, },
{ {
@ -61,7 +65,7 @@ const featuresList = [
title: "Inbox", title: "Inbox",
description: description:
"Inbox are enabled for all the projects in this workspace. Access it from the issues views page.", "Inbox are enabled for all the projects in this workspace. Access it from the issues views page.",
icon: <Inbox className="h-4 w-4 text-cyan-500 flex-shrink-0" />, icon: <Inbox className="h-4 w-4 text-fuchsia-500 flex-shrink-0" />,
property: "inbox_view", property: "inbox_view",
}, },
]; ];
@ -149,11 +153,11 @@ const FeaturesSettings: NextPage = () => {
</Breadcrumbs> </Breadcrumbs>
} }
> >
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2 h-full">
<div className="w-80 py-8"> <div className="w-80 pt-8 overflow-y-hidden flex-shrink-0">
<SettingsSidebar /> <SettingsSidebar />
</div> </div>
<section className="pr-9 py-8 w-full"> <section className="pr-9 py-8 w-full overflow-y-auto">
<div className="flex items-center py-3.5 border-b border-custom-border-200"> <div className="flex items-center py-3.5 border-b border-custom-border-200">
<h3 className="text-xl font-medium">Features</h3> <h3 className="text-xl font-medium">Features</h3>
</div> </div>

View File

@ -25,7 +25,6 @@ import {
TextArea, TextArea,
Loader, Loader,
CustomSelect, CustomSelect,
SecondaryButton,
DangerButton, DangerButton,
Icon, Icon,
PrimaryButton, PrimaryButton,
@ -67,7 +66,7 @@ const GeneralSettings: NextPage = () => {
: null : null
); );
const { data: memberDetails, error } = useSWR( const { data: memberDetails } = useSWR(
workspaceSlug && projectId ? USER_PROJECT_VIEW(projectId.toString()) : null, workspaceSlug && projectId ? USER_PROJECT_VIEW(projectId.toString()) : null,
workspaceSlug && projectId workspaceSlug && projectId
? () => projectService.projectMemberMe(workspaceSlug.toString(), projectId.toString()) ? () => projectService.projectMemberMe(workspaceSlug.toString(), projectId.toString())
@ -168,6 +167,7 @@ const GeneralSettings: NextPage = () => {
}; };
const currentNetwork = NETWORK_CHOICES.find((n) => n.key === projectDetails?.network); const currentNetwork = NETWORK_CHOICES.find((n) => n.key === projectDetails?.network);
const selectedNetwork = NETWORK_CHOICES.find((n) => n.key === watch("network"));
const isAdmin = memberDetails?.role === 20; const isAdmin = memberDetails?.role === 20;
@ -190,21 +190,21 @@ const GeneralSettings: NextPage = () => {
onClose={() => setSelectedProject(null)} onClose={() => setSelectedProject(null)}
user={user} user={user}
/> />
<form onSubmit={handleSubmit(onSubmit)}> <div className="flex flex-row gap-2 h-full">
<div className="flex flex-row gap-2"> <div className="w-80 pt-8 overflow-y-hidden flex-shrink-0">
<div className="w-80 py-8"> <SettingsSidebar />
<SettingsSidebar /> </div>
</div> <div className={`pr-9 py-8 w-full overflow-y-auto ${isAdmin ? "" : "opacity-60"}`}>
<div className={`pr-9 py-8 w-full ${isAdmin ? "" : "opacity-60"}`}> <form onSubmit={handleSubmit(onSubmit)}>
<div className="relative h-44 w-full mt-6"> <div className="relative h-44 w-full mt-6">
<img <img
src={watch("cover_image")!} src={watch("cover_image")!}
alt={watch("cover_image")!} alt={watch("cover_image")!}
className="h-44 w-full rounded-md object-cover" className="h-44 w-full rounded-md object-cover"
/> />
<div className="flex items-end justify-between absolute bottom-4 w-full px-4"> <div className="flex items-end justify-between gap-3 absolute bottom-4 w-full px-4">
<div className="flex gap-3"> <div className="flex gap-3 flex-grow truncate">
<div className="flex items-center justify-center bg-custom-background-90 h-[52px] w-[52px] rounded-lg"> <div className="flex items-center justify-center flex-shrink-0 bg-custom-background-90 h-[52px] w-[52px] rounded-lg">
{projectDetails ? ( {projectDetails ? (
<div className="h-7 w-7 grid place-items-center"> <div className="h-7 w-7 grid place-items-center">
<Controller <Controller
@ -226,8 +226,8 @@ const GeneralSettings: NextPage = () => {
</Loader> </Loader>
)} )}
</div> </div>
<div className="flex flex-col gap-1 text-white"> <div className="flex flex-col gap-1 text-white truncate">
<span className="text-lg font-semibold">{watch("name")}</span> <span className="text-lg font-semibold truncate">{watch("name")}</span>
<span className="flex items-center gap-2 text-sm"> <span className="flex items-center gap-2 text-sm">
<span> <span>
{watch("identifier")} . {currentNetwork?.label} {watch("identifier")} . {currentNetwork?.label}
@ -236,7 +236,7 @@ const GeneralSettings: NextPage = () => {
</div> </div>
</div> </div>
<div className="flex justify-center"> <div className="flex justify-center flex-shrink-0">
{projectDetails ? ( {projectDetails ? (
<div> <div>
<Controller <Controller
@ -350,7 +350,7 @@ const GeneralSettings: NextPage = () => {
<CustomSelect <CustomSelect
value={value} value={value}
onChange={onChange} onChange={onChange}
label={currentNetwork?.label ?? "Select network"} label={selectedNetwork?.label ?? "Select network"}
className="!border-custom-border-200 !shadow-none" className="!border-custom-border-200 !shadow-none"
input input
disabled={!isAdmin} disabled={!isAdmin}
@ -388,62 +388,63 @@ const GeneralSettings: NextPage = () => {
)} )}
</div> </div>
</div> </div>
{isAdmin && (
<Disclosure as="div" className="border-t border-custom-border-400">
{({ open }) => (
<div className="w-full">
<Disclosure.Button
as="button"
type="button"
className="flex items-center justify-between w-full py-4"
>
<span className="text-xl tracking-tight">Delete Project</span>
<Icon iconName={open ? "expand_less" : "expand_more"} className="!text-2xl" />
</Disclosure.Button>
<Disclosure as="div" className="border-t border-custom-border-400"> <Transition
{({ open }) => ( show={open}
<div className="w-full"> enter="transition duration-100 ease-out"
<Disclosure.Button enterFrom="transform opacity-0"
as="button" enterTo="transform opacity-100"
type="button" leave="transition duration-75 ease-out"
className="flex items-center justify-between w-full py-4" leaveFrom="transform opacity-100"
> leaveTo="transform opacity-0"
<span className="text-xl tracking-tight">Danger Zone</span> >
<Icon iconName={open ? "expand_more" : "expand_less"} className="!text-2xl" /> <Disclosure.Panel>
</Disclosure.Button> <div className="flex flex-col gap-8">
<span className="text-sm tracking-tight">
<Transition The danger zone of the project delete page is a critical area that
show={open} requires careful consideration and attention. When deleting a project,
enter="transition duration-100 ease-out" all of the data and resources within that project will be permanently
enterFrom="transform opacity-0" removed and cannot be recovered.
enterTo="transform opacity-100" </span>
leave="transition duration-75 ease-out" <div>
leaveFrom="transform opacity-100" {projectDetails ? (
leaveTo="transform opacity-0" <div>
> <DangerButton
<Disclosure.Panel> onClick={() => setSelectedProject(projectDetails.id ?? null)}
<div className="flex flex-col gap-8"> className="!text-sm"
<span className="text-sm tracking-tight"> outline
The danger zone of the project delete page is a critical area that >
requires careful consideration and attention. When deleting a project, all Delete my project
of the data and resources within that project will be permanently removed </DangerButton>
and cannot be recovered. </div>
</span> ) : (
<div> <Loader className="mt-2 w-full">
{projectDetails ? ( <Loader.Item height="38px" width="144px" />
<div> </Loader>
<DangerButton )}
onClick={() => setSelectedProject(projectDetails.id ?? null)} </div>
className="!text-sm"
outline
>
Delete my project
</DangerButton>
</div>
) : (
<Loader className="mt-2 w-full">
<Loader.Item height="38px" width="144px" />
</Loader>
)}
</div> </div>
</div> </Disclosure.Panel>
</Disclosure.Panel> </Transition>
</Transition> </div>
</div> )}
)} </Disclosure>
</Disclosure> )}
</div> </form>
</div> </div>
</form> </div>
</ProjectAuthorizationWrapper> </ProjectAuthorizationWrapper>
); );
}; };

View File

@ -54,18 +54,20 @@ const ProjectIntegrations: NextPage = () => {
link={`/${workspaceSlug}/projects/${projectId}/issues`} link={`/${workspaceSlug}/projects/${projectId}/issues`}
linkTruncate linkTruncate
/> />
<BreadcrumbItem title="Integrations" unshrinkTitle /> <BreadcrumbItem title="Integrations Settings" unshrinkTitle />
</Breadcrumbs> </Breadcrumbs>
} }
> >
<div className="flex flex-row gap-2 h-full overflow-hidden"> <div className="flex flex-row gap-2 h-full">
<div className="w-80 py-8"> <div className="w-80 pt-8 overflow-y-hidden flex-shrink-0">
<SettingsSidebar /> <SettingsSidebar />
</div> </div>
{workspaceIntegrations ? ( <div className="pr-9 py-8 gap-10 w-full overflow-y-auto">
workspaceIntegrations.length > 0 ? ( <div className="flex items-center py-3.5 border-b border-custom-border-200">
<section className="pr-9 py-8 overflow-y-auto w-full"> <h3 className="text-xl font-medium">Integrations</h3>
<IntegrationAndImportExportBanner bannerName="Integrations" /> </div>
{workspaceIntegrations ? (
workspaceIntegrations.length > 0 ? (
<div> <div>
{workspaceIntegrations.map((integration) => ( {workspaceIntegrations.map((integration) => (
<SingleIntegration <SingleIntegration
@ -74,26 +76,26 @@ const ProjectIntegrations: NextPage = () => {
/> />
))} ))}
</div> </div>
</section> ) : (
<EmptyState
title="You haven't configured integrations"
description="Configure GitHub and other integrations to sync your project issues."
image={emptyIntegration}
primaryButton={{
text: "Configure now",
onClick: () => router.push(`/${workspaceSlug}/settings/integrations`),
}}
/>
)
) : ( ) : (
<EmptyState <Loader className="space-y-5">
title="You haven't configured integrations" <Loader.Item height="40px" />
description="Configure GitHub and other integrations to sync your project issues." <Loader.Item height="40px" />
image={emptyIntegration} <Loader.Item height="40px" />
primaryButton={{ <Loader.Item height="40px" />
text: "Configure now", </Loader>
onClick: () => router.push(`/${workspaceSlug}/settings/integrations`), )}
}} </div>
/>
)
) : (
<Loader className="space-y-5">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
)}
</div> </div>
</ProjectAuthorizationWrapper> </ProjectAuthorizationWrapper>
); );

View File

@ -113,11 +113,11 @@ const LabelsSettings: NextPage = () => {
</Breadcrumbs> </Breadcrumbs>
} }
> >
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2 h-full">
<div className="w-80 py-8"> <div className="w-80 pt-8 overflow-y-hidden flex-shrink-0">
<SettingsSidebar /> <SettingsSidebar />
</div> </div>
<section className="pr-9 py-8 gap-10 w-full"> <section className="pr-9 py-8 gap-10 w-full overflow-y-auto">
<div className="flex items-center justify-between pt-2 pb-3.5 border-b border-custom-border-200"> <div className="flex items-center justify-between pt-2 pb-3.5 border-b border-custom-border-200">
<h3 className="text-xl font-medium">Labels</h3> <h3 className="text-xl font-medium">Labels</h3>
@ -129,7 +129,7 @@ const LabelsSettings: NextPage = () => {
Add label Add label
</PrimaryButton> </PrimaryButton>
</div> </div>
<div className="space-y-3 py-6"> <div className="space-y-3 py-6 h-full w-full">
{labelForm && ( {labelForm && (
<CreateUpdateLabelInline <CreateUpdateLabelInline
labelForm={labelForm} labelForm={labelForm}

View File

@ -273,11 +273,11 @@ const MembersSettings: NextPage = () => {
user={user} user={user}
onSuccess={() => mutateMembers()} onSuccess={() => mutateMembers()}
/> />
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2 h-full">
<div className="w-80 py-8"> <div className="w-80 pt-8 overflow-y-hidden flex-shrink-0">
<SettingsSidebar /> <SettingsSidebar />
</div> </div>
<section className="pr-9 py-8 w-full"> <section className="pr-9 py-8 w-full overflow-y-auto">
<div className="flex items-center py-3.5 border-b border-custom-border-200"> <div className="flex items-center py-3.5 border-b border-custom-border-200">
<h3 className="text-xl font-medium">Defaults</h3> <h3 className="text-xl font-medium">Defaults</h3>
</div> </div>
@ -334,7 +334,7 @@ const MembersSettings: NextPage = () => {
</div> </div>
<div className="flex items-center justify-between gap-4 py-3.5 border-b border-custom-border-200"> <div className="flex items-center justify-between gap-4 py-3.5 border-b border-custom-border-200">
<h4 className="text-xl font-medium border-b border-custom-border-100">Members</h4> <h4 className="text-xl font-medium">Members</h4>
<PrimaryButton onClick={() => setInviteModal(true)}>Add Member</PrimaryButton> <PrimaryButton onClick={() => setInviteModal(true)}>Add Member</PrimaryButton>
</div> </div>
{!projectMembers || !projectInvitations ? ( {!projectMembers || !projectInvitations ? (
@ -386,11 +386,13 @@ const MembersSettings: NextPage = () => {
<h4 className="text-sm">{member.display_name || member.email}</h4> <h4 className="text-sm">{member.display_name || member.email}</h4>
)} )}
{isOwner && ( {isOwner && (
<p className="mt-0.5 text-xs text-custom-text-200">{member.email}</p> <p className="mt-0.5 text-xs text-custom-sidebar-text-300">
{member.email}
</p>
)} )}
</div> </div>
</div> </div>
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-3 text-xs">
{!member.member && ( {!member.member && (
<div className="mr-2 flex items-center justify-center rounded-full bg-yellow-500/20 px-2 py-1 text-center text-xs text-yellow-500"> <div className="mr-2 flex items-center justify-center rounded-full bg-yellow-500/20 px-2 py-1 text-center text-xs text-yellow-500">
Pending Pending

View File

@ -73,11 +73,11 @@ const StatesSettings: NextPage = () => {
</Breadcrumbs> </Breadcrumbs>
} }
> >
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2 h-full">
<div className="w-80 py-8"> <div className="w-80 pt-8 overflow-y-hidden flex-shrink-0">
<SettingsSidebar /> <SettingsSidebar />
</div> </div>
<div className="pr-9 py-8 gap-10 w-full"> <div className="pr-9 py-8 gap-10 w-full overflow-y-auto">
<div className="flex items-center py-3.5 border-b border-custom-border-200"> <div className="flex items-center py-3.5 border-b border-custom-border-200">
<h3 className="text-xl font-medium">States</h3> <h3 className="text-xl font-medium">States</h3>
</div> </div>

View File

@ -8,7 +8,8 @@ import useSWR from "swr";
import workspaceService from "services/workspace.service"; import workspaceService from "services/workspace.service";
// layouts // layouts
import { WorkspaceAuthorizationLayout } from "layouts/auth-layout"; import { WorkspaceAuthorizationLayout } from "layouts/auth-layout";
import { SettingsHeader } from "components/workspace"; // component
import { SettingsSidebar } from "components/project";
// ui // ui
import { SecondaryButton } from "components/ui"; import { SecondaryButton } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
@ -42,14 +43,17 @@ const BillingSettings: NextPage = () => {
</Breadcrumbs> </Breadcrumbs>
} }
> >
<div className="p-8"> <div className="flex flex-row gap-2 h-full">
<SettingsHeader /> <div className="w-80 pt-8 overflow-y-hidden flex-shrink-0">
<section className="space-y-8"> <SettingsSidebar />
</div>
<section className="pr-9 py-8 w-full overflow-y-auto">
<div> <div>
<h3 className="text-2xl font-semibold leading-6">Billing & Plans</h3> <div className="flex items-center py-3.5 border-b border-custom-border-200">
<p className="mt-4 text-sm text-custom-text-200">Free launch preview</p> <h3 className="text-xl font-medium">Billing & Plan</h3>
</div>
</div> </div>
<div className="space-y-8 md:w-2/3"> <div className="px-4 py-6">
<div> <div>
<h4 className="text-md mb-1 leading-6">Current plan</h4> <h4 className="text-md mb-1 leading-6">Current plan</h4>
<p className="mb-3 text-sm text-custom-text-200"> <p className="mb-3 text-sm text-custom-text-200">

View File

@ -6,10 +6,9 @@ import useSWR from "swr";
import workspaceService from "services/workspace.service"; import workspaceService from "services/workspace.service";
// layouts // layouts
import { WorkspaceAuthorizationLayout } from "layouts/auth-layout"; import { WorkspaceAuthorizationLayout } from "layouts/auth-layout";
import { SettingsHeader } from "components/workspace";
// components // components
import ExportGuide from "components/exporter/guide"; import ExportGuide from "components/exporter/guide";
import { IntegrationAndImportExportBanner } from "components/ui"; import { SettingsSidebar } from "components/project";
// ui // ui
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// types // types
@ -41,10 +40,16 @@ const ImportExport: NextPage = () => {
</Breadcrumbs> </Breadcrumbs>
} }
> >
<div className="p-8 space-y-4"> <div className="flex flex-row gap-2 h-full">
<SettingsHeader /> <div className="w-80 pt-8 overflow-y-hidden flex-shrink-0">
<IntegrationAndImportExportBanner bannerName="Export" /> <SettingsSidebar />
<ExportGuide /> </div>
<div className="pr-9 py-8 w-full overflow-y-auto">
<div className="flex items-center py-3.5 border-b border-custom-border-200">
<h3 className="text-xl font-medium">Exports</h3>
</div>
<ExportGuide />
</div>
</div> </div>
</WorkspaceAuthorizationLayout> </WorkspaceAuthorizationLayout>
); );

View File

@ -6,10 +6,9 @@ import useSWR from "swr";
import workspaceService from "services/workspace.service"; import workspaceService from "services/workspace.service";
// layouts // layouts
import { WorkspaceAuthorizationLayout } from "layouts/auth-layout"; import { WorkspaceAuthorizationLayout } from "layouts/auth-layout";
import { SettingsHeader } from "components/workspace";
// components // components
import IntegrationGuide from "components/integration/guide"; import IntegrationGuide from "components/integration/guide";
import { IntegrationAndImportExportBanner } from "components/ui"; import { SettingsSidebar } from "components/project";
// ui // ui
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// types // types
@ -41,15 +40,16 @@ const ImportExport: NextPage = () => {
</Breadcrumbs> </Breadcrumbs>
} }
> >
<div className="p-8 space-y-4"> <div className="flex flex-row gap-2 h-full">
<SettingsHeader /> <div className="w-80 pt-8 overflow-y-hidden flex-shrink-0">
<IntegrationAndImportExportBanner <SettingsSidebar />
bannerName="Import/ Export" </div>
description="Integrations and importers are only available on the cloud version. We plan to open-source <section className="pr-9 py-8 w-full overflow-y-auto">
our SDKs in the near future so that the community can request or contribute integrations as <div className="flex items-center py-3.5 border-b border-custom-border-200">
needed." <h3 className="text-xl font-medium">Imports</h3>
/> </div>
<IntegrationGuide /> <IntegrationGuide />
</section>
</div> </div>
</WorkspaceAuthorizationLayout> </WorkspaceAuthorizationLayout>
); );

View File

@ -16,14 +16,16 @@ import useUserAuth from "hooks/use-user-auth";
import { WorkspaceAuthorizationLayout } from "layouts/auth-layout"; import { WorkspaceAuthorizationLayout } from "layouts/auth-layout";
// components // components
import { ImageUploadModal } from "components/core"; import { ImageUploadModal } from "components/core";
import { DeleteWorkspaceModal, SettingsHeader } from "components/workspace"; import { DeleteWorkspaceModal } from "components/workspace";
import { SettingsSidebar } from "components/project";
// ui // ui
import { Spinner, Input, CustomSelect, SecondaryButton, DangerButton } from "components/ui"; import { Disclosure, Transition } from "@headlessui/react";
import { Spinner, Input, CustomSelect, DangerButton, PrimaryButton, Icon } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// icons // icons
import { LinkIcon } from "@heroicons/react/24/outline"; import { Pencil } from "lucide-react";
// helpers // helpers
import { copyTextToClipboard, truncateText } from "helpers/string.helper"; import { truncateText } from "helpers/string.helper";
// types // types
import type { IWorkspace } from "types"; import type { IWorkspace } from "types";
import type { NextPage } from "next"; import type { NextPage } from "next";
@ -135,6 +137,7 @@ const WorkspaceSettings: NextPage = () => {
logo: "", logo: "",
}; };
}); });
setIsImageUploadModalOpen(false);
}) })
.catch(() => { .catch(() => {
setToastAlert({ setToastAlert({
@ -162,6 +165,8 @@ const WorkspaceSettings: NextPage = () => {
<ImageUploadModal <ImageUploadModal
isOpen={isImageUploadModalOpen} isOpen={isImageUploadModalOpen}
onClose={() => setIsImageUploadModalOpen(false)} onClose={() => setIsImageUploadModalOpen(false)}
isRemoving={isImageRemoving}
handleDelete={() => handleDelete(activeWorkspace?.logo)}
onSuccess={(imageUrl) => { onSuccess={(imageUrl) => {
setIsImageUploading(true); setIsImageUploading(true);
setValue("logo", imageUrl); setValue("logo", imageUrl);
@ -178,67 +183,109 @@ const WorkspaceSettings: NextPage = () => {
data={activeWorkspace ?? null} data={activeWorkspace ?? null}
user={user} user={user}
/> />
<div className="p-8"> <div className="flex flex-row gap-2 h-full">
<SettingsHeader /> <div className="w-80 pt-8 overflow-y-hidden flex-shrink-0">
<SettingsSidebar />
</div>
{activeWorkspace ? ( {activeWorkspace ? (
<div className={`space-y-8 sm:space-y-12 ${isAdmin ? "" : "opacity-60"}`}> <div className={`pr-9 py-8 w-full overflow-y-auto ${isAdmin ? "" : "opacity-60"}`}>
<div className="grid grid-cols-12 gap-4 sm:gap-16"> <div className="flex gap-5 items-center pb-7 border-b border-custom-border-200">
<div className="col-span-12 sm:col-span-6"> <div className="flex flex-col gap-1">
<h4 className="text-lg font-semibold">Logo</h4> <button
<p className="text-sm text-custom-text-200"> type="button"
Max file size is 5MB. Supported file types are .jpg and .png. onClick={() => setIsImageUploadModalOpen(true)}
</p> disabled={!isAdmin}
>
{watch("logo") && watch("logo") !== null && watch("logo") !== "" ? (
<div className="relative mx-auto flex h-14 w-14">
<img
src={watch("logo")!}
className="absolute top-0 left-0 h-full w-full object-cover rounded-md"
alt="Workspace Logo"
/>
</div>
) : (
<div className="relative flex h-14 w-14 items-center justify-center rounded bg-gray-700 p-4 uppercase text-white">
{activeWorkspace?.name?.charAt(0) ?? "N"}
</div>
)}
</button>
</div> </div>
<div className="col-span-12 sm:col-span-6"> <div className="flex flex-col gap-1">
<div className="flex items-center gap-4"> <h3 className="text-lg font-semibold leading-6">{watch("name")}</h3>
<span className="text-sm tracking-tight">{`${
typeof window !== "undefined" &&
window.location.origin.replace("http://", "").replace("https://", "")
}/${activeWorkspace.slug}`}</span>
<div className="flex item-center gap-2.5">
<button <button
type="button" className="flex items-center gap-1.5 text-xs text-left text-custom-primary-100 font-medium"
onClick={() => setIsImageUploadModalOpen(true)} onClick={() => setIsImageUploadModalOpen(true)}
disabled={!isAdmin} disabled={!isAdmin}
> >
{watch("logo") && watch("logo") !== null && watch("logo") !== "" ? ( {watch("logo") && watch("logo") !== null && watch("logo") !== "" ? (
<div className="relative mx-auto flex h-12 w-12"> <>
<img <Pencil className="h-3 w-3" />
src={watch("logo")!} Edit logo
className="absolute top-0 left-0 h-full w-full object-cover rounded-md" </>
alt="Workspace Logo"
/>
</div>
) : ( ) : (
<div className="relative flex h-12 w-12 items-center justify-center rounded bg-gray-700 p-4 uppercase text-white"> "Upload logo"
{activeWorkspace?.name?.charAt(0) ?? "N"}
</div>
)} )}
</button> </button>
{isAdmin && (
<div className="flex gap-4">
<SecondaryButton
onClick={() => {
setIsImageUploadModalOpen(true);
}}
>
{isImageUploading ? "Uploading..." : "Upload"}
</SecondaryButton>
{activeWorkspace.logo && activeWorkspace.logo !== "" && (
<DangerButton
onClick={() => handleDelete(activeWorkspace.logo)}
loading={isImageRemoving}
>
{isImageRemoving ? "Removing..." : "Remove"}
</DangerButton>
)}
</div>
)}
</div> </div>
</div> </div>
</div> </div>
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6"> <div className="flex flex-col gap-8 my-10">
<h4 className="text-lg font-semibold">URL</h4> <div className="grid grid-col grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 items-center justify-between gap-10 w-full">
<p className="text-sm text-custom-text-200">Your workspace URL.</p> <div className="flex flex-col gap-1 ">
</div> <h4 className="text-sm">Workspace Name</h4>
<div className="col-span-12 flex items-center gap-2 sm:col-span-6"> <Input
<div className="flex flex-col gap-1"> id="name"
name="name"
placeholder="Name"
autoComplete="off"
register={register}
error={errors.name}
validations={{
required: "Name is required",
maxLength: {
value: 80,
message: "Workspace name should not exceed 80 characters",
},
}}
disabled={!isAdmin}
/>
</div>
<div className="flex flex-col gap-1 ">
<h4 className="text-sm">Company Size</h4>
<Controller
name="organization_size"
control={control}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
onChange={onChange}
label={
ORGANIZATION_SIZE.find((c) => c === value) ?? "Select organization size"
}
width="w-full"
input
disabled={!isAdmin}
>
{ORGANIZATION_SIZE?.map((item) => (
<CustomSelect.Option key={item} value={item}>
{item}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
<div className="flex flex-col gap-1 ">
<h4 className="text-sm">Workspace URL</h4>
<Input <Input
id="url" id="url"
name="url" name="url"
@ -253,114 +300,66 @@ const WorkspaceSettings: NextPage = () => {
disabled disabled
/> />
</div> </div>
<SecondaryButton
className="h-min"
onClick={() =>
copyTextToClipboard(
`${typeof window !== "undefined" && window.location.origin}/${
activeWorkspace.slug
}`
).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Workspace link copied to clipboard.",
});
})
}
outline
>
<LinkIcon className="h-[18px] w-[18px]" />
</SecondaryButton>
</div> </div>
</div>
<div className="grid grid-cols-12 gap-4 sm:gap-16"> <div className="flex items-center justify-between py-2">
<div className="col-span-12 sm:col-span-6"> <PrimaryButton
<h4 className="text-lg font-semibold">Name</h4> onClick={handleSubmit(onSubmit)}
<p className="text-sm text-custom-text-200">Give a name to your workspace.</p> loading={isSubmitting}
</div>
<div className="col-span-12 sm:col-span-6">
<Input
id="name"
name="name"
placeholder="Name"
autoComplete="off"
register={register}
error={errors.name}
validations={{
required: "Name is required",
maxLength: {
value: 80,
message: "Workspace name should not exceed 80 characters",
},
}}
disabled={!isAdmin} disabled={!isAdmin}
/> >
</div> {isSubmitting ? "Updating..." : "Update Workspace"}
</div> </PrimaryButton>
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold">Organization Size</h4>
<p className="text-sm text-custom-text-200">What size is your organization?</p>
</div>
<div className="col-span-12 sm:col-span-6">
<Controller
name="organization_size"
control={control}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
onChange={onChange}
label={
ORGANIZATION_SIZE.find((c) => c === value) ?? "Select organization size"
}
width="w-full"
input
disabled={!isAdmin}
>
{ORGANIZATION_SIZE?.map((item) => (
<CustomSelect.Option key={item} value={item}>
{item}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div> </div>
</div> </div>
{isAdmin && ( <Disclosure as="div" className="border-t border-custom-border-400">
<> {({ open }) => (
<div className="sm:text-right"> <div className="w-full">
<SecondaryButton <Disclosure.Button
onClick={handleSubmit(onSubmit)} as="button"
loading={isSubmitting} type="button"
disabled={!isAdmin} className="flex items-center justify-between w-full py-4"
> >
{isSubmitting ? "Updating..." : "Update Workspace"} <span className="text-xl tracking-tight">Delete Workspace</span>
</SecondaryButton> <Icon iconName={open ? "expand_less" : "expand_more"} className="!text-2xl" />
</Disclosure.Button>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
<Disclosure.Panel>
<div className="flex flex-col gap-8">
<span className="text-sm tracking-tight">
The danger zone of the project delete page is a critical area that
requires careful consideration and attention. When deleting a project, all
of the data and resources within that project will be permanently removed
and cannot be recovered.
</span>
<div>
<DangerButton
onClick={() => setIsOpen(true)}
className="!text-sm"
outline
>
Delete my project
</DangerButton>
</div>
</div>
</Disclosure.Panel>
</Transition>
</div> </div>
<div className="grid grid-cols-12 gap-4 sm:gap-16"> )}
<div className="col-span-12 sm:col-span-6"> </Disclosure>
<h4 className="text-lg font-semibold">Danger Zone</h4>
<p className="text-sm text-custom-text-200">
The danger zone of the workspace delete page is a critical area that requires
careful consideration and attention. When deleting a workspace, all of the
data and resources within that workspace will be permanently removed and
cannot be recovered.
</p>
</div>
<div className="col-span-12 sm:col-span-6">
<DangerButton onClick={() => setIsOpen(true)} outline>
Delete the workspace
</DangerButton>
</div>
</div>
</>
)}
</div> </div>
) : ( ) : (
<div className="grid h-full w-full place-items-center px-4 sm:px-0"> <div className="flex items-center justify-center h-full w-full px-4 sm:px-0">
<Spinner /> <Spinner />
</div> </div>
)} )}

View File

@ -9,9 +9,9 @@ import workspaceService from "services/workspace.service";
import IntegrationService from "services/integration"; import IntegrationService from "services/integration";
// layouts // layouts
import { WorkspaceAuthorizationLayout } from "layouts/auth-layout"; import { WorkspaceAuthorizationLayout } from "layouts/auth-layout";
import { SettingsHeader } from "components/workspace";
// components // components
import { SingleIntegrationCard } from "components/integration"; import { SingleIntegrationCard } from "components/integration";
import { SettingsSidebar } from "components/project";
// ui // ui
import { IntegrationAndImportExportBanner, Loader } from "components/ui"; import { IntegrationAndImportExportBanner, Loader } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
@ -44,23 +44,25 @@ const WorkspaceIntegrations: NextPage = () => {
link={`/${workspaceSlug}`} link={`/${workspaceSlug}`}
linkTruncate linkTruncate
/> />
<BreadcrumbItem title="Integrations" unshrinkTitle /> <BreadcrumbItem title="Integrations Settings" unshrinkTitle />
</Breadcrumbs> </Breadcrumbs>
} }
> >
<div className="p-8"> <div className="flex flex-row gap-2 h-full">
<SettingsHeader /> <div className="w-80 pt-8 overflow-y-hidden flex-shrink-0">
<section className="space-y-5"> <SettingsSidebar />
</div>
<section className="pr-9 py-8 w-full overflow-y-auto">
<IntegrationAndImportExportBanner bannerName="Integrations" /> <IntegrationAndImportExportBanner bannerName="Integrations" />
<div className="space-y-5"> <div>
{appIntegrations ? ( {appIntegrations ? (
appIntegrations.map((integration) => ( appIntegrations.map((integration) => (
<SingleIntegrationCard key={integration.id} integration={integration} /> <SingleIntegrationCard key={integration.id} integration={integration} />
)) ))
) : ( ) : (
<Loader className="space-y-5"> <Loader className="space-y-2.5 mt-4">
<Loader.Item height="60px" /> <Loader.Item height="89px" />
<Loader.Item height="60px" /> <Loader.Item height="89px" />
</Loader> </Loader>
)} )}
</div> </div>

View File

@ -13,15 +13,15 @@ import useUser from "hooks/use-user";
import useWorkspaceMembers from "hooks/use-workspace-members"; import useWorkspaceMembers from "hooks/use-workspace-members";
// layouts // layouts
import { WorkspaceAuthorizationLayout } from "layouts/auth-layout"; import { WorkspaceAuthorizationLayout } from "layouts/auth-layout";
import { SettingsHeader } from "components/workspace";
// components // components
import ConfirmWorkspaceMemberRemove from "components/workspace/confirm-workspace-member-remove"; import ConfirmWorkspaceMemberRemove from "components/workspace/confirm-workspace-member-remove";
import SendWorkspaceInvitationModal from "components/workspace/send-workspace-invitation-modal"; import SendWorkspaceInvitationModal from "components/workspace/send-workspace-invitation-modal";
import { SettingsSidebar } from "components/project";
// ui // ui
import { CustomMenu, CustomSelect, Loader } from "components/ui"; import { CustomMenu, CustomSelect, Icon, Loader, PrimaryButton } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// icons // icons
import { PlusIcon } from "@heroicons/react/24/outline"; import { XMarkIcon } from "components/icons";
// types // types
import type { NextPage } from "next"; import type { NextPage } from "next";
// fetch-keys // fetch-keys
@ -143,9 +143,8 @@ const MembersSettings: NextPage = () => {
}); });
}) })
.finally(() => { .finally(() => {
mutateMembers( mutateMembers((prevData: any) =>
(prevData: any) => prevData?.filter((item: any) => item.id !== selectedRemoveMember)
prevData?.filter((item: any) => item.id !== selectedRemoveMember)
); );
}); });
} }
@ -187,19 +186,14 @@ const MembersSettings: NextPage = () => {
user={user} user={user}
onSuccess={handleInviteModalSuccess} onSuccess={handleInviteModalSuccess}
/> />
<div className="p-8"> <div className="flex flex-row gap-2 h-full">
<SettingsHeader /> <div className="w-80 pt-8 overflow-y-hidden flex-shrink-0">
<section className="space-y-5"> <SettingsSidebar />
<div className="flex items-end justify-between gap-4"> </div>
<h3 className="text-2xl font-semibold">Members</h3> <section className="pr-9 py-8 w-full overflow-y-auto">
<button <div className="flex items-center justify-between gap-4 pt-2 pb-3.5 border-b border-custom-border-200">
type="button" <h4 className="text-xl font-medium">Members</h4>
className="flex items-center gap-2 text-custom-primary outline-none" <PrimaryButton onClick={() => setInviteModal(true)}>Add Member</PrimaryButton>
onClick={() => setInviteModal(true)}
>
<PlusIcon className="h-4 w-4" />
Add Member
</button>
</div> </div>
{!workspaceMembers || !workspaceInvitations ? ( {!workspaceMembers || !workspaceInvitations ? (
<Loader className="space-y-5"> <Loader className="space-y-5">
@ -209,23 +203,30 @@ const MembersSettings: NextPage = () => {
<Loader.Item height="40px" /> <Loader.Item height="40px" />
</Loader> </Loader>
) : ( ) : (
<div className="divide-y divide-custom-border-200 rounded-[10px] border border-custom-border-200 bg-custom-background-100 px-6"> <div className="divide-y divide-custom-border-200">
{members.length > 0 {members.length > 0
? members.map((member) => ( ? members.map((member) => (
<div key={member.id} className="flex items-center justify-between py-6"> <div
key={member.id}
className="group flex items-center justify-between px-3.5 py-[18px]"
>
<div className="flex items-center gap-x-8 gap-y-2"> <div className="flex items-center gap-x-8 gap-y-2">
{member.avatar && member.avatar !== "" ? ( {member.avatar && member.avatar !== "" ? (
<div className="relative flex h-10 w-10 items-center justify-center rounded-lg p-4 capitalize text-white"> <Link href={`/${workspaceSlug}/profile/${member.memberId}`}>
<img <a className="relative flex h-10 w-10 items-center justify-center rounded-lg p-4 capitalize text-white">
src={member.avatar} <img
className="absolute top-0 left-0 h-full w-full object-cover rounded-lg" src={member.avatar}
alt={member.display_name || member.email} className="absolute top-0 left-0 h-full w-full object-cover rounded-lg"
/> alt={member.display_name || member.email}
</div> />
</a>
</Link>
) : member.display_name || member.email ? ( ) : member.display_name || member.email ? (
<div className="relative flex h-10 w-10 items-center justify-center rounded-lg p-4 capitalize bg-gray-700 text-white"> <Link href={`/${workspaceSlug}/profile/${member.memberId}`}>
{(member.display_name || member.email)?.charAt(0)} <a className="relative flex h-10 w-10 items-center justify-center rounded-lg p-4 capitalize bg-gray-700 text-white">
</div> {(member.display_name || member.email)?.charAt(0)}
</a>
</Link>
) : ( ) : (
<div className="relative flex h-10 w-10 items-center justify-center rounded-lg p-4 capitalize bg-gray-700 text-white"> <div className="relative flex h-10 w-10 items-center justify-center rounded-lg p-4 capitalize bg-gray-700 text-white">
? ?
@ -244,14 +245,18 @@ const MembersSettings: NextPage = () => {
</a> </a>
</Link> </Link>
) : ( ) : (
<h4 className="text-sm">{member.display_name || member.email}</h4> <h4 className="text-sm cursor-default">
{member.display_name || member.email}
</h4>
)} )}
{isOwner && ( {isOwner && (
<p className="text-xs text-custom-text-200">{member.email}</p> <p className="mt-0.5 text-xs text-custom-sidebar-text-300">
{member.email}
</p>
)} )}
</div> </div>
</div> </div>
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-3 text-xs">
{!member?.status && ( {!member?.status && (
<div className="mr-2 flex items-center justify-center rounded-full bg-yellow-500/20 px-2 py-1 text-center text-xs text-yellow-500"> <div className="mr-2 flex items-center justify-center rounded-full bg-yellow-500/20 px-2 py-1 text-center text-xs text-yellow-500">
<p>Pending</p> <p>Pending</p>
@ -263,9 +268,22 @@ const MembersSettings: NextPage = () => {
</div> </div>
)} )}
<CustomSelect <CustomSelect
label={ROLE[member.role as keyof typeof ROLE]} customButton={
<button className="flex item-center gap-1">
<span
className={`flex items-center text-sm font-medium ${
member.memberId !== user?.id ? "" : "text-custom-sidebar-text-400"
}`}
>
{ROLE[member.role as keyof typeof ROLE]}
</span>
{member.memberId !== user?.id && (
<Icon iconName="expand_more" className="text-lg font-medium" />
)}
</button>
}
value={member.role} value={member.role}
onChange={(value: any) => { onChange={(value: 5 | 10 | 15 | 20 | undefined) => {
if (!workspaceSlug) return; if (!workspaceSlug) return;
mutateMembers( mutateMembers(
@ -323,7 +341,14 @@ const MembersSettings: NextPage = () => {
} }
}} }}
> >
{user?.id === member.memberId ? "Leave" : "Remove member"} <span className="flex items-center justify-start gap-2">
<XMarkIcon className="h-4 w-4" />
<span>
{" "}
{user?.id === member.memberId ? "Leave" : "Remove member"}
</span>
</span>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
</CustomMenu> </CustomMenu>
</div> </div>

View File

@ -32,6 +32,15 @@ class MyDocument extends Document {
<link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png" /> <link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest.json" /> <link rel="manifest" href="/site.webmanifest.json" />
<link rel="shortcut icon" href="/favicon/favicon.ico" /> <link rel="shortcut icon" href="/favicon/favicon.ico" />
{isSessionRecorderEnabled && process.env.NEXT_PUBLIC_SESSION_RECORDER_KEY && (
<Script id="clarity-tracking">
{`(function(c,l,a,r,i,t,y){
c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
})(window, document, "clarity", "script", "${process.env.NEXT_PUBLIC_SESSION_RECORDER_KEY}");`}
</Script>
)}
</Head> </Head>
<body> <body>
<Main /> <Main />
@ -43,15 +52,6 @@ class MyDocument extends Document {
src="https://plausible.io/js/script.js" src="https://plausible.io/js/script.js"
/> />
)} )}
{isSessionRecorderEnabled && process.env.NEXT_PUBLIC_SESSION_RECORDER_KEY && (
<Script id="clarity-tracking">
{`(function(c,l,a,r,i,t,y){
c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
})(window, document, "clarity", "script", "${process.env.NEXT_PUBLIC_SESSION_RECORDER_KEY}");`}
</Script>
)}
{process.env.NEXT_PUBLIC_POSTHOG_KEY && process.env.NEXT_PUBLIC_POSTHOG_HOST && ( {process.env.NEXT_PUBLIC_POSTHOG_KEY && process.env.NEXT_PUBLIC_POSTHOG_HOST && (
<Script id="posthog-tracking"> <Script id="posthog-tracking">
{`!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]); {`!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);

View File

@ -0,0 +1,54 @@
<svg width="201" height="150" viewBox="0 0 201 150" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_2267_36125" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="101" height="150">
<rect width="101" height="150" fill="#D9D9D9"/>
</mask>
<g mask="url(#mask0_2267_36125)">
<path d="M1.25 4.00001C1.25 1.92894 2.92893 0.25 5 0.25H225C227.071 0.25 228.75 1.92893 228.75 4V189.75H1.25V4.00001Z" fill="#231035" stroke="#3F2B58" stroke-width="0.5"/>
<path d="M2 4C2 2.34315 3.34315 1 5 1H204C205.657 1 207 2.34315 207 4V10H2V4Z" fill="#32184C"/>
<line x1="51.25" y1="10" x2="51.25" y2="190" stroke="#3F2B58" stroke-width="0.5"/>
<line x1="1" y1="9.75" x2="211" y2="9.75002" stroke="#3F2B58" stroke-width="0.5"/>
<rect x="5" y="14" width="36" height="6" rx="1" fill="#572D81"/>
<rect x="5" y="26" width="39" height="4" rx="1" fill="#371B52"/>
<rect x="5" y="35" width="31" height="4" rx="1" fill="#371B52"/>
<rect x="5" y="53" width="26" height="4" rx="1" fill="#371B52"/>
<rect x="5" y="80" width="34" height="4" rx="1" fill="#371B52"/>
<rect x="5" y="44" width="35" height="4" rx="1" fill="#371B52"/>
<rect x="5" y="62" width="29" height="4" rx="1" fill="#371B52"/>
<rect x="5" y="71" width="38" height="4" rx="1" fill="#371B52"/>
<rect x="43" y="14" width="6" height="6" rx="3" fill="#572D81"/>
<rect x="66" y="44" width="51" height="4" rx="1" fill="#572D81"/>
<rect x="66" y="53" width="132" height="4" rx="1" fill="#371B52"/>
<rect x="66" y="60" width="97" height="4" rx="1" fill="#371B52"/>
<rect x="75" y="76" width="52" height="4" rx="1" fill="#371B52"/>
<rect x="75" y="87" width="78" height="4" rx="1" fill="#371B52"/>
<rect x="75" y="98" width="71" height="4" rx="1" fill="#371B52"/>
<rect x="75" y="109" width="58" height="4" rx="1" fill="#371B52"/>
<rect x="66" y="75" width="6" height="6" rx="3" fill="#401E60"/>
<rect x="66" y="86" width="6" height="6" rx="3" fill="#401E60"/>
<rect x="66" y="97" width="6" height="6" rx="3" fill="#401E60"/>
<rect x="66" y="108" width="6" height="6" rx="3" fill="#401E60"/>
<rect x="66" y="26" width="12" height="12" rx="6" fill="#572D81"/>
<rect x="5" y="4" width="3" height="3" rx="1.5" fill="#EF4444"/>
<rect x="9" y="4" width="3" height="3" rx="1.5" fill="#FCD34D"/>
<rect x="13" y="4" width="3" height="3" rx="1.5" fill="#4ADE80"/>
</g>
<mask id="mask1_2267_36125" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="101" y="0" width="100" height="150">
<rect x="101" width="100" height="150" fill="#D9D9D9"/>
</mask>
<g mask="url(#mask1_2267_36125)">
<path d="M0.5 4.00001C0.5 2.06701 2.067 0.5 4 0.5H224C225.933 0.5 227.5 2.067 227.5 4V189.5H0.5V4.00001Z" fill="#000C1B" stroke="#172534"/>
<path d="M1 4C1 2.34315 2.34315 1 4 1H203C204.657 1 206 2.34315 206 4V10H1V4Z" fill="#001936"/>
<line x1="2.18557e-08" y1="9.75" x2="210" y2="9.75002" stroke="#172534" stroke-width="0.5"/>
<rect x="65" y="44" width="51" height="4" rx="1" fill="#172B52"/>
<rect x="65" y="53" width="132" height="4" rx="1" fill="#151E3D"/>
<rect x="65" y="60" width="97" height="4" rx="1" fill="#151E3D"/>
<rect x="74" y="76" width="52" height="4" rx="1" fill="#151E3D"/>
<rect x="74" y="87" width="78" height="4" rx="1" fill="#151E3D"/>
<rect x="74" y="98" width="71" height="4" rx="1" fill="#151E3D"/>
<rect x="74" y="109" width="58" height="4" rx="1" fill="#151E3D"/>
<rect x="129" y="76" width="6" height="4" rx="1" fill="#151E3D"/>
<rect x="155" y="87" width="6" height="4" rx="1" fill="#151E3D"/>
<rect x="148" y="98" width="6" height="4" rx="1" fill="#151E3D"/>
<rect x="135" y="109" width="6" height="4" rx="1" fill="#151E3D"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@ -0,0 +1,33 @@
<svg width="462" height="536" viewBox="0 0 462 536" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.827922 13.2468C0.827922 6.38804 6.38802 0.827922 13.2468 0.827922H741.818C748.677 0.827922 754.237 6.38802 754.237 13.2468V628.393H0.827922V13.2468Z" fill="white" stroke="#E5E5E5" stroke-width="1.65584"/>
<path d="M0.827922 13.2468C0.827922 6.38802 6.38802 0.827922 13.2468 0.827922H672.273C677.302 0.827922 681.38 4.90533 681.38 9.93507V32.289H0.827922V13.2468Z" fill="#F5F5F5" stroke="#E5E5E5" stroke-width="1.65584"/>
<line x1="166.414" y1="33.1162" x2="166.414" y2="629.22" stroke="#E5E5E5" stroke-width="1.65584"/>
<line x1="-7.23793e-08" y1="32.2883" x2="695.455" y2="32.2882" stroke="#E5E5E5" stroke-width="1.65584"/>
<rect x="13.25" y="46.3633" width="119.221" height="19.8701" rx="3.31169" fill="#E5E5E5"/>
<rect x="13.25" y="86.1035" width="129.156" height="13.2468" rx="3.31169" fill="#F1F1F1"/>
<rect x="13.25" y="115.908" width="102.662" height="13.2468" rx="3.31169" fill="#F1F1F1"/>
<rect x="13.25" y="175.519" width="86.1039" height="13.2468" rx="3.31169" fill="#F1F1F1"/>
<rect x="13.25" y="264.935" width="112.597" height="13.2468" rx="3.31169" fill="#F1F1F1"/>
<rect x="13.25" y="145.714" width="115.909" height="13.2468" rx="3.31169" fill="#F1F1F1"/>
<rect x="13.25" y="205.324" width="96.039" height="13.2468" rx="3.31169" fill="#F1F1F1"/>
<rect x="13.25" y="235.129" width="125.844" height="13.2468" rx="3.31169" fill="#F1F1F1"/>
<rect x="139.094" y="46.3633" width="19.8701" height="19.8701" rx="9.93506" fill="#E5E5E5"/>
<rect x="215.266" y="145.715" width="168.896" height="13.2468" rx="3.31169" fill="#E5E5E5"/>
<rect x="397.406" y="145.715" width="43.0519" height="13.2468" rx="3.31169" fill="#3F76FF"/>
<rect x="215.266" y="175.521" width="437.143" height="13.2468" rx="3.31169" fill="#F1F1F1"/>
<rect x="215.266" y="198.702" width="321.234" height="13.2468" rx="3.31169" fill="#F1F1F1"/>
<rect x="245.07" y="251.688" width="172.208" height="13.2468" rx="3.31169" fill="#F1F1F1"/>
<rect x="245.07" y="288.117" width="258.312" height="13.2468" rx="3.31169" fill="#F1F1F1"/>
<rect x="245.07" y="324.546" width="235.13" height="13.2468" rx="3.31169" fill="#F1F1F1"/>
<rect x="245.07" y="360.975" width="192.078" height="13.2468" rx="3.31169" fill="#F1F1F1"/>
<rect x="427.219" y="251.688" width="19.8701" height="13.2468" rx="3.31169" fill="#F1F1F1"/>
<rect x="447.078" y="360.975" width="19.8701" height="13.2468" rx="3.31169" fill="#F1F1F1"/>
<rect x="215.266" y="248.378" width="19.8701" height="19.8701" rx="9.93506" fill="#E5E5E5"/>
<rect x="215.266" y="284.806" width="19.8701" height="19.8701" rx="9.93506" fill="#E5E5E5"/>
<rect x="215.266" y="321.234" width="19.8701" height="19.8701" rx="9.93506" fill="#E5E5E5"/>
<rect x="215.266" y="357.663" width="19.8701" height="19.8701" rx="9.93506" fill="#E5E5E5"/>
<rect x="215.266" y="86.1045" width="39.7403" height="39.7403" rx="19.8701" fill="#D4D4D4"/>
<rect x="13.2422" y="13.2471" width="9.93506" height="9.93506" rx="4.96753" fill="#EF4444"/>
<rect x="26.4922" y="13.2471" width="9.93506" height="9.93506" rx="4.96753" fill="#FCD34D"/>
<rect x="39.7422" y="13.2471" width="9.93506" height="9.93506" rx="4.96753" fill="#4ADE80"/>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -0,0 +1,34 @@
<svg width="201" height="151" viewBox="0 0 201 151" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.25 4.00001C0.25 1.92894 1.92893 0.25 4 0.25H224C226.071 0.25 227.75 1.92893 227.75 4V189.75H0.25V4.00001Z" fill="#171717" stroke="white" stroke-width="0.5"/>
<path d="M1 4C1 2.34315 2.34315 1 4 1H203C204.657 1 206 2.34315 206 4V10H1V4Z" fill="#171717"/>
<line x1="50.25" y1="10" x2="50.25" y2="190" stroke="white" stroke-width="0.5"/>
<line x1="2.18557e-08" y1="9.75" x2="210" y2="9.75002" stroke="white" stroke-width="0.5"/>
<rect x="4" y="14" width="36" height="6" rx="1" fill="white"/>
<rect x="4" y="26" width="39" height="4" rx="1" fill="#D4D4D4"/>
<rect x="4" y="35" width="31" height="4" rx="1" fill="#D4D4D4"/>
<rect x="4" y="53" width="26" height="4" rx="1" fill="#D4D4D4"/>
<rect x="4" y="80" width="34" height="4" rx="1" fill="#D4D4D4"/>
<rect x="4" y="44" width="35" height="4" rx="1" fill="#D4D4D4"/>
<rect x="4" y="62" width="29" height="4" rx="1" fill="#D4D4D4"/>
<rect x="4" y="71" width="38" height="4" rx="1" fill="#D4D4D4"/>
<rect x="42" y="14" width="6" height="6" rx="3" fill="white"/>
<rect x="65" y="44" width="51" height="4" rx="1" fill="#F1F1F1"/>
<rect x="65" y="53" width="132" height="4" rx="1" fill="#D4D4D4"/>
<rect x="65" y="60" width="97" height="4" rx="1" fill="#D4D4D4"/>
<rect x="74" y="76" width="52" height="4" rx="1" fill="#D4D4D4"/>
<rect x="74" y="87" width="78" height="4" rx="1" fill="#D4D4D4"/>
<rect x="74" y="98" width="71" height="4" rx="1" fill="#D4D4D4"/>
<rect x="74" y="109" width="58" height="4" rx="1" fill="#D4D4D4"/>
<rect x="129" y="76" width="6" height="4" rx="1" fill="#D4D4D4"/>
<rect x="155" y="87" width="6" height="4" rx="1" fill="#D4D4D4"/>
<rect x="148" y="98" width="6" height="4" rx="1" fill="#D4D4D4"/>
<rect x="135" y="109" width="6" height="4" rx="1" fill="#D4D4D4"/>
<rect x="65" y="75" width="6" height="6" rx="3" fill="white"/>
<rect x="65" y="86" width="6" height="6" rx="3" fill="white"/>
<rect x="65" y="97" width="6" height="6" rx="3" fill="white"/>
<rect x="65" y="108" width="6" height="6" rx="3" fill="white"/>
<rect x="65" y="26" width="12" height="12" rx="6" fill="#F1F1F1"/>
<rect x="4" y="4" width="3" height="3" rx="1.5" fill="#EF4444"/>
<rect x="8" y="4" width="3" height="3" rx="1.5" fill="#FCD34D"/>
<rect x="12" y="4" width="3" height="3" rx="1.5" fill="#4ADE80"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -0,0 +1,41 @@
<svg width="201" height="154" viewBox="0 0 201 154" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2273_36428)">
<path d="M0.25 4.00001C0.25 1.92894 1.92893 0.25 4 0.25H224C226.071 0.25 227.75 1.92893 227.75 4V189.75H0.25V4.00001Z" fill="#121212" stroke="#262626" stroke-width="0.5"/>
<path d="M0.25 4C0.25 1.92893 1.92893 0.25 4 0.25H203C204.519 0.25 205.75 1.48122 205.75 3V9.75H0.25V4Z" fill="#222222" stroke="#222222" stroke-width="0.5"/>
<line x1="50.25" y1="10" x2="50.25" y2="190" stroke="#262626" stroke-width="0.5"/>
<rect x="4" y="14" width="36" height="6" rx="1" fill="#404040"/>
<rect x="4" y="26" width="39" height="4" rx="1" fill="#262626"/>
<rect x="4" y="35" width="31" height="4" rx="1" fill="#262626"/>
<rect x="4" y="53" width="26" height="4" rx="1" fill="#262626"/>
<rect x="4" y="80" width="34" height="4" rx="1" fill="#262626"/>
<rect x="4" y="44" width="35" height="4" rx="1" fill="#262626"/>
<rect x="4" y="62" width="29" height="4" rx="1" fill="#262626"/>
<rect x="4" y="71" width="38" height="4" rx="1" fill="#262626"/>
<rect x="42" y="14" width="6" height="6" rx="3" fill="#404040"/>
<rect x="65" y="44" width="51" height="4" rx="1" fill="#404040"/>
<rect x="65" y="53" width="132" height="4" rx="1" fill="#262626"/>
<rect x="65" y="60" width="97" height="4" rx="1" fill="#262626"/>
<rect x="74" y="76" width="52" height="4" rx="1" fill="#262626"/>
<rect x="74" y="87" width="78" height="4" rx="1" fill="#262626"/>
<rect x="74" y="98" width="71" height="4" rx="1" fill="#262626"/>
<rect x="74" y="109" width="58" height="4" rx="1" fill="#262626"/>
<rect x="129" y="76" width="6" height="4" rx="1" fill="#262626"/>
<rect x="155" y="87" width="6" height="4" rx="1" fill="#262626"/>
<rect x="148" y="98" width="6" height="4" rx="1" fill="#262626"/>
<rect x="135" y="109" width="6" height="4" rx="1" fill="#262626"/>
<rect x="65" y="75" width="6" height="6" rx="3" fill="#222222"/>
<rect x="65" y="86" width="6" height="6" rx="3" fill="#222222"/>
<rect x="65" y="97" width="6" height="6" rx="3" fill="#222222"/>
<rect x="65" y="108" width="6" height="6" rx="3" fill="#222222"/>
<rect x="65" y="26" width="12" height="12" rx="6" fill="#404040"/>
<rect x="4" y="4" width="3" height="3" rx="1.5" fill="#EF4444"/>
<rect x="8" y="4" width="3" height="3" rx="1.5" fill="#FCD34D"/>
<rect x="12" y="4" width="3" height="3" rx="1.5" fill="#4ADE80"/>
<rect x="121" y="44" width="13" height="4" rx="1" fill="#3F76FF"/>
</g>
<defs>
<clipPath id="clip0_2273_36428">
<rect width="201" height="154" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -0,0 +1,42 @@
<svg width="204" height="154" viewBox="0 0 204 154" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2273_36429)">
<path d="M0.25 4.00001C0.25 1.92894 1.92893 0.25 4 0.25H224C226.071 0.25 227.75 1.92893 227.75 4V189.75H0.25V4.00001Z" fill="white" stroke="black" stroke-width="0.5"/>
<path d="M1 4C1 2.34315 2.34315 1 4 1H203C204.657 1 206 2.34315 206 4V10H1V4Z" fill="white"/>
<line x1="50.25" y1="10" x2="50.25" y2="190" stroke="black" stroke-width="0.5"/>
<line x1="-2.18557e-08" y1="9.75" x2="210" y2="9.74998" stroke="black" stroke-width="0.5"/>
<rect x="4" y="14" width="36" height="6" rx="1" fill="#2E2E2E"/>
<rect x="4" y="26" width="39" height="4" rx="1" fill="#404040"/>
<rect x="4" y="35" width="31" height="4" rx="1" fill="#404040"/>
<rect x="4" y="53" width="26" height="4" rx="1" fill="#404040"/>
<rect x="4" y="80" width="34" height="4" rx="1" fill="#404040"/>
<rect x="4" y="44" width="35" height="4" rx="1" fill="#404040"/>
<rect x="4" y="62" width="29" height="4" rx="1" fill="#404040"/>
<rect x="4" y="71" width="38" height="4" rx="1" fill="#404040"/>
<rect x="42" y="14" width="6" height="6" rx="3" fill="#2E2E2E"/>
<rect x="65" y="44" width="51" height="4" rx="1" fill="#2E2E2E"/>
<rect x="65" y="53" width="132" height="4" rx="1" fill="#404040"/>
<rect x="65" y="60" width="97" height="4" rx="1" fill="#404040"/>
<rect x="74" y="76" width="52" height="4" rx="1" fill="#404040"/>
<rect x="74" y="87" width="78" height="4" rx="1" fill="#404040"/>
<rect x="74" y="98" width="71" height="4" rx="1" fill="#404040"/>
<rect x="74" y="109" width="58" height="4" rx="1" fill="#404040"/>
<rect x="129" y="76" width="6" height="4" rx="1" fill="#404040"/>
<rect x="155" y="87" width="6" height="4" rx="1" fill="#404040"/>
<rect x="148" y="98" width="6" height="4" rx="1" fill="#404040"/>
<rect x="135" y="109" width="6" height="4" rx="1" fill="#404040"/>
<rect x="65" y="75" width="6" height="6" rx="3" fill="#3A3A3A"/>
<rect x="65" y="86" width="6" height="6" rx="3" fill="#3A3A3A"/>
<rect x="65" y="97" width="6" height="6" rx="3" fill="#3A3A3A"/>
<rect x="65" y="108" width="6" height="6" rx="3" fill="#3A3A3A"/>
<rect x="65" y="26" width="12" height="12" rx="6" fill="#262626"/>
<rect x="4" y="4" width="3" height="3" rx="1.5" fill="#EF4444"/>
<rect x="8" y="4" width="3" height="3" rx="1.5" fill="#FCD34D"/>
<rect x="12" y="4" width="3" height="3" rx="1.5" fill="#4ADE80"/>
<rect x="121" y="44" width="13" height="4" rx="1" fill="#3F76FF"/>
</g>
<defs>
<clipPath id="clip0_2273_36429">
<rect width="204" height="154" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -0,0 +1,42 @@
<svg width="201" height="154" viewBox="0 0 201 154" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2273_36427)">
<path d="M0.25 4.00001C0.25 1.92894 1.92893 0.25 4 0.25H224C226.071 0.25 227.75 1.92893 227.75 4V189.75H0.25V4.00001Z" fill="white" stroke="#E5E5E5" stroke-width="0.5"/>
<path d="M0.25 4C0.25 1.92893 1.92893 0.25 4 0.25H203C204.519 0.25 205.75 1.48122 205.75 3V9.75H0.25V4Z" fill="#F5F5F5" stroke="#E5E5E5" stroke-width="0.5"/>
<line x1="50.25" y1="10" x2="50.25" y2="190" stroke="#E5E5E5" stroke-width="0.5"/>
<line x1="-2.18557e-08" y1="9.75" x2="210" y2="9.74998" stroke="#E5E5E5" stroke-width="0.5"/>
<rect x="4" y="14" width="36" height="6" rx="1" fill="#E5E5E5"/>
<rect x="4" y="26" width="39" height="4" rx="1" fill="#F1F1F1"/>
<rect x="4" y="35" width="31" height="4" rx="1" fill="#F1F1F1"/>
<rect x="4" y="53" width="26" height="4" rx="1" fill="#F1F1F1"/>
<rect x="4" y="80" width="34" height="4" rx="1" fill="#F1F1F1"/>
<rect x="4" y="44" width="35" height="4" rx="1" fill="#F1F1F1"/>
<rect x="4" y="62" width="29" height="4" rx="1" fill="#F1F1F1"/>
<rect x="4" y="71" width="38" height="4" rx="1" fill="#F1F1F1"/>
<rect x="42" y="14" width="6" height="6" rx="3" fill="#E5E5E5"/>
<rect x="65" y="44" width="51" height="4" rx="1" fill="#E5E5E5"/>
<rect x="120" y="44" width="13" height="4" rx="1" fill="#3F76FF"/>
<rect x="65" y="53" width="132" height="4" rx="1" fill="#F1F1F1"/>
<rect x="65" y="60" width="97" height="4" rx="1" fill="#F1F1F1"/>
<rect x="74" y="76" width="52" height="4" rx="1" fill="#F1F1F1"/>
<rect x="74" y="87" width="78" height="4" rx="1" fill="#F1F1F1"/>
<rect x="74" y="98" width="71" height="4" rx="1" fill="#F1F1F1"/>
<rect x="74" y="109" width="58" height="4" rx="1" fill="#F1F1F1"/>
<rect x="129" y="76" width="6" height="4" rx="1" fill="#F1F1F1"/>
<rect x="155" y="87" width="6" height="4" rx="1" fill="#F1F1F1"/>
<rect x="148" y="98" width="6" height="4" rx="1" fill="#F1F1F1"/>
<rect x="135" y="109" width="6" height="4" rx="1" fill="#F1F1F1"/>
<rect x="65" y="75" width="6" height="6" rx="3" fill="#E5E5E5"/>
<rect x="65" y="86" width="6" height="6" rx="3" fill="#E5E5E5"/>
<rect x="65" y="97" width="6" height="6" rx="3" fill="#E5E5E5"/>
<rect x="65" y="108" width="6" height="6" rx="3" fill="#E5E5E5"/>
<rect x="65" y="26" width="12" height="12" rx="6" fill="#D4D4D4"/>
<rect x="4" y="4" width="3" height="3" rx="1.5" fill="#EF4444"/>
<rect x="8" y="4" width="3" height="3" rx="1.5" fill="#FCD34D"/>
<rect x="12" y="4" width="3" height="3" rx="1.5" fill="#4ADE80"/>
</g>
<defs>
<clipPath id="clip0_2273_36427">
<rect width="201" height="154" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -154,6 +154,7 @@ class ProjectIssuesServices extends APIService {
relation_type: "duplicate" | "relates_to" | "blocked_by"; relation_type: "duplicate" | "relates_to" | "blocked_by";
related_issue: string; related_issue: string;
}>; }>;
relation?: "blocking" | null;
} }
) { ) {
return this.post( return this.post(
@ -617,6 +618,73 @@ class ProjectIssuesServices extends APIService {
throw error?.response?.data; throw error?.response?.data;
}); });
} }
async getDraftIssues(workspaceSlug: string, projectId: string, params?: any): Promise<any> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-drafts/`, {
params,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async createDraftIssue(
workspaceSlug: string,
projectId: string,
data: any,
user: ICurrentUserResponse
): Promise<any> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-drafts/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async updateDraftIssue(
workspaceSlug: string,
projectId: string,
issueId: string,
data: any,
user: ICurrentUserResponse
): Promise<any> {
return this.patch(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-drafts/${issueId}/`,
data
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async deleteDraftIssue(
workspaceSlug: string,
projectId: string,
issueId: string,
user: ICurrentUserResponse
): Promise<any> {
return this.delete(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-drafts/${issueId}/`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async getDraftIssueById(workspaceSlug: string, projectId: string, issueId: string): Promise<any> {
return this.get(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-drafts/${issueId}/`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
} }
export default new ProjectIssuesServices(); const projectIssuesServices = new ProjectIssuesServices();
export default projectIssuesServices;

189
web/store/draft-issue.ts Normal file
View File

@ -0,0 +1,189 @@
// mobx
import { action, observable, runInAction, makeAutoObservable } from "mobx";
// services
import issueService from "services/issues.service";
// types
import type { ICurrentUserResponse, IIssue } from "types";
class DraftIssuesStore {
issues: { [key: string]: IIssue } = {};
isIssuesLoading: boolean = false;
rootStore: any | null = null;
constructor(_rootStore: any | null = null) {
makeAutoObservable(this, {
issues: observable.ref,
isIssuesLoading: observable.ref,
rootStore: observable.ref,
loadDraftIssues: action,
getIssueById: action,
createDraftIssue: action,
updateDraftIssue: action,
deleteDraftIssue: action,
});
this.rootStore = _rootStore;
}
/**
* @description Fetch all draft issues of a project and hydrate issues field
*/
loadDraftIssues = async (workspaceSlug: string, projectId: string, params?: any) => {
this.isIssuesLoading = true;
try {
const issuesResponse = await issueService.getDraftIssues(workspaceSlug, projectId, params);
const issues = Array.isArray(issuesResponse) ? { allIssues: issuesResponse } : issuesResponse;
runInAction(() => {
this.issues = issues;
this.isIssuesLoading = false;
});
} catch (error) {
this.isIssuesLoading = false;
console.error("Fetching issues error", error);
}
};
/**
* @description Fetch a single draft issue by id and hydrate issues field
* @param workspaceSlug
* @param projectId
* @param issueId
* @returns {IIssue}
*/
getIssueById = async (
workspaceSlug: string,
projectId: string,
issueId: string
): Promise<IIssue> => {
if (this.issues[issueId]) return this.issues[issueId];
try {
const issueResponse: IIssue = await issueService.getDraftIssueById(
workspaceSlug,
projectId,
issueId
);
const issues = {
...this.issues,
[issueId]: { ...issueResponse },
};
runInAction(() => {
this.issues = issues;
});
return issueResponse;
} catch (error) {
throw error;
}
};
/**
* @description Create a new draft issue and hydrate issues field
* @param workspaceSlug
* @param projectId
* @param issueForm
* @param user
* @returns {IIssue}
*/
createDraftIssue = async (
workspaceSlug: string,
projectId: string,
issueForm: IIssue,
user: ICurrentUserResponse
): Promise<IIssue> => {
try {
const issueResponse = await issueService.createDraftIssue(
workspaceSlug,
projectId,
issueForm,
user
);
const issues = {
...this.issues,
[issueResponse.id]: { ...issueResponse },
};
runInAction(() => {
this.issues = issues;
});
return issueResponse;
} catch (error) {
console.error("Creating issue error", error);
throw error;
}
};
updateDraftIssue = async (
workspaceSlug: string,
projectId: string,
issueId: string,
issueForm: Partial<IIssue>,
user: ICurrentUserResponse
) => {
// keep a copy of the issue in the store
const originalIssue = { ...this.issues[issueId] };
// immediately update the issue in the store
const updatedIssue = { ...this.issues[issueId], ...issueForm };
if (updatedIssue.assignees_list) updatedIssue.assignees = updatedIssue.assignees_list;
try {
runInAction(() => {
this.issues[issueId] = { ...updatedIssue };
});
// make a patch request to update the issue
const issueResponse: IIssue = await issueService.updateDraftIssue(
workspaceSlug,
projectId,
issueId,
issueForm,
user
);
const updatedIssues = { ...this.issues };
updatedIssues[issueId] = { ...issueResponse };
runInAction(() => {
this.issues = updatedIssues;
});
} catch (error) {
// if there is an error, revert the changes
runInAction(() => {
this.issues[issueId] = originalIssue;
});
return error;
}
};
deleteDraftIssue = async (
workspaceSlug: string,
projectId: string,
issueId: string,
user: ICurrentUserResponse
) => {
const issues = { ...this.issues };
delete issues[issueId];
try {
runInAction(() => {
this.issues = issues;
});
issueService.deleteDraftIssue(workspaceSlug, projectId, issueId, user);
} catch (error) {
console.error("Deleting issue error", error);
}
};
}
export default DraftIssuesStore;

View File

@ -6,6 +6,7 @@ import ThemeStore from "./theme";
import ProjectStore, { IProjectStore } from "./project"; import ProjectStore, { IProjectStore } from "./project";
import ProjectPublishStore, { IProjectPublishStore } from "./project-publish"; import ProjectPublishStore, { IProjectPublishStore } from "./project-publish";
import IssuesStore from "./issues"; import IssuesStore from "./issues";
import DraftIssuesStore from "./draft-issue";
enableStaticRendering(typeof window === "undefined"); enableStaticRendering(typeof window === "undefined");
@ -15,6 +16,7 @@ export class RootStore {
project: IProjectStore; project: IProjectStore;
projectPublish: IProjectPublishStore; projectPublish: IProjectPublishStore;
issues: IssuesStore; issues: IssuesStore;
draftIssuesStore: DraftIssuesStore;
constructor() { constructor() {
this.user = new UserStore(this); this.user = new UserStore(this);
@ -22,5 +24,6 @@ export class RootStore {
this.project = new ProjectStore(this); this.project = new ProjectStore(this);
this.projectPublish = new ProjectPublishStore(this); this.projectPublish = new ProjectPublishStore(this);
this.issues = new IssuesStore(this); this.issues = new IssuesStore(this);
this.draftIssuesStore = new DraftIssuesStore(this);
} }
} }

21
web/types/issues.d.ts vendored
View File

@ -76,8 +76,10 @@ export type IssueRelationType = "duplicate" | "relates_to" | "blocked_by";
export interface IssueRelation { export interface IssueRelation {
id: string; id: string;
issue: string; issue: string;
related_issue: string; issue_detail: BlockeIssueDetail;
relation_type: IssueRelationType; relation_type: IssueRelationType;
related_issue: string;
relation: "blocking" | null;
} }
export interface IIssue { export interface IIssue {
@ -87,20 +89,8 @@ export interface IIssue {
assignees_list: string[]; assignees_list: string[];
attachment_count: number; attachment_count: number;
attachments: any[]; attachments: any[];
issue_relations: { issue_relations: IssueRelation[];
id: string; related_issues: IssueRelation[];
issue: string;
issue_detail: BlockeIssueDetail;
relation_type: IssueRelationType;
related_issue: string;
}[];
related_issues: {
id: string;
issue: string;
related_issue_detail: BlockeIssueDetail;
relation_type: IssueRelationType;
related_issue: string;
}[];
bridge_id?: string | null; bridge_id?: string | null;
completed_at: Date; completed_at: Date;
created_at: string; created_at: string;
@ -118,6 +108,7 @@ export interface IIssue {
issue_module: IIssueModule | null; issue_module: IIssueModule | null;
labels: string[]; labels: string[];
label_details: any[]; label_details: any[];
is_draft: boolean;
labels_list: string[]; labels_list: string[];
links_list: IIssueLink[]; links_list: IIssueLink[];
link_count: number; link_count: number;