Merge pull request #189 from makeplane/sync/ce-ee

sync: merge conflicts need to be resolved
This commit is contained in:
sriram veeraghanta
2024-05-01 18:12:28 +05:30
committed by GitHub
48 changed files with 581 additions and 491 deletions

View File

@@ -32,7 +32,6 @@ from plane.api.serializers import (
LabelSerializer,
)
from plane.app.permissions import (
WorkspaceEntityPermission,
ProjectEntityPermission,
ProjectLitePermission,
ProjectMemberPermission,

View File

@@ -38,7 +38,7 @@ from plane.db.models import (
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.issue_filters import issue_filters
from plane.utils.user_timezone_converter import user_timezone_converter
class CycleIssueViewSet(WebhookMixin, BaseViewSet):
serializer_class = CycleIssueSerializer
@@ -191,6 +191,11 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
"is_draft",
"archived_at",
)
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(
issues, datetime_fields, request.user.user_timezone
)
return Response(issues, status=status.HTTP_200_OK)
def create(self, request, slug, project_id, cycle_id):

View File

@@ -47,7 +47,7 @@ from plane.db.models import (
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.issue_filters import issue_filters
from plane.utils.user_timezone_converter import user_timezone_converter
class IssueArchiveViewSet(BaseViewSet):
permission_classes = [
@@ -239,6 +239,11 @@ class IssueArchiveViewSet(BaseViewSet):
"is_draft",
"archived_at",
)
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(
issue_queryset, datetime_fields, request.user.user_timezone
)
return Response(issues, status=status.HTTP_200_OK)
def retrieve(self, request, slug, project_id, pk=None):

View File

@@ -50,6 +50,7 @@ from plane.db.models import (
Project,
)
from plane.utils.issue_filters import issue_filters
from plane.utils.user_timezone_converter import user_timezone_converter
# Module imports
from .. import BaseAPIView, BaseViewSet, WebhookMixin
@@ -241,6 +242,10 @@ class IssueListEndpoint(BaseAPIView):
"is_draft",
"archived_at",
)
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(
issues, datetime_fields, request.user.user_timezone
)
return Response(issues, status=status.HTTP_200_OK)
@@ -440,6 +445,10 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
"is_draft",
"archived_at",
)
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(
issue_queryset, datetime_fields, request.user.user_timezone
)
return Response(issues, status=status.HTTP_200_OK)
def create(self, request, slug, project_id):
@@ -503,6 +512,10 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
)
.first()
)
datetime_fields = ["created_at", "updated_at"]
issue = user_timezone_converter(
issue, datetime_fields, request.user.user_timezone
)
return Response(issue, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

View File

@@ -45,6 +45,7 @@ from plane.db.models import (
Project,
)
from plane.utils.issue_filters import issue_filters
from plane.utils.user_timezone_converter import user_timezone_converter
# Module imports
from .. import BaseViewSet
@@ -229,6 +230,10 @@ class IssueDraftViewSet(BaseViewSet):
"is_draft",
"archived_at",
)
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(
issue_queryset, datetime_fields, request.user.user_timezone
)
return Response(issues, status=status.HTTP_200_OK)
def create(self, request, slug, project_id):

View File

@@ -31,6 +31,7 @@ from plane.db.models import (
IssueAttachment,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.user_timezone_converter import user_timezone_converter
from collections import defaultdict
@@ -132,6 +133,10 @@ class SubIssuesEndpoint(BaseAPIView):
"is_draft",
"archived_at",
)
datetime_fields = ["created_at", "updated_at"]
sub_issues = user_timezone_converter(
sub_issues, datetime_fields, request.user.user_timezone
)
return Response(
{
"sub_issues": sub_issues,

View File

@@ -32,6 +32,8 @@ from plane.db.models import (
ModuleLink,
)
from plane.utils.analytics_plot import burndown_plot
from plane.utils.user_timezone_converter import user_timezone_converter
# Module imports
from .. import BaseAPIView
@@ -199,6 +201,10 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
"updated_at",
"archived_at",
)
datetime_fields = ["created_at", "updated_at"]
modules = user_timezone_converter(
modules, datetime_fields, request.user.user_timezone
)
return Response(modules, status=status.HTTP_200_OK)
else:
queryset = (

View File

@@ -48,6 +48,8 @@ from plane.db.models import (
Project,
)
from plane.utils.analytics_plot import burndown_plot
from plane.utils.user_timezone_converter import user_timezone_converter
# Module imports
from .. import BaseAPIView, BaseViewSet, WebhookMixin
@@ -236,6 +238,10 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
"updated_at",
)
).first()
datetime_fields = ["created_at", "updated_at"]
module = user_timezone_converter(
module, datetime_fields, request.user.user_timezone
)
return Response(module, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -277,6 +283,10 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
"created_at",
"updated_at",
)
datetime_fields = ["created_at", "updated_at"]
modules = user_timezone_converter(
modules, datetime_fields, request.user.user_timezone
)
return Response(modules, status=status.HTTP_200_OK)
def retrieve(self, request, slug, project_id, pk):
@@ -454,6 +464,10 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
"created_at",
"updated_at",
).first()
datetime_fields = ["created_at", "updated_at"]
module = user_timezone_converter(
module, datetime_fields, request.user.user_timezone
)
return Response(module, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

View File

@@ -31,7 +31,7 @@ from plane.db.models import (
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.issue_filters import issue_filters
from plane.utils.user_timezone_converter import user_timezone_converter
class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
serializer_class = ModuleIssueSerializer
@@ -150,6 +150,11 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
"is_draft",
"archived_at",
)
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(
issues, datetime_fields, request.user.user_timezone
)
return Response(issues, status=status.HTTP_200_OK)
# create multiple issues inside a module

View File

@@ -42,7 +42,7 @@ from plane.db.models import (
IssueAttachment,
)
from plane.utils.issue_filters import issue_filters
from plane.utils.user_timezone_converter import user_timezone_converter
class GlobalViewViewSet(BaseViewSet):
serializer_class = IssueViewSerializer
@@ -255,6 +255,10 @@ class GlobalViewIssuesViewSet(BaseViewSet):
"is_draft",
"archived_at",
)
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(
issues, datetime_fields, request.user.user_timezone
)
return Response(issues, status=status.HTTP_200_OK)

View File

