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):
related_issue_detail = IssueProjectLiteSerializer(read_only=True, source="related_issue")
issue_detail = IssueProjectLiteSerializer(read_only=True, source="related_issue")
class Meta:
model = IssueRelation
fields = [
"related_issue_detail",
"issue_detail",
"relation_type",
"related_issue",
"issue",

View File

@ -54,6 +54,7 @@ from plane.api.serializers import (
CommentReactionSerializer,
IssueVoteSerializer,
IssueRelationSerializer,
RelatedIssueSerializer,
IssuePublicSerializer,
)
from plane.api.permissions import (
@ -2112,9 +2113,10 @@ class IssueRelationViewSet(BaseViewSet):
def create(self, request, slug, project_id, issue_id):
try:
related_list = request.data.get("related_list", [])
relation = request.data.get("relation", None)
project = Project.objects.get(pk=project_id)
issueRelation = IssueRelation.objects.bulk_create(
issue_relation = IssueRelation.objects.bulk_create(
[
IssueRelation(
issue_id=related_issue["issue"],
@ -2140,11 +2142,17 @@ class IssueRelationViewSet(BaseViewSet):
current_instance=None,
epoch = int(timezone.now().timestamp())
)
return Response(
IssueRelationSerializer(issueRelation, many=True).data,
status=status.HTTP_201_CREATED,
)
if relation == "blocking":
return Response(
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:
if "already exists" in str(e):
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:
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_activities.append(
IssueActivity(
@ -1112,7 +1131,7 @@ def create_issue_relation_activity(
project=project,
workspace=project.workspace,
comment=f'added {issue_relation.get("relation_type")} relation',
old_identifier=issue_relation.get("issue"),
old_identifier=issue_relation.get("related_issue"),
epoch=epoch,
)
)
@ -1126,22 +1145,42 @@ def delete_issue_relation_activity(
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:
issue = Issue.objects.get(pk=current_instance.get("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 the {current_instance.get("relation_type")} relation',
old_identifier=current_instance.get("issue"),
epoch=epoch,
if current_instance.get("relation_type") == "blocked_by":
relation_type = "blocking"
else:
relation_type = current_instance.get("relation_type")
issue = Issue.objects.get(pk=current_instance.get("issue"))
issue_activities.append(
IssueActivity(
issue_id=current_instance.get("related_issue"),
actor=actor,
verb="deleted",
old_value=f"{project.identifier}-{issue.sequence_id}",
new_value="",
field=relation_type,
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(

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 && (
<div className="ml-12">
<div className="flex flex-col gap-4">
<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 flex-col rounded bg-custom-background-90 border border-custom-border-200 p-2">
<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 issues that are inactive for
</div>
@ -138,7 +138,7 @@ export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleCha
</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 ">
<CustomSearchSelect

View File

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

View File

@ -2,8 +2,9 @@ import { useRouter } from "next/router";
// icons
import { Icon, Tooltip } from "components/ui";
import { CopyPlus } from "lucide-react";
import { Squares2X2Icon } from "@heroicons/react/24/outline";
import { BlockedIcon, BlockerIcon } from "components/icons";
import { BlockedIcon, BlockerIcon, RelatedIcon } from "components/icons";
// helpers
import { renderShortDateWithYearFormat } from "helpers/date-time.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: {
message: (activity) => {
if (activity.new_value === "restore") return "restored 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: {
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: {
message: (activity) => {
@ -157,7 +158,7 @@ const activityDetails: {
},
icon: <BlockerIcon height="12" width="12" color="#6b7280" />,
},
blocks: {
blocked_by: {
message: (activity) => {
if (activity.old_value === "")
return (
@ -176,6 +177,44 @@ const activityDetails: {
},
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: {
message: (activity, showIssue, workspaceSlug) => {
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: {
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: {
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: {
message: (activity) => {
if (activity.verb === "created") return "created the 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: {
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: {
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: {
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: {
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: {
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: {
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: {
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: {
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: {
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 = () => {
const router = useRouter();
const { workspaceSlug, projectId, viewId } = router.query;
const isArchivedIssues = router.pathname.includes("archived-issues");
const isDraftIssues = router.pathname.includes("draft-issues");
const {
displayFilters,
@ -75,7 +87,7 @@ export const IssuesFilterView: React.FC = () => {
return (
<div className="flex items-center gap-2">
{!isArchivedIssues && (
{!isArchivedIssues && !isDraftIssues && (
<div className="flex items-center gap-x-1">
{issueViewOptions.map((option) => (
<Tooltip
@ -105,6 +117,36 @@ export const IssuesFilterView: React.FC = () => {
))}
</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
filters={filters}
onSelect={(option) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -23,7 +23,7 @@ export const SingleExport: React.FC<Props> = ({ service, refreshing }) => {
};
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>
<h4 className="flex items-center gap-2 text-sm">
<span>

View File

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

View File

@ -16,7 +16,7 @@ type Props = {
};
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>
<h4 className="flex items-center gap-2 text-sm">
<span>

View File

@ -15,6 +15,7 @@ import { DangerButton, Loader, PrimaryButton } from "components/ui";
// icons
import GithubLogo from "public/services/github.png";
import SlackLogo from "public/services/slack.png";
import { CheckCircle2 } from "lucide-react";
// types
import { IAppIntegration, IWorkspaceIntegration } from "types";
// fetch-keys
@ -27,13 +28,12 @@ type Props = {
const integrationDetails: { [key: string]: any } = {
github: {
logo: GithubLogo,
installed:
"Activate GitHub integrations on individual projects to sync with specific repositories.",
installed: "Activate GitHub on individual projects to sync with specific repositories.",
notInstalled: "Connect with GitHub with your Plane workspace to sync project issues.",
},
slack: {
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.",
},
};
@ -99,31 +99,22 @@ export const SingleIntegrationCard: React.FC<Props> = ({ integration }) => {
);
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="h-12 w-12 flex-shrink-0">
<div className="h-10 w-10 flex-shrink-0">
<Image
src={integrationDetails[integration.provider].logo}
alt={`${integration.title} Logo`}
/>
</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}
{workspaceIntegrations ? (
isInstalled ? (
<span className="flex items-center gap-1 text-sm font-normal text-green-500">
<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}
{workspaceIntegrations
? isInstalled && <CheckCircle2 className="h-3.5 w-3.5 text-white fill-green-500" />
: null}
</h3>
<p className="text-sm text-custom-text-200">
<p className="text-sm text-custom-text-200 tracking-tight">
{workspaceIntegrations
? isInstalled
? integrationDetails[integration.provider].installed
@ -135,12 +126,12 @@ export const SingleIntegrationCard: React.FC<Props> = ({ integration }) => {
{workspaceIntegrations ? (
isInstalled ? (
<DangerButton onClick={handleRemoveIntegration} loading={deletingIntegration}>
{deletingIntegration ? "Removing..." : "Remove installation"}
<DangerButton onClick={handleRemoveIntegration} loading={deletingIntegration} outline>
{deletingIntegration ? "Uninstalling..." : "Uninstall"}
</DangerButton>
) : (
<PrimaryButton onClick={startAuth} loading={isInstalling}>
{isInstalling ? "Installing..." : "Add installation"}
{isInstalling ? "Installing..." : "Install"}
</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>>;
createMore: boolean;
setCreateMore: React.Dispatch<React.SetStateAction<boolean>>;
handleClose: () => void;
handleDiscardClose: () => void;
status: boolean;
user: ICurrentUserResponse | undefined;
handleFormDirty: (payload: Partial<IIssue> | null) => void;
fieldsToShow: (
| "project"
| "name"
@ -80,18 +81,21 @@ export interface IssueFormProps {
)[];
}
export const IssueForm: FC<IssueFormProps> = ({
handleFormSubmit,
initialData,
projectId,
setActiveProject,
createMore,
setCreateMore,
handleClose,
status,
user,
fieldsToShow,
}) => {
export const IssueForm: FC<IssueFormProps> = (props) => {
const {
handleFormSubmit,
initialData,
projectId,
setActiveProject,
createMore,
setCreateMore,
handleDiscardClose,
status,
user,
fieldsToShow,
handleFormDirty,
} = props;
const [stateModal, setStateModal] = useState(false);
const [labelModal, setLabelModal] = useState(false);
const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false);
@ -109,7 +113,7 @@ export const IssueForm: FC<IssueFormProps> = ({
const {
register,
formState: { errors, isSubmitting },
formState: { errors, isSubmitting, isDirty },
handleSubmit,
reset,
watch,
@ -124,6 +128,25 @@ export const IssueForm: FC<IssueFormProps> = ({
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>) => {
await handleFormSubmit(formData);
@ -543,7 +566,13 @@ export const IssueForm: FC<IssueFormProps> = ({
<ToggleSwitch value={createMore} onChange={() => {}} size="md" />
</div>
<div className="flex items-center gap-2">
<SecondaryButton onClick={handleClose}>Discard</SecondaryButton>
<SecondaryButton
onClick={() => {
handleDiscardClose();
}}
>
Discard
</SecondaryButton>
<PrimaryButton type="submit" loading={isSubmitting}>
{status
? isSubmitting

View File

@ -16,3 +16,9 @@ export * from "./sub-issues-list";
export * from "./label";
export * from "./issue-reaction";
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";
@ -19,8 +19,9 @@ import useInboxView from "hooks/use-inbox-view";
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
import useProjects from "hooks/use-projects";
import useMyIssues from "hooks/my-issues/use-my-issues";
import useLocalStorage from "hooks/use-local-storage";
// components
import { IssueForm } from "components/issues";
import { IssueForm, ConfirmIssueDiscard } from "components/issues";
// types
import type { IIssue } from "types";
// fetch-keys
@ -35,6 +36,7 @@ import {
MODULE_DETAILS,
VIEW_ISSUES,
INBOX_ISSUES,
PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS,
} from "constants/fetch-keys";
// constants
import { INBOX_ISSUE_SOURCE } from "constants/inbox";
@ -73,6 +75,8 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
}) => {
// states
const [createMore, setCreateMore] = useState(false);
const [formDirtyState, setFormDirtyState] = useState<any>(null);
const [showConfirmDiscard, setShowConfirmDiscard] = useState(false);
const [activeProject, setActiveProject] = useState<string | null>(null);
const router = useRouter();
@ -80,7 +84,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
const { displayFilters, params } = useIssuesView();
const { params: calendarParams } = useCalendarIssuesView();
const { order_by, group_by, ...viewGanttParams } = params;
const { ...viewGanttParams } = params;
const { params: inboxParams } = useInboxView();
const { params: spreadsheetParams } = useSpreadsheetIssuesView();
@ -89,20 +93,36 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
const { groupedIssues, mutateMyIssues } = useMyIssues(workspaceSlug?.toString());
const { setValue: setValueInLocalStorage, clearValue: clearLocalStorageValue } =
useLocalStorage<any>("draftedIssue", {});
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 = useCallback(() => {
handleClose();
setActiveProject(null);
}, [handleClose]);
const onClose = () => {
if (!showConfirmDiscard) handleClose();
if (formDirtyState === null) return setActiveProject(null);
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(() => {
// 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>) => {
if (!user) return;
await issuesService
.patchIssue(workspaceSlug as string, activeProject ?? "", data?.id ?? "", payload, user)
.then((res) => {
@ -294,7 +354,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
if (payload.cycle && payload.cycle !== "") addIssueToCycle(res.id, payload.cycle);
if (payload.module && payload.module !== "") addIssueToModule(res.id, payload.module);
if (!createMore) onClose();
if (!createMore) onDiscardClose();
setToastAlert({
type: "success",
@ -331,49 +391,65 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
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>
<>
<ConfirmIssueDiscard
isOpen={showConfirmDiscard}
handleClose={() => setShowConfirmDiscard(false)}
onConfirm={createDraftIssue}
onDiscard={() => {
handleClose();
setActiveProject(null);
setFormDirtyState(null);
setShowConfirmDiscard(false);
clearLocalStorageValue();
}}
/>
<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">
<IssueForm
handleFormSubmit={handleFormSubmit}
initialData={data ?? prePopulateData}
createMore={createMore}
setCreateMore={setCreateMore}
handleClose={onClose}
projectId={activeProject ?? ""}
setActiveProject={setActiveProject}
status={data ? true : false}
user={user}
fieldsToShow={fieldsToShow}
/>
</Dialog.Panel>
</Transition.Child>
<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">
<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>
</Dialog>
</Transition.Root>
</Dialog>
</Transition.Root>
</>
);
};

View File

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

View File

@ -73,7 +73,7 @@ export const SidebarBlockedSelect: React.FC<Props> = ({
...selectedIssues.map((issue) => ({
issue: issueId as string,
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,
})),
],
@ -111,17 +111,17 @@ export const SidebarBlockedSelect: React.FC<Props> = ({
{blockedByIssue && blockedByIssue.length > 0
? blockedByIssue.map((relation) => (
<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"
>
<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"
rel="noopener noreferrer"
className="flex items-center gap-1"
>
<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>
<button
type="button"

View File

@ -81,6 +81,7 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
related_issue_detail: issue.blocker_issue_detail,
})),
],
relation: "blocking",
})
.then((response) => {
submitChanges({
@ -89,7 +90,7 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
...(response ?? []).map((i: any) => ({
id: i.id,
relation_type: i.relation_type,
issue_detail: i.related_issue_detail,
issue_detail: i.issue_detail,
issue: i.related_issue,
})),
],
@ -118,7 +119,7 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
{blockerIssue && blockerIssue.length > 0
? blockerIssue.map((relation) => (
<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"
>
<a
@ -134,9 +135,7 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
type="button"
className="opacity-0 duration-300 group-hover:opacity-100"
onClick={() => {
const updatedBlockers = blockerIssue.filter(
(i) => i.issue_detail?.id !== relation.issue_detail?.id
);
const updatedBlockers = blockerIssue.filter((i) => i.id !== relation.id);
submitChanges({
issue_relations: updatedBlockers,

View File

@ -18,7 +18,7 @@ import { BlockeIssueDetail, IIssue, ISearchIssueResponse } from "types";
type Props = {
issueId?: string;
submitChanges: (formData: Partial<IIssue>) => void;
submitChanges: (formData?: Partial<IIssue>) => void;
watch: UseFormWatch<IIssue>;
disabled?: boolean;
};
@ -69,7 +69,7 @@ export const SidebarDuplicateSelect: React.FC<Props> = (props) => {
related_list: [
...selectedIssues.map((issue) => ({
issue: issueId as string,
related_issue_detail: issue.blocker_issue_detail,
issue_detail: issue.blocker_issue_detail,
related_issue: issue.blocker_issue_detail.id,
relation_type: "duplicate" as const,
})),
@ -90,7 +90,7 @@ export const SidebarDuplicateSelect: React.FC<Props> = (props) => {
?.filter((i) => i.relation_type === "duplicate")
.map((i) => ({
...i,
related_issue_detail: i.issue_detail,
issue_detail: i.issue_detail,
related_issue: i.issue_detail?.id,
})),
];
@ -114,39 +114,35 @@ export const SidebarDuplicateSelect: React.FC<Props> = (props) => {
{duplicateIssuesRelation && duplicateIssuesRelation.length > 0
? duplicateIssuesRelation.map((relation) => (
<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"
>
<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"
rel="noopener noreferrer"
className="flex items-center gap-1"
>
<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>
<button
type="button"
className="opacity-0 duration-300 group-hover:opacity-100"
onClick={() => {
const updatedBlockers = duplicateIssuesRelation.filter(
(i) => i.related_issue_detail?.id !== relation.related_issue_detail?.id
);
submitChanges({
related_issues: updatedBlockers,
});
if (!user) return;
issuesService.deleteIssueRelation(
workspaceSlug as string,
projectId as string,
issueId as string,
relation.id,
user
);
issuesService
.deleteIssueRelation(
workspaceSlug as string,
projectId as string,
issueId as string,
relation.id,
user
)
.then(() => {
submitChanges();
});
}}
>
<X className="h-2 w-2" />

View File

@ -18,7 +18,7 @@ import { BlockeIssueDetail, IIssue, ISearchIssueResponse } from "types";
type Props = {
issueId?: string;
submitChanges: (formData: Partial<IIssue>) => void;
submitChanges: (formData?: Partial<IIssue>) => void;
watch: UseFormWatch<IIssue>;
disabled?: boolean;
};
@ -69,7 +69,7 @@ export const SidebarRelatesSelect: React.FC<Props> = (props) => {
related_list: [
...selectedIssues.map((issue) => ({
issue: issueId as string,
related_issue_detail: issue.blocker_issue_detail,
issue_detail: issue.blocker_issue_detail,
related_issue: issue.blocker_issue_detail.id,
relation_type: "relates_to" as const,
})),
@ -90,7 +90,7 @@ export const SidebarRelatesSelect: React.FC<Props> = (props) => {
?.filter((i) => i.relation_type === "relates_to")
.map((i) => ({
...i,
related_issue_detail: i.issue_detail,
issue_detail: i.issue_detail,
related_issue: i.issue_detail?.id,
})),
];
@ -114,39 +114,35 @@ export const SidebarRelatesSelect: React.FC<Props> = (props) => {
{relatedToIssueRelation && relatedToIssueRelation.length > 0
? relatedToIssueRelation.map((relation) => (
<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"
>
<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"
rel="noopener noreferrer"
className="flex items-center gap-1"
>
<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>
<button
type="button"
className="opacity-0 duration-300 group-hover:opacity-100"
onClick={() => {
const updatedBlockers = relatedToIssueRelation.filter(
(i) => i.related_issue_detail?.id !== relation.related_issue_detail?.id
);
submitChanges({
related_issues: updatedBlockers,
});
if (!user) return;
issuesService.deleteIssueRelation(
workspaceSlug as string,
projectId as string,
issueId as string,
relation.id,
user
);
issuesService
.deleteIssueRelation(
workspaceSlug as string,
projectId as string,
issueId as string,
relation.id,
user
)
.then(() => {
submitChanges();
});
}}
>
<X className="h-2 w-2" />

View File

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

View File

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

View File

@ -43,7 +43,7 @@ export const SingleLabel: React.FC<Props> = ({
>
<CustomMenu.MenuItem onClick={() => addLabelToGroup(label)}>
<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>
</CustomMenu.MenuItem>

View File

@ -204,7 +204,7 @@ export const ProfileIssuesView = () => {
);
const handleIssueAction = useCallback(
(issue: IIssue, action: "copy" | "edit" | "delete") => {
(issue: IIssue, action: "copy" | "edit" | "delete" | "updateDraft") => {
if (action === "copy") makeIssueCopy(issue);
else if (action === "edit") handleEditIssue(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 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>
<DangerButton onClick={handleDeletion} loading={isDeleteLoading}>
{isDeleteLoading ? "Removing..." : "Remove"}

View File

@ -8,6 +8,8 @@ import useSWR from "swr";
import projectService from "services/project.service";
// ui
import { Avatar, CustomSearchSelect } from "components/ui";
// icon
import { Ban } from "lucide-react";
// fetch-keys
import { PROJECT_MEMBERS } from "constants/fetch-keys";
@ -49,7 +51,10 @@ export const MemberSelect: React.FC<Props> = ({ value, onChange }) => {
{selectedOption ? (
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>
}
@ -61,7 +66,12 @@ export const MemberSelect: React.FC<Props> = ({ value, onChange }) => {
{
value: "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>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" />
</button>
@ -249,10 +251,13 @@ const SendProjectInvitationModal: React.FC<Props> = (props) => {
render={({ field }) => (
<CustomSelect
{...field}
label={
<span className="capitalize">
{field.value ? ROLE[field.value] : "Select role"}
</span>
customButton={
<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">
<span className="capitalize">
{field.value ? ROLE[field.value] : "Select role"}
</span>
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</button>
}
input
width="w-full"

View File

@ -43,30 +43,107 @@ export const SettingsSidebar = () => {
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 (
<div className="flex flex-col gap-2 w-80 px-9">
<span className="text-xs text-custom-sidebar-text-400 font-semibold">SETTINGS</span>
<div className="flex flex-col gap-1 w-full">
{projectLinks.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 className="flex flex-col gap-6 w-80 px-5">
<div className="flex flex-col gap-2">
<span className="text-xs text-custom-sidebar-text-400 font-semibold">SETTINGS</span>
<div className="flex flex-col gap-1 w-full">
{(projectId ? projectLinks : workspaceLinks).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>
{!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>
);
};

View File

@ -25,6 +25,7 @@ import {
PhotoFilterOutlined,
SettingsOutlined,
} from "@mui/icons-material";
import { PenSquare } from "lucide-react";
// helpers
import { renderEmoji } from "helpers/emoji.helper";
// types
@ -288,6 +289,16 @@ export const SingleSidebarProject: React.FC<Props> = observer((props) => {
</div>
</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
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" />
</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">
{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
type="button"
className="grid place-items-center group-hover:opacity-100 opacity-0"
@ -215,14 +216,26 @@ export const SingleState: React.FC<Props> = ({
>
{state.default ? (
<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>
) : groupLength === 1 ? (
<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>
) : (
<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>
</div>

View File

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

View File

@ -6,7 +6,7 @@ type Props = {
};
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>
{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">

View File

@ -1,9 +1,10 @@
import { useRouter } from "next/router";
// icons
import { CopyPlus } from "lucide-react";
import { Icon, Tooltip } from "components/ui";
import { Squares2X2Icon } from "@heroicons/react/24/outline";
import { BlockedIcon, BlockerIcon } from "components/icons";
import { BlockedIcon, BlockerIcon, RelatedIcon } from "components/icons";
// helpers
import { renderShortDateWithYearFormat } from "helpers/date-time.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: {
@ -81,7 +82,7 @@ const activityDetails: {
if (activity.new_value === "restore") return "restored 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: {
@ -99,7 +100,7 @@ const activityDetails: {
{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: {
@ -117,7 +118,7 @@ const activityDetails: {
icon: <BlockerIcon height="12" width="12" color="#6b7280" />,
},
blocks: {
blocked_by: {
message: (activity) => (
<>
{activity.old_value === ""
@ -132,6 +133,36 @@ const activityDetails: {
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: {
message: (activity) => (
<>
@ -156,7 +187,7 @@ const activityDetails: {
</button>
</>
),
icon: <Icon iconName="contrast" className="!text-sm" aria-hidden="true" />,
icon: <Icon iconName="contrast" className="!text-2xl" aria-hidden="true" />,
},
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: {
@ -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: {
@ -198,7 +229,7 @@ const activityDetails: {
if (activity.verb === "created") return "created the 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: {
@ -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: {
@ -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: {
@ -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: {
@ -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: {
@ -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: {
@ -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: {
@ -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: {
@ -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.map((issue) => (
<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"
>
<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"
rel="noopener noreferrer"
className="flex items-center gap-1"
>
<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>
<button
type="button"
className="duration-300"
onClick={() => {
const updatedBlocked = blockedIssue.filter(
(i) => i.related_issue_detail?.id !== issue.related_issue_detail?.id
);
const updatedBlocked = blockedIssue.filter((i) => i?.id !== issue?.id);
if (!user) return;

View File

@ -79,7 +79,7 @@ const ConfirmWorkspaceMemberRemove: React.FC<Props> = ({ isOpen, onClose, data,
</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>
<DangerButton onClick={handleDeletion} loading={isDeleteLoading}>
{isDeleteLoading ? "Removing..." : "Remove"}

View File

@ -6,7 +6,6 @@ export * from "./help-section";
export * from "./issues-list";
export * from "./issues-pie-chart";
export * from "./issues-stats";
export * from "./settings-header";
export * from "./sidebar-dropdown";
export * from "./sidebar-menu";
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
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
import { useMobxStore } from "lib/mobx/store-provider";
export const WorkspaceSidebarQuickAction = () => {
const store: any = useMobxStore();
return (
<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>
const [isDraftIssueModalOpen, setIsDraftIssueModalOpen] = useState(false);
<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);
const { storedValue, clearValue } = useLocalStorage<any>(
"draftedIssue",
JSON.stringify(undefined)
);
return (
<>
<CreateUpdateDraftIssueModal
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" />
</button>
</div>
<div
className={`flex items-center justify-between w-full rounded cursor-pointer px-2 gap-1 group ${
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}`;
};
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) =>
`PROJECT_ISSUES_DETAILS_${issueId.toUpperCase()}`;
export const PROJECT_ISSUES_PROPERTIES = (projectId: string) =>

View File

@ -20,6 +20,7 @@ import {
CYCLE_ISSUES_WITH_PARAMS,
MODULE_ISSUES_WITH_PARAMS,
PROJECT_ARCHIVED_ISSUES_LIST_WITH_PARAMS,
PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS,
PROJECT_ISSUES_LIST_WITH_PARAMS,
STATES_LIST,
VIEW_ISSUES,
@ -38,6 +39,7 @@ const useIssuesView = () => {
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId, viewId, archivedIssueId } = router.query;
const isArchivedIssues = router.pathname.includes("archived-issues");
const isDraftIssues = router.pathname.includes("draft-issues");
const params: any = {
order_by: displayFilters?.order_by,
@ -72,6 +74,15 @@ const useIssuesView = () => {
: 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(
workspaceSlug && projectId && cycleId && params
? CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params)
@ -151,6 +162,8 @@ const useIssuesView = () => {
? viewIssues
: isArchivedIssues
? projectArchivedIssues
: isDraftIssues
? draftIssues
: projectIssues;
if (Array.isArray(issuesToGroup)) return { allIssues: issuesToGroup };
@ -169,6 +182,8 @@ const useIssuesView = () => {
moduleId,
viewId,
isArchivedIssues,
isDraftIssues,
draftIssues,
emptyStatesObject,
]);
@ -191,6 +206,8 @@ const useIssuesView = () => {
? mutateViewIssues
: isArchivedIssues
? mutateProjectArchivedIssues
: isDraftIssues
? mutateDraftIssues
: mutateProjectIssues,
filters,
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";
// layouts
import { WorkspaceAuthorizationLayout } from "layouts/auth-layout";
import SettingsNavbar from "layouts/settings-navbar";
// components
import { ActivityIcon, ActivityMessage } from "components/core";
import { TipTapEditor } from "components/tiptap";
@ -20,6 +19,7 @@ import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
import { USER_ACTIVITY } from "constants/fetch-keys";
// helper
import { timeAgo } from "helpers/date-time.helper";
import { SettingsSidebar } from "components/project";
const ProfileActivity = () => {
const router = useRouter();
@ -38,186 +38,179 @@ const ProfileActivity = () => {
</Breadcrumbs>
}
>
<div className="p-8">
<div className="mb-8 space-y-6">
<div>
<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 className="flex flex-row gap-2 h-full">
<div className="w-80 pt-8 overflow-y-hidden flex-shrink-0">
<SettingsSidebar />
</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">
<ChatBubbleLeftEllipsisIcon
className="h-3.5 w-3.5 text-custom-text-200"
aria-hidden="true"
/>
</span>
</div>
<div className="min-w-0 flex-1">
<div>
<div className="text-xs">
{activityItem.actor_detail.is_bot
? activityItem.actor_detail.first_name + " Bot"
: activityItem.actor_detail.display_name}
</div>
<p className="mt-0.5 text-xs text-custom-text-200">
Commented {timeAgo(activityItem.created_at)}
</p>
{userActivity ? (
<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">
<h3 className="text-xl font-medium">Acitivity</h3>
</div>
<div className={`flex flex-col gap-2 py-4 w-full`}>
<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-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 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 className="min-w-0 flex-1">
<div>
<div className="text-xs">
{activityItem.actor_detail.is_bot
? activityItem.actor_detail.first_name + " Bot"
: activityItem.actor_detail.display_name}
</div>
<p className="mt-0.5 text-xs text-custom-text-200">
Commented {timeAgo(activityItem.created_at)}
</p>
</div>
<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>
);
}
);
}
const message =
activityItem.verb === "created" &&
activityItem.field !== "cycles" &&
activityItem.field !== "modules" &&
activityItem.field !== "attachment" &&
activityItem.field !== "link" &&
activityItem.field !== "estimate" ? (
<span className="text-custom-text-200">
created{" "}
<Link
href={`/${workspaceSlug}/projects/${activityItem.project}/issues/${activityItem.issue}`}
>
<a className="inline-flex items-center hover:underline">
this issue. <ArrowTopRightOnSquareIcon className="ml-1 h-3.5 w-3.5" />
</a>
</Link>
</span>
) : activityItem.field ? (
<ActivityMessage activity={activityItem} showIssue />
) : (
"created the issue."
);
const message =
activityItem.verb === "created" &&
activityItem.field !== "cycles" &&
activityItem.field !== "modules" &&
activityItem.field !== "attachment" &&
activityItem.field !== "link" &&
activityItem.field !== "estimate" ? (
<span className="text-custom-text-200">
created{" "}
<Link
href={`/${workspaceSlug}/projects/${activityItem.project}/issues/${activityItem.issue}`}
>
<a className="inline-flex items-center hover:underline">
this issue. <ArrowTopRightOnSquareIcon className="ml-1 h-3.5 w-3.5" />
</a>
</Link>
</span>
) : activityItem.field ? (
<ActivityMessage activity={activityItem} showIssue />
) : (
"created the issue."
);
if ("field" in activityItem && activityItem.field !== "updated_by") {
return (
<li key={activityItem.id}>
<div className="relative pb-1">
{userActivity.results.length > 1 &&
activityIdx !== userActivity.results.length - 1 ? (
<span
className="absolute top-5 left-5 -ml-px h-full w-0.5 bg-custom-background-80"
aria-hidden="true"
/>
) : null}
<div className="relative flex items-start space-x-2">
<>
<div>
<div className="relative px-1.5">
<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 ? (
activityItem.new_value === "restore" ? (
<Icon
iconName="history"
className="text-sm text-custom-text-200"
if ("field" in activityItem && activityItem.field !== "updated_by") {
return (
<li key={activityItem.id}>
<div className="relative pb-1">
<div className="relative flex items-center space-x-2">
<>
<div>
<div className="relative px-1.5">
<div className="mt-1.5">
<div className="flex h-6 w-6 items-center justify-center">
{activityItem.field ? (
activityItem.new_value === "restore" ? (
<Icon
iconName="history"
className="!text-2xl text-custom-text-200"
/>
) : (
<ActivityIcon activity={activityItem} />
)
) : activityItem.actor_detail.avatar &&
activityItem.actor_detail.avatar !== "" ? (
<img
src={activityItem.actor_detail.avatar}
alt={activityItem.actor_detail.display_name}
height={24}
width={24}
className="rounded-full"
/>
) : (
<ActivityIcon activity={activityItem} />
)
) : activityItem.actor_detail.avatar &&
activityItem.actor_detail.avatar !== "" ? (
<img
src={activityItem.actor_detail.avatar}
alt={activityItem.actor_detail.display_name}
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
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.display_name?.charAt(0)}
</div>
)}
</div>
</div>
</div>
</div>
</div>
<div className="min-w-0 flex-1 py-3">
<div className="text-xs text-custom-text-200 break-words">
{activityItem.field === "archived_at" &&
activityItem.new_value !== "restore" ? (
<span className="text-gray font-medium">Plane</span>
) : activityItem.actor_detail.is_bot ? (
<span className="text-gray font-medium">
{activityItem.actor_detail.first_name} Bot
<div className="min-w-0 flex-1 py-4 border-b border-custom-border-200">
<div className="text-sm text-custom-text-200 break-words">
{activityItem.field === "archived_at" &&
activityItem.new_value !== "restore" ? (
<span className="text-gray font-medium">Plane</span>
) : activityItem.actor_detail.is_bot ? (
<span className="text-gray font-medium">
{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>
) : (
<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>
</li>
);
}
})}
</ul>
</div>
</li>
);
}
})}
</ul>
</div>
</section>
) : (
<Loader className="space-y-5">
<Loader.Item height="40px" />

View File

@ -1,4 +1,6 @@
import React, { useEffect, useState } from "react";
import { useRouter } from "next/router";
import Link from "next/link";
// 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";
// layouts
import { WorkspaceAuthorizationLayout } from "layouts/auth-layout";
import SettingsNavbar from "layouts/settings-navbar";
// components
import { ImagePickerPopover, ImageUploadModal } from "components/core";
import { SettingsSidebar } from "components/project";
// ui
import {
CustomSearchSelect,
CustomSelect,
DangerButton,
Input,
SecondaryButton,
Spinner,
} from "components/ui";
import { CustomSearchSelect, CustomSelect, Input, PrimaryButton, Spinner } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// icons
import { UserIcon } from "@heroicons/react/24/outline";
import { UserCircle } from "lucide-react";
// types
import type { NextPage } from "next";
import type { IUser } from "types";
@ -46,6 +42,9 @@ const Profile: NextPage = () => {
const [isRemoving, setIsRemoving] = useState(false);
const [isImageUploadModalOpen, setIsImageUploadModalOpen] = useState(false);
const router = useRouter();
const { workspaceSlug } = router.query;
const {
register,
handleSubmit,
@ -126,6 +125,7 @@ const Profile: NextPage = () => {
if (!prevData) return prevData;
return { ...prevData, avatar: "" };
}, false);
setIsRemoving(false);
})
.catch(() => {
setToastAlert({
@ -155,6 +155,8 @@ const Profile: NextPage = () => {
<ImageUploadModal
isOpen={isImageUploadModalOpen}
onClose={() => setIsImageUploadModalOpen(false)}
isRemoving={isRemoving}
handleDelete={() => handleDelete(myProfile?.avatar, true)}
onSuccess={(url) => {
setValue("avatar", url);
handleSubmit(onSubmit)();
@ -164,81 +166,49 @@ const Profile: NextPage = () => {
userImage
/>
{myProfile ? (
<div className="p-8">
<div className="mb-8 space-y-6">
<div>
<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>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="flex flex-row gap-2 h-full">
<div className="w-80 pt-8 overflow-y-hidden flex-shrink-0">
<SettingsSidebar />
</div>
<SettingsNavbar profilePage />
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8 sm:space-y-12">
<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">Profile Picture</h4>
<p className="text-sm text-custom-text-200">
Max file size is 5MB. Supported file types are .jpg and .png.
</p>
</div>
<div className="col-span-12 sm:col-span-6">
<div className="flex items-center gap-4">
<button type="button" onClick={() => setIsImageUploadModalOpen(true)}>
{!watch("avatar") || watch("avatar") === "" ? (
<div className="h-12 w-12 rounded-md bg-custom-background-80 p-2">
<UserIcon className="h-full w-full text-custom-text-200" />
</div>
) : (
<div className="relative h-12 w-12 overflow-hidden">
<img
src={watch("avatar")}
className="absolute top-0 left-0 h-full w-full object-cover rounded-md"
onClick={() => setIsImageUploadModalOpen(true)}
alt={myProfile.display_name}
/>
</div>
)}
</button>
<div className="flex items-center gap-2">
<SecondaryButton
onClick={() => {
setIsImageUploadModalOpen(true);
}}
>
Upload
</SecondaryButton>
{myProfile.avatar && myProfile.avatar !== "" && (
<DangerButton
onClick={() => handleDelete(myProfile.avatar, true)}
loading={isRemoving}
>
{isRemoving ? "Removing..." : "Remove"}
</DangerButton>
)}
<div className={`flex flex-col gap-8 pr-9 py-9 w-full overflow-y-auto`}>
<div className="relative h-44 w-full mt-6">
<img
src={
watch("cover_image") ??
"https://images.unsplash.com/photo-1506383796573-caf02b4a79ab"
}
className="h-44 w-full rounded-lg object-cover"
alt={myProfile?.name ?? "Cover image"}
/>
<div className="flex items-end justify-between absolute left-8 -bottom-6">
<div className="flex gap-3">
<div className="flex items-center justify-center bg-custom-background-90 h-16 w-16 rounded-lg">
<button type="button" onClick={() => setIsImageUploadModalOpen(true)}>
{!watch("avatar") || watch("avatar") === "" ? (
<div className="h-16 w-16 rounded-md bg-custom-background-80 p-2">
<UserIcon className="h-full w-full text-custom-text-200" />
</div>
) : (
<div className="relative h-16 w-16 overflow-hidden">
<img
src={watch("avatar")}
className="absolute top-0 left-0 h-full w-full object-cover rounded-lg"
onClick={() => setIsImageUploadModalOpen(true)}
alt={myProfile.display_name}
/>
</div>
)}
</button>
</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">
<h4 className="text-lg font-semibold">Cover Photo</h4>
<p className="text-sm text-custom-text-200">
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">
<div className="flex absolute right-3 bottom-3">
<Controller
control={control}
name="cover_image"
render={() => (
<ImagePickerPopover
label={"Change cover"}
onChange={(imageUrl) => {
@ -249,157 +219,167 @@ const Profile: NextPage = () => {
"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>
<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 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">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>
</form>
) : (
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
<Spinner />

View File

@ -3,7 +3,6 @@ import { useEffect, useState } from "react";
import useUserAuth from "hooks/use-user-auth";
// layouts
import { WorkspaceAuthorizationLayout } from "layouts/auth-layout";
import SettingsNavbar from "layouts/settings-navbar";
// components
import { CustomThemeSelector, ThemeSwitch } from "components/core";
// ui
@ -15,6 +14,7 @@ import { ICustomTheme } from "types";
import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
import { SettingsSidebar } from "components/project";
const ProfilePreferences = observer(() => {
const { user: myProfile } = useUserAuth();
@ -59,18 +59,16 @@ const ProfilePreferences = observer(() => {
}
>
{myProfile ? (
<div className="p-8">
<div className="mb-8 space-y-6">
<div>
<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 className="flex flex-row gap-2 h-full">
<div className="w-80 pt-8 overflow-y-hidden flex-shrink-0">
<SettingsSidebar />
</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">
<h4 className="text-lg font-semibold text-custom-text-100">Theme</h4>
<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>
}
>
<div className="flex flex-row gap-2">
<div className="w-80 py-8">
<div className="flex flex-row gap-2 h-full">
<div className="w-80 pt-8 overflow-y-hidden flex-shrink-0">
<SettingsSidebar />
</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">
<h3 className="text-xl font-medium">Automations</h3>
</div>

View File

@ -125,11 +125,11 @@ const EstimatesSettings: NextPage = () => {
</Breadcrumbs>
}
>
<div className="flex flex-row gap-2 h-full overflow-hidden">
<div className="w-80 py-8">
<div className="flex flex-row gap-2 h-full">
<div className="w-80 pt-8 overflow-y-hidden flex-shrink-0">
<SettingsSidebar />
</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">
<h3 className="text-xl font-medium">Estimates</h3>
<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";
// 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
import { IProject } from "types";
import type { NextPage } from "next";
@ -33,7 +34,10 @@ const featuresList = [
title: "Cycles",
description:
"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",
},
{
@ -61,7 +65,7 @@ const featuresList = [
title: "Inbox",
description:
"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",
},
];
@ -149,11 +153,11 @@ const FeaturesSettings: NextPage = () => {
</Breadcrumbs>
}
>
<div className="flex flex-row gap-2">
<div className="w-80 py-8">
<div className="flex flex-row gap-2 h-full">
<div className="w-80 pt-8 overflow-y-hidden flex-shrink-0">
<SettingsSidebar />
</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">
<h3 className="text-xl font-medium">Features</h3>
</div>

View File

@ -25,7 +25,6 @@ import {
TextArea,
Loader,
CustomSelect,
SecondaryButton,
DangerButton,
Icon,
PrimaryButton,
@ -67,7 +66,7 @@ const GeneralSettings: NextPage = () => {
: null
);
const { data: memberDetails, error } = useSWR(
const { data: memberDetails } = useSWR(
workspaceSlug && projectId ? USER_PROJECT_VIEW(projectId.toString()) : null,
workspaceSlug && projectId
? () => projectService.projectMemberMe(workspaceSlug.toString(), projectId.toString())
@ -168,6 +167,7 @@ const GeneralSettings: NextPage = () => {
};
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;
@ -190,21 +190,21 @@ const GeneralSettings: NextPage = () => {
onClose={() => setSelectedProject(null)}
user={user}
/>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="flex flex-row gap-2">
<div className="w-80 py-8">
<SettingsSidebar />
</div>
<div className={`pr-9 py-8 w-full ${isAdmin ? "" : "opacity-60"}`}>
<div className="flex flex-row gap-2 h-full">
<div className="w-80 pt-8 overflow-y-hidden flex-shrink-0">
<SettingsSidebar />
</div>
<div className={`pr-9 py-8 w-full overflow-y-auto ${isAdmin ? "" : "opacity-60"}`}>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="relative h-44 w-full mt-6">
<img
src={watch("cover_image")!}
alt={watch("cover_image")!}
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 gap-3">
<div className="flex items-center justify-center bg-custom-background-90 h-[52px] w-[52px] rounded-lg">
<div className="flex items-end justify-between gap-3 absolute bottom-4 w-full px-4">
<div className="flex gap-3 flex-grow truncate">
<div className="flex items-center justify-center flex-shrink-0 bg-custom-background-90 h-[52px] w-[52px] rounded-lg">
{projectDetails ? (
<div className="h-7 w-7 grid place-items-center">
<Controller
@ -226,8 +226,8 @@ const GeneralSettings: NextPage = () => {
</Loader>
)}
</div>
<div className="flex flex-col gap-1 text-white">
<span className="text-lg font-semibold">{watch("name")}</span>
<div className="flex flex-col gap-1 text-white truncate">
<span className="text-lg font-semibold truncate">{watch("name")}</span>
<span className="flex items-center gap-2 text-sm">
<span>
{watch("identifier")} . {currentNetwork?.label}
@ -236,7 +236,7 @@ const GeneralSettings: NextPage = () => {
</div>
</div>
<div className="flex justify-center">
<div className="flex justify-center flex-shrink-0">
{projectDetails ? (
<div>
<Controller
@ -350,7 +350,7 @@ const GeneralSettings: NextPage = () => {
<CustomSelect
value={value}
onChange={onChange}
label={currentNetwork?.label ?? "Select network"}
label={selectedNetwork?.label ?? "Select network"}
className="!border-custom-border-200 !shadow-none"
input
disabled={!isAdmin}
@ -388,62 +388,63 @@ const GeneralSettings: NextPage = () => {
)}
</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">
{({ 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">Danger Zone</span>
<Icon iconName={open ? "expand_more" : "expand_less"} 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>
{projectDetails ? (
<div>
<DangerButton
onClick={() => setSelectedProject(projectDetails.id ?? null)}
className="!text-sm"
outline
>
Delete my project
</DangerButton>
</div>
) : (
<Loader className="mt-2 w-full">
<Loader.Item height="38px" width="144px" />
</Loader>
)}
<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>
{projectDetails ? (
<div>
<DangerButton
onClick={() => setSelectedProject(projectDetails.id ?? null)}
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>
</Transition>
</div>
)}
</Disclosure>
</div>
</Disclosure.Panel>
</Transition>
</div>
)}
</Disclosure>
)}
</form>
</div>
</form>
</div>
</ProjectAuthorizationWrapper>
);
};

View File

@ -54,18 +54,20 @@ const ProjectIntegrations: NextPage = () => {
link={`/${workspaceSlug}/projects/${projectId}/issues`}
linkTruncate
/>
<BreadcrumbItem title="Integrations" unshrinkTitle />
<BreadcrumbItem title="Integrations Settings" unshrinkTitle />
</Breadcrumbs>
}
>
<div className="flex flex-row gap-2 h-full overflow-hidden">
<div className="w-80 py-8">
<div className="flex flex-row gap-2 h-full">
<div className="w-80 pt-8 overflow-y-hidden flex-shrink-0">
<SettingsSidebar />
</div>
{workspaceIntegrations ? (
workspaceIntegrations.length > 0 ? (
<section className="pr-9 py-8 overflow-y-auto w-full">
<IntegrationAndImportExportBanner bannerName="Integrations" />
<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">
<h3 className="text-xl font-medium">Integrations</h3>
</div>
{workspaceIntegrations ? (
workspaceIntegrations.length > 0 ? (
<div>
{workspaceIntegrations.map((integration) => (
<SingleIntegration
@ -74,26 +76,26 @@ const ProjectIntegrations: NextPage = () => {
/>
))}
</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
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`),
}}
/>
)
) : (
<Loader className="space-y-5">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
)}
<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>
);

View File

@ -113,11 +113,11 @@ const LabelsSettings: NextPage = () => {
</Breadcrumbs>
}
>
<div className="flex flex-row gap-2">
<div className="w-80 py-8">
<div className="flex flex-row gap-2 h-full">
<div className="w-80 pt-8 overflow-y-hidden flex-shrink-0">
<SettingsSidebar />
</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">
<h3 className="text-xl font-medium">Labels</h3>
@ -129,7 +129,7 @@ const LabelsSettings: NextPage = () => {
Add label
</PrimaryButton>
</div>
<div className="space-y-3 py-6">
<div className="space-y-3 py-6 h-full w-full">
{labelForm && (
<CreateUpdateLabelInline
labelForm={labelForm}

View File

@ -273,11 +273,11 @@ const MembersSettings: NextPage = () => {
user={user}
onSuccess={() => mutateMembers()}
/>
<div className="flex flex-row gap-2">
<div className="w-80 py-8">
<div className="flex flex-row gap-2 h-full">
<div className="w-80 pt-8 overflow-y-hidden flex-shrink-0">
<SettingsSidebar />
</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">
<h3 className="text-xl font-medium">Defaults</h3>
</div>
@ -334,7 +334,7 @@ const MembersSettings: NextPage = () => {
</div>
<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>
</div>
{!projectMembers || !projectInvitations ? (
@ -386,11 +386,13 @@ const MembersSettings: NextPage = () => {
<h4 className="text-sm">{member.display_name || member.email}</h4>
)}
{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 className="flex items-center gap-2 text-xs">
<div className="flex items-center gap-3 text-xs">
{!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">
Pending

View File

@ -73,11 +73,11 @@ const StatesSettings: NextPage = () => {
</Breadcrumbs>
}
>
<div className="flex flex-row gap-2">
<div className="w-80 py-8">
<div className="flex flex-row gap-2 h-full">
<div className="w-80 pt-8 overflow-y-hidden flex-shrink-0">
<SettingsSidebar />
</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">
<h3 className="text-xl font-medium">States</h3>
</div>

View File

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

View File

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

View File

@ -16,14 +16,16 @@ import useUserAuth from "hooks/use-user-auth";
import { WorkspaceAuthorizationLayout } from "layouts/auth-layout";
// components
import { ImageUploadModal } from "components/core";
import { DeleteWorkspaceModal, SettingsHeader } from "components/workspace";
import { DeleteWorkspaceModal } from "components/workspace";
import { SettingsSidebar } from "components/project";
// 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";
// icons
import { LinkIcon } from "@heroicons/react/24/outline";
import { Pencil } from "lucide-react";
// helpers
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
import { truncateText } from "helpers/string.helper";
// types
import type { IWorkspace } from "types";
import type { NextPage } from "next";
@ -135,6 +137,7 @@ const WorkspaceSettings: NextPage = () => {
logo: "",
};
});
setIsImageUploadModalOpen(false);
})
.catch(() => {
setToastAlert({
@ -162,6 +165,8 @@ const WorkspaceSettings: NextPage = () => {
<ImageUploadModal
isOpen={isImageUploadModalOpen}
onClose={() => setIsImageUploadModalOpen(false)}
isRemoving={isImageRemoving}
handleDelete={() => handleDelete(activeWorkspace?.logo)}
onSuccess={(imageUrl) => {
setIsImageUploading(true);
setValue("logo", imageUrl);
@ -178,67 +183,109 @@ const WorkspaceSettings: NextPage = () => {
data={activeWorkspace ?? null}
user={user}
/>
<div className="p-8">
<SettingsHeader />
<div className="flex flex-row gap-2 h-full">
<div className="w-80 pt-8 overflow-y-hidden flex-shrink-0">
<SettingsSidebar />
</div>
{activeWorkspace ? (
<div className={`space-y-8 sm:space-y-12 ${isAdmin ? "" : "opacity-60"}`}>
<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">Logo</h4>
<p className="text-sm text-custom-text-200">
Max file size is 5MB. Supported file types are .jpg and .png.
</p>
<div className={`pr-9 py-8 w-full overflow-y-auto ${isAdmin ? "" : "opacity-60"}`}>
<div className="flex gap-5 items-center pb-7 border-b border-custom-border-200">
<div className="flex flex-col gap-1">
<button
type="button"
onClick={() => setIsImageUploadModalOpen(true)}
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 className="col-span-12 sm:col-span-6">
<div className="flex items-center gap-4">
<div className="flex flex-col gap-1">
<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
type="button"
className="flex items-center gap-1.5 text-xs text-left text-custom-primary-100 font-medium"
onClick={() => setIsImageUploadModalOpen(true)}
disabled={!isAdmin}
>
{watch("logo") && watch("logo") !== null && watch("logo") !== "" ? (
<div className="relative mx-auto flex h-12 w-12">
<img
src={watch("logo")!}
className="absolute top-0 left-0 h-full w-full object-cover rounded-md"
alt="Workspace Logo"
/>
</div>
<>
<Pencil className="h-3 w-3" />
Edit logo
</>
) : (
<div className="relative flex h-12 w-12 items-center justify-center rounded bg-gray-700 p-4 uppercase text-white">
{activeWorkspace?.name?.charAt(0) ?? "N"}
</div>
"Upload logo"
)}
</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 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">URL</h4>
<p className="text-sm text-custom-text-200">Your workspace URL.</p>
</div>
<div className="col-span-12 flex items-center gap-2 sm:col-span-6">
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-8 my-10">
<div className="grid grid-col grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 items-center justify-between gap-10 w-full">
<div className="flex flex-col gap-1 ">
<h4 className="text-sm">Workspace Name</h4>
<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}
/>
</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
id="url"
name="url"
@ -253,114 +300,66 @@ const WorkspaceSettings: NextPage = () => {
disabled
/>
</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 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">Name</h4>
<p className="text-sm text-custom-text-200">Give a name to your workspace.</p>
</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",
},
}}
<div className="flex items-center justify-between py-2">
<PrimaryButton
onClick={handleSubmit(onSubmit)}
loading={isSubmitting}
disabled={!isAdmin}
/>
</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">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>
)}
/>
>
{isSubmitting ? "Updating..." : "Update Workspace"}
</PrimaryButton>
</div>
</div>
{isAdmin && (
<>
<div className="sm:text-right">
<SecondaryButton
onClick={handleSubmit(onSubmit)}
loading={isSubmitting}
disabled={!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"
>
{isSubmitting ? "Updating..." : "Update Workspace"}
</SecondaryButton>
<span className="text-xl tracking-tight">Delete Workspace</span>
<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 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">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>
</>
)}
)}
</Disclosure>
</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 />
</div>
)}

View File

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

View File

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

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";
related_issue: string;
}>;
relation?: "blocking" | null;
}
) {
return this.post(
@ -617,6 +618,73 @@ class ProjectIssuesServices extends APIService {
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 ProjectPublishStore, { IProjectPublishStore } from "./project-publish";
import IssuesStore from "./issues";
import DraftIssuesStore from "./draft-issue";
enableStaticRendering(typeof window === "undefined");
@ -15,6 +16,7 @@ export class RootStore {
project: IProjectStore;
projectPublish: IProjectPublishStore;
issues: IssuesStore;
draftIssuesStore: DraftIssuesStore;
constructor() {
this.user = new UserStore(this);
@ -22,5 +24,6 @@ export class RootStore {
this.project = new ProjectStore(this);
this.projectPublish = new ProjectPublishStore(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 {
id: string;
issue: string;
related_issue: string;
issue_detail: BlockeIssueDetail;
relation_type: IssueRelationType;
related_issue: string;
relation: "blocking" | null;
}
export interface IIssue {
@ -87,20 +89,8 @@ export interface IIssue {
assignees_list: string[];
attachment_count: number;
attachments: any[];
issue_relations: {
id: string;
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;
}[];
issue_relations: IssueRelation[];
related_issues: IssueRelation[];
bridge_id?: string | null;
completed_at: Date;
created_at: string;
@ -118,6 +108,7 @@ export interface IIssue {
issue_module: IIssueModule | null;
labels: string[];
label_details: any[];
is_draft: boolean;
labels_list: string[];
links_list: IIssueLink[];
link_count: number;