@@ -0,0 +1,25 @@
import pytz
def user_timezone_converter(queryset, datetime_fields, user_timezone):
# Create a timezone object for the user's timezone
user_tz = pytz.timezone(user_timezone)
# Check if queryset is a dictionary (single item) or a list of dictionaries
if isinstance(queryset, dict):
queryset_values = [queryset]
else:
queryset_values = list(queryset.values())
# Iterate over the dictionaries in the list
for item in queryset_values:
# Iterate over the datetime fields
for field in datetime_fields:
# Convert the datetime field to the user's timezone
if item[field]:
item[field] = item[field].astimezone(user_tz)
# If queryset was a single item, return a single item
if isinstance(queryset, dict):
return queryset_values[0]
else:
return queryset_values

View File

@@ -1,3 +1,14 @@
.ProseMirror {
--font-size-h1: 1.5rem;
--font-size-h2: 1.3125rem;
--font-size-h3: 1.125rem;
--font-size-h4: 0.9375rem;
--font-size-h5: 0.8125rem;
--font-size-h6: 0.75rem;
--font-size-regular: 0.9375rem;
--font-size-list: var(--font-size-regular);
}
.ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
@@ -56,7 +67,7 @@
/* to-do list */
ul[data-type="taskList"] li {
font-size: 1rem;
font-size: var(--font-size-list);
line-height: 1.5;
}
@@ -162,7 +173,7 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p {
cursor: text;
line-height: 1.2;
font-family: inherit;
font-size: 14px;
font-size: var(--font-size-regular);
color: inherit;
-moz-box-sizing: border-box;
box-sizing: border-box;
@@ -310,15 +321,15 @@ ul[data-type="taskList"] ul[data-type="taskList"] {
.prose :where(h1):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 2rem;
margin-bottom: 4px;
font-size: 1.875rem;
font-weight: 700;
font-size: var(--font-size-h1);
font-weight: 600;
line-height: 1.3;
}
.prose :where(h2):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 1.4rem;
margin-bottom: 1px;
font-size: 1.5rem;
font-size: var(--font-size-h2);
font-weight: 600;
line-height: 1.3;
}
@@ -326,21 +337,23 @@ ul[data-type="taskList"] ul[data-type="taskList"] {
.prose :where(h3):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 1rem;
margin-bottom: 1px;
font-size: 1.25rem;
font-size: var(--font-size-h3);
font-weight: 600;
line-height: 1.3;
}
.prose :where(h4):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 1rem;
margin-bottom: 1px;
font-size: 1rem;
font-size: var(--font-size-h4);
font-weight: 600;
line-height: 1.5;
}
.prose :where(h5):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 1rem;
margin-bottom: 1px;
font-size: 0.9rem;
font-size: var(--font-size-h5);
font-weight: 600;
line-height: 1.5;
}
@@ -348,7 +361,7 @@ ul[data-type="taskList"] ul[data-type="taskList"] {
.prose :where(h6):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 1rem;
margin-bottom: 1px;
font-size: 0.83rem;
font-size: var(--font-size-h6);
font-weight: 600;
line-height: 1.5;
}
@@ -356,14 +369,14 @@ ul[data-type="taskList"] ul[data-type="taskList"] {
.prose :where(p):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 0.25rem;
margin-bottom: 1px;
padding: 3px 2px;
font-size: 1rem;
padding: 3px 0;
font-size: var(--font-size-regular);
line-height: 1.5;
}
.prose :where(ol):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p,
.prose :where(ul):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p {
font-size: 1rem;
font-size: var(--font-size-list);
line-height: 1.5;
}

View File

@@ -17,14 +17,9 @@ export type TPage = {
project: string | undefined;
updated_at: Date | undefined;
updated_by: string | undefined;
view_props: TPageViewProps | undefined;
workspace: string | undefined;
};
export type TPageViewProps = {
full_width?: boolean;
};
// page filters
export type TPageNavigationTabs = "public" | "private" | "archived";

View File

@@ -18,7 +18,7 @@ export const RichTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, Ric
mentionHandler={{ highlights: mentionHighlights }}
{...props}
// overriding the customClassName to add relative class passed
containerClassName={cn(props.containerClassName, "relative border border-custom-border-200 p-3")}
containerClassName={cn("relative p-0 border-none", props.containerClassName)}
/>
);
}

View File

@@ -33,7 +33,7 @@ const CollaboratorListItem: React.FC<CollaboratorListItemProps> = observer((prop
<div className="flex justify-center">
<Avatar
src={userDetails.avatar}
name={isCurrentUser ? "You" : userDetails.display_name}
name={userDetails.display_name}
size={69}
className="!text-3xl !font-medium"
showTooltip={false}

View File

@@ -51,7 +51,7 @@ export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProp
suggestions: mentionSuggestions,
}}
{...rest}
containerClassName={cn(containerClassName, "relative min-h-[150px] border border-custom-border-200 p-3")}
containerClassName={cn("relative min-h-[150px] pl-3", containerClassName)}
/>
);
});

View File

@@ -33,7 +33,7 @@ export const InboxIssueContentProperties: React.FC<Props> = observer((props) =>
if (!issue || !issue?.id) return <></>;
return (
<div className="flex h-min w-full flex-col divide-y-2 divide-custom-border-200 overflow-hidden">
<div className="h-min w-full overflow-y-auto px-5">
<div className="h-min w-full overflow-y-auto px-3">
<h5 className="text-sm font-medium my-4">Properties</h5>
<div className={`divide-y-2 divide-custom-border-200 ${!isEditable ? "opacity-60" : ""}`}>
<div className="flex flex-col gap-3">

View File

@@ -114,7 +114,7 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
return (
<>
<div className="rounded-lg space-y-4">
<div className="rounded-lg space-y-4 pl-3">
<IssueTitleInput
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
@@ -124,6 +124,7 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
issueOperations={issueOperations}
disabled={!isEditable}
value={issue.name}
containerClassName="-ml-3"
/>
{loader === "issue-loading" ? (
@@ -140,6 +141,7 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
disabled={!isEditable}
issueOperations={issueOperations}
setIsSubmitting={(value) => setIsSubmitting(value)}
containerClassName="-ml-3 border-none"
/>
)}
@@ -152,12 +154,15 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
/>
)}
</div>
<IssueAttachmentRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issue.id}
disabled={!isEditable}
/>
<div className="pl-3">
<IssueAttachmentRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issue.id}
disabled={!isEditable}
/>
</div>
<InboxIssueContentProperties
workspaceSlug={workspaceSlug}
@@ -168,7 +173,7 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
duplicateIssueDetails={inboxIssue?.duplicate_issue_detail}
/>
<div className="pb-12">
<div className="pb-12 pl-3">
<IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issue.id} />
</div>
</>

View File

@@ -52,7 +52,7 @@ export const InboxContentRoot: FC<TInboxContentRoot> = observer((props) => {
isSubmitting={isSubmitting}
/>
</div>
<div className="h-full w-full space-y-5 divide-y-2 divide-custom-border-300 overflow-y-auto p-5 vertical-scrollbar scrollbar-md">
<div className="h-full w-full space-y-5 divide-y-2 divide-custom-border-200 overflow-y-auto px-6 py-5 vertical-scrollbar scrollbar-md">
<InboxIssueMainContent
workspaceSlug={workspaceSlug}
projectId={projectId}

View File

@@ -133,6 +133,7 @@ export const InboxIssueCreateRoot: FC<TInboxIssueCreateRoot> = observer((props)
data={formData}
handleData={handleFormData}
editorRef={descriptionEditorRef}
containerClassName="border-[0.5px] border-custom-border-200 py-3"
/>
<InboxIssueProperties projectId={projectId} data={formData} handleData={handleFormData} />
<div className="relative flex justify-between items-center gap-3">

View File

@@ -138,6 +138,7 @@ export const InboxIssueEditRoot: FC<TInboxIssueEditRoot> = observer((props) => {
data={formData}
handleData={handleFormData}
editorRef={descriptionEditorRef}
containerClassName="border-[0.5px] border-custom-border-200 py-3"
/>
<InboxIssueProperties projectId={projectId} data={formData} handleData={handleFormData} isVisible />
<div className="relative flex justify-end items-center gap-3">

View File

@@ -11,6 +11,7 @@ import { getDescriptionPlaceholder } from "@/helpers/issue.helper";
import { useProjectInbox } from "@/hooks/store";
type TInboxIssueDescription = {
containerClassName?: string;
workspaceSlug: string;
projectId: string;
workspaceId: string;
@@ -21,7 +22,7 @@ type TInboxIssueDescription = {
// TODO: have to implement GPT Assistance
export const InboxIssueDescription: FC<TInboxIssueDescription> = observer((props) => {
const { workspaceSlug, projectId, workspaceId, data, handleData, editorRef } = props;
const {containerClassName, workspaceSlug, projectId, workspaceId, data, handleData, editorRef } = props;
// hooks
const { loader } = useProjectInbox();
@@ -42,6 +43,7 @@ export const InboxIssueDescription: FC<TInboxIssueDescription> = observer((props
dragDropEnabled={false}
onChange={(_description: object, description_html: string) => handleData("description_html", description_html)}
placeholder={getDescriptionPlaceholder}
containerClassName={containerClassName}
/>
</div>
);

View File

@@ -1,326 +0,0 @@
import { Fragment, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/router";
import { Controller, useForm } from "react-hook-form";
import { Sparkle } from "lucide-react";
import { Transition, Dialog } from "@headlessui/react";
import { EditorRefApi } from "@plane/rich-text-editor";
// types
import { TIssue } from "@plane/types";
// ui
import { Button, Input, ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { GptAssistantPopover } from "@/components/core";
import { PriorityDropdown } from "@/components/dropdowns";
import { RichTextEditor } from "@/components/editor/rich-text-editor/rich-text-editor";
import { ISSUE_CREATED } from "@/constants/event-tracker";
import { useApplication, useEventTracker, useWorkspace, useProjectInbox } from "@/hooks/store";
// services
import { AIService } from "@/services/ai.service";
// components
// ui
// types
// constants
type Props = {
isOpen: boolean;
onClose: () => void;
};
const defaultValues: Partial<TIssue> = {
name: "",
description_html: "<p></p>",
priority: "none",
};
// services
const aiService = new AIService();
export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
const { isOpen, onClose } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
if (!workspaceSlug || !projectId) return null;
// states
const [createMore, setCreateMore] = useState(false);
const [gptAssistantModal, setGptAssistantModal] = useState(false);
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
// refs
const editorRef = useRef<EditorRefApi>(null);
// hooks
const workspaceStore = useWorkspace();
const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug.toString() as string)?.id.toString() as string;
// store hooks
const { createInboxIssue } = useProjectInbox();
const {
config: { envConfig },
} = useApplication();
const { captureIssueEvent } = useEventTracker();
// form info
const {
control,
formState: { errors, isSubmitting },
handleSubmit,
reset,
watch,
getValues,
} = useForm<Partial<TIssue>>({ defaultValues });
const issueName = watch("name");
const handleClose = () => {
onClose();
reset(defaultValues);
editorRef?.current?.clearEditor();
};
const handleFormSubmit = async (formData: Partial<TIssue>) => {
if (!workspaceSlug || !projectId) return;
await createInboxIssue(workspaceSlug.toString(), projectId.toString(), formData)
.then((res) => {
if (!createMore) {
router.push(`/${workspaceSlug}/projects/${projectId}/inbox/?currentTab=open&inboxIssueId=${res?.issue?.id}`);
handleClose();
} else {
reset(defaultValues);
editorRef?.current?.clearEditor();
}
captureIssueEvent({
eventName: ISSUE_CREATED,
payload: {
...formData,
state: "SUCCESS",
element: "Inbox page",
},
path: router.pathname,
});
})
.catch((error) => {
console.error(error);
captureIssueEvent({
eventName: ISSUE_CREATED,
payload: {
...formData,
state: "FAILED",
element: "Inbox page",
},
path: router.pathname,
});
});
};
const handleAiAssistance = async (response: string) => {
if (!workspaceSlug || !projectId) return;
editorRef.current?.setEditorValueAtCursorPosition(response);
};
const handleAutoGenerateDescription = async () => {
const issueName = getValues("name");
if (!workspaceSlug || !projectId || !issueName) return;
setIAmFeelingLucky(true);
aiService
.createGptTask(workspaceSlug as string, projectId as string, {
prompt: issueName,
task: "Generate a proper description for this issue.",
})
.then((res) => {
if (res.response === "")
setToast({
type: TOAST_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)
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: error || "You have reached the maximum number of requests of 50 requests per month per user.",
});
else
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: error || "Some error occurred. Please try again.",
});
})
.finally(() => setIAmFeelingLucky(false));
};
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="my-10 flex items-center justify-center p-4 text-center sm:p-0 md:my-20">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform rounded-lg bg-custom-background-100 p-5 text-left shadow-custom-shadow-md transition-all sm:w-full sm:max-w-2xl">
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div className="space-y-5">
<h3 className="text-xl font-semibold leading-6 text-custom-text-100">Create Inbox Issue</h3>
<div className="space-y-3">
<div className="mt-2 space-y-3">
<div>
<Controller
control={control}
name="name"
rules={{
required: "Title is required",
maxLength: {
value: 255,
message: "Title should be less than 255 characters",
},
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="name"
name="name"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.name)}
placeholder="Title"
className="w-full resize-none text-xl"
/>
)}
/>
</div>
<div className="relative">
<div className="border-0.5 absolute bottom-3.5 right-3.5 z-10 flex rounded bg-custom-background-80">
{watch("name") && 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..."
) : (
<>
<Sparkle className="h-4 w-4" />I{"'"}m feeling lucky
</>
)}
</button>
)}
{envConfig?.has_openai_configured && (
<GptAssistantPopover
isOpen={gptAssistantModal}
projectId={projectId.toString()}
handleClose={() => {
setGptAssistantModal((prevData) => !prevData);
// this is done so that the title do not reset after gpt popover closed
reset(getValues());
}}
onResponse={(response) => {
handleAiAssistance(response);
}}
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)}
>
<Sparkle className="h-4 w-4" />
AI
</button>
}
className="!min-w-[38rem]"
placement="top-end"
/>
)}
</div>
<Controller
name="description_html"
control={control}
render={({ field: { value, onChange } }) => (
<RichTextEditor
initialValue={!value || value === "" ? "<p></p>" : value}
ref={editorRef}
workspaceSlug={workspaceSlug.toString()}
workspaceId={workspaceId}
projectId={projectId.toString()}
dragDropEnabled={false}
onChange={(_description: object, description_html: string) => {
onChange(description_html);
}}
/>
)}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<Controller
control={control}
name="priority"
render={({ field: { value, onChange } }) => (
<div className="h-5">
<PriorityDropdown
value={value ?? "none"}
onChange={onChange}
buttonVariant="background-with-text"
/>
</div>
)}
/>
</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">
<Button variant="neutral-primary" size="sm" onClick={() => handleClose()}>
Discard
</Button>
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
{isSubmitting ? "Adding Issue..." : "Add Issue"}
</Button>
</div>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
});

View File

@@ -15,6 +15,7 @@ import { getDescriptionPlaceholder } from "@/helpers/issue.helper";
import { useWorkspace } from "@/hooks/store";
export type IssueDescriptionInputProps = {
containerClassName?: string;
workspaceSlug: string;
projectId: string;
issueId: string;
@@ -28,6 +29,7 @@ export type IssueDescriptionInputProps = {
export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((props) => {
const {
containerClassName,
workspaceSlug,
projectId,
issueId,
@@ -110,11 +112,12 @@ export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((p
placeholder={
placeholder ? placeholder : (isFocused, value) => getDescriptionPlaceholder(isFocused, value)
}
containerClassName={containerClassName}
/>
) : (
<RichTextReadOnlyEditor
initialValue={localIssueDescription.description_html ?? ""}
containerClassName="!p-0 !pt-2 text-custom-text-200 min-h-[150px]"
containerClassName={containerClassName}
/>
)
}

View File

@@ -54,7 +54,7 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
return (
<>
<div className="rounded-lg space-y-4">
<div className="rounded-lg space-y-4 pl-3">
{issue.parent_id && (
<IssueParentDetail
workspaceSlug={workspaceSlug}
@@ -85,6 +85,7 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
issueOperations={issueOperations}
disabled={!isEditable}
value={issue.name}
containerClassName="-ml-3"
/>
{/* {issue?.description_html === issueDescription && ( */}
@@ -97,6 +98,7 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
disabled={!isEditable}
issueOperations={issueOperations}
setIsSubmitting={(value) => setIsSubmitting(value)}
containerClassName="-ml-3 border-none"
/>
{/* )} */}
@@ -121,14 +123,18 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
)}
</div>
<IssueAttachmentRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={!isEditable}
/>
<div className="pl-3">
<IssueAttachmentRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={!isEditable}
/>
</div>
<IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} disabled={isArchived} />
<div className="pl-3">
<IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} disabled={isArchived} />
</div>
</>
);
});

View File

@@ -357,7 +357,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
/>
) : (
<div className="flex h-full w-full overflow-hidden">
<div className="max-w-2/3 h-full w-full space-y-5 divide-y-2 divide-custom-border-200 overflow-y-auto p-5">
<div className="max-w-2/3 h-full w-full space-y-5 divide-y-2 divide-custom-border-200 overflow-y-auto px-6 py-5">
<IssueMainContent
workspaceSlug={workspaceSlug}
swrIssueDetails={swrIssueDetails}

View File

@@ -161,6 +161,7 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = observer((props
portalElement={portalElement}
placement={placements}
menuItemsClassName="z-[14]"
maxHeight="lg"
closeOnSelect
>
{MENU_ITEMS.map((item) => {

View File

@@ -123,6 +123,7 @@ export const ArchivedIssueQuickActions: React.FC<IQuickActionProps> = observer((
portalElement={portalElement}
placement={placements}
menuItemsClassName="z-[14]"
maxHeight="lg"
closeOnSelect
>
{MENU_ITEMS.map((item) => {

View File

@@ -181,6 +181,7 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
customButton={customActionButton}
portalElement={portalElement}
menuItemsClassName="z-[14]"
maxHeight="lg"
closeOnSelect
>
{MENU_ITEMS.map((item) => {

View File

@@ -107,6 +107,7 @@ export const DraftIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
portalElement={portalElement}
placement={placements}
menuItemsClassName="z-[14]"
maxHeight="lg"
closeOnSelect
>
{MENU_ITEMS.map((item) => {

View File

@@ -178,6 +178,7 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((pr
customButton={customActionButton}
portalElement={portalElement}
menuItemsClassName="z-[14]"
maxHeight="lg"
closeOnSelect
>
{MENU_ITEMS.map((item) => {

View File

@@ -171,6 +171,7 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p
customButton={customActionButton}
portalElement={portalElement}
menuItemsClassName="z-[14]"
maxHeight="lg"
closeOnSelect
>
{MENU_ITEMS.map((item) => {

View File

@@ -480,6 +480,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
ref={editorRef}
tabIndex={getTabIndex("description_html")}
placeholder={getDescriptionPlaceholder}
containerClassName="border-[0.5px] border-custom-border-200 py-3"
/>
)}
/>

View File

@@ -69,6 +69,7 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = observer(
issueOperations={issueOperations}
disabled={disabled}
value={issue.name}
containerClassName="-ml-3"
/>
<IssueDescriptionInput
@@ -81,6 +82,7 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = observer(
disabled={disabled}
issueOperations={issueOperations}
setIsSubmitting={(value) => setIsSubmitting(value)}
containerClassName="-ml-3 border-none"
/>
{currentUser && (

View File

@@ -3,6 +3,7 @@ import { observer } from "mobx-react";
// components
import { TextArea } from "@plane/ui";
// types
import { cn } from "@/helpers/common.helper";
import useDebounce from "@/hooks/use-debounce";
import { TIssueOperations } from "./issue-detail";
// hooks
@@ -16,12 +17,26 @@ export type IssueTitleInputProps = {
issueOperations: TIssueOperations;
projectId: string;
issueId: string;
className?: string;
containerClassName?: string;
};
export const IssueTitleInput: FC<IssueTitleInputProps> = observer((props) => {
const { disabled, value, workspaceSlug, isSubmitting, setIsSubmitting, issueId, issueOperations, projectId } = props;
const {
disabled,
value,
workspaceSlug,
isSubmitting,
setIsSubmitting,
issueId,
issueOperations,
projectId,
className,
containerClassName,
} = props;
// states
const [title, setTitle] = useState("");
const [isLengthVisible, setIsLengthVisible] = useState(false);
// hooks
const debouncedValue = useDebounce(title, 1500);
@@ -76,19 +91,32 @@ export const IssueTitleInput: FC<IssueTitleInputProps> = observer((props) => {
if (disabled) return <div className="text-2xl font-medium">{title}</div>;
return (
<div className="relative">
<div className={cn("relative", containerClassName)}>
<TextArea
id="title-input"
className={`min-h-min block w-full resize-none overflow-hidden rounded border-none bg-transparent px-3 py-2 text-2xl font-medium outline-none ring-0 focus:ring-1 focus:ring-custom-primary ${
title?.length === 0 ? "!ring-red-400" : ""
}`}
className={cn(
"block w-full resize-none overflow-hidden rounded border-none bg-transparent px-3 py-0 text-2xl font-medium outline-none ring-0",
{
"ring-red-400": title.length === 0,
},
className
)}
disabled={disabled}
value={title}
onChange={handleTitleChange}
maxLength={255}
placeholder="Issue title"
onFocus={() => setIsLengthVisible(true)}
onBlur={() => setIsLengthVisible(false)}
/>
<div className="pointer-events-none absolute bottom-1 right-1 z-[2] rounded bg-custom-background-100 p-0.5 text-xs text-custom-text-200">
<div
className={cn(
"pointer-events-none absolute bottom-1 right-1 z-[2] rounded bg-custom-background-100 p-0.5 text-xs text-custom-text-200 opacity-0 transition-opacity",
{
"opacity-100": isLengthVisible,
}
)}
>
<span className={`${title.length === 0 || title.length > 255 ? "text-red-500" : ""}`}>{title.length}</span>
/255
</div>

View File

@@ -18,6 +18,7 @@ import { IssueEmbedCard, PageContentBrowser, PageEditorTitle } from "@/component
import { cn } from "@/helpers/common.helper";
// hooks
import { useMember, useMention, useUser, useWorkspace } from "@/hooks/store";
import { usePageFilters } from "@/hooks/use-page-filters";
import { useIssueEmbed } from "@/hooks/use-issue-embed";
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
// services
@@ -69,7 +70,6 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
const workspaceId = workspaceSlug ? getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "" : "";
const pageTitle = pageStore?.name ?? "";
const pageDescription = pageStore?.description_html ?? "<p></p>";
const isFullWidth = !!pageStore?.view_props?.full_width;
const { description_html, isContentEditable, updateTitle, isSubmitting, setIsSubmitting } = pageStore;
const projectMemberIds = projectId ? getProjectMemberIds(projectId.toString()) : [];
const projectMemberDetails = projectMemberIds?.map((id) => getUserDetails(id) as IUserLite);
@@ -80,6 +80,9 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
members: projectMemberDetails,
user: currentUser ?? undefined,
});
// page filters
const { isFullWidth } = usePageFilters();
// issue-embed
const { fetchIssues } = useIssueEmbed(workspaceSlug?.toString() ?? "", projectId?.toString() ?? "");
@@ -99,8 +102,8 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
<div
className={cn("sticky top-0 hidden h-full flex-shrink-0 -translate-x-full p-5 duration-200 md:block", {
"translate-x-0": sidePeekVisible,
"w-56 lg:w-72": !isFullWidth,
"w-[10%]": isFullWidth,
"w-40 lg:w-56": !isFullWidth,
"w-[5%]": isFullWidth,
})}
>
{!isFullWidth && (
@@ -112,8 +115,8 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
</div>
<div
className={cn("h-full w-full pt-5", {
"md:w-[calc(100%-14rem)] xl:w-[calc(100%-18rem-18rem)]": !isFullWidth,
"md:w-[80%]": isFullWidth,
"md:w-[calc(100%-10rem)] xl:w-[calc(100%-14rem-14rem)]": !isFullWidth,
"md:w-[90%]": isFullWidth,
})}
>
<div className="h-full w-full flex flex-col gap-y-7 overflow-y-auto overflow-x-hidden">
@@ -201,8 +204,8 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
</div>
<div
className={cn("hidden xl:block flex-shrink-0", {
"w-56 lg:w-72": !isFullWidth,
"w-[10%]": isFullWidth,
"w-40 lg:w-56": !isFullWidth,
"w-[5%]": isFullWidth,
})}
/>
</div>

View File

@@ -2,6 +2,8 @@ import { observer } from "mobx-react";
import { EditorReadOnlyRefApi, EditorRefApi, IMarking } from "@plane/document-editor";
// components
import { PageExtraOptions, PageSummaryPopover, PageToolbar } from "@/components/pages";
// hooks
import { usePageFilters } from "@/hooks/use-page-filters";
// store
import { IPageStore } from "@/store/pages/page.store";
@@ -34,8 +36,9 @@ export const PageEditorMobileHeaderRoot: React.FC<Props> = observer((props) => {
setSidePeekVisible,
} = props;
// derived values
const { isContentEditable, view_props } = pageStore;
const isFullWidth = !!view_props?.full_width;
const { isContentEditable } = pageStore;
// page filters
const { isFullWidth } = usePageFilters();
if (!editorRef.current && !readOnlyEditorRef.current) return null;

View File

@@ -4,13 +4,11 @@ import { ArchiveRestoreIcon, Clipboard, Copy, Link, Lock, LockOpen } from "lucid
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/document-editor";
// ui
import { ArchiveIcon, CustomMenu, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
// constants
import { EUserProjectRoles } from "@/constants/project";
// helpers
import { cn } from "@/helpers/common.helper";
import { copyTextToClipboard, copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
import { useApplication, useUser } from "@/hooks/store";
import { useApplication } from "@/hooks/store";
import { usePageFilters } from "@/hooks/use-page-filters";
// store
import { IPageStore } from "@/store/pages/page.store";
@@ -34,18 +32,13 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
canCurrentUserDuplicatePage,
canCurrentUserLockPage,
restore,
view_props,
updateViewProps,
} = pageStore;
// store hooks
const {
router: { workspaceSlug, projectId },
} = useApplication();
const {
membership: { currentProjectRole },
} = useUser();
// auth
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
// page filters
const { isFullWidth, handleFullWidth } = usePageFilters();
const handleArchivePage = async () =>
await archive().catch(() =>
@@ -149,22 +142,10 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
<CustomMenu maxHeight="md" placement="bottom-start" verticalEllipsis closeOnSelect>
<CustomMenu.MenuItem
className="hidden md:flex w-full items-center justify-between gap-2"
onClick={() =>
updateViewProps({
full_width: !view_props?.full_width,
})
}
disabled={!isEditingAllowed}
onClick={() => handleFullWidth(!isFullWidth)}
>
Full width
<ToggleSwitch
value={!!view_props?.full_width}
onChange={() => {}}
className={cn({
"opacity-40": !isEditingAllowed,
})}
disabled={!isEditingAllowed}
/>
<ToggleSwitch value={isFullWidth} onChange={() => {}} />
</CustomMenu.MenuItem>
{MENU_ITEMS.map((item) => {
if (!item.shouldRender) return null;

View File

@@ -4,6 +4,8 @@ import { EditorReadOnlyRefApi, EditorRefApi, IMarking } from "@plane/document-ed
import { PageEditorMobileHeaderRoot, PageExtraOptions, PageSummaryPopover, PageToolbar } from "@/components/pages";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { usePageFilters } from "@/hooks/use-page-filters";
// store
import { IPageStore } from "@/store/pages/page.store";
@@ -36,8 +38,9 @@ export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
setSidePeekVisible,
} = props;
// derived values
const { isContentEditable, view_props } = pageStore;
const isFullWidth = !!view_props?.full_width;
const { isContentEditable } = pageStore;
// page filters
const { isFullWidth } = usePageFilters();
if (!editorRef.current && !readOnlyEditorRef.current) return null;
@@ -46,8 +49,8 @@ export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
<div className="hidden md:flex items-center border-b border-custom-border-200 px-3 py-2 md:px-5">
<div
className={cn("flex-shrink-0", {
"w-56 lg:w-72": !isFullWidth,
"w-[10%]": isFullWidth,
"w-40 lg:w-56": !isFullWidth,
"w-[5%]": isFullWidth,
})}
>
<PageSummaryPopover

View File

@@ -23,7 +23,7 @@ export const PageEditorTitle: React.FC<Props> = observer((props) => {
<>
{readOnly ? (
<h6
className="break-words bg-transparent text-4xl font-bold"
className="break-words bg-transparent text-[1.75rem] font-semibold"
style={{
lineHeight: "1.2",
}}
@@ -34,7 +34,7 @@ export const PageEditorTitle: React.FC<Props> = observer((props) => {
<>
<TextArea
onChange={(e) => updateTitle(e.target.value)}
className="w-full bg-custom-background text-4xl font-bold outline-none p-0 border-none resize-none rounded-none"
className="w-full bg-custom-background text-[1.75rem] font-semibold outline-none p-0 border-none resize-none rounded-none"
style={{
lineHeight: "1.2",
}}

View File

@@ -0,0 +1,127 @@
import { observer } from "mobx-react";
import Link from "next/link";
import { ExternalLink, LinkIcon } from "lucide-react";
// ui
import { TStaticViewTypes } from "@plane/types";
import { ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
type Props = {
parentRef: React.RefObject<HTMLElement>;
workspaceSlug: string;
globalViewId: string | undefined;
view: {
key: TStaticViewTypes;
label: string;
};
};
export const DefaultWorkspaceViewQuickActions: React.FC<Props> = observer((props) => {
const { parentRef, globalViewId, view, workspaceSlug } = props;
const viewLink = `${workspaceSlug}/workspace-views/${view.key}`;
const handleCopyText = () =>
copyUrlToClipboard(viewLink).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link Copied!",
message: "View link copied to clipboard.",
});
});
const handleOpenInNewTab = () => window.open(`/${viewLink}`, "_blank");
const MENU_ITEMS: TContextMenuItem[] = [
{
key: "open-new-tab",
action: handleOpenInNewTab,
title: "Open in new tab",
icon: ExternalLink,
},
{
key: "copy-link",
action: handleCopyText,
title: "Copy link",
icon: LinkIcon,
},
];
return (
<>
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
<CustomMenu
customButton={
<>
{view.key === globalViewId ? (
<span
className={`flex min-w-min flex-shrink-0 whitespace-nowrap border-b-2 p-3 text-sm font-medium outline-none ${
view.key === globalViewId
? "border-custom-primary-100 text-custom-primary-100"
: "border-transparent hover:border-custom-border-200 hover:text-custom-text-400"
}`}
>
{view.label}
</span>
) : (
<Link
key={view.key}
id={`global-view-${view.key}`}
href={`/${workspaceSlug}/workspace-views/${view.key}`}
>
<span
className={`flex min-w-min flex-shrink-0 whitespace-nowrap border-b-2 p-3 text-sm font-medium outline-none ${
view.key === globalViewId
? "border-custom-primary-100 text-custom-primary-100"
: "border-transparent hover:border-custom-border-200 hover:text-custom-text-400"
}`}
>
{view.label}
</span>
</Link>
)}
</>
}
placement="bottom-end"
menuItemsClassName="z-20"
closeOnSelect
>
{MENU_ITEMS.map((item) => {
if (item.shouldRender === false) return null;
return (
<CustomMenu.MenuItem
key={item.key}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
item.action();
}}
className={cn(
"flex items-center gap-2",
{
"text-custom-text-400": item.disabled,
},
item.className
)}
>
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<div>
<h5>{item.title}</h5>
{item.description && (
<p
className={cn("text-custom-text-300 whitespace-pre-line", {
"text-custom-text-400": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</>
);
});

View File

@@ -1,11 +1,16 @@
import React, { useEffect, useRef, useState } from "react";
import { observer } from "mobx-react-lite";
import Link from "next/link";
import { useRouter } from "next/router";
// icons
import { Plus } from "lucide-react";
// types
import { TStaticViewTypes } from "@plane/types";
// components
import { CreateUpdateWorkspaceViewModal } from "@/components/workspace";
import {
CreateUpdateWorkspaceViewModal,
DefaultWorkspaceViewQuickActions,
WorkspaceViewQuickActions,
} from "@/components/workspace";
// constants
import { GLOBAL_VIEW_OPENED } from "@/constants/event-tracker";
import { DEFAULT_GLOBAL_VIEWS_LIST, EUserWorkspaceRoles } from "@/constants/workspace";
@@ -14,6 +19,8 @@ import { useEventTracker, useGlobalView, useUser } from "@/hooks/store";
const ViewTab = observer((props: { viewId: string }) => {
const { viewId } = props;
// refs
const parentRef = useRef<HTMLDivElement>(null);
// router
const router = useRouter();
const { workspaceSlug, globalViewId } = router.query;
@@ -22,30 +29,54 @@ const ViewTab = observer((props: { viewId: string }) => {
const view = getViewDetailsById(viewId);
if (!view) return null;
if (!view || !workspaceSlug || !globalViewId) return null;
return (
<Link key={viewId} id={`global-view-${viewId}`} href={`/${workspaceSlug}/workspace-views/${viewId}`}>
<span
className={`flex min-w-min flex-shrink-0 whitespace-nowrap border-b-2 p-3 text-sm font-medium outline-none ${
viewId === globalViewId
? "border-custom-primary-100 text-custom-primary-100"
: "border-transparent hover:border-custom-border-200 hover:text-custom-text-400"
}`}
>
{view.name}
</span>
</Link>
<div ref={parentRef} className="relative">
<WorkspaceViewQuickActions
parentRef={parentRef}
view={view}
viewId={viewId}
globalViewId={globalViewId?.toString()}
workspaceSlug={workspaceSlug?.toString()}
/>
</div>
);
});
const DefaultViewTab = (props: {
tab: {
key: TStaticViewTypes;
label: string;
};
}) => {
const { tab } = props;
// refs
const parentRef = useRef<HTMLDivElement>(null);
// router
const router = useRouter();
const { workspaceSlug, globalViewId } = router.query;
if (!workspaceSlug || !globalViewId) return null;
return (
<div key={tab.key} ref={parentRef} className="relative">
<DefaultWorkspaceViewQuickActions
parentRef={parentRef}
globalViewId={globalViewId?.toString()}
workspaceSlug={workspaceSlug?.toString()}
view={tab}
/>
</div>
);
};
export const GlobalViewsHeader: React.FC = observer(() => {
// states
const [createViewModal, setCreateViewModal] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// router
const router = useRouter();
const { workspaceSlug, globalViewId } = router.query;
const { globalViewId } = router.query;
// store hooks
const { currentWorkspaceViews } = useGlobalView();
const {
@@ -82,23 +113,11 @@ export const GlobalViewsHeader: React.FC = observer(() => {
ref={containerRef}
className="flex w-full items-center overflow-x-auto px-4 horizontal-scrollbar scrollbar-sm"
>
{DEFAULT_GLOBAL_VIEWS_LIST.map((tab) => (
<Link key={tab.key} id={`global-view-${tab.key}`} href={`/${workspaceSlug}/workspace-views/${tab.key}`}>
<span
className={`flex min-w-min flex-shrink-0 whitespace-nowrap border-b-2 p-3 text-sm font-medium outline-none ${
tab.key === globalViewId
? "border-custom-primary-100 text-custom-primary-100"
: "border-transparent hover:border-custom-border-200 hover:text-custom-text-400"
}`}
>
{tab.label}
</span>
</Link>
{DEFAULT_GLOBAL_VIEWS_LIST.map((tab, index) => (
<DefaultViewTab key={`${tab.key}-${index}`} tab={tab} />
))}
{currentWorkspaceViews?.map((viewId) => (
<ViewTab key={viewId} viewId={viewId} />
))}
{currentWorkspaceViews?.map((viewId) => <ViewTab key={viewId} viewId={viewId} />)}
</div>
{isAuthorizedUser && (

View File

@@ -5,3 +5,5 @@ export * from "./header";
export * from "./modal";
export * from "./view-list-item";
export * from "./views-list";
export * from "./quick-action";
export * from "./default-view-quick-action";

View File

@@ -0,0 +1,155 @@
import { useState } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { ExternalLink, LinkIcon, Pencil, Trash2 } from "lucide-react";
// types
import { IWorkspaceView } from "@plane/types";
// ui
import { ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { CreateUpdateWorkspaceViewModal, DeleteGlobalViewModal } from "@/components/workspace";
// constants
import { EUserProjectRoles } from "@/constants/project";
// helpers
import { cn } from "@/helpers/common.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
import { useUser } from "@/hooks/store";
type Props = {
parentRef: React.RefObject<HTMLElement>;
workspaceSlug: string;
globalViewId: string;
viewId: string;
view: IWorkspaceView;
};
export const WorkspaceViewQuickActions: React.FC<Props> = observer((props) => {
const { parentRef, view, globalViewId, viewId, workspaceSlug } = props;
// states
const [updateViewModal, setUpdateViewModal] = useState(false);
const [deleteViewModal, setDeleteViewModal] = useState(false);
// store hooks
const {
membership: { currentWorkspaceRole },
} = useUser();
// auth
const isEditingAllowed = !!currentWorkspaceRole && currentWorkspaceRole >= EUserProjectRoles.MEMBER;
const viewLink = `${workspaceSlug}/workspace-views/${view.id}`;
const handleCopyText = () =>
copyUrlToClipboard(viewLink).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link Copied!",
message: "View link copied to clipboard.",
});
});
const handleOpenInNewTab = () => window.open(`/${viewLink}`, "_blank");
const MENU_ITEMS: TContextMenuItem[] = [
{
key: "edit",
action: () => setUpdateViewModal(true),
title: "Edit",
icon: Pencil,
shouldRender: isEditingAllowed,
},
{
key: "open-new-tab",
action: handleOpenInNewTab,
title: "Open in new tab",
icon: ExternalLink,
},
{
key: "copy-link",
action: handleCopyText,
title: "Copy link",
icon: LinkIcon,
},
{
key: "delete",
action: () => setDeleteViewModal(true),
title: "Delete",
icon: Trash2,
shouldRender: isEditingAllowed,
},
];
return (
<>
<CreateUpdateWorkspaceViewModal data={view} isOpen={updateViewModal} onClose={() => setUpdateViewModal(false)} />
<DeleteGlobalViewModal data={view} isOpen={deleteViewModal} onClose={() => setDeleteViewModal(false)} />
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
<CustomMenu
customButton={
<>
{viewId === globalViewId ? (
<span
className={`flex min-w-min flex-shrink-0 whitespace-nowrap border-b-2 p-3 text-sm font-medium outline-none ${
viewId === globalViewId
? "border-custom-primary-100 text-custom-primary-100"
: "border-transparent hover:border-custom-border-200 hover:text-custom-text-400"
}`}
>
{view.name}
</span>
) : (
<Link key={viewId} id={`global-view-${viewId}`} href={`/${workspaceSlug}/workspace-views/${viewId}`}>
<span
className={`flex min-w-min flex-shrink-0 whitespace-nowrap border-b-2 p-3 text-sm font-medium outline-none ${
viewId === globalViewId
? "border-custom-primary-100 text-custom-primary-100"
: "border-transparent hover:border-custom-border-200 hover:text-custom-text-400"
}`}
>
{view.name}
</span>
</Link>
)}
</>
}
placement="bottom-end"
menuItemsClassName="z-20"
closeOnSelect
>
{MENU_ITEMS.map((item) => {
if (item.shouldRender === false) return null;
return (
<CustomMenu.MenuItem
key={item.key}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
item.action();
}}
className={cn(
"flex items-center gap-2",
{
"text-custom-text-400": item.disabled,
},
item.className
)}
>
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<div>
<h5>{item.title}</h5>
{item.description && (
<p
className={cn("text-custom-text-300 whitespace-pre-line", {
"text-custom-text-400": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</>
);
});

View File

@@ -0,0 +1,12 @@
// hooks
import useLocalStorage from "@/hooks/use-local-storage";
export const usePageFilters = () => {
const { storedValue: isFullWidth, setValue: setFullWidth } = useLocalStorage<boolean>("page_full_width", true);
const handleFullWidth = (value: boolean) => setFullWidth(value);
return {
isFullWidth: !!isFullWidth,
handleFullWidth,
};
};

View File

@@ -103,9 +103,9 @@ const ArchivedIssueDetailsPage: NextPageWithLayout = observer(() => {
</Loader>
) : (
<div className="flex h-full overflow-hidden">
<div className="h-full w-full space-y-3 divide-y-2 divide-custom-border-200 overflow-y-auto p-5">
<div className="h-full w-full space-y-3 divide-y-2 divide-custom-border-200 overflow-y-auto">
{issue?.archived_at && canRestoreIssue && (
<div className="flex items-center justify-between gap-2 rounded-md border border-custom-border-200 bg-custom-background-90 px-2.5 py-2 text-sm text-custom-text-200">
<div className="flex items-center justify-between gap-2 rounded-md border border-custom-border-200 bg-custom-background-90 px-2.5 py-2 text-sm text-custom-text-200 my-5 mx-3">
<div className="flex items-center gap-2">
<ArchiveIcon className="h-4 w-4" />
<p>This issue has been archived.</p>

View File

@@ -1,7 +1,7 @@
import set from "lodash/set";
import { action, computed, makeObservable, observable, reaction, runInAction } from "mobx";
// types
import { TPage, TPageViewProps } from "@plane/types";
import { TPage } from "@plane/types";
// constants
import { EPageAccess } from "@/constants/page";
import { EUserProjectRoles } from "@/constants/project";
@@ -33,7 +33,6 @@ export interface IPageStore extends TPage {
cleanup: () => void;
// actions
update: (pageData: Partial<TPage>) => Promise<TPage | undefined>;
updateViewProps: (viewProps: Partial<TPageViewProps>) => void;
makePublic: () => Promise<void>;
makePrivate: () => Promise<void>;
lock: () => Promise<void>;
@@ -65,7 +64,6 @@ export class PageStore implements IPageStore {
updated_by: string | undefined;
created_at: Date | undefined;
updated_at: Date | undefined;
view_props: TPageViewProps | undefined;
// helpers
oldName: string = "";
// reactions
@@ -93,7 +91,6 @@ export class PageStore implements IPageStore {
this.updated_by = page?.updated_by || undefined;
this.created_at = page?.created_at || undefined;
this.updated_at = page?.updated_at || undefined;
this.view_props = page?.view_props || undefined;
this.oldName = page?.name || "";
makeObservable(this, {
@@ -117,7 +114,6 @@ export class PageStore implements IPageStore {
updated_by: observable.ref,
created_at: observable.ref,
updated_at: observable.ref,
view_props: observable,
// helpers
oldName: observable,
// computed
@@ -137,7 +133,6 @@ export class PageStore implements IPageStore {
cleanup: action,
// actions
update: action,
updateViewProps: action,
makePublic: action,
makePrivate: action,
lock: action,
@@ -216,7 +211,6 @@ export class PageStore implements IPageStore {
updated_by: this.updated_by,
created_at: this.created_at,
updated_at: this.updated_at,
view_props: this.view_props,
};
}
@@ -338,38 +332,6 @@ export class PageStore implements IPageStore {
}
};
/**
* @description update the page view props
* @param {Partial<TPageViewProps>} updatedProps
*/
updateViewProps = async (updatedProps: Partial<TPageViewProps>) => {
const { workspaceSlug, projectId } = this.store.app.router;
if (!workspaceSlug || !projectId || !this.id) return undefined;
const currentViewProps = { ...this.view_props };
runInAction(() => {
Object.keys(updatedProps).forEach((key) => {
const currentPageKey = key as keyof TPageViewProps;
if (this.view_props) set(this.view_props, key, updatedProps[currentPageKey]);
});
});
try {
await this.pageService.update(workspaceSlug, projectId, this.id, {
view_props: {
...this.view_props,
...updatedProps,
},
});
} catch (error) {
runInAction(() => {
this.view_props = currentViewProps;
});
throw error;
}
};
/**
* @description make the page public
*/

View File

@@ -42,8 +42,8 @@ export class ProjectPageStore implements IProjectPageStore {
error: TError | undefined = undefined;
filters: TPageFilters = {
searchQuery: "",
sortKey: "name",
sortBy: "asc",
sortKey: "updated_at",
sortBy: "desc",
};
// service
service: PageService;