From 6c2fecd3222db43ce301af1eea5dc1d0a749d102 Mon Sep 17 00:00:00 2001 From: Facundo Martin Gordillo Date: Fri, 19 Jan 2024 11:40:41 +0100 Subject: [PATCH 01/68] fix: Updated "deployment documentation" link (#3413) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 41ebdd169..b509fd6f6 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Meet [Plane](https://plane.so). An open-source software development tool to mana > Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/A92xrEGCge) or GitHub issues, and we will use your feedback to improve on our upcoming releases. -The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account. Plane Cloud offers a hosted solution for Plane. If you prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/self-hosting/docker-compose). +The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account. Plane Cloud offers a hosted solution for Plane. If you prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/docker-compose). ## ⚡️ Contributors Quick Start From 543636eb4007ea03c225c368a7c9670a794dd00a Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Fri, 19 Jan 2024 16:13:22 +0530 Subject: [PATCH 02/68] dev: update apiserver .env.example (#3412) --- apiserver/.env.example | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apiserver/.env.example b/apiserver/.env.example index 3ac9a3aeb..7e8e11639 100644 --- a/apiserver/.env.example +++ b/apiserver/.env.example @@ -8,11 +8,11 @@ SENTRY_DSN="" SENTRY_ENVIRONMENT="development" # Database Settings -PGUSER="plane" -PGPASSWORD="plane" -PGHOST="plane-db" -PGDATABASE="plane" -DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE} +POSTGRES_USER="plane" +POSTGRES_PASSWORD="plane" +POSTGRES_HOST="plane-db" +POSTGRES_DB="plane" +DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}/${POSTGRES_DB} # Oauth variables GOOGLE_CLIENT_ID="" From 7272c54439a1af603c3f9a847a65454a12272ee2 Mon Sep 17 00:00:00 2001 From: Jigin Jayaprakash <145117767+JiginJayaprakash@users.noreply.github.com> Date: Thu, 4 Jan 2024 17:26:14 +0530 Subject: [PATCH 03/68] Fixes 3299 (#3308) --- apiserver/.env.example | 2 -- 1 file changed, 2 deletions(-) diff --git a/apiserver/.env.example b/apiserver/.env.example index 37178b398..3ac9a3aeb 100644 --- a/apiserver/.env.example +++ b/apiserver/.env.example @@ -39,8 +39,6 @@ OPENAI_API_BASE="https://api.openai.com/v1" # deprecated OPENAI_API_KEY="sk-" # deprecated GPT_ENGINE="gpt-3.5-turbo" # deprecated -# Github -GITHUB_CLIENT_SECRET="" # For fetching release notes # Settings related to Docker DOCKERIZED=1 # deprecated From 46e79dde278e41b078f39df38a003a852922222e Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Thu, 4 Jan 2024 16:27:17 +0530 Subject: [PATCH 04/68] fix: Login workflow depending on smtp is configured (#3307) --- apiserver/.env.example | 1 - apiserver/plane/app/views/config.py | 15 +- packages/types/src/app.d.ts | 4 +- .../account/sign-in-forms/email-form.tsx | 20 ++- .../account/sign-in-forms/password.tsx | 43 +++--- web/components/account/sign-in-forms/root.tsx | 131 +++++++++--------- .../account/sign-in-forms/unique-code.tsx | 5 +- 7 files changed, 121 insertions(+), 98 deletions(-) diff --git a/apiserver/.env.example b/apiserver/.env.example index 3ac9a3aeb..6558078e2 100644 --- a/apiserver/.env.example +++ b/apiserver/.env.example @@ -39,7 +39,6 @@ OPENAI_API_BASE="https://api.openai.com/v1" # deprecated OPENAI_API_KEY="sk-" # deprecated GPT_ENGINE="gpt-3.5-turbo" # deprecated - # Settings related to Docker DOCKERIZED=1 # deprecated diff --git a/apiserver/plane/app/views/config.py b/apiserver/plane/app/views/config.py index c53b30495..80467c90d 100644 --- a/apiserver/plane/app/views/config.py +++ b/apiserver/plane/app/views/config.py @@ -20,7 +20,6 @@ class ConfigurationEndpoint(BaseAPIView): ] def get(self, request): - # Get all the configuration ( GOOGLE_CLIENT_ID, @@ -90,8 +89,12 @@ class ConfigurationEndpoint(BaseAPIView): data = {} # Authentication - data["google_client_id"] = GOOGLE_CLIENT_ID if GOOGLE_CLIENT_ID and GOOGLE_CLIENT_ID != "\"\"" else None - data["github_client_id"] = GITHUB_CLIENT_ID if GITHUB_CLIENT_ID and GITHUB_CLIENT_ID != "\"\"" else None + data["google_client_id"] = ( + GOOGLE_CLIENT_ID if GOOGLE_CLIENT_ID and GOOGLE_CLIENT_ID != '""' else None + ) + data["github_client_id"] = ( + GITHUB_CLIENT_ID if GITHUB_CLIENT_ID and GITHUB_CLIENT_ID != '""' else None + ) data["github_app_name"] = GITHUB_APP_NAME data["magic_login"] = ( bool(EMAIL_HOST_USER) and bool(EMAIL_HOST_PASSWORD) @@ -114,7 +117,9 @@ class ConfigurationEndpoint(BaseAPIView): # File size settings data["file_size_limit"] = float(os.environ.get("FILE_SIZE_LIMIT", 5242880)) - # is self managed - data["is_self_managed"] = bool(int(os.environ.get("IS_SELF_MANAGED", "1"))) + # is smtp configured + data["is_smtp_configured"] = not ( + bool(EMAIL_HOST_USER) and bool(EMAIL_HOST_PASSWORD) + ) return Response(data, status=status.HTTP_200_OK) diff --git a/packages/types/src/app.d.ts b/packages/types/src/app.d.ts index 4d938ce26..92b304e17 100644 --- a/packages/types/src/app.d.ts +++ b/packages/types/src/app.d.ts @@ -1,5 +1,3 @@ - - export interface IAppConfig { email_password_login: boolean; file_size_limit: number; @@ -12,5 +10,5 @@ export interface IAppConfig { posthog_host: string | null; has_openai_configured: boolean; has_unsplash_configured: boolean; - is_self_managed: boolean; + is_smtp_configured: boolean; } diff --git a/web/components/account/sign-in-forms/email-form.tsx b/web/components/account/sign-in-forms/email-form.tsx index c1e124eab..7642c3b99 100644 --- a/web/components/account/sign-in-forms/email-form.tsx +++ b/web/components/account/sign-in-forms/email-form.tsx @@ -1,10 +1,12 @@ import React, { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; import { XCircle } from "lucide-react"; +import { observer } from "mobx-react-lite"; // services import { AuthService } from "services/auth.service"; // hooks import useToast from "hooks/use-toast"; +import { useApplication } from "hooks/store"; // ui import { Button, Input } from "@plane/ui"; // helpers @@ -25,11 +27,13 @@ type TEmailFormValues = { const authService = new AuthService(); -export const EmailForm: React.FC = (props) => { +export const EmailForm: React.FC = observer((props) => { const { handleStepChange, updateEmail } = props; - + // hooks const { setToastAlert } = useToast(); - + const { + config: { envConfig }, + } = useApplication(); const { control, formState: { errors, isSubmitting, isValid }, @@ -54,9 +58,11 @@ export const EmailForm: React.FC = (props) => { await authService .emailCheck(payload) .then((res) => { - // if the password has been autoset, send the user to magic sign-in - if (res.is_password_autoset) handleStepChange(ESignInSteps.UNIQUE_CODE); - // if the password has not been autoset, send them to password sign-in + // if the password has been auto set, send the user to magic sign-in + if (res.is_password_autoset && envConfig?.is_smtp_configured) { + handleStepChange(ESignInSteps.UNIQUE_CODE); + } + // if the password has not been auto set, send them to password sign-in else handleStepChange(ESignInSteps.PASSWORD); }) .catch((err) => @@ -119,4 +125,4 @@ export const EmailForm: React.FC = (props) => { ); -}; +}); diff --git a/web/components/account/sign-in-forms/password.tsx b/web/components/account/sign-in-forms/password.tsx index ef9edbfbc..c2eb358f2 100644 --- a/web/components/account/sign-in-forms/password.tsx +++ b/web/components/account/sign-in-forms/password.tsx @@ -6,6 +6,7 @@ import { XCircle } from "lucide-react"; import { AuthService } from "services/auth.service"; // hooks import useToast from "hooks/use-toast"; +import { useApplication } from "hooks/store"; // ui import { Button, Input } from "@plane/ui"; // helpers @@ -14,12 +15,14 @@ import { checkEmailValidity } from "helpers/string.helper"; import { IPasswordSignInData } from "@plane/types"; // constants import { ESignInSteps } from "components/account"; +import { observer } from "mobx-react-lite"; type Props = { email: string; updateEmail: (email: string) => void; handleStepChange: (step: ESignInSteps) => void; handleSignInRedirection: () => Promise; + handleEmailClear: () => void; }; type TPasswordFormValues = { @@ -34,13 +37,16 @@ const defaultValues: TPasswordFormValues = { const authService = new AuthService(); -export const PasswordForm: React.FC = (props) => { - const { email, updateEmail, handleStepChange, handleSignInRedirection } = props; +export const PasswordForm: React.FC = observer((props) => { + const { email, updateEmail, handleStepChange, handleSignInRedirection, handleEmailClear } = props; // states const [isSendingUniqueCode, setIsSendingUniqueCode] = useState(false); const [isSendingResetPasswordLink, setIsSendingResetPasswordLink] = useState(false); // toast alert const { setToastAlert } = useToast(); + const { + config: { envConfig }, + } = useApplication(); // form info const { control, @@ -157,11 +163,12 @@ export const PasswordForm: React.FC = (props) => { hasError={Boolean(errors.email)} placeholder="orville.wright@frstflt.com" className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400" + disabled /> {value.length > 0 && ( onChange("")} + onClick={handleEmailClear} /> )} @@ -199,26 +206,28 @@ export const PasswordForm: React.FC = (props) => { -
- +
+ {envConfig && envConfig.is_smtp_configured && ( + + )}

@@ -230,4 +239,4 @@ export const PasswordForm: React.FC = (props) => { ); -}; +}); diff --git a/web/components/account/sign-in-forms/root.tsx b/web/components/account/sign-in-forms/root.tsx index 616f4809f..80f46c63e 100644 --- a/web/components/account/sign-in-forms/root.tsx +++ b/web/components/account/sign-in-forms/root.tsx @@ -13,7 +13,6 @@ import { OAuthOptions, OptionalSetPasswordForm, CreatePasswordForm, - SelfHostedSignInForm, } from "components/account"; export enum ESignInSteps { @@ -45,69 +44,73 @@ export const SignInRoot = observer(() => { return ( <>

- {envConfig?.is_self_managed ? ( - setEmail(newEmail)} - handleSignInRedirection={handleRedirection} - /> - ) : ( - <> - {signInStep === ESignInSteps.EMAIL && ( - setSignInStep(step)} - updateEmail={(newEmail) => setEmail(newEmail)} - /> - )} - {signInStep === ESignInSteps.PASSWORD && ( - setEmail(newEmail)} - handleStepChange={(step) => setSignInStep(step)} - handleSignInRedirection={handleRedirection} - /> - )} - {signInStep === ESignInSteps.SET_PASSWORD_LINK && ( - setEmail(newEmail)} /> - )} - {signInStep === ESignInSteps.USE_UNIQUE_CODE_FROM_PASSWORD && ( - setEmail(newEmail)} - handleStepChange={(step) => setSignInStep(step)} - handleSignInRedirection={handleRedirection} - submitButtonLabel="Go to workspace" - showTermsAndConditions - updateUserOnboardingStatus={(value) => setIsOnboarded(value)} - /> - )} - {signInStep === ESignInSteps.UNIQUE_CODE && ( - setEmail(newEmail)} - handleStepChange={(step) => setSignInStep(step)} - handleSignInRedirection={handleRedirection} - updateUserOnboardingStatus={(value) => setIsOnboarded(value)} - /> - )} - {signInStep === ESignInSteps.OPTIONAL_SET_PASSWORD && ( - setSignInStep(step)} - handleSignInRedirection={handleRedirection} - isOnboarded={isOnboarded} - /> - )} - {signInStep === ESignInSteps.CREATE_PASSWORD && ( - setSignInStep(step)} - handleSignInRedirection={handleRedirection} - isOnboarded={isOnboarded} - /> - )} - - )} + <> + {signInStep === ESignInSteps.EMAIL && ( + setSignInStep(step)} + updateEmail={(newEmail) => setEmail(newEmail)} + /> + )} + {signInStep === ESignInSteps.PASSWORD && ( + setEmail(newEmail)} + handleStepChange={(step) => setSignInStep(step)} + handleEmailClear={() => { + setEmail(""); + setSignInStep(ESignInSteps.EMAIL); + }} + handleSignInRedirection={handleRedirection} + /> + )} + {signInStep === ESignInSteps.SET_PASSWORD_LINK && ( + setEmail(newEmail)} /> + )} + {signInStep === ESignInSteps.USE_UNIQUE_CODE_FROM_PASSWORD && ( + setEmail(newEmail)} + handleStepChange={(step) => setSignInStep(step)} + handleSignInRedirection={handleRedirection} + submitButtonLabel="Go to workspace" + showTermsAndConditions + updateUserOnboardingStatus={(value) => setIsOnboarded(value)} + handleEmailClear={() => { + setEmail(""); + setSignInStep(ESignInSteps.EMAIL); + }} + /> + )} + {signInStep === ESignInSteps.UNIQUE_CODE && ( + setEmail(newEmail)} + handleStepChange={(step) => setSignInStep(step)} + handleSignInRedirection={handleRedirection} + updateUserOnboardingStatus={(value) => setIsOnboarded(value)} + handleEmailClear={() => { + setEmail(""); + setSignInStep(ESignInSteps.EMAIL); + }} + /> + )} + {signInStep === ESignInSteps.OPTIONAL_SET_PASSWORD && ( + setSignInStep(step)} + handleSignInRedirection={handleRedirection} + isOnboarded={isOnboarded} + /> + )} + {signInStep === ESignInSteps.CREATE_PASSWORD && ( + setSignInStep(step)} + handleSignInRedirection={handleRedirection} + isOnboarded={isOnboarded} + /> + )} +
{isOAuthEnabled && !OAUTH_HIDDEN_STEPS.includes(signInStep) && ( diff --git a/web/components/account/sign-in-forms/unique-code.tsx b/web/components/account/sign-in-forms/unique-code.tsx index 433fea00a..3ab75831d 100644 --- a/web/components/account/sign-in-forms/unique-code.tsx +++ b/web/components/account/sign-in-forms/unique-code.tsx @@ -25,6 +25,7 @@ type Props = { submitButtonLabel?: string; showTermsAndConditions?: boolean; updateUserOnboardingStatus: (value: boolean) => void; + handleEmailClear: () => void; }; type TUniqueCodeFormValues = { @@ -50,6 +51,7 @@ export const UniqueCodeForm: React.FC = (props) => { submitButtonLabel = "Continue", showTermsAndConditions = false, updateUserOnboardingStatus, + handleEmailClear, } = props; // states const [isRequestingNewCode, setIsRequestingNewCode] = useState(false); @@ -183,11 +185,12 @@ export const UniqueCodeForm: React.FC = (props) => { hasError={Boolean(errors.email)} placeholder="orville.wright@frstflt.com" className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400" + disabled /> {value.length > 0 && ( onChange("")} + onClick={handleEmailClear} /> )}
From 64ca267db482b8a0d17206f6e8998b706c6b5500 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Thu, 4 Jan 2024 16:29:18 +0530 Subject: [PATCH 05/68] dev: new create issue modal (#3312) --- .../command-palette/command-palette.tsx | 8 +- web/components/issues/draft-issue-form.tsx | 81 ++- web/components/issues/draft-issue-modal.tsx | 2 +- web/components/issues/form.tsx | 655 ------------------ web/components/issues/index.ts | 3 +- .../kanban/headers/group-by-card.tsx | 10 +- .../list/headers/group-by-card.tsx | 10 +- .../quick-action-dropdowns/all-issue.tsx | 29 +- .../quick-action-dropdowns/cycle-issue.tsx | 29 +- .../quick-action-dropdowns/module-issue.tsx | 29 +- .../quick-action-dropdowns/project-issue.tsx | 20 +- .../issues/issue-modal/draft-issue-layout.tsx | 82 +++ web/components/issues/issue-modal/form.tsx | 599 ++++++++++++++++ web/components/issues/issue-modal/index.ts | 3 + web/components/issues/issue-modal/modal.tsx | 188 +++++ web/components/issues/modal.tsx | 448 ------------ web/components/issues/sub-issues/root.tsx | 8 +- web/store/estimate.store.ts | 6 +- 18 files changed, 1004 insertions(+), 1206 deletions(-) delete mode 100644 web/components/issues/form.tsx create mode 100644 web/components/issues/issue-modal/draft-issue-layout.tsx create mode 100644 web/components/issues/issue-modal/form.tsx create mode 100644 web/components/issues/issue-modal/index.ts create mode 100644 web/components/issues/issue-modal/modal.tsx delete mode 100644 web/components/issues/modal.tsx diff --git a/web/components/command-palette/command-palette.tsx b/web/components/command-palette/command-palette.tsx index e5f781dd9..04b2fb714 100644 --- a/web/components/command-palette/command-palette.tsx +++ b/web/components/command-palette/command-palette.tsx @@ -60,7 +60,6 @@ export const CommandPalette: FC = observer(() => { isDeleteIssueModalOpen, toggleDeleteIssueModal, isAnyModalOpen, - createIssueStoreType, } = commandPalette; const { setToastAlert } = useToast(); @@ -215,11 +214,8 @@ export const CommandPalette: FC = observer(() => { toggleCreateIssueModal(false)} - prePopulateData={ - cycleId ? { cycle_id: cycleId.toString() } : moduleId ? { module_id: moduleId.toString() } : undefined - } - currentStore={createIssueStoreType} + onClose={() => toggleCreateIssueModal(false)} + data={cycleId ? { cycle_id: cycleId.toString() } : moduleId ? { module_id: moduleId.toString() } : undefined} /> {workspaceSlug && projectId && issueId && issueDetails && ( diff --git a/web/components/issues/draft-issue-form.tsx b/web/components/issues/draft-issue-form.tsx index 2d79f4ee1..9c6a9bb04 100644 --- a/web/components/issues/draft-issue-form.tsx +++ b/web/components/issues/draft-issue-form.tsx @@ -4,7 +4,7 @@ import { Controller, useForm } from "react-hook-form"; import { observer } from "mobx-react-lite"; import { Sparkle, X } from "lucide-react"; // hooks -import { useApplication, useEstimate, useMention } from "hooks/store"; +import { useApplication, useEstimate, useMention, useProject } from "hooks/store"; import useToast from "hooks/use-toast"; import useLocalStorage from "hooks/use-local-storage"; // services @@ -18,8 +18,10 @@ import { CreateStateModal } from "components/states"; import { CreateLabelModal } from "components/labels"; import { RichTextEditorWithRef } from "@plane/rich-text-editor"; import { + CycleDropdown, DateDropdown, EstimateDropdown, + ModuleDropdown, PriorityDropdown, ProjectDropdown, ProjectMemberDropdown, @@ -103,7 +105,7 @@ export const DraftIssueForm: FC = observer((props) => { const [gptAssistantModal, setGptAssistantModal] = useState(false); const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false); // store hooks - const { areEstimatesActiveForProject } = useEstimate(); + const { areEstimatesEnabledForProject } = useEstimate(); const { mentionHighlights, mentionSuggestions } = useMention(); // hooks const { setValue: setLocalStorageValue } = useLocalStorage("draftedIssue", {}); @@ -117,6 +119,7 @@ export const DraftIssueForm: FC = observer((props) => { const { config: { envConfig }, } = useApplication(); + const { getProjectById } = useProject(); // form info const { formState: { errors, isSubmitting }, @@ -277,6 +280,8 @@ export const DraftIssueForm: FC = observer((props) => { const maxDate = targetDate ? new Date(targetDate) : null; maxDate?.setDate(maxDate.getDate()); + const projectDetails = getProjectById(projectId); + return ( <> {projectId && ( @@ -302,19 +307,21 @@ export const DraftIssueForm: FC = observer((props) => { control={control} name="project_id" render={({ field: { value, onChange } }) => ( - { - onChange(val); - setActiveProject(val); - }} - buttonVariant="background-with-text" - /> +
+ { + onChange(val); + setActiveProject(val); + }} + buttonVariant="border-with-text" + /> +
)} /> )}

- {status ? "Update" : "Create"} Issue + {status ? "Update" : "Create"} issue

{watch("parent_id") && @@ -374,11 +381,11 @@ export const DraftIssueForm: FC = observer((props) => { )} {(fieldsToShow.includes("all") || fieldsToShow.includes("description")) && (
-
+
{issueName && issueName !== "" && ( @@ -408,10 +415,10 @@ export const DraftIssueForm: FC = observer((props) => { button={ } @@ -470,7 +477,7 @@ export const DraftIssueForm: FC = observer((props) => { name="priority" render={({ field: { value, onChange } }) => (
- +
)} /> @@ -485,8 +492,10 @@ export const DraftIssueForm: FC = observer((props) => { projectId={projectId} value={value} onChange={onChange} + buttonVariant={value?.length > 0 ? "transparent-without-text" : "border-with-text"} + buttonClassName={value?.length > 0 ? "hover:bg-transparent px-0" : ""} + placeholder="Assignees" multiple - buttonVariant="background-with-text" />
)} @@ -542,8 +551,40 @@ export const DraftIssueForm: FC = observer((props) => { )} /> )} + {projectDetails?.cycle_view && ( + ( +
+ onChange(cycleId)} + value={value} + buttonVariant="border-with-text" + /> +
+ )} + /> + )} + {projectDetails?.module_view && ( + ( +
+ onChange(moduleId)} + buttonVariant="border-with-text" + /> +
+ )} + /> + )} {(fieldsToShow.includes("all") || fieldsToShow.includes("estimate")) && - areEstimatesActiveForProject(projectId) && ( + areEstimatesEnabledForProject(projectId) && ( = observer((props) => { value={value} onChange={onChange} projectId={projectId} - buttonVariant="background-with-text" + buttonVariant="border-with-text" />
)} diff --git a/web/components/issues/draft-issue-modal.tsx b/web/components/issues/draft-issue-modal.tsx index 4008e6383..39a5fbc5f 100644 --- a/web/components/issues/draft-issue-modal.tsx +++ b/web/components/issues/draft-issue-modal.tsx @@ -322,7 +322,7 @@ export const CreateUpdateDraftIssueModal: React.FC = observer( leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - + = { - project_id: "", - name: "", - description_html: "

", - estimate_point: null, - state_id: "", - parent_id: null, - priority: "none", - assignee_ids: [], - label_ids: [], - start_date: undefined, - target_date: undefined, -}; - -export interface IssueFormProps { - handleFormSubmit: (values: Partial) => Promise; - initialData?: Partial; - projectId: string; - setActiveProject: React.Dispatch>; - createMore: boolean; - setCreateMore: React.Dispatch>; - handleDiscardClose: () => void; - status: boolean; - handleFormDirty: (payload: Partial | null) => void; - fieldsToShow: ( - | "project" - | "name" - | "description" - | "state" - | "priority" - | "assignee" - | "label" - | "startDate" - | "dueDate" - | "estimate" - | "parent" - | "all" - | "module" - | "cycle" - )[]; -} - -// services -const aiService = new AIService(); -const fileService = new FileService(); - -export const IssueForm: FC = observer((props) => { - const { - handleFormSubmit, - initialData, - projectId, - setActiveProject, - createMore, - setCreateMore, - handleDiscardClose, - status, - fieldsToShow, - handleFormDirty, - } = props; - // states - const [stateModal, setStateModal] = useState(false); - const [labelModal, setLabelModal] = useState(false); - const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false); - const [selectedParentIssue, setSelectedParentIssue] = useState(null); - const [gptAssistantModal, setGptAssistantModal] = useState(false); - const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false); - // refs - const editorRef = useRef(null); - // router - const router = useRouter(); - const { workspaceSlug } = router.query; - // store hooks - const { - config: { envConfig }, - } = useApplication(); - const { getProjectById } = useProject(); - const { areEstimatesActiveForProject } = useEstimate(); - const { mentionHighlights, mentionSuggestions } = useMention(); - // toast alert - const { setToastAlert } = useToast(); - // form info - const { - formState: { errors, isSubmitting, isDirty }, - handleSubmit, - reset, - watch, - control, - getValues, - setValue, - setFocus, - } = useForm({ - defaultValues: initialData ?? defaultValues, - reValidateMode: "onChange", - }); - - const issueName = watch("name"); - - const payload: Partial = { - name: getValues("name"), - state_id: getValues("state_id"), - priority: getValues("priority"), - assignee_ids: getValues("assignee_ids"), - label_ids: getValues("label_ids"), - start_date: getValues("start_date"), - target_date: getValues("target_date"), - project_id: getValues("project_id"), - parent_id: getValues("parent_id"), - cycle_id: getValues("cycle_id"), - module_id: getValues("module_id"), - }; - - // derived values - const projectDetails = getProjectById(projectId); - - 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) => { - await handleFormSubmit(formData); - - setGptAssistantModal(false); - - reset({ - ...defaultValues, - project_id: projectId, - description_html: "

", - }); - editorRef?.current?.clearEditor(); - }; - - const handleAiAssistance = async (response: string) => { - if (!workspaceSlug || !projectId) return; - - setValue("description_html", `${watch("description_html")}

${response}

`); - editorRef.current?.setEditorValue(`${watch("description_html")}`); - }; - - const handleAutoGenerateDescription = async () => { - if (!workspaceSlug || !projectId) return; - - setIAmFeelingLucky(true); - - aiService - .createGptTask(workspaceSlug.toString(), projectId.toString(), { - prompt: issueName, - task: "Generate a proper description for this issue.", - }) - .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, - ...initialData, - }); - }, [setFocus, initialData, reset]); - - // update projectId in form when projectId changes - useEffect(() => { - reset({ - ...getValues(), - project_id: 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 && ( - <> - setStateModal(false)} projectId={projectId} /> - setLabelModal(false)} - projectId={projectId} - onSuccess={(response) => setValue("label_ids", [...watch("label_ids"), response.id])} - /> - - )} -
-
-
- {(fieldsToShow.includes("all") || fieldsToShow.includes("project")) && !status && ( - ( -
- { - onChange(val); - setActiveProject(val); - }} - buttonVariant="border-with-text" - /> -
- )} - /> - )} -

- {status ? "Update" : "Create"} Issue -

-
- {watch("parent_id") && - (fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && - selectedParentIssue && ( -
-
- - - {selectedParentIssue.project__identifier}-{selectedParentIssue.sequence_id} - - {selectedParentIssue.name.substring(0, 50)} - { - setValue("parent_id", null); - setSelectedParentIssue(null); - }} - /> -
-
- )} -
-
- {(fieldsToShow.includes("all") || fieldsToShow.includes("name")) && ( -
- ( - - )} - /> -
- )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("description")) && ( -
-
- {issueName && issueName !== "" && ( - - )} - {envConfig?.has_openai_configured && ( - { - setGptAssistantModal((prevData) => !prevData); - // this is done so that the title do not reset after gpt popover closed - reset(getValues()); - }} - onResponse={(response) => { - handleAiAssistance(response); - }} - placement="top-end" - button={ - - } - /> - )} -
- ( - { - onChange(description_html); - }} - mentionHighlights={mentionHighlights} - mentionSuggestions={mentionSuggestions} - /> - )} - /> -
- )} -
- {(fieldsToShow.includes("all") || fieldsToShow.includes("state")) && ( - ( -
- -
- )} - /> - )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("priority")) && ( - ( -
- -
- )} - /> - )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("assignee")) && ( - ( -
- 0 ? "transparent-without-text" : "border-with-text"} - buttonClassName={value?.length > 0 ? "hover:bg-transparent px-0" : ""} - placeholder="Assignees" - multiple - /> -
- )} - /> - )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("label")) && ( - ( -
- -
- )} - /> - )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("startDate")) && ( - ( -
- onChange(date ? renderFormattedPayloadDate(date) : null)} - buttonVariant="border-with-text" - placeholder="Start date" - maxDate={maxDate ?? undefined} - /> -
- )} - /> - )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("dueDate")) && ( -
- ( -
- onChange(date ? renderFormattedPayloadDate(date) : null)} - buttonVariant="border-with-text" - placeholder="Due date" - minDate={minDate ?? undefined} - /> -
- )} - /> -
- )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("cycle")) && projectDetails?.cycle_view && ( - ( -
- -
- )} - /> - )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("module")) && projectDetails?.module_view && ( - ( -
- -
- )} - /> - )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("estimate")) && - areEstimatesActiveForProject(projectId) && ( - ( -
- -
- )} - /> - )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && ( - <> - {watch("parent_id") ? ( - -
- - - {selectedParentIssue && - `${selectedParentIssue.project__identifier}- - ${selectedParentIssue.sequence_id}`} - -
- - } - placement="bottom-start" - > - setParentIssueListModalOpen(true)}> - Change parent issue - - setValue("parent_id", null)}> - Remove parent issue - -
- ) : ( - - )} - - ( - setParentIssueListModalOpen(false)} - onChange={(issue) => { - onChange(issue.id); - setSelectedParentIssue(issue); - }} - projectId={projectId} - /> - )} - /> - - )} -
-
-
-
-
- {!status && ( -
setCreateMore((prevData) => !prevData)} - > -
- {}} size="sm" /> -
- Create more -
- )} -
- - -
-
-
- - ); -}); diff --git a/web/components/issues/index.ts b/web/components/issues/index.ts index 4a58e6547..b8af27d40 100644 --- a/web/components/issues/index.ts +++ b/web/components/issues/index.ts @@ -1,15 +1,14 @@ export * from "./attachment"; export * from "./comment"; +export * from "./issue-modal"; export * from "./sidebar-select"; export * from "./view-select"; export * from "./activity"; export * from "./delete-issue-modal"; export * from "./description-form"; -export * from "./form"; export * from "./issue-layouts"; export * from "./peek-overview"; export * from "./main-content"; -export * from "./modal"; export * from "./parent-issues-list-modal"; export * from "./sidebar"; export * from "./label"; diff --git a/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx b/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx index 232fa6ebd..4d4776d38 100644 --- a/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx +++ b/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx @@ -2,9 +2,8 @@ import React, { FC } from "react"; import { useRouter } from "next/router"; // components import { CustomMenu } from "@plane/ui"; -import { CreateUpdateIssueModal } from "components/issues/modal"; -import { CreateUpdateDraftIssueModal } from "components/issues/draft-issue-modal"; import { ExistingIssuesListModal } from "components/core"; +import { CreateUpdateIssueModal, CreateUpdateDraftIssueModal } from "components/issues"; // lucide icons import { Minimize2, Maximize2, Circle, Plus } from "lucide-react"; // hooks @@ -85,12 +84,7 @@ export const HeaderGroupByCard: FC = observer((props) => { fieldsToShow={["all"]} /> ) : ( - setIsOpen(false)} - prePopulateData={issuePayload} - currentStore={currentStore} - /> + setIsOpen(false)} data={issuePayload} /> )} {renderExistingIssueModal && ( ) : ( - setIsOpen(false)} - currentStore={currentStore} - prePopulateData={issuePayload} - /> + setIsOpen(false)} data={issuePayload} /> )} {renderExistingIssueModal && ( diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx index 2e69ee129..efd9490d7 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx @@ -11,19 +11,17 @@ import { copyUrlToClipboard } from "helpers/string.helper"; // types import { TIssue } from "@plane/types"; import { IQuickActionProps } from "../list/list-view-types"; -import { EIssuesStoreType } from "constants/issue"; export const AllIssueQuickActions: React.FC = (props) => { const { issue, handleDelete, handleUpdate, customActionButton } = props; - - const router = useRouter(); - const { workspaceSlug } = router.query; - // states const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); - const [issueToEdit, setIssueToEdit] = useState(null); + const [issueToEdit, setIssueToEdit] = useState(undefined); const [deleteIssueModal, setDeleteIssueModal] = useState(false); - + // router + const router = useRouter(); + const { workspaceSlug } = router.query; + // toast alert const { setToastAlert } = useToast(); const handleCopyIssueLink = () => { @@ -36,6 +34,12 @@ export const AllIssueQuickActions: React.FC = (props) => { ); }; + const duplicateIssuePayload = { + ...issue, + name: `${issue.name} (copy)`, + }; + delete duplicateIssuePayload.id; + return ( <> = (props) => { /> { + onClose={() => { setCreateUpdateIssueModal(false); - setIssueToEdit(null); + setIssueToEdit(undefined); }} - // pre-populate date only if not editing - prePopulateData={!issueToEdit && createUpdateIssueModal ? { ...issue, name: `${issue.name} (copy)` } : {}} - data={issueToEdit} + data={issueToEdit ?? duplicateIssuePayload} onSubmit={async (data) => { - if (issueToEdit && handleUpdate) handleUpdate({ ...issueToEdit, ...data }); + if (issueToEdit && handleUpdate) await handleUpdate({ ...issueToEdit, ...data }); }} - currentStore={EIssuesStoreType.PROJECT} /> = (props) => { const { issue, handleDelete, handleUpdate, handleRemoveFromView, customActionButton } = props; - - const router = useRouter(); - const { workspaceSlug, cycleId } = router.query; - // states const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); - const [issueToEdit, setIssueToEdit] = useState(null); + const [issueToEdit, setIssueToEdit] = useState(undefined); const [deleteIssueModal, setDeleteIssueModal] = useState(false); - + // router + const router = useRouter(); + const { workspaceSlug, cycleId } = router.query; + // toast alert const { setToastAlert } = useToast(); const handleCopyIssueLink = () => { @@ -36,6 +34,12 @@ export const CycleIssueQuickActions: React.FC = (props) => { ); }; + const duplicateIssuePayload = { + ...issue, + name: `${issue.name} (copy)`, + }; + delete duplicateIssuePayload.id; + return ( <> = (props) => { /> { + onClose={() => { setCreateUpdateIssueModal(false); - setIssueToEdit(null); + setIssueToEdit(undefined); }} - // pre-populate date only if not editing - prePopulateData={!issueToEdit && createUpdateIssueModal ? { ...issue, name: `${issue.name} (copy)` } : {}} - data={issueToEdit} + data={issueToEdit ?? duplicateIssuePayload} onSubmit={async (data) => { - if (issueToEdit && handleUpdate) handleUpdate({ ...issueToEdit, ...data }); + if (issueToEdit && handleUpdate) await handleUpdate({ ...issueToEdit, ...data }); }} - currentStore={EIssuesStoreType.CYCLE} /> = (props) => { const { issue, handleDelete, handleUpdate, handleRemoveFromView, customActionButton } = props; - - const router = useRouter(); - const { workspaceSlug, moduleId } = router.query; - // states const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); - const [issueToEdit, setIssueToEdit] = useState(null); + const [issueToEdit, setIssueToEdit] = useState(undefined); const [deleteIssueModal, setDeleteIssueModal] = useState(false); - + // router + const router = useRouter(); + const { workspaceSlug, moduleId } = router.query; + // toast alert const { setToastAlert } = useToast(); const handleCopyIssueLink = () => { @@ -36,6 +34,12 @@ export const ModuleIssueQuickActions: React.FC = (props) => { ); }; + const duplicateIssuePayload = { + ...issue, + name: `${issue.name} (copy)`, + }; + delete duplicateIssuePayload.id; + return ( <> = (props) => { /> { + onClose={() => { setCreateUpdateIssueModal(false); - setIssueToEdit(null); + setIssueToEdit(undefined); }} - // pre-populate date only if not editing - prePopulateData={!issueToEdit && createUpdateIssueModal ? { ...issue, name: `${issue.name} (copy)` } : {}} - data={issueToEdit} + data={issueToEdit ?? duplicateIssuePayload} onSubmit={async (data) => { - if (issueToEdit && handleUpdate) handleUpdate({ ...issueToEdit, ...data }); + if (issueToEdit && handleUpdate) await handleUpdate({ ...issueToEdit, ...data }); }} - currentStore={EIssuesStoreType.MODULE} /> = (props) => { const { issue, handleDelete, handleUpdate, customActionButton } = props; @@ -23,7 +22,7 @@ export const ProjectIssueQuickActions: React.FC = (props) => const { workspaceSlug } = router.query; // states const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); - const [issueToEdit, setIssueToEdit] = useState(null); + const [issueToEdit, setIssueToEdit] = useState(undefined); const [deleteIssueModal, setDeleteIssueModal] = useState(false); // store hooks const { @@ -44,6 +43,12 @@ export const ProjectIssueQuickActions: React.FC = (props) => ); }; + const duplicateIssuePayload = { + ...issue, + name: `${issue.name} (copy)`, + }; + delete duplicateIssuePayload.id; + return ( <> = (props) => /> { + onClose={() => { setCreateUpdateIssueModal(false); - setIssueToEdit(null); + setIssueToEdit(undefined); }} - // pre-populate date only if not editing - prePopulateData={!issueToEdit && createUpdateIssueModal ? { ...issue, name: `${issue.name} (copy)` } : {}} - data={issueToEdit} + data={issueToEdit ?? duplicateIssuePayload} onSubmit={async (data) => { - if (issueToEdit && handleUpdate) handleUpdate({ ...issueToEdit, ...data }); + if (issueToEdit && handleUpdate) await handleUpdate({ ...issueToEdit, ...data }); }} - currentStore={EIssuesStoreType.PROJECT} /> | null; + data?: Partial; + onChange: (formData: Partial | null) => void; + onClose: (saveDraftIssueInLocalStorage?: boolean) => void; + onSubmit: (formData: Partial) => Promise; + projectId: string; +} + +const issueDraftService = new IssueDraftService(); + +export const DraftIssueLayout: React.FC = observer((props) => { + const { changesMade, data, onChange, onClose, onSubmit, projectId } = props; + // states + const [issueDiscardModal, setIssueDiscardModal] = useState(false); + // router + const router = useRouter(); + const { workspaceSlug } = router.query; + // toast alert + const { setToastAlert } = useToast(); + + const handleClose = () => { + if (changesMade) setIssueDiscardModal(true); + else onClose(false); + }; + + const handleCreateDraftIssue = async () => { + if (!changesMade || !workspaceSlug || !projectId) return; + + const payload = { ...changesMade }; + + await issueDraftService + .createDraftIssue(workspaceSlug.toString(), projectId.toString(), payload) + .then(() => { + setToastAlert({ + type: "success", + title: "Success!", + message: "Draft Issue created successfully.", + }); + + onChange(null); + setIssueDiscardModal(false); + onClose(false); + }) + .catch(() => + setToastAlert({ + type: "error", + title: "Error!", + message: "Issue could not be created. Please try again.", + }) + ); + }; + + return ( + <> + setIssueDiscardModal(false)} + onConfirm={handleCreateDraftIssue} + onDiscard={() => { + onChange(null); + setIssueDiscardModal(false); + onClose(false); + }} + /> + + + ); +}); diff --git a/web/components/issues/issue-modal/form.tsx b/web/components/issues/issue-modal/form.tsx new file mode 100644 index 000000000..7f00f6216 --- /dev/null +++ b/web/components/issues/issue-modal/form.tsx @@ -0,0 +1,599 @@ +import React, { FC, useState, useRef } from "react"; +import { useRouter } from "next/router"; +import { observer } from "mobx-react-lite"; +import { Controller, useForm } from "react-hook-form"; +import { LayoutPanelTop, Sparkle, X } from "lucide-react"; +// editor +import { RichTextEditorWithRef } from "@plane/rich-text-editor"; +// hooks +import { useApplication, useEstimate, useMention, useProject } from "hooks/store"; +import useToast from "hooks/use-toast"; +// services +import { AIService } from "services/ai.service"; +import { FileService } from "services/file.service"; +// components +import { GptAssistantPopover } from "components/core"; +import { ParentIssuesListModal } from "components/issues"; +import { IssueLabelSelect } from "components/issues/select"; +import { CreateLabelModal } from "components/labels"; +import { + CycleDropdown, + DateDropdown, + EstimateDropdown, + ModuleDropdown, + PriorityDropdown, + ProjectDropdown, + ProjectMemberDropdown, + StateDropdown, +} from "components/dropdowns"; +// ui +import { Button, CustomMenu, Input, ToggleSwitch } from "@plane/ui"; +// helpers +import { renderFormattedPayloadDate } from "helpers/date-time.helper"; +// types +import type { TIssue, ISearchIssueResponse } from "@plane/types"; + +const defaultValues: Partial = { + project_id: "", + name: "", + description_html: "", + estimate_point: null, + state_id: "", + parent_id: null, + priority: "none", + assignee_ids: [], + label_ids: [], + cycle_id: null, + module_id: null, + start_date: null, + target_date: null, +}; + +export interface IssueFormProps { + data?: Partial; + onChange?: (formData: Partial | null) => void; + onClose: () => void; + onSubmit: (values: Partial) => Promise; + projectId: string; +} + +// services +const aiService = new AIService(); +const fileService = new FileService(); + +export const IssueFormRoot: FC = observer((props) => { + const { data, onChange, onClose, onSubmit, projectId } = props; + // states + const [labelModal, setLabelModal] = useState(false); + const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false); + const [selectedParentIssue, setSelectedParentIssue] = useState(null); + const [gptAssistantModal, setGptAssistantModal] = useState(false); + const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false); + const [createMore, setCreateMore] = useState(false); + // refs + const editorRef = useRef(null); + // router + const router = useRouter(); + const { workspaceSlug } = router.query; + // store hooks + const { + config: { envConfig }, + } = useApplication(); + const { getProjectById } = useProject(); + const { areEstimatesEnabledForProject } = useEstimate(); + const { mentionHighlights, mentionSuggestions } = useMention(); + // toast alert + const { setToastAlert } = useToast(); + // form info + const { + formState: { errors, isDirty, isSubmitting }, + handleSubmit, + reset, + watch, + control, + getValues, + setValue, + } = useForm({ + defaultValues: { ...defaultValues, project_id: projectId, ...data }, + reValidateMode: "onChange", + }); + + const issueName = watch("name"); + + const handleFormSubmit = async (formData: Partial) => { + await onSubmit(formData); + + setGptAssistantModal(false); + + reset({ + ...defaultValues, + project_id: getValues("project_id"), + }); + editorRef?.current?.clearEditor(); + }; + + const handleAiAssistance = async (response: string) => { + if (!workspaceSlug || !projectId) return; + + setValue("description_html", `${watch("description_html")}

${response}

`); + editorRef.current?.setEditorValue(`${watch("description_html")}`); + }; + + const handleAutoGenerateDescription = async () => { + if (!workspaceSlug || !projectId) return; + + setIAmFeelingLucky(true); + + aiService + .createGptTask(workspaceSlug.toString(), projectId.toString(), { + prompt: issueName, + task: "Generate a proper description for this issue.", + }) + .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)); + }; + + const handleFormChange = () => { + if (!onChange) return; + + if (isDirty) onChange(watch()); + else onChange(null); + }; + + 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()); + + const projectDetails = getProjectById(projectId); + + return ( + <> + {projectId && ( + setLabelModal(false)} + projectId={projectId} + onSuccess={(response) => { + setValue("label_ids", [...watch("label_ids"), response.id]); + handleFormChange(); + }} + /> + )} +
+
+
+ {/* Don't show project selection if editing an issue */} + {!data?.id && ( + ( +
+ { + onChange(projectId); + handleFormChange(); + }} + buttonVariant="border-with-text" + /> +
+ )} + /> + )} +

+ {data?.id ? "Update" : "Create"} issue +

+
+ {watch("parent_id") && selectedParentIssue && ( +
+
+ + + {selectedParentIssue.project__identifier}-{selectedParentIssue.sequence_id} + + {selectedParentIssue.name.substring(0, 50)} + { + setValue("parent_id", null); + handleFormChange(); + setSelectedParentIssue(null); + }} + /> +
+
+ )} +
+
+ ( + { + onChange(e.target.value); + handleFormChange(); + }} + ref={ref} + hasError={Boolean(errors.name)} + placeholder="Issue Title" + className="resize-none text-xl w-full" + /> + )} + /> +
+
+ {issueName && issueName.trim() !== "" && ( + + )} + {envConfig?.has_openai_configured && ( + { + setGptAssistantModal((prevData) => !prevData); + // this is done so that the title do not reset after gpt popover closed + reset(getValues()); + }} + onResponse={(response) => { + handleAiAssistance(response); + }} + placement="top-end" + button={ + + } + /> + )} +
+ ( + { + onChange(description_html); + handleFormChange(); + }} + mentionHighlights={mentionHighlights} + mentionSuggestions={mentionSuggestions} + /> + )} + /> +
+
+ ( +
+ { + onChange(stateId); + handleFormChange(); + }} + projectId={projectId} + buttonVariant="border-with-text" + /> +
+ )} + /> + ( +
+ { + onChange(priority); + handleFormChange(); + }} + buttonVariant="border-with-text" + /> +
+ )} + /> + ( +
+ { + onChange(assigneeIds); + handleFormChange(); + }} + buttonVariant={value?.length > 0 ? "transparent-without-text" : "border-with-text"} + buttonClassName={value?.length > 0 ? "hover:bg-transparent px-0" : ""} + placeholder="Assignees" + multiple + /> +
+ )} + /> + ( +
+ { + onChange(labelIds); + handleFormChange(); + }} + projectId={projectId} + /> +
+ )} + /> + ( +
+ { + onChange(date ? renderFormattedPayloadDate(date) : null); + handleFormChange(); + }} + buttonVariant="border-with-text" + placeholder="Start date" + maxDate={maxDate ?? undefined} + /> +
+ )} + /> + ( +
+ { + onChange(date ? renderFormattedPayloadDate(date) : null); + handleFormChange(); + }} + buttonVariant="border-with-text" + placeholder="Due date" + minDate={minDate ?? undefined} + /> +
+ )} + /> + {projectDetails?.cycle_view && ( + ( +
+ { + onChange(cycleId); + handleFormChange(); + }} + value={value} + buttonVariant="border-with-text" + /> +
+ )} + /> + )} + {projectDetails?.module_view && ( + ( +
+ { + onChange(moduleId); + handleFormChange(); + }} + buttonVariant="border-with-text" + /> +
+ )} + /> + )} + {areEstimatesEnabledForProject(projectId) && ( + ( +
+ { + onChange(estimatePoint); + handleFormChange(); + }} + projectId={projectId} + buttonVariant="border-with-text" + /> +
+ )} + /> + )} + + {watch("parent_id") ? ( +
+ + + {selectedParentIssue && + `${selectedParentIssue.project__identifier}- + ${selectedParentIssue.sequence_id}`} + +
+ ) : ( +
+ + Add parent +
+ )} + + } + placement="bottom-start" + > + {watch("parent_id") ? ( + <> + setParentIssueListModalOpen(true)}> + Change parent issue + + { + setValue("parent_id", null); + handleFormChange(); + }} + > + Remove parent issue + + + ) : ( + setParentIssueListModalOpen(true)}> + Select parent Issue + + )} +
+ ( + setParentIssueListModalOpen(false)} + onChange={(issue) => { + onChange(issue.id); + handleFormChange(); + setSelectedParentIssue(issue); + }} + projectId={projectId} + /> + )} + /> +
+
+
+
+
+
setCreateMore((prevData) => !prevData)} + > +
+ {}} size="sm" /> +
+ Create more +
+
+ + +
+
+
+ + ); +}); diff --git a/web/components/issues/issue-modal/index.ts b/web/components/issues/issue-modal/index.ts new file mode 100644 index 000000000..feac885d4 --- /dev/null +++ b/web/components/issues/issue-modal/index.ts @@ -0,0 +1,3 @@ +export * from "./draft-issue-layout"; +export * from "./form"; +export * from "./modal"; diff --git a/web/components/issues/issue-modal/modal.tsx b/web/components/issues/issue-modal/modal.tsx new file mode 100644 index 000000000..975d4f09e --- /dev/null +++ b/web/components/issues/issue-modal/modal.tsx @@ -0,0 +1,188 @@ +import React, { useState } from "react"; +import { useRouter } from "next/router"; +import { observer } from "mobx-react-lite"; +import { Dialog, Transition } from "@headlessui/react"; +// hooks +import { useIssues, useProject } from "hooks/store"; +import useToast from "hooks/use-toast"; +import useLocalStorage from "hooks/use-local-storage"; +// components +import { DraftIssueLayout } from "./draft-issue-layout"; +import { IssueFormRoot } from "./form"; +// types +import type { TIssue } from "@plane/types"; +// constants +import { EIssuesStoreType } from "constants/issue"; + +export interface IssuesModalProps { + data?: Partial; + isOpen: boolean; + onClose: () => void; + onSubmit?: (res: Partial) => Promise; + withDraftIssueWrapper?: boolean; +} + +export const CreateUpdateIssueModal: React.FC = observer((props) => { + const { data, isOpen, onClose, onSubmit, withDraftIssueWrapper = true } = props; + // states + const [changesMade, setChangesMade] = useState | null>(null); + // router + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + // store hooks + const { workspaceProjectIds } = useProject(); + const { + issues: { createIssue, updateIssue }, + } = useIssues(EIssuesStoreType.PROJECT); + const { + issues: { addIssueToCycle }, + } = useIssues(EIssuesStoreType.CYCLE); + const { + issues: { addIssueToModule }, + } = useIssues(EIssuesStoreType.MODULE); + // toast alert + const { setToastAlert } = useToast(); + // local storage + const { setValue: setLocalStorageDraftIssue } = useLocalStorage("draftedIssue", {}); + + const handleClose = (saveDraftIssueInLocalStorage?: boolean) => { + if (changesMade && saveDraftIssueInLocalStorage) { + const draftIssue = JSON.stringify(changesMade); + + setLocalStorageDraftIssue(draftIssue); + } + + onClose(); + }; + + const handleCreateIssue = async (payload: Partial): Promise => { + if (!workspaceSlug || !payload.project_id) return null; + + await createIssue(workspaceSlug.toString(), payload.project_id, payload) + .then(async (res) => { + setToastAlert({ + type: "success", + title: "Success!", + message: "Issue created successfully.", + }); + handleClose(); + return res; + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Issue could not be created. Please try again.", + }); + }); + + return null; + }; + + const handleUpdateIssue = async (payload: Partial): Promise => { + if (!workspaceSlug || !payload.project_id || !data?.id) return null; + + await updateIssue(workspaceSlug.toString(), payload.project_id, data.id, payload) + .then((res) => { + setToastAlert({ + type: "success", + title: "Success!", + message: "Issue updated successfully.", + }); + handleClose(); + return { ...payload, ...res }; + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Issue could not be updated. Please try again.", + }); + }); + + return null; + }; + + const handleFormSubmit = async (formData: Partial) => { + if (!workspaceSlug || !formData.project_id) return; + + const payload: Partial = { + ...formData, + description_html: formData.description_html ?? "

", + }; + + let res: TIssue | null = null; + if (!data?.id) res = await handleCreateIssue(payload); + else res = await handleUpdateIssue(payload); + + // add issue to cycle if cycle is selected, and cycle is different from current cycle + if (formData.cycle_id && res && (!data?.id || formData.cycle_id !== data?.cycle_id)) + await addIssueToCycle(workspaceSlug.toString(), formData.project_id, formData.cycle_id, [res.id]); + + // add issue to module if module is selected, and module is different from current module + if (formData.module_id && res && (!data?.id || formData.module_id !== data?.module_id)) + await addIssueToModule(workspaceSlug.toString(), formData.project_id, formData.module_id, [res.id]); + + if (res && onSubmit) await onSubmit(res); + }; + + const handleFormChange = (formData: Partial | null) => setChangesMade(formData); + + // don't open the modal if there are no projects + if (!workspaceProjectIds || workspaceProjectIds.length === 0) return null; + + // if project id is present in the router query, use that as the selected project id, otherwise use the first project id + const selectedProjectId = projectId ? projectId.toString() : workspaceProjectIds[0]; + + return ( + + handleClose(true)}> + +
+ + +
+
+ + + {withDraftIssueWrapper ? ( + + ) : ( + handleClose(false)} + onSubmit={handleFormSubmit} + projectId={selectedProjectId} + /> + )} + + +
+
+
+
+ ); +}); diff --git a/web/components/issues/modal.tsx b/web/components/issues/modal.tsx deleted file mode 100644 index 402e94651..000000000 --- a/web/components/issues/modal.tsx +++ /dev/null @@ -1,448 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { useRouter } from "next/router"; -import { observer } from "mobx-react-lite"; -import { mutate } from "swr"; -import { Dialog, Transition } from "@headlessui/react"; -// hooks -import { useApplication, useCycle, useIssues, useModule, useProject, useUser, useWorkspace } from "hooks/store"; -import useToast from "hooks/use-toast"; -import useLocalStorage from "hooks/use-local-storage"; -// services -import { IssueDraftService } from "services/issue"; -// components -import { IssueForm, ConfirmIssueDiscard } from "components/issues"; -// types -import type { TIssue } from "@plane/types"; -// fetch-keys -import { USER_ISSUE, SUB_ISSUES } from "constants/fetch-keys"; -import { EIssuesStoreType, TCreateModalStoreTypes } from "constants/issue"; - -export interface IssuesModalProps { - data?: TIssue | null; - handleClose: () => void; - isOpen: boolean; - prePopulateData?: Partial; - fieldsToShow?: ( - | "project" - | "name" - | "description" - | "state" - | "priority" - | "assignee" - | "label" - | "startDate" - | "dueDate" - | "estimate" - | "parent" - | "all" - | "module" - | "cycle" - )[]; - onSubmit?: (data: Partial) => Promise; - handleSubmit?: (data: Partial) => Promise; - currentStore?: TCreateModalStoreTypes; -} - -const issueDraftService = new IssueDraftService(); - -export const CreateUpdateIssueModal: React.FC = observer((props) => { - const { - data, - handleClose, - isOpen, - prePopulateData: prePopulateDataProps, - fieldsToShow = ["all"], - onSubmit, - handleSubmit, - currentStore = EIssuesStoreType.PROJECT, - } = props; - // states - const [createMore, setCreateMore] = useState(false); - const [formDirtyState, setFormDirtyState] = useState(null); - const [showConfirmDiscard, setShowConfirmDiscard] = useState(false); - const [activeProject, setActiveProject] = useState(null); - const [prePopulateData, setPreloadedData] = useState>({}); - // router - const router = useRouter(); - const { workspaceSlug, projectId, cycleId, moduleId } = router.query as { - workspaceSlug: string; - projectId: string | undefined; - cycleId: string | undefined; - moduleId: string | undefined; - }; - // store hooks - - const { issues: projectIssues } = useIssues(EIssuesStoreType.PROJECT); - const { issues: moduleIssues } = useIssues(EIssuesStoreType.MODULE); - const { issues: cycleIssues } = useIssues(EIssuesStoreType.CYCLE); - const { issues: viewIssues } = useIssues(EIssuesStoreType.PROJECT_VIEW); - const { issues: profileIssues } = useIssues(EIssuesStoreType.PROFILE); - - const { - eventTracker: { postHogEventTracker }, - } = useApplication(); - const { currentUser } = useUser(); - const { currentWorkspace } = useWorkspace(); - const { workspaceProjectIds } = useProject(); - const { fetchCycleDetails } = useCycle(); - const { fetchModuleDetails } = useModule(); - - const issueStores = { - [EIssuesStoreType.PROJECT]: { - store: projectIssues, - dataIdToUpdate: activeProject, - viewId: undefined, - }, - [EIssuesStoreType.PROJECT_VIEW]: { - store: viewIssues, - dataIdToUpdate: activeProject, - viewId: undefined, - }, - [EIssuesStoreType.PROFILE]: { - store: profileIssues, - dataIdToUpdate: currentUser?.id || undefined, - viewId: undefined, - }, - [EIssuesStoreType.CYCLE]: { - store: cycleIssues, - dataIdToUpdate: activeProject, - viewId: cycleId, - }, - [EIssuesStoreType.MODULE]: { - store: moduleIssues, - dataIdToUpdate: activeProject, - viewId: moduleId, - }, - }; - - const { store: currentIssueStore, viewId, dataIdToUpdate } = issueStores[currentStore]; - - const { setValue: setValueInLocalStorage, clearValue: clearLocalStorageValue } = useLocalStorage( - "draftedIssue", - {} - ); - - const { setToastAlert } = useToast(); - - useEffect(() => { - setPreloadedData(prePopulateDataProps ?? {}); - - if (cycleId && !prePopulateDataProps?.cycle_id) { - setPreloadedData((prevData) => ({ - ...(prevData ?? {}), - ...prePopulateDataProps, - cycle_id: cycleId.toString(), - })); - } - if (moduleId && !prePopulateDataProps?.module_id) { - setPreloadedData((prevData) => ({ - ...(prevData ?? {}), - ...prePopulateDataProps, - module_id: moduleId.toString(), - })); - } - if ( - (router.asPath.includes("my-issues") || router.asPath.includes("assigned")) && - !prePopulateDataProps?.assignee_ids - ) { - setPreloadedData((prevData) => ({ - ...(prevData ?? {}), - ...prePopulateDataProps, - assignees: prePopulateDataProps?.assignee_ids ?? [currentUser?.id ?? ""], - })); - } - }, [prePopulateDataProps, cycleId, moduleId, router.asPath, currentUser?.id]); - - /** - * - * @description This function is used to close the modals. This function will show a confirm discard modal if the form is dirty. - * @returns void - */ - - const onClose = () => { - if (!showConfirmDiscard) handleClose(); - if (formDirtyState === null) return setActiveProject(null); - const data = JSON.stringify(formDirtyState); - setValueInLocalStorage(data); - }; - - /** - * @description This function is used to close the modals. This function is to be used when the form is submitted, - * meaning we don't need to show the confirm discard modal or store the form data in local storage. - */ - - const onFormSubmitClose = () => { - setFormDirtyState(null); - handleClose(); - }; - - /** - * @description This function is used to close the modals. This function is to be used when we click outside the modal, - * meaning we don't need to show the confirm discard modal but will store the form data in local storage. - * Use this function when you want to store the form data in local storage. - */ - - const onDiscardClose = () => { - if (formDirtyState !== null && formDirtyState.name.trim() !== "") { - setShowConfirmDiscard(true); - } else { - handleClose(); - setActiveProject(null); - } - }; - - const handleFormDirty = (data: any) => { - setFormDirtyState(data); - }; - - 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_id) { - setActiveProject(data.project_id); - return; - } - - // if data is not present, set active project to the project - // in the url. This has the least priority. - if (workspaceProjectIds && workspaceProjectIds.length > 0 && !activeProject) - setActiveProject(projectId ?? workspaceProjectIds?.[0] ?? null); - }, [data, projectId, workspaceProjectIds, isOpen, activeProject]); - - const addIssueToCycle = async (issue: TIssue, cycleId: string) => { - if (!workspaceSlug || !activeProject) return; - - await cycleIssues.addIssueToCycle(workspaceSlug, issue.project_id, cycleId, [issue.id]); - fetchCycleDetails(workspaceSlug, activeProject, cycleId); - }; - - const addIssueToModule = async (issue: TIssue, moduleId: string) => { - if (!workspaceSlug || !activeProject) return; - - await moduleIssues.addIssueToModule(workspaceSlug, activeProject, moduleId, [issue.id]); - fetchModuleDetails(workspaceSlug, activeProject, moduleId); - }; - - const createIssue = async (payload: Partial) => { - if (!workspaceSlug || !dataIdToUpdate) return; - - await currentIssueStore - .createIssue(workspaceSlug, dataIdToUpdate, payload, viewId) - .then(async (res) => { - if (!res) throw new Error(); - - if (handleSubmit) { - await handleSubmit(res); - } else { - currentIssueStore.fetchIssues(workspaceSlug, dataIdToUpdate, "mutation", viewId); - - if (payload.cycle_id && payload.cycle_id !== "") await addIssueToCycle(res, payload.cycle_id); - if (payload.module_id && payload.module_id !== "") await addIssueToModule(res, payload.module_id); - - setToastAlert({ - type: "success", - title: "Success!", - message: "Issue created successfully.", - }); - postHogEventTracker( - "ISSUE_CREATED", - { - ...res, - state: "SUCCESS", - }, - { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - } - ); - if (payload.parent_id && payload.parent_id !== "") mutate(SUB_ISSUES(payload.parent_id)); - } - }) - .catch((err) => { - setToastAlert({ - type: "error", - title: "Error!", - message: err.detail ?? "Issue could not be created. Please try again.", - }); - postHogEventTracker( - "ISSUE_CREATED", - { - state: "FAILED", - }, - { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - } - ); - }); - - if (!createMore) onFormSubmitClose(); - }; - - const createDraftIssue = async () => { - if (!workspaceSlug || !activeProject || !currentUser) return; - - const payload: Partial = { - ...formDirtyState, - }; - - await issueDraftService - .createDraftIssue(workspaceSlug as string, activeProject ?? "", payload) - .then(() => { - setToastAlert({ - type: "success", - title: "Success!", - message: "Draft Issue created successfully.", - }); - handleClose(); - setActiveProject(null); - setFormDirtyState(null); - setShowConfirmDiscard(false); - - if (payload.assignee_ids?.some((assignee) => assignee === currentUser?.id)) - mutate(USER_ISSUE(workspaceSlug as string)); - - if (payload.parent_id && payload.parent_id !== "") mutate(SUB_ISSUES(payload.parent_id)); - }) - .catch((err) => { - setToastAlert({ - type: "error", - title: "Error!", - message: err.detail ?? "Issue could not be created. Please try again.", - }); - }); - }; - - const updateIssue = async (payload: Partial) => { - if (!workspaceSlug || !dataIdToUpdate || !data) return; - - await currentIssueStore - .updateIssue(workspaceSlug, dataIdToUpdate, data.id, payload, viewId) - .then((res) => { - if (!createMore) onFormSubmitClose(); - - setToastAlert({ - type: "success", - title: "Success!", - message: "Issue updated successfully.", - }); - postHogEventTracker( - "ISSUE_UPDATED", - { - ...res, - state: "SUCCESS", - }, - { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - } - ); - }) - .catch((err) => { - setToastAlert({ - type: "error", - title: "Error!", - message: err.detail ?? "Issue could not be updated. Please try again.", - }); - postHogEventTracker( - "ISSUE_UPDATED", - { - state: "FAILED", - }, - { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - } - ); - }); - }; - - const handleFormSubmit = async (formData: Partial) => { - if (!workspaceSlug || !dataIdToUpdate || !currentStore) return; - - const payload: Partial = { - ...formData, - description_html: formData.description_html ?? "

", - }; - - if (!data) await createIssue(payload); - else await updateIssue(payload); - - if (onSubmit) await onSubmit(payload); - }; - - if (!workspaceProjectIds || workspaceProjectIds.length === 0) return null; - - return ( - <> - setShowConfirmDiscard(false)} - onConfirm={createDraftIssue} - onDiscard={() => { - handleClose(); - setActiveProject(null); - setFormDirtyState(null); - setShowConfirmDiscard(false); - clearLocalStorageValue(); - }} - /> - - - - -
- - -
-
- - - - - -
-
-
-
- - ); -}); diff --git a/web/components/issues/sub-issues/root.tsx b/web/components/issues/sub-issues/root.tsx index 25d85be15..025e4741f 100644 --- a/web/components/issues/sub-issues/root.tsx +++ b/web/components/issues/sub-issues/root.tsx @@ -320,10 +320,10 @@ export const SubIssuesRoot: React.FC = observer((props) => { {isEditable && issueCrudOperation?.create?.toggle && ( { + onClose={() => { mutateSubIssues(issueCrudOperation?.create?.issueId); handleIssueCrudOperation("create", null); }} @@ -342,11 +342,11 @@ export const SubIssuesRoot: React.FC = observer((props) => { <> { + onClose={() => { mutateSubIssues(issueCrudOperation?.edit?.issueId); handleIssueCrudOperation("edit", null, null); }} - data={issueCrudOperation?.edit?.issue} + data={issueCrudOperation?.edit?.issue ?? undefined} /> )} diff --git a/web/store/estimate.store.ts b/web/store/estimate.store.ts index af938c52e..19a05b544 100644 --- a/web/store/estimate.store.ts +++ b/web/store/estimate.store.ts @@ -14,7 +14,7 @@ export interface IEstimateStore { projectEstimates: IEstimate[] | null; activeEstimateDetails: IEstimate | null; // computed actions - areEstimatesActiveForProject: (projectId: string) => boolean; + areEstimatesEnabledForProject: (projectId: string) => boolean; getEstimatePointValue: (estimateKey: number | null) => string; getProjectEstimateById: (estimateId: string) => IEstimate | null; getProjectActiveEstimateDetails: (projectId: string) => IEstimate | null; @@ -48,7 +48,7 @@ export class EstimateStore implements IEstimateStore { projectEstimates: computed, activeEstimateDetails: computed, // computed actions - areEstimatesActiveForProject: action, + areEstimatesEnabledForProject: action, getProjectEstimateById: action, getEstimatePointValue: action, getProjectActiveEstimateDetails: action, @@ -96,7 +96,7 @@ export class EstimateStore implements IEstimateStore { * @description returns true if estimates are enabled for a project using project id * @param projectId */ - areEstimatesActiveForProject = (projectId: string) => { + areEstimatesEnabledForProject = (projectId: string) => { const projectDetails = this.rootStore.projectRoot.project.getProjectById(projectId); if (!projectDetails) return false; return Boolean(projectDetails.estimate) ?? false; From b340232e76aca30090f983c137cde74075f6657c Mon Sep 17 00:00:00 2001 From: "M. Palanikannan" <73993394+Palanikannan1437@users.noreply.github.com> Date: Thu, 4 Jan 2024 16:30:10 +0530 Subject: [PATCH 06/68] =?UTF-8?q?=E2=9C=A8=20chore:=20Updated=20TableView?= =?UTF-8?q?=20component=20in=20table=20extension=20to=20solve=20sentry=20(?= =?UTF-8?q?#3309)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit error of table not being defined while getting getBoundingClientRect() and solve other TS issues - Added ResolvedPos import from @tiptap/pm/model - Updated setCellsBackgroundColor function parameter type to string - Declared ToolboxItem type for toolbox items - Modified columnsToolboxItems and rowsToolboxItems to use the ToolboxItem type - Updated createToolbox function parameters to specify Element or null for triggerButton and ToolboxItem[] for items - Added ts-expect-error comment above the toolbox variable declaration - Updated update method parameter type to readonly Decoration[] - Changed destructuring assignment of hoveredTable and hoveredCell in updateControls method to use Object.values and reduce method - Added null check for this.table in updateControls method - Wrapped the code that updates columnsControl and rowsControl with null checks for each control - Replaced ts-ignore comments with proper dispatch calls in selectColumn and selectRow methods Co-authored-by: sriram veeraghanta --- .../ui/extensions/table/table/table-view.tsx | 85 ++++++++++--------- 1 file changed, 45 insertions(+), 40 deletions(-) diff --git a/packages/editor/core/src/ui/extensions/table/table/table-view.tsx b/packages/editor/core/src/ui/extensions/table/table/table-view.tsx index bc42b49ff..bd96ff1b1 100644 --- a/packages/editor/core/src/ui/extensions/table/table/table-view.tsx +++ b/packages/editor/core/src/ui/extensions/table/table/table-view.tsx @@ -1,5 +1,5 @@ import { h } from "jsx-dom-cjs"; -import { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { Node as ProseMirrorNode, ResolvedPos } from "@tiptap/pm/model"; import { Decoration, NodeView } from "@tiptap/pm/view"; import tippy, { Instance, Props } from "tippy.js"; @@ -8,6 +8,12 @@ import { CellSelection, TableMap, updateColumnsOnResize } from "@tiptap/pm/table import { icons } from "src/ui/extensions/table/table/icons"; +type ToolboxItem = { + label: string; + icon: string; + action: (args: any) => void; +}; + export function updateColumns( node: ProseMirrorNode, colgroup: HTMLElement, @@ -75,7 +81,7 @@ const defaultTippyOptions: Partial = { placement: "right", }; -function setCellsBackgroundColor(editor: Editor, backgroundColor) { +function setCellsBackgroundColor(editor: Editor, backgroundColor: string) { return editor .chain() .focus() @@ -88,7 +94,7 @@ function setCellsBackgroundColor(editor: Editor, backgroundColor) { .run(); } -const columnsToolboxItems = [ +const columnsToolboxItems: ToolboxItem[] = [ { label: "Add Column Before", icon: icons.insertLeftTableIcon, @@ -109,7 +115,7 @@ const columnsToolboxItems = [ }: { editor: Editor; triggerButton: HTMLElement; - controlsContainer; + controlsContainer: Element; }) => { createColorPickerToolbox({ triggerButton, @@ -127,7 +133,7 @@ const columnsToolboxItems = [ }, ]; -const rowsToolboxItems = [ +const rowsToolboxItems: ToolboxItem[] = [ { label: "Add Row Above", icon: icons.insertTopTableIcon, @@ -172,11 +178,12 @@ function createToolbox({ tippyOptions, onClickItem, }: { - triggerButton: HTMLElement; - items: { icon: string; label: string }[]; + triggerButton: Element | null; + items: ToolboxItem[]; tippyOptions: any; - onClickItem: any; + onClickItem: (item: ToolboxItem) => void; }): Instance { + // @ts-expect-error const toolbox = tippy(triggerButton, { content: h( "div", @@ -278,14 +285,14 @@ export class TableView implements NodeView { decorations: Decoration[]; editor: Editor; getPos: () => number; - hoveredCell; + hoveredCell: ResolvedPos | null = null; map: TableMap; root: HTMLElement; - table: HTMLElement; - colgroup: HTMLElement; + table: HTMLTableElement; + colgroup: HTMLTableColElement; tbody: HTMLElement; - rowsControl?: HTMLElement; - columnsControl?: HTMLElement; + rowsControl?: HTMLElement | null; + columnsControl?: HTMLElement | null; columnsToolbox?: Instance; rowsToolbox?: Instance; controls?: HTMLElement; @@ -398,13 +405,13 @@ export class TableView implements NodeView { this.render(); } - update(node: ProseMirrorNode, decorations) { + update(node: ProseMirrorNode, decorations: readonly Decoration[]) { if (node.type !== this.node.type) { return false; } this.node = node; - this.decorations = decorations; + this.decorations = [...decorations]; this.map = TableMap.get(this.node); if (this.editor.isEditable) { @@ -430,19 +437,16 @@ export class TableView implements NodeView { } updateControls() { - const { hoveredTable: table, hoveredCell: cell } = Object.values(this.decorations).reduce( - (acc, curr) => { - if (curr.spec.hoveredCell !== undefined) { - acc["hoveredCell"] = curr.spec.hoveredCell; - } + const { hoveredTable: table, hoveredCell: cell } = Object.values(this.decorations).reduce((acc, curr) => { + if (curr.spec.hoveredCell !== undefined) { + acc["hoveredCell"] = curr.spec.hoveredCell; + } - if (curr.spec.hoveredTable !== undefined) { - acc["hoveredTable"] = curr.spec.hoveredTable; - } - return acc; - }, - {} as Record - ) as any; + if (curr.spec.hoveredTable !== undefined) { + acc["hoveredTable"] = curr.spec.hoveredTable; + } + return acc; + }, {} as Record) as any; if (table === undefined || cell === undefined) { return this.root.classList.add("controls--disabled"); @@ -453,14 +457,21 @@ export class TableView implements NodeView { const cellDom = this.editor.view.nodeDOM(cell.pos) as HTMLElement; + if (!this.table) { + return; + } + const tableRect = this.table.getBoundingClientRect(); const cellRect = cellDom.getBoundingClientRect(); - this.columnsControl.style.left = `${cellRect.left - tableRect.left - this.table.parentElement!.scrollLeft}px`; - this.columnsControl.style.width = `${cellRect.width}px`; - - this.rowsControl.style.top = `${cellRect.top - tableRect.top}px`; - this.rowsControl.style.height = `${cellRect.height}px`; + if (this.columnsControl) { + this.columnsControl.style.left = `${cellRect.left - tableRect.left - this.table.parentElement!.scrollLeft}px`; + this.columnsControl.style.width = `${cellRect.width}px`; + } + if (this.rowsControl) { + this.rowsControl.style.top = `${cellRect.top - tableRect.top}px`; + this.rowsControl.style.height = `${cellRect.height}px`; + } } selectColumn() { @@ -471,10 +482,7 @@ export class TableView implements NodeView { const headCellPos = this.map.map[colIndex + this.map.width * (this.map.height - 1)] + (this.getPos() + 1); const cellSelection = CellSelection.create(this.editor.view.state.doc, anchorCellPos, headCellPos); - this.editor.view.dispatch( - // @ts-ignore - this.editor.state.tr.setSelection(cellSelection) - ); + this.editor.view.dispatch(this.editor.state.tr.setSelection(cellSelection)); } selectRow() { @@ -485,9 +493,6 @@ export class TableView implements NodeView { const headCellPos = this.map.map[anchorCellIndex + (this.map.width - 1)] + (this.getPos() + 1); const cellSelection = CellSelection.create(this.editor.state.doc, anchorCellPos, headCellPos); - this.editor.view.dispatch( - // @ts-ignore - this.editor.view.state.tr.setSelection(cellSelection) - ); + this.editor.view.dispatch(this.editor.view.state.tr.setSelection(cellSelection)); } } From 9eb8f41008fec515f41eb3e5021b21bafcbcecd7 Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Fri, 5 Jan 2024 14:13:04 +0530 Subject: [PATCH 07/68] chore: UI/UX improvements (#3319) * chore: add proper message for cycle/ module having start & end date but isn't active yet. * fix: infinite loader after updating workspace settings. * fix: user profile icon dropdown doesn't closes automatically. * style: fix inconsistent padding in cycle empty state. * chore: remove multiple `empty state` in labels settings and improve add label logic. * style: fix inconsistent padding in project label, integration and estimates empty state. * style: fix integrations settings breadcrumb title. * style: add proper `disabled` styles for email field in profile settings. * style: fix cycle layout height. --- web/components/common/new-empty-state.tsx | 2 +- web/components/cycles/sidebar.tsx | 4 ++- web/components/estimates/estimates-list.tsx | 2 +- .../labels/project-setting-label-list.tsx | 33 +++++++------------ web/components/modules/sidebar.tsx | 4 ++- web/components/workspace/sidebar-dropdown.tsx | 16 ++++----- .../projects/[projectId]/cycles/index.tsx | 6 ++-- .../[projectId]/settings/integrations.tsx | 22 +++++++------ .../[workspaceSlug]/settings/integrations.tsx | 2 +- web/pages/profile/index.tsx | 7 ++-- web/services/workspace.service.ts | 3 +- web/store/workspace/index.ts | 2 +- 12 files changed, 48 insertions(+), 55 deletions(-) diff --git a/web/components/common/new-empty-state.tsx b/web/components/common/new-empty-state.tsx index dbe654e11..efbab8249 100644 --- a/web/components/common/new-empty-state.tsx +++ b/web/components/common/new-empty-state.tsx @@ -43,7 +43,7 @@ export const NewEmptyState: React.FC = ({ return (
-
+

{title}

{description &&

{description}

}
diff --git a/web/components/cycles/sidebar.tsx b/web/components/cycles/sidebar.tsx index f2f7792f6..81cefac50 100644 --- a/web/components/cycles/sidebar.tsx +++ b/web/components/cycles/sidebar.tsx @@ -539,7 +539,9 @@ export const CycleDetailsSidebar: React.FC = observer((props) => {
- Invalid date. Please enter valid date. + {cycleDetails?.start_date && cycleDetails?.end_date + ? "This cycle isn't active yet." + : "Invalid date. Please enter valid date."}
)} diff --git a/web/components/estimates/estimates-list.tsx b/web/components/estimates/estimates-list.tsx index 323cbe888..05b174461 100644 --- a/web/components/estimates/estimates-list.tsx +++ b/web/components/estimates/estimates-list.tsx @@ -102,7 +102,7 @@ export const EstimatesList: React.FC = observer(() => { ))} ) : ( -
+
{ Add label
-
+
{showLabelForm && ( -
+
{
)} {projectLabels ? ( - projectLabels.length === 0 ? ( + projectLabels.length === 0 && !showLabelForm ? ( { ) ) ) : ( - - - - - - - )} - - {/* empty state */} - {projectLabels && projectLabels.length === 0 && ( - newLabel(), - }} - /> + !showLabelForm && ( + + + + + + + ) )}
diff --git a/web/components/modules/sidebar.tsx b/web/components/modules/sidebar.tsx index 303dcbd2b..5b6456207 100644 --- a/web/components/modules/sidebar.tsx +++ b/web/components/modules/sidebar.tsx @@ -550,7 +550,9 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => {
- Invalid date. Please enter valid date. + {moduleDetails?.start_date && moduleDetails?.target_date + ? "This module isn't active yet." + : "Invalid date. Please enter valid date."}
)} diff --git a/web/components/workspace/sidebar-dropdown.tsx b/web/components/workspace/sidebar-dropdown.tsx index 614356c21..616562f27 100644 --- a/web/components/workspace/sidebar-dropdown.tsx +++ b/web/components/workspace/sidebar-dropdown.tsx @@ -278,14 +278,14 @@ export const WorkspaceSidebarDropdown = observer(() => {
{currentUser?.email} {profileLinks(workspaceSlug?.toString() ?? "", currentUser?.id ?? "").map((link, index) => ( - - + + {link.name} - - + + ))}
@@ -301,13 +301,13 @@ export const WorkspaceSidebarDropdown = observer(() => {
{isUserInstanceAdmin && (
- - + + Enter God Mode - - + +
)} diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/cycles/index.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/cycles/index.tsx index 13373b886..e35f0c341 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/cycles/index.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/cycles/index.tsx @@ -59,7 +59,7 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => { if (!workspaceSlug || !projectId) return null; return ( - <> +
{ handleClose={() => setCreateModal(false)} /> {totalCycles === 0 ? ( -
+
{ )} - +
); }); diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/settings/integrations.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/settings/integrations.tsx index aae6f4670..bc0163464 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/settings/integrations.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/settings/integrations.tsx @@ -54,16 +54,18 @@ const ProjectIntegrationsPage: NextPageWithLayout = () => { ))}
) : ( - router.push(`/${workspaceSlug}/settings/integrations`), - }} - disabled={!isAdmin} - /> +
+ router.push(`/${workspaceSlug}/settings/integrations`), + }} + disabled={!isAdmin} + /> +
) ) : ( diff --git a/web/pages/[workspaceSlug]/settings/integrations.tsx b/web/pages/[workspaceSlug]/settings/integrations.tsx index 378cc232d..70147c312 100644 --- a/web/pages/[workspaceSlug]/settings/integrations.tsx +++ b/web/pages/[workspaceSlug]/settings/integrations.tsx @@ -65,7 +65,7 @@ const WorkspaceIntegrationsPage: NextPageWithLayout = observer(() => { WorkspaceIntegrationsPage.getLayout = function getLayout(page: ReactElement) { return ( - }> + }> {page} ); diff --git a/web/pages/profile/index.tsx b/web/pages/profile/index.tsx index 5ac1c02d1..88333388a 100644 --- a/web/pages/profile/index.tsx +++ b/web/pages/profile/index.tsx @@ -218,7 +218,7 @@ const ProfileSettingsPage: NextPageWithLayout = observer(() => {

- First name * + First name*

{ rules={{ required: "Email is required.", }} - render={({ field: { value, onChange, ref } }) => ( + render={({ field: { value, ref } }) => ( )} diff --git a/web/services/workspace.service.ts b/web/services/workspace.service.ts index 21b9f7c49..2515853f5 100644 --- a/web/services/workspace.service.ts +++ b/web/services/workspace.service.ts @@ -14,10 +14,9 @@ import { IWorkspaceBulkInviteFormData, IWorkspaceViewProps, IUserProjectsRole, - TIssueMap, TIssue, + IWorkspaceView, } from "@plane/types"; -import { IWorkspaceView } from "@plane/types"; export class WorkspaceService extends APIService { constructor() { diff --git a/web/store/workspace/index.ts b/web/store/workspace/index.ts index b05e6da34..4020aaef7 100644 --- a/web/store/workspace/index.ts +++ b/web/store/workspace/index.ts @@ -135,7 +135,7 @@ export class WorkspaceRootStore implements IWorkspaceRootStore { updateWorkspace = async (workspaceSlug: string, data: Partial) => await this.workspaceService.updateWorkspace(workspaceSlug, data).then((response) => { runInAction(() => { - set(this.workspaces, response.id, data); + set(this.workspaces, response.id, response); }); return response; }); From 266f14d5506e32eb53940c77f69bd62930248459 Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Fri, 5 Jan 2024 14:38:09 +0530 Subject: [PATCH 08/68] fix: project identifier cursor behaviour in create project modal. (#3320) --- .../project/create-project-modal.tsx | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/web/components/project/create-project-modal.tsx b/web/components/project/create-project-modal.tsx index 2b657c810..61e5086bb 100644 --- a/web/components/project/create-project-modal.tsx +++ b/web/components/project/create-project-modal.tsx @@ -126,6 +126,8 @@ export const CreateProjectModal: FC = observer((props) => { else payload.emoji = formData.emoji_and_icon; payload.project_lead = formData.project_lead_member; + // Upper case identifier + payload.identifier = payload.identifier.toUpperCase(); return createProject(workspaceSlug.toString(), payload) .then((res) => { @@ -176,14 +178,7 @@ export const CreateProjectModal: FC = observer((props) => { return; } if (e.target.value === "") setValue("identifier", ""); - else - setValue( - "identifier", - e.target.value - .replace(/[^ÇŞĞIİÖÜA-Za-z0-9]/g, "") - .toUpperCase() - .substring(0, 5) - ); + else setValue("identifier", e.target.value.replace(/[^ÇŞĞIİÖÜA-Za-z0-9]/g, "").substring(0, 5)); onChange(e); }; @@ -191,7 +186,7 @@ export const CreateProjectModal: FC = observer((props) => { const { value } = e.target; const alphanumericValue = value.replace(/[^ÇŞĞIİÖÜA-Za-z0-9]/g, ""); setIsChangeInIdentifierRequired(false); - onChange(alphanumericValue.toUpperCase()); + onChange(alphanumericValue); }; return ( @@ -301,7 +296,8 @@ export const CreateProjectModal: FC = observer((props) => { required: "Identifier is required", // allow only alphanumeric & non-latin characters validate: (value) => - /^[ÇŞĞIİÖÜA-Z0-9]+$/.test(value.toUpperCase()) || "Identifier must be in uppercase.", + /^[ÇŞĞIİÖÜA-Z0-9]+$/.test(value.toUpperCase()) || + "Only Alphanumeric & Non-latin characters are allowed.", minLength: { value: 1, message: "Identifier must at least be of 1 character", @@ -321,7 +317,7 @@ export const CreateProjectModal: FC = observer((props) => { onChange={handleIdentifierChange(onChange)} hasError={Boolean(errors.identifier)} placeholder="Identifier" - className="w-full text-xs focus:border-blue-400" + className="w-full text-xs focus:border-blue-400 uppercase" /> )} /> From efd3ebf0675f059e1ace6f23d0e3694fdb4135b1 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Fri, 5 Jan 2024 23:37:13 +0530 Subject: [PATCH 09/68] chore: bug fixes and improvement (#3303) * refactor: updated preloaded function for the list view quick add * fix: resolved bug in the assignee dropdown * chore: issue sidebar link improvement * fix: resolved subscription store bug * chore: updated preloaded function for the kanban layout quick add * chore: resolved issues in the list filters and component * chore: filter store updated * fix: issue serializer changed * chore: quick add preload function updated * fix: build error * fix: serializer changed * fix: minor request change * chore: resolved build issues and updated the prepopulated data in the quick add issue. * fix: build fix and code refactor * fix: spreadsheet layout quick add fix * fix: issue peek overview link section updated * fix: cycle status bug fix * fix: serializer changes * fix: assignee and labels listing * chore: issue modal parent_id default value updated * fix: cycle and module issue serializer change * fix: cycle list serializer changed * chore: prepopulated validation in both list and kanban for quick add and group header add issues * chore: group header validation added * fix: issue response payload change * dev: make cycle and module issue create response simillar * chore: custom control link component added * dev: make issue create and update response simillar to list and retrieve * fix: build error * chore: control link component improvement * chore: globalise issue peek overview * chore: control link component improvement * chore: made changes and optimised the issue peek overview root * build-error: resolved build erros for issueId dependancy from issue detail store * chore: peek overview link fix * dev: update state nullable rule --------- Co-authored-by: gurusainath Co-authored-by: NarayanBavisetti Co-authored-by: pablohashescobar --- apiserver/plane/app/serializers/issue.py | 35 +- apiserver/plane/app/views/base.py | 4 +- apiserver/plane/app/views/cycle.py | 20 +- apiserver/plane/app/views/issue.py | 54 +- apiserver/plane/app/views/module.py | 18 +- apiserver/plane/app/views/view.py | 18 +- packages/types/src/inbox.d.ts | 17 +- packages/types/src/issues.d.ts | 115 +-- packages/types/src/issues/issue.d.ts | 35 +- packages/ui/src/control-link/control-link.tsx | 27 + packages/ui/src/control-link/index.ts | 1 + packages/ui/src/index.ts | 1 + web/components/cycles/cycles-list-item.tsx | 3 +- web/components/issues/attachment/root.tsx | 12 +- .../calendar/base-calendar-root.tsx | 14 +- .../issue-layouts/calendar/day-tile.tsx | 2 +- .../calendar/quick-add-issue-form.tsx | 4 +- .../issue-layouts/gantt/base-gantt-root.tsx | 26 +- .../gantt/quick-add-issue-form.tsx | 7 +- .../issue-layouts/kanban/base-kanban-root.tsx | 4 +- .../issues/issue-layouts/kanban/default.tsx | 4 +- .../issue-layouts/kanban/kanban-group.tsx | 33 +- .../kanban/quick-add-issue-form.tsx | 35 +- .../issues/issue-layouts/list/block.tsx | 51 +- .../issues/issue-layouts/list/default.tsx | 34 +- .../list/quick-add-issue-form.tsx | 18 +- .../properties/all-properties.tsx | 4 +- .../roots/project-layout-root.tsx | 60 +- .../roots/project-view-layout-root.tsx | 13 +- .../spreadsheet/columns/assignee-column.tsx | 2 +- .../spreadsheet/quick-add-issue-form.tsx | 7 +- .../spreadsheet/spreadsheet-view.tsx | 19 +- web/components/issues/issue-layouts/utils.tsx | 14 +- .../issues/issue-links/link-detail.tsx | 23 +- web/components/issues/issue-links/root.tsx | 16 +- .../issues/peek-overview/properties.tsx | 51 +- web/components/issues/peek-overview/root.tsx | 219 +++-- web/components/issues/peek-overview/view.tsx | 61 +- .../issues/sidebar-select/cycle.tsx | 15 +- .../issues/sidebar-select/module.tsx | 14 +- .../issues/sidebar-select/parent.tsx | 10 +- web/components/issues/sub-issues/issue.tsx | 25 +- web/helpers/issue.helper.ts | 79 +- web/lib/app-provider.tsx | 5 +- .../archived-issues/[archivedIssueId].tsx | 4 +- .../projects/[projectId]/issues/[issueId].tsx | 32 +- .../projects/[projectId]/pages/[pageId].tsx | 20 +- web/store/issue/archived/filter.store.ts | 13 +- web/store/issue/cycle/filter.store.ts | 13 +- web/store/issue/draft/filter.store.ts | 13 +- web/store/issue/helpers/issue-helper.store.ts | 11 +- .../issue/issue-details/activity.store.ts | 2 +- .../issue/issue-details/attachment.store.ts | 2 +- web/store/issue/issue-details/link.store.ts | 2 +- .../issue/issue-details/reaction.store.ts | 2 +- .../issue/issue-details/relation.store.ts | 2 +- web/store/issue/issue-details/root.store.ts | 24 +- .../issue/issue-details/subscription.store.ts | 2 +- web/store/issue/issue_detail.store.ts | 757 ------------------ web/store/issue/issue_kanban_view.store.ts | 2 +- web/store/issue/module/filter.store.ts | 13 +- web/store/issue/profile/filter.store.ts | 13 +- web/store/issue/project-views/filter.store.ts | 13 +- web/store/issue/project/filter.store.ts | 13 +- web/store/issue/workspace/filter.store.ts | 13 +- 65 files changed, 630 insertions(+), 1565 deletions(-) create mode 100644 packages/ui/src/control-link/control-link.tsx create mode 100644 packages/ui/src/control-link/index.ts delete mode 100644 web/store/issue/issue_detail.store.ts diff --git a/apiserver/plane/app/serializers/issue.py b/apiserver/plane/app/serializers/issue.py index 6d39f1760..f9b5b579f 100644 --- a/apiserver/plane/app/serializers/issue.py +++ b/apiserver/plane/app/serializers/issue.py @@ -30,6 +30,8 @@ from plane.db.models import ( CommentReaction, IssueVote, IssueRelation, + State, + Project, ) @@ -69,19 +71,16 @@ class IssueProjectLiteSerializer(BaseSerializer): ##TODO: Find a better way to write this serializer ## Find a better approach to save manytomany? class IssueCreateSerializer(BaseSerializer): - state_detail = StateSerializer(read_only=True, source="state") - created_by_detail = UserLiteSerializer(read_only=True, source="created_by") - project_detail = ProjectLiteSerializer(read_only=True, source="project") - workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") - - assignees = serializers.ListField( - child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), + # ids + state_id = serializers.PrimaryKeyRelatedField(source="state", queryset=State.objects.all(), required=False, allow_null=True) + parent_id = serializers.PrimaryKeyRelatedField(source='parent', queryset=Issue.objects.all(), required=False, allow_null=True) + label_ids = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()), write_only=True, required=False, ) - - labels = serializers.ListField( - child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()), + assignee_ids = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), write_only=True, required=False, ) @@ -100,8 +99,10 @@ class IssueCreateSerializer(BaseSerializer): def to_representation(self, instance): data = super().to_representation(instance) - data['assignees'] = [str(assignee.id) for assignee in instance.assignees.all()] - data['labels'] = [str(label.id) for label in instance.labels.all()] + assignee_ids = self.initial_data.get('assignee_ids') + data['assignee_ids'] = assignee_ids if assignee_ids else [] + label_ids = self.initial_data.get('label_ids') + data['label_ids'] = label_ids if label_ids else [] return data def validate(self, data): @@ -114,8 +115,8 @@ class IssueCreateSerializer(BaseSerializer): return data def create(self, validated_data): - assignees = validated_data.pop("assignees", None) - labels = validated_data.pop("labels", None) + assignees = validated_data.pop("assignee_ids", None) + labels = validated_data.pop("label_ids", None) project_id = self.context["project_id"] workspace_id = self.context["workspace_id"] @@ -173,8 +174,8 @@ class IssueCreateSerializer(BaseSerializer): return issue def update(self, instance, validated_data): - assignees = validated_data.pop("assignees", None) - labels = validated_data.pop("labels", None) + assignees = validated_data.pop("assignee_ids", None) + labels = validated_data.pop("labels_ids", None) # Related models project_id = instance.project_id @@ -544,7 +545,7 @@ class IssueSerializer(DynamicBaseSerializer): attachment_count = serializers.IntegerField(read_only=True) link_count = serializers.IntegerField(read_only=True) - # is + # is_subscribed is_subscribed = serializers.BooleanField(read_only=True) class Meta: diff --git a/apiserver/plane/app/views/base.py b/apiserver/plane/app/views/base.py index 5bd79cb96..3fae82e92 100644 --- a/apiserver/plane/app/views/base.py +++ b/apiserver/plane/app/views/base.py @@ -99,6 +99,7 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): response = super().handle_exception(exc) return response except Exception as e: + print(e) if settings.DEBUG else print("Server Error") if isinstance(e, IntegrityError): return Response( {"error": "The payload is not valid"}, @@ -124,8 +125,7 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): {"error": f"key {e} does not exist"}, status=status.HTTP_400_BAD_REQUEST, ) - - print(e) if settings.DEBUG else print("Server Error") + capture_exception(e) return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/apiserver/plane/app/views/cycle.py b/apiserver/plane/app/views/cycle.py index 73741b983..15a6c24b0 100644 --- a/apiserver/plane/app/views/cycle.py +++ b/apiserver/plane/app/views/cycle.py @@ -31,6 +31,7 @@ from plane.app.serializers import ( CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer, + IssueSerializer, IssueStateSerializer, CycleWriteSerializer, CycleUserPropertiesSerializer, @@ -46,9 +47,9 @@ from plane.db.models import ( IssueAttachment, Label, CycleUserProperties, + IssueSubscriber, ) from plane.bgtasks.issue_activites_task import issue_activity -from plane.utils.grouper import group_results from plane.utils.issue_filters import issue_filters from plane.utils.analytics_plot import burndown_plot @@ -322,6 +323,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet): project_id=project_id, owned_by=request.user, ) + cycle = self.get_queryset().filter(pk=serializer.data["id"]).first() + serializer = CycleSerializer(cycle) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) else: @@ -548,6 +551,8 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): .prefetch_related("labels") .order_by(order_by) .filter(**filters) + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate(module_id=F("issue_module__module_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -560,8 +565,15 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): .annotate(count=Func(F("id"), function="Count")) .values("count") ) + .annotate( + is_subscribed=Exists( + IssueSubscriber.objects.filter( + subscriber=self.request.user, issue_id=OuterRef("id") + ) + ) + ) ) - serializer = IssueStateSerializer( + serializer = IssueSerializer( issues, many=True, fields=fields if fields else None ) return Response(serializer.data, status=status.HTTP_200_OK) @@ -652,8 +664,10 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): ) # Return all Cycle Issues + issues = self.get_queryset().values_list("issue_id", flat=True) + return Response( - CycleIssueSerializer(self.get_queryset(), many=True).data, + IssueSerializer(Issue.objects.filter(pk__in=issues), many=True).data, status=status.HTTP_200_OK, ) diff --git a/apiserver/plane/app/views/issue.py b/apiserver/plane/app/views/issue.py index 6c88ef090..4a91c9fe1 100644 --- a/apiserver/plane/app/views/issue.py +++ b/apiserver/plane/app/views/issue.py @@ -34,11 +34,11 @@ from rest_framework.parsers import MultiPartParser, FormParser # Module imports from . import BaseViewSet, BaseAPIView, WebhookMixin from plane.app.serializers import ( - IssueCreateSerializer, IssueActivitySerializer, IssueCommentSerializer, IssuePropertySerializer, IssueSerializer, + IssueCreateSerializer, LabelSerializer, IssueFlatSerializer, IssueLinkSerializer, @@ -110,12 +110,7 @@ class IssueViewSet(WebhookMixin, BaseViewSet): def get_queryset(self): return ( - Issue.issue_objects.annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) + Issue.issue_objects .filter(project_id=self.kwargs.get("project_id")) .filter(workspace__slug=self.kwargs.get("slug")) .select_related("project") @@ -143,13 +138,11 @@ class IssueViewSet(WebhookMixin, BaseViewSet): .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") - ) - .annotate( - is_subscribed=Exists( - IssueSubscriber.objects.filter( - subscriber=self.request.user, issue_id=OuterRef("id") - ) - ) + ).annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") ) ).distinct() @@ -251,16 +244,13 @@ class IssueViewSet(WebhookMixin, BaseViewSet): current_instance=None, epoch=int(timezone.now().timestamp()), ) + issue = self.get_queryset().filter(pk=serializer.data["id"]).first() + serializer = IssueSerializer(issue) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def retrieve(self, request, slug, project_id, pk=None): - issue = Issue.issue_objects.annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ).get(workspace__slug=slug, project_id=project_id, pk=pk) + issue = self.get_queryset().filter(pk=pk).first() return Response( IssueSerializer(issue, fields=self.fields, expand=self.expand).data, status=status.HTTP_200_OK, @@ -284,7 +274,8 @@ class IssueViewSet(WebhookMixin, BaseViewSet): current_instance=current_instance, epoch=int(timezone.now().timestamp()), ) - return Response(serializer.data, status=status.HTTP_200_OK) + issue = self.get_queryset().filter(pk=pk).first() + return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def destroy(self, request, slug, project_id, pk=None): @@ -719,13 +710,6 @@ class SubIssuesEndpoint(BaseAPIView): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .annotate( - is_subscribed=Exists( - IssueSubscriber.objects.filter( - subscriber=self.request.user, issue_id=OuterRef("id") - ) - ) - ) .prefetch_related( Prefetch( "issue_reactions", @@ -1080,7 +1064,7 @@ class IssueArchiveViewSet(BaseViewSet): else issue_queryset.filter(parent__isnull=True) ) - issues = IssueLiteSerializer( + issues = IssueSerializer( issue_queryset, many=True, fields=fields if fields else None ).data return Response(issues, status=status.HTTP_200_OK) @@ -1163,16 +1147,6 @@ class IssueSubscriberViewSet(BaseViewSet): project_id=project_id, is_active=True, ) - .annotate( - is_subscribed=Exists( - IssueSubscriber.objects.filter( - workspace__slug=slug, - project_id=project_id, - issue_id=issue_id, - subscriber=OuterRef("member"), - ) - ) - ) .select_related("member") ) serializer = ProjectMemberLiteSerializer(members, many=True) @@ -1613,7 +1587,7 @@ class IssueDraftViewSet(BaseViewSet): else: issue_queryset = issue_queryset.order_by(order_by_param) - issues = IssueLiteSerializer( + issues = IssueSerializer( issue_queryset, many=True, fields=fields if fields else None ).data return Response(issues, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/module.py b/apiserver/plane/app/views/module.py index 6baf23121..576b763fd 100644 --- a/apiserver/plane/app/views/module.py +++ b/apiserver/plane/app/views/module.py @@ -20,7 +20,7 @@ from plane.app.serializers import ( ModuleIssueSerializer, ModuleLinkSerializer, ModuleFavoriteSerializer, - IssueStateSerializer, + IssueSerializer, ModuleUserPropertiesSerializer, ) from plane.app.permissions import ProjectEntityPermission, ProjectLitePermission @@ -33,6 +33,7 @@ from plane.db.models import ( ModuleFavorite, IssueLink, IssueAttachment, + IssueSubscriber, ModuleUserProperties, ) from plane.bgtasks.issue_activites_task import issue_activity @@ -353,6 +354,8 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet): .prefetch_related("labels") .order_by(order_by) .filter(**filters) + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate(module_id=F("issue_module__module_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -365,8 +368,15 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet): .annotate(count=Func(F("id"), function="Count")) .values("count") ) + .annotate( + is_subscribed=Exists( + IssueSubscriber.objects.filter( + subscriber=self.request.user, issue_id=OuterRef("id") + ) + ) + ) ) - serializer = IssueStateSerializer( + serializer = IssueSerializer( issues, many=True, fields=fields if fields else None ) return Response(serializer.data, status=status.HTTP_200_OK) @@ -447,8 +457,10 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet): epoch=int(timezone.now().timestamp()), ) + issues = self.get_queryset().values_list("issue_id", flat=True) + return Response( - ModuleIssueSerializer(self.get_queryset(), many=True).data, + IssueSerializer(Issue.objects.filter(pk__in=issues), many=True).data, status=status.HTTP_200_OK, ) diff --git a/apiserver/plane/app/views/view.py b/apiserver/plane/app/views/view.py index a2f00a819..0c9be5ae6 100644 --- a/apiserver/plane/app/views/view.py +++ b/apiserver/plane/app/views/view.py @@ -24,7 +24,7 @@ from . import BaseViewSet, BaseAPIView from plane.app.serializers import ( GlobalViewSerializer, IssueViewSerializer, - IssueLiteSerializer, + IssueSerializer, IssueViewFavoriteSerializer, ) from plane.app.permissions import ( @@ -42,6 +42,7 @@ from plane.db.models import ( IssueReaction, IssueLink, IssueAttachment, + IssueSubscriber, ) from plane.utils.issue_filters import issue_filters from plane.utils.grouper import group_results @@ -127,6 +128,19 @@ class GlobalViewIssuesViewSet(BaseViewSet): .annotate(count=Func(F("id"), function="Count")) .values("count") ) + .annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + is_subscribed=Exists( + IssueSubscriber.objects.filter( + subscriber=self.request.user, issue_id=OuterRef("id") + ) + ) + ) ) # Priority Ordering @@ -185,7 +199,7 @@ class GlobalViewIssuesViewSet(BaseViewSet): else: issue_queryset = issue_queryset.order_by(order_by_param) - serializer = IssueLiteSerializer( + serializer = IssueSerializer( issue_queryset, many=True, fields=fields if fields else None ) return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/packages/types/src/inbox.d.ts b/packages/types/src/inbox.d.ts index 1b474c3ab..4d666ae83 100644 --- a/packages/types/src/inbox.d.ts +++ b/packages/types/src/inbox.d.ts @@ -1,7 +1,13 @@ -import { TIssue } from "./issues"; +import { TIssue } from "./issues/base"; import type { IProjectLite } from "./projects"; -export interface IInboxIssue extends TIssue { +export type TInboxIssueExtended = { + completed_at: string | null; + start_date: string | null; + target_date: string | null; +}; + +export interface IInboxIssue extends TIssue, TInboxIssueExtended { issue_inbox: { duplicate_to: string | null; id: string; @@ -48,7 +54,12 @@ interface StatusDuplicate { duplicate_to: string; } -export type TInboxStatus = StatusReject | StatusSnoozed | StatusAccepted | StatusDuplicate | StatePending; +export type TInboxStatus = + | StatusReject + | StatusSnoozed + | StatusAccepted + | StatusDuplicate + | StatePending; export interface IInboxFilterOptions { priority?: string[] | null; diff --git a/packages/types/src/issues.d.ts b/packages/types/src/issues.d.ts index c0ad7bc7f..c9376b34b 100644 --- a/packages/types/src/issues.d.ts +++ b/packages/types/src/issues.d.ts @@ -1,8 +1,6 @@ import { ReactElement } from "react"; import { KeyedMutator } from "swr"; import type { - IState, - IUser, ICycle, IModule, IUserLite, @@ -12,6 +10,7 @@ import type { Properties, IIssueDisplayFilterOptions, IIssueReaction, + TIssue, } from "@plane/types"; export interface IIssueCycle { @@ -78,59 +77,6 @@ export interface IssueRelation { relation: "blocking" | null; } -export interface IIssue { - archived_at: string; - assignees: string[]; - assignee_details: IUser[]; - attachment_count: number; - attachments: any[]; - issue_relations: IssueRelation[]; - issue_reactions: IIssueReaction[]; - related_issues: IssueRelation[]; - bridge_id?: string | null; - completed_at: Date; - created_at: string; - created_by: string; - cycle: string | null; - cycle_id: string | null; - cycle_detail: ICycle | null; - description: any; - description_html: any; - description_stripped: any; - estimate_point: number | null; - id: string; - // tempId is used for optimistic updates. It is not a part of the API response. - tempId?: string; - issue_cycle: IIssueCycle | null; - issue_link: ILinkDetails[]; - issue_module: IIssueModule | null; - labels: string[]; - label_details: any[]; - is_draft: boolean; - links_list: IIssueLink[]; - link_count: number; - module: string | null; - module_id: string | null; - name: string; - parent: string | null; - parent_detail: IIssueParent | null; - priority: TIssuePriorities; - project: string; - project_detail: IProjectLite; - sequence_id: number; - sort_order: number; - sprints: string | null; - start_date: string | null; - state: string; - state_detail: IState; - sub_issues_count: number; - target_date: string | null; - updated_at: string; - updated_by: string; - workspace: string; - workspace_detail: IWorkspaceLite; -} - export interface ISubIssuesState { backlog: number; unstarted: number; @@ -283,62 +229,3 @@ export interface IGroupByColumn { export interface IIssueMap { [key: string]: TIssue; } - -// new issue structure types -export type TIssue = { - id: string; - name: string; - state_id: string; - description_html: string; - sort_order: number; - completed_at: string | null; - estimate_point: number | null; - priority: TIssuePriorities; - start_date: string | null; - target_date: string | null; - sequence_id: number; - project_id: string; - parent_id: string | null; - cycle_id: string | null; - module_id: string | null; - label_ids: string[]; - assignee_ids: string[]; - sub_issues_count: number; - created_at: string; - updated_at: string; - created_by: string; - updated_by: string; - attachment_count: number; - link_count: number; - is_subscribed: boolean; - archived_at: boolean; - is_draft: boolean; - // tempId is used for optimistic updates. It is not a part of the API response. - tempId?: string; - // issue details - related_issues: any; - issue_reactions: any; - issue_relations: any; - issue_cycle: any; - issue_module: any; - parent_detail: any; - issue_link: any; -}; - -export type TIssueMap = { - [issue_id: string]: TIssue; -}; - -export type TLoader = "init-loader" | "mutation" | undefined; - -export type TGroupedIssues = { - [group_id: string]: string[]; -}; - -export type TSubGroupedIssues = { - [sub_grouped_id: string]: { - [group_id: string]: string[]; - }; -}; - -export type TUnGroupedIssues = string[]; diff --git a/packages/types/src/issues/issue.d.ts b/packages/types/src/issues/issue.d.ts index e9ec14528..9734f85c2 100644 --- a/packages/types/src/issues/issue.d.ts +++ b/packages/types/src/issues/issue.d.ts @@ -1,32 +1,41 @@ +import { TIssuePriorities } from "../issues"; + // new issue structure types export type TIssue = { id: string; + sequence_id: number; name: string; - state_id: string; description_html: string; sort_order: number; - completed_at: string | null; - estimate_point: number | null; + + state_id: string; priority: TIssuePriorities; - start_date: string; - target_date: string; - sequence_id: number; + label_ids: string[]; + assignee_ids: string[]; + estimate_point: number | null; + + sub_issues_count: number; + attachment_count: number; + link_count: number; + project_id: string; parent_id: string | null; cycle_id: string | null; module_id: string | null; - label_ids: string[]; - assignee_ids: string[]; - sub_issues_count: number; + created_at: string; updated_at: string; + start_date: string | null; + target_date: string | null; + completed_at: string | null; + archived_at: string | null; + created_by: string; updated_by: string; - attachment_count: number; - link_count: number; - is_subscribed: boolean; - archived_at: boolean; + is_draft: boolean; + is_subscribed: boolean; + // tempId is used for optimistic updates. It is not a part of the API response. tempId?: string; }; diff --git a/packages/ui/src/control-link/control-link.tsx b/packages/ui/src/control-link/control-link.tsx new file mode 100644 index 000000000..dbdbaf095 --- /dev/null +++ b/packages/ui/src/control-link/control-link.tsx @@ -0,0 +1,27 @@ +import * as React from "react"; + +export type TControlLink = React.AnchorHTMLAttributes & { + href: string; + onClick: () => void; + children: React.ReactNode; + target?: string; +}; + +export const ControlLink: React.FC = (props) => { + const { href, onClick, children, target = "_self", ...rest } = props; + const LEFT_CLICK_EVENT_CODE = 0; + + const _onClick = (event: React.MouseEvent) => { + const clickCondition = (event.metaKey || event.ctrlKey) && event.button === LEFT_CLICK_EVENT_CODE; + if (!clickCondition) { + event.preventDefault(); + onClick(); + } + }; + + return ( + + {children} + + ); +}; diff --git a/packages/ui/src/control-link/index.ts b/packages/ui/src/control-link/index.ts new file mode 100644 index 000000000..86cdfc28e --- /dev/null +++ b/packages/ui/src/control-link/index.ts @@ -0,0 +1 @@ +export * from "./control-link"; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 4b1bb2fcf..b90b6993a 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -9,3 +9,4 @@ export * from "./progress"; export * from "./spinners"; export * from "./tooltip"; export * from "./loader"; +export * from "./control-link"; diff --git a/web/components/cycles/cycles-list-item.tsx b/web/components/cycles/cycles-list-item.tsx index bbb30bc7a..d25364bcd 100644 --- a/web/components/cycles/cycles-list-item.tsx +++ b/web/components/cycles/cycles-list-item.tsx @@ -116,7 +116,8 @@ export const CyclesListItem: FC = (props) => { if (!cycleDetails) return null; // computed - const cycleStatus = cycleDetails.status.toLocaleLowerCase() as TCycleGroups; + // TODO: change this logic once backend fix the response + const cycleStatus = cycleDetails.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft"; const isCompleted = cycleStatus === "completed"; const endDate = new Date(cycleDetails.end_date ?? ""); const startDate = new Date(cycleDetails.start_date ?? ""); diff --git a/web/components/issues/attachment/root.tsx b/web/components/issues/attachment/root.tsx index 9d8a31b05..ac92bb5b6 100644 --- a/web/components/issues/attachment/root.tsx +++ b/web/components/issues/attachment/root.tsx @@ -22,15 +22,15 @@ export const IssueAttachmentRoot: FC = (props) => { const { router: { workspaceSlug, projectId }, } = useApplication(); - const { issueId, createAttachment, removeAttachment } = useIssueDetail(); + const { peekIssue, createAttachment, removeAttachment } = useIssueDetail(); const { setToastAlert } = useToast(); const handleAttachmentOperations: TAttachmentOperations = useMemo( () => ({ create: async (data: FormData) => { try { - if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); - await createAttachment(workspaceSlug, projectId, issueId, data); + if (!workspaceSlug || !projectId || !peekIssue?.issueId) throw new Error("Missing required fields"); + await createAttachment(workspaceSlug, projectId, peekIssue?.issueId, data); setToastAlert({ message: "The attachment has been successfully uploaded", type: "success", @@ -46,8 +46,8 @@ export const IssueAttachmentRoot: FC = (props) => { }, remove: async (attachmentId: string) => { try { - if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); - await removeAttachment(workspaceSlug, projectId, issueId, attachmentId); + if (!workspaceSlug || !projectId || !peekIssue?.issueId) throw new Error("Missing required fields"); + await removeAttachment(workspaceSlug, projectId, peekIssue?.issueId, attachmentId); setToastAlert({ message: "The attachment has been successfully removed", type: "success", @@ -62,7 +62,7 @@ export const IssueAttachmentRoot: FC = (props) => { } }, }), - [workspaceSlug, projectId, issueId, createAttachment, removeAttachment, setToastAlert] + [workspaceSlug, projectId, peekIssue, createAttachment, removeAttachment, setToastAlert] ); return ( diff --git a/web/components/issues/issue-layouts/calendar/base-calendar-root.tsx b/web/components/issues/issue-layouts/calendar/base-calendar-root.tsx index 2d7f5005a..3b3ef887e 100644 --- a/web/components/issues/issue-layouts/calendar/base-calendar-root.tsx +++ b/web/components/issues/issue-layouts/calendar/base-calendar-root.tsx @@ -3,7 +3,7 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { DragDropContext, DropResult } from "@hello-pangea/dnd"; // components -import { CalendarChart, IssuePeekOverview } from "components/issues"; +import { CalendarChart } from "components/issues"; // hooks import useToast from "hooks/use-toast"; // types @@ -34,7 +34,7 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => { // router const router = useRouter(); - const { workspaceSlug, projectId, peekIssueId, peekProjectId } = router.query; + const { workspaceSlug, projectId } = router.query; // hooks const { setToastAlert } = useToast(); @@ -113,16 +113,6 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => { />
- {workspaceSlug && peekIssueId && peekProjectId && ( - - await handleIssues(issueToUpdate.target_date ?? "", issueToUpdate as TIssue, EIssueActions.UPDATE) - } - /> - )} ); }); diff --git a/web/components/issues/issue-layouts/calendar/day-tile.tsx b/web/components/issues/issue-layouts/calendar/day-tile.tsx index 6bc3052a9..5b4885bf3 100644 --- a/web/components/issues/issue-layouts/calendar/day-tile.tsx +++ b/web/components/issues/issue-layouts/calendar/day-tile.tsx @@ -97,7 +97,7 @@ export const CalendarDayTile: React.FC = observer((props) => { formKey="target_date" groupId={formattedDatePayload} prePopulatedData={{ - target_date: renderFormattedPayloadDate(date.date), + target_date: renderFormattedPayloadDate(date.date) ?? undefined, }} quickAddCallback={quickAddCallback} viewId={viewId} diff --git a/web/components/issues/issue-layouts/calendar/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/calendar/quick-add-issue-form.tsx index 7a3c01417..0f81d79a6 100644 --- a/web/components/issues/issue-layouts/calendar/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/calendar/quick-add-issue-form.tsx @@ -110,11 +110,11 @@ export const CalendarQuickAddIssueForm: React.FC = observer((props) => { }, [errors, setToastAlert]); const onSubmitHandler = async (formData: TIssue) => { - if (isSubmitting || !groupId || !workspaceDetail || !projectDetail || !workspaceSlug || !projectId) return; + if (isSubmitting || !workspaceSlug || !projectId) return; reset({ ...defaultValues }); - const payload = createIssuePayload(workspaceDetail, projectDetail, { + const payload = createIssuePayload(projectId.toString(), { ...(prePopulatedData ?? {}), ...formData, }); diff --git a/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx b/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx index 13b324282..73802886e 100644 --- a/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx +++ b/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx @@ -1,10 +1,10 @@ -import React, { useCallback } from "react"; +import React from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; // hooks import { useIssues, useUser } from "hooks/store"; // components -import { IssueGanttBlock, IssuePeekOverview } from "components/issues"; +import { IssueGanttBlock } from "components/issues"; import { GanttChartRoot, IBlockUpdateData, @@ -32,10 +32,10 @@ interface IBaseGanttRoot { } export const BaseGanttRoot: React.FC = observer((props: IBaseGanttRoot) => { - const { issueFiltersStore, issueStore, viewId, issueActions } = props; + const { issueFiltersStore, issueStore, viewId } = props; // router const router = useRouter(); - const { workspaceSlug, peekIssueId, peekProjectId } = router.query; + const { workspaceSlug } = router.query; // store hooks const { membership: { currentProjectRole }, @@ -57,14 +57,6 @@ export const BaseGanttRoot: React.FC = observer((props: IBaseGan await issueStore.updateIssue(workspaceSlug.toString(), issue.project_id, issue.id, payload, viewId); }; - const handleIssues = useCallback( - async (issue: TIssue, action: EIssueActions) => { - if (issueActions[action]) { - await issueActions[action]!(issue); - } - }, - [issueActions] - ); const isAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; return ( @@ -92,16 +84,6 @@ export const BaseGanttRoot: React.FC = observer((props: IBaseGan enableReorder={appliedDisplayFilters?.order_by === "sort_order" && isAllowed} />
- {workspaceSlug && peekIssueId && peekProjectId && ( - { - await handleIssues(issueToUpdate as TIssue, action); - }} - /> - )} ); }); diff --git a/web/components/issues/issue-layouts/gantt/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/gantt/quick-add-issue-form.tsx index 621a12d76..a370440f9 100644 --- a/web/components/issues/issue-layouts/gantt/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/gantt/quick-add-issue-form.tsx @@ -104,14 +104,11 @@ export const GanttInlineCreateIssueForm: React.FC = observer((props) => { const onSubmitHandler = async (formData: TIssue) => { if (isSubmitting || !workspaceSlug || !projectId) return; - // resetting the form so that user can add another issue quickly - reset({ ...defaultValues, ...(prePopulatedData ?? {}) }); + reset({ ...defaultValues }); - const payload = createIssuePayload(workspaceDetail!, currentProjectDetails!, { + const payload = createIssuePayload(projectId.toString(), { ...(prePopulatedData ?? {}), ...formData, - start_date: renderFormattedPayloadDate(new Date()), - target_date: renderFormattedPayloadDate(new Date(new Date().getTime() + 24 * 60 * 60 * 1000)), }); try { diff --git a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx index dcf6e7cc3..c262af2ca 100644 --- a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx +++ b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx @@ -276,14 +276,14 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas
- {workspaceSlug && peekIssueId && peekProjectId && ( + {/* {workspaceSlug && peekIssueId && peekProjectId && ( await handleIssues(issueToUpdate as TIssue, EIssueActions.UPDATE)} /> - )} + )} */} ); }); diff --git a/web/components/issues/issue-layouts/kanban/default.tsx b/web/components/issues/issue-layouts/kanban/default.tsx index 4e81907be..3820f3bac 100644 --- a/web/components/issues/issue-layouts/kanban/default.tsx +++ b/web/components/issues/issue-layouts/kanban/default.tsx @@ -79,6 +79,8 @@ const GroupByKanBan: React.FC = observer((props) => { const verticalAlignPosition = (_list: IGroupByColumn) => kanBanToggle?.groupByHeaderMinMax.includes(_list.id); + const isGroupByCreatedBy = group_by === "created_by"; + return (
{list && @@ -100,7 +102,7 @@ const GroupByKanBan: React.FC = observer((props) => { kanBanToggle={kanBanToggle} handleKanBanToggle={handleKanBanToggle} issuePayload={_list.payload} - disableIssueCreation={disableIssueCreation} + disableIssueCreation={disableIssueCreation || isGroupByCreatedBy} currentStore={currentStore} addIssuesToView={addIssuesToView} /> diff --git a/web/components/issues/issue-layouts/kanban/kanban-group.tsx b/web/components/issues/issue-layouts/kanban/kanban-group.tsx index ce0a4d105..cbd1b1fc1 100644 --- a/web/components/issues/issue-layouts/kanban/kanban-group.tsx +++ b/web/components/issues/issue-layouts/kanban/kanban-group.tsx @@ -9,6 +9,8 @@ import { TUnGroupedIssues, } from "@plane/types"; import { EIssueActions } from "../types"; +// hooks +import { useProjectState } from "hooks/store"; //components import { KanBanQuickAddIssueForm, KanbanIssueBlocksList } from "."; @@ -56,6 +58,33 @@ export const KanbanGroup = (props: IKanbanGroup) => { viewId, } = props; + const projectState = useProjectState(); + + const prePopulateQuickAddData = (groupByKey: string | null, value: string) => { + const defaultState = projectState.projectStates?.find((state) => state.default); + let preloadedData: object = { state_id: defaultState?.id }; + + if (groupByKey) { + if (groupByKey === "state") { + preloadedData = { ...preloadedData, state_id: value }; + } else if (groupByKey === "priority") { + preloadedData = { ...preloadedData, priority: value }; + } else if (groupByKey === "labels" && value != "None") { + preloadedData = { ...preloadedData, label_ids: [value] }; + } else if (groupByKey === "assignees" && value != "None") { + preloadedData = { ...preloadedData, assignee_ids: [value] }; + } else if (groupByKey === "created_by") { + preloadedData = { ...preloadedData }; + } else { + preloadedData = { ...preloadedData, [groupByKey]: value }; + } + } + + return preloadedData; + }; + + const isGroupByCreatedBy = group_by === "created_by"; + return (
@@ -87,13 +116,13 @@ export const KanbanGroup = (props: IKanbanGroup) => {
- {enableQuickIssueCreate && !disableIssueCreation && ( + {enableQuickIssueCreate && !disableIssueCreation && !isGroupByCreatedBy && ( = obser const router = useRouter(); const { workspaceSlug, projectId } = router.query; // store hooks - const { getWorkspaceBySlug } = useWorkspace(); const { getProjectById } = useProject(); - const workspaceDetail = workspaceSlug ? getWorkspaceBySlug(workspaceSlug.toString()) : null; const projectDetail = projectId ? getProjectById(projectId.toString()) : null; const ref = useRef(null); @@ -87,11 +85,11 @@ export const KanBanQuickAddIssueForm: React.FC = obser }, [isOpen, reset]); const onSubmitHandler = async (formData: TIssue) => { - if (isSubmitting || !groupId || !workspaceDetail || !projectDetail || !workspaceSlug || !projectId) return; + if (isSubmitting || !workspaceSlug || !projectId) return; reset({ ...defaultValues }); - const payload = createIssuePayload(workspaceDetail, projectDetail, { + const payload = createIssuePayload(projectId.toString(), { ...(prePopulatedData ?? {}), ...formData, }); @@ -143,33 +141,6 @@ export const KanBanQuickAddIssueForm: React.FC = obser New Issue
)} - - {/* {isOpen && ( -
- - - )} - - {isOpen && ( -

- Press {"'"}Enter{"'"} to add another issue -

- )} - - {!isOpen && ( - - )} */}
); }); diff --git a/web/components/issues/issue-layouts/list/block.tsx b/web/components/issues/issue-layouts/list/block.tsx index 7c49e744c..99138d8f9 100644 --- a/web/components/issues/issue-layouts/list/block.tsx +++ b/web/components/issues/issue-layouts/list/block.tsx @@ -1,13 +1,13 @@ -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; // components import { IssueProperties } from "../properties/all-properties"; +// hooks +import { useApplication, useIssueDetail, useProject } from "hooks/store"; // ui -import { Spinner, Tooltip } from "@plane/ui"; +import { Spinner, Tooltip, ControlLink } from "@plane/ui"; // types import { TIssue, IIssueDisplayProperties, TIssueMap } from "@plane/types"; import { EIssueActions } from "../types"; -import { useProject } from "hooks/store"; interface IssueBlockProps { issueId: string; @@ -20,27 +20,29 @@ interface IssueBlockProps { export const IssueBlock: React.FC = observer((props: IssueBlockProps) => { const { issuesMap, issueId, handleIssues, quickActions, displayProperties, canEditProperties } = props; - // router - const router = useRouter(); + // hooks + const { + router: { workspaceSlug, projectId }, + } = useApplication(); + const { getProjectById } = useProject(); + const { setPeekIssue } = useIssueDetail(); + const updateIssue = (issueToUpdate: TIssue) => { handleIssues(issueToUpdate, EIssueActions.UPDATE); }; + const handleIssuePeekOverview = (issue: TIssue) => + workspaceSlug && + issue && + issue.project_id && + issue.id && + setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id }); + const issue = issuesMap[issueId]; if (!issue) return null; - const handleIssuePeekOverview = () => { - const { query } = router; - - router.push({ - pathname: router.pathname, - query: { ...query, peekIssueId: issue?.id, peekProjectId: issue?.project_id }, - }); - }; - const canEditIssueProperties = canEditProperties(issue.project_id); - const { getProjectById } = useProject(); const projectDetails = getProjectById(issue.project_id); return ( @@ -55,14 +57,17 @@ export const IssueBlock: React.FC = observer((props: IssueBlock {issue?.tempId !== undefined && (
)} - -
- {issue.name} -
-
+ + handleIssuePeekOverview(issue)} + className="w-full line-clamp-1 cursor-pointer text-sm font-medium text-custom-text-100" + > + + {issue.name} + +
{!issue?.tempId ? ( diff --git a/web/components/issues/issue-layouts/list/default.tsx b/web/components/issues/issue-layouts/list/default.tsx index 9bf7cfc78..38994215c 100644 --- a/web/components/issues/issue-layouts/list/default.tsx +++ b/web/components/issues/issue-layouts/list/default.tsx @@ -21,7 +21,6 @@ export interface IGroupByList { issueIds: TGroupedIssues | TUnGroupedIssues | any; issuesMap: TIssueMap; group_by: string | null; - is_list?: boolean; handleIssues: (issue: TIssue, action: EIssueActions) => Promise; quickActions: (issue: TIssue) => React.ReactNode; displayProperties: IIssueDisplayProperties | undefined; @@ -45,7 +44,6 @@ const GroupByList: React.FC = (props) => { issueIds, issuesMap, group_by, - is_list = false, handleIssues, quickActions, displayProperties, @@ -70,11 +68,27 @@ const GroupByList: React.FC = (props) => { const prePopulateQuickAddData = (groupByKey: string | null, value: any) => { const defaultState = projectState.projectStates?.find((state) => state.default); - if (groupByKey === null) return { state_id: defaultState?.id }; - else { - if (groupByKey === "state") return { state: groupByKey === "state" ? value : defaultState?.id }; - else return { state_id: defaultState?.id, [groupByKey]: value }; + let preloadedData: object = { state_id: defaultState?.id }; + + if (groupByKey === null) { + preloadedData = { ...preloadedData }; + } else { + if (groupByKey === "state") { + preloadedData = { ...preloadedData, state_id: value }; + } else if (groupByKey === "priority") { + preloadedData = { ...preloadedData, priority: value }; + } else if (groupByKey === "labels" && value != "None") { + preloadedData = { ...preloadedData, label_ids: [value] }; + } else if (groupByKey === "assignees" && value != "None") { + preloadedData = { ...preloadedData, assignee_ids: [value] }; + } else if (groupByKey === "created_by") { + preloadedData = { ...preloadedData }; + } else { + preloadedData = { ...preloadedData, [groupByKey]: value }; + } } + + return preloadedData; }; const validateEmptyIssueGroups = (issues: TIssue[]) => { @@ -83,6 +97,10 @@ const GroupByList: React.FC = (props) => { return true; }; + const is_list = group_by === null ? true : false; + + const isGroupByCreatedBy = group_by === "created_by"; + return (
{list && @@ -97,7 +115,7 @@ const GroupByList: React.FC = (props) => { title={_list.name || ""} count={is_list ? issueIds?.length || 0 : issueIds?.[_list.id]?.length || 0} issuePayload={_list.payload} - disableIssueCreation={disableIssueCreation} + disableIssueCreation={disableIssueCreation || isGroupByCreatedBy} currentStore={currentStore} addIssuesToView={addIssuesToView} /> @@ -114,7 +132,7 @@ const GroupByList: React.FC = (props) => { /> )} - {enableIssueQuickAdd && !disableIssueCreation && ( + {enableIssueQuickAdd && !disableIssueCreation && !isGroupByCreatedBy && (
= observer((props // router const router = useRouter(); const { workspaceSlug, projectId } = router.query; - // store hooks - const { currentWorkspace } = useWorkspace(); - const { currentProjectDetails } = useProject(); + // hooks + const { getProjectById } = useProject(); + + const projectDetail = (projectId && getProjectById(projectId.toString())) || undefined; const ref = useRef(null); @@ -88,11 +89,11 @@ export const ListQuickAddIssueForm: FC = observer((props }, [isOpen, reset]); const onSubmitHandler = async (formData: TIssue) => { - if (isSubmitting || !currentWorkspace || !currentProjectDetails || !workspaceSlug || !projectId) return; + if (isSubmitting || !workspaceSlug || !projectId) return; reset({ ...defaultValues }); - const payload = createIssuePayload(currentWorkspace, currentProjectDetails, { + const payload = createIssuePayload(projectId.toString(), { ...(prePopulatedData ?? {}), ...formData, }); @@ -127,12 +128,7 @@ export const ListQuickAddIssueForm: FC = observer((props onSubmit={handleSubmit(onSubmitHandler)} className="flex w-full items-center gap-x-3 border-[0.5px] border-t-0 border-custom-border-100 bg-custom-background-100 px-3" > - +
{`Press 'Enter' to add another issue`}
diff --git a/web/components/issues/issue-layouts/properties/all-properties.tsx b/web/components/issues/issue-layouts/properties/all-properties.tsx index fe05d834b..0df4b415e 100644 --- a/web/components/issues/issue-layouts/properties/all-properties.tsx +++ b/web/components/issues/issue-layouts/properties/all-properties.tsx @@ -141,8 +141,8 @@ export const IssueProperties: React.FC = observer((props) => { onChange={handleAssignee} disabled={isReadOnly} multiple - buttonVariant={issue.assignee_ids.length > 0 ? "transparent-without-text" : "border-without-text"} - buttonClassName={issue.assignee_ids.length > 0 ? "hover:bg-transparent px-0" : ""} + buttonVariant={issue.assignee_ids?.length > 0 ? "transparent-without-text" : "border-without-text"} + buttonClassName={issue.assignee_ids?.length > 0 ? "hover:bg-transparent px-0" : ""} />
diff --git a/web/components/issues/issue-layouts/roots/project-layout-root.tsx b/web/components/issues/issue-layouts/roots/project-layout-root.tsx index f8e428e5c..453f331cb 100644 --- a/web/components/issues/issue-layouts/roots/project-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/project-layout-root.tsx @@ -1,4 +1,4 @@ -import { useRouter } from "next/router"; +import { FC } from "react"; import { observer } from "mobx-react-lite"; import useSWR from "swr"; // components @@ -10,21 +10,24 @@ import { ProjectAppliedFiltersRoot, ProjectSpreadsheetLayout, ProjectEmptyState, + IssuePeekOverview, } from "components/issues"; +// ui import { Spinner } from "@plane/ui"; -import { useIssues } from "hooks/store/use-issues"; -import { EIssuesStoreType } from "constants/issue"; // hooks +import { useApplication, useIssues } from "hooks/store"; +// constants +import { EIssuesStoreType } from "constants/issue"; -export const ProjectLayoutRoot: React.FC = observer(() => { - // router - const router = useRouter(); - const { workspaceSlug, projectId } = router.query as { workspaceSlug: string; projectId: string }; - +export const ProjectLayoutRoot: FC = observer(() => { + // hooks + const { + router: { workspaceSlug, projectId }, + } = useApplication(); const { issues, issuesFilter } = useIssues(EIssuesStoreType.PROJECT); useSWR( - workspaceSlug && projectId ? `PROJECT_ISSUES_V3_${workspaceSlug}_${projectId}` : null, + workspaceSlug && projectId ? `PROJECT_ISSUES_${workspaceSlug}_${projectId}` : null, async () => { if (workspaceSlug && projectId) { await issuesFilter?.fetchFilters(workspaceSlug, projectId); @@ -40,28 +43,35 @@ export const ProjectLayoutRoot: React.FC = observer(() => {
- {issues?.loader === "init-loader" || !issues?.groupedIssueIds ? ( + {issues?.loader === "init-loader" ? (
) : ( <> - {(issues?.groupedIssueIds ?? {}).length == 0 ? ( - - ) : ( -
- {activeLayout === "list" ? ( - - ) : activeLayout === "kanban" ? ( - - ) : activeLayout === "calendar" ? ( - - ) : activeLayout === "gantt_chart" ? ( - - ) : activeLayout === "spreadsheet" ? ( - - ) : null} + {!issues?.groupedIssueIds ? ( +
+
+ ) : ( + <> +
+ {activeLayout === "list" ? ( + + ) : activeLayout === "kanban" ? ( + + ) : activeLayout === "calendar" ? ( + + ) : activeLayout === "gantt_chart" ? ( + + ) : activeLayout === "spreadsheet" ? ( + + ) : null} +
+ + {/* peek overview */} + + )} )} diff --git a/web/components/issues/issue-layouts/roots/project-view-layout-root.tsx b/web/components/issues/issue-layouts/roots/project-view-layout-root.tsx index 0c2b323a2..f6b5500a6 100644 --- a/web/components/issues/issue-layouts/roots/project-view-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/project-view-layout-root.tsx @@ -29,12 +29,15 @@ export const ProjectViewLayoutRoot: React.FC = observer(() => { issuesFilter: { issueFilters, fetchFilters }, } = useIssues(EIssuesStoreType.PROJECT_VIEW); - useSWR(workspaceSlug && projectId && viewId ? `PROJECT_ISSUES_V3_${workspaceSlug}_${projectId}` : null, async () => { - if (workspaceSlug && projectId && viewId) { - await fetchFilters(workspaceSlug, projectId, viewId); - await fetchIssues(workspaceSlug, projectId, groupedIssueIds ? "mutation" : "init-loader"); + useSWR( + workspaceSlug && projectId && viewId ? `PROJECT_VIEW_ISSUES_${workspaceSlug}_${projectId}` : null, + async () => { + if (workspaceSlug && projectId && viewId) { + await fetchFilters(workspaceSlug, projectId, viewId); + await fetchIssues(workspaceSlug, projectId, groupedIssueIds ? "mutation" : "init-loader"); + } } - }); + ); const activeLayout = issueFilters?.displayFilters?.layout; diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/assignee-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/assignee-column.tsx index 6dbcecb8d..89d8367f3 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/assignee-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/assignee-column.tsx @@ -32,7 +32,7 @@ export const SpreadsheetAssigneeColumn: React.FC = ({ issueId, onChange, disabled={disabled} multiple placeholder="Assignees" - buttonVariant={issueDetail.assignee_ids.length > 0 ? "transparent-without-text" : "transparent-with-text"} + buttonVariant={issueDetail.assignee_ids?.length > 0 ? "transparent-without-text" : "transparent-with-text"} buttonClassName="text-left" buttonContainerClassName="w-full" /> diff --git a/web/components/issues/issue-layouts/spreadsheet/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/spreadsheet/quick-add-issue-form.tsx index 603276b3b..44eb3a198 100644 --- a/web/components/issues/issue-layouts/spreadsheet/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/quick-add-issue-form.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, useRef } from "react"; +import { useRouter } from "next/router"; import { useForm } from "react-hook-form"; import { observer } from "mobx-react-lite"; import { PlusIcon } from "lucide-react"; @@ -55,6 +56,10 @@ const Inputs = (props: any) => { export const SpreadsheetQuickAddIssueForm: React.FC = observer((props) => { const { formKey, prePopulatedData, quickAddCallback, viewId } = props; + + // router + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; // store hooks const { currentWorkspace } = useWorkspace(); const { currentProjectDetails } = useProject(); @@ -148,7 +153,7 @@ export const SpreadsheetQuickAddIssueForm: React.FC = observer((props) => reset({ ...defaultValues }); - const payload = createIssuePayload(currentWorkspace, currentProjectDetails, { + const payload = createIssuePayload(currentProjectDetails.id, { ...(prePopulatedData ?? {}), ...formData, }); diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx index b86eabf54..0e5d2ba94 100644 --- a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx @@ -1,13 +1,7 @@ import React, { useEffect, useRef, useState } from "react"; -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; // components -import { - IssuePeekOverview, - SpreadsheetColumnsList, - SpreadsheetIssuesColumn, - SpreadsheetQuickAddIssueForm, -} from "components/issues"; +import { SpreadsheetColumnsList, SpreadsheetIssuesColumn, SpreadsheetQuickAddIssueForm } from "components/issues"; import { Spinner, LayersIcon } from "@plane/ui"; // types import { TIssue, IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueLabel, IState } from "@plane/types"; @@ -56,9 +50,6 @@ export const SpreadsheetView: React.FC = observer((props) => { const [isScrolled, setIsScrolled] = useState(false); // refs const containerRef = useRef(null); - // router - const router = useRouter(); - const { workspaceSlug, peekIssueId, peekProjectId } = router.query; const handleScroll = () => { if (!containerRef.current) return; @@ -186,14 +177,6 @@ export const SpreadsheetView: React.FC = observer((props) => { ))} */}
- {workspaceSlug && peekIssueId && peekProjectId && ( - await handleIssues(issueToUpdate, EIssueActions.UPDATE)} - /> - )}
); }); diff --git a/web/components/issues/issue-layouts/utils.tsx b/web/components/issues/issue-layouts/utils.tsx index 866e26e75..f3f4a483e 100644 --- a/web/components/issues/issue-layouts/utils.tsx +++ b/web/components/issues/issue-layouts/utils.tsx @@ -49,7 +49,7 @@ const getProjectColumns = (project: IProjectStore): IGroupByColumn[] | undefined id: project.id, name: project.name, Icon:
{renderEmoji(project.emoji || "")}
, - payload: { project: project.id }, + payload: { project_id: project.id }, }; }) as any; }; @@ -66,7 +66,7 @@ const getStateColumns = (projectState: IStateStore): IGroupByColumn[] | undefine
), - payload: { state: state.id }, + payload: { state_id: state.id }, })) as any; }; @@ -111,7 +111,7 @@ const getLabelsColumns = (projectLabel: ILabelRootStore) => { Icon: (
), - payload: { labels: [label.id] }, + payload: label?.id === "None" ? {} : { label_ids: [label.id] }, })); }; @@ -123,17 +123,17 @@ const getAssigneeColumns = (member: IMemberRootStore) => { if (!projectMemberIds) return; - const assigneeColumns = projectMemberIds.map((memberId) => { + const assigneeColumns: any = projectMemberIds.map((memberId) => { const member = getUserDetails(memberId); return { id: memberId, name: member?.display_name || "", Icon: , - payload: { assignees: [memberId] }, + payload: { assignee_ids: [memberId] }, }; }); - assigneeColumns.push({ id: "None", name: "None", Icon: , payload: { assignees: [""] } }); + assigneeColumns.push({ id: "None", name: "None", Icon: , payload: {} }); return assigneeColumns; }; @@ -152,7 +152,7 @@ const getCreatedByColumns = (member: IMemberRootStore) => { id: memberId, name: member?.display_name || "", Icon: , - payload: { assignees: [memberId] }, + payload: {}, }; }); }; diff --git a/web/components/issues/issue-links/link-detail.tsx b/web/components/issues/issue-links/link-detail.tsx index d00e43597..3a5fdc224 100644 --- a/web/components/issues/issue-links/link-detail.tsx +++ b/web/components/issues/issue-links/link-detail.tsx @@ -1,5 +1,6 @@ import { FC, useState } from "react"; // hooks +import useToast from "hooks/use-toast"; import { useIssueDetail } from "hooks/store"; // ui import { ExternalLinkIcon, Tooltip } from "@plane/ui"; @@ -9,6 +10,7 @@ import { Pencil, Trash2, LinkIcon } from "lucide-react"; import { IssueLinkCreateUpdateModal, TLinkOperationsModal } from "./create-update-link-modal"; // helpers import { calculateTimeAgo } from "helpers/date-time.helper"; +import { copyTextToClipboard } from "helpers/string.helper"; export type TIssueLinkDetail = { linkId: string; @@ -23,6 +25,8 @@ export const IssueLinkDetail: FC = (props) => { const { link: { getLinkById }, } = useIssueDetail(); + const { setToastAlert } = useToast(); + // state const [isIssueLinkModalOpen, setIsIssueLinkModalOpen] = useState(false); const toggleIssueLinkModal = (modalToggle: boolean) => setIsIssueLinkModalOpen(modalToggle); @@ -40,18 +44,23 @@ export const IssueLinkDetail: FC = (props) => { />
-
+
{ + copyTextToClipboard(linkDetail.url); + setToastAlert({ + type: "success", + title: "Link copied!", + message: "Link copied to clipboard", + }); + }} + >
- - // copyToClipboard(linkDetail.title && linkDetail.title !== "" ? linkDetail.title : linkDetail.url) - // } - > + {linkDetail.title && linkDetail.title !== "" ? linkDetail.title : linkDetail.url} diff --git a/web/components/issues/issue-links/root.tsx b/web/components/issues/issue-links/root.tsx index bd2db3d39..d4e948bb2 100644 --- a/web/components/issues/issue-links/root.tsx +++ b/web/components/issues/issue-links/root.tsx @@ -27,7 +27,7 @@ export const IssueLinkRoot: FC = (props) => { const { router: { workspaceSlug, projectId }, } = useApplication(); - const { issueId, createLink, updateLink, removeLink } = useIssueDetail(); + const { peekIssue, createLink, updateLink, removeLink } = useIssueDetail(); // state const [isIssueLinkModalOpen, setIsIssueLinkModalOpen] = useState(false); const toggleIssueLinkModal = (modalToggle: boolean) => setIsIssueLinkModalOpen(modalToggle); @@ -38,8 +38,8 @@ export const IssueLinkRoot: FC = (props) => { () => ({ create: async (data: Partial) => { try { - if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); - await createLink(workspaceSlug, projectId, issueId, data); + if (!workspaceSlug || !projectId || !peekIssue?.issueId) throw new Error("Missing required fields"); + await createLink(workspaceSlug, projectId, peekIssue?.issueId, data); setToastAlert({ message: "The link has been successfully created", type: "success", @@ -56,8 +56,8 @@ export const IssueLinkRoot: FC = (props) => { }, update: async (linkId: string, data: Partial) => { try { - if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); - await updateLink(workspaceSlug, projectId, issueId, linkId, data); + if (!workspaceSlug || !projectId || !peekIssue?.issueId) throw new Error("Missing required fields"); + await updateLink(workspaceSlug, projectId, peekIssue?.issueId, linkId, data); setToastAlert({ message: "The link has been successfully updated", type: "success", @@ -74,8 +74,8 @@ export const IssueLinkRoot: FC = (props) => { }, remove: async (linkId: string) => { try { - if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); - await removeLink(workspaceSlug, projectId, issueId, linkId); + if (!workspaceSlug || !projectId || !peekIssue?.issueId) throw new Error("Missing required fields"); + await removeLink(workspaceSlug, projectId, peekIssue?.issueId, linkId); setToastAlert({ message: "The link has been successfully removed", type: "success", @@ -91,7 +91,7 @@ export const IssueLinkRoot: FC = (props) => { } }, }), - [workspaceSlug, projectId, issueId, createLink, updateLink, removeLink, setToastAlert] + [workspaceSlug, projectId, peekIssue, createLink, updateLink, removeLink, setToastAlert] ); return ( diff --git a/web/components/issues/peek-overview/properties.tsx b/web/components/issues/peek-overview/properties.tsx index 852f1ac92..48afc4cd4 100644 --- a/web/components/issues/peek-overview/properties.tsx +++ b/web/components/issues/peek-overview/properties.tsx @@ -6,11 +6,17 @@ import { CalendarDays, Link2, Plus, Signal, Tag, Triangle, LayoutPanelTop } from import { useIssueDetail, useProject, useUser } from "hooks/store"; // ui icons import { DiceIcon, DoubleCircleIcon, UserGroupIcon, ContrastIcon } from "@plane/ui"; -import { SidebarCycleSelect, SidebarLabelSelect, SidebarModuleSelect, SidebarParentSelect } from "components/issues"; +import { + IssueLinkRoot, + SidebarCycleSelect, + SidebarLabelSelect, + SidebarModuleSelect, + SidebarParentSelect, +} from "components/issues"; import { EstimateDropdown, PriorityDropdown, ProjectMemberDropdown, StateDropdown } from "components/dropdowns"; // components import { CustomDatePicker } from "components/ui"; -import { LinkModal, LinksList } from "components/core"; +import { LinkModal } from "components/core"; // types import { TIssue, TIssuePriorities, ILinkDetails, IIssueLink } from "@plane/types"; // constants @@ -39,6 +45,9 @@ export const PeekOverviewProperties: FC = observer((pro const router = useRouter(); const { workspaceSlug, projectId } = router.query; + const uneditable = currentProjectRole ? [5, 10].includes(currentProjectRole) : false; + const isAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; + const handleState = (_state: string) => { issueUpdate({ ...issue, state_id: _state }); }; @@ -274,42 +283,8 @@ export const PeekOverviewProperties: FC = observer((pro
-
-
-
- -

Links

-
-
- {!disableUserActions && ( - - )} -
-
-
- {issue?.issue_link && issue.issue_link.length > 0 ? ( - - ) : null} -
+
+
diff --git a/web/components/issues/peek-overview/root.tsx b/web/components/issues/peek-overview/root.tsx index 12113af08..2b06bd0da 100644 --- a/web/components/issues/peek-overview/root.tsx +++ b/web/components/issues/peek-overview/root.tsx @@ -1,4 +1,5 @@ -import { FC, Fragment, ReactNode, useCallback, useEffect } from "react"; +import { FC, Fragment, useEffect, useState } from "react"; +// router import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; // hooks @@ -13,33 +14,28 @@ import { TIssue, IIssueLink } from "@plane/types"; // constants import { EUserProjectRoles } from "constants/project"; import { EIssuesStoreType } from "constants/issue"; -import { EIssueActions } from "../issue-layouts/types"; interface IIssuePeekOverview { - workspaceSlug: string; - projectId: string; - issueId: string; - handleIssue: (issue: Partial, action: EIssueActions) => void; isArchived?: boolean; - children?: ReactNode; } export const IssuePeekOverview: FC = observer((props) => { - const { workspaceSlug, projectId, issueId, handleIssue, children, isArchived = false } = props; + const { isArchived = false } = props; // router const router = useRouter(); - const { peekIssueId } = router.query; - // FIXME - // store hooks - // const { - // archivedIssueDetail: { - // getIssue: getArchivedIssue, - // loader: archivedIssueLoader, - // fetchPeekIssueDetails: fetchArchivedPeekIssueDetails, - // }, - // } = useMobxStore(); - + // hooks + const { currentProjectDetails } = useProject(); + const { setToastAlert } = useToast(); const { + membership: { currentProjectRole }, + } = useUser(); + const { + issues: { removeIssue: removeArchivedIssue }, + } = useIssues(EIssuesStoreType.ARCHIVED); + const { + peekIssue, + updateIssue, + removeIssue, createComment, updateComment, removeComment, @@ -53,37 +49,38 @@ export const IssuePeekOverview: FC = observer((props) => { updateLink, removeLink, issue: { getIssueById, fetchIssue }, - // loader, - setIssueId, fetchActivities, } = useIssueDetail(); - const { - issues: { removeIssue }, - } = useIssues(EIssuesStoreType.ARCHIVED); - const { - membership: { currentProjectRole }, - } = useUser(); - const { currentProjectDetails } = useProject(); - - const { setToastAlert } = useToast(); - - const fetchIssueDetail = useCallback(async () => { - if (workspaceSlug && projectId && peekIssueId) { - //if (isArchived) await fetchArchivedPeekIssueDetails(workspaceSlug, projectId, peekIssueId as string); - //else - await fetchIssue(workspaceSlug, projectId, peekIssueId.toString()); - } - }, [fetchIssue, workspaceSlug, projectId, peekIssueId]); + // state + const [loader, setLoader] = useState(false); useEffect(() => { - fetchIssueDetail(); - }, [workspaceSlug, projectId, peekIssueId, fetchIssueDetail]); + if (peekIssue) { + setLoader(true); + fetchIssue(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId).finally(() => { + setLoader(false); + }); + } + }, [peekIssue, fetchIssue]); + if (!peekIssue) return <>; + + const issue = getIssueById(peekIssue.issueId) || undefined; + + const redirectToIssueDetail = () => { + router.push({ + pathname: `/${peekIssue.workspaceSlug}/projects/${peekIssue.projectId}/${ + isArchived ? "archived-issues" : "issues" + }/${peekIssue.issueId}`, + }); + }; const handleCopyText = (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); copyUrlToClipboard( - `${workspaceSlug}/projects/${projectId}/${isArchived ? "archived-issues" : "issues"}/${peekIssueId}` + `${peekIssue.workspaceSlug}/projects/${peekIssue.projectId}/${isArchived ? "archived-issues" : "issues"}/${ + peekIssue.issueId + }` ).then(() => { setToastAlert({ type: "success", @@ -93,101 +90,81 @@ export const IssuePeekOverview: FC = observer((props) => { }); }; - const redirectToIssueDetail = () => { - router.push({ - pathname: `/${workspaceSlug}/projects/${projectId}/${isArchived ? "archived-issues" : "issues"}/${issueId}`, - }); - }; - - // const issue = isArchived ? getArchivedIssue : getIssue; - // const isLoading = isArchived ? archivedIssueLoader : loader; - - const issue = getIssueById(issueId); - const isLoading = false; - const issueUpdate = async (_data: Partial) => { - if (handleIssue) { - await handleIssue(_data, EIssueActions.UPDATE); - fetchActivities(workspaceSlug, projectId, issueId); - } + if (!issue) return; + await updateIssue(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId, _data); + fetchActivities(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId); + }; + const issueDelete = async () => { + if (!issue) return; + if (isArchived) await removeArchivedIssue(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId); + else await removeIssue(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId); }; - const issueReactionCreate = (reaction: string) => createReaction(workspaceSlug, projectId, issueId, reaction); + const issueReactionCreate = (reaction: string) => + createReaction(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId, reaction); + const issueReactionRemove = (reaction: string) => + removeReaction(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId, reaction); - const issueReactionRemove = (reaction: string) => removeReaction(workspaceSlug, projectId, issueId, reaction); - - const issueCommentCreate = (comment: any) => createComment(workspaceSlug, projectId, issueId, comment); - - const issueCommentUpdate = (comment: any) => updateComment(workspaceSlug, projectId, issueId, comment?.id, comment); - - const issueCommentRemove = (commentId: string) => removeComment(workspaceSlug, projectId, issueId, commentId); + const issueCommentCreate = (comment: any) => + createComment(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId, comment); + const issueCommentUpdate = (comment: any) => + updateComment(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId, comment?.id, comment); + const issueCommentRemove = (commentId: string) => + removeComment(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId, commentId); const issueCommentReactionCreate = (commentId: string, reaction: string) => - createCommentReaction(workspaceSlug, projectId, commentId, reaction); - + createCommentReaction(peekIssue.workspaceSlug, peekIssue.projectId, commentId, reaction); const issueCommentReactionRemove = (commentId: string, reaction: string) => - removeCommentReaction(workspaceSlug, projectId, commentId, reaction); + removeCommentReaction(peekIssue.workspaceSlug, peekIssue.projectId, commentId, reaction); - const issueSubscriptionCreate = () => createSubscription(workspaceSlug, projectId, issueId); - - const issueSubscriptionRemove = () => removeSubscription(workspaceSlug, projectId, issueId); - - const issueLinkCreate = (formData: IIssueLink) => createLink(workspaceSlug, projectId, issueId, formData); + const issueSubscriptionCreate = () => + createSubscription(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId); + const issueSubscriptionRemove = () => + removeSubscription(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId); + const issueLinkCreate = (formData: IIssueLink) => + createLink(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId, formData); const issueLinkUpdate = (formData: IIssueLink, linkId: string) => - updateLink(workspaceSlug, projectId, issueId, linkId, formData); - - const issueLinkDelete = (linkId: string) => removeLink(workspaceSlug, projectId, issueId, linkId); - - const handleDeleteIssue = async () => { - if (!issue) return; - - if (isArchived) await removeIssue(workspaceSlug, projectId, issue?.id); - // FIXME else delete... - const { query } = router; - if (query.peekIssueId) { - setIssueId(undefined); - delete query.peekIssueId; - delete query.peekProjectId; - router.push({ - pathname: router.pathname, - query: { ...query }, - }); - } - }; + updateLink(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId, linkId, formData); + const issueLinkDelete = (linkId: string) => + removeLink(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId, linkId); const userRole = currentProjectRole ?? EUserProjectRoles.GUEST; + const isLoading = !issue || loader ? true : false; return ( - - {children} - + {isLoading ? ( + <> // TODO: show the spinner + ) : ( + + )} ); }); diff --git a/web/components/issues/peek-overview/view.tsx b/web/components/issues/peek-overview/view.tsx index e598683b2..6a7737f73 100644 --- a/web/components/issues/peek-overview/view.tsx +++ b/web/components/issues/peek-overview/view.tsx @@ -1,7 +1,5 @@ -import { FC, ReactNode, useRef, useState } from "react"; -import { useRouter } from "next/router"; +import { FC, useRef, useState } from "react"; import { observer } from "mobx-react-lite"; -import useSWR from "swr"; import { MoveRight, MoveDiagonal, Bell, Link2, Trash2 } from "lucide-react"; // hooks import { useIssueDetail, useUser } from "hooks/store"; @@ -43,7 +41,6 @@ interface IIssueView { issueLinkUpdate: (formData: IIssueLink, linkId: string) => Promise; issueLinkDelete: (linkId: string) => Promise; handleDeleteIssue: () => Promise; - children: ReactNode; disableUserActions?: boolean; showCommentAccessSpecifier?: boolean; } @@ -92,7 +89,6 @@ export const IssueView: FC = observer((props) => { issueLinkUpdate, issueLinkDelete, handleDeleteIssue, - children, disableUserActions = false, showCommentAccessSpecifier = false, } = props; @@ -101,58 +97,19 @@ export const IssueView: FC = observer((props) => { const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved"); // ref const issuePeekOverviewRef = useRef(null); - // router - const router = useRouter(); - const { peekIssueId } = router.query; // store hooks const { - fetchSubscriptions, activity, reaction, subscription, - setIssueId, + setPeekIssue, isAnyModalOpen, isDeleteIssueModalOpen, toggleDeleteIssueModal, } = useIssueDetail(); const { currentUser } = useUser(); - const updateRoutePeekId = () => { - if (issueId != peekIssueId) { - setIssueId(issueId); - const { query } = router; - router.push({ - pathname: router.pathname, - query: { ...query, peekIssueId: issueId, peekProjectId: projectId }, - }); - } - }; - - const removeRoutePeekId = () => { - const { query } = router; - - if (query.peekIssueId) { - setIssueId(undefined); - - delete query.peekIssueId; - delete query.peekProjectId; - router.push({ - pathname: router.pathname, - query: { ...query }, - }); - } - }; - - useSWR( - workspaceSlug && projectId && issueId && peekIssueId && issueId === peekIssueId - ? `ISSUE_PEEK_OVERVIEW_SUBSCRIPTION_${workspaceSlug}_${projectId}_${peekIssueId}` - : null, - async () => { - if (workspaceSlug && projectId && issueId && peekIssueId && issueId === peekIssueId) { - await fetchSubscriptions(workspaceSlug, projectId, issueId); - } - } - ); + const removeRoutePeekId = () => setPeekIssue(undefined); const issueReactions = reaction.getReactionsByIssueId(issueId) || []; const issueActivity = activity.getActivitiesByIssueId(issueId); @@ -172,6 +129,7 @@ export const IssueView: FC = observer((props) => { onSubmit={handleDeleteIssue} /> )} + {issue && isArchived && ( = observer((props) => { onSubmit={handleDeleteIssue} /> )} -
- {children && ( -
- {children} -
- )} - {issueId === peekIssueId && ( +
+ {issueId && (
= observer((props) => {
{issue?.created_by !== currentUser?.id && !issue?.assignee_ids.includes(currentUser?.id ?? "") && - !router.pathname.includes("[archivedIssueId]") && ( + !issue?.archived_at && ( diff --git a/web/components/issues/sidebar-select/module.tsx b/web/components/issues/sidebar-select/module.tsx index ee35a58b6..235f8486b 100644 --- a/web/components/issues/sidebar-select/module.tsx +++ b/web/components/issues/sidebar-select/module.tsx @@ -81,17 +81,17 @@ export const SidebarModuleSelect: React.FC = observer((props) => { }); // derived values - const issueModule = issueDetail?.issue_module; - const selectedModule = issueModule?.module ? getModuleById(issueModule?.module) : null; + const issueModule = (issueDetail && issueDetail?.module_id && getModuleById(issueDetail.module_id)) || undefined; + const disableSelect = disabled || isUpdating; return (
{ - value === issueModule?.module_detail.id - ? handleRemoveIssueFromModule(issueModule?.id ?? "", issueModule?.module ?? "") + value === issueDetail?.module_id + ? handleRemoveIssueFromModule(issueModule?.id ?? "", issueDetail?.module_id ?? "") : handleModuleChange ? handleModuleChange(value) : handleModuleStoreChange(value); @@ -99,7 +99,7 @@ export const SidebarModuleSelect: React.FC = observer((props) => { options={options} customButton={
- + diff --git a/web/components/issues/sidebar-select/parent.tsx b/web/components/issues/sidebar-select/parent.tsx index 8d3d71c49..47fb01b27 100644 --- a/web/components/issues/sidebar-select/parent.tsx +++ b/web/components/issues/sidebar-select/parent.tsx @@ -2,13 +2,14 @@ import React, { useState } from "react"; import { useRouter } from "next/router"; // hooks -import { useIssueDetail, useProject } from "hooks/store"; +import { useIssueDetail, useIssues, useProject } from "hooks/store"; // components import { ParentIssuesListModal } from "components/issues"; // icons import { X } from "lucide-react"; // types import { TIssue, ISearchIssueResponse } from "@plane/types"; +import { observer } from "mobx-react-lite"; type Props = { onChange: (value: string) => void; @@ -16,7 +17,7 @@ type Props = { disabled?: boolean; }; -export const SidebarParentSelect: React.FC = ({ onChange, issueDetails, disabled = false }) => { +export const SidebarParentSelect: React.FC = observer(({ onChange, issueDetails, disabled = false }) => { const [selectedParentIssue, setSelectedParentIssue] = useState(null); const { isParentIssueModalOpen, toggleParentIssueModal } = useIssueDetail(); @@ -26,6 +27,7 @@ export const SidebarParentSelect: React.FC = ({ onChange, issueDetails, d // hooks const { getProjectById } = useProject(); + const { issueMap } = useIssues(); return ( <> @@ -56,7 +58,7 @@ export const SidebarParentSelect: React.FC = ({ onChange, issueDetails, d {selectedParentIssue && issueDetails?.parent_id ? ( `${selectedParentIssue.project__identifier}-${selectedParentIssue.sequence_id}` ) : !selectedParentIssue && issueDetails?.parent_id ? ( - `${getProjectById(issueDetails.parent_id)?.identifier}-${issueDetails.parent_detail?.sequence_id}` + `${getProjectById(issueDetails.parent_id)?.identifier}-${issueMap[issueDetails.parent_id]?.sequence_id}` ) : ( Select issue )} @@ -64,4 +66,4 @@ export const SidebarParentSelect: React.FC = ({ onChange, issueDetails, d ); -}; +}); diff --git a/web/components/issues/sub-issues/issue.tsx b/web/components/issues/sub-issues/issue.tsx index 8a9f2c0eb..57a887798 100644 --- a/web/components/issues/sub-issues/issue.tsx +++ b/web/components/issues/sub-issues/issue.tsx @@ -1,10 +1,8 @@ -import { useRouter } from "next/router"; import React from "react"; import { ChevronDown, ChevronRight, X, Pencil, Trash, Link as LinkIcon, Loader } from "lucide-react"; // components import { SubIssuesRootList } from "./issues-list"; import { IssueProperty } from "./properties"; -import { IssuePeekOverview } from "components/issues"; // ui import { CustomMenu, Tooltip } from "@plane/ui"; // types @@ -42,7 +40,6 @@ export const SubIssues: React.FC = ({ projectId, parentIssue, issueId, - handleIssue, spacingLeft = 0, user, editable, @@ -53,9 +50,6 @@ export const SubIssues: React.FC = ({ handleIssueCrudOperation, handleUpdateIssue, }) => { - const router = useRouter(); - const { peekProjectId, peekIssueId } = router.query; - const { issue: { getIssueById }, } = useIssueDetail(); @@ -68,25 +62,8 @@ export const SubIssues: React.FC = ({ (issue?.project_id && getProjectStates(issue?.project_id)?.find((state) => issue?.state_id == state.id)) || undefined; - const handleIssuePeekOverview = () => { - const { query } = router; - - router.push({ - pathname: router.pathname, - query: { ...query, peekIssueId: issue?.id, peekProjectId: issue?.project_id }, - }); - }; - return ( <> - {workspaceSlug && peekProjectId && peekIssueId && peekIssueId === issue?.id && ( - await handleUpdateIssue(issue, { ...issue, ...issueToUpdate })} - /> - )}
{issue && (
= ({ )}
-
+
) => TIssue = ( + projectId: string, formData: Partial -) => TIssue = (workspaceDetail: IWorkspace, projectDetail: IProject, formData: Partial) => { - const payload = { - archived_at: null, - assignee_details: [], - attachment_count: 0, - attachments: [], - issue_relations: [], - related_issues: [], - bridge_id: null, - completed_at: new Date(), - created_at: "", - created_by: "", - cycle: null, - cycle_id: null, - cycle_detail: null, - description: {}, - description_html: "", - description_stripped: "", - estimate_point: null, - issue_cycle: null, - issue_link: [], - issue_module: null, - label_details: [], - is_draft: false, - links_list: [], - link_count: 0, - module: null, - module_id: null, - name: "", - parent: null, - parent_detail: null, - priority: "none", - project: projectDetail.id, - project_detail: projectDetail, - sequence_id: 0, - sort_order: 0, - sprints: null, - start_date: null, - state: projectDetail.default_state, - state_detail: {} as any, - sub_issues_count: 0, - target_date: null, - updated_at: "", - updated_by: "", - workspace: workspaceDetail.id, - workspace_detail: workspaceDetail, +) => { + const payload: TIssue = { id: uuidv4(), + project_id: projectId, + // tempId is used for optimistic updates. It is not a part of the API response. tempId: uuidv4(), // to be overridden by the form data ...formData, - assignee_ids: Array.isArray(formData.assignee_ids) - ? formData.assignee_ids - : formData.assignee_ids && formData.assignee_ids !== "none" && formData.assignee_ids !== null - ? [formData.assignee_ids] - : [], - label_ids: Array.isArray(formData.label_ids) - ? formData.label_ids - : formData.label_ids && formData.label_ids !== "none" && formData.label_ids !== null - ? [formData.label_ids] - : [], } as TIssue; return payload; diff --git a/web/lib/app-provider.tsx b/web/lib/app-provider.tsx index dad6253c9..027800cd8 100644 --- a/web/lib/app-provider.tsx +++ b/web/lib/app-provider.tsx @@ -47,7 +47,7 @@ export const AppProvider: FC = observer((props) => { - = observer((props) => { posthogHost={envConfig?.posthog_host || null} > {children} - + */} + {children} diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/archived-issues/[archivedIssueId].tsx b/web/pages/[workspaceSlug]/projects/[projectId]/archived-issues/[archivedIssueId].tsx index d59bf4fe0..43c23eb78 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/archived-issues/[archivedIssueId].tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/archived-issues/[archivedIssueId].tsx @@ -30,8 +30,8 @@ const defaultValues: Partial = { state_id: "", priority: "low", target_date: new Date().toString(), - issue_cycle: null, - issue_module: null, + cycle_id: null, + module_id: null, }; // services diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx b/web/pages/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx index fd1a1fd6d..d6cc2b04d 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx @@ -26,8 +26,8 @@ const defaultValues: Partial = { // description: "", description_html: "", estimate_point: null, - issue_cycle: null, - issue_module: null, + cycle_id: null, + module_id: null, name: "", priority: "low", start_date: undefined, @@ -43,7 +43,7 @@ const IssueDetailsPage: NextPageWithLayout = observer(() => { const router = useRouter(); const { workspaceSlug, projectId, issueId: routeIssueId } = router.query; - const { issueId, fetchIssue } = useIssueDetail(); + const { peekIssue, fetchIssue } = useIssueDetail(); useEffect(() => { if (!workspaceSlug || !projectId || !routeIssueId) return; fetchIssue(workspaceSlug as string, projectId as string, routeIssueId as string); @@ -54,9 +54,9 @@ const IssueDetailsPage: NextPageWithLayout = observer(() => { mutate: mutateIssueDetails, error, } = useSWR( - workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null, - workspaceSlug && projectId && issueId - ? () => issueService.retrieve(workspaceSlug as string, projectId as string, issueId as string) + workspaceSlug && projectId && peekIssue?.issueId ? ISSUE_DETAILS(peekIssue?.issueId as string) : null, + workspaceSlug && projectId && peekIssue?.issueId + ? () => issueService.retrieve(workspaceSlug as string, projectId as string, peekIssue?.issueId as string) : null ); @@ -66,10 +66,10 @@ const IssueDetailsPage: NextPageWithLayout = observer(() => { const submitChanges = useCallback( async (formData: Partial) => { - if (!workspaceSlug || !projectId || !issueId) return; + if (!workspaceSlug || !projectId || !peekIssue?.issueId) return; mutate( - ISSUE_DETAILS(issueId as string), + ISSUE_DETAILS(peekIssue?.issueId as string), (prevData) => { if (!prevData) return prevData; @@ -85,30 +85,30 @@ const IssueDetailsPage: NextPageWithLayout = observer(() => { ...formData, }; - delete payload.related_issues; - delete payload.issue_relations; + // delete payload.related_issues; + // delete payload.issue_relations; await issueService - .patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload) + .patchIssue(workspaceSlug as string, projectId as string, peekIssue?.issueId as string, payload) .then(() => { mutateIssueDetails(); - mutate(PROJECT_ISSUES_ACTIVITY(issueId as string)); + mutate(PROJECT_ISSUES_ACTIVITY(peekIssue?.issueId as string)); }) .catch((e) => { console.error(e); }); }, - [workspaceSlug, issueId, projectId, mutateIssueDetails] + [workspaceSlug, peekIssue?.issueId, projectId, mutateIssueDetails] ); useEffect(() => { if (!issueDetails) return; - mutate(PROJECT_ISSUES_ACTIVITY(issueId as string)); + mutate(PROJECT_ISSUES_ACTIVITY(peekIssue?.issueId as string)); reset({ ...issueDetails, }); - }, [issueDetails, reset, issueId]); + }, [issueDetails, reset, peekIssue?.issueId]); return ( <> @@ -123,7 +123,7 @@ const IssueDetailsPage: NextPageWithLayout = observer(() => { onClick: () => router.push(`/${workspaceSlug}/projects/${projectId}/issues`), }} /> - ) : issueDetails && projectId && issueId ? ( + ) : issueDetails && projectId && peekIssue?.issueId ? (
diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx index 261ad17cc..16bc2d3ae 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx @@ -18,7 +18,6 @@ import { AppLayout } from "layouts/app-layout"; // components import { GptAssistantPopover } from "components/core"; import { PageDetailsHeader } from "components/headers/page-details"; -import { IssuePeekOverview } from "components/issues/peek-overview"; import { EmptyState } from "components/common"; // ui import { DocumentEditorWithRef, DocumentReadOnlyEditorWithRef } from "@plane/document-editor"; @@ -49,7 +48,7 @@ const PageDetailsPage: NextPageWithLayout = observer(() => { const editorRef = useRef(null); // router const router = useRouter(); - const { workspaceSlug, projectId, pageId, peekIssueId } = router.query; + const { workspaceSlug, projectId, pageId } = router.query; // store hooks const { issues: { updateIssue }, @@ -108,12 +107,6 @@ const PageDetailsPage: NextPageWithLayout = observer(() => { } ); - const handleUpdateIssue = (issueId: string, data: Partial) => { - if (!workspaceSlug || !projectId || !currentUser) return; - - updateIssue(workspaceSlug.toString(), projectId.toString(), issueId, data); - }; - const fetchIssue = async (issueId: string) => { const issue = await issueService.retrieve(workspaceSlug as string, projectId as string, issueId as string); return issue as TIssue; @@ -523,17 +516,6 @@ const PageDetailsPage: NextPageWithLayout = observer(() => { )}
)} - { - if (peekIssueId && typeof peekIssueId === "string") { - handleUpdateIssue(peekIssueId, issueToUpdate); - } - }} - />
) : ( diff --git a/web/store/issue/archived/filter.store.ts b/web/store/issue/archived/filter.store.ts index fc81a9e03..aeb71d7d2 100644 --- a/web/store/issue/archived/filter.store.ts +++ b/web/store/issue/archived/filter.store.ts @@ -158,16 +158,23 @@ export class ArchivedIssuesFilter extends IssueFilterHelperStore implements IArc _filters.displayFilters = { ..._filters.displayFilters, ...updatedDisplayFilters }; // set sub_group_by to null if group_by is set to null - if (_filters.displayFilters.group_by === null) _filters.displayFilters.sub_group_by = null; + if (_filters.displayFilters.group_by === null) { + _filters.displayFilters.sub_group_by = null; + updatedDisplayFilters.sub_group_by = null; + } // set sub_group_by to null if layout is switched to kanban group_by and sub_group_by are same if ( _filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === _filters.displayFilters.sub_group_by - ) + ) { _filters.displayFilters.sub_group_by = null; + updatedDisplayFilters.sub_group_by = null; + } // set group_by to state if layout is switched to kanban and group_by is null - if (_filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === null) + if (_filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === null) { _filters.displayFilters.group_by = "state"; + updatedDisplayFilters.group_by = "state"; + } runInAction(() => { Object.keys(updatedDisplayFilters).forEach((_key) => { diff --git a/web/store/issue/cycle/filter.store.ts b/web/store/issue/cycle/filter.store.ts index 007cb9a2b..fa933c372 100644 --- a/web/store/issue/cycle/filter.store.ts +++ b/web/store/issue/cycle/filter.store.ts @@ -145,16 +145,23 @@ export class CycleIssuesFilter extends IssueFilterHelperStore implements ICycleI _filters.displayFilters = { ..._filters.displayFilters, ...updatedDisplayFilters }; // set sub_group_by to null if group_by is set to null - if (_filters.displayFilters.group_by === null) _filters.displayFilters.sub_group_by = null; + if (_filters.displayFilters.group_by === null) { + _filters.displayFilters.sub_group_by = null; + updatedDisplayFilters.sub_group_by = null; + } // set sub_group_by to null if layout is switched to kanban group_by and sub_group_by are same if ( _filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === _filters.displayFilters.sub_group_by - ) + ) { _filters.displayFilters.sub_group_by = null; + updatedDisplayFilters.sub_group_by = null; + } // set group_by to state if layout is switched to kanban and group_by is null - if (_filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === null) + if (_filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === null) { _filters.displayFilters.group_by = "state"; + updatedDisplayFilters.group_by = "state"; + } runInAction(() => { Object.keys(updatedDisplayFilters).forEach((_key) => { diff --git a/web/store/issue/draft/filter.store.ts b/web/store/issue/draft/filter.store.ts index 8683a3298..3e43eb147 100644 --- a/web/store/issue/draft/filter.store.ts +++ b/web/store/issue/draft/filter.store.ts @@ -142,16 +142,23 @@ export class DraftIssuesFilter extends IssueFilterHelperStore implements IDraftI _filters.displayFilters = { ..._filters.displayFilters, ...updatedDisplayFilters }; // set sub_group_by to null if group_by is set to null - if (_filters.displayFilters.group_by === null) _filters.displayFilters.sub_group_by = null; + if (_filters.displayFilters.group_by === null) { + _filters.displayFilters.sub_group_by = null; + updatedDisplayFilters.sub_group_by = null; + } // set sub_group_by to null if layout is switched to kanban group_by and sub_group_by are same if ( _filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === _filters.displayFilters.sub_group_by - ) + ) { _filters.displayFilters.sub_group_by = null; + updatedDisplayFilters.sub_group_by = null; + } // set group_by to state if layout is switched to kanban and group_by is null - if (_filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === null) + if (_filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === null) { _filters.displayFilters.group_by = "state"; + updatedDisplayFilters.group_by = "state"; + } runInAction(() => { Object.keys(updatedDisplayFilters).forEach((_key) => { diff --git a/web/store/issue/helpers/issue-helper.store.ts b/web/store/issue/helpers/issue-helper.store.ts index 8c148caa8..f54d4d780 100644 --- a/web/store/issue/helpers/issue-helper.store.ts +++ b/web/store/issue/helpers/issue-helper.store.ts @@ -76,7 +76,10 @@ export class IssueHelperStore implements TIssueHelperStore { const state_group = this.rootStore?.stateDetails?.find((_state) => _state.id === _issue?.state_id)?.group || "None"; groupArray = [state_group]; - } else groupArray = this.getGroupArray(get(_issue, ISSUE_FILTER_DEFAULT_DATA[groupBy]), isCalendarIssues); + } else { + const groupValue = get(_issue, ISSUE_FILTER_DEFAULT_DATA[groupBy]); + groupArray = groupValue !== undefined ? this.getGroupArray(groupValue, isCalendarIssues) : []; + } for (const group of groupArray) { if (group && _issues[group]) _issues[group].push(_issue.id); @@ -116,8 +119,10 @@ export class IssueHelperStore implements TIssueHelperStore { subGroupArray = [state_group]; groupArray = [state_group]; } else { - subGroupArray = this.getGroupArray(get(_issue, ISSUE_FILTER_DEFAULT_DATA[subGroupBy])); - groupArray = this.getGroupArray(get(_issue, ISSUE_FILTER_DEFAULT_DATA[groupBy])); + const subGroupValue = get(_issue, ISSUE_FILTER_DEFAULT_DATA[subGroupBy]); + const groupValue = get(_issue, ISSUE_FILTER_DEFAULT_DATA[groupBy]); + subGroupArray = subGroupValue != undefined ? this.getGroupArray(subGroupValue) : []; + groupArray = groupValue != undefined ? this.getGroupArray(groupValue) : []; } for (const subGroup of subGroupArray) { diff --git a/web/store/issue/issue-details/activity.store.ts b/web/store/issue/issue-details/activity.store.ts index caa1eb0a3..168a3f540 100644 --- a/web/store/issue/issue-details/activity.store.ts +++ b/web/store/issue/issue-details/activity.store.ts @@ -49,7 +49,7 @@ export class IssueActivityStore implements IIssueActivityStore { // computed get issueActivities() { - const issueId = this.rootIssueDetailStore.issueId; + const issueId = this.rootIssueDetailStore.peekIssue?.issueId; if (!issueId) return undefined; return this.activities[issueId] ?? undefined; } diff --git a/web/store/issue/issue-details/attachment.store.ts b/web/store/issue/issue-details/attachment.store.ts index fab3e3a83..c21c2ac1d 100644 --- a/web/store/issue/issue-details/attachment.store.ts +++ b/web/store/issue/issue-details/attachment.store.ts @@ -62,7 +62,7 @@ export class IssueAttachmentStore implements IIssueAttachmentStore { // computed get issueAttachments() { - const issueId = this.rootIssueDetailStore.issueId; + const issueId = this.rootIssueDetailStore.peekIssue?.issueId; if (!issueId) return undefined; return this.attachments[issueId] ?? undefined; } diff --git a/web/store/issue/issue-details/link.store.ts b/web/store/issue/issue-details/link.store.ts index 50072472e..a77ee7417 100644 --- a/web/store/issue/issue-details/link.store.ts +++ b/web/store/issue/issue-details/link.store.ts @@ -60,7 +60,7 @@ export class IssueLinkStore implements IIssueLinkStore { // computed get issueLinks() { - const issueId = this.rootIssueDetailStore.issueId; + const issueId = this.rootIssueDetailStore.peekIssue?.issueId; if (!issueId) return undefined; return this.links[issueId] ?? undefined; } diff --git a/web/store/issue/issue-details/reaction.store.ts b/web/store/issue/issue-details/reaction.store.ts index 217b5c447..bac47ccde 100644 --- a/web/store/issue/issue-details/reaction.store.ts +++ b/web/store/issue/issue-details/reaction.store.ts @@ -53,7 +53,7 @@ export class IssueReactionStore implements IIssueReactionStore { // computed get issueReactions() { - const issueId = this.rootIssueDetailStore.issueId; + const issueId = this.rootIssueDetailStore.peekIssue?.issueId; if (!issueId) return undefined; return this.reactions[issueId] ?? undefined; } diff --git a/web/store/issue/issue-details/relation.store.ts b/web/store/issue/issue-details/relation.store.ts index bb3a28878..f9e0ac5f0 100644 --- a/web/store/issue/issue-details/relation.store.ts +++ b/web/store/issue/issue-details/relation.store.ts @@ -68,7 +68,7 @@ export class IssueRelationStore implements IIssueRelationStore { // computed get issueRelations() { - const issueId = this.rootIssueDetailStore.issueId; + const issueId = this.rootIssueDetailStore.peekIssue?.issueId; if (!issueId) return undefined; return this.relationMap?.[issueId] ?? undefined; } diff --git a/web/store/issue/issue-details/root.store.ts b/web/store/issue/issue-details/root.store.ts index c4f5be09a..67aa4b46f 100644 --- a/web/store/issue/issue-details/root.store.ts +++ b/web/store/issue/issue-details/root.store.ts @@ -18,6 +18,12 @@ import { IIssueRelationStore, IssueRelationStore, IIssueRelationStoreActions } f import { TIssue, IIssueActivity, TIssueLink, TIssueRelationTypes } from "@plane/types"; +export type TPeekIssue = { + workspaceSlug: string; + projectId: string; + issueId: string; +}; + export interface IIssueDetail extends IIssueStoreActions, IIssueReactionStoreActions, @@ -30,14 +36,14 @@ export interface IIssueDetail IIssueAttachmentStoreActions, IIssueRelationStoreActions { // observables - issueId: string | undefined; + peekIssue: TPeekIssue | undefined; isIssueLinkModalOpen: boolean; isParentIssueModalOpen: boolean; isDeleteIssueModalOpen: boolean; // computed isAnyModalOpen: boolean; // actions - setIssueId: (issueId: string | undefined) => void; + setPeekIssue: (peekIssue: TPeekIssue | undefined) => void; toggleIssueLinkModal: (value: boolean) => void; toggleParentIssueModal: (value: boolean) => void; toggleDeleteIssueModal: (value: boolean) => void; @@ -57,7 +63,7 @@ export interface IIssueDetail export class IssueDetail implements IIssueDetail { // observables - issueId: string | undefined = undefined; + peekIssue: TPeekIssue | undefined = undefined; isIssueLinkModalOpen: boolean = false; isParentIssueModalOpen: boolean = false; isDeleteIssueModalOpen: boolean = false; @@ -77,14 +83,14 @@ export class IssueDetail implements IIssueDetail { constructor(rootStore: IIssueRootStore) { makeObservable(this, { // observables - issueId: observable.ref, + peekIssue: observable, isIssueLinkModalOpen: observable.ref, isParentIssueModalOpen: observable.ref, isDeleteIssueModalOpen: observable.ref, // computed isAnyModalOpen: computed, // action - setIssueId: action, + setPeekIssue: action, toggleIssueLinkModal: action, toggleParentIssueModal: action, toggleDeleteIssueModal: action, @@ -110,16 +116,14 @@ export class IssueDetail implements IIssueDetail { } // actions - setIssueId = (issueId: string | undefined) => (this.issueId = issueId); + setPeekIssue = (peekIssue: TPeekIssue | undefined) => (this.peekIssue = peekIssue); toggleIssueLinkModal = (value: boolean) => (this.isIssueLinkModalOpen = value); toggleParentIssueModal = (value: boolean) => (this.isParentIssueModalOpen = value); toggleDeleteIssueModal = (value: boolean) => (this.isDeleteIssueModalOpen = value); // issue - fetchIssue = async (workspaceSlug: string, projectId: string, issueId: string) => { - this.issueId = issueId; - return this.issue.fetchIssue(workspaceSlug, projectId, issueId); - }; + fetchIssue = async (workspaceSlug: string, projectId: string, issueId: string) => + this.issue.fetchIssue(workspaceSlug, projectId, issueId); updateIssue = async (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => this.issue.updateIssue(workspaceSlug, projectId, issueId, data); removeIssue = async (workspaceSlug: string, projectId: string, issueId: string) => diff --git a/web/store/issue/issue-details/subscription.store.ts b/web/store/issue/issue-details/subscription.store.ts index ce71bf446..02f863cbe 100644 --- a/web/store/issue/issue-details/subscription.store.ts +++ b/web/store/issue/issue-details/subscription.store.ts @@ -46,7 +46,7 @@ export class IssueSubscriptionStore implements IIssueSubscriptionStore { if (!issueId) return undefined; const currentUserId = this.rootIssueDetail.rootIssueStore.currentUserId; if (!currentUserId) return undefined; - return this.subscriptionMap[issueId][currentUserId] ?? undefined; + return this.subscriptionMap[issueId]?.[currentUserId] ?? undefined; }; fetchSubscriptions = async (workspaceSlug: string, projectId: string, issueId: string) => { diff --git a/web/store/issue/issue_detail.store.ts b/web/store/issue/issue_detail.store.ts deleted file mode 100644 index 36e2c1b9d..000000000 --- a/web/store/issue/issue_detail.store.ts +++ /dev/null @@ -1,757 +0,0 @@ -import { observable, action, makeObservable, runInAction, computed, autorun } from "mobx"; -// services -import { IssueService, IssueReactionService, IssueCommentService } from "services/issue"; -import { NotificationService } from "services/notification.service"; -// types -import { IIssueRootStore } from "./root.store"; -import type { TIssue, IIssueActivity, IIssueLink, ILinkDetails } from "@plane/types"; -// constants -import { groupReactionEmojis } from "constants/issue"; -import { RootStore } from "store/root.store"; - -export interface IIssueDetailStore { - loader: boolean; - error: any | null; - - peekId: string | null; - issues: { - [issueId: string]: TIssue; - }; - issueReactions: { - [issueId: string]: any; - }; - issueActivity: { - [issueId: string]: IIssueActivity[]; - }; - issueCommentReactions: { - [issueId: string]: { - [comment_id: string]: any; - }; - }; - issueSubscription: { - [issueId: string]: any; - }; - - setPeekId: (issueId: string | null) => void; - - // computed - getIssue: TIssue | null; - getIssueReactions: any | null; - getIssueActivity: IIssueActivity[] | null; - getIssueCommentReactions: any | null; - getIssueSubscription: any | null; - - // fetch issue details - fetchIssueDetails: (workspaceSlug: string, projectId: string, issueId: string) => Promise; - // deleting issue - deleteIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise; - - fetchPeekIssueDetails: (workspaceSlug: string, projectId: string, issueId: string) => Promise; - - fetchIssueReactions: (workspaceSlug: string, projectId: string, issueId: string) => Promise; - createIssueReaction: (workspaceSlug: string, projectId: string, issueId: string, reaction: string) => Promise; - removeIssueReaction: (workspaceSlug: string, projectId: string, issueId: string, reaction: string) => Promise; - - createIssueLink: ( - workspaceSlug: string, - projectId: string, - issueId: string, - data: IIssueLink - ) => Promise; - updateIssueLink: ( - workspaceSlug: string, - projectId: string, - issueId: string, - linkId: string, - data: IIssueLink - ) => Promise; - deleteIssueLink: (workspaceSlug: string, projectId: string, issueId: string, linkId: string) => Promise; - - fetchIssueActivity: (workspaceSlug: string, projectId: string, issueId: string) => Promise; - createIssueComment: (workspaceSlug: string, projectId: string, issueId: string, data: any) => Promise; - updateIssueComment: ( - workspaceSlug: string, - projectId: string, - issueId: string, - commentId: string, - data: any - ) => Promise; - removeIssueComment: (workspaceSlug: string, projectId: string, issueId: string, commentId: string) => Promise; - - fetchIssueCommentReactions: ( - workspaceSlug: string, - projectId: string, - issueId: string, - commentId: string - ) => Promise; - creationIssueCommentReaction: ( - workspaceSlug: string, - projectId: string, - issueId: string, - commentId: string, - reaction: string - ) => Promise; - removeIssueCommentReaction: ( - workspaceSlug: string, - projectId: string, - issueId: string, - commentId: string, - reaction: string - ) => Promise; - - fetchIssueSubscription: (workspaceSlug: string, projectId: string, issueId: string) => Promise; - createIssueSubscription: (workspaceSlug: string, projectId: string, issueId: string) => Promise; - removeIssueSubscription: (workspaceSlug: string, projectId: string, issueId: string) => Promise; -} - -export class IssueDetailStore implements IIssueDetailStore { - loader: boolean = false; - error: any | null = null; - - peekId: string | null = null; - issues: { - [issueId: string]: TIssue; - } = {}; - issueReactions: { - [issueId: string]: any; - } = {}; - issueActivity: { - [issueId: string]: IIssueActivity[]; - } = {}; - issueCommentReactions: { - [issueId: string]: any; - } = {}; - issueSubscription: { - [issueId: string]: any; - } = {}; - - // root store - issueRootStore; - rootStore; - // service - issueService; - issueReactionService; - issueCommentService; - notificationService; - - constructor(_issueRootStore: IIssueRootStore, _rootStore: RootStore) { - makeObservable(this, { - // observable - loader: observable.ref, - error: observable.ref, - - peekId: observable.ref, - issues: observable.ref, - issueReactions: observable.ref, - issueActivity: observable.ref, - issueCommentReactions: observable.ref, - issueSubscription: observable.ref, - - getIssue: computed, - getIssueReactions: computed, - getIssueActivity: computed, - getIssueCommentReactions: computed, - getIssueSubscription: computed, - - setPeekId: action, - - fetchIssueDetails: action, - deleteIssue: action, - - fetchPeekIssueDetails: action, - - fetchIssueReactions: action, - createIssueReaction: action, - removeIssueReaction: action, - - createIssueLink: action, - updateIssueLink: action, - deleteIssueLink: action, - - fetchIssueActivity: action, - createIssueComment: action, - updateIssueComment: action, - removeIssueComment: action, - - fetchIssueCommentReactions: action, - creationIssueCommentReaction: action, - removeIssueCommentReaction: action, - - fetchIssueSubscription: action, - createIssueSubscription: action, - removeIssueSubscription: action, - }); - - this.issueRootStore = _issueRootStore; - this.rootStore = _rootStore; - this.issueService = new IssueService(); - this.issueReactionService = new IssueReactionService(); - this.issueCommentService = new IssueCommentService(); - this.notificationService = new NotificationService(); - - autorun(() => { - const projectId = this.rootStore?.app.router.projectId; - const peekId = this.peekId; - - if (!projectId || !peekId) return; - - // FIXME: uncomment and fix - // const issue = this.issueRootStore.projectIssues.issues?.[projectId]?.[peekId]; - - // if (issue && issue.id) - // runInAction(() => { - // this.issues = { - // ...this.issues, - // [issue.id]: { - // ...this.issues[issue.id], - // ...issue, - // }, - // }; - // }); - }); - } - - get getIssue() { - if (!this.peekId) return null; - const _issue = this.issues[this.peekId]; - return _issue || null; - } - - get getIssueReactions() { - if (!this.peekId) return null; - const _reactions = this.issueReactions[this.peekId]; - return _reactions || null; - } - - get getIssueActivity() { - if (!this.peekId) return null; - const activity = this.issueActivity[this.peekId]; - return activity || null; - } - - get getIssueCommentReactions() { - if (!this.peekId) return null; - const _commentReactions = this.issueCommentReactions[this.peekId]; - return _commentReactions || null; - } - - get getIssueSubscription() { - if (!this.peekId) return null; - const _commentSubscription = this.issueSubscription[this.peekId]; - return _commentSubscription || null; - } - - setPeekId = (issueId: string | null) => (this.peekId = issueId); - - fetchIssueDetails = async (workspaceSlug: string, projectId: string, issueId: string) => { - try { - this.loader = true; - this.error = null; - this.peekId = issueId; - - const issueDetailsResponse = await this.issueService.retrieve(workspaceSlug, projectId, issueId); - - runInAction(() => { - this.loader = false; - this.error = null; - this.issues = { - ...this.issues, - [issueId]: issueDetailsResponse, - }; - }); - - return issueDetailsResponse; - } catch (error) { - runInAction(() => { - this.loader = false; - this.error = error; - }); - - throw error; - } - }; - - deleteIssue = async (workspaceSlug: string, projectId: string, issueId: string) => { - const newIssues = { ...this.issues }; - delete newIssues[issueId]; - - try { - runInAction(() => { - this.loader = true; - this.error = null; - this.issues = newIssues; - }); - - const user = this.rootStore.user.currentUser; - - if (!user) return; - - const response = await this.issueService.deleteIssue(workspaceSlug, projectId, issueId); - - runInAction(() => { - this.loader = false; - this.error = null; - }); - - return response; - } catch (error) { - this.fetchIssueDetails(workspaceSlug, projectId, issueId); - - runInAction(() => { - this.loader = false; - this.error = error; - }); - - return error; - } - }; - - fetchPeekIssueDetails = async (workspaceSlug: string, projectId: string, issueId: string) => { - try { - this.loader = true; - this.error = null; - - this.peekId = issueId; - - const issueDetailsResponse = await this.issueService.retrieve(workspaceSlug, projectId, issueId); - await this.fetchIssueReactions(workspaceSlug, projectId, issueId); - await this.fetchIssueActivity(workspaceSlug, projectId, issueId); - - runInAction(() => { - this.loader = false; - this.error = null; - this.issues = { - ...this.issues, - [issueId]: issueDetailsResponse, - }; - }); - - return issueDetailsResponse; - } catch (error) { - runInAction(() => { - this.loader = false; - this.error = error; - }); - - throw error; - } - }; - - // reactions - fetchIssueReactions = async (workspaceSlug: string, projectId: string, issueId: string) => { - try { - const _reactions = await this.issueReactionService.listIssueReactions(workspaceSlug, projectId, issueId); - - const _issueReactions = { - ...this.issueReactions, - [issueId]: groupReactionEmojis(_reactions), - }; - - runInAction(() => { - this.issueReactions = _issueReactions; - }); - } catch (error) { - console.warn("error creating the issue reaction", error); - throw error; - } - }; - createIssueReaction = async (workspaceSlug: string, projectId: string, issueId: string, reaction: string) => { - let _currentReactions = this.getIssueReactions; - - try { - const _reaction = await this.issueReactionService.createIssueReaction(workspaceSlug, projectId, issueId, { - reaction, - }); - - _currentReactions = { - ..._currentReactions, - [reaction]: [..._currentReactions[reaction], { ..._reaction }], - }; - - runInAction(() => { - this.issueReactions = { - ...this.issueReactions, - [issueId]: _currentReactions, - }; - }); - } catch (error) { - runInAction(() => { - this.issueReactions = { - ...this.issueReactions, - [issueId]: _currentReactions, - }; - }); - console.warn("error creating the issue reaction", error); - throw error; - } - }; - removeIssueReaction = async (workspaceSlug: string, projectId: string, issueId: string, reaction: string) => { - let _currentReactions = this.getIssueReactions; - - try { - const user = this.rootStore.user.currentUser; - - if (user) { - _currentReactions = { - ..._currentReactions, - [reaction]: [..._currentReactions[reaction].filter((r: any) => r.actor !== user.id)], - }; - - runInAction(() => { - this.issueReactions = { - ...this.issueReactions, - [issueId]: _currentReactions, - }; - }); - - await this.issueReactionService.deleteIssueReaction(workspaceSlug, projectId, issueId, reaction); - } - } catch (error) { - runInAction(() => { - this.issueReactions = { - ...this.issueReactions, - [issueId]: _currentReactions, - }; - }); - console.warn("error removing the issue reaction", error); - throw error; - } - }; - - fetchIssueActivity = async (workspaceSlug: string, projectId: string, issueId: string) => { - try { - const issueActivityResponse = await this.issueService.getIssueActivities(workspaceSlug, projectId, issueId); - - const _issueComments = { - ...this.issueActivity, - [issueId]: [...issueActivityResponse], - }; - - runInAction(() => { - this.issueActivity = _issueComments; - }); - } catch (error) { - console.warn("error creating the issue comment", error); - throw error; - } - }; - - // comments - createIssueComment = async (workspaceSlug: string, projectId: string, issueId: string, data: any) => { - try { - const _issueCommentResponse = await this.issueCommentService.createIssueComment( - workspaceSlug, - projectId, - issueId, - data - ); - - const _issueComments = { - ...this.issueActivity, - [issueId]: [...this.issueActivity[issueId], _issueCommentResponse], - }; - - runInAction(() => { - this.issueActivity = _issueComments; - }); - } catch (error) { - console.warn("error creating the issue comment", error); - throw error; - } - }; - - updateIssueComment = async ( - workspaceSlug: string, - projectId: string, - issueId: string, - commentId: string, - data: any - ) => { - try { - const _issueCommentResponse = await this.issueCommentService.patchIssueComment( - workspaceSlug, - projectId, - issueId, - commentId, - data - ); - - const _issueComments = { - ...this.issueActivity, - [issueId]: this.issueActivity[issueId].map((comment) => - comment.id === commentId ? _issueCommentResponse : comment - ), - }; - - runInAction(() => { - this.issueActivity = _issueComments; - }); - } catch (error) { - console.warn("error updating the issue comment", error); - throw error; - } - }; - - removeIssueComment = async (workspaceSlug: string, projectId: string, issueId: string, commentId: string) => { - try { - const _issueComments = { - ...this.issueActivity, - [issueId]: this.issueActivity[issueId]?.filter((comment) => comment.id != commentId), - }; - - await this.issueCommentService.deleteIssueComment(workspaceSlug, projectId, issueId, commentId); - - runInAction(() => { - this.issueActivity = _issueComments; - }); - } catch (error) { - console.warn("error removing the issue comment", error); - throw error; - } - }; - - // comment reactions - fetchIssueCommentReactions = async (workspaceSlug: string, projectId: string, issueId: string, commentId: string) => { - try { - const _reactions = await this.issueReactionService.listIssueCommentReactions(workspaceSlug, projectId, commentId); - - const _issueCommentReactions = { - ...this.issueCommentReactions, - [issueId]: { - ...this.issueCommentReactions[issueId], - [commentId]: groupReactionEmojis(_reactions), - }, - }; - - runInAction(() => { - this.issueCommentReactions = _issueCommentReactions; - }); - } catch (error) { - console.warn("error removing the issue comment", error); - throw error; - } - }; - - creationIssueCommentReaction = async ( - workspaceSlug: string, - projectId: string, - issueId: string, - commentId: string, - reaction: string - ) => { - let _currentReactions = this.getIssueCommentReactions; - _currentReactions = _currentReactions && commentId ? _currentReactions?.[commentId] : null; - - try { - const _reaction = await this.issueReactionService.createIssueCommentReaction( - workspaceSlug, - projectId, - commentId, - { - reaction, - } - ); - - _currentReactions = { - ..._currentReactions, - [reaction]: [..._currentReactions?.[reaction], { ..._reaction }], - }; - - const _issueCommentReactions = { - ...this.issueCommentReactions, - [issueId]: { - ...this.issueCommentReactions[issueId], - [commentId]: _currentReactions, - }, - }; - - runInAction(() => { - this.issueCommentReactions = _issueCommentReactions; - }); - } catch (error) { - console.warn("error removing the issue comment", error); - throw error; - } - }; - - removeIssueCommentReaction = async ( - workspaceSlug: string, - projectId: string, - issueId: string, - commentId: string, - reaction: string - ) => { - let _currentReactions = this.getIssueCommentReactions; - _currentReactions = _currentReactions && commentId ? _currentReactions?.[commentId] : null; - - try { - const user = this.rootStore.user.currentUser; - - if (user) { - _currentReactions = { - ..._currentReactions, - [reaction]: [..._currentReactions?.[reaction].filter((r: any) => r.actor !== user.id)], - }; - - const _issueCommentReactions = { - ...this.issueCommentReactions, - [issueId]: { - ...this.issueCommentReactions[issueId], - [commentId]: _currentReactions, - }, - }; - - runInAction(() => { - this.issueCommentReactions = _issueCommentReactions; - }); - - await this.issueReactionService.deleteIssueCommentReaction(workspaceSlug, projectId, commentId, reaction); - } - } catch (error) { - console.warn("error removing the issue comment", error); - throw error; - } - }; - - createIssueLink = async (workspaceSlug: string, projectId: string, issueId: string, data: IIssueLink) => { - try { - const response = await this.issueService.createIssueLink(workspaceSlug, projectId, issueId, data); - - runInAction(() => { - this.issues = { - ...this.issues, - [issueId]: { - ...this.issues[issueId], - issue_link: [response, ...this.issues[issueId].issue_link], - }, - }; - }); - - return response; - } catch (error) { - console.error("Failed to create link in store", error); - - this.fetchIssueDetails(workspaceSlug, projectId, issueId); - - runInAction(() => { - this.error = error; - }); - - throw error; - } - }; - - updateIssueLink = async ( - workspaceSlug: string, - projectId: string, - issueId: string, - linkId: string, - data: IIssueLink - ) => { - try { - const response = await this.issueService.updateIssueLink(workspaceSlug, projectId, issueId, linkId, data); - - runInAction(() => { - this.issues = { - ...this.issues, - [issueId]: { - ...this.issues[issueId], - issue_link: this.issues[issueId].issue_link.map((link: any) => (link.id === linkId ? response : link)), - }, - }; - }); - - return response; - } catch (error) { - console.error("Failed to update link in issue store", error); - - this.fetchIssueDetails(workspaceSlug, projectId, issueId); - - runInAction(() => { - this.error = error; - }); - - throw error; - } - }; - - deleteIssueLink = async (workspaceSlug: string, projectId: string, issueId: string, linkId: string) => { - try { - runInAction(() => { - this.issues = { - ...this.issues, - [issueId]: { - ...this.issues[issueId], - issue_link: this.issues[issueId].issue_link.filter((link: any) => link.id !== linkId), - }, - }; - }); - await this.issueService.deleteIssueLink(workspaceSlug, projectId, issueId, linkId); - } catch (error) { - console.error("Failed to delete link in issue store", error); - - runInAction(() => { - this.error = error; - }); - - throw error; - } - }; - - // subscriptions - fetchIssueSubscription = async (workspaceSlug: string, projectId: string, issueId: string) => { - try { - const _subscription = await this.notificationService.getIssueNotificationSubscriptionStatus( - workspaceSlug, - projectId, - issueId - ); - - const _issueSubscription = { - ...this.issueSubscription, - [issueId]: _subscription, - }; - - runInAction(() => { - this.issueSubscription = _issueSubscription; - }); - } catch (error) { - console.warn("error fetching the issue subscription", error); - throw error; - } - }; - createIssueSubscription = async (workspaceSlug: string, projectId: string, issueId: string) => { - try { - await this.notificationService.subscribeToIssueNotifications(workspaceSlug, projectId, issueId); - - const _issueSubscription = { - ...this.issueSubscription, - [issueId]: { subscribed: true }, - }; - - runInAction(() => { - this.issueSubscription = _issueSubscription; - }); - } catch (error) { - console.warn("error creating the issue subscription", error); - throw error; - } - }; - removeIssueSubscription = async (workspaceSlug: string, projectId: string, issueId: string) => { - try { - const _issueSubscription = { - ...this.issueSubscription, - [issueId]: { subscribed: false }, - }; - - runInAction(() => { - this.issueSubscription = _issueSubscription; - }); - - await this.notificationService.unsubscribeFromIssueNotifications(workspaceSlug, projectId, issueId); - } catch (error) { - console.warn("error removing the issue subscription", error); - throw error; - } - }; -} diff --git a/web/store/issue/issue_kanban_view.store.ts b/web/store/issue/issue_kanban_view.store.ts index 5d0b13d22..3664ad22d 100644 --- a/web/store/issue/issue_kanban_view.store.ts +++ b/web/store/issue/issue_kanban_view.store.ts @@ -40,7 +40,7 @@ export class IssueKanBanViewStore implements IIssueKanBanViewStore { get canUserDragDrop() { return true; - if (this.rootStore.issueDetail.issueId) return false; + if (this.rootStore.issueDetail.peekIssue?.issueId) return false; // FIXME: uncomment and fix // if ( // this.rootStore?.issueFilter?.userDisplayFilters?.order_by && diff --git a/web/store/issue/module/filter.store.ts b/web/store/issue/module/filter.store.ts index ab7d98b3f..7819ad6e0 100644 --- a/web/store/issue/module/filter.store.ts +++ b/web/store/issue/module/filter.store.ts @@ -145,16 +145,23 @@ export class ModuleIssuesFilter extends IssueFilterHelperStore implements IModul _filters.displayFilters = { ..._filters.displayFilters, ...updatedDisplayFilters }; // set sub_group_by to null if group_by is set to null - if (_filters.displayFilters.group_by === null) _filters.displayFilters.sub_group_by = null; + if (_filters.displayFilters.group_by === null) { + _filters.displayFilters.sub_group_by = null; + updatedDisplayFilters.sub_group_by = null; + } // set sub_group_by to null if layout is switched to kanban group_by and sub_group_by are same if ( _filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === _filters.displayFilters.sub_group_by - ) + ) { _filters.displayFilters.sub_group_by = null; + updatedDisplayFilters.sub_group_by = null; + } // set group_by to state if layout is switched to kanban and group_by is null - if (_filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === null) + if (_filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === null) { _filters.displayFilters.group_by = "state"; + updatedDisplayFilters.group_by = "state"; + } runInAction(() => { Object.keys(updatedDisplayFilters).forEach((_key) => { diff --git a/web/store/issue/profile/filter.store.ts b/web/store/issue/profile/filter.store.ts index b1c7567d5..e14b7179d 100644 --- a/web/store/issue/profile/filter.store.ts +++ b/web/store/issue/profile/filter.store.ts @@ -150,16 +150,23 @@ export class ProfileIssuesFilter extends IssueFilterHelperStore implements IProf _filters.displayFilters = { ..._filters.displayFilters, ...updatedDisplayFilters }; // set sub_group_by to null if group_by is set to null - if (_filters.displayFilters.group_by === null) _filters.displayFilters.sub_group_by = null; + if (_filters.displayFilters.group_by === null) { + _filters.displayFilters.sub_group_by = null; + updatedDisplayFilters.sub_group_by = null; + } // set sub_group_by to null if layout is switched to kanban group_by and sub_group_by are same if ( _filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === _filters.displayFilters.sub_group_by - ) + ) { _filters.displayFilters.sub_group_by = null; + updatedDisplayFilters.sub_group_by = null; + } // set group_by to state if layout is switched to kanban and group_by is null - if (_filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === null) + if (_filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === null) { _filters.displayFilters.group_by = "state"; + updatedDisplayFilters.group_by = "state"; + } runInAction(() => { Object.keys(updatedDisplayFilters).forEach((_key) => { diff --git a/web/store/issue/project-views/filter.store.ts b/web/store/issue/project-views/filter.store.ts index a162d5dd5..5d0ec332b 100644 --- a/web/store/issue/project-views/filter.store.ts +++ b/web/store/issue/project-views/filter.store.ts @@ -146,16 +146,23 @@ export class ProjectViewIssuesFilter extends IssueFilterHelperStore implements I _filters.displayFilters = { ..._filters.displayFilters, ...updatedDisplayFilters }; // set sub_group_by to null if group_by is set to null - if (_filters.displayFilters.group_by === null) _filters.displayFilters.sub_group_by = null; + if (_filters.displayFilters.group_by === null) { + _filters.displayFilters.sub_group_by = null; + updatedDisplayFilters.sub_group_by = null; + } // set sub_group_by to null if layout is switched to kanban group_by and sub_group_by are same if ( _filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === _filters.displayFilters.sub_group_by - ) + ) { _filters.displayFilters.sub_group_by = null; + updatedDisplayFilters.sub_group_by = null; + } // set group_by to state if layout is switched to kanban and group_by is null - if (_filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === null) + if (_filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === null) { _filters.displayFilters.group_by = "state"; + updatedDisplayFilters.group_by = "state"; + } runInAction(() => { Object.keys(updatedDisplayFilters).forEach((_key) => { diff --git a/web/store/issue/project/filter.store.ts b/web/store/issue/project/filter.store.ts index e1e8aa8bd..2b47e4187 100644 --- a/web/store/issue/project/filter.store.ts +++ b/web/store/issue/project/filter.store.ts @@ -142,16 +142,23 @@ export class ProjectIssuesFilter extends IssueFilterHelperStore implements IProj _filters.displayFilters = { ..._filters.displayFilters, ...updatedDisplayFilters }; // set sub_group_by to null if group_by is set to null - if (_filters.displayFilters.group_by === null) _filters.displayFilters.sub_group_by = null; + if (_filters.displayFilters.group_by === null) { + _filters.displayFilters.sub_group_by = null; + updatedDisplayFilters.sub_group_by = null; + } // set sub_group_by to null if layout is switched to kanban group_by and sub_group_by are same if ( _filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === _filters.displayFilters.sub_group_by - ) + ) { _filters.displayFilters.sub_group_by = null; + updatedDisplayFilters.sub_group_by = null; + } // set group_by to state if layout is switched to kanban and group_by is null - if (_filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === null) + if (_filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === null) { _filters.displayFilters.group_by = "state"; + updatedDisplayFilters.group_by = "state"; + } runInAction(() => { Object.keys(updatedDisplayFilters).forEach((_key) => { diff --git a/web/store/issue/workspace/filter.store.ts b/web/store/issue/workspace/filter.store.ts index 4fbf84050..3f6aa97b9 100644 --- a/web/store/issue/workspace/filter.store.ts +++ b/web/store/issue/workspace/filter.store.ts @@ -157,16 +157,23 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo _filters.displayFilters = { ..._filters.displayFilters, ...updatedDisplayFilters }; // set sub_group_by to null if group_by is set to null - if (_filters.displayFilters.group_by === null) _filters.displayFilters.sub_group_by = null; + if (_filters.displayFilters.group_by === null) { + _filters.displayFilters.sub_group_by = null; + updatedDisplayFilters.sub_group_by = null; + } // set sub_group_by to null if layout is switched to kanban group_by and sub_group_by are same if ( _filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === _filters.displayFilters.sub_group_by - ) + ) { _filters.displayFilters.sub_group_by = null; + updatedDisplayFilters.sub_group_by = null; + } // set group_by to state if layout is switched to kanban and group_by is null - if (_filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === null) + if (_filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === null) { _filters.displayFilters.group_by = "state"; + updatedDisplayFilters.group_by = "state"; + } runInAction(() => { Object.keys(updatedDisplayFilters).forEach((_key) => { From 0a05aef046ba02788072c0d41b3e918b51abac2f Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Fri, 5 Jan 2024 23:42:52 +0530 Subject: [PATCH 10/68] fix: workspace invitations response updated (#3321) --- packages/types/src/workspace.d.ts | 10 +++++++--- web/components/onboarding/invitations.tsx | 2 +- web/pages/invitations/index.tsx | 12 ++++++------ web/pages/workspace-invitations/index.tsx | 8 ++++---- 4 files changed, 18 insertions(+), 14 deletions(-) diff --git a/packages/types/src/workspace.d.ts b/packages/types/src/workspace.d.ts index 2fc8d6912..2d7e94d95 100644 --- a/packages/types/src/workspace.d.ts +++ b/packages/types/src/workspace.d.ts @@ -1,5 +1,10 @@ import { EUserWorkspaceRoles } from "constants/workspace"; -import type { IProjectMember, IUser, IUserLite, IWorkspaceViewProps } from "@plane/types"; +import type { + IProjectMember, + IUser, + IUserLite, + IWorkspaceViewProps, +} from "@plane/types"; export interface IWorkspace { readonly id: string; @@ -32,8 +37,7 @@ export interface IWorkspaceMemberInvitation { responded_at: Date; role: EUserWorkspaceRoles; token: string; - workspace: string; - workspace_detail: { + workspace: { id: string; logo: string; name: string; diff --git a/web/components/onboarding/invitations.tsx b/web/components/onboarding/invitations.tsx index 65cf3d7c7..f4f23373d 100644 --- a/web/components/onboarding/invitations.tsx +++ b/web/components/onboarding/invitations.tsx @@ -86,7 +86,7 @@ export const Invitations: React.FC = (props) => { invitations.length > 0 && invitations.map((invitation) => { const isSelected = invitationsRespond.includes(invitation.id); - const invitedWorkspace = workspaces[invitation.workspace]; + const invitedWorkspace = workspaces[invitation.workspace.id]; return (
{ .then((res) => { mutate("USER_WORKSPACES"); const firstInviteId = invitationsRespond[0]; - const redirectWorkspace = invitations?.find((i) => i.id === firstInviteId)?.workspace_detail; + const redirectWorkspace = invitations?.find((i) => i.id === firstInviteId)?.workspace; postHogEventTracker("MEMBER_ACCEPTED", { ...res, state: "SUCCESS", @@ -153,23 +153,23 @@ const UserInvitationsPage: NextPageWithLayout = observer(() => { >
- {invitation.workspace_detail.logo && invitation.workspace_detail.logo.trim() !== "" ? ( + {invitation.workspace.logo && invitation.workspace.logo.trim() !== "" ? ( {invitation.workspace_detail.name} ) : ( - {invitation.workspace_detail.name[0]} + {invitation.workspace.name[0]} )}
-
{truncateText(invitation.workspace_detail.name, 30)}
+
{truncateText(invitation.workspace.name, 30)}

{ROLE[invitation.role]}

{ const handleAccept = () => { if (!invitationDetail) return; workspaceService - .joinWorkspace(invitationDetail.workspace_detail.slug, invitationDetail.id, { + .joinWorkspace(invitationDetail.workspace.slug, invitationDetail.id, { accepted: true, email: invitationDetail.email, }) @@ -55,7 +55,7 @@ const WorkspaceInvitationPage: NextPageWithLayout = observer(() => { const handleReject = () => { if (!invitationDetail) return; workspaceService - .joinWorkspace(invitationDetail.workspace_detail.slug, invitationDetail.id, { + .joinWorkspace(invitationDetail.workspace.slug, invitationDetail.id, { accepted: false, email: invitationDetail.email, }) @@ -78,7 +78,7 @@ const WorkspaceInvitationPage: NextPageWithLayout = observer(() => { {invitationDetail.accepted ? ( <> router.push("/")} /> @@ -86,7 +86,7 @@ const WorkspaceInvitationPage: NextPageWithLayout = observer(() => { ) : ( From b62a1b11b1d577b2a04cb1e547b8e62dc80c8325 Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Sun, 7 Jan 2024 12:05:52 +0530 Subject: [PATCH 11/68] fix: pages store structure changes --- .../issues/issue-layouts/kanban/default.tsx | 5 +- web/hooks/store/use-project-page.ts | 9 + web/store/page.store.ts | 367 ++++-------------- web/store/project-page.store.ts | 136 +++++++ web/store/root.store.ts | 6 +- 5 files changed, 237 insertions(+), 286 deletions(-) create mode 100644 web/hooks/store/use-project-page.ts create mode 100644 web/store/project-page.store.ts diff --git a/web/components/issues/issue-layouts/kanban/default.tsx b/web/components/issues/issue-layouts/kanban/default.tsx index 3820f3bac..037fb5a8f 100644 --- a/web/components/issues/issue-layouts/kanban/default.tsx +++ b/web/components/issues/issue-layouts/kanban/default.tsx @@ -89,7 +89,10 @@ const GroupByKanBan: React.FC = observer((props) => { const verticalPosition = verticalAlignPosition(_list); return ( -
+
{sub_group_by === null && (
{ + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useProjectPublish must be used within StoreProvider"); + return context.projectPages; +}; diff --git a/web/store/page.store.ts b/web/store/page.store.ts index debbad0ff..327ec82fe 100644 --- a/web/store/page.store.ts +++ b/web/store/page.store.ts @@ -1,214 +1,95 @@ -import { action, computed, makeObservable, observable, runInAction } from "mobx"; +import { observable, runInAction } from "mobx"; import set from "lodash/set"; import omit from "lodash/omit"; import isToday from "date-fns/isToday"; import isThisWeek from "date-fns/isThisWeek"; import isYesterday from "date-fns/isYesterday"; -// services + +import { IPage } from "@plane/types"; import { PageService } from "services/page.service"; -// helpers -import { renderFormattedPayloadDate } from "helpers/date-time.helper"; -// types -import { IPage, IRecentPages } from "@plane/types"; -// store -import { RootStore } from "./root.store"; +import { is } from "date-fns/locale"; export interface IPageStore { - pages: Record; - archivedPages: Record; - // project computed - projectPageIds: string[] | null; - favoriteProjectPageIds: string[] | null; - privateProjectPageIds: string[] | null; - publicProjectPageIds: string[] | null; - archivedProjectPageIds: string[] | null; - recentProjectPages: IRecentPages | null; - // fetch page information actions - getUnArchivedPageById: (pageId: string) => IPage | null; - getArchivedPageById: (pageId: string) => IPage | null; - // fetch actions - fetchProjectPages: (workspaceSlug: string, projectId: string) => Promise; - fetchArchivedProjectPages: (workspaceSlug: string, projectId: string) => Promise; - // favorites actions - addToFavorites: (workspaceSlug: string, projectId: string, pageId: string) => Promise; - removeFromFavorites: (workspaceSlug: string, projectId: string, pageId: string) => Promise; - // crud - createPage: (workspaceSlug: string, projectId: string, data: Partial) => Promise; - updatePage: (workspaceSlug: string, projectId: string, pageId: string, data: Partial) => Promise; - deletePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise; - // access control actions - makePublic: (workspaceSlug: string, projectId: string, pageId: string) => Promise; - makePrivate: (workspaceSlug: string, projectId: string, pageId: string) => Promise; - // archive actions - archivePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise; - restorePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise; + access: number; + archived_at: string | null; + color: string; + created_at: Date; + created_by: string; + description: string; + description_html: string; + description_stripped: string | null; + id: string; + is_favorite: boolean; + is_locked: boolean; + labels: string[]; + name: string; + owned_by: string; + project: string; + updated_at: Date; + updated_by: string; + workspace: string; } -export class PageStore implements IPageStore { - pages: Record = {}; - archivedPages: Record = {}; - // services - pageService; - // stores - rootStore; +export class PageStore { + access: number; + archived_at: string | null; + color: string; + created_at: Date; + created_by: string; + description: string; + description_html: string; + description_stripped: string | null; + id: string; + is_favorite: boolean; + is_locked: boolean; + labels: string[]; + name: string; + owned_by: string; + project: string; + updated_at: Date; + updated_by: string; + workspace: string; - constructor(rootStore: RootStore) { - makeObservable(this, { - pages: observable, - archivedPages: observable, - // computed - projectPageIds: computed, - favoriteProjectPageIds: computed, - publicProjectPageIds: computed, - privateProjectPageIds: computed, - archivedProjectPageIds: computed, - recentProjectPages: computed, - // computed actions - getUnArchivedPageById: action, - getArchivedPageById: action, - // fetch actions - fetchProjectPages: action, - fetchArchivedProjectPages: action, - // favorites actions - addToFavorites: action, - removeFromFavorites: action, - // crud - createPage: action, - updatePage: action, - deletePage: action, - // access control actions - makePublic: action, - makePrivate: action, - // archive actions - archivePage: action, - restorePage: action, + pageService; + + constructor(page: IPage) { + observable(this, { + name: observable.ref, + description_html: observable.ref, + is_favorite: observable.ref, + is_locked: observable.ref, }); - // stores - this.rootStore = rootStore; - // services + this.created_by = page?.created_by || ""; + this.created_at = page?.created_at || new Date(); + this.color = page?.color || ""; + this.archived_at = page?.archived_at || null; + this.name = page?.name || ""; + this.description = page?.description || ""; + this.description_stripped = page?.description_stripped || ""; + this.description_html = page?.description_html || ""; + this.access = page?.access || 0; + this.workspace = page?.workspace || ""; + this.updated_by = page?.updated_by || ""; + this.updated_at = page?.updated_at || new Date(); + this.project = page?.project || ""; + this.owned_by = page?.owned_by || ""; + this.labels = page?.labels || []; + this.is_locked = page?.is_locked || false; + this.id = page?.id || ""; + this.is_favorite = page?.is_favorite || false; + this.pageService = new PageService(); } - /** - * retrieves all pages for a projectId that is available in the url. - */ - get projectPageIds() { - const projectId = this.rootStore.app.router.projectId; - if (!projectId) return null; - const projectPageIds = Object.keys(this.pages).filter((pageId) => this.pages?.[pageId]?.project === projectId); - return projectPageIds ?? null; - } + updateName = async (name: string) => { + this.name = name; + await this.pageService.patchPage(this.workspace, this.project, this.id, { name }); + }; - /** - * retrieves all favorite pages for a projectId that is available in the url. - */ - get favoriteProjectPageIds() { - if (!this.projectPageIds) return null; - const favoritePagesIds = Object.keys(this.projectPageIds).filter((pageId) => this.pages?.[pageId]?.is_favorite); - return favoritePagesIds ?? null; - } - - /** - * retrieves all private pages for a projectId that is available in the url. - */ - get privateProjectPageIds() { - if (!this.projectPageIds) return null; - const privatePagesIds = Object.keys(this.projectPageIds).filter((pageId) => this.pages?.[pageId]?.access === 1); - return privatePagesIds ?? null; - } - - /** - * retrieves all shared pages which are public to everyone in the project for a projectId that is available in the url. - */ - get publicProjectPageIds() { - if (!this.projectPageIds) return null; - const publicPagesIds = Object.keys(this.projectPageIds).filter((pageId) => this.pages?.[pageId]?.access === 0); - return publicPagesIds ?? null; - } - - /** - * retrieves all recent pages for a projectId that is available in the url. - * In format where today, yesterday, this_week, older are keys. - */ - get recentProjectPages() { - if (!this.projectPageIds) return null; - const data: IRecentPages = { today: [], yesterday: [], this_week: [], older: [] }; - data.today = this.projectPageIds.filter((p) => isToday(new Date(this.pages?.[p]?.created_at))) || []; - data.yesterday = this.projectPageIds.filter((p) => isYesterday(new Date(this.pages?.[p]?.created_at))) || []; - data.this_week = - this.projectPageIds.filter((p) => { - const pageCreatedAt = this.pages?.[p]?.created_at; - return ( - isThisWeek(new Date(pageCreatedAt)) && - !isToday(new Date(pageCreatedAt)) && - !isYesterday(new Date(pageCreatedAt)) - ); - }) || []; - data.older = - this.projectPageIds.filter((p) => { - const pageCreatedAt = this.pages?.[p]?.created_at; - return !isThisWeek(new Date(pageCreatedAt)) && !isYesterday(new Date(pageCreatedAt)); - }) || []; - return data; - } - - /** - * retrieves all archived pages for a projectId that is available in the url. - */ - get archivedProjectPageIds() { - const projectId = this.rootStore.app.router.projectId; - if (!projectId) return null; - const archivedProjectPageIds = Object.keys(this.archivedPages).filter( - (pageId) => this.archivedPages?.[pageId]?.project === projectId - ); - return archivedProjectPageIds ?? null; - } - - /** - * retrieves a page from pages by id. - * @param pageId - * @returns IPage | null - */ - getUnArchivedPageById = (pageId: string) => this.pages?.[pageId] ?? null; - - /** - * retrieves a page from archived pages by id. - * @param pageId - * @returns IPage | null - */ - getArchivedPageById = (pageId: string) => this.archivedPages?.[pageId] ?? null; - - /** - * fetches all pages for a project. - * @param workspaceSlug - * @param projectId - * @returns Promise - */ - fetchProjectPages = async (workspaceSlug: string, projectId: string) => - await this.pageService.getProjectPages(workspaceSlug, projectId).then((response) => { - runInAction(() => { - response.forEach((page) => { - set(this.pages, [page.id], page); - }); - }); - return response; - }); - - /** - * fetches all archived pages for a project. - * @param workspaceSlug - * @param projectId - * @returns Promise - */ - fetchArchivedProjectPages = async (workspaceSlug: string, projectId: string) => - await this.pageService.getArchivedPages(workspaceSlug, projectId).then((response) => { - runInAction(() => { - response.forEach((page) => { - set(this.archivedPages, [page.id], page); - }); - }); - return response; - }); + updateDescription = async (description: string) => { + this.description = description; + await this.pageService.patchPage(this.workspace, this.project, this.id, { description }); + }; /** * Add Page to users favorites list @@ -219,13 +100,11 @@ export class PageStore implements IPageStore { addToFavorites = async (workspaceSlug: string, projectId: string, pageId: string) => { try { runInAction(() => { - set(this.pages, [pageId, "is_favorite"], true); + this.is_favorite = true; }); await this.pageService.addPageToFavorites(workspaceSlug, projectId, pageId); } catch (error) { - runInAction(() => { - set(this.pages, [pageId, "is_favorite"], false); - }); + this.is_favorite = false; throw error; } }; @@ -238,62 +117,13 @@ export class PageStore implements IPageStore { */ removeFromFavorites = async (workspaceSlug: string, projectId: string, pageId: string) => { try { - runInAction(() => { - set(this.pages, [pageId, "is_favorite"], false); - }); + this.is_favorite = false; await this.pageService.removePageFromFavorites(workspaceSlug, projectId, pageId); } catch (error) { - runInAction(() => { - set(this.pages, [pageId, "is_favorite"], true); - }); + this.is_favorite = true; throw error; } }; - /** - * Creates a new page using the api and updated the local state in store - * @param workspaceSlug - * @param projectId - * @param data - */ - createPage = async (workspaceSlug: string, projectId: string, data: Partial) => - await this.pageService.createPage(workspaceSlug, projectId, data).then((response) => { - runInAction(() => { - set(this.pages, [response.id], response); - }); - return response; - }); - - /** - * updates the page using the api and updates the local state in store - * @param workspaceSlug - * @param projectId - * @param pageId - * @param data - * @returns - */ - updatePage = async (workspaceSlug: string, projectId: string, pageId: string, data: Partial) => - await this.pageService.patchPage(workspaceSlug, projectId, pageId, data).then((response) => { - const originalPage = this.getUnArchivedPageById(pageId); - runInAction(() => { - set(this.pages, [pageId], { ...originalPage, ...data }); - }); - return response; - }); - - /** - * delete a page using the api and updates the local state in store - * @param workspaceSlug - * @param projectId - * @param pageId - * @returns - */ - deletePage = async (workspaceSlug: string, projectId: string, pageId: string) => - await this.pageService.deletePage(workspaceSlug, projectId, pageId).then((response) => { - runInAction(() => { - omit(this.archivedPages, [pageId]); - }); - return response; - }); /** * make a page public @@ -305,12 +135,12 @@ export class PageStore implements IPageStore { makePublic = async (workspaceSlug: string, projectId: string, pageId: string) => { try { runInAction(() => { - set(this.pages, [pageId, "access"], 0); + this.access = 0; }); await this.pageService.patchPage(workspaceSlug, projectId, pageId, { access: 0 }); } catch (error) { runInAction(() => { - set(this.pages, [pageId, "access"], 1); + this.access = 1; }); throw error; } @@ -326,43 +156,14 @@ export class PageStore implements IPageStore { makePrivate = async (workspaceSlug: string, projectId: string, pageId: string) => { try { runInAction(() => { - set(this.pages, [pageId, "access"], 1); + this.access = 1; }); await this.pageService.patchPage(workspaceSlug, projectId, pageId, { access: 1 }); } catch (error) { runInAction(() => { - set(this.pages, [pageId, "access"], 0); + this.access = 0; }); throw error; } }; - - /** - * Mark a page archived - * @param workspaceSlug - * @param projectId - * @param pageId - */ - archivePage = async (workspaceSlug: string, projectId: string, pageId: string) => - await this.pageService.archivePage(workspaceSlug, projectId, pageId).then(() => { - runInAction(() => { - set(this.archivedPages, [pageId], this.pages[pageId]); - set(this.archivedPages, [pageId, "archived_at"], renderFormattedPayloadDate(new Date())); - omit(this.pages, [pageId]); - }); - }); - - /** - * Restore a page from archived pages to pages - * @param workspaceSlug - * @param projectId - * @param pageId - */ - restorePage = async (workspaceSlug: string, projectId: string, pageId: string) => - await this.pageService.restorePage(workspaceSlug, projectId, pageId).then(() => { - runInAction(() => { - set(this.pages, [pageId], this.archivedPages[pageId]); - omit(this.archivedPages, [pageId]); - }); - }); } diff --git a/web/store/project-page.store.ts b/web/store/project-page.store.ts new file mode 100644 index 000000000..2e913d9f9 --- /dev/null +++ b/web/store/project-page.store.ts @@ -0,0 +1,136 @@ +import { makeObservable, observable, runInAction, action } from "mobx"; +import { set } from "lodash"; +// services +import { PageService } from "services/page.service"; +// store +import { PageStore, IPageStore } from "store/page.store"; +// types +import { IPage } from "@plane/types"; + +export interface IProjectPageStore { + projectPages: Record; + projectArchivedPages: Record; + // fetch actions + fetchProjectPages: (workspaceSlug: string, projectId: string) => void; + fetchArchivedProjectPages: (workspaceSlug: string, projectId: string) => void; + // crud actions + createPage: (workspaceSlug: string, projectId: string, data: Partial) => void; + deletePage: (workspaceSlug: string, projectId: string, pageId: string) => void; +} + +export class ProjectPageStore implements IProjectPageStore { + projectPages: Record = {}; // { projectId: [page1, page2] } + projectArchivedPages: Record = {}; // { projectId: [page1, page2] } + + pageService; + + constructor() { + makeObservable(this, { + projectPages: observable, + projectArchivedPages: observable, + // fetch actions + fetchProjectPages: action, + fetchArchivedProjectPages: action, + // crud actions + createPage: action, + deletePage: action, + }); + this.pageService = new PageService(); + } + + /** + * Fetching all the pages for a specific project + * @param workspaceSlug + * @param projectId + */ + fetchProjectPages = async (workspaceSlug: string, projectId: string) => { + const response = await this.pageService.getProjectPages(workspaceSlug, projectId); + runInAction(() => { + this.projectPages[projectId] = response?.map((page) => new PageStore(page)); + }); + }; + + /** + * fetches all archived pages for a project. + * @param workspaceSlug + * @param projectId + * @returns Promise + */ + fetchArchivedProjectPages = async (workspaceSlug: string, projectId: string) => + await this.pageService.getArchivedPages(workspaceSlug, projectId).then((response) => { + runInAction(() => { + this.projectArchivedPages[projectId] = response?.map((page) => new PageStore(page)); + }); + return response; + }); + + /** + * Creates a new page using the api and updated the local state in store + * @param workspaceSlug + * @param projectId + * @param data + */ + createPage = async (workspaceSlug: string, projectId: string, data: Partial) => { + const response = await this.pageService.createPage(workspaceSlug, projectId, data); + runInAction(() => { + this.projectPages[projectId] = [...this.projectPages[projectId], new PageStore(response)]; + }); + return response; + }; + + /** + * delete a page using the api and updates the local state in store + * @param workspaceSlug + * @param projectId + * @param pageId + * @returns + */ + deletePage = async (workspaceSlug: string, projectId: string, pageId: string) => { + const response = await this.pageService.deletePage(workspaceSlug, projectId, pageId); + runInAction(() => { + this.projectPages = set( + this.projectPages, + [projectId], + this.projectPages[projectId].filter((page) => page.id !== pageId) + ); + }); + return response; + }; + + /** + * Mark a page archived + * @param workspaceSlug + * @param projectId + * @param pageId + */ + archivePage = async (workspaceSlug: string, projectId: string, pageId: string) => { + const response = await this.pageService.archivePage(workspaceSlug, projectId, pageId); + runInAction(() => { + set( + this.projectPages, + [projectId], + this.projectPages[projectId].filter((page) => page.id !== pageId) + ); + this.projectArchivedPages = set(this.projectArchivedPages, [projectId], this.projectArchivedPages[projectId]); + }); + return response; + }; + + /** + * Restore a page from archived pages to pages + * @param workspaceSlug + * @param projectId + * @param pageId + */ + restorePage = async (workspaceSlug: string, projectId: string, pageId: string) => + await this.pageService.restorePage(workspaceSlug, projectId, pageId).then(() => { + runInAction(() => { + set( + this.projectArchivedPages, + [projectId], + this.projectArchivedPages[projectId].filter((page) => page.id !== pageId) + ); + set(this.projectPages, [projectId], [...this.projectPages[projectId]]); + }); + }); +} diff --git a/web/store/root.store.ts b/web/store/root.store.ts index 209e30679..80745dfa9 100644 --- a/web/store/root.store.ts +++ b/web/store/root.store.ts @@ -16,6 +16,7 @@ import { IInboxRootStore, InboxRootStore } from "./inbox"; import { IEstimateStore, EstimateStore } from "./estimate.store"; import { GlobalViewStore, IGlobalViewStore } from "./global-view.store"; import { IMentionStore, MentionStore } from "./mention.store"; +import { IProjectPageStore, ProjectPageStore } from "./project-page.store"; enableStaticRendering(typeof window === "undefined"); @@ -31,11 +32,12 @@ export class RootStore { module: IModuleStore; projectView: IProjectViewStore; globalView: IGlobalViewStore; - page: IPageStore; + // page: IPageStore; issue: IIssueRootStore; state: IStateStore; estimate: IEstimateStore; mention: IMentionStore; + projectPages: IProjectPageStore; constructor() { this.app = new AppRootStore(this); @@ -50,10 +52,10 @@ export class RootStore { this.module = new ModulesStore(this); this.projectView = new ProjectViewStore(this); this.globalView = new GlobalViewStore(this); - this.page = new PageStore(this); this.issue = new IssueRootStore(this); this.state = new StateStore(this); this.estimate = new EstimateStore(this); this.mention = new MentionStore(this); + this.projectPages = new ProjectPageStore(); } } From 12a33927225b163603013cd2a90199421aa82aa9 Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Mon, 8 Jan 2024 16:16:45 +0530 Subject: [PATCH 12/68] fix: estimate order not maintained in create/ update modal. (#3326) * fix: estimate order not maintained in create/ update modal. * fix: estimate points mutation on update. --- web/components/estimates/estimates-list.tsx | 8 +++++++- web/store/estimate.store.ts | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/web/components/estimates/estimates-list.tsx b/web/components/estimates/estimates-list.tsx index 05b174461..e226e17e0 100644 --- a/web/components/estimates/estimates-list.tsx +++ b/web/components/estimates/estimates-list.tsx @@ -14,6 +14,8 @@ import { EmptyState } from "components/common"; import emptyEstimate from "public/empty-state/estimate.svg"; // types import { IEstimate } from "@plane/types"; +// helpers +import { orderArrayBy } from "helpers/array.helper"; export const EstimatesList: React.FC = observer(() => { // states @@ -31,7 +33,11 @@ export const EstimatesList: React.FC = observer(() => { const editEstimate = (estimate: IEstimate) => { setEstimateFormOpen(true); - setEstimateToUpdate(estimate); + // Order the points array by key before updating the estimate to update state + setEstimateToUpdate({ + ...estimate, + points: orderArrayBy(estimate.points, "key"), + }); }; const disableEstimates = () => { diff --git a/web/store/estimate.store.ts b/web/store/estimate.store.ts index 19a05b544..0dd90671f 100644 --- a/web/store/estimate.store.ts +++ b/web/store/estimate.store.ts @@ -172,7 +172,7 @@ export class EstimateStore implements IEstimateStore { updateEstimate = async (workspaceSlug: string, projectId: string, estimateId: string, data: IEstimateFormData) => await this.estimateService.patchEstimate(workspaceSlug, projectId, estimateId, data).then((response) => { const updatedEstimates = (this.estimates?.[projectId] ?? []).map((estimate) => - estimate.id === estimateId ? { ...estimate, ...data.estimate } : estimate + estimate.id === estimateId ? { ...estimate, ...data.estimate, points: [...data.estimate_points] } : estimate ); runInAction(() => { set(this.estimates, projectId, updatedEstimates); From 1257a880893eb5c02ec005d88eea756c2f8ae2f3 Mon Sep 17 00:00:00 2001 From: rahulramesha <71900764+rahulramesha@users.noreply.github.com> Date: Mon, 8 Jan 2024 19:20:42 +0530 Subject: [PATCH 13/68] fix: breaking cycle issues and replacing router.push with Links (#3330) * fix cycle creation and active cycle map * minor fix in cycle store * create cycle breaking fix * replace last possible bits of router.push with Link --------- Co-authored-by: Rahul R --- .../actions/workspace-settings-actions.tsx | 78 ++++++++++--------- .../core/sidebar/progress-chart.tsx | 10 ++- .../cycles/active-cycle-details.tsx | 24 +++--- web/components/headers/cycle-issues.tsx | 10 +-- web/components/headers/module-issues.tsx | 13 ++-- .../headers/project-view-issues.tsx | 13 ++-- .../notifications/notification-card.tsx | 29 +++---- .../notifications/notification-popover.tsx | 1 + web/components/project/card.tsx | 13 ++-- web/components/ui/empty-space.tsx | 51 +++++++----- .../settings-layout/project/layout.tsx | 15 ++-- web/pages/create-workspace.tsx | 7 +- web/pages/workspace-invitations/index.tsx | 30 ++----- web/store/cycle.store.ts | 25 +++--- 14 files changed, 161 insertions(+), 158 deletions(-) diff --git a/web/components/command-palette/actions/workspace-settings-actions.tsx b/web/components/command-palette/actions/workspace-settings-actions.tsx index 84e62593a..7503343ee 100644 --- a/web/components/command-palette/actions/workspace-settings-actions.tsx +++ b/web/components/command-palette/actions/workspace-settings-actions.tsx @@ -2,6 +2,7 @@ import { useRouter } from "next/router"; import { Command } from "cmdk"; // icons import { SettingIcon } from "components/icons"; +import Link from "next/link"; type Props = { closePalette: () => void; @@ -13,48 +14,55 @@ export const CommandPaletteWorkspaceSettingsActions: React.FC = (props) = const router = useRouter(); const { workspaceSlug } = router.query; - const redirect = (path: string) => { - closePalette(); - router.push(path); - }; - return ( <> - redirect(`/${workspaceSlug}/settings`)} className="focus:outline-none"> -
- - General -
+ + +
+ + General +
+
- redirect(`/${workspaceSlug}/settings/members`)} className="focus:outline-none"> -
- - Members -
+ + +
+ + Members +
+
- redirect(`/${workspaceSlug}/settings/billing`)} className="focus:outline-none"> -
- - Billing and Plans -
+ + +
+ + Billing and Plans +
+
- redirect(`/${workspaceSlug}/settings/integrations`)} className="focus:outline-none"> -
- - Integrations -
+ + +
+ + Integrations +
+
- redirect(`/${workspaceSlug}/settings/imports`)} className="focus:outline-none"> -
- - Import -
+ + +
+ + Import +
+
- redirect(`/${workspaceSlug}/settings/exports`)} className="focus:outline-none"> -
- - Export -
+ + +
+ + Export +
+
); diff --git a/web/components/core/sidebar/progress-chart.tsx b/web/components/core/sidebar/progress-chart.tsx index 9e9a4bac8..274e5da0c 100644 --- a/web/components/core/sidebar/progress-chart.tsx +++ b/web/components/core/sidebar/progress-chart.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { eachDayOfInterval } from "date-fns"; +import { eachDayOfInterval, isValid } from "date-fns"; // ui import { LineGraph } from "components/ui"; // helpers @@ -47,7 +47,13 @@ const ProgressChart: React.FC = ({ distribution, startDate, endDate, tota })); const generateXAxisTickValues = () => { - const dates = eachDayOfInterval({ start: new Date(startDate), end: new Date(endDate) }); + const start = new Date(startDate); + const end = new Date(endDate); + + let dates: Date[] = []; + if (isValid(start) && isValid(end)) { + dates = eachDayOfInterval({ start, end }); + } const maxDates = 4; const totalDates = dates.length; diff --git a/web/components/cycles/active-cycle-details.tsx b/web/components/cycles/active-cycle-details.tsx index 5ef912572..eb06c693a 100644 --- a/web/components/cycles/active-cycle-details.tsx +++ b/web/components/cycles/active-cycle-details.tsx @@ -33,6 +33,7 @@ import { truncateText } from "helpers/string.helper"; import { ICycle } from "@plane/types"; import { EIssuesStoreType } from "constants/issue"; import { ACTIVE_CYCLE_ISSUES } from "store/issue/cycle"; +import { CYCLE_ISSUES_WITH_PARAMS } from "constants/fetch-keys"; const stateGroups = [ { @@ -73,7 +74,7 @@ export const ActiveCycleDetails: React.FC = observer((props const { workspaceSlug, projectId } = props; const { - issues: { issues }, + issues: { issues, fetchActiveCycleIssues }, issueMap, } = useIssues(EIssuesStoreType.CYCLE); // store hooks @@ -99,13 +100,14 @@ export const ActiveCycleDetails: React.FC = observer((props const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null; const issueIds = issues?.[ACTIVE_CYCLE_ISSUES]; - // useSWR( - // workspaceSlug && projectId && cycleId ? CYCLE_ISSUES_WITH_PARAMS(cycleId, { priority: "urgent,high" }) : null, - // workspaceSlug && projectId && cycleId - // ? () => - // fetchActiveCycleIssues(workspaceSlug, projectId, ) - // : null - // ); + useSWR( + workspaceSlug && projectId && currentProjectActiveCycleId + ? CYCLE_ISSUES_WITH_PARAMS(currentProjectActiveCycleId, { priority: "urgent,high" }) + : null, + workspaceSlug && projectId && currentProjectActiveCycleId + ? () => fetchActiveCycleIssues(workspaceSlug, projectId, currentProjectActiveCycleId) + : null + ); if (!activeCycle && isLoading) return ( @@ -382,9 +384,9 @@ export const ActiveCycleDetails: React.FC = observer((props {issueIds ? ( issueIds.length > 0 ? ( issueIds.map((issue: any) => ( -
router.push(`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`)} + href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`} className="flex cursor-pointer flex-wrap items-center justify-between gap-2 rounded-md border border-custom-border-200 bg-custom-background-90 px-3 py-1.5" >
@@ -427,7 +429,7 @@ export const ActiveCycleDetails: React.FC = observer((props )}
-
+ )) ) : (
diff --git a/web/components/headers/cycle-issues.tsx b/web/components/headers/cycle-issues.tsx index 7873ea691..a616e4470 100644 --- a/web/components/headers/cycle-issues.tsx +++ b/web/components/headers/cycle-issues.tsx @@ -1,6 +1,7 @@ import { useCallback, useState } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; +import Link from "next/link"; // hooks import { useApplication, @@ -41,14 +42,11 @@ const CycleDropdownOption: React.FC<{ cycleId: string }> = ({ cycleId }) => { if (!cycle) return null; return ( - router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`)} - > -
+ + {truncateText(cycle.name, 40)} -
+
); }; diff --git a/web/components/headers/module-issues.tsx b/web/components/headers/module-issues.tsx index 3a607a4df..9cfc9c1b3 100644 --- a/web/components/headers/module-issues.tsx +++ b/web/components/headers/module-issues.tsx @@ -1,6 +1,7 @@ import { useCallback, useState } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; +import Link from "next/link"; // hooks import { useApplication, @@ -41,14 +42,14 @@ const ModuleDropdownOption: React.FC<{ moduleId: string }> = ({ moduleId }) => { if (!moduleDetail) return null; return ( - router.push(`/${workspaceSlug}/projects/${projectId}/modules/${moduleDetail.id}`)} - > -
+ + {truncateText(moduleDetail.name, 40)} -
+
); }; diff --git a/web/components/headers/project-view-issues.tsx b/web/components/headers/project-view-issues.tsx index d53880a53..48e38491b 100644 --- a/web/components/headers/project-view-issues.tsx +++ b/web/components/headers/project-view-issues.tsx @@ -2,6 +2,7 @@ import { useCallback } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { Plus } from "lucide-react"; +import Link from "next/link"; // hooks import { useApplication, @@ -154,14 +155,14 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => { if (!view) return; return ( - router.push(`/${workspaceSlug}/projects/${projectId}/views/${viewId}`)} - > -
+ + {truncateText(view.name, 40)} -
+
); })} diff --git a/web/components/notifications/notification-card.tsx b/web/components/notifications/notification-card.tsx index 6e20da193..58b79ac3e 100644 --- a/web/components/notifications/notification-card.tsx +++ b/web/components/notifications/notification-card.tsx @@ -2,6 +2,7 @@ import React from "react"; import Image from "next/image"; import { useRouter } from "next/router"; import { ArchiveRestore, Clock, MessageSquare, User2 } from "lucide-react"; +import Link from "next/link"; // hooks import useToast from "hooks/use-toast"; // icons @@ -10,17 +11,14 @@ import { ArchiveIcon, CustomMenu, Tooltip } from "@plane/ui"; import { snoozeOptions } from "constants/notification"; // helper import { replaceUnderscoreIfSnakeCase, truncateText, stripAndTruncateHTML } from "helpers/string.helper"; -import { - calculateTimeAgo, - renderFormattedTime, - renderFormattedDate, -} from "helpers/date-time.helper"; +import { calculateTimeAgo, renderFormattedTime, renderFormattedDate } from "helpers/date-time.helper"; // type import type { IUserNotification } from "@plane/types"; type NotificationCardProps = { notification: IUserNotification; isSnoozedTabOpen: boolean; + closePopover: () => void; markNotificationReadStatus: (notificationId: string) => Promise; markNotificationReadStatusToggle: (notificationId: string) => Promise; markNotificationArchivedStatus: (notificationId: string) => Promise; @@ -32,6 +30,7 @@ export const NotificationCard: React.FC = (props) => { const { notification, isSnoozedTabOpen, + closePopover, markNotificationReadStatus, markNotificationReadStatusToggle, markNotificationArchivedStatus, @@ -47,15 +46,14 @@ export const NotificationCard: React.FC = (props) => { if (isSnoozedTabOpen && new Date(notification.snoozed_till!) < new Date()) return null; return ( -
{ markNotificationReadStatus(notification.id); - router.push( - `/${workspaceSlug}/projects/${notification.project}/${ - notification.data.issue_activity.field === "archived_at" ? "archived-issues" : "issues" - }/${notification.data.issue.id}` - ); + closePopover(); }} + href={`/${workspaceSlug}/projects/${notification.project}/${ + notification.data.issue_activity.field === "archived_at" ? "archived-issues" : "issues" + }/${notification.data.issue.id}`} className={`group relative flex w-full cursor-pointer items-center gap-4 p-3 pl-6 ${ notification.read_at === null ? "bg-custom-primary-70/5" : "hover:bg-custom-background-200" }`} @@ -149,7 +147,8 @@ export const NotificationCard: React.FC = (props) => {

- Till {renderFormattedDate(notification.snoozed_till)}, {renderFormattedTime(notification.snoozed_till, '12-hour')} + Till {renderFormattedDate(notification.snoozed_till)},{" "} + {renderFormattedTime(notification.snoozed_till, "12-hour")}

) : ( @@ -195,6 +194,8 @@ export const NotificationCard: React.FC = (props) => { type="button" onClick={(e) => { e.stopPropagation(); + e.preventDefault(); + item.onClick(); }} key={item.id} @@ -204,7 +205,6 @@ export const NotificationCard: React.FC = (props) => { ))} - = (props) => { key={item.label} onClick={(e) => { e.stopPropagation(); + e.preventDefault(); if (!item.value) { setSelectedNotificationForSnooze(notification.id); @@ -243,6 +244,6 @@ export const NotificationCard: React.FC = (props) => {
-
+ ); }; diff --git a/web/components/notifications/notification-popover.tsx b/web/components/notifications/notification-popover.tsx index d3dc541b3..4b55ea4cb 100644 --- a/web/components/notifications/notification-popover.tsx +++ b/web/components/notifications/notification-popover.tsx @@ -119,6 +119,7 @@ export const NotificationPopover = observer(() => { = observer((props) => { }); }; - const projectMembersIds = project.members.map((member) => member.member_id); + const projectMembersIds = project.members?.map((member) => member.member_id); return ( <> @@ -178,7 +179,7 @@ export const ProjectCard: React.FC = observer((props) => { } position="top" > - {projectMembersIds.length > 0 ? ( + {projectMembersIds && projectMembersIds.length > 0 ? (
{projectMembersIds.map((memberId) => { @@ -195,17 +196,15 @@ export const ProjectCard: React.FC = observer((props) => { )} {(isOwner || isMember) && ( - + )} {!project.is_member ? ( diff --git a/web/components/ui/empty-space.tsx b/web/components/ui/empty-space.tsx index 4c5e94736..4b70bbb15 100644 --- a/web/components/ui/empty-space.tsx +++ b/web/components/ui/empty-space.tsx @@ -45,28 +45,39 @@ type EmptySpaceItemProps = { title: string; description?: React.ReactNode | string; Icon: any; - action: () => void; + action?: () => void; + href?: string; }; -const EmptySpaceItem: React.FC = ({ title, description, Icon, action }) => ( - <> -
  • -
    -
    - - -
    -
    -
    {title}
    - {description ?
    {description}
    : null} -
    -
    -
    +const EmptySpaceItem: React.FC = ({ title, description, Icon, action, href }) => { + let spaceItem = ( +
    +
    + +
    -
  • - -); +
    +
    {title}
    + {description ?
    {description}
    : null} +
    +
    +
    +
    + ); + + if (href) { + spaceItem = {spaceItem}; + } + + return ( + <> +
  • + {spaceItem} +
  • + + ); +}; export { EmptySpace, EmptySpaceItem }; diff --git a/web/layouts/settings-layout/project/layout.tsx b/web/layouts/settings-layout/project/layout.tsx index 2abeb1976..c029643bf 100644 --- a/web/layouts/settings-layout/project/layout.tsx +++ b/web/layouts/settings-layout/project/layout.tsx @@ -1,6 +1,7 @@ import { FC, ReactNode } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; +import Link from "next/link"; // hooks import { useUser } from "hooks/store"; // components @@ -31,14 +32,12 @@ export const ProjectSettingLayout: FC = observer((props) } - onClick={() => router.push(`/${workspaceSlug}/projects/${projectId}/issues`)} - > - Go to issues - + //TODO: Create a new component called Button Link to handle such scenarios + + + } /> ) : ( diff --git a/web/pages/create-workspace.tsx b/web/pages/create-workspace.tsx index 458a595cc..10eb11f55 100644 --- a/web/pages/create-workspace.tsx +++ b/web/pages/create-workspace.tsx @@ -3,6 +3,7 @@ import { useRouter } from "next/router"; import Image from "next/image"; import { useTheme } from "next-themes"; import { observer } from "mobx-react-lite"; +import Link from "next/link"; // hooks import { useUser } from "hooks/store"; // layouts @@ -39,9 +40,9 @@ const CreateWorkspacePage: NextPageWithLayout = observer(() => {
    - +
    {currentUser?.email}
    diff --git a/web/pages/workspace-invitations/index.tsx b/web/pages/workspace-invitations/index.tsx index 331441935..aa95d0a38 100644 --- a/web/pages/workspace-invitations/index.tsx +++ b/web/pages/workspace-invitations/index.tsx @@ -81,7 +81,7 @@ const WorkspaceInvitationPage: NextPageWithLayout = observer(() => { title={`You are already a member of ${invitationDetail.workspace.name}`} description="Your workspace is where you'll create projects, collaborate on your issues, and organize different streams of work in your Plane account." > - router.push("/")} /> + ) : ( @@ -103,35 +103,15 @@ const WorkspaceInvitationPage: NextPageWithLayout = observer(() => { link={{ text: "Or start from an empty project", href: "/" }} > {!currentUser ? ( - { - router.push("/"); - }} - /> + ) : ( - { - router.push("/"); - }} - /> + )} - { - router.push("https://github.com/makeplane"); - }} - /> + { - router.push("https://discord.com/invite/8SR2N9PAcJ"); - }} + href="https://discord.com/invite/8SR2N9PAcJ" /> ) : ( diff --git a/web/store/cycle.store.ts b/web/store/cycle.store.ts index 53c3bce60..5b663f79b 100644 --- a/web/store/cycle.store.ts +++ b/web/store/cycle.store.ts @@ -14,7 +14,7 @@ import { CycleService } from "services/cycle.service"; export interface ICycleStore { // observables cycleMap: Record; - activeCycleMap: Record; // TODO: Merge these two into single map + activeCycleIdMap: Record; // computed currentProjectCycleIds: string[] | null; currentProjectCompletedCycleIds: string[] | null; @@ -49,7 +49,7 @@ export interface ICycleStore { export class CycleStore implements ICycleStore { // observables cycleMap: Record = {}; - activeCycleMap: Record = {}; + activeCycleIdMap: Record = {}; // root store rootStore; // services @@ -61,7 +61,7 @@ export class CycleStore implements ICycleStore { makeObservable(this, { // observables cycleMap: observable, - activeCycleMap: observable, + activeCycleIdMap: observable, // computed currentProjectCycleIds: computed, currentProjectCompletedCycleIds: computed, @@ -168,8 +168,8 @@ export class CycleStore implements ICycleStore { get currentProjectActiveCycleId() { const projectId = this.rootStore.app.router.projectId; if (!projectId) return null; - const activeCycle = Object.keys(this.activeCycleMap ?? {}).find( - (cycleId) => this.activeCycleMap?.[cycleId]?.project === projectId + const activeCycle = Object.keys(this.activeCycleIdMap ?? {}).find( + (cycleId) => this.cycleMap?.[cycleId]?.project === projectId ); return activeCycle || null; } @@ -186,7 +186,8 @@ export class CycleStore implements ICycleStore { * @param cycleId * @returns */ - getActiveCycleById = (cycleId: string): ICycle | null => this.activeCycleMap?.[cycleId] ?? null; + getActiveCycleById = (cycleId: string): ICycle | null => + this.activeCycleIdMap?.[cycleId] && this.cycleMap?.[cycleId] ? this.cycleMap?.[cycleId] : null; /** * @description returns list of cycle ids of the project id passed as argument @@ -235,7 +236,8 @@ export class CycleStore implements ICycleStore { await this.cycleService.getCyclesWithParams(workspaceSlug, projectId, "current").then((response) => { runInAction(() => { response.forEach((cycle) => { - set(this.activeCycleMap, [cycle.id], cycle); + set(this.activeCycleIdMap, [cycle.id], true); + set(this.cycleMap, [cycle.id], cycle); }); }); return response; @@ -252,7 +254,6 @@ export class CycleStore implements ICycleStore { await this.cycleService.getCycleDetails(workspaceSlug, projectId, cycleId).then((response) => { runInAction(() => { set(this.cycleMap, [response.id], { ...this.cycleMap?.[response.id], ...response }); - set(this.activeCycleMap, [response.id], { ...this.activeCycleMap?.[response.id], ...response }); }); return response; }); @@ -268,7 +269,6 @@ export class CycleStore implements ICycleStore { await this.cycleService.createCycle(workspaceSlug, projectId, data).then((response) => { runInAction(() => { set(this.cycleMap, [response.id], response); - set(this.activeCycleMap, [response.id], response); }); return response; }); @@ -285,7 +285,6 @@ export class CycleStore implements ICycleStore { try { runInAction(() => { set(this.cycleMap, [cycleId], { ...this.cycleMap?.[cycleId], ...data }); - set(this.activeCycleMap, [cycleId], { ...this.activeCycleMap?.[cycleId], ...data }); }); const response = await this.cycleService.patchCycle(workspaceSlug, projectId, cycleId, data); return response; @@ -307,7 +306,7 @@ export class CycleStore implements ICycleStore { await this.cycleService.deleteCycle(workspaceSlug, projectId, cycleId).then(() => { runInAction(() => { delete this.cycleMap[cycleId]; - delete this.activeCycleMap[cycleId]; + delete this.activeCycleIdMap[cycleId]; }); }); @@ -324,7 +323,6 @@ export class CycleStore implements ICycleStore { try { runInAction(() => { if (currentCycle) set(this.cycleMap, [cycleId, "is_favorite"], true); - if (currentActiveCycle) set(this.activeCycleMap, [cycleId, "is_favorite"], true); }); // updating through api. const response = await this.cycleService.addCycleToFavorites(workspaceSlug, projectId, { cycle: cycleId }); @@ -332,7 +330,6 @@ export class CycleStore implements ICycleStore { } catch (error) { runInAction(() => { if (currentCycle) set(this.cycleMap, [cycleId, "is_favorite"], false); - if (currentActiveCycle) set(this.activeCycleMap, [cycleId, "is_favorite"], false); }); throw error; } @@ -351,14 +348,12 @@ export class CycleStore implements ICycleStore { try { runInAction(() => { if (currentCycle) set(this.cycleMap, [cycleId, "is_favorite"], false); - if (currentActiveCycle) set(this.activeCycleMap, [cycleId, "is_favorite"], false); }); const response = await this.cycleService.removeCycleFromFavorites(workspaceSlug, projectId, cycleId); return response; } catch (error) { runInAction(() => { if (currentCycle) set(this.cycleMap, [cycleId, "is_favorite"], true); - if (currentActiveCycle) set(this.activeCycleMap, [cycleId, "is_favorite"], true); }); throw error; } From 5e03f3dd8238b88eb9fcd242f7f9038cb85547b2 Mon Sep 17 00:00:00 2001 From: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> Date: Mon, 8 Jan 2024 23:25:14 +0530 Subject: [PATCH 14/68] chore: mobile configs (#3328) * chore: mobile configs * chore: mobile configurations changed * chore: removed the slack id * chore: reversed google client id --- apiserver/plane/app/urls/config.py | 7 +- apiserver/plane/app/views/__init__.py | 2 +- apiserver/plane/app/views/config.py | 110 ++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 2 deletions(-) diff --git a/apiserver/plane/app/urls/config.py b/apiserver/plane/app/urls/config.py index 12beb63aa..d12984c87 100644 --- a/apiserver/plane/app/urls/config.py +++ b/apiserver/plane/app/urls/config.py @@ -1,7 +1,7 @@ from django.urls import path -from plane.app.views import ConfigurationEndpoint +from plane.app.views import ConfigurationEndpoint, MobileConfigurationEndpoint urlpatterns = [ path( @@ -9,4 +9,9 @@ urlpatterns = [ ConfigurationEndpoint.as_view(), name="configuration", ), + path( + "mobile-configs/", + MobileConfigurationEndpoint.as_view(), + name="configuration", + ), ] \ No newline at end of file diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index 520a3fd38..af7c60be0 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -165,7 +165,7 @@ from .notification import ( from .exporter import ExportIssuesEndpoint -from .config import ConfigurationEndpoint +from .config import ConfigurationEndpoint, MobileConfigurationEndpoint from .webhook import ( WebhookEndpoint, diff --git a/apiserver/plane/app/views/config.py b/apiserver/plane/app/views/config.py index 80467c90d..aebf92a9e 100644 --- a/apiserver/plane/app/views/config.py +++ b/apiserver/plane/app/views/config.py @@ -123,3 +123,113 @@ class ConfigurationEndpoint(BaseAPIView): ) return Response(data, status=status.HTTP_200_OK) + + +class MobileConfigurationEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def get(self, request): + ( + GOOGLE_CLIENT_ID, + GOOGLE_SERVER_CLIENT_ID, + GOOGLE_IOS_CLIENT_ID, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + ENABLE_MAGIC_LINK_LOGIN, + ENABLE_EMAIL_PASSWORD, + POSTHOG_API_KEY, + POSTHOG_HOST, + UNSPLASH_ACCESS_KEY, + OPENAI_API_KEY, + ) = get_configuration_value( + [ + { + "key": "GOOGLE_CLIENT_ID", + "default": os.environ.get("GOOGLE_CLIENT_ID", None), + }, + { + "key": "GOOGLE_SERVER_CLIENT_ID", + "default": os.environ.get("GOOGLE_SERVER_CLIENT_ID", None), + }, + { + "key": "GOOGLE_IOS_CLIENT_ID", + "default": os.environ.get("GOOGLE_IOS_CLIENT_ID", None), + }, + { + "key": "EMAIL_HOST_USER", + "default": os.environ.get("EMAIL_HOST_USER", None), + }, + { + "key": "EMAIL_HOST_PASSWORD", + "default": os.environ.get("EMAIL_HOST_PASSWORD", None), + }, + { + "key": "ENABLE_MAGIC_LINK_LOGIN", + "default": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "1"), + }, + { + "key": "ENABLE_EMAIL_PASSWORD", + "default": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"), + }, + { + "key": "POSTHOG_API_KEY", + "default": os.environ.get("POSTHOG_API_KEY", "1"), + }, + { + "key": "POSTHOG_HOST", + "default": os.environ.get("POSTHOG_HOST", "1"), + }, + { + "key": "UNSPLASH_ACCESS_KEY", + "default": os.environ.get("UNSPLASH_ACCESS_KEY", "1"), + }, + { + "key": "OPENAI_API_KEY", + "default": os.environ.get("OPENAI_API_KEY", "1"), + }, + ] + ) + data = {} + # Authentication + data["google_client_id"] = ( + GOOGLE_CLIENT_ID if GOOGLE_CLIENT_ID and GOOGLE_CLIENT_ID != '""' else None + ) + data["google_server_client_id"] = ( + GOOGLE_SERVER_CLIENT_ID + if GOOGLE_SERVER_CLIENT_ID and GOOGLE_SERVER_CLIENT_ID != '""' + else None + ) + data["google_ios_client_id"] = ( + (GOOGLE_IOS_CLIENT_ID)[::-1] if GOOGLE_IOS_CLIENT_ID is not None else None + ) + # Posthog + data["posthog_api_key"] = POSTHOG_API_KEY + data["posthog_host"] = POSTHOG_HOST + + data["magic_login"] = ( + bool(EMAIL_HOST_USER) and bool(EMAIL_HOST_PASSWORD) + ) and ENABLE_MAGIC_LINK_LOGIN == "1" + + data["email_password_login"] = ENABLE_EMAIL_PASSWORD == "1" + + # Posthog + data["posthog_api_key"] = POSTHOG_API_KEY + data["posthog_host"] = POSTHOG_HOST + + # Unsplash + data["has_unsplash_configured"] = bool(UNSPLASH_ACCESS_KEY) + + # Open AI settings + data["has_openai_configured"] = bool(OPENAI_API_KEY) + + # File size settings + data["file_size_limit"] = float(os.environ.get("FILE_SIZE_LIMIT", 5242880)) + + # is smtp configured + data["is_smtp_configured"] = not ( + bool(EMAIL_HOST_USER) and bool(EMAIL_HOST_PASSWORD) + ) + + return Response(data, status=status.HTTP_200_OK) From 68d370fd8674bd0bc5963bb5d3901eefc6f9ae19 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jan 2024 22:45:10 +0530 Subject: [PATCH 15/68] chore(deps): bump tj-actions/changed-files in /.github/workflows (#3327) Bumps [tj-actions/changed-files](https://github.com/tj-actions/changed-files) from 38 to 41. - [Release notes](https://github.com/tj-actions/changed-files/releases) - [Changelog](https://github.com/tj-actions/changed-files/blob/main/HISTORY.md) - [Commits](https://github.com/tj-actions/changed-files/compare/v38...v41) --- updated-dependencies: - dependency-name: tj-actions/changed-files dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build-test-pull-request.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-test-pull-request.yml b/.github/workflows/build-test-pull-request.yml index fd5d5ad03..296e965d7 100644 --- a/.github/workflows/build-test-pull-request.yml +++ b/.github/workflows/build-test-pull-request.yml @@ -25,7 +25,7 @@ jobs: - name: Get changed files id: changed-files - uses: tj-actions/changed-files@v38 + uses: tj-actions/changed-files@v41 with: files_yaml: | apiserver: From 43b503c7563693b01acc91314eed08e07d634e67 Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Mon, 8 Jan 2024 23:26:32 +0530 Subject: [PATCH 16/68] fix: security warnings related to information exposure and regex validations (#3325) --- apiserver/plane/api/views/base.py | 5 ++--- apiserver/plane/app/views/base.py | 10 ++++------ apiserver/plane/space/views/base.py | 9 ++++----- apiserver/plane/utils/issue_search.py | 4 ++-- apiserver/plane/utils/paginator.py | 2 +- 5 files changed, 13 insertions(+), 17 deletions(-) diff --git a/apiserver/plane/api/views/base.py b/apiserver/plane/api/views/base.py index abde4e8b0..035266bd5 100644 --- a/apiserver/plane/api/views/base.py +++ b/apiserver/plane/api/views/base.py @@ -104,15 +104,14 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): ) if isinstance(e, ObjectDoesNotExist): - model_name = str(exc).split(" matching query does not exist.")[0] return Response( - {"error": f"{model_name} does not exist."}, + {"error": f"The required object does not exist."}, status=status.HTTP_404_NOT_FOUND, ) if isinstance(e, KeyError): return Response( - {"error": f"key {e} does not exist"}, + {"error": f" The required key does not exist."}, status=status.HTTP_400_BAD_REQUEST, ) diff --git a/apiserver/plane/app/views/base.py b/apiserver/plane/app/views/base.py index 3fae82e92..eb3ade229 100644 --- a/apiserver/plane/app/views/base.py +++ b/apiserver/plane/app/views/base.py @@ -113,16 +113,15 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): ) if isinstance(e, ObjectDoesNotExist): - model_name = str(exc).split(" matching query does not exist.")[0] return Response( - {"error": f"{model_name} does not exist."}, + {"error": f"The required object does not exist."}, status=status.HTTP_404_NOT_FOUND, ) if isinstance(e, KeyError): capture_exception(e) return Response( - {"error": f"key {e} does not exist"}, + {"error": f"The required key does not exist."}, status=status.HTTP_400_BAD_REQUEST, ) @@ -216,14 +215,13 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): ) if isinstance(e, ObjectDoesNotExist): - model_name = str(exc).split(" matching query does not exist.")[0] return Response( - {"error": f"{model_name} does not exist."}, + {"error": f"The required object does not exist."}, status=status.HTTP_404_NOT_FOUND, ) if isinstance(e, KeyError): - return Response({"error": f"key {e} does not exist"}, status=status.HTTP_400_BAD_REQUEST) + return Response({"error": f"The required key does not exist."}, status=status.HTTP_400_BAD_REQUEST) if settings.DEBUG: print(e) diff --git a/apiserver/plane/space/views/base.py b/apiserver/plane/space/views/base.py index b1d749a09..7a819095b 100644 --- a/apiserver/plane/space/views/base.py +++ b/apiserver/plane/space/views/base.py @@ -85,14 +85,14 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): if isinstance(e, ObjectDoesNotExist): model_name = str(exc).split(" matching query does not exist.")[0] return Response( - {"error": f"{model_name} does not exist."}, + {"error": f"The required object does not exist."}, status=status.HTTP_404_NOT_FOUND, ) if isinstance(e, KeyError): capture_exception(e) return Response( - {"error": f"key {e} does not exist"}, + {"error": "The required key does not exist."}, status=status.HTTP_400_BAD_REQUEST, ) @@ -172,14 +172,13 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): ) if isinstance(e, ObjectDoesNotExist): - model_name = str(exc).split(" matching query does not exist.")[0] return Response( - {"error": f"{model_name} does not exist."}, + {"error": f"The required object does not exist."}, status=status.HTTP_404_NOT_FOUND, ) if isinstance(e, KeyError): - return Response({"error": f"key {e} does not exist"}, status=status.HTTP_400_BAD_REQUEST) + return Response({"error": "The required key does not exist."}, status=status.HTTP_400_BAD_REQUEST) if settings.DEBUG: print(e) diff --git a/apiserver/plane/utils/issue_search.py b/apiserver/plane/utils/issue_search.py index 40f85dde4..d38b1f4c3 100644 --- a/apiserver/plane/utils/issue_search.py +++ b/apiserver/plane/utils/issue_search.py @@ -12,8 +12,8 @@ def search_issues(query, queryset): fields = ["name", "sequence_id"] q = Q() for field in fields: - if field == "sequence_id": - sequences = re.findall(r"\d+\.\d+|\d+", query) + if field == "sequence_id" and len(query) <= 20: + sequences = re.findall(r"[A-Za-z0-9]{1,12}-\d+", query) for sequence_id in sequences: q |= Q(**{"sequence_id": sequence_id}) else: diff --git a/apiserver/plane/utils/paginator.py b/apiserver/plane/utils/paginator.py index 793614cc0..3563dad34 100644 --- a/apiserver/plane/utils/paginator.py +++ b/apiserver/plane/utils/paginator.py @@ -188,7 +188,7 @@ class BasePaginator: try: cursor_result = paginator.get_result(limit=per_page, cursor=input_cursor) except BadPaginationError as e: - raise ParseError(detail=str(e)) + raise ParseError(detail="Error in parsing") # Serialize result according to the on_result function if on_results: From 80dc38b649cb661f01337c42c561fa022061b233 Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Mon, 8 Jan 2024 23:27:09 +0530 Subject: [PATCH 17/68] fix: jira importer validations (#3323) * fix: jira importer validations * dev: update validation for cloud hostname * dev: update the function to be used externally * dev: update codeql workflow * dev: update repository selection api --- .github/workflows/codeql.yml | 4 +-- apiserver/plane/app/views/importer.py | 31 ++++++++++++------- apiserver/plane/utils/importers/jira.py | 22 +++++++++++-- .../integration/single-integration-card.tsx | 2 +- web/services/project/project.service.ts | 6 +++- 5 files changed, 48 insertions(+), 17 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 29fbde453..9f6ab1bfb 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,10 +2,10 @@ name: "CodeQL" on: push: - branches: [ 'develop', 'hot-fix', 'stage-release' ] + branches: [ 'develop', 'preview', 'master' ] pull_request: # The branches below must be a subset of the branches above - branches: [ 'develop' ] + branches: [ 'develop', 'preview', 'master' ] schedule: - cron: '53 19 * * 5' diff --git a/apiserver/plane/app/views/importer.py b/apiserver/plane/app/views/importer.py index b99d663e2..00d698ac5 100644 --- a/apiserver/plane/app/views/importer.py +++ b/apiserver/plane/app/views/importer.py @@ -35,14 +35,13 @@ from plane.app.serializers import ( ModuleSerializer, ) from plane.utils.integrations.github import get_github_repo_details -from plane.utils.importers.jira import jira_project_issue_summary +from plane.utils.importers.jira import jira_project_issue_summary, is_allowed_hostname from plane.bgtasks.importer_task import service_importer from plane.utils.html_processor import strip_tags from plane.app.permissions import WorkSpaceAdminPermission class ServiceIssueImportSummaryEndpoint(BaseAPIView): - def get(self, request, slug, service): if service == "github": owner = request.GET.get("owner", False) @@ -122,6 +121,7 @@ class ImportServiceEndpoint(BaseAPIView): permission_classes = [ WorkSpaceAdminPermission, ] + def post(self, request, slug, service): project_id = request.data.get("project_id", False) @@ -174,6 +174,21 @@ class ImportServiceEndpoint(BaseAPIView): data = request.data.get("data", False) metadata = request.data.get("metadata", False) config = request.data.get("config", False) + + cloud_hostname = metadata.get("cloud_hostname", False) + + if not cloud_hostname: + return Response( + {"error": "Cloud hostname is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if not is_allowed_hostname(cloud_hostname): + return Response( + {"error": "Hostname is not a valid hostname."}, + status=status.HTTP_400_BAD_REQUEST, + ) + if not data or not metadata: return Response( {"error": "Data, config and metadata are required"}, @@ -221,9 +236,7 @@ class ImportServiceEndpoint(BaseAPIView): return Response(serializer.data) def delete(self, request, slug, service, pk): - importer = Importer.objects.get( - pk=pk, service=service, workspace__slug=slug - ) + importer = Importer.objects.get(pk=pk, service=service, workspace__slug=slug) if importer.imported_data is not None: # Delete all imported Issues @@ -241,9 +254,7 @@ class ImportServiceEndpoint(BaseAPIView): return Response(status=status.HTTP_204_NO_CONTENT) def patch(self, request, slug, service, pk): - importer = Importer.objects.get( - pk=pk, service=service, workspace__slug=slug - ) + importer = Importer.objects.get(pk=pk, service=service, workspace__slug=slug) serializer = ImporterSerializer(importer, data=request.data, partial=True) if serializer.is_valid(): serializer.save() @@ -479,9 +490,7 @@ class BulkImportModulesEndpoint(BaseAPIView): [ ModuleLink( module=module, - url=module_data.get("link", {}).get( - "url", "https://plane.so" - ), + url=module_data.get("link", {}).get("url", "https://plane.so"), title=module_data.get("link", {}).get( "title", "Original Issue" ), diff --git a/apiserver/plane/utils/importers/jira.py b/apiserver/plane/utils/importers/jira.py index b427ba14f..3081096fe 100644 --- a/apiserver/plane/utils/importers/jira.py +++ b/apiserver/plane/utils/importers/jira.py @@ -2,13 +2,31 @@ import requests from requests.auth import HTTPBasicAuth from sentry_sdk import capture_exception +from urllib.parse import urlparse + +def is_allowed_hostname(hostname): + allowed_lists = ["atl-paas.net", "atlassian.com", "atlassian.net", "jira.com"] + # Extract the base domain from the hostname + parsed_uri = urlparse(f"https://{hostname}") # Add scheme for urlparse to work properly + domain = parsed_uri.netloc.split(":")[0] # Removes port number if included + base_domain = ".".join(domain.split(".")[-2:]) # Extract base domain + + # Check if the base domain is in the allowed list + return base_domain in allowed_lists + def jira_project_issue_summary(email, api_token, project_key, hostname): try: + + + if not is_allowed_hostname(hostname): + print("Errored Hostname") + return {"error": "Invalid or unauthorized hostname"} + auth = HTTPBasicAuth(email, api_token) headers = {"Accept": "application/json"} - issue_url = f"https://{hostname}/rest/api/3/search?jql=project={project_key} AND issuetype=Story" + issue_url = f"https://{hostname}/rest/api/3/search?jql=project={project_key} AND issuetype!=Epic" issue_response = requests.request( "GET", issue_url, headers=headers, auth=auth ).json()["total"] @@ -18,7 +36,7 @@ def jira_project_issue_summary(email, api_token, project_key, hostname): "GET", module_url, headers=headers, auth=auth ).json()["total"] - status_url = f"https://{hostname}/rest/api/3/status/?jql=project={project_key}" + status_url = f"https://{hostname}/rest/api/3/project/${project_key}/statuses" status_response = requests.request( "GET", status_url, headers=headers, auth=auth ).json() diff --git a/web/components/integration/single-integration-card.tsx b/web/components/integration/single-integration-card.tsx index 7cdac6e00..70bbb5fa4 100644 --- a/web/components/integration/single-integration-card.tsx +++ b/web/components/integration/single-integration-card.tsx @@ -140,7 +140,7 @@ export const SingleIntegrationCard: React.FC = observer(({ integration }) variant="danger" onClick={() => { if (!isUserAdmin) return; - handleRemoveIntegration; + handleRemoveIntegration(); }} disabled={!isUserAdmin} loading={deletingIntegration} diff --git a/web/services/project/project.service.ts b/web/services/project/project.service.ts index 76a729e6d..4956b952e 100644 --- a/web/services/project/project.service.ts +++ b/web/services/project/project.service.ts @@ -86,7 +86,11 @@ export class ProjectService extends APIService { } async getGithubRepositories(url: string): Promise { - return this.request(url) + return this.request({ + method: "get", + url, + headers: this.getAccessToken() ? this.getHeaders() : {}, + }) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; From e72920d33eab4a0a74ad64615949b4bf4ba87794 Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Tue, 9 Jan 2024 20:40:23 +0530 Subject: [PATCH 18/68] fix: update jira summary endpoints (#3333) * dev: update jira summary endpoints * dev: update jira project key validations * dev: updated key length --- apiserver/plane/utils/importers/jira.py | 70 +++++++++++++++++++------ 1 file changed, 53 insertions(+), 17 deletions(-) diff --git a/apiserver/plane/utils/importers/jira.py b/apiserver/plane/utils/importers/jira.py index 3081096fe..5e8c31f97 100644 --- a/apiserver/plane/utils/importers/jira.py +++ b/apiserver/plane/utils/importers/jira.py @@ -1,53 +1,89 @@ import requests +import re from requests.auth import HTTPBasicAuth from sentry_sdk import capture_exception +from urllib.parse import urlparse, urljoin -from urllib.parse import urlparse def is_allowed_hostname(hostname): - allowed_lists = ["atl-paas.net", "atlassian.com", "atlassian.net", "jira.com"] - # Extract the base domain from the hostname - parsed_uri = urlparse(f"https://{hostname}") # Add scheme for urlparse to work properly - domain = parsed_uri.netloc.split(":")[0] # Removes port number if included - base_domain = ".".join(domain.split(".")[-2:]) # Extract base domain + allowed_domains = ["atl-paas.net", "atlassian.com", "atlassian.net", "jira.com"] + parsed_uri = urlparse(f"https://{hostname}") + domain = parsed_uri.netloc.split(":")[0] # Ensures no port is included + base_domain = ".".join(domain.split(".")[-2:]) + return base_domain in allowed_domains - # Check if the base domain is in the allowed list - return base_domain in allowed_lists + +def is_valid_project_key(project_key): + if project_key: + project_key = project_key.strip().upper() + # Adjust the regular expression as needed based on your specific requirements. + if len(project_key) > 30: + return False + # Check the validity of the key as well + pattern = re.compile(r'^[A-Z0-9]{1,10}$') + return pattern.match(project_key) is not None + else: + False + +def generate_valid_project_key(project_key): + return project_key.strip().upper() + +def generate_url(hostname, path): + if not is_allowed_hostname(hostname): + raise ValueError("Invalid or unauthorized hostname") + return urljoin(f"https://{hostname}", path) def jira_project_issue_summary(email, api_token, project_key, hostname): try: - - if not is_allowed_hostname(hostname): - print("Errored Hostname") return {"error": "Invalid or unauthorized hostname"} + if not is_valid_project_key(project_key): + return {"error": "Invalid project key"} + auth = HTTPBasicAuth(email, api_token) headers = {"Accept": "application/json"} + + # make the project key upper case + project_key = generate_valid_project_key(project_key) - issue_url = f"https://{hostname}/rest/api/3/search?jql=project={project_key} AND issuetype!=Epic" + # issues + issue_url = generate_url( + hostname, + f"/rest/api/3/search?jql=project={project_key} AND issuetype!=Epic", + ) issue_response = requests.request( "GET", issue_url, headers=headers, auth=auth ).json()["total"] - module_url = f"https://{hostname}/rest/api/3/search?jql=project={project_key} AND issuetype=Epic" + # modules + module_url = generate_url( + hostname, f"/rest/api/3/search?jql=project={project_key} AND issuetype=Epic" + ) module_response = requests.request( "GET", module_url, headers=headers, auth=auth ).json()["total"] - status_url = f"https://{hostname}/rest/api/3/project/${project_key}/statuses" + # status + status_url = generate_url( + hostname, f"/rest/api/3/project/${project_key}/statuses" + ) status_response = requests.request( "GET", status_url, headers=headers, auth=auth ).json() - labels_url = f"https://{hostname}/rest/api/3/label/?jql=project={project_key}" + # labels + labels_url = generate_url( + hostname, f"/rest/api/3/label/?jql=project={project_key}" + ) labels_response = requests.request( "GET", labels_url, headers=headers, auth=auth ).json()["total"] - users_url = ( - f"https://{hostname}/rest/api/3/users/search?jql=project={project_key}" + # users + users_url = generate_url( + hostname, f"/rest/api/3/users/search?jql=project={project_key}" ) users_response = requests.request( "GET", users_url, headers=headers, auth=auth From 0e49d616b70be88f2b6b73af5875ed3d2e145a10 Mon Sep 17 00:00:00 2001 From: Manish Gupta <59428681+mguptahub@users.noreply.github.com> Date: Tue, 9 Jan 2024 22:50:51 +0530 Subject: [PATCH 19/68] fixes web container public assets (#3336) --- web/Dockerfile.web | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/web/Dockerfile.web b/web/Dockerfile.web index d9260e61d..e0d525c2c 100644 --- a/web/Dockerfile.web +++ b/web/Dockerfile.web @@ -1,3 +1,6 @@ +# ****************************************** +# STAGE 1: Build the project +# ****************************************** FROM node:18-alpine AS builder RUN apk add --no-cache libc6-compat # Set working directory @@ -8,6 +11,10 @@ COPY . . RUN turbo prune --scope=web --docker + +# ****************************************** +# STAGE 2: Install dependencies & build the project +# ****************************************** # Add lockfile and package.json's of isolated subworkspace FROM node:18-alpine AS installer @@ -31,6 +38,11 @@ ENV NEXT_PUBLIC_DEPLOY_URL=$NEXT_PUBLIC_DEPLOY_URL RUN yarn turbo run build --filter=web + +# ****************************************** +# STAGE 3: Copy the project and start it +# ****************************************** + FROM node:18-alpine AS runner WORKDIR /app @@ -46,6 +58,7 @@ COPY --from=installer /app/web/package.json . # https://nextjs.org/docs/advanced-features/output-file-tracing COPY --from=installer --chown=captain:plane /app/web/.next/standalone ./ COPY --from=installer --chown=captain:plane /app/web/.next ./web/.next +COPY --from=installer --chown=captain:plane /app/web/public ./web/public ARG NEXT_PUBLIC_API_BASE_URL="" ARG NEXT_PUBLIC_DEPLOY_URL="" From 0f99fb302b12c9b7644cf2170815384da1ae08ba Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Wed, 10 Jan 2024 12:21:24 +0530 Subject: [PATCH 20/68] chore: modal and dropdown improvement (#3332) * dev: dropdown key down custom hook added * chore: plane ui dropdowns updated * chore: cycle and module tab index added in modals * chore: view and page tab index added in modals * chore: issue modal tab indexing added * chore: project modal tab indexing added * fix: build fix * build-error: build error in pages new structure and reverted back to old page structure --------- Co-authored-by: gurusainath --- packages/ui/src/dropdowns/custom-menu.tsx | 80 ++-- .../ui/src/dropdowns/custom-search-select.tsx | 168 ++++---- packages/ui/src/dropdowns/custom-select.tsx | 65 +++- packages/ui/src/dropdowns/helper.tsx | 1 + .../ui/src/hooks/use-dropdown-key-down.tsx | 24 ++ .../src/hooks/use-outside-click-detector.tsx | 19 + web/components/core/image-picker-popover.tsx | 34 +- web/components/cycles/form.tsx | 9 +- web/components/dropdowns/cycle.tsx | 114 +++--- web/components/dropdowns/date.tsx | 55 ++- web/components/dropdowns/estimate.tsx | 114 +++--- .../dropdowns/member/project-member.tsx | 113 +++--- web/components/dropdowns/member/types.d.ts | 1 + web/components/dropdowns/module.tsx | 114 +++--- web/components/dropdowns/priority.tsx | 109 +++--- web/components/dropdowns/project.tsx | 114 +++--- web/components/dropdowns/state.tsx | 114 +++--- web/components/emoji-icon-picker/types.d.ts | 1 + .../filters/header/helpers/dropdown.tsx | 38 +- web/components/issues/issue-modal/form.tsx | 24 +- web/components/issues/select/label.tsx | 257 ++++++------ web/components/modules/form.tsx | 13 +- web/components/modules/select/status.tsx | 4 +- web/components/pages/page-form.tsx | 8 +- .../project/create-project-modal.tsx | 12 +- web/components/views/form.tsx | 8 +- web/hooks/use-dropdown-key-down.tsx | 24 ++ web/store/page.store.ts | 365 ++++++++++++++---- web/store/project-page.store.ts | 12 +- web/store/root.store.ts | 3 +- 30 files changed, 1300 insertions(+), 717 deletions(-) create mode 100644 packages/ui/src/hooks/use-dropdown-key-down.tsx create mode 100644 packages/ui/src/hooks/use-outside-click-detector.tsx create mode 100644 web/hooks/use-dropdown-key-down.tsx diff --git a/packages/ui/src/dropdowns/custom-menu.tsx b/packages/ui/src/dropdowns/custom-menu.tsx index 7c2d95a18..d6b0281ce 100644 --- a/packages/ui/src/dropdowns/custom-menu.tsx +++ b/packages/ui/src/dropdowns/custom-menu.tsx @@ -2,6 +2,9 @@ import * as React from "react"; // react-poppper import { usePopper } from "react-popper"; +// hooks +import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down"; +import useOutsideClickDetector from "../hooks/use-outside-click-detector"; // headless ui import { Menu } from "@headlessui/react"; // type @@ -27,16 +30,35 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { verticalEllipsis = false, width = "auto", menuButtonOnClick, + tabIndex, } = props; const [referenceElement, setReferenceElement] = React.useState(null); const [popperElement, setPopperElement] = React.useState(null); + const [isOpen, setIsOpen] = React.useState(false); + // refs + const dropdownRef = React.useRef(null); const { styles, attributes } = usePopper(referenceElement, popperElement, { placement: placement ?? "auto", }); + + const openDropdown = () => { + setIsOpen(true); + if (referenceElement) referenceElement.focus(); + }; + const closeDropdown = () => setIsOpen(false); + const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen); + useOutsideClickDetector(dropdownRef, closeDropdown); + return ( - + {({ open }) => ( <> {customButton ? ( @@ -44,7 +66,10 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { diff --git a/packages/ui/src/dropdowns/custom-search-select.tsx b/packages/ui/src/dropdowns/custom-search-select.tsx index 759a88349..f9af00c93 100644 --- a/packages/ui/src/dropdowns/custom-search-select.tsx +++ b/packages/ui/src/dropdowns/custom-search-select.tsx @@ -1,7 +1,10 @@ -import React, { useState } from "react"; +import React, { useRef, useState } from "react"; // react-popper import { usePopper } from "react-popper"; +// hooks +import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down"; +import useOutsideClickDetector from "../hooks/use-outside-click-detector"; // headless ui import { Combobox } from "@headlessui/react"; // icons @@ -29,11 +32,15 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => { optionsClassName = "", value, width = "auto", + tabIndex, } = props; const [query, setQuery] = useState(""); const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState(null); + const [isOpen, setIsOpen] = useState(false); + // refs + const dropdownRef = useRef(null); const { styles, attributes } = usePopper(referenceElement, popperElement, { placement: placement ?? "bottom-start", @@ -50,8 +57,23 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => { if (multiple) comboboxProps.multiple = true; + const openDropdown = () => { + setIsOpen(true); + if (referenceElement) referenceElement.focus(); + }; + const closeDropdown = () => setIsOpen(false); + const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen); + useOutsideClickDetector(dropdownRef, closeDropdown); + return ( - + {({ open }: { open: boolean }) => { if (open && onOpen) onOpen(); @@ -67,6 +89,7 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => { ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer hover:bg-custom-background-80" } ${customButtonClassName}`} + onClick={openDropdown} > {customButton} @@ -83,86 +106,89 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => { ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer hover:bg-custom-background-80" } ${buttonClassName}`} + onClick={openDropdown} > {label} {!noChevron && !disabled &&
    )} @@ -140,6 +144,7 @@ export const CycleForm: React.FC = (props) => { buttonVariant="border-with-text" placeholder="End date" minDate={minDate} + tabIndex={4} />
    )} @@ -149,10 +154,10 @@ export const CycleForm: React.FC = (props) => {
    - -
    diff --git a/web/components/dropdowns/cycle.tsx b/web/components/dropdowns/cycle.tsx index 4d5c60acd..5a861a8f9 100644 --- a/web/components/dropdowns/cycle.tsx +++ b/web/components/dropdowns/cycle.tsx @@ -1,4 +1,4 @@ -import { Fragment, ReactNode, useEffect, useState } from "react"; +import { Fragment, ReactNode, useEffect, useRef, useState } from "react"; import { observer } from "mobx-react-lite"; import { Combobox } from "@headlessui/react"; import { usePopper } from "react-popper"; @@ -6,6 +6,8 @@ import { Placement } from "@popperjs/core"; import { Check, ChevronDown, Search } from "lucide-react"; // hooks import { useApplication, useCycle } from "hooks/store"; +import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; // icons import { ContrastIcon } from "@plane/ui"; // helpers @@ -26,6 +28,7 @@ type Props = { placement?: Placement; projectId: string; value: string | null; + tabIndex?: number; }; type ButtonProps = { @@ -104,9 +107,13 @@ export const CycleDropdown: React.FC = observer((props) => { placement, projectId, value, + tabIndex, } = props; // states const [query, setQuery] = useState(""); + const [isOpen, setIsOpen] = useState(false); + // refs + const dropdownRef = useRef(null); // popper-js refs const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState(null); @@ -166,15 +173,26 @@ export const CycleDropdown: React.FC = observer((props) => { const selectedCycle = value ? getCycleById(value) : null; + const openDropdown = () => { + setIsOpen(true); + if (referenceElement) referenceElement.focus(); + }; + const closeDropdown = () => setIsOpen(false); + const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen); + useOutsideClickDetector(dropdownRef, closeDropdown); + return ( {button ? ( @@ -182,6 +200,7 @@ export const CycleDropdown: React.FC = observer((props) => { ref={setReferenceElement} type="button" className={cn("block h-full w-full outline-none", buttonContainerClassName)} + onClick={openDropdown} > {button} @@ -197,6 +216,7 @@ export const CycleDropdown: React.FC = observer((props) => { }, buttonContainerClassName )} + onClick={openDropdown} > {buttonVariant === "border-with-text" ? ( = observer((props) => { )} - -
    -
    - - setQuery(e.target.value)} - placeholder="Search" - displayValue={(assigned: any) => assigned?.name} - /> -
    -
    - {filteredOptions ? ( - filteredOptions.length > 0 ? ( - filteredOptions.map((option) => ( - - `w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${ - active ? "bg-custom-background-80" : "" - } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` - } - > - {({ selected }) => ( - <> - {option.content} - {selected && } - - )} - - )) + {isOpen && ( + +
    +
    + + setQuery(e.target.value)} + placeholder="Search" + displayValue={(assigned: any) => assigned?.name} + /> +
    +
    + {filteredOptions ? ( + filteredOptions.length > 0 ? ( + filteredOptions.map((option) => ( + + `w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${ + active ? "bg-custom-background-80" : "" + } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` + } + > + {({ selected }) => ( + <> + {option.content} + {selected && } + + )} + + )) + ) : ( +

    No matches found

    + ) ) : ( -

    No matches found

    - ) - ) : ( -

    Loading...

    - )} +

    Loading...

    + )} +
    -
    - + + )} ); }); diff --git a/web/components/dropdowns/date.tsx b/web/components/dropdowns/date.tsx index e791413b4..92f35b910 100644 --- a/web/components/dropdowns/date.tsx +++ b/web/components/dropdowns/date.tsx @@ -1,9 +1,12 @@ -import React, { useState } from "react"; +import React, { useRef, useState } from "react"; import { Popover } from "@headlessui/react"; import DatePicker from "react-datepicker"; import { usePopper } from "react-popper"; import { CalendarDays, X } from "lucide-react"; // import "react-datepicker/dist/react-datepicker.css"; +// hooks +import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; // helpers import { renderFormattedDate } from "helpers/date-time.helper"; import { cn } from "helpers/common.helper"; @@ -25,6 +28,7 @@ type Props = { placement?: Placement; value: Date | string | null; closeOnSelect?: boolean; + tabIndex?: number; }; type ButtonProps = { @@ -124,7 +128,11 @@ export const DateDropdown: React.FC = (props) => { placement, value, closeOnSelect = true, + tabIndex, } = props; + const [isOpen, setIsOpen] = useState(false); + // refs + const dropdownRef = useRef(null); // popper-js refs const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState(null); @@ -143,8 +151,16 @@ export const DateDropdown: React.FC = (props) => { const isDateSelected = value !== null && value !== undefined && value.toString().trim() !== ""; + const openDropdown = () => { + setIsOpen(true); + if (referenceElement) referenceElement.focus(); + }; + const closeDropdown = () => setIsOpen(false); + const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen); + useOutsideClickDetector(dropdownRef, closeDropdown); + return ( - + {({ close }) => ( <> @@ -159,6 +175,7 @@ export const DateDropdown: React.FC = (props) => { }, buttonContainerClassName )} + onClick={openDropdown} > {buttonVariant === "border-with-text" ? ( = (props) => { ) : null} - -
    - { - onChange(val); - if (closeOnSelect) close(); - }} - dateFormat="dd-MM-yyyy" - minDate={minDate} - maxDate={maxDate} - calendarClassName="shadow-custom-shadow-rg rounded" - inline - /> -
    -
    + {isOpen && ( + +
    + { + onChange(val); + if (closeOnSelect) close(); + }} + dateFormat="dd-MM-yyyy" + minDate={minDate} + maxDate={maxDate} + calendarClassName="shadow-custom-shadow-rg rounded" + inline + /> +
    +
    + )} )}
    diff --git a/web/components/dropdowns/estimate.tsx b/web/components/dropdowns/estimate.tsx index 8d538f53f..9138b2bea 100644 --- a/web/components/dropdowns/estimate.tsx +++ b/web/components/dropdowns/estimate.tsx @@ -1,4 +1,4 @@ -import { Fragment, ReactNode, useEffect, useState } from "react"; +import { Fragment, ReactNode, useEffect, useRef, useState } from "react"; import { observer } from "mobx-react-lite"; import { Combobox } from "@headlessui/react"; import { usePopper } from "react-popper"; @@ -7,6 +7,8 @@ import { Check, ChevronDown, Search, Triangle } from "lucide-react"; import sortBy from "lodash/sortBy"; // hooks import { useApplication, useEstimate } from "hooks/store"; +import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; // helpers import { cn } from "helpers/common.helper"; // types @@ -24,6 +26,7 @@ type Props = { placement?: Placement; projectId: string; value: number | null; + tabIndex?: number; }; type ButtonProps = { @@ -102,9 +105,13 @@ export const EstimateDropdown: React.FC = observer((props) => { placement, projectId, value, + tabIndex, } = props; // states const [query, setQuery] = useState(""); + const [isOpen, setIsOpen] = useState(false); + // refs + const dropdownRef = useRef(null); // popper-js refs const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState(null); @@ -160,15 +167,26 @@ export const EstimateDropdown: React.FC = observer((props) => { const selectedEstimate = value !== null ? getEstimatePointValue(value) : null; + const openDropdown = () => { + setIsOpen(true); + if (referenceElement) referenceElement.focus(); + }; + const closeDropdown = () => setIsOpen(false); + const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen); + useOutsideClickDetector(dropdownRef, closeDropdown); + return ( {button ? ( @@ -176,6 +194,7 @@ export const EstimateDropdown: React.FC = observer((props) => { ref={setReferenceElement} type="button" className={cn("block h-full w-full outline-none", buttonContainerClassName)} + onClick={openDropdown} > {button} @@ -191,6 +210,7 @@ export const EstimateDropdown: React.FC = observer((props) => { }, buttonContainerClassName )} + onClick={openDropdown} > {buttonVariant === "border-with-text" ? ( = observer((props) => { )} - -
    -
    - - setQuery(e.target.value)} - placeholder="Search" - displayValue={(assigned: any) => assigned?.name} - /> -
    -
    - {filteredOptions ? ( - filteredOptions.length > 0 ? ( - filteredOptions.map((option) => ( - - `w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${ - active ? "bg-custom-background-80" : "" - } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` - } - > - {({ selected }) => ( - <> - {option.content} - {selected && } - - )} - - )) + {isOpen && ( + +
    +
    + + setQuery(e.target.value)} + placeholder="Search" + displayValue={(assigned: any) => assigned?.name} + /> +
    +
    + {filteredOptions ? ( + filteredOptions.length > 0 ? ( + filteredOptions.map((option) => ( + + `w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${ + active ? "bg-custom-background-80" : "" + } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` + } + > + {({ selected }) => ( + <> + {option.content} + {selected && } + + )} + + )) + ) : ( +

    No matching results

    + ) ) : ( -

    No matching results

    - ) - ) : ( -

    Loading...

    - )} +

    Loading...

    + )} +
    -
    - + + )} ); }); diff --git a/web/components/dropdowns/member/project-member.tsx b/web/components/dropdowns/member/project-member.tsx index 18d317a56..1e6856274 100644 --- a/web/components/dropdowns/member/project-member.tsx +++ b/web/components/dropdowns/member/project-member.tsx @@ -1,10 +1,12 @@ -import { Fragment, useEffect, useState } from "react"; +import { Fragment, useEffect, useRef, useState } from "react"; import { observer } from "mobx-react-lite"; import { Combobox } from "@headlessui/react"; import { usePopper } from "react-popper"; import { Check, Search } from "lucide-react"; // hooks import { useApplication, useMember, useUser } from "hooks/store"; +import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; // components import { BackgroundButton, BorderButton, TransparentButton } from "components/dropdowns"; // icons @@ -33,9 +35,13 @@ export const ProjectMemberDropdown: React.FC = observer((props) => { placement, projectId, value, + tabIndex, } = props; // states const [query, setQuery] = useState(""); + const [isOpen, setIsOpen] = useState(false); + // refs + const dropdownRef = useRef(null); // popper-js refs const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState(null); @@ -93,12 +99,23 @@ export const ProjectMemberDropdown: React.FC = observer((props) => { if (!projectMemberIds) fetchProjectMembers(workspaceSlug, projectId); }, [fetchProjectMembers, projectId, projectMemberIds, workspaceSlug]); + const openDropdown = () => { + setIsOpen(true); + if (referenceElement) referenceElement.focus(); + }; + const closeDropdown = () => setIsOpen(false); + const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen); + useOutsideClickDetector(dropdownRef, closeDropdown); + return ( @@ -107,6 +124,7 @@ export const ProjectMemberDropdown: React.FC = observer((props) => { ref={setReferenceElement} type="button" className={cn("block h-full w-full outline-none", buttonContainerClassName)} + onClick={openDropdown} > {button} @@ -122,6 +140,7 @@ export const ProjectMemberDropdown: React.FC = observer((props) => { }, buttonContainerClassName )} + onClick={openDropdown} > {buttonVariant === "border-with-text" ? ( = observer((props) => { )} - -
    -
    - - setQuery(e.target.value)} - placeholder="Search" - displayValue={(assigned: any) => assigned?.name} - /> -
    -
    - {filteredOptions ? ( - filteredOptions.length > 0 ? ( - filteredOptions.map((option) => ( - - `w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${ - active ? "bg-custom-background-80" : "" - } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` - } - > - {({ selected }) => ( - <> - {option.content} - {selected && } - - )} - - )) + {isOpen && ( + +
    +
    + + setQuery(e.target.value)} + placeholder="Search" + displayValue={(assigned: any) => assigned?.name} + /> +
    +
    + {filteredOptions ? ( + filteredOptions.length > 0 ? ( + filteredOptions.map((option) => ( + + `w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${ + active ? "bg-custom-background-80" : "" + } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` + } + > + {({ selected }) => ( + <> + {option.content} + {selected && } + + )} + + )) + ) : ( +

    No matching results

    + ) ) : ( -

    No matching results

    - ) - ) : ( -

    Loading...

    - )} +

    Loading...

    + )} +
    -
    - + + )} ); }); diff --git a/web/components/dropdowns/member/types.d.ts b/web/components/dropdowns/member/types.d.ts index 4c0bff67b..f5f81a5c6 100644 --- a/web/components/dropdowns/member/types.d.ts +++ b/web/components/dropdowns/member/types.d.ts @@ -11,6 +11,7 @@ export type MemberDropdownProps = { dropdownArrow?: boolean; placeholder?: string; placement?: Placement; + tabIndex?: number; } & ( | { multiple: false; diff --git a/web/components/dropdowns/module.tsx b/web/components/dropdowns/module.tsx index ff35c26b6..e0d6b52f7 100644 --- a/web/components/dropdowns/module.tsx +++ b/web/components/dropdowns/module.tsx @@ -1,4 +1,4 @@ -import { Fragment, ReactNode, useEffect, useState } from "react"; +import { Fragment, ReactNode, useEffect, useRef, useState } from "react"; import { observer } from "mobx-react-lite"; import { Combobox } from "@headlessui/react"; import { usePopper } from "react-popper"; @@ -6,6 +6,8 @@ import { Placement } from "@popperjs/core"; import { Check, ChevronDown, Search } from "lucide-react"; // hooks import { useApplication, useModule } from "hooks/store"; +import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; // icons import { DiceIcon } from "@plane/ui"; // helpers @@ -26,6 +28,7 @@ type Props = { placement?: Placement; projectId: string; value: string | null; + tabIndex?: number; }; type DropdownOptions = @@ -104,9 +107,13 @@ export const ModuleDropdown: React.FC = observer((props) => { placement, projectId, value, + tabIndex, } = props; // states const [query, setQuery] = useState(""); + const [isOpen, setIsOpen] = useState(false); + // refs + const dropdownRef = useRef(null); // popper-js refs const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState(null); @@ -166,15 +173,26 @@ export const ModuleDropdown: React.FC = observer((props) => { const selectedModule = value ? getModuleById(value) : null; + const openDropdown = () => { + setIsOpen(true); + if (referenceElement) referenceElement.focus(); + }; + const closeDropdown = () => setIsOpen(false); + const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen); + useOutsideClickDetector(dropdownRef, closeDropdown); + return ( {button ? ( @@ -182,6 +200,7 @@ export const ModuleDropdown: React.FC = observer((props) => { ref={setReferenceElement} type="button" className={cn("block h-full w-full outline-none", buttonContainerClassName)} + onClick={openDropdown} > {button} @@ -197,6 +216,7 @@ export const ModuleDropdown: React.FC = observer((props) => { }, buttonContainerClassName )} + onClick={openDropdown} > {buttonVariant === "border-with-text" ? ( = observer((props) => { )} - -
    -
    - - setQuery(e.target.value)} - placeholder="Search" - displayValue={(assigned: any) => assigned?.name} - /> -
    -
    - {filteredOptions ? ( - filteredOptions.length > 0 ? ( - filteredOptions.map((option) => ( - - `w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${ - active ? "bg-custom-background-80" : "" - } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` - } - > - {({ selected }) => ( - <> - {option.content} - {selected && } - - )} - - )) + {isOpen && ( + +
    +
    + + setQuery(e.target.value)} + placeholder="Search" + displayValue={(assigned: any) => assigned?.name} + /> +
    +
    + {filteredOptions ? ( + filteredOptions.length > 0 ? ( + filteredOptions.map((option) => ( + + `w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${ + active ? "bg-custom-background-80" : "" + } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` + } + > + {({ selected }) => ( + <> + {option.content} + {selected && } + + )} + + )) + ) : ( +

    No matching results

    + ) ) : ( -

    No matching results

    - ) - ) : ( -

    Loading...

    - )} +

    Loading...

    + )} +
    -
    - + + )} ); }); diff --git a/web/components/dropdowns/priority.tsx b/web/components/dropdowns/priority.tsx index 5c467a7b6..6712922ab 100644 --- a/web/components/dropdowns/priority.tsx +++ b/web/components/dropdowns/priority.tsx @@ -1,8 +1,11 @@ -import { Fragment, ReactNode, useState } from "react"; +import { Fragment, ReactNode, useRef, useState } from "react"; import { Combobox } from "@headlessui/react"; import { usePopper } from "react-popper"; import { Placement } from "@popperjs/core"; import { Check, ChevronDown, Search } from "lucide-react"; +// hooks +import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; // icons import { PriorityIcon } from "@plane/ui"; // helpers @@ -25,6 +28,7 @@ type Props = { onChange: (val: TIssuePriorities) => void; placement?: Placement; value: TIssuePriorities; + tabIndex?: number; }; type ButtonProps = { @@ -210,9 +214,13 @@ export const PriorityDropdown: React.FC = (props) => { onChange, placement, value, + tabIndex, } = props; // states const [query, setQuery] = useState(""); + const [isOpen, setIsOpen] = useState(false); + // refs + const dropdownRef = useRef(null); // popper-js refs const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState(null); @@ -269,15 +277,26 @@ export const PriorityDropdown: React.FC = (props) => { const filteredOptions = query === "" ? options : options.filter((o) => o.query.toLowerCase().includes(query.toLowerCase())); + const openDropdown = () => { + setIsOpen(true); + if (referenceElement) referenceElement.focus(); + }; + const closeDropdown = () => setIsOpen(false); + const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen); + useOutsideClickDetector(dropdownRef, closeDropdown); + return ( {button ? ( @@ -285,6 +304,7 @@ export const PriorityDropdown: React.FC = (props) => { ref={setReferenceElement} type="button" className={cn("block h-full w-full outline-none", buttonContainerClassName)} + onClick={openDropdown} > {button} @@ -300,6 +320,7 @@ export const PriorityDropdown: React.FC = (props) => { }, buttonContainerClassName )} + onClick={openDropdown} > {buttonVariant === "border-with-text" ? ( = (props) => { )} - -
    -
    - - setQuery(e.target.value)} - placeholder="Search" - displayValue={(assigned: any) => assigned?.name} - /> + {isOpen && ( + +
    +
    + + setQuery(e.target.value)} + placeholder="Search" + displayValue={(assigned: any) => assigned?.name} + /> +
    +
    + {filteredOptions.length > 0 ? ( + filteredOptions.map((option) => ( + + `w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${ + active ? "bg-custom-background-80" : "" + } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` + } + > + {({ selected }) => ( + <> + {option.content} + {selected && } + + )} + + )) + ) : ( +

    No matching results

    + )} +
    -
    - {filteredOptions.length > 0 ? ( - filteredOptions.map((option) => ( - - `w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${ - active ? "bg-custom-background-80" : "" - } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` - } - > - {({ selected }) => ( - <> - {option.content} - {selected && } - - )} - - )) - ) : ( -

    No matching results

    - )} -
    -
    - + + )} ); }; diff --git a/web/components/dropdowns/project.tsx b/web/components/dropdowns/project.tsx index 65169dd88..7649e1d75 100644 --- a/web/components/dropdowns/project.tsx +++ b/web/components/dropdowns/project.tsx @@ -1,4 +1,4 @@ -import { Fragment, ReactNode, useState } from "react"; +import { Fragment, ReactNode, useRef, useState } from "react"; import { observer } from "mobx-react-lite"; import { Combobox } from "@headlessui/react"; import { usePopper } from "react-popper"; @@ -6,6 +6,8 @@ import { Placement } from "@popperjs/core"; import { Check, ChevronDown, Search } from "lucide-react"; // hooks import { useProject } from "hooks/store"; +import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; // helpers import { cn } from "helpers/common.helper"; import { renderEmoji } from "helpers/emoji.helper"; @@ -24,6 +26,7 @@ type Props = { onChange: (val: string) => void; placement?: Placement; value: string | null; + tabIndex?: number; }; type ButtonProps = { @@ -99,9 +102,13 @@ export const ProjectDropdown: React.FC = observer((props) => { onChange, placement, value, + tabIndex, } = props; // states const [query, setQuery] = useState(""); + const [isOpen, setIsOpen] = useState(false); + // refs + const dropdownRef = useRef(null); // popper-js refs const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState(null); @@ -146,15 +153,26 @@ export const ProjectDropdown: React.FC = observer((props) => { const selectedProject = value ? getProjectById(value) : null; + const openDropdown = () => { + setIsOpen(true); + if (referenceElement) referenceElement.focus(); + }; + const closeDropdown = () => setIsOpen(false); + const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen); + useOutsideClickDetector(dropdownRef, closeDropdown); + return ( {button ? ( @@ -162,6 +180,7 @@ export const ProjectDropdown: React.FC = observer((props) => { ref={setReferenceElement} type="button" className={cn("block h-full w-full outline-none", buttonContainerClassName)} + onClick={openDropdown} > {button} @@ -177,6 +196,7 @@ export const ProjectDropdown: React.FC = observer((props) => { }, buttonContainerClassName )} + onClick={openDropdown} > {buttonVariant === "border-with-text" ? ( = observer((props) => { )} - -
    -
    - - setQuery(e.target.value)} - placeholder="Search" - displayValue={(assigned: any) => assigned?.name} - /> -
    -
    - {filteredOptions ? ( - filteredOptions.length > 0 ? ( - filteredOptions.map((option) => ( - - `w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${ - active ? "bg-custom-background-80" : "" - } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` - } - > - {({ selected }) => ( - <> - {option.content} - {selected && } - - )} - - )) + {isOpen && ( + +
    +
    + + setQuery(e.target.value)} + placeholder="Search" + displayValue={(assigned: any) => assigned?.name} + /> +
    +
    + {filteredOptions ? ( + filteredOptions.length > 0 ? ( + filteredOptions.map((option) => ( + + `w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${ + active ? "bg-custom-background-80" : "" + } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` + } + > + {({ selected }) => ( + <> + {option.content} + {selected && } + + )} + + )) + ) : ( +

    No matching results

    + ) ) : ( -

    No matching results

    - ) - ) : ( -

    Loading...

    - )} +

    Loading...

    + )} +
    -
    - + + )} ); }); diff --git a/web/components/dropdowns/state.tsx b/web/components/dropdowns/state.tsx index c7ba9eced..ba48af4bd 100644 --- a/web/components/dropdowns/state.tsx +++ b/web/components/dropdowns/state.tsx @@ -1,4 +1,4 @@ -import { Fragment, ReactNode, useEffect, useState } from "react"; +import { Fragment, ReactNode, useEffect, useRef, useState } from "react"; import { observer } from "mobx-react-lite"; import { Combobox } from "@headlessui/react"; import { usePopper } from "react-popper"; @@ -6,6 +6,8 @@ import { Placement } from "@popperjs/core"; import { Check, ChevronDown, Search } from "lucide-react"; // hooks import { useApplication, useProjectState } from "hooks/store"; +import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; // icons import { StateGroupIcon } from "@plane/ui"; // helpers @@ -26,6 +28,7 @@ type Props = { placement?: Placement; projectId: string; value: string; + tabIndex?: number; }; type ButtonProps = { @@ -96,9 +99,13 @@ export const StateDropdown: React.FC = observer((props) => { placement, projectId, value, + tabIndex, } = props; // states const [query, setQuery] = useState(""); + const [isOpen, setIsOpen] = useState(false); + // refs + const dropdownRef = useRef(null); // popper-js refs const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState(null); @@ -144,15 +151,26 @@ export const StateDropdown: React.FC = observer((props) => { const selectedState = getStateById(value); + const openDropdown = () => { + setIsOpen(true); + if (referenceElement) referenceElement.focus(); + }; + const closeDropdown = () => setIsOpen(false); + const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen); + useOutsideClickDetector(dropdownRef, closeDropdown); + return ( {button ? ( @@ -160,6 +178,7 @@ export const StateDropdown: React.FC = observer((props) => { ref={setReferenceElement} type="button" className={cn("block h-full w-full outline-none", buttonContainerClassName)} + onClick={openDropdown} > {button} @@ -175,6 +194,7 @@ export const StateDropdown: React.FC = observer((props) => { }, buttonContainerClassName )} + onClick={openDropdown} > {buttonVariant === "border-with-text" ? ( = observer((props) => { )} - -
    -
    - - setQuery(e.target.value)} - placeholder="Search" - displayValue={(assigned: any) => assigned?.name} - /> -
    -
    - {filteredOptions ? ( - filteredOptions.length > 0 ? ( - filteredOptions.map((option) => ( - - `w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${ - active ? "bg-custom-background-80" : "" - } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` - } - > - {({ selected }) => ( - <> - {option.content} - {selected && } - - )} - - )) + {isOpen && ( + +
    +
    + + setQuery(e.target.value)} + placeholder="Search" + displayValue={(assigned: any) => assigned?.name} + /> +
    +
    + {filteredOptions ? ( + filteredOptions.length > 0 ? ( + filteredOptions.map((option) => ( + + `w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${ + active ? "bg-custom-background-80" : "" + } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` + } + > + {({ selected }) => ( + <> + {option.content} + {selected && } + + )} + + )) + ) : ( +

    No matches found

    + ) ) : ( -

    No matches found

    - ) - ) : ( -

    Loading...

    - )} +

    Loading...

    + )} +
    -
    - + + )} ); }); diff --git a/web/components/emoji-icon-picker/types.d.ts b/web/components/emoji-icon-picker/types.d.ts index 01220ed44..8a0b54342 100644 --- a/web/components/emoji-icon-picker/types.d.ts +++ b/web/components/emoji-icon-picker/types.d.ts @@ -11,4 +11,5 @@ export type Props = { ) => void; onIconColorChange?: (data: any) => void; disabled?: boolean; + tabIndex?: number; }; diff --git a/web/components/issues/issue-layouts/filters/header/helpers/dropdown.tsx b/web/components/issues/issue-layouts/filters/header/helpers/dropdown.tsx index 9c0ef8511..518403ee1 100644 --- a/web/components/issues/issue-layouts/filters/header/helpers/dropdown.tsx +++ b/web/components/issues/issue-layouts/filters/header/helpers/dropdown.tsx @@ -1,7 +1,10 @@ -import React, { Fragment, useState } from "react"; +import React, { Fragment, useRef, useState } from "react"; import { usePopper } from "react-popper"; -import { Popover, Transition } from "@headlessui/react"; +import { Popover } from "@headlessui/react"; import { Placement } from "@popperjs/core"; +// hooks +import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; // ui import { Button } from "@plane/ui"; // icons @@ -12,20 +15,32 @@ type Props = { title?: string; placement?: Placement; disabled?: boolean; + tabIndex?: number; }; export const FiltersDropdown: React.FC = (props) => { - const { children, title = "Dropdown", placement, disabled = false } = props; + const { children, title = "Dropdown", placement, disabled = false, tabIndex } = props; const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState(null); + const [isOpen, setIsOpen] = useState(false); + // refs + const dropdownRef = useRef(null); const { styles, attributes } = usePopper(referenceElement, popperElement, { placement: placement ?? "auto", }); + const openDropdown = () => { + setIsOpen(true); + if (referenceElement) referenceElement.focus(); + }; + const closeDropdown = () => setIsOpen(false); + const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen); + useOutsideClickDetector(dropdownRef, closeDropdown); + return ( - + {({ open }) => { if (open) { } @@ -40,22 +55,15 @@ export const FiltersDropdown: React.FC = (props) => { appendIcon={ } + onClick={openDropdown} >
    {title}
    - - + {isOpen && ( +
    = (props) => {
    {children}
    -
    + )} ); }} diff --git a/web/components/issues/issue-modal/form.tsx b/web/components/issues/issue-modal/form.tsx index 7f00f6216..687e32302 100644 --- a/web/components/issues/issue-modal/form.tsx +++ b/web/components/issues/issue-modal/form.tsx @@ -209,6 +209,7 @@ export const IssueFormRoot: FC = observer((props) => { handleFormChange(); }} buttonVariant="border-with-text" + tabIndex={19} />
    )} @@ -238,6 +239,7 @@ export const IssueFormRoot: FC = observer((props) => { handleFormChange(); setSelectedParentIssue(null); }} + tabIndex={20} />
    @@ -268,6 +270,7 @@ export const IssueFormRoot: FC = observer((props) => { hasError={Boolean(errors.name)} placeholder="Issue Title" className="resize-none text-xl w-full" + tabIndex={1} /> )} /> @@ -281,6 +284,7 @@ export const IssueFormRoot: FC = observer((props) => { }`} onClick={handleAutoGenerateDescription} disabled={iAmFeelingLucky} + tabIndex={3} > {iAmFeelingLucky ? ( "Generating response" @@ -309,6 +313,7 @@ export const IssueFormRoot: FC = observer((props) => { 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)} + tabIndex={4} > AI @@ -340,6 +345,7 @@ export const IssueFormRoot: FC = observer((props) => { }} mentionHighlights={mentionHighlights} mentionSuggestions={mentionSuggestions} + // tabIndex={2} /> )} /> @@ -358,6 +364,7 @@ export const IssueFormRoot: FC = observer((props) => { }} projectId={projectId} buttonVariant="border-with-text" + tabIndex={6} />
    )} @@ -374,6 +381,7 @@ export const IssueFormRoot: FC = observer((props) => { handleFormChange(); }} buttonVariant="border-with-text" + tabIndex={7} />
    )} @@ -394,6 +402,7 @@ export const IssueFormRoot: FC = observer((props) => { buttonClassName={value?.length > 0 ? "hover:bg-transparent px-0" : ""} placeholder="Assignees" multiple + tabIndex={8} />
    )} @@ -411,6 +420,7 @@ export const IssueFormRoot: FC = observer((props) => { handleFormChange(); }} projectId={projectId} + tabIndex={9} />
    )} @@ -429,6 +439,7 @@ export const IssueFormRoot: FC = observer((props) => { buttonVariant="border-with-text" placeholder="Start date" maxDate={maxDate ?? undefined} + tabIndex={10} />
    )} @@ -447,6 +458,7 @@ export const IssueFormRoot: FC = observer((props) => { buttonVariant="border-with-text" placeholder="Due date" minDate={minDate ?? undefined} + tabIndex={11} />
    )} @@ -465,6 +477,7 @@ export const IssueFormRoot: FC = observer((props) => { }} value={value} buttonVariant="border-with-text" + tabIndex={12} />
    )} @@ -484,6 +497,7 @@ export const IssueFormRoot: FC = observer((props) => { handleFormChange(); }} buttonVariant="border-with-text" + tabIndex={13} />
    )} @@ -503,6 +517,7 @@ export const IssueFormRoot: FC = observer((props) => { }} projectId={projectId} buttonVariant="border-with-text" + tabIndex={14} />
    )} @@ -532,6 +547,7 @@ export const IssueFormRoot: FC = observer((props) => { } placement="bottom-start" + tabIndex={15} > {watch("parent_id") ? ( <> @@ -578,6 +594,10 @@ export const IssueFormRoot: FC = observer((props) => {
    setCreateMore((prevData) => !prevData)} + onKeyDown={(e) => { + if (e.key === "Enter") setCreateMore((prevData) => !prevData); + }} + tabIndex={16} >
    {}} size="sm" /> @@ -585,10 +605,10 @@ export const IssueFormRoot: FC = observer((props) => { Create more
    - -
    diff --git a/web/components/issues/select/label.tsx b/web/components/issues/select/label.tsx index d5de45688..f72006677 100644 --- a/web/components/issues/select/label.tsx +++ b/web/components/issues/select/label.tsx @@ -1,11 +1,13 @@ -import React, { Fragment, useState } from "react"; +import React, { Fragment, useRef, useState } from "react"; import { useRouter } from "next/router"; import useSWR from "swr"; -import { Combobox, Transition } from "@headlessui/react"; +import { Combobox } from "@headlessui/react"; import { usePopper } from "react-popper"; import { observer } from "mobx-react-lite"; // hooks import { useLabel } from "hooks/store"; +import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; // ui import { IssueLabelsList } from "components/ui"; // icons @@ -18,10 +20,11 @@ type Props = { projectId: string; label?: JSX.Element; disabled?: boolean; + tabIndex?: number; }; export const IssueLabelSelect: React.FC = observer((props) => { - const { setIsOpen, value, onChange, projectId, label, disabled = false } = props; + const { setIsOpen, value, onChange, projectId, label, disabled = false, tabIndex } = props; // router const router = useRouter(); const { workspaceSlug } = router.query; @@ -33,6 +36,9 @@ export const IssueLabelSelect: React.FC = observer((props) => { const [query, setQuery] = useState(""); const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState(null); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + // refs + const dropdownRef = useRef(null); // popper const { styles, attributes } = usePopper(referenceElement, popperElement, { placement: "bottom-start", @@ -46,86 +52,122 @@ export const IssueLabelSelect: React.FC = observer((props) => { workspaceSlug && projectId ? () => fetchProjectLabels(workspaceSlug.toString(), projectId) : null ); + const openDropdown = () => { + setIsDropdownOpen(true); + if (referenceElement) referenceElement.focus(); + }; + const closeDropdown = () => setIsDropdownOpen(false); + const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isDropdownOpen); + useOutsideClickDetector(dropdownRef, closeDropdown); + return ( onChange(val)} className="relative flex-shrink-0 h-full" multiple disabled={disabled} + onKeyDown={handleKeyDown} > - {({ open }: any) => ( - <> - - - + + + - +
    - -
    -
    - - setQuery(event.target.value)} - placeholder="Search" - displayValue={(assigned: any) => assigned?.name} - /> -
    -
    - {projectLabels && filteredOptions ? ( - filteredOptions.length > 0 ? ( - filteredOptions.map((label) => { - const children = projectLabels?.filter((l) => l.parent === label.id); +
    + + setQuery(event.target.value)} + placeholder="Search" + displayValue={(assigned: any) => assigned?.name} + /> +
    +
    + {projectLabels && filteredOptions ? ( + filteredOptions.length > 0 ? ( + filteredOptions.map((label) => { + const children = projectLabels?.filter((l) => l.parent === label.id); - if (children.length === 0) { - if (!label.parent) - return ( + if (children.length === 0) { + if (!label.parent) + return ( + + `${ + active ? "bg-custom-background-80" : "" + } group flex min-w-[14rem] cursor-pointer select-none items-center gap-2 truncate rounded px-1 py-1.5 text-custom-text-200` + } + value={label.id} + > + {({ selected }) => ( +
    +
    + + {label.name} +
    +
    + +
    +
    + )} +
    + ); + } else + return ( +
    +
    + {label.name} +
    +
    + {children.map((child) => ( `${ active ? "bg-custom-background-80" : "" } group flex min-w-[14rem] cursor-pointer select-none items-center gap-2 truncate rounded px-1 py-1.5 text-custom-text-200` } - value={label.id} + value={child.id} > {({ selected }) => (
    @@ -133,10 +175,10 @@ export const IssueLabelSelect: React.FC = observer((props) => { - {label.name} + {child.name}
    @@ -144,65 +186,28 @@ export const IssueLabelSelect: React.FC = observer((props) => {
    )}
    - ); - } else - return ( -
    -
    - {label.name} -
    -
    - {children.map((child) => ( - - `${ - active ? "bg-custom-background-80" : "" - } group flex min-w-[14rem] cursor-pointer select-none items-center gap-2 truncate rounded px-1 py-1.5 text-custom-text-200` - } - value={child.id} - > - {({ selected }) => ( -
    -
    - - {child.name} -
    -
    - -
    -
    - )} -
    - ))} -
    -
    - ); - }) - ) : ( -

    No matching results

    - ) - ) : ( -

    Loading...

    - )} - -
    -
    - - - + ))} +
    +
    + ); + }) + ) : ( +

    No matching results

    + ) + ) : ( +

    Loading...

    + )} + +
    +
    + )}
    ); diff --git a/web/components/modules/form.tsx b/web/components/modules/form.tsx index c2836e8e0..be0792caa 100644 --- a/web/components/modules/form.tsx +++ b/web/components/modules/form.tsx @@ -93,6 +93,7 @@ export const ModuleForm: React.FC = ({ setActiveProject(val); }} buttonVariant="border-with-text" + tabIndex={10} />
    )} @@ -124,6 +125,7 @@ export const ModuleForm: React.FC = ({ hasError={Boolean(errors.name)} placeholder="Module Title" className="w-full resize-none placeholder:text-sm placeholder:font-medium focus:border-blue-400" + tabIndex={1} /> )} /> @@ -141,6 +143,7 @@ export const ModuleForm: React.FC = ({ placeholder="Description..." className="h-24 w-full resize-none text-sm" hasError={Boolean(errors?.description)} + tabIndex={2} /> )} /> @@ -157,6 +160,7 @@ export const ModuleForm: React.FC = ({ buttonVariant="border-with-text" placeholder="Start date" maxDate={maxDate ?? undefined} + tabIndex={3} />
    )} @@ -172,11 +176,12 @@ export const ModuleForm: React.FC = ({ buttonVariant="border-with-text" placeholder="Target date" minDate={minDate ?? undefined} + tabIndex={4} />
    )} /> - + = ({ multiple={false} buttonVariant="border-with-text" placeholder="Lead" + tabIndex={6} />
    )} @@ -206,6 +212,7 @@ export const ModuleForm: React.FC = ({ buttonVariant={value && value.length > 0 ? "transparent-without-text" : "border-with-text"} buttonClassName={value && value.length > 0 ? "hover:bg-transparent px-0" : ""} placeholder="Members" + tabIndex={7} />
    )} @@ -214,10 +221,10 @@ export const ModuleForm: React.FC = ({
    - -
    diff --git a/web/components/modules/select/status.tsx b/web/components/modules/select/status.tsx index 590f58c2c..33a634e9b 100644 --- a/web/components/modules/select/status.tsx +++ b/web/components/modules/select/status.tsx @@ -12,9 +12,10 @@ import { MODULE_STATUS } from "constants/module"; type Props = { control: Control; error?: FieldError; + tabIndex?: number; }; -export const ModuleStatusSelect: React.FC = ({ control, error }) => ( +export const ModuleStatusSelect: React.FC = ({ control, error, tabIndex }) => ( = ({ control, error }) => (
    } onChange={onChange} + tabIndex={tabIndex} noChevron > {MODULE_STATUS.map((status) => ( diff --git a/web/components/pages/page-form.tsx b/web/components/pages/page-form.tsx index 2fe822ef4..84ef66b59 100644 --- a/web/components/pages/page-form.tsx +++ b/web/components/pages/page-form.tsx @@ -59,6 +59,7 @@ export const PageForm: React.FC = (props) => { hasError={Boolean(errors.name)} placeholder="Title" className="w-full resize-none text-lg" + tabIndex={1} /> )} /> @@ -72,7 +73,7 @@ export const PageForm: React.FC = (props) => { render={({ field: { value, onChange } }) => (
    - {PAGE_ACCESS_SPECIFIERS.map((access) => ( + {PAGE_ACCESS_SPECIFIERS.map((access, index) => ( -
    diff --git a/web/components/project/create-project-modal.tsx b/web/components/project/create-project-modal.tsx index 61e5086bb..4039b40a0 100644 --- a/web/components/project/create-project-modal.tsx +++ b/web/components/project/create-project-modal.tsx @@ -226,7 +226,7 @@ export const CreateProjectModal: FC = observer((props) => { )}
    -
    @@ -238,6 +238,7 @@ export const CreateProjectModal: FC = observer((props) => { }} control={control} value={watch("cover_image")} + tabIndex={9} />
    @@ -253,6 +254,7 @@ export const CreateProjectModal: FC = observer((props) => { } onChange={onChange} value={value} + tabIndex={10} /> )} /> @@ -278,11 +280,11 @@ export const CreateProjectModal: FC = observer((props) => { name="name" type="text" value={value} - tabIndex={1} onChange={handleNameChange(onChange)} hasError={Boolean(errors.name)} placeholder="Project Title" className="w-full focus:border-blue-400" + tabIndex={1} /> )} /> @@ -313,11 +315,11 @@ export const CreateProjectModal: FC = observer((props) => { name="identifier" type="text" value={value} - tabIndex={2} onChange={handleIdentifierChange(onChange)} hasError={Boolean(errors.identifier)} placeholder="Identifier" className="w-full text-xs focus:border-blue-400 uppercase" + tabIndex={2} /> )} /> @@ -332,11 +334,11 @@ export const CreateProjectModal: FC = observer((props) => { id="description" name="description" value={value} - tabIndex={3} placeholder="Description..." onChange={onChange} className="!h-24 text-sm focus:border-blue-400" hasError={Boolean(errors?.description)} + tabIndex={3} /> )} /> @@ -366,6 +368,7 @@ export const CreateProjectModal: FC = observer((props) => { } placement="bottom-start" noChevron + tabIndex={4} > {NETWORK_CHOICES.map((network) => ( = observer((props) => { placeholder="Lead" multiple={false} buttonVariant="border-with-text" + tabIndex={5} />
    )} diff --git a/web/components/views/form.tsx b/web/components/views/form.tsx index 647e864dc..ad38a470c 100644 --- a/web/components/views/form.tsx +++ b/web/components/views/form.tsx @@ -130,6 +130,7 @@ export const ProjectViewForm: React.FC = observer((props) => { hasError={Boolean(errors.name)} placeholder="Title" className="w-full resize-none text-xl focus:border-blue-400" + tabIndex={1} /> )} /> @@ -147,6 +148,7 @@ export const ProjectViewForm: React.FC = observer((props) => { hasError={Boolean(errors?.description)} value={value} onChange={onChange} + tabIndex={2} /> )} /> @@ -156,7 +158,7 @@ export const ProjectViewForm: React.FC = observer((props) => { control={control} name="query_data" render={({ field: { onChange, value: filters } }) => ( - + { @@ -199,10 +201,10 @@ export const ProjectViewForm: React.FC = observer((props) => {
    - - +
    +
    + ); +}; diff --git a/packages/editor/document-editor/src/ui/components/links/link-input-view.tsx b/packages/editor/document-editor/src/ui/components/links/link-input-view.tsx new file mode 100644 index 000000000..fa73adbe1 --- /dev/null +++ b/packages/editor/document-editor/src/ui/components/links/link-input-view.tsx @@ -0,0 +1,9 @@ +import { LinkViewProps } from "./link-view"; + +export const LinkInputView = ({ + viewProps, + switchView, +}: { + viewProps: LinkViewProps; + switchView: (view: "LinkPreview" | "LinkEditView" | "LinkInputView") => void; +}) =>

    LinkInputView

    ; diff --git a/packages/editor/document-editor/src/ui/components/links/link-preview.tsx b/packages/editor/document-editor/src/ui/components/links/link-preview.tsx new file mode 100644 index 000000000..ff3fd0263 --- /dev/null +++ b/packages/editor/document-editor/src/ui/components/links/link-preview.tsx @@ -0,0 +1,52 @@ +import { Copy, GlobeIcon, Link2Off, PencilIcon } from "lucide-react"; +import { LinkViewProps } from "./link-view"; + +export const LinkPreview = ({ + viewProps, + switchView, +}: { + viewProps: LinkViewProps; + switchView: (view: "LinkPreview" | "LinkEditView" | "LinkInputView") => void; +}) => { + const { editor, from, to, url } = viewProps; + + const removeLink = () => { + editor.view.dispatch(editor.state.tr.removeMark(from, to, editor.schema.marks.link)); + viewProps.onActionCompleteHandler({ + title: "Link successfully removed", + message: "The link was removed from the text.", + type: "success", + }); + viewProps.closeLinkView(); + }; + + const copyLinkToClipboard = () => { + navigator.clipboard.writeText(url); + viewProps.onActionCompleteHandler({ + title: "Link successfully copied", + message: "The link was copied to the clipboard.", + type: "success", + }); + viewProps.closeLinkView(); + }; + + return ( +
    +
    + +

    {url.length > 40 ? url.slice(0, 40) + "..." : url}

    +
    + + + +
    +
    +
    + ); +}; diff --git a/packages/editor/document-editor/src/ui/components/links/link-view.tsx b/packages/editor/document-editor/src/ui/components/links/link-view.tsx new file mode 100644 index 000000000..f1d22a68e --- /dev/null +++ b/packages/editor/document-editor/src/ui/components/links/link-view.tsx @@ -0,0 +1,48 @@ +import { Editor } from "@tiptap/react"; +import { CSSProperties, useEffect, useState } from "react"; +import { LinkEditView } from "./link-edit-view"; +import { LinkInputView } from "./link-input-view"; +import { LinkPreview } from "./link-preview"; + +export interface LinkViewProps { + view?: "LinkPreview" | "LinkEditView" | "LinkInputView"; + editor: Editor; + from: number; + to: number; + url: string; + closeLinkView: () => void; + onActionCompleteHandler: (action: { + title: string; + message: string; + type: "success" | "error" | "warning" | "info"; + }) => void; +} + +export const LinkView = (props: LinkViewProps & { style: CSSProperties }) => { + const [currentView, setCurrentView] = useState(props.view ?? "LinkInputView"); + const [prevFrom, setPrevFrom] = useState(props.from); + + const switchView = (view: "LinkPreview" | "LinkEditView" | "LinkInputView") => { + setCurrentView(view); + }; + + useEffect(() => { + if (props.from !== prevFrom) { + setCurrentView("LinkPreview"); + setPrevFrom(props.from); + } + }, []); + + const renderView = () => { + switch (currentView) { + case "LinkPreview": + return ; + case "LinkEditView": + return ; + case "LinkInputView": + return ; + } + }; + + return renderView(); +}; diff --git a/packages/editor/document-editor/src/ui/components/page-renderer.tsx b/packages/editor/document-editor/src/ui/components/page-renderer.tsx index c2d001abe..1bda353b8 100644 --- a/packages/editor/document-editor/src/ui/components/page-renderer.tsx +++ b/packages/editor/document-editor/src/ui/components/page-renderer.tsx @@ -1,12 +1,30 @@ import { EditorContainer, EditorContentWrapper } from "@plane/editor-core"; -import { Editor } from "@tiptap/react"; -import { useState } from "react"; +import { Node } from "@tiptap/pm/model"; +import { EditorView } from "@tiptap/pm/view"; +import { Editor, ReactRenderer } from "@tiptap/react"; +import { useCallback, useRef, useState } from "react"; import { DocumentDetails } from "src/types/editor-types"; +import { LinkView, LinkViewProps } from "./links/link-view"; +import { + autoUpdate, + computePosition, + flip, + hide, + shift, + useDismiss, + useFloating, + useInteractions, +} from "@floating-ui/react"; type IPageRenderer = { documentDetails: DocumentDetails; updatePageTitle: (title: string) => Promise; editor: Editor; + onActionCompleteHandler: (action: { + title: string; + message: string; + type: "success" | "error" | "warning" | "info"; + }) => void; editorClassNames: string; editorContentCustomClassNames?: string; readonly: boolean; @@ -29,6 +47,23 @@ export const PageRenderer = (props: IPageRenderer) => { const [pageTitle, setPagetitle] = useState(documentDetails.title); + const [linkViewProps, setLinkViewProps] = useState(); + const [isOpen, setIsOpen] = useState(false); + const [coordinates, setCoordinates] = useState<{ x: number; y: number }>(); + + const { refs, floatingStyles, context } = useFloating({ + open: isOpen, + onOpenChange: setIsOpen, + middleware: [flip(), shift(), hide({ strategy: "referenceHidden" })], + whileElementsMounted: autoUpdate, + }); + + const dismiss = useDismiss(context, { + ancestorScroll: true, + }); + + const { getFloatingProps } = useInteractions([dismiss]); + const debouncedUpdatePageTitle = debounce(updatePageTitle, 300); const handlePageTitleChange = (title: string) => { @@ -36,8 +71,101 @@ export const PageRenderer = (props: IPageRenderer) => { debouncedUpdatePageTitle(title); }; + const [cleanup, setcleanup] = useState(() => () => {}); + + const floatingElementRef = useRef(null); + + const closeLinkView = () => { + setIsOpen(false); + }; + + const switchLinkView = (view: "LinkPreview" | "LinkEditView" | "LinkInputView") => { + if (!linkViewProps) return; + setLinkViewProps({ + ...linkViewProps, + view: view, + }); + }; + + const handleLinkHover = useCallback( + (event: React.MouseEvent) => { + if (!editor) return; + const target = event.target as HTMLElement; + const view = editor.view as EditorView; + + if (!target || !view) return; + const pos = view.posAtDOM(target, 0); + if (!pos || pos < 0) return; + + if (target.nodeName !== "A") return; + + const node = view.state.doc.nodeAt(pos) as Node; + if (!node || !node.isAtom) return; + + // we need to check if any of the marks are links + const marks = node.marks; + + if (!marks) return; + + const linkMark = marks.find((mark) => mark.type.name === "link"); + + if (!linkMark) return; + + if (floatingElementRef.current) { + floatingElementRef.current?.remove(); + } + + if (cleanup) cleanup(); + + const href = linkMark.attrs.href; + const componentLink = new ReactRenderer(LinkView, { + props: { + view: "LinkPreview", + url: href, + editor: editor, + from: pos, + to: pos + node.nodeSize, + }, + editor, + }); + + const referenceElement = target as HTMLElement; + const floatingElement = componentLink.element as HTMLElement; + + floatingElementRef.current = floatingElement; + + const cleanupFunc = autoUpdate(referenceElement, floatingElement, () => { + computePosition(referenceElement, floatingElement, { + placement: "bottom", + middleware: [ + flip(), + shift(), + hide({ + strategy: "referenceHidden", + }), + ], + }).then(({ x, y }) => { + setCoordinates({ x: x - 300, y: y - 50 }); + setIsOpen(true); + setLinkViewProps({ + onActionCompleteHandler: props.onActionCompleteHandler, + closeLinkView: closeLinkView, + view: "LinkPreview", + url: href, + editor: editor, + from: pos, + to: pos + node.nodeSize, + }); + }); + }); + + setcleanup(cleanupFunc); + }, + [editor, cleanup] + ); + return ( -
    +
    {!readonly ? ( handlePageTitleChange(e.target.value)} @@ -52,11 +180,20 @@ export const PageRenderer = (props: IPageRenderer) => { disabled /> )} -
    +
    + {isOpen && linkViewProps && coordinates && ( +
    + +
    + )}
    ); }; diff --git a/packages/editor/document-editor/src/ui/index.tsx b/packages/editor/document-editor/src/ui/index.tsx index df3554024..34aa54c50 100644 --- a/packages/editor/document-editor/src/ui/index.tsx +++ b/packages/editor/document-editor/src/ui/index.tsx @@ -151,6 +151,7 @@ const DocumentEditor = ({
    -
    +
    Promise.resolve()} readonly={true} editor={editor} diff --git a/yarn.lock b/yarn.lock index 8d7ada2d9..aea383166 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1358,14 +1358,23 @@ "@floating-ui/core" "^1.4.2" "@floating-ui/utils" "^0.1.3" -"@floating-ui/react-dom@^2.0.4": +"@floating-ui/react-dom@^2.0.3", "@floating-ui/react-dom@^2.0.4": version "2.0.4" resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.0.4.tgz#b076fafbdfeb881e1d86ae748b7ff95150e9f3ec" integrity sha512-CF8k2rgKeh/49UrnIBs4BdxPUV6vize/Db1d/YbCLyp9GiVZ0BEwf5AiDSxJRCr6yOkGqTFHtmrULxkEfYZ7dQ== dependencies: "@floating-ui/dom" "^1.5.1" -"@floating-ui/utils@^0.1.3": +"@floating-ui/react@^0.26.4": + version "0.26.4" + resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.26.4.tgz#7626667d2dabc80e2696b500df7f1a348d7ec7a8" + integrity sha512-pRiEz+SiPyfTcckAtLkEf3KJ/sUbB4X4fWMcDm27HT2kfAq+dH+hMc2VoOkNaGpDE35a2PKo688ugWeHaToL3g== + dependencies: + "@floating-ui/react-dom" "^2.0.3" + "@floating-ui/utils" "^0.1.5" + tabbable "^6.0.1" + +"@floating-ui/utils@^0.1.3", "@floating-ui/utils@^0.1.5": version "0.1.6" resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.1.6.tgz#22958c042e10b67463997bd6ea7115fe28cbcaf9" integrity sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A== @@ -1919,6 +1928,11 @@ "@radix-ui/react-primitive" "1.0.0" "@radix-ui/react-use-callback-ref" "1.0.0" +"@radix-ui/react-icons@^1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-icons/-/react-icons-1.3.0.tgz#c61af8f323d87682c5ca76b856d60c2312dbcb69" + integrity sha512-jQxj/0LKgp+j9BiTXz3O3sgs26RNet2iLWmsPyRz2SIcR4q/4SbazXfnYwbAr+vLYKSfc7qxzyGQA1HLlYiuNw== + "@radix-ui/react-id@1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.0.0.tgz#8d43224910741870a45a8c9d092f25887bb6d11e" @@ -2793,7 +2807,7 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@18.2.42", "@types/react@^18.2.42": +"@types/react@*", "@types/react@^18.2.42": version "18.2.42" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.42.tgz#6f6b11a904f6d96dda3c2920328a97011a00aba7" integrity sha512-c1zEr96MjakLYus/wPnuWDo1/zErfdU9rNsIGmE+NV71nx88FG9Ttgo5dqorXTu/LImX2f63WBP986gJkMPNbA== @@ -6198,6 +6212,11 @@ lucide-react@^0.294.0: resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.294.0.tgz#dc406e1e7e2f722cf93218fe5b31cf3c95778817" integrity sha512-V7o0/VECSGbLHn3/1O67FUgBwWB+hmzshrgDVRJQhMh8uj5D3HBuIvhuAmQTtlupILSplwIZg5FTc4tTKMA2SA== +lucide-react@^0.309.0: + version "0.309.0" + resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.309.0.tgz#7369893cb4b074a0a0b1d3acdc6fd9a8bdb5add1" + integrity sha512-zNVPczuwFrCfksZH3zbd1UDE6/WYhYAdbe2k7CImVyPAkXLgIwbs6eXQ4loigqDnUFjyFYCI5jZ1y10Kqal0dg== + magic-string@^0.25.0, magic-string@^0.25.7: version "0.25.9" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.9.tgz#de7f9faf91ef8a1c91d02c2e5314c8277dbcdd1c" @@ -8323,6 +8342,11 @@ swr@^2.1.3, swr@^2.2.2: client-only "^0.0.1" use-sync-external-store "^1.2.0" +tabbable@^6.0.1: + version "6.2.0" + resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.2.0.tgz#732fb62bc0175cfcec257330be187dcfba1f3b97" + integrity sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew== + table@^6.0.9: version "6.8.1" resolved "https://registry.yarnpkg.com/table/-/table-6.8.1.tgz#ea2b71359fe03b017a5fbc296204471158080bdf" From 4611ec0b8310dc40faeacb42dab93c6a3aa60b62 Mon Sep 17 00:00:00 2001 From: guru_sainath Date: Wed, 10 Jan 2024 20:09:45 +0530 Subject: [PATCH 24/68] chore: refactored and resolved build issues on the issues and issue detail page (#3340) * fix: handled undefined issue_id in list layout * dev: issue detail store and optimization * dev: issue filter and list operations * fix: typo on labels update * dev: Handled all issues in the list layout in project issues * dev: handled kanban and auick add issue in swimlanes * chore: fixed peekoverview in kanban * chore: fixed peekoverview in calendar * chore: fixed peekoverview in gantt * chore: updated quick add in the gantt chart * chore: handled issue detail properties and resolved build issues --------- Co-authored-by: pablohashescobar --- apiserver/plane/app/serializers/base.py | 3 +- apiserver/plane/app/serializers/issue.py | 2 +- packages/types/src/issues.d.ts | 2 +- packages/types/src/issues/issue_reaction.d.ts | 2 +- packages/types/src/view-props.d.ts | 13 +- packages/types/src/workspace-views.d.ts | 6 +- web/components/core/index.ts | 1 - .../core/sidebar/sidebar-progress-stats.tsx | 2 +- .../gantt-chart/sidebar/sidebar.tsx | 4 +- web/components/inbox/main-content.tsx | 24 +- .../issues/attachment/attachment-upload.tsx | 4 +- web/components/issues/attachment/root.tsx | 31 +- .../issues/comment/comment-reaction.tsx | 7 +- web/components/issues/description-form.tsx | 13 +- web/components/issues/index.ts | 14 +- .../issues/issue-detail/cycle-select.tsx | 103 +++++ web/components/issues/issue-detail/index.ts | 14 + .../issue-detail/label/create-label.tsx | 163 +++++++ .../issues/issue-detail/label/index.ts | 5 + .../issue-detail/label/label-list-item.tsx | 52 +++ .../issues/issue-detail/label/label-list.tsx | 40 ++ .../issues/issue-detail/label/root.tsx | 92 ++++ .../issue-detail/label/select-existing.tsx | 9 + .../links}/create-update-link-modal.tsx | 2 +- .../issues/issue-detail/links/index.ts | 4 + .../links}/link-detail.tsx | 8 +- .../links}/links.tsx | 0 .../links}/root.tsx | 54 ++- .../issues/issue-detail/main-content.tsx | 130 ++++++ .../issues/issue-detail/module-select.tsx | 103 +++++ .../issues/issue-detail/parent-select.tsx | 82 ++++ .../issues/issue-detail/parent/index.ts | 4 + .../issues/issue-detail/parent/root.tsx | 72 +++ .../issue-detail/parent/sibling-item.tsx | 39 ++ .../issues/issue-detail/parent/siblings.tsx | 51 +++ .../issues/issue-detail/reactions/index.ts | 4 + .../issues/issue-detail/reactions/issue.tsx | 103 +++++ .../reactions}/reaction-selector.tsx | 0 .../relation-select.tsx} | 12 +- web/components/issues/issue-detail/root.tsx | 199 ++++++++ .../issues/{ => issue-detail}/sidebar.tsx | 427 ++++++++---------- .../issues/issue-detail/subscription.tsx | 54 +++ .../issue-layouts/calendar/issue-blocks.tsx | 103 +++-- .../issues/issue-layouts/gantt/blocks.tsx | 69 +-- .../gantt/quick-add-issue-form.tsx | 182 ++++---- .../issue-layouts/kanban/base-kanban-root.tsx | 39 +- .../issues/issue-layouts/kanban/block.tsx | 43 +- .../issues/issue-layouts/kanban/default.tsx | 118 ++--- .../kanban/headers/group-by-card.tsx | 14 +- .../kanban/headers/sub-group-by-card.tsx | 12 +- .../issue-layouts/kanban/kanban-group.tsx | 114 +++-- .../kanban/quick-add-issue-form.tsx | 8 +- .../issues/issue-layouts/kanban/swimlanes.tsx | 50 +- .../issues/issue-layouts/kanban/utils.ts | 116 +++-- .../issue-layouts/list/base-list-root.tsx | 9 - .../issues/issue-layouts/list/block.tsx | 2 +- .../properties/all-properties.tsx | 29 +- .../roots/archived-issue-layout-root.tsx | 52 ++- .../issue-layouts/roots/cycle-layout-root.tsx | 47 +- .../roots/draft-issue-layout-root.tsx | 57 ++- .../issues/issue-layouts/roots/index.ts | 7 +- .../roots/module-layout-root.tsx | 42 +- .../roots/project-layout-root.tsx | 27 +- .../roots/project-view-layout-root.tsx | 60 +-- web/components/issues/issue-layouts/utils.tsx | 18 +- web/components/issues/issue-links/index.ts | 1 - web/components/issues/issue-reaction.tsx | 80 ---- web/components/issues/main-content.tsx | 271 ----------- .../issues/peek-overview/properties.tsx | 101 ++--- web/components/issues/peek-overview/root.tsx | 159 +++++-- web/components/issues/peek-overview/view.tsx | 40 +- web/components/issues/select/label.tsx | 3 +- .../issues/sidebar-select/cycle.tsx | 135 ------ web/components/issues/sidebar-select/index.ts | 5 - .../issues/sidebar-select/label.tsx | 225 --------- .../issues/sidebar-select/module.tsx | 128 ------ .../issues/sidebar-select/parent.tsx | 69 --- web/components/issues/sub-issues/issue.tsx | 6 +- .../issues/sub-issues/issues-list.tsx | 6 +- web/components/issues/sub-issues/root.tsx | 221 ++++----- web/components/modules/sidebar.tsx | 2 +- .../pages/create-update-page-modal.tsx | 158 +++---- web/components/pages/pages-list/list-item.tsx | 3 +- .../pages/pages-list/recent-pages-list.tsx | 3 +- web/constants/issue.ts | 1 + web/hooks/store/use-issues.ts | 87 ++-- web/hooks/store/use-page.ts | 4 +- web/hooks/use-issue-reaction.tsx | 91 ---- .../archived-issues/[archivedIssueId].tsx | 13 +- .../projects/[projectId]/issues/[issueId].tsx | 145 ++---- .../[projectId]/modules/[moduleId].tsx | 1 + web/services/issue/issue.service.ts | 6 +- web/services/issue_filter.service.ts | 14 +- web/store/issue/archived/filter.store.ts | 53 ++- web/store/issue/cycle/filter.store.ts | 61 ++- web/store/issue/cycle/issue.store.ts | 23 +- web/store/issue/draft/filter.store.ts | 42 +- .../helpers/issue-filter-helper.store.ts | 23 +- web/store/issue/issue-details/issue.store.ts | 9 +- web/store/issue/issue-details/link.store.ts | 6 +- .../issue/issue-details/reaction.store.ts | 81 +++- web/store/issue/issue-details/root.store.ts | 18 +- .../issue/issue-details/sub_issues.store.ts | 64 +++ web/store/issue/module/filter.store.ts | 63 ++- web/store/issue/module/issue.store.ts | 22 +- web/store/issue/profile/filter.store.ts | 46 +- web/store/issue/project-views/filter.store.ts | 61 ++- web/store/issue/project/filter.store.ts | 59 ++- web/store/issue/project/issue.store.ts | 4 - web/store/issue/root.store.ts | 4 +- web/store/issue/workspace/filter.store.ts | 67 ++- yarn.lock | 7 +- 112 files changed, 3303 insertions(+), 2560 deletions(-) create mode 100644 web/components/issues/issue-detail/cycle-select.tsx create mode 100644 web/components/issues/issue-detail/index.ts create mode 100644 web/components/issues/issue-detail/label/create-label.tsx create mode 100644 web/components/issues/issue-detail/label/index.ts create mode 100644 web/components/issues/issue-detail/label/label-list-item.tsx create mode 100644 web/components/issues/issue-detail/label/label-list.tsx create mode 100644 web/components/issues/issue-detail/label/root.tsx create mode 100644 web/components/issues/issue-detail/label/select-existing.tsx rename web/components/issues/{issue-links => issue-detail/links}/create-update-link-modal.tsx (99%) create mode 100644 web/components/issues/issue-detail/links/index.ts rename web/components/issues/{issue-links => issue-detail/links}/link-detail.tsx (94%) rename web/components/issues/{issue-links => issue-detail/links}/links.tsx (100%) rename web/components/issues/{issue-links => issue-detail/links}/root.tsx (63%) create mode 100644 web/components/issues/issue-detail/main-content.tsx create mode 100644 web/components/issues/issue-detail/module-select.tsx create mode 100644 web/components/issues/issue-detail/parent-select.tsx create mode 100644 web/components/issues/issue-detail/parent/index.ts create mode 100644 web/components/issues/issue-detail/parent/root.tsx create mode 100644 web/components/issues/issue-detail/parent/sibling-item.tsx create mode 100644 web/components/issues/issue-detail/parent/siblings.tsx create mode 100644 web/components/issues/issue-detail/reactions/index.ts create mode 100644 web/components/issues/issue-detail/reactions/issue.tsx rename web/components/{core => issues/issue-detail/reactions}/reaction-selector.tsx (100%) rename web/components/issues/{sidebar-select/relation.tsx => issue-detail/relation-select.tsx} (95%) create mode 100644 web/components/issues/issue-detail/root.tsx rename web/components/issues/{ => issue-detail}/sidebar.tsx (50%) create mode 100644 web/components/issues/issue-detail/subscription.tsx delete mode 100644 web/components/issues/issue-links/index.ts delete mode 100644 web/components/issues/issue-reaction.tsx delete mode 100644 web/components/issues/main-content.tsx delete mode 100644 web/components/issues/sidebar-select/cycle.tsx delete mode 100644 web/components/issues/sidebar-select/index.ts delete mode 100644 web/components/issues/sidebar-select/label.tsx delete mode 100644 web/components/issues/sidebar-select/module.tsx delete mode 100644 web/components/issues/sidebar-select/parent.tsx delete mode 100644 web/hooks/use-issue-reaction.tsx diff --git a/apiserver/plane/app/serializers/base.py b/apiserver/plane/app/serializers/base.py index f67f5cf52..d25d3b4b0 100644 --- a/apiserver/plane/app/serializers/base.py +++ b/apiserver/plane/app/serializers/base.py @@ -77,7 +77,7 @@ class DynamicBaseSerializer(BaseSerializer): "assignees": UserLiteSerializer, "labels": LabelSerializer, "issue_cycle": CycleIssueSerializer, - "parent": IssueFlatSerializer, + "parent": IssueSerializer, } self.fields[field] = expansion[field](many=True if field in ["members", "assignees", "labels", "issue_cycle"] else False) @@ -119,6 +119,7 @@ class DynamicBaseSerializer(BaseSerializer): "assignees": UserLiteSerializer, "labels": LabelSerializer, "issue_cycle": CycleIssueSerializer, + "parent": IssueSerializer, } # Check if field in expansion then expand the field if expand in expansion: diff --git a/apiserver/plane/app/serializers/issue.py b/apiserver/plane/app/serializers/issue.py index f9b5b579f..64ae9ccfb 100644 --- a/apiserver/plane/app/serializers/issue.py +++ b/apiserver/plane/app/serializers/issue.py @@ -175,7 +175,7 @@ class IssueCreateSerializer(BaseSerializer): def update(self, instance, validated_data): assignees = validated_data.pop("assignee_ids", None) - labels = validated_data.pop("labels_ids", None) + labels = validated_data.pop("label_ids", None) # Related models project_id = instance.project_id diff --git a/packages/types/src/issues.d.ts b/packages/types/src/issues.d.ts index c9376b34b..b6ab05f2c 100644 --- a/packages/types/src/issues.d.ts +++ b/packages/types/src/issues.d.ts @@ -222,7 +222,7 @@ export type GroupByColumnTypes = export interface IGroupByColumn { id: string; name: string; - Icon: ReactElement | undefined; + icon: ReactElement | undefined; payload: Partial; } diff --git a/packages/types/src/issues/issue_reaction.d.ts b/packages/types/src/issues/issue_reaction.d.ts index 2fe646246..6fc071a9f 100644 --- a/packages/types/src/issues/issue_reaction.d.ts +++ b/packages/types/src/issues/issue_reaction.d.ts @@ -17,5 +17,5 @@ export type TIssueReactionMap = { }; export type TIssueReactionIdMap = { - [issue_id: string]: string[]; + [issue_id: string]: { [reaction: string]: string[] }; }; diff --git a/packages/types/src/view-props.d.ts b/packages/types/src/view-props.d.ts index 282fc5a9c..7f1d49632 100644 --- a/packages/types/src/view-props.d.ts +++ b/packages/types/src/view-props.d.ts @@ -1,4 +1,9 @@ -export type TIssueLayouts = "list" | "kanban" | "calendar" | "spreadsheet" | "gantt_chart"; +export type TIssueLayouts = + | "list" + | "kanban" + | "calendar" + | "spreadsheet" + | "gantt_chart"; export type TIssueGroupByOptions = | "state" @@ -108,10 +113,16 @@ export interface IIssueDisplayProperties { updated_on?: boolean; } +export type TIssueKanbanFilters = { + group_by: string[]; + sub_group_by: string[]; +}; + export interface IIssueFilters { filters: IIssueFilterOptions | undefined; displayFilters: IIssueDisplayFilterOptions | undefined; displayProperties: IIssueDisplayProperties | undefined; + kanbanFilters: TIssueKanbanFilters | undefined; } export interface IIssueFiltersResponse { diff --git a/packages/types/src/workspace-views.d.ts b/packages/types/src/workspace-views.d.ts index 29aa56742..e270f4f69 100644 --- a/packages/types/src/workspace-views.d.ts +++ b/packages/types/src/workspace-views.d.ts @@ -29,4 +29,8 @@ export interface IWorkspaceView { }; } -export type TStaticViewTypes = "all-issues" | "assigned" | "created" | "subscribed"; +export type TStaticViewTypes = + | "all-issues" + | "assigned" + | "created" + | "subscribed"; diff --git a/web/components/core/index.ts b/web/components/core/index.ts index ff0fabc4e..4f99f3606 100644 --- a/web/components/core/index.ts +++ b/web/components/core/index.ts @@ -3,5 +3,4 @@ export * from "./modals"; export * from "./sidebar"; export * from "./theme"; export * from "./activity"; -export * from "./reaction-selector"; export * from "./image-picker-popover"; diff --git a/web/components/core/sidebar/sidebar-progress-stats.tsx b/web/components/core/sidebar/sidebar-progress-stats.tsx index 6d89981cd..698695ee4 100644 --- a/web/components/core/sidebar/sidebar-progress-stats.tsx +++ b/web/components/core/sidebar/sidebar-progress-stats.tsx @@ -126,7 +126,7 @@ export const SidebarProgressStats: React.FC = ({ - {distribution.assignees.length > 0 ? ( + {distribution?.assignees.length > 0 ? ( distribution.assignees.map((assignee, index) => { if (assignee.assignee_id) return ( diff --git a/web/components/gantt-chart/sidebar/sidebar.tsx b/web/components/gantt-chart/sidebar/sidebar.tsx index c366bcfed..88a138a1b 100644 --- a/web/components/gantt-chart/sidebar/sidebar.tsx +++ b/web/components/gantt-chart/sidebar/sidebar.tsx @@ -7,7 +7,7 @@ import { useChart } from "components/gantt-chart/hooks"; // ui import { Loader } from "@plane/ui"; // components -import { GanttInlineCreateIssueForm, IssueGanttSidebarBlock } from "components/issues"; +import { GanttQuickAddIssueForm, IssueGanttSidebarBlock } from "components/issues"; // helpers import { findTotalDaysInRange } from "helpers/date-time.helper"; // types @@ -169,7 +169,7 @@ export const IssueGanttSidebar: React.FC = (props) => { {droppableProvided.placeholder} {enableQuickIssueCreate && !disableIssueCreation && ( - + )}
    )} diff --git a/web/components/inbox/main-content.tsx b/web/components/inbox/main-content.tsx index c43a132ec..97346d0a0 100644 --- a/web/components/inbox/main-content.tsx +++ b/web/components/inbox/main-content.tsx @@ -7,7 +7,13 @@ import { AlertTriangle, CheckCircle2, Clock, Copy, ExternalLink, Inbox, XCircle // hooks import { useProjectState, useUser, useInboxIssues } from "hooks/store"; // components -import { IssueDescriptionForm, IssueDetailsSidebar, IssueReaction, IssueUpdateStatus } from "components/issues"; +import { + IssueDescriptionForm, + // FIXME: have to replace this once the issue details page is ready --issue-detail-- + // IssueDetailsSidebar, + // IssueReaction, + IssueUpdateStatus, +} from "components/issues"; import { InboxIssueActivity } from "components/inbox"; // ui import { Loader, StateGroupIcon } from "@plane/ui"; @@ -226,7 +232,9 @@ export const InboxMainContent: React.FC = observer(() => { )}
    -
    + + {/* FIXME: have to replace this once the issue details page is ready --issue-detail-- */} + {/*
    setIsSubmitting(value)} isSubmitting={isSubmitting} @@ -239,26 +247,28 @@ export const InboxMainContent: React.FC = observer(() => { handleFormSubmit={submitChanges} isAllowed={isAllowed || currentUser?.id === issueDetails.created_by} /> -
    +
    */} - {workspaceSlug && projectId && ( + {/* FIXME: have to replace this once the issue details page is ready --issue-detail-- */} + {/* {workspaceSlug && projectId && ( - )} + )} */}
    - + /> */}
    ) : ( diff --git a/web/components/issues/attachment/attachment-upload.tsx b/web/components/issues/attachment/attachment-upload.tsx index c53574cb4..264f0c643 100644 --- a/web/components/issues/attachment/attachment-upload.tsx +++ b/web/components/issues/attachment/attachment-upload.tsx @@ -11,15 +11,15 @@ import { TAttachmentOperations } from "./root"; type TAttachmentOperationsModal = Exclude; type Props = { + workspaceSlug: string; disabled?: boolean; handleAttachmentOperations: TAttachmentOperationsModal; }; export const IssueAttachmentUpload: React.FC = observer((props) => { - const { disabled = false, handleAttachmentOperations } = props; + const { workspaceSlug, disabled = false, handleAttachmentOperations } = props; // store hooks const { - router: { workspaceSlug }, config: { envConfig }, } = useApplication(); // states diff --git a/web/components/issues/attachment/root.tsx b/web/components/issues/attachment/root.tsx index ac92bb5b6..209058f9f 100644 --- a/web/components/issues/attachment/root.tsx +++ b/web/components/issues/attachment/root.tsx @@ -1,13 +1,17 @@ import { FC, useMemo } from "react"; // hooks -import { useApplication, useIssueDetail } from "hooks/store"; +import { useIssueDetail } from "hooks/store"; import useToast from "hooks/use-toast"; // components import { IssueAttachmentUpload } from "./attachment-upload"; import { IssueAttachmentsList } from "./attachments-list"; export type TIssueAttachmentRoot = { - isEditable: boolean; + workspaceSlug: string; + projectId: string; + issueId: string; + is_archived: boolean; + is_editable: boolean; }; export type TAttachmentOperations = { @@ -17,20 +21,17 @@ export type TAttachmentOperations = { export const IssueAttachmentRoot: FC = (props) => { // props - const { isEditable } = props; + const { workspaceSlug, projectId, issueId, is_archived, is_editable } = props; // hooks - const { - router: { workspaceSlug, projectId }, - } = useApplication(); - const { peekIssue, createAttachment, removeAttachment } = useIssueDetail(); + const { createAttachment, removeAttachment } = useIssueDetail(); const { setToastAlert } = useToast(); const handleAttachmentOperations: TAttachmentOperations = useMemo( () => ({ create: async (data: FormData) => { try { - if (!workspaceSlug || !projectId || !peekIssue?.issueId) throw new Error("Missing required fields"); - await createAttachment(workspaceSlug, projectId, peekIssue?.issueId, data); + if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); + await createAttachment(workspaceSlug, projectId, issueId, data); setToastAlert({ message: "The attachment has been successfully uploaded", type: "success", @@ -46,8 +47,8 @@ export const IssueAttachmentRoot: FC = (props) => { }, remove: async (attachmentId: string) => { try { - if (!workspaceSlug || !projectId || !peekIssue?.issueId) throw new Error("Missing required fields"); - await removeAttachment(workspaceSlug, projectId, peekIssue?.issueId, attachmentId); + if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); + await removeAttachment(workspaceSlug, projectId, issueId, attachmentId); setToastAlert({ message: "The attachment has been successfully removed", type: "success", @@ -62,14 +63,18 @@ export const IssueAttachmentRoot: FC = (props) => { } }, }), - [workspaceSlug, projectId, peekIssue, createAttachment, removeAttachment, setToastAlert] + [workspaceSlug, projectId, issueId, createAttachment, removeAttachment, setToastAlert] ); return (

    Attachments

    - +
    diff --git a/web/components/issues/comment/comment-reaction.tsx b/web/components/issues/comment/comment-reaction.tsx index eb80b0323..a59337575 100644 --- a/web/components/issues/comment/comment-reaction.tsx +++ b/web/components/issues/comment/comment-reaction.tsx @@ -5,7 +5,7 @@ import { observer } from "mobx-react-lite"; import { useUser } from "hooks/store"; import useCommentReaction from "hooks/use-comment-reaction"; // ui -import { ReactionSelector } from "components/core"; +// import { ReactionSelector } from "components/core"; // helper import { renderEmoji } from "helpers/emoji.helper"; // types @@ -47,7 +47,8 @@ export const CommentReaction: FC = observer((props) => { return (
    - {!readonly && ( + {/* FIXME: have to replace this once the issue details page is ready --issue-detail-- */} + {/* {!readonly && ( = observer((props) => { } onSelect={handleReactionClick} /> - )} + )} */} {Object.keys(groupedReactions || {}).map( (reaction) => diff --git a/web/components/issues/description-form.tsx b/web/components/issues/description-form.tsx index 3f463496e..cd678735d 100644 --- a/web/components/issues/description-form.tsx +++ b/web/components/issues/description-form.tsx @@ -8,6 +8,7 @@ import { TextArea } from "@plane/ui"; import { RichTextEditor } from "@plane/rich-text-editor"; // types import { TIssue } from "@plane/types"; +import { TIssueOperations } from "./issue-detail"; // services import { FileService } from "services/file.service"; import { useMention } from "hooks/store"; @@ -18,14 +19,16 @@ export interface IssueDescriptionFormValues { } export interface IssueDetailsProps { + workspaceSlug: string; + projectId: string; + issueId: string; issue: { name: string; description_html: string; id: string; project_id?: string; }; - workspaceSlug: string; - handleFormSubmit: (value: IssueDescriptionFormValues) => Promise; + issueOperations: TIssueOperations; isAllowed: boolean; isSubmitting: "submitting" | "submitted" | "saved"; setIsSubmitting: (value: "submitting" | "submitted" | "saved") => void; @@ -34,7 +37,7 @@ export interface IssueDetailsProps { const fileService = new FileService(); export const IssueDescriptionForm: FC = (props) => { - const { issue, handleFormSubmit, workspaceSlug, isAllowed, isSubmitting, setIsSubmitting } = props; + const { workspaceSlug, projectId, issueId, issue, issueOperations, isAllowed, isSubmitting, setIsSubmitting } = props; // states const [characterLimit, setCharacterLimit] = useState(false); @@ -75,12 +78,12 @@ export const IssueDescriptionForm: FC = (props) => { async (formData: Partial) => { if (!formData?.name || formData?.name.length === 0 || formData?.name.length > 255) return; - await handleFormSubmit({ + await issueOperations.update(workspaceSlug, projectId, issueId, { name: formData.name ?? "", description_html: formData.description_html ?? "

    ", }); }, - [handleFormSubmit] + [workspaceSlug, projectId, issueId, issueOperations] ); useEffect(() => { diff --git a/web/components/issues/index.ts b/web/components/issues/index.ts index b8af27d40..f1c6636cd 100644 --- a/web/components/issues/index.ts +++ b/web/components/issues/index.ts @@ -1,21 +1,22 @@ export * from "./attachment"; export * from "./comment"; export * from "./issue-modal"; -export * from "./sidebar-select"; export * from "./view-select"; export * from "./activity"; export * from "./delete-issue-modal"; export * from "./description-form"; export * from "./issue-layouts"; -export * from "./peek-overview"; -export * from "./main-content"; + export * from "./parent-issues-list-modal"; -export * from "./sidebar"; export * from "./label"; -export * from "./issue-reaction"; export * from "./confirm-issue-discard"; export * from "./issue-update-status"; +// issue details +export * from "./issue-detail"; + +export * from "./peek-overview"; + // draft issue export * from "./draft-issue-form"; export * from "./draft-issue-modal"; @@ -23,6 +24,3 @@ export * from "./delete-draft-issue-modal"; // archived issue export * from "./delete-archived-issue-modal"; - -// issue links -export * from "./issue-links"; diff --git a/web/components/issues/issue-detail/cycle-select.tsx b/web/components/issues/issue-detail/cycle-select.tsx new file mode 100644 index 000000000..24ed1c963 --- /dev/null +++ b/web/components/issues/issue-detail/cycle-select.tsx @@ -0,0 +1,103 @@ +import React, { ReactNode, useState } from "react"; +import { observer } from "mobx-react-lite"; +import useSWR from "swr"; +// hooks +import { useCycle, useIssueDetail } from "hooks/store"; +// ui +import { ContrastIcon, CustomSearchSelect, Spinner, Tooltip } from "@plane/ui"; +// types +import type { TIssueOperations } from "./root"; + +type TIssueCycleSelect = { + workspaceSlug: string; + projectId: string; + issueId: string; + issueOperations: TIssueOperations; + disabled?: boolean; +}; + +export const IssueCycleSelect: React.FC = observer((props) => { + const { workspaceSlug, projectId, issueId, issueOperations, disabled = false } = props; + // hooks + const { getCycleById, currentProjectIncompleteCycleIds, fetchAllCycles } = useCycle(); + const { + issue: { getIssueById }, + } = useIssueDetail(); + // state + const [isUpdating, setIsUpdating] = useState(false); + + useSWR(workspaceSlug && projectId ? `PROJECT_${projectId}_ISSUE_${issueId}_CYCLES` : null, async () => { + if (workspaceSlug && projectId) await fetchAllCycles(workspaceSlug, projectId); + }); + + const issue = getIssueById(issueId); + const projectCycleIds = currentProjectIncompleteCycleIds; + const issueCycle = (issue && issue.cycle_id && getCycleById(issue.cycle_id)) || undefined; + const disableSelect = disabled || isUpdating; + + const handleIssueCycleChange = async (cycleId: string) => { + if (!cycleId) return; + setIsUpdating(true); + if (issue && issue.cycle_id === cycleId) + await issueOperations.removeIssueFromCycle(workspaceSlug, projectId, cycleId, issueId); + else await issueOperations.addIssueToCycle(workspaceSlug, projectId, cycleId, [issueId]); + setIsUpdating(false); + }; + + type TDropdownOptions = { value: string; query: string; content: ReactNode }[]; + const options: TDropdownOptions | undefined = projectCycleIds + ? (projectCycleIds + .map((cycleId) => { + const cycle = getCycleById(cycleId) || undefined; + if (!cycle) return undefined; + return { + value: cycle.id, + query: cycle.name, + content: ( +
    + + + + {cycle.name} +
    + ) as ReactNode, + }; + }) + .filter((cycle) => cycle !== undefined) as TDropdownOptions) + : undefined; + + return ( +
    + handleIssueCycleChange(value)} + options={options} + customButton={ +
    + + + +
    + } + width="max-w-[10rem]" + noChevron + disabled={disableSelect} + /> + {isUpdating && } +
    + ); +}); diff --git a/web/components/issues/issue-detail/index.ts b/web/components/issues/issue-detail/index.ts new file mode 100644 index 000000000..63ef560a1 --- /dev/null +++ b/web/components/issues/issue-detail/index.ts @@ -0,0 +1,14 @@ +export * from "./root"; + +export * from "./main-content"; +export * from "./sidebar"; + +// select +export * from "./cycle-select"; +export * from "./module-select"; +export * from "./parent-select"; +export * from "./relation-select"; +export * from "./parent"; +export * from "./label"; +export * from "./subscription"; +export * from "./links"; diff --git a/web/components/issues/issue-detail/label/create-label.tsx b/web/components/issues/issue-detail/label/create-label.tsx new file mode 100644 index 000000000..94af347d6 --- /dev/null +++ b/web/components/issues/issue-detail/label/create-label.tsx @@ -0,0 +1,163 @@ +import { FC, useState, Fragment, useEffect } from "react"; +import { Plus, X } from "lucide-react"; +import { Controller, useForm } from "react-hook-form"; +import { TwitterPicker } from "react-color"; +import { Popover, Transition } from "@headlessui/react"; +// hooks +import { useIssueDetail } from "hooks/store"; +import useToast from "hooks/use-toast"; +// ui +import { Input } from "@plane/ui"; +// types +import { TLabelOperations } from "./root"; +import { IIssueLabel } from "@plane/types"; + +type ILabelCreate = { + workspaceSlug: string; + projectId: string; + issueId: string; + labelOperations: TLabelOperations; + disabled?: boolean; +}; + +const defaultValues: Partial = { + name: "", + color: "#ff0000", +}; + +export const LabelCreate: FC = (props) => { + const { workspaceSlug, projectId, issueId, labelOperations, disabled = false } = props; + // hooks + const { setToastAlert } = useToast(); + const { + issue: { getIssueById }, + } = useIssueDetail(); + // state + const [isCreateToggle, setIsCreateToggle] = useState(false); + const handleIsCreateToggle = () => setIsCreateToggle(!isCreateToggle); + // react hook form + const { + handleSubmit, + formState: { errors, isSubmitting }, + reset, + control, + setFocus, + } = useForm>({ + defaultValues, + }); + + useEffect(() => { + if (!isCreateToggle) return; + + setFocus("name"); + reset(); + }, [isCreateToggle, reset, setFocus]); + + const handleLabel = async (formData: Partial) => { + if (!workspaceSlug || !projectId || isSubmitting) return; + + try { + const issue = getIssueById(issueId); + const labelResponse = await labelOperations.createLabel(workspaceSlug, projectId, formData); + const currentLabels = [...(issue?.label_ids || []), labelResponse.id]; + await labelOperations.updateIssue(workspaceSlug, projectId, issueId, { label_ids: currentLabels }); + reset(defaultValues); + } catch (error) { + setToastAlert({ + title: "Label creation failed", + type: "error", + message: "Label creation failed. Please try again sometime later.", + }); + } + }; + + return ( + <> +
    +
    + {isCreateToggle ? ( + + ) : ( + + )} +
    +
    {isCreateToggle ? "Cancel" : "New"}
    +
    + + {isCreateToggle && ( +
    +
    + ( + + <> + + {value && value?.trim() !== "" && ( + + )} + + + + + onChange(value.hex)} /> + + + + + )} + /> +
    + ( + + )} + /> + + + + )} + + ); +}; diff --git a/web/components/issues/issue-detail/label/index.ts b/web/components/issues/issue-detail/label/index.ts new file mode 100644 index 000000000..005620ddd --- /dev/null +++ b/web/components/issues/issue-detail/label/index.ts @@ -0,0 +1,5 @@ +export * from "./root"; + +export * from "./label-list"; +export * from "./label-list-item"; +export * from "./create-label"; diff --git a/web/components/issues/issue-detail/label/label-list-item.tsx b/web/components/issues/issue-detail/label/label-list-item.tsx new file mode 100644 index 000000000..3368e9a56 --- /dev/null +++ b/web/components/issues/issue-detail/label/label-list-item.tsx @@ -0,0 +1,52 @@ +import { FC } from "react"; +import { X } from "lucide-react"; +// types +import { TLabelOperations } from "./root"; +import { useIssueDetail, useLabel } from "hooks/store"; + +type TLabelListItem = { + workspaceSlug: string; + projectId: string; + issueId: string; + labelId: string; + labelOperations: TLabelOperations; +}; + +export const LabelListItem: FC = (props) => { + const { workspaceSlug, projectId, issueId, labelId, labelOperations } = props; + // hooks + const { + issue: { getIssueById }, + } = useIssueDetail(); + const { getLabelById } = useLabel(); + + const issue = getIssueById(issueId); + const label = getLabelById(labelId); + + const handleLabel = async () => { + if (issue) { + const currentLabels = issue.label_ids.filter((_labelId) => _labelId !== labelId); + await labelOperations.updateIssue(workspaceSlug, projectId, issueId, { label_ids: currentLabels }); + } + }; + + if (!label) return <>; + return ( +
    +
    +
    {label.name}
    +
    + +
    +
    + ); +}; diff --git a/web/components/issues/issue-detail/label/label-list.tsx b/web/components/issues/issue-detail/label/label-list.tsx new file mode 100644 index 000000000..b29e9b920 --- /dev/null +++ b/web/components/issues/issue-detail/label/label-list.tsx @@ -0,0 +1,40 @@ +import { FC } from "react"; +// components +import { LabelListItem } from "./label-list-item"; +// hooks +import { useIssueDetail } from "hooks/store"; +// types +import { TLabelOperations } from "./root"; + +type TLabelList = { + workspaceSlug: string; + projectId: string; + issueId: string; + labelOperations: TLabelOperations; +}; + +export const LabelList: FC = (props) => { + const { workspaceSlug, projectId, issueId, labelOperations } = props; + // hooks + const { + issue: { getIssueById }, + } = useIssueDetail(); + + const issue = getIssueById(issueId); + const issueLabels = issue?.label_ids || undefined; + + if (!issue || !issueLabels) return <>; + return ( + <> + {issueLabels.map((labelId) => ( + + ))} + + ); +}; diff --git a/web/components/issues/issue-detail/label/root.tsx b/web/components/issues/issue-detail/label/root.tsx new file mode 100644 index 000000000..f0ffdd19d --- /dev/null +++ b/web/components/issues/issue-detail/label/root.tsx @@ -0,0 +1,92 @@ +import { FC, useMemo } from "react"; +import { observer } from "mobx-react-lite"; +// components +import { LabelList, LabelCreate } from "./"; + +// hooks +import { useIssueDetail, useLabel } from "hooks/store"; +// types +import { IIssueLabel, TIssue } from "@plane/types"; +import useToast from "hooks/use-toast"; + +export type TIssueLabel = { + workspaceSlug: string; + projectId: string; + issueId: string; + disabled: boolean; +}; + +export type TLabelOperations = { + updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => Promise; + createLabel: (workspaceSlug: string, projectId: string, data: Partial) => Promise; +}; + +export const IssueLabel: FC = observer((props) => { + const { workspaceSlug, projectId, issueId, disabled = false } = props; + // hooks + const { updateIssue } = useIssueDetail(); + const { + project: { createLabel }, + } = useLabel(); + const { setToastAlert } = useToast(); + + const labelOperations: TLabelOperations = useMemo( + () => ({ + updateIssue: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => { + try { + await updateIssue(workspaceSlug, projectId, issueId, data); + setToastAlert({ + title: "Issue updated successfully", + type: "success", + message: "Issue updated successfully", + }); + } catch (error) { + setToastAlert({ + title: "Issue update failed", + type: "error", + message: "Issue update failed", + }); + } + }, + createLabel: async (workspaceSlug: string, projectId: string, data: Partial) => { + try { + const labelResponse = await createLabel(workspaceSlug, projectId, data); + setToastAlert({ + title: "Label created successfully", + type: "success", + message: "Label created successfully", + }); + return labelResponse; + } catch (error) { + setToastAlert({ + title: "Label creation failed", + type: "error", + message: "Label creation failed", + }); + return error; + } + }, + }), + [updateIssue, createLabel, setToastAlert] + ); + + return ( +
    + + + {/*
    select existing labels
    */} + + +
    + ); +}); diff --git a/web/components/issues/issue-detail/label/select-existing.tsx b/web/components/issues/issue-detail/label/select-existing.tsx new file mode 100644 index 000000000..f4c287e86 --- /dev/null +++ b/web/components/issues/issue-detail/label/select-existing.tsx @@ -0,0 +1,9 @@ +import { FC } from "react"; + +type TLabelExistingSelect = {}; + +export const LabelExistingSelect: FC = (props) => { + const {} = props; + + return <>; +}; diff --git a/web/components/issues/issue-links/create-update-link-modal.tsx b/web/components/issues/issue-detail/links/create-update-link-modal.tsx similarity index 99% rename from web/components/issues/issue-links/create-update-link-modal.tsx rename to web/components/issues/issue-detail/links/create-update-link-modal.tsx index 1cbcd4656..fc9eb3838 100644 --- a/web/components/issues/issue-links/create-update-link-modal.tsx +++ b/web/components/issues/issue-detail/links/create-update-link-modal.tsx @@ -42,7 +42,7 @@ export const IssueLinkCreateUpdateModal: FC = (props) const onClose = () => { handleModal(false); const timeout = setTimeout(() => { - reset(defaultValues); + reset(preloadedData ? preloadedData : defaultValues); clearTimeout(timeout); }, 500); }; diff --git a/web/components/issues/issue-detail/links/index.ts b/web/components/issues/issue-detail/links/index.ts new file mode 100644 index 000000000..4a06c89af --- /dev/null +++ b/web/components/issues/issue-detail/links/index.ts @@ -0,0 +1,4 @@ +export * from "./root"; + +export * from "./links"; +export * from "./link-detail"; diff --git a/web/components/issues/issue-links/link-detail.tsx b/web/components/issues/issue-detail/links/link-detail.tsx similarity index 94% rename from web/components/issues/issue-links/link-detail.tsx rename to web/components/issues/issue-detail/links/link-detail.tsx index 3a5fdc224..c92c13977 100644 --- a/web/components/issues/issue-links/link-detail.tsx +++ b/web/components/issues/issue-detail/links/link-detail.tsx @@ -23,13 +23,17 @@ export const IssueLinkDetail: FC = (props) => { const { linkId, linkOperations, isNotAllowed } = props; // hooks const { + toggleIssueLinkModal: toggleIssueLinkModalStore, link: { getLinkById }, } = useIssueDetail(); const { setToastAlert } = useToast(); // state const [isIssueLinkModalOpen, setIsIssueLinkModalOpen] = useState(false); - const toggleIssueLinkModal = (modalToggle: boolean) => setIsIssueLinkModalOpen(modalToggle); + const toggleIssueLinkModal = (modalToggle: boolean) => { + toggleIssueLinkModalStore(modalToggle); + setIsIssueLinkModalOpen(modalToggle); + }; const linkDetail = getLinkById(linkId); if (!linkDetail) return <>; @@ -74,7 +78,7 @@ export const IssueLinkDetail: FC = (props) => { onClick={(e) => { e.preventDefault(); e.stopPropagation(); - setIsIssueLinkModalOpen(true); + toggleIssueLinkModal(true); }} > diff --git a/web/components/issues/issue-links/links.tsx b/web/components/issues/issue-detail/links/links.tsx similarity index 100% rename from web/components/issues/issue-links/links.tsx rename to web/components/issues/issue-detail/links/links.tsx diff --git a/web/components/issues/issue-links/root.tsx b/web/components/issues/issue-detail/links/root.tsx similarity index 63% rename from web/components/issues/issue-links/root.tsx rename to web/components/issues/issue-detail/links/root.tsx index d4e948bb2..5a0fb2bdf 100644 --- a/web/components/issues/issue-links/root.tsx +++ b/web/components/issues/issue-detail/links/root.tsx @@ -1,7 +1,7 @@ -import { FC, useMemo, useState } from "react"; +import { FC, useCallback, useMemo, useState } from "react"; import { Plus } from "lucide-react"; // hooks -import { useApplication, useIssueDetail } from "hooks/store"; +import { useIssueDetail } from "hooks/store"; import useToast from "hooks/use-toast"; // components import { IssueLinkCreateUpdateModal } from "./create-update-link-modal"; @@ -16,21 +16,27 @@ export type TLinkOperations = { }; export type TIssueLinkRoot = { - uneditable: boolean; - isAllowed: boolean; + workspaceSlug: string; + projectId: string; + issueId: string; + is_editable: boolean; + is_archived: boolean; }; export const IssueLinkRoot: FC = (props) => { // props - const { uneditable, isAllowed } = props; + const { workspaceSlug, projectId, issueId, is_editable, is_archived } = props; // hooks - const { - router: { workspaceSlug, projectId }, - } = useApplication(); - const { peekIssue, createLink, updateLink, removeLink } = useIssueDetail(); + const { toggleIssueLinkModal: toggleIssueLinkModalStore, createLink, updateLink, removeLink } = useIssueDetail(); // state - const [isIssueLinkModalOpen, setIsIssueLinkModalOpen] = useState(false); - const toggleIssueLinkModal = (modalToggle: boolean) => setIsIssueLinkModalOpen(modalToggle); + const [isIssueLinkModal, setIsIssueLinkModal] = useState(false); + const toggleIssueLinkModal = useCallback( + (modalToggle: boolean) => { + toggleIssueLinkModalStore(modalToggle); + setIsIssueLinkModal(modalToggle); + }, + [toggleIssueLinkModalStore] + ); const { setToastAlert } = useToast(); @@ -38,8 +44,8 @@ export const IssueLinkRoot: FC = (props) => { () => ({ create: async (data: Partial) => { try { - if (!workspaceSlug || !projectId || !peekIssue?.issueId) throw new Error("Missing required fields"); - await createLink(workspaceSlug, projectId, peekIssue?.issueId, data); + if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); + await createLink(workspaceSlug, projectId, issueId, data); setToastAlert({ message: "The link has been successfully created", type: "success", @@ -56,8 +62,8 @@ export const IssueLinkRoot: FC = (props) => { }, update: async (linkId: string, data: Partial) => { try { - if (!workspaceSlug || !projectId || !peekIssue?.issueId) throw new Error("Missing required fields"); - await updateLink(workspaceSlug, projectId, peekIssue?.issueId, linkId, data); + if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); + await updateLink(workspaceSlug, projectId, issueId, linkId, data); setToastAlert({ message: "The link has been successfully updated", type: "success", @@ -74,8 +80,8 @@ export const IssueLinkRoot: FC = (props) => { }, remove: async (linkId: string) => { try { - if (!workspaceSlug || !projectId || !peekIssue?.issueId) throw new Error("Missing required fields"); - await removeLink(workspaceSlug, projectId, peekIssue?.issueId, linkId); + if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); + await removeLink(workspaceSlug, projectId, issueId, linkId); setToastAlert({ message: "The link has been successfully removed", type: "success", @@ -91,28 +97,28 @@ export const IssueLinkRoot: FC = (props) => { } }, }), - [workspaceSlug, projectId, peekIssue, createLink, updateLink, removeLink, setToastAlert] + [workspaceSlug, projectId, issueId, createLink, updateLink, removeLink, setToastAlert, toggleIssueLinkModal] ); return ( <> -
    +

    Links

    - {isAllowed && ( + {is_editable && ( diff --git a/web/components/issues/issue-detail/main-content.tsx b/web/components/issues/issue-detail/main-content.tsx new file mode 100644 index 000000000..116a0a006 --- /dev/null +++ b/web/components/issues/issue-detail/main-content.tsx @@ -0,0 +1,130 @@ +import { useState } from "react"; +import { observer } from "mobx-react-lite"; +// hooks +import { useIssueDetail, useProject, useProjectState, useUser } from "hooks/store"; +// components +import { IssueDescriptionForm, IssueAttachmentRoot, IssueUpdateStatus } from "components/issues"; +import { IssueParentDetail } from "./parent"; +import { IssueReaction } from "./reactions"; +import { SubIssuesRoot } from "../sub-issues"; +// ui +import { StateGroupIcon } from "@plane/ui"; +// types +import { TIssueOperations } from "./root"; +// constants +import { EUserProjectRoles } from "constants/project"; + +type Props = { + workspaceSlug: string; + projectId: string; + issueId: string; + issueOperations: TIssueOperations; + is_archived: boolean; + is_editable: boolean; +}; + +export const IssueMainContent: React.FC = observer((props) => { + const { workspaceSlug, projectId, issueId, issueOperations, is_archived, is_editable } = props; + // states + const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved"); + // hooks + const { + currentUser, + membership: { currentProjectRole }, + } = useUser(); + const { getProjectById } = useProject(); + const { projectStates } = useProjectState(); + const { + issue: { getIssueById }, + } = useIssueDetail(); + + const issue = getIssueById(issueId); + if (!issue) return <>; + + const projectDetails = projectId ? getProjectById(projectId) : null; + const currentIssueState = projectStates?.find((s) => s.id === issue.state_id); + + const isAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; + + return ( + <> +
    + {issue.parent_id && ( + + )} + +
    + {currentIssueState && ( + + )} + +
    + + setIsSubmitting(value)} + isSubmitting={isSubmitting} + issue={issue} + issueOperations={issueOperations} + isAllowed={isAllowed || !is_editable} + /> + + {currentUser && ( + + )} + + {currentUser && ( + + )} +
    + + {/* issue attachments */} + + + {/*
    +

    Comments/Activity

    + + +
    */} + + ); +}); diff --git a/web/components/issues/issue-detail/module-select.tsx b/web/components/issues/issue-detail/module-select.tsx new file mode 100644 index 000000000..4ac5f1fa5 --- /dev/null +++ b/web/components/issues/issue-detail/module-select.tsx @@ -0,0 +1,103 @@ +import React, { ReactNode, useState } from "react"; +import { observer } from "mobx-react-lite"; +import useSWR from "swr"; +// hooks +import { useModule, useIssueDetail } from "hooks/store"; +// ui +import { CustomSearchSelect, DiceIcon, Spinner, Tooltip } from "@plane/ui"; +// types +import type { TIssueOperations } from "./root"; + +type TIssueModuleSelect = { + workspaceSlug: string; + projectId: string; + issueId: string; + issueOperations: TIssueOperations; + disabled?: boolean; +}; + +export const IssueModuleSelect: React.FC = observer((props) => { + const { workspaceSlug, projectId, issueId, issueOperations, disabled = false } = props; + // hooks + const { getModuleById, projectModuleIds, fetchModules } = useModule(); + const { + issue: { getIssueById }, + } = useIssueDetail(); + // state + const [isUpdating, setIsUpdating] = useState(false); + + useSWR(workspaceSlug && projectId ? `PROJECT_${projectId}_ISSUE_${issueId}_MODULES` : null, async () => { + if (workspaceSlug && projectId) await fetchModules(workspaceSlug, projectId); + }); + + const issue = getIssueById(issueId); + const issueModule = (issue && issue.module_id && getModuleById(issue.module_id)) || undefined; + const disableSelect = disabled || isUpdating; + + const handleIssueModuleChange = async (moduleId: string) => { + if (!moduleId) return; + setIsUpdating(true); + if (issue && issue.module_id === moduleId) + await issueOperations.removeIssueFromModule(workspaceSlug, projectId, moduleId, issueId); + else await issueOperations.addIssueToModule(workspaceSlug, projectId, moduleId, [issueId]); + setIsUpdating(false); + }; + + type TDropdownOptions = { value: string; query: string; content: ReactNode }[]; + const options: TDropdownOptions | undefined = projectModuleIds + ? (projectModuleIds + .map((moduleId) => { + const _module = getModuleById(moduleId); + if (!_module) return undefined; + + return { + value: _module.id, + query: _module.name, + content: ( +
    + + + + {_module.name} +
    + ) as ReactNode, + }; + }) + .filter((_module) => _module !== undefined) as TDropdownOptions) + : undefined; + + return ( +
    + handleIssueModuleChange(value)} + options={options} + customButton={ +
    + + + +
    + } + width="max-w-[10rem]" + noChevron + disabled={disableSelect} + /> + {isUpdating && } +
    + ); +}); diff --git a/web/components/issues/issue-detail/parent-select.tsx b/web/components/issues/issue-detail/parent-select.tsx new file mode 100644 index 000000000..ad1bb6dda --- /dev/null +++ b/web/components/issues/issue-detail/parent-select.tsx @@ -0,0 +1,82 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react-lite"; +import { X } from "lucide-react"; +// hooks +import { useIssueDetail, useProject } from "hooks/store"; +import { Spinner } from "@plane/ui"; +// components +import { ParentIssuesListModal } from "components/issues"; +import { TIssueOperations } from "./root"; + +type TIssueParentSelect = { + workspaceSlug: string; + projectId: string; + issueId: string; + issueOperations: TIssueOperations; + + disabled?: boolean; +}; + +export const IssueParentSelect: React.FC = observer( + ({ workspaceSlug, projectId, issueId, issueOperations, disabled = false }) => { + // hooks + const { getProjectById } = useProject(); + const { + issue: { getIssueById }, + } = useIssueDetail(); + // state + const { isParentIssueModalOpen, toggleParentIssueModal } = useIssueDetail(); + const [updating, setUpdating] = useState(false); + + const issue = getIssueById(issueId); + + const parentIssue = issue && issue.parent_id ? getIssueById(issue.parent_id) : undefined; + const parentIssueProjectDetails = + parentIssue && parentIssue.project_id ? getProjectById(parentIssue.project_id) : undefined; + + const handleParentIssue = async (_issueId: string | null = null) => { + setUpdating(true); + await issueOperations.update(workspaceSlug, projectId, issueId, { parent_id: _issueId }).finally(() => { + toggleParentIssueModal(false); + setUpdating(false); + }); + }; + + if (!issue) return <>; + + return ( +
    + toggleParentIssueModal(false)} + onChange={(issue: any) => handleParentIssue(issue?.id)} + /> + + + + {updating && } +
    + ); + } +); diff --git a/web/components/issues/issue-detail/parent/index.ts b/web/components/issues/issue-detail/parent/index.ts new file mode 100644 index 000000000..1b5a96749 --- /dev/null +++ b/web/components/issues/issue-detail/parent/index.ts @@ -0,0 +1,4 @@ +export * from "./root"; + +export * from "./siblings"; +export * from "./sibling-item"; diff --git a/web/components/issues/issue-detail/parent/root.tsx b/web/components/issues/issue-detail/parent/root.tsx new file mode 100644 index 000000000..2176ccecc --- /dev/null +++ b/web/components/issues/issue-detail/parent/root.tsx @@ -0,0 +1,72 @@ +import { FC } from "react"; +import Link from "next/link"; +import { MinusCircle } from "lucide-react"; +// component +import { IssueParentSiblings } from "./siblings"; +// ui +import { CustomMenu } from "@plane/ui"; +// hooks +import { useIssueDetail, useIssues, useProject, useProjectState } from "hooks/store"; +// types +import { TIssueOperations } from "../root"; +import { TIssue } from "@plane/types"; + +export type TIssueParentDetail = { + workspaceSlug: string; + projectId: string; + issueId: string; + issue: TIssue; + issueOperations: TIssueOperations; +}; + +export const IssueParentDetail: FC = (props) => { + const { workspaceSlug, projectId, issueId, issue, issueOperations } = props; + // hooks + const { issueMap } = useIssues(); + const { peekIssue } = useIssueDetail(); + const { getProjectById } = useProject(); + const { getProjectStates } = useProjectState(); + + const parentIssue = issueMap?.[issue.parent_id || ""] || undefined; + + const issueParentState = getProjectStates(parentIssue?.project_id)?.find( + (state) => state?.id === parentIssue?.state_id + ); + const stateColor = issueParentState?.color || undefined; + + if (!parentIssue) return <>; + + return ( + <> +
    + +
    +
    + + + {getProjectById(parentIssue.project_id)?.identifier}-{parentIssue?.sequence_id} + +
    + {(parentIssue?.name ?? "").substring(0, 50)} +
    + + + +
    + Sibling issues +
    + + + + issueOperations.update(workspaceSlug, projectId, issueId, { parent_id: null })} + className="flex items-center gap-2 py-2 text-red-500" + > + + Remove Parent Issue + +
    +
    + + ); +}; diff --git a/web/components/issues/issue-detail/parent/sibling-item.tsx b/web/components/issues/issue-detail/parent/sibling-item.tsx new file mode 100644 index 000000000..cbcf4741b --- /dev/null +++ b/web/components/issues/issue-detail/parent/sibling-item.tsx @@ -0,0 +1,39 @@ +import { FC } from "react"; +import Link from "next/link"; +// ui +import { CustomMenu, LayersIcon } from "@plane/ui"; +// hooks +import { useIssueDetail, useProject } from "hooks/store"; + +type TIssueParentSiblingItem = { + issueId: string; +}; + +export const IssueParentSiblingItem: FC = (props) => { + const { issueId } = props; + // hooks + const { getProjectById } = useProject(); + const { + peekIssue, + issue: { getIssueById }, + } = useIssueDetail(); + + const issueDetail = (issueId && getIssueById(issueId)) || undefined; + if (!issueDetail) return <>; + + const projectDetails = (issueDetail.project_id && getProjectById(issueDetail.project_id)) || undefined; + + return ( + <> + + + + {projectDetails?.identifier}-{issueDetail.sequence_id} + + + + ); +}; diff --git a/web/components/issues/issue-detail/parent/siblings.tsx b/web/components/issues/issue-detail/parent/siblings.tsx new file mode 100644 index 000000000..b8ebc9ec9 --- /dev/null +++ b/web/components/issues/issue-detail/parent/siblings.tsx @@ -0,0 +1,51 @@ +import { FC } from "react"; +import useSWR from "swr"; +import { observer } from "mobx-react-lite"; +// components +import { IssueParentSiblingItem } from "./sibling-item"; +// hooks +import { useIssueDetail } from "hooks/store"; +// types +import { TIssue } from "@plane/types"; + +export type TIssueParentSiblings = { + currentIssue: TIssue; + parentIssue: TIssue; +}; + +export const IssueParentSiblings: FC = (props) => { + const { currentIssue, parentIssue } = props; + // hooks + const { + peekIssue, + fetchSubIssues, + subIssues: { subIssuesByIssueId }, + } = useIssueDetail(); + + const { isLoading } = useSWR( + peekIssue && parentIssue + ? `ISSUE_PARENT_CHILD_ISSUES_${peekIssue?.workspaceSlug}_${parentIssue.project_id}_${parentIssue.id}` + : null, + peekIssue && parentIssue + ? () => fetchSubIssues(peekIssue?.workspaceSlug, parentIssue.project_id, parentIssue.id) + : null + ); + + const subIssueIds = (parentIssue && subIssuesByIssueId(parentIssue.id)) || undefined; + + return ( +
    + {isLoading ? ( +
    + Loading +
    + ) : subIssueIds && subIssueIds.length > 0 ? ( + subIssueIds.map((issueId) => currentIssue.id != issueId && ) + ) : ( +
    + No sibling issues +
    + )} +
    + ); +}; diff --git a/web/components/issues/issue-detail/reactions/index.ts b/web/components/issues/issue-detail/reactions/index.ts new file mode 100644 index 000000000..8dc6f05bd --- /dev/null +++ b/web/components/issues/issue-detail/reactions/index.ts @@ -0,0 +1,4 @@ +export * from "./reaction-selector"; + +export * from "./issue"; +// export * from "./issue-comment"; diff --git a/web/components/issues/issue-detail/reactions/issue.tsx b/web/components/issues/issue-detail/reactions/issue.tsx new file mode 100644 index 000000000..1627a6730 --- /dev/null +++ b/web/components/issues/issue-detail/reactions/issue.tsx @@ -0,0 +1,103 @@ +import { FC, useMemo } from "react"; +import { observer } from "mobx-react-lite"; +// components +import { ReactionSelector } from "./reaction-selector"; +// hooks +import { useIssueDetail } from "hooks/store"; +import useToast from "hooks/use-toast"; +// types +import { IUser } from "@plane/types"; +import { renderEmoji } from "helpers/emoji.helper"; + +export type TIssueReaction = { + workspaceSlug: string; + projectId: string; + issueId: string; + currentUser: IUser; +}; + +export const IssueReaction: FC = observer((props) => { + const { workspaceSlug, projectId, issueId, currentUser } = props; + // hooks + const { + reaction: { getReactionsByIssueId, reactionsByUser }, + createReaction, + removeReaction, + } = useIssueDetail(); + const { setToastAlert } = useToast(); + + const reactionIds = getReactionsByIssueId(issueId); + const userReactions = reactionsByUser(issueId, currentUser.id).map((r) => r.reaction); + + const issueReactionOperations = useMemo( + () => ({ + create: async (reaction: string) => { + try { + if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields"); + await createReaction(workspaceSlug, projectId, issueId, reaction); + setToastAlert({ + title: "Reaction created successfully", + type: "success", + message: "Reaction created successfully", + }); + } catch (error) { + setToastAlert({ + title: "Reaction creation failed", + type: "error", + message: "Reaction creation failed", + }); + } + }, + remove: async (reaction: string) => { + try { + if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields"); + await removeReaction(workspaceSlug, projectId, issueId, reaction, currentUser.id); + setToastAlert({ + title: "Reaction removed successfully", + type: "success", + message: "Reaction removed successfully", + }); + } catch (error) { + setToastAlert({ + title: "Reaction remove failed", + type: "error", + message: "Reaction remove failed", + }); + } + }, + react: async (reaction: string) => { + if (userReactions.includes(reaction)) await issueReactionOperations.remove(reaction); + else await issueReactionOperations.create(reaction); + }, + }), + [workspaceSlug, projectId, issueId, currentUser, createReaction, removeReaction, setToastAlert, userReactions] + ); + + return ( +
    + + + {reactionIds && + Object.keys(reactionIds || {}).map( + (reaction) => + reactionIds[reaction]?.length > 0 && ( + <> + + + ) + )} +
    + ); +}); diff --git a/web/components/core/reaction-selector.tsx b/web/components/issues/issue-detail/reactions/reaction-selector.tsx similarity index 100% rename from web/components/core/reaction-selector.tsx rename to web/components/issues/issue-detail/reactions/reaction-selector.tsx diff --git a/web/components/issues/sidebar-select/relation.tsx b/web/components/issues/issue-detail/relation-select.tsx similarity index 95% rename from web/components/issues/sidebar-select/relation.tsx rename to web/components/issues/issue-detail/relation-select.tsx index 58e0d720b..801c04ebd 100644 --- a/web/components/issues/sidebar-select/relation.tsx +++ b/web/components/issues/issue-detail/relation-select.tsx @@ -1,5 +1,4 @@ import React, { useState } from "react"; -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { X, CopyPlus } from "lucide-react"; // hooks @@ -37,17 +36,16 @@ const issueRelationObject: Record = { }, }; -type Props = { +type TIssueRelationSelect = { + workspaceSlug: string; + projectId: string; issueId: string; relationKey: TIssueRelationTypes; disabled?: boolean; }; -export const SidebarIssueRelationSelect: React.FC = observer((props) => { - const { issueId, relationKey, disabled = false } = props; - // router - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; +export const IssueRelationSelect: React.FC = observer((props) => { + const { workspaceSlug, projectId, issueId, relationKey, disabled = false } = props; // hooks const { currentUser } = useUser(); const { getProjectById } = useProject(); diff --git a/web/components/issues/issue-detail/root.tsx b/web/components/issues/issue-detail/root.tsx new file mode 100644 index 000000000..876f55369 --- /dev/null +++ b/web/components/issues/issue-detail/root.tsx @@ -0,0 +1,199 @@ +import { FC, useMemo } from "react"; +import { useRouter } from "next/router"; +// components +import { IssueMainContent } from "./main-content"; +import { IssueDetailsSidebar } from "./sidebar"; +// ui +import { EmptyState } from "components/common"; +// images +import emptyIssue from "public/empty-state/issue.svg"; +// hooks +import { useIssueDetail } from "hooks/store"; +import useToast from "hooks/use-toast"; +// types +import { TIssue } from "@plane/types"; + +export type TIssueOperations = { + update: (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => Promise; + remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise; + addIssueToCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise; + removeIssueFromCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise; + addIssueToModule: (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => Promise; + removeIssueFromModule: (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) => Promise; +}; + +export type TIssueDetailRoot = { + workspaceSlug: string; + projectId: string; + issueId: string; + is_archived?: boolean; + is_editable?: boolean; +}; + +export const IssueDetailRoot: FC = (props) => { + const { workspaceSlug, projectId, issueId, is_archived = false, is_editable = true } = props; + // router + const router = useRouter(); + // hooks + const { + issue: { getIssueById }, + updateIssue, + removeIssue, + addIssueToCycle, + removeIssueFromCycle, + addIssueToModule, + removeIssueFromModule, + } = useIssueDetail(); + const { setToastAlert } = useToast(); + + const issueOperations: TIssueOperations = useMemo( + () => ({ + update: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => { + try { + await updateIssue(workspaceSlug, projectId, issueId, data); + setToastAlert({ + title: "Issue updated successfully", + type: "success", + message: "Issue updated successfully", + }); + } catch (error) { + setToastAlert({ + title: "Issue update failed", + type: "error", + message: "Issue update failed", + }); + } + }, + remove: async (workspaceSlug: string, projectId: string, issueId: string) => { + try { + await removeIssue(workspaceSlug, projectId, issueId); + setToastAlert({ + title: "Issue deleted successfully", + type: "success", + message: "Issue deleted successfully", + }); + } catch (error) { + setToastAlert({ + title: "Issue delete failed", + type: "error", + message: "Issue delete failed", + }); + } + }, + addIssueToCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => { + try { + await addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds); + setToastAlert({ + title: "Cycle added to issue successfully", + type: "success", + message: "Issue added to issue successfully", + }); + } catch (error) { + setToastAlert({ + title: "Cycle add to issue failed", + type: "error", + message: "Cycle add to issue failed", + }); + } + }, + removeIssueFromCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => { + try { + await removeIssueFromCycle(workspaceSlug, projectId, cycleId, issueId); + setToastAlert({ + title: "Cycle removed from issue successfully", + type: "success", + message: "Cycle removed from issue successfully", + }); + } catch (error) { + setToastAlert({ + title: "Cycle remove from issue failed", + type: "error", + message: "Cycle remove from issue failed", + }); + } + }, + addIssueToModule: async (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => { + try { + await addIssueToModule(workspaceSlug, projectId, moduleId, issueIds); + setToastAlert({ + title: "Module added to issue successfully", + type: "success", + message: "Module added to issue successfully", + }); + } catch (error) { + setToastAlert({ + title: "Module add to issue failed", + type: "error", + message: "Module add to issue failed", + }); + } + }, + removeIssueFromModule: async (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) => { + try { + await removeIssueFromModule(workspaceSlug, projectId, moduleId, issueId); + setToastAlert({ + title: "Module removed from issue successfully", + type: "success", + message: "Module removed from issue successfully", + }); + } catch (error) { + setToastAlert({ + title: "Module remove from issue failed", + type: "error", + message: "Module remove from issue failed", + }); + } + }, + }), + [ + updateIssue, + removeIssue, + addIssueToCycle, + removeIssueFromCycle, + addIssueToModule, + removeIssueFromModule, + setToastAlert, + ] + ); + + const issue = getIssueById(issueId); + + return ( + <> + {!issue ? ( + router.push(`/${workspaceSlug}/projects/${projectId}/issues`), + }} + /> + ) : ( +
    +
    + +
    +
    + +
    +
    + )} + + ); +}; diff --git a/web/components/issues/sidebar.tsx b/web/components/issues/issue-detail/sidebar.tsx similarity index 50% rename from web/components/issues/sidebar.tsx rename to web/components/issues/issue-detail/sidebar.tsx index 6b24b84b6..ce4071f06 100644 --- a/web/components/issues/sidebar.tsx +++ b/web/components/issues/issue-detail/sidebar.tsx @@ -1,45 +1,40 @@ -import React, { useCallback, useState } from "react"; +import React, { useState } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; -import { mutate } from "swr"; -import { Controller, UseFormWatch } from "react-hook-form"; -import { Bell, CalendarDays, LinkIcon, Signal, Tag, Trash2, Triangle, LayoutPanelTop } from "lucide-react"; +import { CalendarDays, LinkIcon, Signal, Tag, Trash2, Triangle, LayoutPanelTop } from "lucide-react"; // hooks -import { useEstimate, useIssues, useProject, useProjectState, useUser } from "hooks/store"; +import { useEstimate, useIssueDetail, useProject, useProjectState, useUser } from "hooks/store"; import useToast from "hooks/use-toast"; -import useUserIssueNotificationSubscription from "hooks/use-issue-notification-subscription"; -// services -import { IssueService } from "services/issue"; -import { ModuleService } from "services/module.service"; // components import { DeleteIssueModal, - SidebarIssueRelationSelect, - SidebarCycleSelect, - SidebarModuleSelect, - SidebarParentSelect, - SidebarLabelSelect, IssueLinkRoot, + IssueRelationSelect, + IssueCycleSelect, + IssueModuleSelect, + IssueParentSelect, + IssueLabel, } from "components/issues"; +import { IssueSubscription } from "./subscription"; import { EstimateDropdown, PriorityDropdown, ProjectMemberDropdown, StateDropdown } from "components/dropdowns"; // ui import { CustomDatePicker } from "components/ui"; // icons -import { Button, ContrastIcon, DiceIcon, DoubleCircleIcon, StateGroupIcon, UserGroupIcon } from "@plane/ui"; +import { ContrastIcon, DiceIcon, DoubleCircleIcon, StateGroupIcon, UserGroupIcon } from "@plane/ui"; // helpers import { copyTextToClipboard } from "helpers/string.helper"; // types -import type { TIssue } from "@plane/types"; +import type { TIssueOperations } from "./root"; // fetch-keys -import { ISSUE_DETAILS } from "constants/fetch-keys"; import { EUserProjectRoles } from "constants/project"; -import { EIssuesStoreType } from "constants/issue"; type Props = { - control: any; - submitChanges: (formData: any) => void; - issueDetail: TIssue | undefined; - watch: UseFormWatch; + workspaceSlug: string; + projectId: string; + issueId: string; + issueOperations: TIssueOperations; + is_archived: boolean; + is_editable: boolean; fieldsToShow?: ( | "state" | "assignee" @@ -60,74 +55,42 @@ type Props = { | "duplicate" | "relates_to" )[]; - uneditable?: boolean; }; -const issueService = new IssueService(); -const moduleService = new ModuleService(); - export const IssueDetailsSidebar: React.FC = observer((props) => { - const { control, submitChanges, issueDetail, watch: watchIssue, fieldsToShow = ["all"], uneditable = false } = props; - // states - const [deleteIssueModal, setDeleteIssueModal] = useState(false); + const { + workspaceSlug, + projectId, + issueId, + issueOperations, + is_archived, + is_editable, + fieldsToShow = ["all"], + } = props; + // router + const router = useRouter(); + const { inboxIssueId } = router.query; // store hooks const { getProjectById } = useProject(); - const { - issues: { removeIssue }, - } = useIssues(EIssuesStoreType.PROJECT); const { currentUser, membership: { currentProjectRole }, } = useUser(); const { projectStates } = useProjectState(); const { areEstimatesEnabledForCurrentProject } = useEstimate(); - // router - const router = useRouter(); - const { workspaceSlug, projectId, issueId, inboxIssueId } = router.query; - - const { loading, handleSubscribe, handleUnsubscribe, subscribed } = useUserIssueNotificationSubscription( - currentUser, - workspaceSlug, - projectId, - issueId - ); - const { setToastAlert } = useToast(); + const { + issue: { getIssueById }, + } = useIssueDetail(); + // states + const [deleteIssueModal, setDeleteIssueModal] = useState(false); - const handleCycleChange = useCallback( - (cycleId: string) => { - if (!workspaceSlug || !projectId || !issueDetail || !currentUser) return; - - issueService - .addIssueToCycle(workspaceSlug as string, projectId as string, cycleId, { - issues: [issueDetail.id], - }) - .then(() => { - mutate(ISSUE_DETAILS(issueId as string)); - }); - }, - [workspaceSlug, projectId, issueId, issueDetail, currentUser] - ); - - const handleModuleChange = useCallback( - (moduleId: string) => { - if (!workspaceSlug || !projectId || !issueDetail || !currentUser) return; - - moduleService - .addIssuesToModule(workspaceSlug as string, projectId as string, moduleId, { - issues: [issueDetail.id], - }) - .then(() => { - mutate(ISSUE_DETAILS(issueId as string)); - }); - }, - [workspaceSlug, projectId, issueId, issueDetail, currentUser] - ); + const issue = getIssueById(issueId); + if (!issue) return <>; const handleCopyText = () => { const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; - - copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/issues/${issueDetail?.id}`).then(() => { + copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`).then(() => { setToastAlert({ type: "success", title: "Link Copied!", @@ -136,7 +99,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { }); }; - const projectDetails = issueDetail ? getProjectById(issueDetail?.project_id) : null; + const projectDetails = issue ? getProjectById(issue.project_id) : null; const showFirstSection = fieldsToShow.includes("all") || @@ -155,28 +118,25 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { const showThirdSection = fieldsToShow.includes("all") || fieldsToShow.includes("cycle") || fieldsToShow.includes("module"); - const startDate = watchIssue("start_date"); - const targetDate = watchIssue("target_date"); - - const minDate = startDate ? new Date(startDate) : null; + const minDate = issue.start_date ? new Date(issue.start_date) : null; minDate?.setDate(minDate.getDate()); - const maxDate = targetDate ? new Date(targetDate) : null; + const maxDate = issue.target_date ? new Date(issue.target_date) : null; maxDate?.setDate(maxDate.getDate()); const isAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; - const currentIssueState = projectStates?.find((s) => s.id === issueDetail?.state_id); + const currentIssueState = projectStates?.find((s) => s.id === issue.state_id); return ( <> - {workspaceSlug && projectId && issueDetail && ( + {workspaceSlug && projectId && issue && ( setDeleteIssueModal(false)} isOpen={deleteIssueModal} - data={issueDetail} + data={issue} onSubmit={async () => { - await removeIssue(workspaceSlug.toString(), projectId.toString(), issueDetail.id); + await issueOperations.remove(workspaceSlug, projectId, issueId); router.push(`/${workspaceSlug}/projects/${projectId}/issues`); }} /> @@ -195,28 +155,22 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { ) : null}

    - {projectDetails?.identifier}-{issueDetail?.sequence_id} + {projectDetails?.identifier}-{issue?.sequence_id}

    +
    - {issueDetail?.created_by !== currentUser?.id && - !issueDetail?.assignee_ids.includes(currentUser?.id ?? "") && - !router.pathname.includes("[archivedIssueId]") && - (fieldsToShow.includes("all") || fieldsToShow.includes("subscribe")) && ( - - )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("link")) && ( + {currentUser && !is_archived && (fieldsToShow.includes("all") || fieldsToShow.includes("subscribe")) && ( + + )} + + {/* {(fieldsToShow.includes("all") || fieldsToShow.includes("link")) && ( - )} - {isAllowed && (fieldsToShow.includes("all") || fieldsToShow.includes("delete")) && ( + )} */} + + {/* {isAllowed && (fieldsToShow.includes("all") || fieldsToShow.includes("delete")) && ( - )} + )} */}
    -
    +
    {showFirstSection && (
    {(fieldsToShow.includes("all") || fieldsToShow.includes("state")) && ( @@ -247,71 +202,63 @@ export const IssueDetailsSidebar: React.FC = observer((props) => {

    State

    - ( -
    - submitChanges({ state: val })} - projectId={projectId?.toString() ?? ""} - disabled={!isAllowed || uneditable} - buttonVariant="background-with-text" - /> -
    - )} - /> + +
    + issueOperations.update(workspaceSlug, projectId, issueId, { state_id: val })} + projectId={projectId?.toString() ?? ""} + disabled={!isAllowed || !is_editable} + buttonVariant="background-with-text" + /> +
    )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("assignee")) && (

    Assignees

    - ( -
    - submitChanges({ assignees: val })} - disabled={!isAllowed || uneditable} - projectId={projectId?.toString() ?? ""} - placeholder="Assignees" - multiple - buttonVariant={value?.length > 0 ? "transparent-without-text" : "background-with-text"} - buttonClassName={value?.length > 0 ? "hover:bg-transparent px-0" : ""} - /> -
    - )} - /> + +
    + + issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val }) + } + disabled={!isAllowed || !is_editable} + projectId={projectId?.toString() ?? ""} + placeholder="Assignees" + multiple + buttonVariant={ + issue?.assignee_ids?.length > 0 ? "transparent-without-text" : "background-with-text" + } + buttonClassName={issue?.assignee_ids?.length > 0 ? "hover:bg-transparent px-0" : ""} + /> +
    )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("priority")) && (

    Priority

    - ( -
    - submitChanges({ priority: val })} - disabled={!isAllowed || uneditable} - buttonVariant="background-with-text" - /> -
    - )} - /> + +
    + issueOperations.update(workspaceSlug, projectId, issueId, { priority: val })} + disabled={!isAllowed || !is_editable} + buttonVariant="background-with-text" + /> +
    )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("estimate")) && areEstimatesEnabledForCurrentProject && (
    @@ -319,25 +266,23 @@ export const IssueDetailsSidebar: React.FC = observer((props) => {

    Estimate

    - ( -
    - submitChanges({ estimate_point: val })} - projectId={projectId?.toString() ?? ""} - disabled={!isAllowed || uneditable} - buttonVariant="background-with-text" - /> -
    - )} - /> + +
    + + issueOperations.update(workspaceSlug, projectId, issueId, { estimate_point: val }) + } + projectId={projectId} + disabled={!isAllowed || !is_editable} + buttonVariant="background-with-text" + /> +
    )}
    )} + {showSecondSection && (
    {(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && ( @@ -347,53 +292,54 @@ export const IssueDetailsSidebar: React.FC = observer((props) => {

    Parent

    - ( - { - submitChanges({ parent: val }); - onChange(val); - }} - issueDetails={issueDetail} - disabled={!isAllowed || uneditable} - /> - )} +
    )} {(fieldsToShow.includes("all") || fieldsToShow.includes("blocker")) && ( - )} {(fieldsToShow.includes("all") || fieldsToShow.includes("blocked")) && ( - )} {(fieldsToShow.includes("all") || fieldsToShow.includes("duplicate")) && ( - )} {(fieldsToShow.includes("all") || fieldsToShow.includes("relates_to")) && ( - )} @@ -404,27 +350,20 @@ export const IssueDetailsSidebar: React.FC = observer((props) => {

    Start date

    - ( - - submitChanges({ - start_date: val, - }) - } - className="border-none bg-custom-background-80" - maxDate={maxDate ?? undefined} - disabled={!isAllowed || uneditable} - /> - )} + + issueOperations.update(workspaceSlug, projectId, issueId, { start_date: val }) + } + className="border-none bg-custom-background-80" + maxDate={maxDate ?? undefined} + disabled={!isAllowed || !is_editable} />
    )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("dueDate")) && (
    @@ -432,23 +371,15 @@ export const IssueDetailsSidebar: React.FC = observer((props) => {

    Due date

    - ( - - submitChanges({ - target_date: val, - }) - } - className="border-none bg-custom-background-80" - minDate={minDate ?? undefined} - disabled={!isAllowed || uneditable} - /> - )} + + issueOperations.update(workspaceSlug, projectId, issueId, { target_date: val }) + } + className="border-none bg-custom-background-80" + minDate={minDate ?? undefined} + disabled={!isAllowed || !is_editable} />
    @@ -465,14 +396,17 @@ export const IssueDetailsSidebar: React.FC = observer((props) => {

    Cycle

    -
    )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("module")) && projectDetails?.module_view && (
    @@ -480,10 +414,12 @@ export const IssueDetailsSidebar: React.FC = observer((props) => {

    Module

    -
    @@ -499,19 +435,24 @@ export const IssueDetailsSidebar: React.FC = observer((props) => {

    Label

    -
    )} {(fieldsToShow.includes("all") || fieldsToShow.includes("link")) && ( - + )}
    diff --git a/web/components/issues/issue-detail/subscription.tsx b/web/components/issues/issue-detail/subscription.tsx new file mode 100644 index 000000000..8f76eca25 --- /dev/null +++ b/web/components/issues/issue-detail/subscription.tsx @@ -0,0 +1,54 @@ +import { FC, useState } from "react"; +import { Bell } from "lucide-react"; +import { observer } from "mobx-react-lite"; +// UI +import { Button } from "@plane/ui"; +// hooks +import { useIssueDetail } from "hooks/store"; + +export type TIssueSubscription = { + workspaceSlug: string; + projectId: string; + issueId: string; + currentUserId: string; + disabled?: boolean; +}; + +export const IssueSubscription: FC = observer((props) => { + const { workspaceSlug, projectId, issueId, currentUserId, disabled } = props; + // hooks + const { + issue: { getIssueById }, + subscription: { getSubscriptionByIssueId }, + createSubscription, + removeSubscription, + } = useIssueDetail(); + // state + const [loading, setLoading] = useState(false); + + const issue = getIssueById(issueId); + const subscription = getSubscriptionByIssueId(issueId); + + const handleSubscription = () => { + setLoading(true); + if (subscription?.subscribed) removeSubscription(workspaceSlug, projectId, issueId); + else createSubscription(workspaceSlug, projectId, issueId); + }; + + if (issue?.created_by === currentUserId || issue?.assignee_ids.includes(currentUserId)) return <>; + + return ( +
    + +
    + ); +}); diff --git a/web/components/issues/issue-layouts/calendar/issue-blocks.tsx b/web/components/issues/issue-layouts/calendar/issue-blocks.tsx index be30560fb..a4d874430 100644 --- a/web/components/issues/issue-layouts/calendar/issue-blocks.tsx +++ b/web/components/issues/issue-layouts/calendar/issue-blocks.tsx @@ -4,12 +4,13 @@ import { observer } from "mobx-react-lite"; import { Draggable } from "@hello-pangea/dnd"; import { MoreHorizontal } from "lucide-react"; // components -import { Tooltip } from "@plane/ui"; +import { Tooltip, ControlLink } from "@plane/ui"; // hooks import useOutsideClickDetector from "hooks/use-outside-click-detector"; +// ui // types import { TIssue, TIssueMap } from "@plane/types"; -import { useProject, useProjectState } from "hooks/store"; +import { useApplication, useIssueDetail, useProject, useProjectState } from "hooks/store"; type Props = { issues: TIssueMap | undefined; @@ -23,21 +24,23 @@ export const CalendarIssueBlocks: React.FC = observer((props) => { // router const router = useRouter(); // hooks + const { + router: { workspaceSlug, projectId }, + } = useApplication(); const { getProjectById } = useProject(); const { getProjectStates } = useProjectState(); + const { setPeekIssue } = useIssueDetail(); // states const [isMenuActive, setIsMenuActive] = useState(false); const menuActionRef = useRef(null); - const handleIssuePeekOverview = (issue: TIssue) => { - const { query } = router; - - router.push({ - pathname: router.pathname, - query: { ...query, peekIssueId: issue?.id, peekProjectId: issue?.project_id }, - }); - }; + const handleIssuePeekOverview = (issue: TIssue) => + workspaceSlug && + issue && + issue.project_id && + issue.id && + setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id }); useOutsideClickDetector(menuActionRef, () => setIsMenuActive(false)); @@ -67,45 +70,53 @@ export const CalendarIssueBlocks: React.FC = observer((props) => { {...provided.draggableProps} {...provided.dragHandleProps} ref={provided.innerRef} - onClick={() => handleIssuePeekOverview(issue)} > - {issue?.tempId !== undefined && ( -
    - )} - -
    handleIssuePeekOverview(issue)} + className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" > -
    - state?.id == issue?.state_id - )?.color, - }} - /> -
    - {getProjectById(issue?.project_id)?.identifier}-{issue.sequence_id} + <> + {issue?.tempId !== undefined && ( +
    + )} + +
    +
    + state?.id == issue?.state_id + )?.color, + }} + /> +
    + {getProjectById(issue?.project_id)?.identifier}-{issue.sequence_id} +
    + +
    {issue.name}
    +
    +
    +
    { + e.preventDefault(); + e.stopPropagation(); + }} + > + {quickActions(issue, customActionButton)} +
    - -
    {issue.name}
    -
    -
    -
    { - e.preventDefault(); - e.stopPropagation(); - }} - > - {quickActions(issue, customActionButton)} -
    -
    + +
    )} diff --git a/web/components/issues/issue-layouts/gantt/blocks.tsx b/web/components/issues/issue-layouts/gantt/blocks.tsx index fd79cdde1..5622cd672 100644 --- a/web/components/issues/issue-layouts/gantt/blocks.tsx +++ b/web/components/issues/issue-layouts/gantt/blocks.tsx @@ -1,25 +1,26 @@ import { useRouter } from "next/router"; // ui -import { Tooltip, StateGroupIcon } from "@plane/ui"; +import { Tooltip, StateGroupIcon, ControlLink } from "@plane/ui"; // helpers import { renderFormattedDate } from "helpers/date-time.helper"; // types import { TIssue } from "@plane/types"; -import { useProject, useProjectState } from "hooks/store"; +import { useApplication, useIssueDetail, useProject, useProjectState } from "hooks/store"; export const IssueGanttBlock = ({ data }: { data: TIssue }) => { - const router = useRouter(); // hooks + const { + router: { workspaceSlug }, + } = useApplication(); const { getProjectStates } = useProjectState(); + const { setPeekIssue } = useIssueDetail(); - const handleIssuePeekOverview = () => { - const { query } = router; - - router.push({ - pathname: router.pathname, - query: { ...query, peekIssueId: data?.id, peekProjectId: data?.project_id }, - }); - }; + const handleIssuePeekOverview = () => + workspaceSlug && + data && + data.project_id && + data.id && + setPeekIssue({ workspaceSlug, projectId: data.project_id, issueId: data.id }); return (
    { // rendering issues on gantt sidebar export const IssueGanttSidebarBlock = ({ data }: { data: TIssue }) => { - const router = useRouter(); // hooks const { getProjectStates } = useProjectState(); const { getProjectById } = useProject(); + const { + router: { workspaceSlug }, + } = useApplication(); + const { setPeekIssue } = useIssueDetail(); - const handleIssuePeekOverview = () => { - const { query } = router; - - router.push({ - pathname: router.pathname, - query: { ...query, peekIssueId: data?.id, peekProjectId: data?.project_id }, - }); - }; + const handleIssuePeekOverview = () => + workspaceSlug && + data && + data.project_id && + data.id && + setPeekIssue({ workspaceSlug, projectId: data.project_id, issueId: data.id }); const currentStateDetails = getProjectStates(data?.project_id)?.find((state) => state?.id == data?.state_id) || undefined; return ( -
    - {currentStateDetails != undefined && ( - - )} -
    - {getProjectById(data?.project_id)?.identifier} {data?.sequence_id} + +
    + {currentStateDetails != undefined && ( + + )} +
    + {getProjectById(data?.project_id)?.identifier} {data?.sequence_id} +
    + + {data?.name} +
    - - {data?.name} - -
    + ); }; diff --git a/web/components/issues/issue-layouts/gantt/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/gantt/quick-add-issue-form.tsx index a370440f9..78f332ddd 100644 --- a/web/components/issues/issue-layouts/gantt/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/gantt/quick-add-issue-form.tsx @@ -1,10 +1,10 @@ -import { useEffect, useState, useRef } from "react"; +import { useEffect, useState, useRef, FC } from "react"; import { useRouter } from "next/router"; import { useForm } from "react-hook-form"; import { observer } from "mobx-react-lite"; import { PlusIcon } from "lucide-react"; // hooks -import { useProject, useWorkspace } from "hooks/store"; +import { useProject } from "hooks/store"; import useToast from "hooks/use-toast"; import useKeypress from "hooks/use-keypress"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; @@ -12,9 +12,38 @@ import useOutsideClickDetector from "hooks/use-outside-click-detector"; import { renderFormattedPayloadDate } from "helpers/date-time.helper"; import { createIssuePayload } from "helpers/issue.helper"; // types -import { TIssue } from "@plane/types"; +import { IProject, TIssue } from "@plane/types"; -type Props = { +interface IInputProps { + formKey: string; + register: any; + setFocus: any; + projectDetail: IProject | null; +} +const Inputs: FC = (props) => { + const { formKey, register, setFocus, projectDetail } = props; + + useEffect(() => { + setFocus(formKey); + }, [formKey, setFocus]); + + return ( +
    +
    {projectDetail?.identifier ?? "..."}
    + +
    + ); +}; + +type IGanttQuickAddIssueForm = { prePopulatedData?: Partial; onSuccess?: (data: TIssue) => Promise | void; quickAddCallback?: ( @@ -30,34 +59,25 @@ const defaultValues: Partial = { name: "", }; -const Inputs = (props: any) => { - const { register, setFocus } = props; - - useEffect(() => { - setFocus("name"); - }, [setFocus]); - - return ( - - ); -}; - -export const GanttInlineCreateIssueForm: React.FC = observer((props) => { +export const GanttQuickAddIssueForm: React.FC = observer((props) => { const { prePopulatedData, quickAddCallback, viewId } = props; // router const router = useRouter(); const { workspaceSlug, projectId } = router.query; - // store hooks - const { getWorkspaceBySlug } = useWorkspace(); - const { currentProjectDetails } = useProject(); + // hooks + const { getProjectById } = useProject(); + const { setToastAlert } = useToast(); + + const projectDetail = (projectId && getProjectById(projectId.toString())) || undefined; + + const ref = useRef(null); + + const [isOpen, setIsOpen] = useState(false); + const handleClose = () => setIsOpen(false); + + useKeypress("Escape", handleClose); + useOutsideClickDetector(ref, handleClose); + // form info const { reset, @@ -67,103 +87,67 @@ export const GanttInlineCreateIssueForm: React.FC = observer((props) => { formState: { errors, isSubmitting }, } = useForm({ defaultValues }); - // ref - const ref = useRef(null); - - // states - const [isOpen, setIsOpen] = useState(false); - - const handleClose = () => setIsOpen(false); - - // hooks - useKeypress("Escape", handleClose); - useOutsideClickDetector(ref, handleClose); - const { setToastAlert } = useToast(); - - // derived values - const workspaceDetail = getWorkspaceBySlug(workspaceSlug?.toString()!); - useEffect(() => { if (!isOpen) reset({ ...defaultValues }); }, [isOpen, reset]); - useEffect(() => { - if (!errors) return; - - Object.keys(errors).forEach((key) => { - const error = errors[key as keyof TIssue]; - - setToastAlert({ - type: "error", - title: "Error!", - message: error?.message?.toString() || "Some error occurred. Please try again.", - }); - }); - }, [errors, setToastAlert]); - const onSubmitHandler = async (formData: TIssue) => { if (isSubmitting || !workspaceSlug || !projectId) return; reset({ ...defaultValues }); + const targetDate = new Date(); + targetDate.setDate(targetDate.getDate() + 1); + const payload = createIssuePayload(projectId.toString(), { ...(prePopulatedData ?? {}), ...formData, + start_date: renderFormattedPayloadDate(new Date()), + target_date: renderFormattedPayloadDate(targetDate), }); try { - if (quickAddCallback) { - await quickAddCallback(workspaceSlug.toString(), projectId.toString(), payload, viewId); - } + quickAddCallback && + (await quickAddCallback(workspaceSlug.toString(), projectId.toString(), { ...payload }, viewId)); setToastAlert({ type: "success", title: "Success!", message: "Issue created successfully.", }); } catch (err: any) { - Object.keys(err || {}).forEach((key) => { - const error = err?.[key]; - const errorTitle = error ? (Array.isArray(error) ? error.join(", ") : error) : null; - - setToastAlert({ - type: "error", - title: "Error!", - message: errorTitle || "Some error occurred. Please try again.", - }); + setToastAlert({ + type: "error", + title: "Error!", + message: err?.message || "Some error occurred. Please try again.", }); } }; - return ( <> - {isOpen && ( -
    -
    -

    {currentProjectDetails?.identifier ?? "..."}

    - - - )} - - {isOpen && ( -

    - Press {"'"}Enter{"'"} to add another issue -

    - )} - - {!isOpen && ( - - )} +
    + {isOpen ? ( +
    +
    + + +
    {`Press 'Enter' to add another issue`}
    +
    + ) : ( +
    setIsOpen(true)} + > + + New Issue +
    + )} +
    ); }); diff --git a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx index c262af2ca..4167619cc 100644 --- a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx +++ b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx @@ -15,17 +15,16 @@ import { IProjectIssues, IProjectIssuesFilter } from "store/issue/project"; //components import { KanBan } from "./default"; import { KanBanSwimLanes } from "./swimlanes"; -import { DeleteIssueModal, IssuePeekOverview } from "components/issues"; +import { DeleteIssueModal } from "components/issues"; import { EUserProjectRoles } from "constants/project"; import { useIssues } from "hooks/store/use-issues"; import { handleDragDrop } from "./utils"; -import { IssueKanBanViewStore } from "store/issue/issue_kanban_view.store"; import { ICycleIssues, ICycleIssuesFilter } from "store/issue/cycle"; import { IDraftIssues, IDraftIssuesFilter } from "store/issue/draft"; import { IProfileIssues, IProfileIssuesFilter } from "store/issue/profile"; import { IModuleIssues, IModuleIssuesFilter } from "store/issue/module"; import { IProjectViewIssues, IProjectViewIssuesFilter } from "store/issue/project-views"; -import { TCreateModalStoreTypes } from "constants/issue"; +import { EIssueFilterType, TCreateModalStoreTypes } from "constants/issue"; export interface IBaseKanBanLayout { issues: IProjectIssues | ICycleIssues | IDraftIssues | IModuleIssues | IProjectViewIssues | IProfileIssues; @@ -69,7 +68,7 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas } = props; // router const router = useRouter(); - const { workspaceSlug, projectId, peekIssueId, peekProjectId } = router.query; + const { workspaceSlug, projectId } = router.query; // store hooks const { membership: { currentProjectRole }, @@ -78,9 +77,6 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas // toast alert const { setToastAlert } = useToast(); - // FIXME get from filters - const kanbanViewStore: IssueKanBanViewStore = {} as IssueKanBanViewStore; - const issueIds = issues?.groupedIssueIds || []; const displayFilters = issuesFilter?.issueFilters?.displayFilters; @@ -211,10 +207,19 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas }); }; - const handleKanBanToggle = (toggle: "groupByHeaderMinMax" | "subgroupByIssuesVisibility", value: string) => { - kanbanViewStore.handleKanBanToggle(toggle, value); + const handleKanbanFilters = (toggle: "group_by" | "sub_group_by", value: string) => { + if (workspaceSlug && projectId) { + let _kanbanFilters = issuesFilter?.issueFilters?.kanbanFilters?.[toggle] || []; + if (_kanbanFilters.includes(value)) _kanbanFilters = _kanbanFilters.filter((_value) => _value != value); + else _kanbanFilters.push(value); + issuesFilter.updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.KANBAN_FILTERS, { + [toggle]: _kanbanFilters, + }); + } }; + const kanbanFilters = issuesFilter?.issueFilters?.kanbanFilters || { group_by: [], sub_group_by: [] }; + return ( <> = observer((props: IBas
    )} -
    +
    + {/* drag and delete component */}
    = observer((props: IBas group_by={group_by} handleIssues={handleIssues} quickActions={renderQuickActions} - kanBanToggle={kanbanViewStore?.kanBanToggle} - handleKanBanToggle={handleKanBanToggle} + handleKanbanFilters={handleKanbanFilters} + kanbanFilters={kanbanFilters} enableQuickIssueCreate={enableQuickAdd} showEmptyGroup={userDisplayFilters?.show_empty_groups || true} quickAddCallback={issues?.quickAddIssue} @@ -275,15 +281,6 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas />
    - - {/* {workspaceSlug && peekIssueId && peekProjectId && ( - await handleIssues(issueToUpdate as TIssue, EIssueActions.UPDATE)} - /> - )} */} ); }); diff --git a/web/components/issues/issue-layouts/kanban/block.tsx b/web/components/issues/issue-layouts/kanban/block.tsx index 682f3c416..40bd19cf1 100644 --- a/web/components/issues/issue-layouts/kanban/block.tsx +++ b/web/components/issues/issue-layouts/kanban/block.tsx @@ -5,12 +5,11 @@ import { observer } from "mobx-react-lite"; import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; import { IssueProperties } from "../properties/all-properties"; // ui -import { Tooltip } from "@plane/ui"; +import { Tooltip, ControlLink } from "@plane/ui"; // types import { TIssue, IIssueDisplayProperties, IIssueMap } from "@plane/types"; import { EIssueActions } from "../types"; -import { useRouter } from "next/router"; -import { useProject } from "hooks/store"; +import { useApplication, useIssueDetail, useProject } from "hooks/store"; interface IssueBlockProps { issueId: string; @@ -34,24 +33,23 @@ interface IssueDetailsBlockProps { const KanbanIssueDetailsBlock: React.FC = observer((props: IssueDetailsBlockProps) => { const { issue, handleIssues, quickActions, isReadOnly, displayProperties } = props; - - const router = useRouter(); - // hooks const { getProjectById } = useProject(); + const { + router: { workspaceSlug, projectId }, + } = useApplication(); + const { setPeekIssue } = useIssueDetail(); const updateIssue = (issueToUpdate: TIssue) => { if (issueToUpdate) handleIssues(issueToUpdate, EIssueActions.UPDATE); }; - const handleIssuePeekOverview = () => { - const { query } = router; - - router.push({ - pathname: router.pathname, - query: { ...query, peekIssueId: issue?.id, peekProjectId: issue?.project_id }, - }); - }; + const handleIssuePeekOverview = (issue: TIssue) => + workspaceSlug && + issue && + issue.project_id && + issue.id && + setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id }); return ( <> @@ -63,11 +61,18 @@ const KanbanIssueDetailsBlock: React.FC = observer((prop
    {quickActions(issue)}
    - -
    - {issue.name} -
    -
    + + handleIssuePeekOverview(issue)} + className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" + > + + {issue.name} + + + void; quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; - kanBanToggle: any; - handleKanBanToggle: any; + kanbanFilters: TIssueKanbanFilters; + handleKanbanFilters: any; enableQuickIssueCreate?: boolean; quickAddCallback?: ( workspaceSlug: string, @@ -57,8 +58,8 @@ const GroupByKanBan: React.FC = observer((props) => { isDragDisabled, handleIssues, quickActions, - kanBanToggle, - handleKanBanToggle, + kanbanFilters, + handleKanbanFilters, enableQuickIssueCreate, quickAddCallback, viewId, @@ -77,58 +78,63 @@ const GroupByKanBan: React.FC = observer((props) => { if (!list) return null; - const verticalAlignPosition = (_list: IGroupByColumn) => kanBanToggle?.groupByHeaderMinMax.includes(_list.id); + const visibilityGroupBy = (_list: IGroupByColumn) => + sub_group_by ? false : kanbanFilters?.group_by.includes(_list.id) ? true : false; const isGroupByCreatedBy = group_by === "created_by"; return ( -
    +
    {list && list.length > 0 && list.map((_list: IGroupByColumn) => { - const verticalPosition = verticalAlignPosition(_list); + const groupByVisibilityToggle = visibilityGroupBy(_list); return (
    {sub_group_by === null && ( -
    +
    )} - + + {!groupByVisibilityToggle && ( + + )}
    ); })} @@ -145,8 +151,8 @@ export interface IKanBan { sub_group_id?: string; handleIssues: (issue: TIssue, action: EIssueActions) => void; quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; - kanBanToggle: any; - handleKanBanToggle: any; + kanbanFilters: TIssueKanbanFilters; + handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void; showEmptyGroup: boolean; enableQuickIssueCreate?: boolean; quickAddCallback?: ( @@ -172,8 +178,8 @@ export const KanBan: React.FC = observer((props) => { sub_group_id = "null", handleIssues, quickActions, - kanBanToggle, - handleKanBanToggle, + kanbanFilters, + handleKanbanFilters, enableQuickIssueCreate, quickAddCallback, viewId, @@ -186,27 +192,25 @@ export const KanBan: React.FC = observer((props) => { const issueKanBanView = useKanbanView(); return ( -
    - -
    + ); }); diff --git a/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx b/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx index 4d4776d38..decc816f6 100644 --- a/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx +++ b/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx @@ -11,7 +11,7 @@ import useToast from "hooks/use-toast"; // mobx import { observer } from "mobx-react-lite"; // types -import { TIssue, ISearchIssueResponse } from "@plane/types"; +import { TIssue, ISearchIssueResponse, TIssueKanbanFilters } from "@plane/types"; import { TCreateModalStoreTypes } from "constants/issue"; interface IHeaderGroupByCard { @@ -21,8 +21,8 @@ interface IHeaderGroupByCard { icon?: React.ReactNode; title: string; count: number; - kanBanToggle: any; - handleKanBanToggle: any; + kanbanFilters: TIssueKanbanFilters; + handleKanbanFilters: any; issuePayload: Partial; disableIssueCreation?: boolean; currentStore?: TCreateModalStoreTypes; @@ -36,14 +36,14 @@ export const HeaderGroupByCard: FC = observer((props) => { icon, title, count, - kanBanToggle, - handleKanBanToggle, + kanbanFilters, + handleKanbanFilters, issuePayload, disableIssueCreation, currentStore, addIssuesToView, } = props; - const verticalAlignPosition = kanBanToggle?.groupByHeaderMinMax.includes(column_id); + const verticalAlignPosition = sub_group_by ? false : kanbanFilters?.group_by.includes(column_id); const [isOpen, setIsOpen] = React.useState(false); const [openExistingIssueListModal, setOpenExistingIssueListModal] = React.useState(false); @@ -117,7 +117,7 @@ export const HeaderGroupByCard: FC = observer((props) => { {sub_group_by === null && (
    handleKanBanToggle("groupByHeaderMinMax", column_id)} + onClick={() => handleKanbanFilters("group_by", column_id)} > {verticalAlignPosition ? ( diff --git a/web/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx b/web/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx index de5e7abc4..ea9464780 100644 --- a/web/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx +++ b/web/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx @@ -1,26 +1,26 @@ import React from "react"; -// lucide icons import { Circle, ChevronDown, ChevronUp } from "lucide-react"; // mobx import { observer } from "mobx-react-lite"; +import { TIssueKanbanFilters } from "@plane/types"; interface IHeaderSubGroupByCard { icon?: React.ReactNode; title: string; count: number; column_id: string; - kanBanToggle: any; - handleKanBanToggle: any; + kanbanFilters: TIssueKanbanFilters; + handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void; } export const HeaderSubGroupByCard = observer( - ({ icon, title, count, column_id, kanBanToggle, handleKanBanToggle }: IHeaderSubGroupByCard) => ( + ({ icon, title, count, column_id, kanbanFilters, handleKanbanFilters }: IHeaderSubGroupByCard) => (
    handleKanBanToggle("subgroupByIssuesVisibility", column_id)} + onClick={() => handleKanbanFilters("sub_group_by", column_id)} > - {kanBanToggle?.subgroupByIssuesVisibility.includes(column_id) ? ( + {kanbanFilters?.sub_group_by.includes(column_id) ? ( ) : ( diff --git a/web/components/issues/issue-layouts/kanban/kanban-group.tsx b/web/components/issues/issue-layouts/kanban/kanban-group.tsx index cbd1b1fc1..cec36ad0c 100644 --- a/web/components/issues/issue-layouts/kanban/kanban-group.tsx +++ b/web/components/issues/issue-layouts/kanban/kanban-group.tsx @@ -1,4 +1,8 @@ import { Droppable } from "@hello-pangea/dnd"; +// hooks +import { useProjectState } from "hooks/store"; +//components +import { KanbanIssueBlocksList, KanBanQuickAddIssueForm } from "."; //types import { TGroupedIssues, @@ -9,10 +13,6 @@ import { TUnGroupedIssues, } from "@plane/types"; import { EIssueActions } from "../types"; -// hooks -import { useProjectState } from "hooks/store"; -//components -import { KanBanQuickAddIssueForm, KanbanIssueBlocksList } from "."; interface IKanbanGroup { groupId: string; @@ -35,7 +35,7 @@ interface IKanbanGroup { viewId?: string; disableIssueCreation?: boolean; canEditProperties: (projectId: string | undefined) => boolean; - verticalPosition: any; + groupByVisibilityToggle: boolean; } export const KanbanGroup = (props: IKanbanGroup) => { @@ -46,7 +46,6 @@ export const KanbanGroup = (props: IKanbanGroup) => { sub_group_by, issuesMap, displayProperties, - verticalPosition, issueIds, isDragDisabled, handleIssues, @@ -56,80 +55,97 @@ export const KanbanGroup = (props: IKanbanGroup) => { disableIssueCreation, quickAddCallback, viewId, + groupByVisibilityToggle, } = props; - + // hooks const projectState = useProjectState(); - const prePopulateQuickAddData = (groupByKey: string | null, value: string) => { + const prePopulateQuickAddData = ( + groupByKey: string | null, + subGroupByKey: string | null, + groupValue: string, + subGroupValue: string + ) => { const defaultState = projectState.projectStates?.find((state) => state.default); let preloadedData: object = { state_id: defaultState?.id }; if (groupByKey) { if (groupByKey === "state") { - preloadedData = { ...preloadedData, state_id: value }; + preloadedData = { ...preloadedData, state_id: groupValue }; } else if (groupByKey === "priority") { - preloadedData = { ...preloadedData, priority: value }; - } else if (groupByKey === "labels" && value != "None") { - preloadedData = { ...preloadedData, label_ids: [value] }; - } else if (groupByKey === "assignees" && value != "None") { - preloadedData = { ...preloadedData, assignee_ids: [value] }; + preloadedData = { ...preloadedData, priority: groupValue }; + } else if (groupByKey === "labels" && groupValue != "None") { + preloadedData = { ...preloadedData, label_ids: [groupValue] }; + } else if (groupByKey === "assignees" && groupValue != "None") { + preloadedData = { ...preloadedData, assignee_ids: [groupValue] }; } else if (groupByKey === "created_by") { preloadedData = { ...preloadedData }; } else { - preloadedData = { ...preloadedData, [groupByKey]: value }; + preloadedData = { ...preloadedData, [groupByKey]: groupValue }; + } + } + + if (subGroupByKey) { + if (subGroupByKey === "state") { + preloadedData = { ...preloadedData, state_id: subGroupValue }; + } else if (subGroupByKey === "priority") { + preloadedData = { ...preloadedData, priority: subGroupValue }; + } else if (subGroupByKey === "labels" && subGroupValue != "None") { + preloadedData = { ...preloadedData, label_ids: [subGroupValue] }; + } else if (subGroupByKey === "assignees" && subGroupValue != "None") { + preloadedData = { ...preloadedData, assignee_ids: [subGroupValue] }; + } else if (subGroupByKey === "created_by") { + preloadedData = { ...preloadedData }; + } else { + preloadedData = { ...preloadedData, [subGroupByKey]: subGroupValue }; } } return preloadedData; }; - const isGroupByCreatedBy = group_by === "created_by"; - return ( -
    +
    {(provided: any, snapshot: any) => (
    - {!verticalPosition ? ( - - ) : null} + {provided.placeholder} + + {enableQuickIssueCreate && !disableIssueCreation && ( +
    + +
    + )}
    )}
    - -
    - {enableQuickIssueCreate && !disableIssueCreation && !isGroupByCreatedBy && ( - - )} -
    ); }; diff --git a/web/components/issues/issue-layouts/kanban/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/kanban/quick-add-issue-form.tsx index e98030018..21aeb3d9d 100644 --- a/web/components/issues/issue-layouts/kanban/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/kanban/quick-add-issue-form.tsx @@ -120,13 +120,13 @@ export const KanBanQuickAddIssueForm: React.FC = obser }; return ( -
    + <> {isOpen ? ( -
    +
    @@ -141,6 +141,6 @@ export const KanBanQuickAddIssueForm: React.FC = obser New Issue
    )} -
    + ); }); diff --git a/web/components/issues/issue-layouts/kanban/swimlanes.tsx b/web/components/issues/issue-layouts/kanban/swimlanes.tsx index d65bbee57..7beba8a92 100644 --- a/web/components/issues/issue-layouts/kanban/swimlanes.tsx +++ b/web/components/issues/issue-layouts/kanban/swimlanes.tsx @@ -13,6 +13,7 @@ import { IIssueMap, TSubGroupedIssues, TUnGroupedIssues, + TIssueKanbanFilters, } from "@plane/types"; // constants import { EIssueActions } from "../types"; @@ -25,16 +26,16 @@ interface ISubGroupSwimlaneHeader { sub_group_by: string | null; group_by: string | null; list: IGroupByColumn[]; - kanBanToggle: any; - handleKanBanToggle: any; + kanbanFilters: TIssueKanbanFilters; + handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void; } const SubGroupSwimlaneHeader: React.FC = ({ issueIds, sub_group_by, group_by, list, - kanBanToggle, - handleKanBanToggle, + kanbanFilters, + handleKanbanFilters, }) => (
    {list && @@ -45,11 +46,11 @@ const SubGroupSwimlaneHeader: React.FC = ({ sub_group_by={sub_group_by} group_by={group_by} column_id={_list.id} - icon={_list.Icon} + icon={_list.icon} title={_list.name} count={(issueIds as TGroupedIssues)?.[_list.id]?.length || 0} - kanBanToggle={kanBanToggle} - handleKanBanToggle={handleKanBanToggle} + kanbanFilters={kanbanFilters} + handleKanbanFilters={handleKanbanFilters} issuePayload={_list.payload} />
    @@ -64,8 +65,8 @@ interface ISubGroupSwimlane extends ISubGroupSwimlaneHeader { displayProperties: IIssueDisplayProperties | undefined; handleIssues: (issue: TIssue, action: EIssueActions) => void; quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; - kanBanToggle: any; - handleKanBanToggle: any; + kanbanFilters: TIssueKanbanFilters; + handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void; isDragStarted?: boolean; disableIssueCreation?: boolean; currentStore?: TCreateModalStoreTypes; @@ -90,8 +91,8 @@ const SubGroupSwimlane: React.FC = observer((props) => { handleIssues, quickActions, displayProperties, - kanBanToggle, - handleKanBanToggle, + kanbanFilters, + handleKanbanFilters, showEmptyGroup, enableQuickIssueCreate, canEditProperties, @@ -123,13 +124,14 @@ const SubGroupSwimlane: React.FC = observer((props) => { icon={_list.Icon} title={_list.name || ""} count={calculateIssueCount(_list.id)} - kanBanToggle={kanBanToggle} - handleKanBanToggle={handleKanBanToggle} + kanbanFilters={kanbanFilters} + handleKanbanFilters={handleKanbanFilters} />
    - {!kanBanToggle?.subgroupByIssuesVisibility.includes(_list.id) && ( + + {!kanbanFilters?.sub_group_by.includes(_list.id) && (
    = observer((props) => { sub_group_id={_list.id} handleIssues={handleIssues} quickActions={quickActions} - kanBanToggle={kanBanToggle} - handleKanBanToggle={handleKanBanToggle} + kanbanFilters={kanbanFilters} + handleKanbanFilters={handleKanbanFilters} showEmptyGroup={showEmptyGroup} enableQuickIssueCreate={enableQuickIssueCreate} canEditProperties={canEditProperties} @@ -165,8 +167,8 @@ export interface IKanBanSwimLanes { group_by: string | null; handleIssues: (issue: TIssue, action: EIssueActions) => void; quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; - kanBanToggle: any; - handleKanBanToggle: any; + kanbanFilters: TIssueKanbanFilters; + handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void; showEmptyGroup: boolean; isDragStarted?: boolean; disableIssueCreation?: boolean; @@ -192,8 +194,8 @@ export const KanBanSwimLanes: React.FC = observer((props) => { group_by, handleIssues, quickActions, - kanBanToggle, - handleKanBanToggle, + kanbanFilters, + handleKanbanFilters, showEmptyGroup, isDragStarted, disableIssueCreation, @@ -227,8 +229,8 @@ export const KanBanSwimLanes: React.FC = observer((props) => { issueIds={issueIds} group_by={group_by} sub_group_by={sub_group_by} - kanBanToggle={kanBanToggle} - handleKanBanToggle={handleKanBanToggle} + kanbanFilters={kanbanFilters} + handleKanbanFilters={handleKanbanFilters} list={groupByList} />
    @@ -243,8 +245,8 @@ export const KanBanSwimLanes: React.FC = observer((props) => { sub_group_by={sub_group_by} handleIssues={handleIssues} quickActions={quickActions} - kanBanToggle={kanBanToggle} - handleKanBanToggle={handleKanBanToggle} + kanbanFilters={kanbanFilters} + handleKanbanFilters={handleKanbanFilters} showEmptyGroup={showEmptyGroup} isDragStarted={isDragStarted} disableIssueCreation={disableIssueCreation} diff --git a/web/components/issues/issue-layouts/kanban/utils.ts b/web/components/issues/issue-layouts/kanban/utils.ts index e12fc2477..5c5de8c45 100644 --- a/web/components/issues/issue-layouts/kanban/utils.ts +++ b/web/components/issues/issue-layouts/kanban/utils.ts @@ -8,6 +8,41 @@ import { IProjectViewIssues } from "store/issue/project-views"; import { IWorkspaceIssues } from "store/issue/workspace"; import { TGroupedIssues, IIssueMap, TSubGroupedIssues, TUnGroupedIssues } from "@plane/types"; +const handleSortOrder = (destinationIssues: string[], destinationIndex: number, issueMap: IIssueMap) => { + const sortOrderDefaultValue = 65535; + let currentIssueState = {}; + + if (destinationIssues && destinationIssues.length > 0) { + if (destinationIndex === 0) { + const destinationIssueId = destinationIssues[destinationIndex]; + currentIssueState = { + ...currentIssueState, + sort_order: issueMap[destinationIssueId].sort_order - sortOrderDefaultValue, + }; + } else if (destinationIndex === destinationIssues.length) { + const destinationIssueId = destinationIssues[destinationIndex - 1]; + currentIssueState = { + ...currentIssueState, + sort_order: issueMap[destinationIssueId].sort_order + sortOrderDefaultValue, + }; + } else { + const destinationTopIssueId = destinationIssues[destinationIndex - 1]; + const destinationBottomIssueId = destinationIssues[destinationIndex]; + currentIssueState = { + ...currentIssueState, + sort_order: (issueMap[destinationTopIssueId].sort_order + issueMap[destinationBottomIssueId].sort_order) / 2, + }; + } + } else { + currentIssueState = { + ...currentIssueState, + sort_order: sortOrderDefaultValue, + }; + } + + return currentIssueState; +}; + export const handleDragDrop = async ( source: DraggableLocation | null | undefined, destination: DraggableLocation | null | undefined, @@ -50,7 +85,7 @@ export const handleDragDrop = async ( !sourceGroupByColumnId || !destinationGroupByColumnId || !sourceSubGroupByColumnId || - !sourceGroupByColumnId + !destinationSubGroupByColumnId ) return; @@ -76,92 +111,49 @@ export const handleDragDrop = async ( const [removed] = sourceIssues.splice(source.index, 1); const removedIssueDetail = issueMap[removed]; + updateIssue = { + id: removedIssueDetail?.id, + project_id: removedIssueDetail?.project_id, + }; + + // for both horizontal and vertical dnd + updateIssue = { + ...updateIssue, + ...handleSortOrder(destinationIssues, destination.index, issueMap), + }; + if (subGroupBy && sourceSubGroupByColumnId && destinationSubGroupByColumnId) { - updateIssue = { - id: removedIssueDetail?.id, - }; - - // for both horizontal and vertical dnd - updateIssue = { - ...updateIssue, - ...handleSortOrder(destinationIssues, destination.index, issueMap), - }; - if (sourceSubGroupByColumnId === destinationSubGroupByColumnId) { if (sourceGroupByColumnId != destinationGroupByColumnId) { - if (groupBy === "state") updateIssue = { ...updateIssue, state: destinationGroupByColumnId }; + if (groupBy === "state") updateIssue = { ...updateIssue, state_id: destinationGroupByColumnId }; if (groupBy === "priority") updateIssue = { ...updateIssue, priority: destinationGroupByColumnId }; } } else { if (subGroupBy === "state") updateIssue = { ...updateIssue, - state: destinationSubGroupByColumnId, + state_id: destinationSubGroupByColumnId, priority: destinationGroupByColumnId, }; if (subGroupBy === "priority") updateIssue = { ...updateIssue, - state: destinationGroupByColumnId, + state_id: destinationGroupByColumnId, priority: destinationSubGroupByColumnId, }; } } else { - updateIssue = { - id: removedIssueDetail?.id, - }; - - // for both horizontal and vertical dnd - updateIssue = { - ...updateIssue, - ...handleSortOrder(destinationIssues, destination.index, issueMap), - }; - // for horizontal dnd if (sourceColumnId != destinationColumnId) { - if (groupBy === "state") updateIssue = { ...updateIssue, state: destinationGroupByColumnId }; + if (groupBy === "state") updateIssue = { ...updateIssue, state_id: destinationGroupByColumnId }; if (groupBy === "priority") updateIssue = { ...updateIssue, priority: destinationGroupByColumnId }; } } if (updateIssue && updateIssue?.id) { - if (viewId) return await store?.updateIssue(workspaceSlug, projectId, updateIssue.id, updateIssue); //, viewId); - else return await store?.updateIssue(workspaceSlug, projectId, updateIssue.id, updateIssue); + if (viewId) + return await store?.updateIssue(workspaceSlug, updateIssue.project_id, updateIssue.id, updateIssue, viewId); + else return await store?.updateIssue(workspaceSlug, updateIssue.project_id, updateIssue.id, updateIssue); } } }; - -const handleSortOrder = (destinationIssues: string[], destinationIndex: number, issueMap: IIssueMap) => { - const sortOrderDefaultValue = 65535; - let currentIssueState = {}; - - if (destinationIssues && destinationIssues.length > 0) { - if (destinationIndex === 0) { - const destinationIssueId = destinationIssues[destinationIndex]; - currentIssueState = { - ...currentIssueState, - sort_order: issueMap[destinationIssueId].sort_order - sortOrderDefaultValue, - }; - } else if (destinationIndex === destinationIssues.length) { - const destinationIssueId = destinationIssues[destinationIndex - 1]; - currentIssueState = { - ...currentIssueState, - sort_order: issueMap[destinationIssueId].sort_order + sortOrderDefaultValue, - }; - } else { - const destinationTopIssueId = destinationIssues[destinationIndex - 1]; - const destinationBottomIssueId = destinationIssues[destinationIndex]; - currentIssueState = { - ...currentIssueState, - sort_order: (issueMap[destinationTopIssueId].sort_order + issueMap[destinationBottomIssueId].sort_order) / 2, - }; - } - } else { - currentIssueState = { - ...currentIssueState, - sort_order: sortOrderDefaultValue, - }; - } - - return currentIssueState; -}; diff --git a/web/components/issues/issue-layouts/list/base-list-root.tsx b/web/components/issues/issue-layouts/list/base-list-root.tsx index dc92aa7d2..87cafa3ad 100644 --- a/web/components/issues/issue-layouts/list/base-list-root.tsx +++ b/web/components/issues/issue-layouts/list/base-list-root.tsx @@ -138,15 +138,6 @@ export const BaseListRoot = observer((props: IBaseListRoot) => { addIssuesToView={addIssuesToView} />
    - - {/* {workspaceSlug && peekIssueId && peekProjectId && ( - await handleIssues(issueToUpdate as TIssue, EIssueActions.UPDATE)} - /> - )} */} ); }); diff --git a/web/components/issues/issue-layouts/list/block.tsx b/web/components/issues/issue-layouts/list/block.tsx index 99138d8f9..820f98fdd 100644 --- a/web/components/issues/issue-layouts/list/block.tsx +++ b/web/components/issues/issue-layouts/list/block.tsx @@ -62,7 +62,7 @@ export const IssueBlock: React.FC = observer((props: IssueBlock href={`/${workspaceSlug}/projects/${projectId}/issues/${issueId}`} target="_blank" onClick={() => handleIssuePeekOverview(issue)} - className="w-full line-clamp-1 cursor-pointer text-sm font-medium text-custom-text-100" + className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" > {issue.name} diff --git a/web/components/issues/issue-layouts/properties/all-properties.tsx b/web/components/issues/issue-layouts/properties/all-properties.tsx index 0df4b415e..e258c96fc 100644 --- a/web/components/issues/issue-layouts/properties/all-properties.tsx +++ b/web/components/issues/issue-layouts/properties/all-properties.tsx @@ -1,7 +1,7 @@ import { observer } from "mobx-react-lite"; import { CalendarCheck2, CalendarClock, Layers, Link, Paperclip } from "lucide-react"; // hooks -import { useLabel } from "hooks/store"; +import { useEstimate, useLabel } from "hooks/store"; // components import { IssuePropertyLabels } from "../properties/labels"; import { Tooltip } from "@plane/ui"; @@ -29,6 +29,7 @@ export interface IIssueProperties { export const IssueProperties: React.FC = observer((props) => { const { issue, handleIssues, displayProperties, isReadOnly, className } = props; const { labelMap } = useLabel(); + const { areEstimatesEnabledForCurrentProject } = useEstimate(); const handleState = (stateId: string) => { handleIssues({ ...issue, state_id: stateId }); @@ -92,7 +93,6 @@ export const IssueProperties: React.FC = observer((props) => { {/* label */} - = observer((props) => {
    } @@ -148,17 +149,19 @@ export const IssueProperties: React.FC = observer((props) => { {/* estimates */} - -
    - -
    -
    + {areEstimatesEnabledForCurrentProject && ( + +
    + +
    +
    + )} {/* extra render properties */} {/* sub-issues */} diff --git a/web/components/issues/issue-layouts/roots/archived-issue-layout-root.tsx b/web/components/issues/issue-layouts/roots/archived-issue-layout-root.tsx index 3b23351d9..bcd30555e 100644 --- a/web/components/issues/issue-layouts/roots/archived-issue-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/archived-issue-layout-root.tsx @@ -5,31 +5,53 @@ import useSWR from "swr"; // mobx store import { useIssues } from "hooks/store"; // components -import { ArchivedIssueListLayout, ArchivedIssueAppliedFiltersRoot } from "components/issues"; +import { ArchivedIssueListLayout, ArchivedIssueAppliedFiltersRoot, ProjectEmptyState } from "components/issues"; import { EIssuesStoreType } from "constants/issue"; +// ui +import { Spinner } from "@plane/ui"; export const ArchivedIssueLayoutRoot: React.FC = observer(() => { + // router const router = useRouter(); - const { workspaceSlug, projectId } = router.query as { workspaceSlug: string; projectId: string }; + const { workspaceSlug, projectId } = router.query; + // hooks + const { issues, issuesFilter } = useIssues(EIssuesStoreType.ARCHIVED); - const { - issues: { groupedIssueIds, fetchIssues }, - issuesFilter: { fetchFilters }, - } = useIssues(EIssuesStoreType.ARCHIVED); - - useSWR(workspaceSlug && projectId ? `ARCHIVED_FILTERS_AND_ISSUES_${projectId.toString()}` : null, async () => { - if (workspaceSlug && projectId) { - await fetchFilters(workspaceSlug, projectId); - await fetchIssues(workspaceSlug, projectId, groupedIssueIds ? "mutation" : "init-loader"); + useSWR( + workspaceSlug && projectId ? `ARCHIVED_ISSUES_${workspaceSlug.toString()}_${projectId.toString()}` : null, + async () => { + if (workspaceSlug && projectId) { + await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString()); + await issues?.fetchIssues( + workspaceSlug.toString(), + projectId.toString(), + issues?.groupedIssueIds ? "mutation" : "init-loader" + ); + } } - }); + ); + if (!workspaceSlug || !projectId) return <>; return (
    -
    - -
    + + {issues?.loader === "init-loader" ? ( +
    + +
    + ) : ( + <> + {!issues?.groupedIssueIds ? ( + // TODO: Replace this with project view empty state + + ) : ( +
    + +
    + )} + + )}
    ); }); diff --git a/web/components/issues/issue-layouts/roots/cycle-layout-root.tsx b/web/components/issues/issue-layouts/roots/cycle-layout-root.tsx index bc63a8aaf..e5398ef90 100644 --- a/web/components/issues/issue-layouts/roots/cycle-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/cycle-layout-root.tsx @@ -21,36 +21,37 @@ import { Spinner } from "@plane/ui"; import { EIssuesStoreType } from "constants/issue"; export const CycleLayoutRoot: React.FC = observer(() => { + const router = useRouter(); + const { workspaceSlug, projectId, cycleId } = router.query; + // store hooks + const { issues, issuesFilter } = useIssues(EIssuesStoreType.CYCLE); + const { getCycleById } = useCycle(); + // state const [transferIssuesModal, setTransferIssuesModal] = useState(false); - const router = useRouter(); - const { workspaceSlug, projectId, cycleId } = router.query as { - workspaceSlug: string; - projectId: string; - cycleId: string; - }; - // store hooks - const { - issues: { loader, groupedIssueIds, fetchIssues }, - issuesFilter: { issueFilters, fetchFilters }, - } = useIssues(EIssuesStoreType.CYCLE); - const { getCycleById } = useCycle(); - useSWR( - workspaceSlug && projectId && cycleId ? `CYCLE_ISSUES_V3_${workspaceSlug}_${projectId}_${cycleId}` : null, + workspaceSlug && projectId && cycleId + ? `CYCLE_ISSUES_${workspaceSlug.toString()}_${projectId.toString()}_${cycleId.toString()}` + : null, async () => { if (workspaceSlug && projectId && cycleId) { - await fetchFilters(workspaceSlug, projectId, cycleId); - await fetchIssues(workspaceSlug, projectId, groupedIssueIds ? "mutation" : "init-loader", cycleId); + await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString(), cycleId.toString()); + await issues?.fetchIssues( + workspaceSlug.toString(), + projectId.toString(), + issues?.groupedIssueIds ? "mutation" : "init-loader", + cycleId.toString() + ); } } ); - const activeLayout = issueFilters?.displayFilters?.layout; + const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout; - const cycleDetails = cycleId ? getCycleById(cycleId) : undefined; + const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined; const cycleStatus = cycleDetails?.status.toLocaleLowerCase() ?? "draft"; + if (!workspaceSlug || !projectId || !cycleId) return <>; return ( <> setTransferIssuesModal(false)} isOpen={transferIssuesModal} /> @@ -59,14 +60,18 @@ export const CycleLayoutRoot: React.FC = observer(() => { {cycleStatus === "completed" && setTransferIssuesModal(true)} />} - {loader === "init-loader" || !groupedIssueIds ? ( + {issues?.loader === "init-loader" ? (
    ) : ( <> - {Object.keys(groupedIssueIds ?? {}).length == 0 ? ( - + {!issues?.groupedIssueIds ? ( + ) : (
    {activeLayout === "list" ? ( diff --git a/web/components/issues/issue-layouts/roots/draft-issue-layout-root.tsx b/web/components/issues/issue-layouts/roots/draft-issue-layout-root.tsx index 79d4b0ac9..19a4da553 100644 --- a/web/components/issues/issue-layouts/roots/draft-issue-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/draft-issue-layout-root.tsx @@ -2,49 +2,64 @@ import React from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import useSWR from "swr"; -// mobx store +// hooks import { useIssues } from "hooks/store"; +// components import { DraftIssueAppliedFiltersRoot } from "../filters/applied-filters/roots/draft-issue"; import { DraftIssueListLayout } from "../list/roots/draft-issue-root"; +import { ProjectEmptyState } from "../empty-states"; +// ui import { Spinner } from "@plane/ui"; import { DraftKanBanLayout } from "../kanban/roots/draft-issue-root"; +// constants import { EIssuesStoreType } from "constants/issue"; export const DraftIssueLayoutRoot: React.FC = observer(() => { + // router const router = useRouter(); - const { workspaceSlug, projectId } = router.query as { workspaceSlug: string; projectId: string }; + const { workspaceSlug, projectId } = router.query; + // hooks + const { issues, issuesFilter } = useIssues(EIssuesStoreType.DRAFT); - const { - issues: { loader, groupedIssueIds, fetchIssues }, - issuesFilter: { issueFilters, fetchFilters }, - } = useIssues(EIssuesStoreType.DRAFT); - - useSWR(workspaceSlug && projectId ? `DRAFT_FILTERS_AND_ISSUES_${projectId.toString()}` : null, async () => { - if (workspaceSlug && projectId) { - await fetchFilters(workspaceSlug, projectId); - await fetchIssues(workspaceSlug, projectId, groupedIssueIds ? "mutation" : "init-loader"); + useSWR( + workspaceSlug && projectId ? `DRAFT_ISSUES_${workspaceSlug.toString()}_${projectId.toString()}` : null, + async () => { + if (workspaceSlug && projectId) { + await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString()); + await issues?.fetchIssues( + workspaceSlug.toString(), + projectId.toString(), + issues?.groupedIssueIds ? "mutation" : "init-loader" + ); + } } - }); + ); - const activeLayout = issueFilters?.displayFilters?.layout || undefined; + const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout || undefined; + if (!workspaceSlug || !projectId) return <>; return (
    - {loader === "init-loader" ? ( + {issues?.loader === "init-loader" ? (
    ) : ( <> -
    - {activeLayout === "list" ? ( - - ) : activeLayout === "kanban" ? ( - - ) : null} -
    + {!issues?.groupedIssueIds ? ( + // TODO: Replace this with project view empty state + + ) : ( +
    + {activeLayout === "list" ? ( + + ) : activeLayout === "kanban" ? ( + + ) : null} +
    + )} )}
    diff --git a/web/components/issues/issue-layouts/roots/index.ts b/web/components/issues/issue-layouts/roots/index.ts index 72f71aae2..727e3e393 100644 --- a/web/components/issues/issue-layouts/roots/index.ts +++ b/web/components/issues/issue-layouts/roots/index.ts @@ -1,6 +1,7 @@ -export * from "./cycle-layout-root"; -export * from "./all-issue-layout-root"; -export * from "./module-layout-root"; export * from "./project-layout-root"; +export * from "./module-layout-root"; +export * from "./cycle-layout-root"; export * from "./project-view-layout-root"; export * from "./archived-issue-layout-root"; +export * from "./draft-issue-layout-root"; +export * from "./all-issue-layout-root"; diff --git a/web/components/issues/issue-layouts/roots/module-layout-root.tsx b/web/components/issues/issue-layouts/roots/module-layout-root.tsx index b14d3cb2f..4478a0faa 100644 --- a/web/components/issues/issue-layouts/roots/module-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/module-layout-root.tsx @@ -2,7 +2,6 @@ import React from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import useSWR from "swr"; - // mobx store import { useIssues } from "hooks/store"; // components @@ -20,42 +19,48 @@ import { Spinner } from "@plane/ui"; import { EIssuesStoreType } from "constants/issue"; export const ModuleLayoutRoot: React.FC = observer(() => { + // router const router = useRouter(); - const { workspaceSlug, projectId, moduleId } = router.query as { - workspaceSlug: string; - projectId: string; - moduleId: string; - }; - - const { - issues: { loader, groupedIssueIds, fetchIssues }, - issuesFilter: { issueFilters, fetchFilters }, - } = useIssues(EIssuesStoreType.MODULE); + const { workspaceSlug, projectId, moduleId } = router.query; + // hooks + const { issues, issuesFilter } = useIssues(EIssuesStoreType.MODULE); useSWR( - workspaceSlug && projectId && moduleId ? `MODULE_ISSUES_V3_${workspaceSlug}_${projectId}_${moduleId}` : null, + workspaceSlug && projectId && moduleId + ? `MODULE_ISSUES_${workspaceSlug.toString()}_${projectId.toString()}_${moduleId.toString()}` + : null, async () => { if (workspaceSlug && projectId && moduleId) { - await fetchFilters(workspaceSlug, projectId, moduleId); - await fetchIssues(workspaceSlug, projectId, groupedIssueIds ? "mutation" : "init-loader", moduleId); + await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString(), moduleId.toString()); + await issues?.fetchIssues( + workspaceSlug.toString(), + projectId.toString(), + issues?.groupedIssueIds ? "mutation" : "init-loader", + moduleId.toString() + ); } } ); - const activeLayout = issueFilters?.displayFilters?.layout || undefined; + const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout || undefined; + if (!workspaceSlug || !projectId || !moduleId) return <>; return (
    - {loader === "init-loader" || !groupedIssueIds ? ( + {issues?.loader === "init-loader" ? (
    ) : ( <> - {Object.keys(groupedIssueIds ?? {}).length == 0 ? ( - + {!issues?.groupedIssueIds ? ( + ) : (
    {activeLayout === "list" ? ( @@ -71,7 +76,6 @@ export const ModuleLayoutRoot: React.FC = observer(() => { ) : null}
    )} - {/* */} )}
    diff --git a/web/components/issues/issue-layouts/roots/project-layout-root.tsx b/web/components/issues/issue-layouts/roots/project-layout-root.tsx index 453f331cb..da9811c61 100644 --- a/web/components/issues/issue-layouts/roots/project-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/project-layout-root.tsx @@ -1,4 +1,5 @@ import { FC } from "react"; +import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import useSWR from "swr"; // components @@ -15,23 +16,27 @@ import { // ui import { Spinner } from "@plane/ui"; // hooks -import { useApplication, useIssues } from "hooks/store"; +import { useIssues } from "hooks/store"; // constants import { EIssuesStoreType } from "constants/issue"; export const ProjectLayoutRoot: FC = observer(() => { + // router + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; // hooks - const { - router: { workspaceSlug, projectId }, - } = useApplication(); const { issues, issuesFilter } = useIssues(EIssuesStoreType.PROJECT); - useSWR( + const {} = useSWR( workspaceSlug && projectId ? `PROJECT_ISSUES_${workspaceSlug}_${projectId}` : null, async () => { if (workspaceSlug && projectId) { - await issuesFilter?.fetchFilters(workspaceSlug, projectId); - await issues?.fetchIssues(workspaceSlug, projectId, issues?.groupedIssueIds ? "mutation" : "init-loader"); + await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString()); + await issues?.fetchIssues( + workspaceSlug.toString(), + projectId.toString(), + issues?.groupedIssueIds ? "mutation" : "init-loader" + ); } }, { revalidateOnFocus: false, refreshInterval: 600000, revalidateOnMount: true } @@ -39,6 +44,7 @@ export const ProjectLayoutRoot: FC = observer(() => { const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout; + if (!workspaceSlug || !projectId) return <>; return (
    @@ -56,6 +62,13 @@ export const ProjectLayoutRoot: FC = observer(() => { ) : ( <>
    + {/* mutation loader */} + {issues?.loader === "mutation" && ( +
    + +
    + )} + {activeLayout === "list" ? ( ) : activeLayout === "kanban" ? ( diff --git a/web/components/issues/issue-layouts/roots/project-view-layout-root.tsx b/web/components/issues/issue-layouts/roots/project-view-layout-root.tsx index f6b5500a6..b11d18a59 100644 --- a/web/components/issues/issue-layouts/roots/project-view-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/project-view-layout-root.tsx @@ -6,6 +6,7 @@ import useSWR from "swr"; import { useIssues } from "hooks/store"; // components import { + ProjectEmptyState, ProjectViewAppliedFiltersRoot, ProjectViewCalendarLayout, ProjectViewGanttLayout, @@ -17,53 +18,58 @@ import { Spinner } from "@plane/ui"; import { EIssuesStoreType } from "constants/issue"; export const ProjectViewLayoutRoot: React.FC = observer(() => { + // router const router = useRouter(); - const { workspaceSlug, projectId, viewId } = router.query as { - workspaceSlug: string; - projectId: string; - viewId?: string; - }; - - const { - issues: { loader, groupedIssueIds, fetchIssues }, - issuesFilter: { issueFilters, fetchFilters }, - } = useIssues(EIssuesStoreType.PROJECT_VIEW); + const { workspaceSlug, projectId, viewId } = router.query; + // hooks + const { issues, issuesFilter } = useIssues(EIssuesStoreType.PROJECT_VIEW); useSWR( workspaceSlug && projectId && viewId ? `PROJECT_VIEW_ISSUES_${workspaceSlug}_${projectId}` : null, async () => { if (workspaceSlug && projectId && viewId) { - await fetchFilters(workspaceSlug, projectId, viewId); - await fetchIssues(workspaceSlug, projectId, groupedIssueIds ? "mutation" : "init-loader"); + await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString(), viewId.toString()); + await issues?.fetchIssues( + workspaceSlug.toString(), + projectId.toString(), + issues?.groupedIssueIds ? "mutation" : "init-loader", + viewId.toString() + ); } } ); - const activeLayout = issueFilters?.displayFilters?.layout; + const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout; + if (!workspaceSlug || !projectId || !viewId) return <>; return (
    - {loader === "init-loader" ? ( + {issues?.loader === "init-loader" ? (
    ) : ( <> -
    - {activeLayout === "list" ? ( - - ) : activeLayout === "kanban" ? ( - - ) : activeLayout === "calendar" ? ( - - ) : activeLayout === "gantt_chart" ? ( - - ) : activeLayout === "spreadsheet" ? ( - - ) : null} -
    + {!issues?.groupedIssueIds ? ( + // TODO: Replace this with project view empty state + + ) : ( +
    + {activeLayout === "list" ? ( + + ) : activeLayout === "kanban" ? ( + + ) : activeLayout === "calendar" ? ( + + ) : activeLayout === "gantt_chart" ? ( + + ) : activeLayout === "spreadsheet" ? ( + + ) : null} +
    + )} )}
    diff --git a/web/components/issues/issue-layouts/utils.tsx b/web/components/issues/issue-layouts/utils.tsx index f3f4a483e..0a55db3fd 100644 --- a/web/components/issues/issue-layouts/utils.tsx +++ b/web/components/issues/issue-layouts/utils.tsx @@ -31,7 +31,7 @@ export const getGroupByColumns = ( case "created_by": return getCreatedByColumns(member) as any; default: - if (includeNone) return [{ id: `null`, name: `All Issues`, payload: {}, Icon: undefined }]; + if (includeNone) return [{ id: `null`, name: `All Issues`, payload: {}, icon: undefined }]; } }; @@ -48,7 +48,7 @@ const getProjectColumns = (project: IProjectStore): IGroupByColumn[] | undefined return { id: project.id, name: project.name, - Icon:
    {renderEmoji(project.emoji || "")}
    , + icon:
    {renderEmoji(project.emoji || "")}
    , payload: { project_id: project.id }, }; }) as any; @@ -61,7 +61,7 @@ const getStateColumns = (projectState: IStateStore): IGroupByColumn[] | undefine return projectStates.map((state) => ({ id: state.id, name: state.name, - Icon: ( + icon: (
    @@ -76,7 +76,7 @@ const getStateGroupColumns = () => { return stateGroups.map((stateGroup) => ({ id: stateGroup.key, name: stateGroup.title, - Icon: ( + icon: (
    @@ -91,7 +91,7 @@ const getPriorityColumns = () => { return priorities.map((priority) => ({ id: priority.key, name: priority.title, - Icon: , + icon: , payload: { priority: priority.key }, })); }; @@ -108,7 +108,7 @@ const getLabelsColumns = (projectLabel: ILabelRootStore) => { return labels.map((label) => ({ id: label.id, name: label.name, - Icon: ( + icon: (
    ), payload: label?.id === "None" ? {} : { label_ids: [label.id] }, @@ -128,12 +128,12 @@ const getAssigneeColumns = (member: IMemberRootStore) => { return { id: memberId, name: member?.display_name || "", - Icon: , + icon: , payload: { assignee_ids: [memberId] }, }; }); - assigneeColumns.push({ id: "None", name: "None", Icon: , payload: {} }); + assigneeColumns.push({ id: "None", name: "None", icon: , payload: {} }); return assigneeColumns; }; @@ -151,7 +151,7 @@ const getCreatedByColumns = (member: IMemberRootStore) => { return { id: memberId, name: member?.display_name || "", - Icon: , + icon: , payload: {}, }; }); diff --git a/web/components/issues/issue-links/index.ts b/web/components/issues/issue-links/index.ts deleted file mode 100644 index 1efe34c51..000000000 --- a/web/components/issues/issue-links/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./root"; diff --git a/web/components/issues/issue-reaction.tsx b/web/components/issues/issue-reaction.tsx deleted file mode 100644 index 37d0599e4..000000000 --- a/web/components/issues/issue-reaction.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { observer } from "mobx-react-lite"; -// hooks -import { useUser } from "hooks/store"; -import useIssueReaction from "hooks/use-issue-reaction"; -// components -import { ReactionSelector } from "components/core"; -// string helpers -import { renderEmoji } from "helpers/emoji.helper"; - -// types -type Props = { - workspaceSlug: string; - projectId: string; - issueId: string; -}; - -export const IssueReaction: React.FC = observer((props) => { - const { workspaceSlug, projectId, issueId } = props; - - const { currentUser } = useUser(); - - const { reactions, groupedReactions, handleReactionCreate, handleReactionDelete } = useIssueReaction( - workspaceSlug, - projectId, - issueId - ); - - const handleReactionClick = (reaction: string) => { - if (!workspaceSlug || !projectId || !issueId) return; - - const isSelected = reactions?.some((r) => r.actor === currentUser?.id && r.reaction === reaction); - - if (isSelected) { - handleReactionDelete(reaction); - } else { - handleReactionCreate(reaction); - } - }; - - return ( -
    - reaction.actor === currentUser?.id).map((r) => r.reaction) || []} - onSelect={handleReactionClick} - /> - - {Object.keys(groupedReactions || {}).map( - (reaction) => - groupedReactions?.[reaction]?.length && - groupedReactions[reaction].length > 0 && ( - - ) - )} -
    - ); -}); diff --git a/web/components/issues/main-content.tsx b/web/components/issues/main-content.tsx deleted file mode 100644 index 2863cb3b9..000000000 --- a/web/components/issues/main-content.tsx +++ /dev/null @@ -1,271 +0,0 @@ -import Link from "next/link"; -import { useRouter } from "next/router"; -import { observer } from "mobx-react-lite"; -import useSWR, { mutate } from "swr"; -import { MinusCircle } from "lucide-react"; -// hooks -import { useApplication, useIssues, useProject, useProjectState, useUser, useWorkspace } from "hooks/store"; -import useToast from "hooks/use-toast"; -// services -import { IssueService, IssueCommentService } from "services/issue"; -// components -import { - IssueAttachmentRoot, - AddComment, - IssueActivitySection, - IssueDescriptionForm, - IssueReaction, - IssueUpdateStatus, -} from "components/issues"; -import { useState } from "react"; -import { SubIssuesRoot } from "./sub-issues"; -// ui -import { CustomMenu, LayersIcon, StateGroupIcon } from "@plane/ui"; -// types -import { TIssue, IIssueActivity } from "@plane/types"; -// fetch-keys -import { PROJECT_ISSUES_ACTIVITY, SUB_ISSUES } from "constants/fetch-keys"; -// constants -import { EUserProjectRoles } from "constants/project"; - -type Props = { - issueDetails: TIssue; - submitChanges: (formData: Partial) => Promise; - uneditable?: boolean; -}; - -// services -const issueService = new IssueService(); -const issueCommentService = new IssueCommentService(); - -export const IssueMainContent: React.FC = observer((props) => { - const { issueDetails, submitChanges, uneditable = false } = props; - // states - const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved"); - // router - const router = useRouter(); - const { workspaceSlug, projectId, issueId } = router.query; - // toast alert - const { setToastAlert } = useToast(); - const { - eventTracker: { postHogEventTracker }, - } = useApplication(); - const { - currentUser, - membership: { currentProjectRole }, - } = useUser(); - const { currentWorkspace } = useWorkspace(); - const { getProjectById } = useProject(); - const { projectStates, getProjectStates } = useProjectState(); - const { issueMap } = useIssues(); - - const projectDetails = projectId ? getProjectById(projectId.toString()) : null; - const currentIssueState = projectStates?.find((s) => s.id === issueDetails.state_id); - - const { data: siblingIssues } = useSWR( - workspaceSlug && projectId && issueDetails?.parent_id ? SUB_ISSUES(issueDetails.parent_id) : null, - workspaceSlug && projectId && issueDetails?.parent_id - ? () => issueService.subIssues(workspaceSlug.toString(), projectId.toString(), issueDetails.parent_id ?? "") - : null - ); - const siblingIssuesList = siblingIssues?.sub_issues.filter((i) => i.id !== issueDetails.id); - - const { data: issueActivity, mutate: mutateIssueActivity } = useSWR( - workspaceSlug && projectId && issueId ? PROJECT_ISSUES_ACTIVITY(issueId.toString()) : null, - workspaceSlug && projectId && issueId - ? () => issueService.getIssueActivities(workspaceSlug.toString(), projectId.toString(), issueId.toString()) - : null - ); - - const handleCommentUpdate = async (commentId: string, data: Partial) => { - if (!workspaceSlug || !projectId || !issueId) return; - - await issueCommentService - .patchIssueComment(workspaceSlug as string, projectId as string, issueId as string, commentId, data) - .then((res) => { - mutateIssueActivity(); - postHogEventTracker( - "COMMENT_UPDATED", - { - ...res, - state: "SUCCESS", - }, - { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - } - ); - }); - }; - - const handleCommentDelete = async (commentId: string) => { - if (!workspaceSlug || !projectId || !issueId || !currentUser) return; - - mutateIssueActivity((prevData: any) => prevData?.filter((p: any) => p.id !== commentId), false); - - await issueCommentService - .deleteIssueComment(workspaceSlug as string, projectId as string, issueId as string, commentId) - .then(() => { - mutateIssueActivity(); - postHogEventTracker( - "COMMENT_DELETED", - { - state: "SUCCESS", - }, - { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - } - ); - }); - }; - - const handleAddComment = async (formData: IIssueActivity) => { - if (!workspaceSlug || !issueDetails || !currentUser) return; - - await issueCommentService - .createIssueComment(workspaceSlug.toString(), issueDetails.project_id, issueDetails.id, formData) - .then((res) => { - mutate(PROJECT_ISSUES_ACTIVITY(issueDetails.id)); - postHogEventTracker( - "COMMENT_ADDED", - { - ...res, - state: "SUCCESS", - }, - { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - } - ); - }) - .catch(() => - setToastAlert({ - type: "error", - title: "Error!", - message: "Comment could not be posted. Please try again.", - }) - ); - }; - - const isAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; - - const parentDetail = issueMap?.[issueDetails.parent_id || ""] || undefined; - - return ( - <> -
    - {issueDetails?.parent_id && parentDetail ? ( -
    - -
    -
    - state?.id === parentDetail?.state_id - )?.color, - }} - /> - - {getProjectById(parentDetail?.project_id)?.identifier}-{parentDetail?.sequence_id} - -
    - {(parentDetail?.name ?? "").substring(0, 50)} -
    - - - - {siblingIssuesList ? ( - siblingIssuesList.length > 0 ? ( - <> -

    - Sibling issues -

    - {siblingIssuesList.map((issue) => ( - - router.push(`/${workspaceSlug}/projects/${projectId as string}/issues/${issue.id}`) - } - className="flex items-center gap-2 py-2" - > - - {getProjectById(issueDetails?.project_id)?.identifier}-{issue.sequence_id} - - ))} - - ) : ( -

    - No sibling issues -

    - ) - ) : null} - submitChanges({ parent_id: null })} - className="flex items-center gap-2 py-2 text-red-500" - > - - Remove Parent Issue - -
    -
    - ) : null} - -
    - {currentIssueState && ( - - )} - -
    - - setIsSubmitting(value)} - isSubmitting={isSubmitting} - workspaceSlug={workspaceSlug as string} - issue={issueDetails} - handleFormSubmit={submitChanges} - isAllowed={isAllowed || !uneditable} - /> - - {workspaceSlug && projectId && ( - - )} - -
    - -
    -
    - - {/* issue attachments */} - - -
    -

    Comments/Activity

    - - -
    - - ); -}); diff --git a/web/components/issues/peek-overview/properties.tsx b/web/components/issues/peek-overview/properties.tsx index 48afc4cd4..7f21f01b7 100644 --- a/web/components/issues/peek-overview/properties.tsx +++ b/web/components/issues/peek-overview/properties.tsx @@ -1,45 +1,35 @@ -import { FC, useState } from "react"; +import { FC, useMemo } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; -import { CalendarDays, Link2, Plus, Signal, Tag, Triangle, LayoutPanelTop } from "lucide-react"; +import { CalendarDays, Signal, Tag, Triangle, LayoutPanelTop } from "lucide-react"; // hooks import { useIssueDetail, useProject, useUser } from "hooks/store"; // ui icons import { DiceIcon, DoubleCircleIcon, UserGroupIcon, ContrastIcon } from "@plane/ui"; -import { - IssueLinkRoot, - SidebarCycleSelect, - SidebarLabelSelect, - SidebarModuleSelect, - SidebarParentSelect, -} from "components/issues"; +import { IssueLinkRoot, IssueCycleSelect, IssueModuleSelect, IssueParentSelect, IssueLabel } from "components/issues"; import { EstimateDropdown, PriorityDropdown, ProjectMemberDropdown, StateDropdown } from "components/dropdowns"; // components import { CustomDatePicker } from "components/ui"; -import { LinkModal } from "components/core"; // types -import { TIssue, TIssuePriorities, ILinkDetails, IIssueLink } from "@plane/types"; +import { TIssue, TIssuePriorities } from "@plane/types"; // constants import { EUserProjectRoles } from "constants/project"; interface IPeekOverviewProperties { issue: TIssue; issueUpdate: (issue: Partial) => void; - issueLinkCreate: (data: IIssueLink) => Promise; - issueLinkUpdate: (data: IIssueLink, linkId: string) => Promise; - issueLinkDelete: (linkId: string) => Promise; disableUserActions: boolean; + issueOperations: any; } export const PeekOverviewProperties: FC = observer((props) => { - const { issue, issueUpdate, issueLinkCreate, issueLinkUpdate, issueLinkDelete, disableUserActions } = props; - // states - const [selectedLinkToUpdate, setSelectedLinkToUpdate] = useState(null); + const { issue, issueUpdate, disableUserActions, issueOperations } = props; + // store hooks const { membership: { currentProjectRole }, } = useUser(); - const { fetchIssue, isIssueLinkModalOpen, toggleIssueLinkModal } = useIssueDetail(); + const { currentUser } = useUser(); const { getProjectById } = useProject(); // router const router = useRouter(); @@ -66,23 +56,6 @@ export const PeekOverviewProperties: FC = observer((pro const handleTargetDate = (_targetDate: string | null) => { issueUpdate({ ...issue, target_date: _targetDate || undefined }); }; - const handleParent = (_parent: string) => { - issueUpdate({ ...issue, parent_id: _parent }); - }; - const handleLabels = (formData: Partial) => { - issueUpdate({ ...issue, ...formData }); - }; - - const handleCycleOrModuleChange = async () => { - if (!workspaceSlug || !projectId) return; - - await fetchIssue(workspaceSlug.toString(), projectId.toString(), issue.id); - }; - - const handleEditLink = (link: ILinkDetails) => { - setSelectedLinkToUpdate(link); - toggleIssueLinkModal(true); - }; const projectDetails = getProjectById(issue.project_id); const isEstimateEnabled = projectDetails?.estimate; @@ -95,17 +68,6 @@ export const PeekOverviewProperties: FC = observer((pro return ( <> - { - toggleIssueLinkModal(false); - setSelectedLinkToUpdate(null); - }} - data={selectedLinkToUpdate} - status={selectedLinkToUpdate ? true : false} - createIssueLink={issueLinkCreate} - updateIssueLink={issueLinkUpdate} - />
    {/* state */} @@ -223,7 +185,13 @@ export const PeekOverviewProperties: FC = observer((pro

    Parent

    - +
    @@ -238,10 +206,12 @@ export const PeekOverviewProperties: FC = observer((pro

    Cycle

    -
    @@ -254,10 +224,12 @@ export const PeekOverviewProperties: FC = observer((pro

    Module

    -
    @@ -269,12 +241,11 @@ export const PeekOverviewProperties: FC = observer((pro

    Label

    -
    @@ -282,10 +253,14 @@ export const PeekOverviewProperties: FC = observer((pro -
    -
    - -
    +
    +
    diff --git a/web/components/issues/peek-overview/root.tsx b/web/components/issues/peek-overview/root.tsx index 2b06bd0da..8253600fd 100644 --- a/web/components/issues/peek-overview/root.tsx +++ b/web/components/issues/peek-overview/root.tsx @@ -1,16 +1,16 @@ -import { FC, Fragment, useEffect, useState } from "react"; +import { FC, Fragment, useEffect, useState, useMemo } from "react"; // router import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; // hooks import useToast from "hooks/use-toast"; -import { useIssueDetail, useIssues, useProject, useUser } from "hooks/store"; +import { useIssueDetail, useIssues, useMember, useProject, useUser } from "hooks/store"; // components import { IssueView } from "components/issues"; // helpers import { copyUrlToClipboard } from "helpers/string.helper"; // types -import { TIssue, IIssueLink } from "@plane/types"; +import { TIssue } from "@plane/types"; // constants import { EUserProjectRoles } from "constants/project"; import { EIssuesStoreType } from "constants/issue"; @@ -19,14 +19,25 @@ interface IIssuePeekOverview { isArchived?: boolean; } +export type TIssuePeekOperations = { + addIssueToCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise; + removeIssueFromCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise; + addIssueToModule: (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => Promise; + removeIssueFromModule: (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) => Promise; +}; + export const IssuePeekOverview: FC = observer((props) => { const { isArchived = false } = props; // router const router = useRouter(); // hooks + const { + project: {}, + } = useMember(); const { currentProjectDetails } = useProject(); const { setToastAlert } = useToast(); const { + currentUser, membership: { currentProjectRole }, } = useUser(); const { @@ -45,12 +56,10 @@ export const IssuePeekOverview: FC = observer((props) => { removeReaction, createSubscription, removeSubscription, - createLink, - updateLink, - removeLink, issue: { getIssueById, fetchIssue }, fetchActivities, } = useIssueDetail(); + const { addIssueToCycle, removeIssueFromCycle, addIssueToModule, removeIssueFromModule } = useIssueDetail(); // state const [loader, setLoader] = useState(false); @@ -62,8 +71,7 @@ export const IssuePeekOverview: FC = observer((props) => { }); } }, [peekIssue, fetchIssue]); - - if (!peekIssue) return <>; + if (!peekIssue?.workspaceSlug || !peekIssue?.projectId || !peekIssue?.issueId) return <>; const issue = getIssueById(peekIssue.issueId) || undefined; @@ -90,6 +98,76 @@ export const IssuePeekOverview: FC = observer((props) => { }); }; + const issueOperations: TIssuePeekOperations = useMemo( + () => ({ + addIssueToCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => { + try { + await addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds); + setToastAlert({ + title: "Cycle added to issue successfully", + type: "success", + message: "Issue added to issue successfully", + }); + } catch (error) { + setToastAlert({ + title: "Cycle add to issue failed", + type: "error", + message: "Cycle add to issue failed", + }); + } + }, + removeIssueFromCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => { + try { + await removeIssueFromCycle(workspaceSlug, projectId, cycleId, issueId); + setToastAlert({ + title: "Cycle removed from issue successfully", + type: "success", + message: "Cycle removed from issue successfully", + }); + } catch (error) { + setToastAlert({ + title: "Cycle remove from issue failed", + type: "error", + message: "Cycle remove from issue failed", + }); + } + }, + addIssueToModule: async (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => { + try { + await addIssueToModule(workspaceSlug, projectId, moduleId, issueIds); + setToastAlert({ + title: "Module added to issue successfully", + type: "success", + message: "Module added to issue successfully", + }); + } catch (error) { + setToastAlert({ + title: "Module add to issue failed", + type: "error", + message: "Module add to issue failed", + }); + } + }, + removeIssueFromModule: async (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) => { + try { + await removeIssueFromModule(workspaceSlug, projectId, moduleId, issueId); + setToastAlert({ + title: "Module removed from issue successfully", + type: "success", + message: "Module removed from issue successfully", + }); + } catch (error) { + setToastAlert({ + title: "Module remove from issue failed", + type: "error", + message: "Module remove from issue failed", + }); + } + }, + }), + [addIssueToCycle, removeIssueFromCycle, addIssueToModule, removeIssueFromModule, setToastAlert] + ); + const issueUpdate = async (_data: Partial) => { if (!issue) return; await updateIssue(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId, _data); @@ -104,7 +182,9 @@ export const IssuePeekOverview: FC = observer((props) => { const issueReactionCreate = (reaction: string) => createReaction(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId, reaction); const issueReactionRemove = (reaction: string) => - removeReaction(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId, reaction); + currentUser && + currentUser.id && + removeReaction(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId, reaction, currentUser.id); const issueCommentCreate = (comment: any) => createComment(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId, comment); @@ -123,48 +203,35 @@ export const IssuePeekOverview: FC = observer((props) => { const issueSubscriptionRemove = () => removeSubscription(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId); - const issueLinkCreate = (formData: IIssueLink) => - createLink(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId, formData); - const issueLinkUpdate = (formData: IIssueLink, linkId: string) => - updateLink(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId, linkId, formData); - const issueLinkDelete = (linkId: string) => - removeLink(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId, linkId); - const userRole = currentProjectRole ?? EUserProjectRoles.GUEST; const isLoading = !issue || loader ? true : false; return ( - {isLoading ? ( - <> // TODO: show the spinner - ) : ( - - )} + ); }); diff --git a/web/components/issues/peek-overview/view.tsx b/web/components/issues/peek-overview/view.tsx index 6a7737f73..f22f2ee2d 100644 --- a/web/components/issues/peek-overview/view.tsx +++ b/web/components/issues/peek-overview/view.tsx @@ -22,12 +22,18 @@ interface IIssueView { workspaceSlug: string; projectId: string; issueId: string; - issue: TIssue | undefined; + isLoading?: boolean; isArchived?: boolean; + + issue: TIssue | undefined; + handleCopyText: (e: React.MouseEvent) => void; redirectToIssueDetail: () => void; + issueUpdate: (issue: Partial) => void; + issueDelete: () => Promise; + issueReactionCreate: (reaction: string) => void; issueReactionRemove: (reaction: string) => void; issueCommentCreate: (comment: any) => void; @@ -37,12 +43,11 @@ interface IIssueView { issueCommentReactionRemove: (commentId: string, reaction: string) => void; issueSubscriptionCreate: () => void; issueSubscriptionRemove: () => void; - issueLinkCreate: (formData: IIssueLink) => Promise; - issueLinkUpdate: (formData: IIssueLink, linkId: string) => Promise; - issueLinkDelete: (linkId: string) => Promise; - handleDeleteIssue: () => Promise; + disableUserActions?: boolean; showCommentAccessSpecifier?: boolean; + + issueOperations: any; } type TPeekModes = "side-peek" | "modal" | "full-screen"; @@ -75,6 +80,7 @@ export const IssueView: FC = observer((props) => { isArchived, handleCopyText, redirectToIssueDetail, + issueUpdate, issueReactionCreate, issueReactionRemove, @@ -85,12 +91,12 @@ export const IssueView: FC = observer((props) => { issueCommentReactionRemove, issueSubscriptionCreate, issueSubscriptionRemove, - issueLinkCreate, - issueLinkUpdate, - issueLinkDelete, - handleDeleteIssue, + + issueDelete, disableUserActions = false, showCommentAccessSpecifier = false, + + issueOperations, } = props; // states const [peekMode, setPeekMode] = useState("side-peek"); @@ -109,7 +115,9 @@ export const IssueView: FC = observer((props) => { } = useIssueDetail(); const { currentUser } = useUser(); - const removeRoutePeekId = () => setPeekIssue(undefined); + const removeRoutePeekId = () => { + setPeekIssue(undefined); + }; const issueReactions = reaction.getReactionsByIssueId(issueId) || []; const issueActivity = activity.getActivitiesByIssueId(issueId); @@ -126,7 +134,7 @@ export const IssueView: FC = observer((props) => { isOpen={isDeleteIssueModalOpen} handleClose={() => toggleDeleteIssueModal(false)} data={issue} - onSubmit={handleDeleteIssue} + onSubmit={issueDelete} /> )} @@ -135,7 +143,7 @@ export const IssueView: FC = observer((props) => { data={issue} isOpen={isDeleteIssueModalOpen} handleClose={() => toggleDeleteIssueModal(false)} - onSubmit={handleDeleteIssue} + onSubmit={issueDelete} /> )} @@ -257,10 +265,8 @@ export const IssueView: FC = observer((props) => { = observer((props) => {
    diff --git a/web/components/issues/select/label.tsx b/web/components/issues/select/label.tsx index f72006677..485adfcaa 100644 --- a/web/components/issues/select/label.tsx +++ b/web/components/issues/select/label.tsx @@ -101,8 +101,7 @@ export const IssueLabelSelect: React.FC = observer((props) => { {isDropdownOpen && (
    void; - disabled?: boolean; - handleIssueUpdate?: () => void; -}; - -// services -const cycleService = new CycleService(); - -export const SidebarCycleSelect: React.FC = (props) => { - const { issueDetail, disabled = false, handleIssueUpdate, handleCycleChange } = props; - // router - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; - // mobx store - const { - issues: { removeIssueFromCycle, addIssueToCycle }, - } = useIssues(EIssuesStoreType.CYCLE); - const { getCycleById } = useCycle(); - - const [isUpdating, setIsUpdating] = useState(false); - - const { data: incompleteCycles } = useSWR( - workspaceSlug && projectId ? INCOMPLETE_CYCLES_LIST(projectId as string) : null, - workspaceSlug && projectId - ? () => cycleService.getCyclesWithParams(workspaceSlug as string, projectId as string) // FIXME, "incomplete") - : null - ); - - const handleCycleStoreChange = async (cycleId: string) => { - if (!workspaceSlug || !issueDetail || !cycleId || !projectId) return; - - setIsUpdating(true); - await addIssueToCycle(workspaceSlug.toString(), projectId?.toString(), cycleId, [issueDetail.id]) - .then(async () => { - handleIssueUpdate && (await handleIssueUpdate()); - }) - .finally(() => { - setIsUpdating(false); - }); - }; - - const handleRemoveIssueFromCycle = (cycleId: string) => { - if (!workspaceSlug || !projectId || !issueDetail) return; - - setIsUpdating(true); - removeIssueFromCycle(workspaceSlug.toString(), projectId.toString(), cycleId, issueDetail.id) - .then(async () => { - handleIssueUpdate && (await handleIssueUpdate()); - mutate(ISSUE_DETAILS(issueDetail.id)); - - mutate(CYCLE_ISSUES(cycleId)); - }) - .catch((e) => { - console.error(e); - }) - .finally(() => { - setIsUpdating(false); - }); - }; - - const options = incompleteCycles?.map((cycle) => ({ - value: cycle.id, - query: cycle.name, - content: ( -
    - - - - {cycle.name} -
    - ), - })); - - const issueCycle = (issueDetail && issueDetail.cycle_id && getCycleById(issueDetail.cycle_id)) || undefined; - - const disableSelect = disabled || isUpdating; - - return ( -
    - { - value === issueDetail?.cycle_id - ? handleRemoveIssueFromCycle(issueDetail?.cycle_id ?? "") - : handleCycleChange - ? handleCycleChange(value) - : handleCycleStoreChange(value); - }} - options={options} - customButton={ -
    - - - -
    - } - width="max-w-[10rem]" - noChevron - disabled={disableSelect} - /> - {isUpdating && } -
    - ); -}; diff --git a/web/components/issues/sidebar-select/index.ts b/web/components/issues/sidebar-select/index.ts deleted file mode 100644 index 323bf1bb3..000000000 --- a/web/components/issues/sidebar-select/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./relation"; -export * from "./cycle"; -export * from "./label"; -export * from "./module"; -export * from "./parent"; diff --git a/web/components/issues/sidebar-select/label.tsx b/web/components/issues/sidebar-select/label.tsx deleted file mode 100644 index 3eca9cccc..000000000 --- a/web/components/issues/sidebar-select/label.tsx +++ /dev/null @@ -1,225 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { useRouter } from "next/router"; -import { observer } from "mobx-react-lite"; -import { Controller, useForm } from "react-hook-form"; -import { TwitterPicker } from "react-color"; -import { Popover, Transition } from "@headlessui/react"; -import { Plus, X } from "lucide-react"; -// hooks -import { useLabel } from "hooks/store"; -import useToast from "hooks/use-toast"; -// ui -import { Input } from "@plane/ui"; -import { IssueLabelSelect } from "../select"; -// types -import { TIssue, IIssueLabel } from "@plane/types"; - -type Props = { - issueDetails: TIssue | undefined; - labelList: string[]; - submitChanges: (formData: any) => void; - isNotAllowed: boolean; - uneditable: boolean; -}; - -const defaultValues: Partial = { - name: "", - color: "#ff0000", -}; - -export const SidebarLabelSelect: React.FC = observer((props) => { - const { issueDetails, labelList, submitChanges, isNotAllowed, uneditable } = props; - // states - const [createLabelForm, setCreateLabelForm] = useState(false); - // router - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; - // toast - const { setToastAlert } = useToast(); - // mobx store - const { - project: { projectLabels, createLabel }, - } = useLabel(); - // form info - const { - handleSubmit, - formState: { errors, isSubmitting }, - reset, - control, - setFocus, - } = useForm>({ - defaultValues, - }); - - const handleNewLabel = async (formData: Partial) => { - if (!workspaceSlug || !projectId || isSubmitting) return; - - await createLabel(workspaceSlug.toString(), projectId.toString(), formData) - .then((res) => { - reset(defaultValues); - - submitChanges({ labels: [...(issueDetails?.label_ids ?? []), res.id] }); - - setCreateLabelForm(false); - }) - .catch((error) => { - setToastAlert({ - type: "error", - title: "Error!", - message: error?.error ?? "Something went wrong. Please try again.", - }); - reset(formData); - }); - }; - - useEffect(() => { - if (!createLabelForm) return; - - setFocus("name"); - reset(); - }, [createLabelForm, reset, setFocus]); - - return ( -
    -
    - {labelList?.map((labelId) => { - const label = projectLabels?.find((l) => l.id === labelId); - - if (label) - return ( - - ); - })} - submitChanges({ labels: val })} - projectId={issueDetails?.project_id ?? ""} - label={ - - Select Label - - } - disabled={uneditable} - /> - {!isNotAllowed && ( - - )} -
    - - {createLabelForm && ( -
    -
    - ( - - <> - - {value && value?.trim() !== "" && ( - - )} - - - - - onChange(value.hex)} /> - - - - - )} - /> -
    - ( - - )} - /> - - - - )} -
    - ); -}); diff --git a/web/components/issues/sidebar-select/module.tsx b/web/components/issues/sidebar-select/module.tsx deleted file mode 100644 index 235f8486b..000000000 --- a/web/components/issues/sidebar-select/module.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import React, { useState } from "react"; -import { useRouter } from "next/router"; -import { observer } from "mobx-react-lite"; -import { mutate } from "swr"; -// hooks -import { useIssues, useModule } from "hooks/store"; -// ui -import { CustomSearchSelect, DiceIcon, Spinner, Tooltip } from "@plane/ui"; -// types -import { TIssue } from "@plane/types"; -// fetch-keys -import { ISSUE_DETAILS, MODULE_ISSUES } from "constants/fetch-keys"; -import { EIssuesStoreType } from "constants/issue"; - -type Props = { - issueDetail: TIssue | undefined; - handleModuleChange?: (moduleId: string) => void; - disabled?: boolean; - handleIssueUpdate?: () => void; -}; - -export const SidebarModuleSelect: React.FC = observer((props) => { - const { issueDetail, disabled = false, handleIssueUpdate, handleModuleChange } = props; - // router - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; - // mobx store - const { - issues: { removeIssueFromModule, addIssueToModule }, - } = useIssues(EIssuesStoreType.MODULE); - const { projectModuleIds, getModuleById } = useModule(); - - const [isUpdating, setIsUpdating] = useState(false); - - const handleModuleStoreChange = async (moduleId: string) => { - if (!workspaceSlug || !issueDetail || !moduleId || !projectId) return; - - setIsUpdating(true); - await addIssueToModule(workspaceSlug.toString(), projectId?.toString(), moduleId, [issueDetail.id]) - .then(async () => { - handleIssueUpdate && (await handleIssueUpdate()); - }) - .finally(() => { - setIsUpdating(false); - }); - }; - - const handleRemoveIssueFromModule = (bridgeId: string, moduleId: string) => { - if (!workspaceSlug || !projectId || !issueDetail) return; - - setIsUpdating(true); - removeIssueFromModule(workspaceSlug.toString(), projectId.toString(), moduleId, issueDetail.id) - .then(async () => { - handleIssueUpdate && (await handleIssueUpdate()); - mutate(ISSUE_DETAILS(issueDetail.id)); - - mutate(MODULE_ISSUES(moduleId)); - }) - .catch((e) => { - console.error(e); - }) - .finally(() => { - setIsUpdating(false); - }); - }; - - const options = projectModuleIds?.map((moduleId) => { - const moduleDetail = getModuleById(moduleId); - return { - value: moduleId, - query: moduleDetail?.name ?? "", - content: ( -
    - - - - {moduleDetail?.name} -
    - ), - }; - }); - - // derived values - const issueModule = (issueDetail && issueDetail?.module_id && getModuleById(issueDetail.module_id)) || undefined; - - const disableSelect = disabled || isUpdating; - - return ( -
    - { - value === issueDetail?.module_id - ? handleRemoveIssueFromModule(issueModule?.id ?? "", issueDetail?.module_id ?? "") - : handleModuleChange - ? handleModuleChange(value) - : handleModuleStoreChange(value); - }} - options={options} - customButton={ -
    - - - -
    - } - width="max-w-[10rem]" - noChevron - disabled={disableSelect} - /> - {isUpdating && } -
    - ); -}); diff --git a/web/components/issues/sidebar-select/parent.tsx b/web/components/issues/sidebar-select/parent.tsx deleted file mode 100644 index 47fb01b27..000000000 --- a/web/components/issues/sidebar-select/parent.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import React, { useState } from "react"; - -import { useRouter } from "next/router"; -// hooks -import { useIssueDetail, useIssues, useProject } from "hooks/store"; -// components -import { ParentIssuesListModal } from "components/issues"; -// icons -import { X } from "lucide-react"; -// types -import { TIssue, ISearchIssueResponse } from "@plane/types"; -import { observer } from "mobx-react-lite"; - -type Props = { - onChange: (value: string) => void; - issueDetails: TIssue | undefined; - disabled?: boolean; -}; - -export const SidebarParentSelect: React.FC = observer(({ onChange, issueDetails, disabled = false }) => { - const [selectedParentIssue, setSelectedParentIssue] = useState(null); - - const { isParentIssueModalOpen, toggleParentIssueModal } = useIssueDetail(); - - const router = useRouter(); - const { projectId, issueId } = router.query; - - // hooks - const { getProjectById } = useProject(); - const { issueMap } = useIssues(); - - return ( - <> - toggleParentIssueModal(false)} - onChange={(issue) => { - onChange(issue.id); - setSelectedParentIssue(issue); - }} - issueId={issueId as string} - projectId={projectId as string} - /> - - - ); -}); diff --git a/web/components/issues/sub-issues/issue.tsx b/web/components/issues/sub-issues/issue.tsx index 57a887798..2d6de08d1 100644 --- a/web/components/issues/sub-issues/issue.tsx +++ b/web/components/issues/sub-issues/issue.tsx @@ -7,7 +7,7 @@ import { IssueProperty } from "./properties"; import { CustomMenu, Tooltip } from "@plane/ui"; // types import { IUser, TIssue, TIssueSubIssues } from "@plane/types"; -import { ISubIssuesRootLoaders, ISubIssuesRootLoadersHandler } from "./root"; +// import { ISubIssuesRootLoaders, ISubIssuesRootLoadersHandler } from "./root"; import { useIssueDetail, useProject, useProjectState } from "hooks/store"; export interface ISubIssues { @@ -24,8 +24,8 @@ export interface ISubIssues { user: IUser | undefined; editable: boolean; removeIssueFromSubIssues: (parentIssueId: string, issue: TIssue) => void; - issuesLoader: ISubIssuesRootLoaders; - handleIssuesLoader: ({ key, issueId }: ISubIssuesRootLoadersHandler) => void; + issuesLoader: any; // FIXME: ISubIssuesRootLoaders replace with any + handleIssuesLoader: ({ key, issueId }: any) => void; // FIXME: ISubIssuesRootLoadersHandler replace with any copyText: (text: string) => void; handleIssueCrudOperation: ( key: "create" | "existing" | "edit" | "delete", diff --git a/web/components/issues/sub-issues/issues-list.tsx b/web/components/issues/sub-issues/issues-list.tsx index 2fe4baea3..e8def0a98 100644 --- a/web/components/issues/sub-issues/issues-list.tsx +++ b/web/components/issues/sub-issues/issues-list.tsx @@ -3,7 +3,7 @@ import { useMemo } from "react"; import { SubIssues } from "./issue"; // types import { IUser, TIssue } from "@plane/types"; -import { ISubIssuesRootLoaders, ISubIssuesRootLoadersHandler } from "./root"; +// import { ISubIssuesRootLoaders, ISubIssuesRootLoadersHandler } from "./root"; // fetch keys import { useIssueDetail } from "hooks/store"; @@ -16,8 +16,8 @@ export interface ISubIssuesRootList { user: IUser | undefined; editable: boolean; removeIssueFromSubIssues: (parentIssueId: string, issue: TIssue) => void; - issuesLoader: ISubIssuesRootLoaders; - handleIssuesLoader: ({ key, issueId }: ISubIssuesRootLoadersHandler) => void; + issuesLoader: any; // FIXME: replace ISubIssuesRootLoaders with any + handleIssuesLoader: ({ key, issueId }: any) => void; // FIXME: replace ISubIssuesRootLoadersHandler with any copyText: (text: string) => void; handleIssueCrudOperation: ( key: "create" | "existing" | "edit" | "delete", diff --git a/web/components/issues/sub-issues/root.tsx b/web/components/issues/sub-issues/root.tsx index 025e4741f..18e1bf1f4 100644 --- a/web/components/issues/sub-issues/root.tsx +++ b/web/components/issues/sub-issues/root.tsx @@ -1,10 +1,8 @@ -import React, { useCallback } from "react"; -import { useRouter } from "next/router"; +import React, { useCallback, useMemo, useState } from "react"; import { observer } from "mobx-react-lite"; -import useSWR, { mutate } from "swr"; import { Plus, ChevronRight, ChevronDown } from "lucide-react"; // hooks -import { useIssues, useUser } from "hooks/store"; +import { useIssueDetail, useIssues, useUser } from "hooks/store"; import useToast from "hooks/use-toast"; // components import { ExistingIssuesListModal } from "components/core"; @@ -26,63 +24,91 @@ import { EUserProjectRoles } from "constants/project"; import { EIssuesStoreType } from "constants/issue"; export interface ISubIssuesRoot { - parentIssue: TIssue; - user: IUser | undefined; -} - -export interface ISubIssuesRootLoaders { - visibility: string[]; - delete: string[]; - sub_issues: string[]; -} -export interface ISubIssuesRootLoadersHandler { - key: "visibility" | "delete" | "sub_issues"; + workspaceSlug: string; + projectId: string; issueId: string; + currentUser: IUser; + is_archived: boolean; + is_editable: boolean; } -const issueService = new IssueService(); - export const SubIssuesRoot: React.FC = observer((props) => { - const { parentIssue, user } = props; + const { workspaceSlug, projectId, issueId, currentUser, is_archived, is_editable } = props; // store hooks - const { - issues: { updateIssue, removeIssue }, - } = useIssues(EIssuesStoreType.PROJECT); const { membership: { currentProjectRole }, } = useUser(); - // router - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; - const { setToastAlert } = useToast(); + const { + subIssues: { subIssuesByIssueId, subIssuesStateDistribution }, + updateIssue, + removeIssue, + fetchSubIssues, + createSubIssues, + } = useIssueDetail(); + // state + const [currentIssue, setCurrentIssue] = useState(); - const { data: issues, isLoading } = useSWR( - workspaceSlug && projectId && parentIssue && parentIssue?.id ? SUB_ISSUES(parentIssue?.id) : null, - workspaceSlug && projectId && parentIssue && parentIssue?.id - ? () => issueService.subIssues(workspaceSlug.toString(), projectId.toString(), parentIssue.id) - : null - ); + console.log("subIssuesByIssueId", subIssuesByIssueId(issueId)); - const [issuesLoader, setIssuesLoader] = React.useState({ - visibility: [parentIssue?.id], - delete: [], - sub_issues: [], - }); - const handleIssuesLoader = ({ key, issueId }: ISubIssuesRootLoadersHandler) => { - setIssuesLoader((previousData: ISubIssuesRootLoaders) => ({ - ...previousData, - [key]: previousData[key].includes(issueId) - ? previousData[key].filter((i: string) => i !== issueId) - : [...previousData[key], issueId], - })); + const copyText = (text: string) => { + const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; + copyTextToClipboard(`${originURL}/${text}`).then(() => { + setToastAlert({ + type: "success", + title: "Link Copied!", + message: "Issue link copied to clipboard.", + }); + }); }; + const subIssueOperations = useMemo( + () => ({ + fetchSubIssues: async (workspaceSlug: string, projectId: string, issueId: string) => { + try { + await fetchSubIssues(workspaceSlug, projectId, issueId); + } catch (error) {} + }, + update: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => { + try { + await updateIssue(workspaceSlug, projectId, issueId, data); + setToastAlert({ + title: "Issue updated successfully", + type: "success", + message: "Issue updated successfully", + }); + } catch (error) { + setToastAlert({ + title: "Issue update failed", + type: "error", + message: "Issue update failed", + }); + } + }, + addSubIssue: async () => { + try { + } catch (error) {} + }, + removeSubIssue: async () => { + try { + } catch (error) {} + }, + updateIssue: async () => { + try { + } catch (error) {} + }, + deleteIssue: async () => { + try { + } catch (error) {} + }, + }), + [] + ); + const [issueCrudOperation, setIssueCrudOperation] = React.useState<{ + // type: "create" | "edit"; create: { toggle: boolean; issueId: string | null }; existing: { toggle: boolean; issueId: string | null }; - edit: { toggle: boolean; issueId: string | null; issue: TIssue | null }; - delete: { toggle: boolean; issueId: string | null; issue: TIssue | null }; }>({ create: { toggle: false, @@ -92,19 +118,10 @@ export const SubIssuesRoot: React.FC = observer((props) => { toggle: false, issueId: null, }, - edit: { - toggle: false, - issueId: null, // parent issue id for mutation - issue: null, - }, - delete: { - toggle: false, - issueId: null, // parent issue id for mutation - issue: null, - }, }); + const handleIssueCrudOperation = ( - key: "create" | "existing" | "edit" | "delete", + key: "create" | "existing", issueId: string | null, issue: TIssue | null = null ) => { @@ -118,75 +135,14 @@ export const SubIssuesRoot: React.FC = observer((props) => { }); }; - const addAsSubIssueFromExistingIssues = async (data: ISearchIssueResponse[]) => { - if (!workspaceSlug || !projectId || !parentIssue || issueCrudOperation?.existing?.issueId === null) return; - const issueId = issueCrudOperation?.existing?.issueId; - const payload = { - sub_issue_ids: data.map((i) => i.id), - }; - await issueService.addSubIssues(workspaceSlug.toString(), projectId.toString(), issueId, payload).finally(() => { - if (issueId) mutate(SUB_ISSUES(issueId)); - }); - }; - - const removeIssueFromSubIssues = async (parentIssueId: string, issue: TIssue) => { - if (!workspaceSlug || !projectId || !parentIssue || !issue?.id) return; - issueService - .patchIssue(workspaceSlug.toString(), projectId.toString(), issue.id, { parent_id: null }) - .then(async () => { - if (parentIssueId) await mutate(SUB_ISSUES(parentIssueId)); - handleIssuesLoader({ key: "delete", issueId: issue?.id }); - setToastAlert({ - type: "success", - title: `Issue removed!`, - message: `Issue removed successfully.`, - }); - }) - .catch(() => { - handleIssuesLoader({ key: "delete", issueId: issue?.id }); - setToastAlert({ - type: "warning", - title: `Error!`, - message: `Error, Please try again later.`, - }); - }); - }; - - const copyText = (text: string) => { - const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; - copyTextToClipboard(`${originURL}/${text}`).then(() => { - setToastAlert({ - type: "success", - title: "Link Copied!", - message: "Issue link copied to clipboard.", - }); - }); - }; - - const handleUpdateIssue = useCallback( - (issue: TIssue, data: Partial) => { - if (!workspaceSlug || !projectId || !user) return; - - updateIssue(workspaceSlug.toString(), projectId.toString(), issue.id, data); - }, - [projectId, updateIssue, user, workspaceSlug] - ); - - const isEditable = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; - - const mutateSubIssues = (parentIssueId: string | null) => { - if (parentIssueId) mutate(SUB_ISSUES(parentIssueId)); - }; - return (
    - {!issues && isLoading ? ( + {/* {!issues && isLoading ? (
    Loading...
    ) : ( <> {issues && issues?.sub_issues && issues?.sub_issues?.length > 0 ? ( <> - {/* header */}
    = observer((props) => { />
    - {isEditable && issuesLoader.visibility.includes(parentIssue?.id) && ( + {is_editable && issuesLoader.visibility.includes(parentIssue?.id) && (
    = observer((props) => { )}
    - {/* issues */} + {issuesLoader.visibility.includes(parentIssue?.id) && workspaceSlug && projectId && (
    = observer((props) => { projectId={projectId.toString()} parentIssue={parentIssue} user={undefined} - editable={isEditable} + editable={is_editable} removeIssueFromSubIssues={removeIssueFromSubIssues} issuesLoader={issuesLoader} handleIssuesLoader={handleIssuesLoader} @@ -262,7 +218,6 @@ export const SubIssuesRoot: React.FC = observer((props) => { > { - mutateSubIssues(parentIssue?.id); handleIssueCrudOperation("create", parentIssue?.id); }} > @@ -270,7 +225,6 @@ export const SubIssuesRoot: React.FC = observer((props) => { { - mutateSubIssues(parentIssue?.id); handleIssueCrudOperation("existing", parentIssue?.id); }} > @@ -280,7 +234,7 @@ export const SubIssuesRoot: React.FC = observer((props) => {
    ) : ( - isEditable && ( + is_editable && (
    No Sub-Issues yet
    @@ -298,7 +252,6 @@ export const SubIssuesRoot: React.FC = observer((props) => { > { - mutateSubIssues(parentIssue?.id); handleIssueCrudOperation("create", parentIssue?.id); }} > @@ -306,7 +259,6 @@ export const SubIssuesRoot: React.FC = observer((props) => { { - mutateSubIssues(parentIssue?.id); handleIssueCrudOperation("existing", parentIssue?.id); }} > @@ -317,19 +269,20 @@ export const SubIssuesRoot: React.FC = observer((props) => {
    ) )} - {isEditable && issueCrudOperation?.create?.toggle && ( + + {is_editable && issueCrudOperation?.create?.toggle && ( { - mutateSubIssues(issueCrudOperation?.create?.issueId); handleIssueCrudOperation("create", null); }} /> )} - {isEditable && issueCrudOperation?.existing?.toggle && issueCrudOperation?.existing?.issueId && ( + + {is_editable && issueCrudOperation?.existing?.toggle && issueCrudOperation?.existing?.issueId && ( handleIssueCrudOperation("existing", null)} @@ -338,19 +291,20 @@ export const SubIssuesRoot: React.FC = observer((props) => { workspaceLevelToggle /> )} - {isEditable && issueCrudOperation?.edit?.toggle && issueCrudOperation?.edit?.issueId && ( + + {is_editable && issueCrudOperation?.edit?.toggle && issueCrudOperation?.edit?.issueId && ( <> { - mutateSubIssues(issueCrudOperation?.edit?.issueId); handleIssueCrudOperation("edit", null, null); }} data={issueCrudOperation?.edit?.issue ?? undefined} /> )} - {isEditable && + + {is_editable && workspaceSlug && projectId && issueCrudOperation?.delete?.issueId && @@ -358,7 +312,6 @@ export const SubIssuesRoot: React.FC = observer((props) => { { - mutateSubIssues(issueCrudOperation?.delete?.issueId); handleIssueCrudOperation("delete", null, null); }} data={issueCrudOperation?.delete?.issue} @@ -372,7 +325,7 @@ export const SubIssuesRoot: React.FC = observer((props) => { /> )} - )} + )} */}
    ); }); diff --git a/web/components/modules/sidebar.tsx b/web/components/modules/sidebar.tsx index 5b6456207..1ecd468d7 100644 --- a/web/components/modules/sidebar.tsx +++ b/web/components/modules/sidebar.tsx @@ -76,7 +76,7 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { const submitChanges = (data: Partial) => { if (!workspaceSlug || !projectId || !moduleId) return; - updateModuleDetails(workspaceSlug.toString(), projectId.toString(), moduleId, data); + updateModuleDetails(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), data); }; const handleCreateLink = async (formData: ModuleLink) => { diff --git a/web/components/pages/create-update-page-modal.tsx b/web/components/pages/create-update-page-modal.tsx index 38896a222..21142cab9 100644 --- a/web/components/pages/create-update-page-modal.tsx +++ b/web/components/pages/create-update-page-modal.tsx @@ -37,90 +37,90 @@ export const CreateUpdatePageModal: FC = (props) => { const createProjectPage = async (payload: IPage) => { if (!workspaceSlug) return; - await createPage(workspaceSlug.toString(), projectId, payload) - .then((res) => { - router.push(`/${workspaceSlug}/projects/${projectId}/pages/${res.id}`); - onClose(); - setToastAlert({ - type: "success", - title: "Success!", - message: "Page created successfully.", - }); - postHogEventTracker( - "PAGE_CREATED", - { - ...res, - state: "SUCCESS", - }, - { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - } - ); - }) - .catch((err) => { - setToastAlert({ - type: "error", - title: "Error!", - message: err.detail ?? "Page could not be created. Please try again.", - }); - postHogEventTracker( - "PAGE_CREATED", - { - state: "FAILED", - }, - { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - } - ); - }); + // await createPage(workspaceSlug.toString(), projectId, payload) + // .then((res) => { + // router.push(`/${workspaceSlug}/projects/${projectId}/pages/${res.id}`); + // onClose(); + // setToastAlert({ + // type: "success", + // title: "Success!", + // message: "Page created successfully.", + // }); + // postHogEventTracker( + // "PAGE_CREATED", + // { + // ...res, + // state: "SUCCESS", + // }, + // { + // isGrouping: true, + // groupType: "Workspace_metrics", + // groupId: currentWorkspace?.id!, + // } + // ); + // }) + // .catch((err) => { + // setToastAlert({ + // type: "error", + // title: "Error!", + // message: err.detail ?? "Page could not be created. Please try again.", + // }); + // postHogEventTracker( + // "PAGE_CREATED", + // { + // state: "FAILED", + // }, + // { + // isGrouping: true, + // groupType: "Workspace_metrics", + // groupId: currentWorkspace?.id!, + // } + // ); + // }); }; const updateProjectPage = async (payload: IPage) => { if (!data || !workspaceSlug) return; - await updatePage(workspaceSlug.toString(), projectId, data.id, payload) - .then((res) => { - onClose(); - setToastAlert({ - type: "success", - title: "Success!", - message: "Page updated successfully.", - }); - postHogEventTracker( - "PAGE_UPDATED", - { - ...res, - state: "SUCCESS", - }, - { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - } - ); - }) - .catch((err) => { - setToastAlert({ - type: "error", - title: "Error!", - message: err.detail ?? "Page could not be updated. Please try again.", - }); - postHogEventTracker( - "PAGE_UPDATED", - { - state: "FAILED", - }, - { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - } - ); - }); + // await updatePage(workspaceSlug.toString(), projectId, data.id, payload) + // .then((res) => { + // onClose(); + // setToastAlert({ + // type: "success", + // title: "Success!", + // message: "Page updated successfully.", + // }); + // postHogEventTracker( + // "PAGE_UPDATED", + // { + // ...res, + // state: "SUCCESS", + // }, + // { + // isGrouping: true, + // groupType: "Workspace_metrics", + // groupId: currentWorkspace?.id!, + // } + // ); + // }) + // .catch((err) => { + // setToastAlert({ + // type: "error", + // title: "Error!", + // message: err.detail ?? "Page could not be updated. Please try again.", + // }); + // postHogEventTracker( + // "PAGE_UPDATED", + // { + // state: "FAILED", + // }, + // { + // isGrouping: true, + // groupType: "Workspace_metrics", + // groupId: currentWorkspace?.id!, + // } + // ); + // }); }; const handleFormSubmit = async (formData: IPage) => { diff --git a/web/components/pages/pages-list/list-item.tsx b/web/components/pages/pages-list/list-item.tsx index e0c32067b..7a92f3296 100644 --- a/web/components/pages/pages-list/list-item.tsx +++ b/web/components/pages/pages-list/list-item.tsx @@ -186,8 +186,9 @@ export const PagesListItem: FC = observer((props) => {

    {pageDetails.name}

    + {/* FIXME: replace any with proper type */} {pageDetails.label_details.length > 0 && - pageDetails.label_details.map((label) => ( + pageDetails.label_details.map((label: any) => (
    { } = useUser(); const { recentProjectPages } = usePage(); - const isEmpty = recentProjectPages && Object.values(recentProjectPages).every((value) => value.length === 0); + // FIXME: replace any with proper type + const isEmpty = recentProjectPages && Object.values(recentProjectPages).every((value: any) => value.length === 0); const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; diff --git a/web/constants/issue.ts b/web/constants/issue.ts index edce5b306..8a48f5eb6 100644 --- a/web/constants/issue.ts +++ b/web/constants/issue.ts @@ -36,6 +36,7 @@ export enum EIssueFilterType { FILTERS = "filters", DISPLAY_FILTERS = "display_filters", DISPLAY_PROPERTIES = "display_properties", + KANBAN_FILTERS = "kanban_filters", } export const ISSUE_PRIORITIES: { diff --git a/web/hooks/store/use-issues.ts b/web/hooks/store/use-issues.ts index 4ec03847b..f2da9d954 100644 --- a/web/hooks/store/use-issues.ts +++ b/web/hooks/store/use-issues.ts @@ -1,4 +1,5 @@ import { useContext } from "react"; +import merge from "lodash/merge"; // mobx store import { StoreContext } from "contexts/store-context"; // types @@ -14,112 +15,102 @@ import { TIssueMap } from "@plane/types"; // constants import { EIssuesStoreType } from "constants/issue"; -export interface IStoreIssues { - [EIssuesStoreType.GLOBAL]: { - issueMap: TIssueMap; +type defaultIssueStore = { + issueMap: TIssueMap; +}; + +export type TStoreIssues = { + [EIssuesStoreType.GLOBAL]: defaultIssueStore & { issues: IWorkspaceIssues; issuesFilter: IWorkspaceIssuesFilter; }; - [EIssuesStoreType.PROFILE]: { - issueMap: TIssueMap; + [EIssuesStoreType.PROFILE]: defaultIssueStore & { issues: IProfileIssues; issuesFilter: IProfileIssuesFilter; }; - [EIssuesStoreType.PROJECT]: { - issueMap: TIssueMap; + [EIssuesStoreType.PROJECT]: defaultIssueStore & { issues: IProjectIssues; issuesFilter: IProjectIssuesFilter; }; - [EIssuesStoreType.CYCLE]: { - issueMap: TIssueMap; + [EIssuesStoreType.CYCLE]: defaultIssueStore & { issues: ICycleIssues; issuesFilter: ICycleIssuesFilter; }; - [EIssuesStoreType.MODULE]: { - issueMap: TIssueMap; + [EIssuesStoreType.MODULE]: defaultIssueStore & { issues: IModuleIssues; issuesFilter: IModuleIssuesFilter; }; - [EIssuesStoreType.PROJECT_VIEW]: { - issueMap: TIssueMap; + [EIssuesStoreType.PROJECT_VIEW]: defaultIssueStore & { issues: IProjectViewIssues; issuesFilter: IProjectViewIssuesFilter; }; - [EIssuesStoreType.ARCHIVED]: { - issueMap: TIssueMap; + [EIssuesStoreType.ARCHIVED]: defaultIssueStore & { issues: IArchivedIssues; issuesFilter: IArchivedIssuesFilter; }; - [EIssuesStoreType.DRAFT]: { - issueMap: TIssueMap; + [EIssuesStoreType.DRAFT]: defaultIssueStore & { issues: IDraftIssues; issuesFilter: IDraftIssuesFilter; }; - [EIssuesStoreType.DEFAULT]: { - issueMap: TIssueMap; + [EIssuesStoreType.DEFAULT]: defaultIssueStore & { issues: undefined; issuesFilter: undefined; }; -} +}; -export const useIssues = (storeType?: T): IStoreIssues[T] => { +export const useIssues = (storeType?: T): TStoreIssues[T] => { const context = useContext(StoreContext); if (context === undefined) throw new Error("useIssues must be used within StoreProvider"); + const defaultStore: defaultIssueStore = { + issueMap: context.issue.issues.issuesMap, + }; + switch (storeType) { case EIssuesStoreType.GLOBAL: - return { - issueMap: context.issue.issues.issuesMap, + return merge(defaultStore, { issues: context.issue.workspaceIssues, issuesFilter: context.issue.workspaceIssuesFilter, - } as IStoreIssues[T]; + }) as TStoreIssues[T]; case EIssuesStoreType.PROFILE: - return { - issueMap: context.issue.issues.issuesMap, + return merge(defaultStore, { issues: context.issue.profileIssues, issuesFilter: context.issue.profileIssuesFilter, - } as IStoreIssues[T]; + }) as TStoreIssues[T]; case EIssuesStoreType.PROJECT: - return { - issueMap: context.issue.issues.issuesMap, + return merge(defaultStore, { issues: context.issue.projectIssues, issuesFilter: context.issue.projectIssuesFilter, - } as IStoreIssues[T]; + }) as TStoreIssues[T]; case EIssuesStoreType.CYCLE: - return { - issueMap: context.issue.issues.issuesMap, + return merge(defaultStore, { issues: context.issue.cycleIssues, issuesFilter: context.issue.cycleIssuesFilter, - } as IStoreIssues[T]; + }) as TStoreIssues[T]; case EIssuesStoreType.MODULE: - return { - issueMap: context.issue.issues.issuesMap, + return merge(defaultStore, { issues: context.issue.moduleIssues, issuesFilter: context.issue.moduleIssuesFilter, - } as IStoreIssues[T]; + }) as TStoreIssues[T]; case EIssuesStoreType.PROJECT_VIEW: - return { - issueMap: context.issue.issues.issuesMap, + return merge(defaultStore, { issues: context.issue.projectViewIssues, issuesFilter: context.issue.projectViewIssuesFilter, - } as IStoreIssues[T]; + }) as TStoreIssues[T]; case EIssuesStoreType.ARCHIVED: - return { - issueMap: context.issue.issues.issuesMap, + return merge(defaultStore, { issues: context.issue.archivedIssues, issuesFilter: context.issue.archivedIssuesFilter, - } as IStoreIssues[T]; + }) as TStoreIssues[T]; case EIssuesStoreType.DRAFT: - return { - issueMap: context.issue.issues.issuesMap, + return merge(defaultStore, { issues: context.issue.draftIssues, issuesFilter: context.issue.draftIssuesFilter, - } as IStoreIssues[T]; + }) as TStoreIssues[T]; default: - return { - issueMap: context.issue.issues.issuesMap, + return merge(defaultStore, { issues: undefined, issuesFilter: undefined, - } as IStoreIssues[T]; + }) as TStoreIssues[T]; } }; diff --git a/web/hooks/store/use-page.ts b/web/hooks/store/use-page.ts index 8cd13dcdc..cc348a4ad 100644 --- a/web/hooks/store/use-page.ts +++ b/web/hooks/store/use-page.ts @@ -4,8 +4,8 @@ import { StoreContext } from "contexts/store-context"; // types import { IPageStore } from "store/page.store"; -export const usePage = (): IPageStore => { +export const usePage = (): any => { const context = useContext(StoreContext); if (context === undefined) throw new Error("usePage must be used within StoreProvider"); - return context.page; + return context as any; }; diff --git a/web/hooks/use-issue-reaction.tsx b/web/hooks/use-issue-reaction.tsx deleted file mode 100644 index f1159c328..000000000 --- a/web/hooks/use-issue-reaction.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import useSWR from "swr"; - -// fetch keys -import { ISSUE_REACTION_LIST } from "constants/fetch-keys"; -// helpers -import { groupReactions } from "helpers/emoji.helper"; -// services -import { IssueReactionService } from "services/issue"; -import { useUser } from "./store"; -// hooks - -const issueReactionService = new IssueReactionService(); - -const useIssueReaction = ( - workspaceSlug?: string | string[] | null, - projectId?: string | string[] | null, - issueId?: string | string[] | null -) => { - const user = useUser(); - - const { - data: reactions, - mutate: mutateReaction, - error, - } = useSWR( - workspaceSlug && projectId && issueId - ? ISSUE_REACTION_LIST(workspaceSlug.toString(), projectId.toString(), issueId.toString()) - : null, - workspaceSlug && projectId && issueId - ? () => - issueReactionService.listIssueReactions(workspaceSlug.toString(), projectId.toString(), issueId.toString()) - : null - ); - - const groupedReactions = groupReactions(reactions || [], "reaction"); - - /** - * @description Use this function to create user's reaction to an issue. This function will mutate the reactions state. - * @param {string} reaction - * @example handleReactionCreate("128077") // hexa-code of the emoji - */ - - const handleReactionCreate = async (reaction: string) => { - if (!workspaceSlug || !projectId || !issueId) return; - - const data = await issueReactionService.createIssueReaction( - workspaceSlug.toString(), - projectId.toString(), - issueId.toString(), - { reaction } - ); - - mutateReaction((prev: any) => [...(prev || []), data]); - }; - - /** - * @description Use this function to delete user's reaction from an issue. This function will mutate the reactions state. - * @param {string} reaction - * @example handleReactionDelete("123") // 123 -> is emoji hexa-code - */ - - const handleReactionDelete = async (reaction: string) => { - if (!workspaceSlug || !projectId || !issueId) return; - - mutateReaction( - (prevData: any) => - prevData?.filter((r: any) => r.actor !== user?.currentUser?.id || r.reaction !== reaction) || [], - false - ); - - await issueReactionService.deleteIssueReaction( - workspaceSlug.toString(), - projectId.toString(), - issueId.toString(), - reaction - ); - - mutateReaction(); - }; - - return { - isLoading: !reactions && !error, - reactions, - groupedReactions, - handleReactionCreate, - handleReactionDelete, - mutateReaction, - } as const; -}; - -export default useIssueReaction; diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/archived-issues/[archivedIssueId].tsx b/web/pages/[workspaceSlug]/projects/[projectId]/archived-issues/[archivedIssueId].tsx index 43c23eb78..d8238d372 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/archived-issues/[archivedIssueId].tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/archived-issues/[archivedIssueId].tsx @@ -9,7 +9,8 @@ import useToast from "hooks/use-toast"; // layouts import { AppLayout } from "layouts/app-layout"; // components -import { IssueDetailsSidebar, IssueMainContent } from "components/issues"; +// FIXME: have to replace this once the issue details page is ready --issue-detail-- +// import { IssueDetailsSidebar, IssueMainContent } from "components/issues"; import { ProjectArchivedIssueDetailsHeader } from "components/headers"; // ui import { ArchiveIcon, Loader } from "@plane/ui"; @@ -158,11 +159,13 @@ const ArchivedIssueDetailsPage: NextPageWithLayout = () => {
    )} -
    + {/* FIXME: have to replace this once the issue details page is ready --issue-detail-- */} + {/*
    -
    +
    */}
    -
    + {/* FIXME: have to replace this once the issue details page is ready --issue-detail-- */} + {/*
    { watch={watch} uneditable /> -
    +
    */}
    ) : ( diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx b/web/pages/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx index d6cc2b04d..6be784368 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx @@ -1,143 +1,42 @@ -import React, { useCallback, useEffect, ReactElement } from "react"; +import React, { ReactElement } from "react"; import { useRouter } from "next/router"; -import useSWR, { mutate } from "swr"; -import { useForm } from "react-hook-form"; -// services -import { IssueService } from "services/issue"; +import useSWR from "swr"; +import { observer } from "mobx-react-lite"; // layouts import { AppLayout } from "layouts/app-layout"; // components import { ProjectIssueDetailsHeader } from "components/headers"; -import { IssueDetailsSidebar, IssueMainContent } from "components/issues"; +import { IssueDetailRoot } from "components/issues"; // ui -import { EmptyState } from "components/common"; import { Loader } from "@plane/ui"; -// images -import emptyIssue from "public/empty-state/issue.svg"; // types -import { TIssue } from "@plane/types"; import { NextPageWithLayout } from "lib/types"; // fetch-keys -import { PROJECT_ISSUES_ACTIVITY, ISSUE_DETAILS } from "constants/fetch-keys"; -import { observer } from "mobx-react-lite"; import { useIssueDetail } from "hooks/store"; -const defaultValues: Partial = { - // description: "", - description_html: "", - estimate_point: null, - cycle_id: null, - module_id: null, - name: "", - priority: "low", - start_date: undefined, - state_id: "", - target_date: undefined, -}; - -// services -const issueService = new IssueService(); - const IssueDetailsPage: NextPageWithLayout = observer(() => { // router const router = useRouter(); - const { workspaceSlug, projectId, issueId: routeIssueId } = router.query; - - const { peekIssue, fetchIssue } = useIssueDetail(); - useEffect(() => { - if (!workspaceSlug || !projectId || !routeIssueId) return; - fetchIssue(workspaceSlug as string, projectId as string, routeIssueId as string); - }, [workspaceSlug, projectId, routeIssueId, fetchIssue]); - + const { workspaceSlug, projectId, issueId } = router.query; + // hooks const { - data: issueDetails, - mutate: mutateIssueDetails, - error, - } = useSWR( - workspaceSlug && projectId && peekIssue?.issueId ? ISSUE_DETAILS(peekIssue?.issueId as string) : null, - workspaceSlug && projectId && peekIssue?.issueId - ? () => issueService.retrieve(workspaceSlug as string, projectId as string, peekIssue?.issueId as string) + fetchIssue, + issue: { getIssueById }, + } = useIssueDetail(); + + const { isLoading } = useSWR( + workspaceSlug && projectId && issueId ? `ISSUE_DETAIL_${workspaceSlug}_${projectId}_${issueId}` : null, + workspaceSlug && projectId && issueId + ? () => fetchIssue(workspaceSlug.toString(), projectId.toString(), issueId.toString()) : null ); - const { reset, control, watch } = useForm({ - defaultValues, - }); - - const submitChanges = useCallback( - async (formData: Partial) => { - if (!workspaceSlug || !projectId || !peekIssue?.issueId) return; - - mutate( - ISSUE_DETAILS(peekIssue?.issueId as string), - (prevData) => { - if (!prevData) return prevData; - - return { - ...prevData, - ...formData, - }; - }, - false - ); - - const payload: Partial = { - ...formData, - }; - - // delete payload.related_issues; - // delete payload.issue_relations; - - await issueService - .patchIssue(workspaceSlug as string, projectId as string, peekIssue?.issueId as string, payload) - .then(() => { - mutateIssueDetails(); - mutate(PROJECT_ISSUES_ACTIVITY(peekIssue?.issueId as string)); - }) - .catch((e) => { - console.error(e); - }); - }, - [workspaceSlug, peekIssue?.issueId, projectId, mutateIssueDetails] - ); - - useEffect(() => { - if (!issueDetails) return; - - mutate(PROJECT_ISSUES_ACTIVITY(peekIssue?.issueId as string)); - reset({ - ...issueDetails, - }); - }, [issueDetails, reset, peekIssue?.issueId]); + const issue = getIssueById(issueId?.toString() || "") || undefined; + const issueLoader = !issue || isLoading ? true : false; return ( <> - {" "} - {error ? ( - router.push(`/${workspaceSlug}/projects/${projectId}/issues`), - }} - /> - ) : issueDetails && projectId && peekIssue?.issueId ? ( -
    -
    - -
    -
    - -
    -
    - ) : ( + {issueLoader ? (
    @@ -152,6 +51,16 @@ const IssueDetailsPage: NextPageWithLayout = observer(() => {
    + ) : ( + workspaceSlug && + projectId && + issueId && ( + + ) )} ); diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/modules/[moduleId].tsx b/web/pages/[workspaceSlug]/projects/[projectId]/modules/[moduleId].tsx index 4f9b1abb0..946041176 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/modules/[moduleId].tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/modules/[moduleId].tsx @@ -38,6 +38,7 @@ const ModuleIssuesPage: NextPageWithLayout = () => { setValue(`${!isSidebarCollapsed}`); }; + if (!workspaceSlug || !projectId || !moduleId) return <>; return ( <> {error ? ( diff --git a/web/services/issue/issue.service.ts b/web/services/issue/issue.service.ts index 1c17d9b6a..5301e368c 100644 --- a/web/services/issue/issue.service.ts +++ b/web/services/issue/issue.service.ts @@ -49,8 +49,10 @@ export class IssueService extends APIService { }); } - async retrieve(workspaceSlug: string, projectId: string, issueId: string): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/`) + async retrieve(workspaceSlug: string, projectId: string, issueId: string, queries?: any): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/`, { + params: queries, + }) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; diff --git a/web/services/issue_filter.service.ts b/web/services/issue_filter.service.ts index 8bb19c305..5103a4bc8 100644 --- a/web/services/issue_filter.service.ts +++ b/web/services/issue_filter.service.ts @@ -29,7 +29,7 @@ export class IssueFiltersService extends APIService { // } // project issue filters - async fetchProjectIssueFilters(workspaceSlug: string, projectId: string): Promise { + async fetchProjectIssueFilters(workspaceSlug: string, projectId: string): Promise { return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/user-properties/`) .then((response) => response?.data) .catch((error) => { @@ -49,7 +49,11 @@ export class IssueFiltersService extends APIService { } // cycle issue filters - async fetchCycleIssueFilters(workspaceSlug: string, projectId: string, cycleId: string): Promise { + async fetchCycleIssueFilters( + workspaceSlug: string, + projectId: string, + cycleId: string + ): Promise { return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/user-properties/`) .then((response) => response?.data) .catch((error) => { @@ -70,7 +74,11 @@ export class IssueFiltersService extends APIService { } // module issue filters - async fetchModuleIssueFilters(workspaceSlug: string, projectId: string, moduleId: string): Promise { + async fetchModuleIssueFilters( + workspaceSlug: string, + projectId: string, + moduleId: string + ): Promise { return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/user-properties/`) .then((response) => response?.data) .catch((error) => { diff --git a/web/store/issue/archived/filter.store.ts b/web/store/issue/archived/filter.store.ts index aeb71d7d2..7192cc012 100644 --- a/web/store/issue/archived/filter.store.ts +++ b/web/store/issue/archived/filter.store.ts @@ -1,4 +1,4 @@ -import { computed, makeObservable, observable, runInAction } from "mobx"; +import { action, computed, makeObservable, observable, runInAction } from "mobx"; import isEmpty from "lodash/isEmpty"; import set from "lodash/set"; // base class @@ -11,6 +11,7 @@ import { IIssueFilterOptions, IIssueDisplayFilterOptions, IIssueDisplayProperties, + TIssueKanbanFilters, IIssueFilters, TIssueParams, } from "@plane/types"; @@ -31,7 +32,7 @@ export interface IArchivedIssuesFilter { workspaceSlug: string, projectId: string, filterType: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties + filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters ) => Promise; } @@ -51,6 +52,9 @@ export class ArchivedIssuesFilter extends IssueFilterHelperStore implements IArc // computed issueFilters: computed, appliedFilters: computed, + // actions + fetchFilters: action, + updateFilters: action, }); // root store this.rootIssueStore = _rootStore; @@ -100,22 +104,18 @@ export class ArchivedIssuesFilter extends IssueFilterHelperStore implements IArc const filters: IIssueFilterOptions = this.computedFilters(_filters?.filters); const displayFilters: IIssueDisplayFilterOptions = this.computedDisplayFilters(_filters?.display_filters); const displayProperties: IIssueDisplayProperties = this.computedDisplayProperties(_filters?.display_properties); - - this.handleIssuesLocalFilters.set( - EIssuesStoreType.ARCHIVED, - EIssueFilterType.FILTERS, - workspaceSlug, - projectId, - undefined, - { - filters: filters, - } - ); + const kanbanFilters = { + group_by: [], + sub_group_by: [], + }; + kanbanFilters.group_by = _filters?.kanban_filters?.group_by || []; + kanbanFilters.sub_group_by = _filters?.kanban_filters?.sub_group_by || []; runInAction(() => { set(this.filters, [projectId, "filters"], filters); set(this.filters, [projectId, "displayFilters"], displayFilters); set(this.filters, [projectId, "displayProperties"], displayProperties); + set(this.filters, [projectId, "kanbanFilters"], kanbanFilters); }); } catch (error) { throw error; @@ -126,7 +126,7 @@ export class ArchivedIssuesFilter extends IssueFilterHelperStore implements IArc workspaceSlug: string, projectId: string, type: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties + filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters ) => { try { if (isEmpty(this.filters) || isEmpty(this.filters[projectId]) || isEmpty(filters)) return; @@ -135,6 +135,7 @@ export class ArchivedIssuesFilter extends IssueFilterHelperStore implements IArc filters: this.filters[projectId].filters as IIssueFilterOptions, displayFilters: this.filters[projectId].displayFilters as IIssueDisplayFilterOptions, displayProperties: this.filters[projectId].displayProperties as IIssueDisplayProperties, + kanbanFilters: this.filters[projectId].kanbanFilters as TIssueKanbanFilters, }; switch (type) { @@ -148,7 +149,7 @@ export class ArchivedIssuesFilter extends IssueFilterHelperStore implements IArc }); }); - this.rootIssueStore.projectIssues.fetchIssues(workspaceSlug, projectId, "mutation"); + this.rootIssueStore.archivedIssues.fetchIssues(workspaceSlug, projectId, "mutation"); this.handleIssuesLocalFilters.set(EIssuesStoreType.ARCHIVED, type, workspaceSlug, projectId, undefined, { filters: _filters.filters, }); @@ -209,6 +210,28 @@ export class ArchivedIssuesFilter extends IssueFilterHelperStore implements IArc display_properties: _filters.displayProperties, }); break; + + case EIssueFilterType.KANBAN_FILTERS: + const updatedKanbanFilters = filters as TIssueKanbanFilters; + _filters.kanbanFilters = { ..._filters.kanbanFilters, ...updatedKanbanFilters }; + + const currentUserId = this.rootIssueStore.currentUserId; + if (currentUserId) + this.handleIssuesLocalFilters.set(EIssuesStoreType.PROJECT, type, workspaceSlug, projectId, undefined, { + kanban_filters: _filters.kanbanFilters, + }); + + runInAction(() => { + Object.keys(updatedKanbanFilters).forEach((_key) => { + set( + this.filters, + [projectId, "kanbanFilters", _key], + updatedKanbanFilters[_key as keyof TIssueKanbanFilters] + ); + }); + }); + + break; default: break; } diff --git a/web/store/issue/cycle/filter.store.ts b/web/store/issue/cycle/filter.store.ts index fa933c372..7fd967d94 100644 --- a/web/store/issue/cycle/filter.store.ts +++ b/web/store/issue/cycle/filter.store.ts @@ -1,4 +1,4 @@ -import { computed, makeObservable, observable, runInAction } from "mobx"; +import { action, computed, makeObservable, observable, runInAction } from "mobx"; import isEmpty from "lodash/isEmpty"; import set from "lodash/set"; // base class @@ -11,11 +11,12 @@ import { IIssueFilterOptions, IIssueDisplayFilterOptions, IIssueDisplayProperties, + TIssueKanbanFilters, IIssueFilters, TIssueParams, } from "@plane/types"; // constants -import { EIssueFilterType } from "constants/issue"; +import { EIssueFilterType, EIssuesStoreType } from "constants/issue"; // services import { IssueFiltersService } from "services/issue_filter.service"; @@ -31,7 +32,7 @@ export interface ICycleIssuesFilter { workspaceSlug: string, projectId: string, filterType: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties, + filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters, cycleId?: string | undefined ) => Promise; } @@ -52,6 +53,9 @@ export class CycleIssuesFilter extends IssueFilterHelperStore implements ICycleI // computed issueFilters: computed, appliedFilters: computed, + // actions + fetchFilters: action, + updateFilters: action, }); // root store this.rootIssueStore = _rootStore; @@ -97,10 +101,28 @@ export class CycleIssuesFilter extends IssueFilterHelperStore implements ICycleI const displayFilters: IIssueDisplayFilterOptions = this.computedDisplayFilters(_filters?.display_filters); const displayProperties: IIssueDisplayProperties = this.computedDisplayProperties(_filters?.display_properties); + // fetching the kanban toggle helpers in the local storage + const kanbanFilters = { + group_by: [], + sub_group_by: [], + }; + const currentUserId = this.rootIssueStore.currentUserId; + if (currentUserId) { + const _kanbanFilters = this.handleIssuesLocalFilters.get( + EIssuesStoreType.CYCLE, + workspaceSlug, + cycleId, + currentUserId + ); + kanbanFilters.group_by = _kanbanFilters?.kanban_filters?.group_by || []; + kanbanFilters.sub_group_by = _kanbanFilters?.kanban_filters?.sub_group_by || []; + } + runInAction(() => { set(this.filters, [cycleId, "filters"], filters); set(this.filters, [cycleId, "displayFilters"], displayFilters); set(this.filters, [cycleId, "displayProperties"], displayProperties); + set(this.filters, [cycleId, "kanbanFilters"], kanbanFilters); }); } catch (error) { throw error; @@ -111,7 +133,7 @@ export class CycleIssuesFilter extends IssueFilterHelperStore implements ICycleI workspaceSlug: string, projectId: string, type: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties, + filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters, cycleId: string | undefined = undefined ) => { try { @@ -119,9 +141,10 @@ export class CycleIssuesFilter extends IssueFilterHelperStore implements ICycleI if (isEmpty(this.filters) || isEmpty(this.filters[projectId]) || isEmpty(filters)) return; const _filters = { - filters: this.filters[projectId].filters as IIssueFilterOptions, - displayFilters: this.filters[projectId].displayFilters as IIssueDisplayFilterOptions, - displayProperties: this.filters[projectId].displayProperties as IIssueDisplayProperties, + filters: this.filters[cycleId].filters as IIssueFilterOptions, + displayFilters: this.filters[cycleId].displayFilters as IIssueDisplayFilterOptions, + displayProperties: this.filters[cycleId].displayProperties as IIssueDisplayProperties, + kanbanFilters: this.filters[cycleId].kanbanFilters as TIssueKanbanFilters, }; switch (type) { @@ -135,7 +158,7 @@ export class CycleIssuesFilter extends IssueFilterHelperStore implements ICycleI }); }); - this.rootIssueStore.projectIssues.fetchIssues(workspaceSlug, projectId, "mutation"); + this.rootIssueStore.cycleIssues.fetchIssues(workspaceSlug, projectId, "mutation", cycleId); await this.issueFilterService.patchCycleIssueFilters(workspaceSlug, projectId, cycleId, { filters: _filters.filters, }); @@ -196,6 +219,28 @@ export class CycleIssuesFilter extends IssueFilterHelperStore implements ICycleI display_properties: _filters.displayProperties, }); break; + + case EIssueFilterType.KANBAN_FILTERS: + const updatedKanbanFilters = filters as TIssueKanbanFilters; + _filters.kanbanFilters = { ..._filters.kanbanFilters, ...updatedKanbanFilters }; + + const currentUserId = this.rootIssueStore.currentUserId; + if (currentUserId) + this.handleIssuesLocalFilters.set(EIssuesStoreType.PROJECT, type, workspaceSlug, cycleId, currentUserId, { + kanban_filters: _filters.kanbanFilters, + }); + + runInAction(() => { + Object.keys(updatedKanbanFilters).forEach((_key) => { + set( + this.filters, + [cycleId, "kanbanFilters", _key], + updatedKanbanFilters[_key as keyof TIssueKanbanFilters] + ); + }); + }); + + break; default: break; } diff --git a/web/store/issue/cycle/issue.store.ts b/web/store/issue/cycle/issue.store.ts index dcb25ca63..21fce7a76 100644 --- a/web/store/issue/cycle/issue.store.ts +++ b/web/store/issue/cycle/issue.store.ts @@ -1,5 +1,8 @@ import { action, observable, makeObservable, computed, runInAction } from "mobx"; import set from "lodash/set"; +import update from "lodash/update"; +import concat from "lodash/concat"; +import pull from "lodash/pull"; // base class import { IssueHelperStore } from "../helpers/issue-helper.store"; // services @@ -259,13 +262,17 @@ export class CycleIssues extends IssueHelperStore implements ICycleIssues { addIssueToCycle = async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => { try { runInAction(() => { - this.issues[cycleId].push(...issueIds); + update(this.issues, cycleId, (cycleIssueIds) => { + if (!cycleIssueIds) return issueIds; + else return concat(cycleIssueIds, issueIds); + }); }); + issueIds.map((issueId) => this.rootStore.issues.updateIssue(issueId, { cycle_id: cycleId })); + const issueToCycle = await this.issueService.addIssueToCycle(workspaceSlug, projectId, cycleId, { issues: issueIds, }); - return issueToCycle; } catch (error) { throw error; @@ -274,13 +281,13 @@ export class CycleIssues extends IssueHelperStore implements ICycleIssues { removeIssueFromCycle = async (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => { try { - const response = await this.issueService.removeIssueFromCycle(workspaceSlug, projectId, cycleId, issueId); + runInAction(() => { + pull(this.issues[cycleId], issueId); + }); - const issueIndex = this.issues[cycleId].findIndex((_issueId) => _issueId === issueId); - if (issueIndex >= 0) - runInAction(() => { - this.issues[cycleId].splice(issueIndex, 1); - }); + this.rootStore.issues.updateIssue(issueId, { cycle_id: null }); + + const response = await this.issueService.removeIssueFromCycle(workspaceSlug, projectId, cycleId, issueId); return response; } catch (error) { diff --git a/web/store/issue/draft/filter.store.ts b/web/store/issue/draft/filter.store.ts index 3e43eb147..a303ee90e 100644 --- a/web/store/issue/draft/filter.store.ts +++ b/web/store/issue/draft/filter.store.ts @@ -1,4 +1,4 @@ -import { computed, makeObservable, observable, runInAction } from "mobx"; +import { action, computed, makeObservable, observable, runInAction } from "mobx"; import isEmpty from "lodash/isEmpty"; import set from "lodash/set"; // base class @@ -11,6 +11,7 @@ import { IIssueFilterOptions, IIssueDisplayFilterOptions, IIssueDisplayProperties, + TIssueKanbanFilters, IIssueFilters, TIssueParams, } from "@plane/types"; @@ -31,7 +32,7 @@ export interface IDraftIssuesFilter { workspaceSlug: string, projectId: string, filterType: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties + filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters ) => Promise; } @@ -51,6 +52,9 @@ export class DraftIssuesFilter extends IssueFilterHelperStore implements IDraftI // computed issueFilters: computed, appliedFilters: computed, + // actions + fetchFilters: action, + updateFilters: action, }); // root store this.rootIssueStore = _rootStore; @@ -95,11 +99,18 @@ export class DraftIssuesFilter extends IssueFilterHelperStore implements IDraftI const filters: IIssueFilterOptions = this.computedFilters(_filters?.filters); const displayFilters: IIssueDisplayFilterOptions = this.computedDisplayFilters(_filters?.display_filters); const displayProperties: IIssueDisplayProperties = this.computedDisplayProperties(_filters?.display_properties); + const kanbanFilters = { + group_by: [], + sub_group_by: [], + }; + kanbanFilters.group_by = _filters?.kanban_filters?.group_by || []; + kanbanFilters.sub_group_by = _filters?.kanban_filters?.sub_group_by || []; runInAction(() => { set(this.filters, [projectId, "filters"], filters); set(this.filters, [projectId, "displayFilters"], displayFilters); set(this.filters, [projectId, "displayProperties"], displayProperties); + set(this.filters, [projectId, "kanbanFilters"], kanbanFilters); }); } catch (error) { throw error; @@ -110,7 +121,7 @@ export class DraftIssuesFilter extends IssueFilterHelperStore implements IDraftI workspaceSlug: string, projectId: string, type: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties + filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters ) => { try { if (isEmpty(this.filters) || isEmpty(this.filters[projectId]) || isEmpty(filters)) return; @@ -119,6 +130,7 @@ export class DraftIssuesFilter extends IssueFilterHelperStore implements IDraftI filters: this.filters[projectId].filters as IIssueFilterOptions, displayFilters: this.filters[projectId].displayFilters as IIssueDisplayFilterOptions, displayProperties: this.filters[projectId].displayProperties as IIssueDisplayProperties, + kanbanFilters: this.filters[projectId].kanbanFilters as TIssueKanbanFilters, }; switch (type) { @@ -132,7 +144,7 @@ export class DraftIssuesFilter extends IssueFilterHelperStore implements IDraftI }); }); - this.rootIssueStore.projectIssues.fetchIssues(workspaceSlug, projectId, "mutation"); + this.rootIssueStore.draftIssues.fetchIssues(workspaceSlug, projectId, "mutation"); this.handleIssuesLocalFilters.set(EIssuesStoreType.DRAFT, type, workspaceSlug, projectId, undefined, { filters: _filters.filters, }); @@ -193,6 +205,28 @@ export class DraftIssuesFilter extends IssueFilterHelperStore implements IDraftI display_properties: _filters.displayProperties, }); break; + + case EIssueFilterType.KANBAN_FILTERS: + const updatedKanbanFilters = filters as TIssueKanbanFilters; + _filters.kanbanFilters = { ..._filters.kanbanFilters, ...updatedKanbanFilters }; + + const currentUserId = this.rootIssueStore.currentUserId; + if (currentUserId) + this.handleIssuesLocalFilters.set(EIssuesStoreType.PROJECT, type, workspaceSlug, projectId, undefined, { + kanban_filters: _filters.kanbanFilters, + }); + + runInAction(() => { + Object.keys(updatedKanbanFilters).forEach((_key) => { + set( + this.filters, + [projectId, "kanbanFilters", _key], + updatedKanbanFilters[_key as keyof TIssueKanbanFilters] + ); + }); + }); + + break; default: break; } diff --git a/web/store/issue/helpers/issue-filter-helper.store.ts b/web/store/issue/helpers/issue-filter-helper.store.ts index c599e1932..55ffe3e81 100644 --- a/web/store/issue/helpers/issue-filter-helper.store.ts +++ b/web/store/issue/helpers/issue-filter-helper.store.ts @@ -6,6 +6,7 @@ import { IIssueFilterOptions, IIssueFilters, IIssueFiltersResponse, + TIssueKanbanFilters, TIssueParams, } from "@plane/types"; // constants @@ -17,7 +18,7 @@ import { storage } from "lib/local-storage"; interface ILocalStoreIssueFilters { key: EIssuesStoreType; workspaceSlug: string; - projectId: string | undefined; + viewId: string | undefined; // It can be projectId, moduleId, cycleId, projectViewId userId: string | undefined; filters: IIssueFilters; } @@ -46,6 +47,7 @@ export class IssueFilterHelperStore implements IIssueFilterHelperStore { filters: isEmpty(filters?.filters) ? undefined : filters?.filters, displayFilters: isEmpty(filters?.displayFilters) ? undefined : filters?.displayFilters, displayProperties: isEmpty(filters?.displayProperties) ? undefined : filters?.displayProperties, + kanbanFilters: isEmpty(filters?.kanbanFilters) ? undefined : filters?.kanbanFilters, }); /** @@ -157,7 +159,7 @@ export class IssueFilterHelperStore implements IIssueFilterHelperStore { get: ( currentView: EIssuesStoreType, workspaceSlug: string, - projectId: string | undefined, + viewId: string | undefined, // It can be projectId, moduleId, cycleId, projectViewId userId: string | undefined ) => { const storageFilters = this.handleIssuesLocalFilters.fetchFiltersFromStorage(); @@ -165,28 +167,28 @@ export class IssueFilterHelperStore implements IIssueFilterHelperStore { (filter: ILocalStoreIssueFilters) => filter.key === currentView && filter.workspaceSlug === workspaceSlug && - filter.projectId === projectId && + filter.viewId === viewId && filter.userId === userId ); if (!currentFilterIndex && currentFilterIndex.length < 0) return undefined; - return storageFilters[currentFilterIndex]; + return storageFilters[currentFilterIndex]?.filters || {}; }, set: ( currentView: EIssuesStoreType, filterType: EIssueFilterType, workspaceSlug: string, - projectId: string | undefined, + viewId: string | undefined, // It can be projectId, moduleId, cycleId, projectViewId userId: string | undefined, - filters: Partial + filters: Partial ) => { const storageFilters = this.handleIssuesLocalFilters.fetchFiltersFromStorage(); const currentFilterIndex = storageFilters.findIndex( (filter: ILocalStoreIssueFilters) => filter.key === currentView && filter.workspaceSlug === workspaceSlug && - filter.projectId === projectId && + filter.viewId === viewId && filter.userId === userId ); @@ -194,14 +196,17 @@ export class IssueFilterHelperStore implements IIssueFilterHelperStore { storageFilters.push({ key: currentView, workspaceSlug: workspaceSlug, - projectId: projectId, + viewId: viewId, userId: userId, filters: filters, }); else storageFilters[currentFilterIndex] = { ...storageFilters[currentFilterIndex], - [filterType]: filters, + filters: { + ...storageFilters[currentFilterIndex].filters, + [filterType]: filters[filterType], + }, }; storage.set("issue_local_filters", JSON.stringify(storageFilters)); diff --git a/web/store/issue/issue-details/issue.store.ts b/web/store/issue/issue-details/issue.store.ts index 9d36ccd86..69fdaa79f 100644 --- a/web/store/issue/issue-details/issue.store.ts +++ b/web/store/issue/issue-details/issue.store.ts @@ -49,11 +49,18 @@ export class IssueStore implements IIssueStore { // actions fetchIssue = async (workspaceSlug: string, projectId: string, issueId: string) => { try { - const issue = await this.issueService.retrieve(workspaceSlug, projectId, issueId); + const query = { + expand: "state,assignees,labels,parent", + }; + const issue = (await this.issueService.retrieve(workspaceSlug, projectId, issueId, query)) as any; if (!issue) throw new Error("Issue not found"); this.rootIssueDetailStore.rootIssueStore.issues.addIssue([issue]); + // store handlers from issue detail + if (issue && issue?.parent && issue?.parent?.id) + this.rootIssueDetailStore.rootIssueStore.issues.addIssue([issue?.parent]); + // issue reactions this.rootIssueDetailStore.reaction.fetchReactions(workspaceSlug, projectId, issueId); diff --git a/web/store/issue/issue-details/link.store.ts b/web/store/issue/issue-details/link.store.ts index a77ee7417..e5760e802 100644 --- a/web/store/issue/issue-details/link.store.ts +++ b/web/store/issue/issue-details/link.store.ts @@ -134,10 +134,10 @@ export class IssueLinkStore implements IIssueLinkStore { try { const response = await this.issueService.deleteIssueLink(workspaceSlug, projectId, issueId, linkId); - const reactionIndex = this.links[issueId].findIndex((_comment) => _comment === linkId); - if (reactionIndex >= 0) + const linkIndex = this.links[issueId].findIndex((_comment) => _comment === linkId); + if (linkIndex >= 0) runInAction(() => { - this.links[issueId].splice(reactionIndex, 1); + this.links[issueId].splice(linkIndex, 1); delete this.linkMap[linkId]; }); diff --git a/web/store/issue/issue-details/reaction.store.ts b/web/store/issue/issue-details/reaction.store.ts index bac47ccde..59c7edd7c 100644 --- a/web/store/issue/issue-details/reaction.store.ts +++ b/web/store/issue/issue-details/reaction.store.ts @@ -1,27 +1,38 @@ -import { action, computed, makeObservable, observable, runInAction } from "mobx"; +import { action, makeObservable, observable, runInAction } from "mobx"; import set from "lodash/set"; +import update from "lodash/update"; +import concat from "lodash/concat"; +import find from "lodash/find"; +import pull from "lodash/pull"; // services import { IssueReactionService } from "services/issue"; // types import { IIssueDetail } from "./root.store"; import { TIssueReaction, TIssueReactionMap, TIssueReactionIdMap } from "@plane/types"; +// helpers +import { groupReactions } from "helpers/emoji.helper"; export interface IIssueReactionStoreActions { // actions fetchReactions: (workspaceSlug: string, projectId: string, issueId: string) => Promise; createReaction: (workspaceSlug: string, projectId: string, issueId: string, reaction: string) => Promise; - removeReaction: (workspaceSlug: string, projectId: string, issueId: string, reaction: string) => Promise; + removeReaction: ( + workspaceSlug: string, + projectId: string, + issueId: string, + reaction: string, + userId: string + ) => Promise; } export interface IIssueReactionStore extends IIssueReactionStoreActions { // observables reactions: TIssueReactionIdMap; reactionMap: TIssueReactionMap; - // computed - issueReactions: string[] | undefined; // helper methods - getReactionsByIssueId: (issueId: string) => string[] | undefined; + getReactionsByIssueId: (issueId: string) => { [reaction_id: string]: string[] } | undefined; getReactionById: (reactionId: string) => TIssueReaction | undefined; + reactionsByUser: (issueId: string, userId: string) => TIssueReaction[]; } export class IssueReactionStore implements IIssueReactionStore { @@ -38,8 +49,6 @@ export class IssueReactionStore implements IIssueReactionStore { // observables reactions: observable, reactionMap: observable, - // computed - issueReactions: computed, // actions fetchReactions: action, createReaction: action, @@ -51,13 +60,6 @@ export class IssueReactionStore implements IIssueReactionStore { this.issueReactionService = new IssueReactionService(); } - // computed - get issueReactions() { - const issueId = this.rootIssueDetailStore.peekIssue?.issueId; - if (!issueId) return undefined; - return this.reactions[issueId] ?? undefined; - } - // helper methods getReactionsByIssueId = (issueId: string) => { if (!issueId) return undefined; @@ -69,13 +71,38 @@ export class IssueReactionStore implements IIssueReactionStore { return this.reactionMap[reactionId] ?? undefined; }; + reactionsByUser = (issueId: string, userId: string) => { + if (!issueId || !userId) return []; + + const reactions = this.getReactionsByIssueId(issueId); + if (!reactions) return []; + + const _userReactions: TIssueReaction[] = []; + Object.keys(reactions).forEach((reaction) => { + reactions[reaction].map((reactionId) => { + const currentReaction = this.getReactionById(reactionId); + if (currentReaction && currentReaction.actor === userId) _userReactions.push(currentReaction); + }); + }); + + return _userReactions; + }; + // actions fetchReactions = async (workspaceSlug: string, projectId: string, issueId: string) => { try { const response = await this.issueReactionService.listIssueReactions(workspaceSlug, projectId, issueId); + const groupedReactions = groupReactions(response || [], "reaction"); + + const issueReactionIdsMap: { [reaction: string]: string[] } = {}; + + Object.keys(groupedReactions).map((reactionId) => { + const reactionIds = (groupedReactions[reactionId] || []).map((reaction) => reaction.id); + issueReactionIdsMap[reactionId] = reactionIds; + }); runInAction(() => { - this.reactions[issueId] = response.map((reaction) => reaction.id); + set(this.reactions, issueId, issueReactionIdsMap); response.forEach((reaction) => set(this.reactionMap, reaction.id, reaction)); }); @@ -92,7 +119,10 @@ export class IssueReactionStore implements IIssueReactionStore { }); runInAction(() => { - this.reactions[issueId].push(response.id); + update(this.reactions, [issueId, reaction], (reactionId) => { + if (!reactionId) return [response.id]; + return concat(reactionId, response.id); + }); set(this.reactionMap, response.id, response); }); @@ -102,21 +132,28 @@ export class IssueReactionStore implements IIssueReactionStore { } }; - removeReaction = async (workspaceSlug: string, projectId: string, issueId: string, reaction: string) => { + removeReaction = async ( + workspaceSlug: string, + projectId: string, + issueId: string, + reaction: string, + userId: string + ) => { try { - const reactionIndex = this.reactions[issueId].findIndex((_reaction) => _reaction === reaction); - if (reactionIndex >= 0) + const userReactions = this.reactionsByUser(issueId, userId); + const currentReaction = find(userReactions, { actor: userId, reaction: reaction }); + + if (currentReaction && currentReaction.id) { runInAction(() => { - this.reactions[issueId].splice(reactionIndex, 1); + pull(this.reactions[issueId][reaction], currentReaction.id); delete this.reactionMap[reaction]; }); + } const response = await this.issueReactionService.deleteIssueReaction(workspaceSlug, projectId, issueId, reaction); return response; } catch (error) { - // TODO: Replace with fetch issue details - // this.fetchReactions(workspaceSlug, projectId, issueId); throw error; } }; diff --git a/web/store/issue/issue-details/root.store.ts b/web/store/issue/issue-details/root.store.ts index 67aa4b46f..88b2e2958 100644 --- a/web/store/issue/issue-details/root.store.ts +++ b/web/store/issue/issue-details/root.store.ts @@ -142,8 +142,13 @@ export class IssueDetail implements IIssueDetail { this.reaction.fetchReactions(workspaceSlug, projectId, issueId); createReaction = async (workspaceSlug: string, projectId: string, issueId: string, reaction: string) => this.reaction.createReaction(workspaceSlug, projectId, issueId, reaction); - removeReaction = async (workspaceSlug: string, projectId: string, issueId: string, reaction: string) => - this.reaction.removeReaction(workspaceSlug, projectId, issueId, reaction); + removeReaction = async ( + workspaceSlug: string, + projectId: string, + issueId: string, + reaction: string, + userId: string + ) => this.reaction.removeReaction(workspaceSlug, projectId, issueId, reaction, userId); // activity fetchActivities = async (workspaceSlug: string, projectId: string, issueId: string) => @@ -198,6 +203,15 @@ export class IssueDetail implements IIssueDetail { this.subIssues.fetchSubIssues(workspaceSlug, projectId, issueId); createSubIssues = async (workspaceSlug: string, projectId: string, issueId: string, data: string[]) => this.subIssues.createSubIssues(workspaceSlug, projectId, issueId, data); + updateSubIssue = async ( + workspaceSlug: string, + projectId: string, + parentIssueId: string, + issueId: string, + data: { oldParentId: string; newParentId: string } + ) => this.subIssues.updateSubIssue(workspaceSlug, projectId, parentIssueId, issueId, data); + removeSubIssue = async (workspaceSlug: string, projectId: string, parentIssueId: string, issueIds: string[]) => + this.subIssues.removeSubIssue(workspaceSlug, projectId, parentIssueId, issueIds); // subscription fetchSubscriptions = async (workspaceSlug: string, projectId: string, issueId: string) => diff --git a/web/store/issue/issue-details/sub_issues.store.ts b/web/store/issue/issue-details/sub_issues.store.ts index e74c647fa..d578e20f0 100644 --- a/web/store/issue/issue-details/sub_issues.store.ts +++ b/web/store/issue/issue-details/sub_issues.store.ts @@ -20,6 +20,19 @@ export interface IIssueSubIssuesStoreActions { issueId: string, data: string[] ) => Promise; + updateSubIssue: ( + workspaceSlug: string, + projectId: string, + parentIssueId: string, + issueId: string, + data: { oldParentId: string; newParentId: string } + ) => any; + removeSubIssue: ( + workspaceSlug: string, + projectId: string, + parentIssueId: string, + issueIds: string[] + ) => Promise; } export interface IIssueSubIssuesStore extends IIssueSubIssuesStoreActions { @@ -48,6 +61,8 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore { // actions fetchSubIssues: action, createSubIssues: action, + updateSubIssue: action, + removeSubIssue: action, }); // root store this.rootIssueDetailStore = rootStore; @@ -113,4 +128,53 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore { throw error; } }; + + updateSubIssue = async ( + workspaceSlug: string, + projectId: string, + parentIssueId: string, + issueId: string, + data: { oldParentId: string; newParentId: string } + ) => { + try { + const oldIssueParentId = data.oldParentId; + const newIssueParentId = data.newParentId; + + // const issue = this.rootIssueDetailStore.rootIssueStore.issues.getIssueById(issueId); + + // runInAction(() => { + // Object.keys(subIssuesStateDistribution).forEach((key) => { + // const stateGroup = key as keyof TSubIssuesStateDistribution; + // set(this.subIssuesStateDistribution, [issueId, key], subIssuesStateDistribution[stateGroup]); + // }); + // set(this.subIssuesStateDistribution, issueId, data); + // }); + + return {} as any; + } catch (error) { + throw error; + } + }; + + removeSubIssue = async (workspaceSlug: string, projectId: string, issueId: string, data: string[]) => { + try { + const response = await this.issueService.addSubIssues(workspaceSlug, projectId, issueId, { sub_issue_ids: data }); + const subIssuesStateDistribution = response?.state_distribution; + const subIssues = response.sub_issues as TIssue[]; + + this.rootIssueDetailStore.rootIssueStore.issues.addIssue(subIssues); + + runInAction(() => { + Object.keys(subIssuesStateDistribution).forEach((key) => { + const stateGroup = key as keyof TSubIssuesStateDistribution; + set(this.subIssuesStateDistribution, [issueId, key], subIssuesStateDistribution[stateGroup]); + }); + set(this.subIssuesStateDistribution, issueId, data); + }); + + return response; + } catch (error) { + throw error; + } + }; } diff --git a/web/store/issue/module/filter.store.ts b/web/store/issue/module/filter.store.ts index 7819ad6e0..d7d4d56da 100644 --- a/web/store/issue/module/filter.store.ts +++ b/web/store/issue/module/filter.store.ts @@ -1,4 +1,4 @@ -import { computed, makeObservable, observable, runInAction } from "mobx"; +import { action, computed, makeObservable, observable, runInAction } from "mobx"; import isEmpty from "lodash/isEmpty"; import set from "lodash/set"; // base class @@ -11,11 +11,12 @@ import { IIssueFilterOptions, IIssueDisplayFilterOptions, IIssueDisplayProperties, + TIssueKanbanFilters, IIssueFilters, TIssueParams, } from "@plane/types"; // constants -import { EIssueFilterType } from "constants/issue"; +import { EIssueFilterType, EIssuesStoreType } from "constants/issue"; // services import { IssueFiltersService } from "services/issue_filter.service"; @@ -31,7 +32,7 @@ export interface IModuleIssuesFilter { workspaceSlug: string, projectId: string, filterType: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties, + filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters, moduleId?: string | undefined ) => Promise; } @@ -52,6 +53,9 @@ export class ModuleIssuesFilter extends IssueFilterHelperStore implements IModul // computed issueFilters: computed, appliedFilters: computed, + // actions + fetchFilters: action, + updateFilters: action, }); // root store this.rootIssueStore = _rootStore; @@ -97,10 +101,28 @@ export class ModuleIssuesFilter extends IssueFilterHelperStore implements IModul const displayFilters: IIssueDisplayFilterOptions = this.computedDisplayFilters(_filters?.display_filters); const displayProperties: IIssueDisplayProperties = this.computedDisplayProperties(_filters?.display_properties); + // fetching the kanban toggle helpers in the local storage + const kanbanFilters = { + group_by: [], + sub_group_by: [], + }; + const currentUserId = this.rootIssueStore.currentUserId; + if (currentUserId) { + const _kanbanFilters = this.handleIssuesLocalFilters.get( + EIssuesStoreType.MODULE, + workspaceSlug, + moduleId, + currentUserId + ); + kanbanFilters.group_by = _kanbanFilters?.kanban_filters?.group_by || []; + kanbanFilters.sub_group_by = _kanbanFilters?.kanban_filters?.sub_group_by || []; + } + runInAction(() => { set(this.filters, [moduleId, "filters"], filters); set(this.filters, [moduleId, "displayFilters"], displayFilters); set(this.filters, [moduleId, "displayProperties"], displayProperties); + set(this.filters, [moduleId, "kanbanFilters"], kanbanFilters); }); } catch (error) { throw error; @@ -111,17 +133,18 @@ export class ModuleIssuesFilter extends IssueFilterHelperStore implements IModul workspaceSlug: string, projectId: string, type: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties, + filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters, moduleId: string | undefined = undefined ) => { try { if (!moduleId) throw new Error("Module id is required"); - if (isEmpty(this.filters) || isEmpty(this.filters[projectId]) || isEmpty(filters)) return; + if (isEmpty(this.filters) || isEmpty(this.filters[moduleId]) || isEmpty(filters)) return; const _filters = { - filters: this.filters[projectId].filters as IIssueFilterOptions, - displayFilters: this.filters[projectId].displayFilters as IIssueDisplayFilterOptions, - displayProperties: this.filters[projectId].displayProperties as IIssueDisplayProperties, + filters: this.filters[moduleId].filters as IIssueFilterOptions, + displayFilters: this.filters[moduleId].displayFilters as IIssueDisplayFilterOptions, + displayProperties: this.filters[moduleId].displayProperties as IIssueDisplayProperties, + kanbanFilters: this.filters[moduleId].kanbanFilters as TIssueKanbanFilters, }; switch (type) { @@ -135,7 +158,7 @@ export class ModuleIssuesFilter extends IssueFilterHelperStore implements IModul }); }); - this.rootIssueStore.projectIssues.fetchIssues(workspaceSlug, projectId, "mutation"); + this.rootIssueStore.moduleIssues.fetchIssues(workspaceSlug, projectId, "mutation", moduleId); await this.issueFilterService.patchModuleIssueFilters(workspaceSlug, projectId, moduleId, { filters: _filters.filters, }); @@ -196,6 +219,28 @@ export class ModuleIssuesFilter extends IssueFilterHelperStore implements IModul display_properties: _filters.displayProperties, }); break; + + case EIssueFilterType.KANBAN_FILTERS: + const updatedKanbanFilters = filters as TIssueKanbanFilters; + _filters.kanbanFilters = { ..._filters.kanbanFilters, ...updatedKanbanFilters }; + + const currentUserId = this.rootIssueStore.currentUserId; + if (currentUserId) + this.handleIssuesLocalFilters.set(EIssuesStoreType.PROJECT, type, workspaceSlug, moduleId, currentUserId, { + kanban_filters: _filters.kanbanFilters, + }); + + runInAction(() => { + Object.keys(updatedKanbanFilters).forEach((_key) => { + set( + this.filters, + [moduleId, "kanbanFilters", _key], + updatedKanbanFilters[_key as keyof TIssueKanbanFilters] + ); + }); + }); + + break; default: break; } diff --git a/web/store/issue/module/issue.store.ts b/web/store/issue/module/issue.store.ts index 3928b2554..80874abe3 100644 --- a/web/store/issue/module/issue.store.ts +++ b/web/store/issue/module/issue.store.ts @@ -1,5 +1,8 @@ import { action, observable, makeObservable, computed, runInAction } from "mobx"; import set from "lodash/set"; +import update from "lodash/update"; +import concat from "lodash/concat"; +import pull from "lodash/pull"; // base class import { IssueHelperStore } from "../helpers/issue-helper.store"; // services @@ -250,9 +253,14 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues { addIssueToModule = async (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => { try { runInAction(() => { - this.issues[moduleId].push(...issueIds); + update(this.issues, moduleId, (moduleIssueIds) => { + if (!moduleIssueIds) return issueIds; + else return concat(moduleIssueIds, issueIds); + }); }); + issueIds.map((issueId) => this.rootStore.issues.updateIssue(issueId, { module_id: moduleId })); + const issueToModule = await this.moduleService.addIssuesToModule(workspaceSlug, projectId, moduleId, { issues: issueIds, }); @@ -265,13 +273,13 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues { removeIssueFromModule = async (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) => { try { - const response = await this.moduleService.removeIssueFromModule(workspaceSlug, projectId, moduleId, issueId); + runInAction(() => { + pull(this.issues[moduleId], issueId); + }); - const issueIndex = this.issues[moduleId].findIndex((_issueId) => _issueId === issueId); - if (issueIndex >= 0) - runInAction(() => { - this.issues[moduleId].splice(issueIndex, 1); - }); + this.rootStore.issues.updateIssue(issueId, { module_id: null }); + + const response = await this.moduleService.removeIssueFromModule(workspaceSlug, projectId, moduleId, issueId); return response; } catch (error) { diff --git a/web/store/issue/profile/filter.store.ts b/web/store/issue/profile/filter.store.ts index e14b7179d..fe30a1c58 100644 --- a/web/store/issue/profile/filter.store.ts +++ b/web/store/issue/profile/filter.store.ts @@ -1,4 +1,4 @@ -import { computed, makeObservable, observable, runInAction } from "mobx"; +import { action, computed, makeObservable, observable, runInAction } from "mobx"; import isEmpty from "lodash/isEmpty"; import set from "lodash/set"; // base class @@ -11,6 +11,7 @@ import { IIssueFilterOptions, IIssueDisplayFilterOptions, IIssueDisplayProperties, + TIssueKanbanFilters, IIssueFilters, TIssueParams, } from "@plane/types"; @@ -32,7 +33,7 @@ export interface IProfileIssuesFilter { workspaceSlug: string, projectId: string | undefined, filterType: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties, + filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters, userId?: string | undefined ) => Promise; } @@ -55,6 +56,9 @@ export class ProfileIssuesFilter extends IssueFilterHelperStore implements IProf // computed issueFilters: computed, appliedFilters: computed, + // actions + fetchFilters: action, + updateFilters: action, }); // root store this.rootIssueStore = _rootStore; @@ -100,11 +104,16 @@ export class ProfileIssuesFilter extends IssueFilterHelperStore implements IProf const filters: IIssueFilterOptions = this.computedFilters(_filters?.filters); const displayFilters: IIssueDisplayFilterOptions = this.computedDisplayFilters(_filters?.display_filters); const displayProperties: IIssueDisplayProperties = this.computedDisplayProperties(_filters?.display_properties); + const kanbanFilters = { + group_by: _filters?.kanban_filters?.group_by || [], + sub_group_by: _filters?.kanban_filters?.sub_group_by || [], + }; runInAction(() => { set(this.filters, [userId, "filters"], filters); set(this.filters, [userId, "displayFilters"], displayFilters); set(this.filters, [userId, "displayProperties"], displayProperties); + set(this.filters, [userId, "kanbanFilters"], kanbanFilters); }); } catch (error) { throw error; @@ -115,7 +124,7 @@ export class ProfileIssuesFilter extends IssueFilterHelperStore implements IProf workspaceSlug: string, projectId: string | undefined, type: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties, + filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters, userId: string | undefined = undefined ) => { try { @@ -127,6 +136,7 @@ export class ProfileIssuesFilter extends IssueFilterHelperStore implements IProf filters: this.filters[userId].filters as IIssueFilterOptions, displayFilters: this.filters[userId].displayFilters as IIssueDisplayFilterOptions, displayProperties: this.filters[userId].displayProperties as IIssueDisplayProperties, + kanbanFilters: this.filters[userId].kanbanFilters as TIssueKanbanFilters, }; switch (type) { @@ -140,7 +150,13 @@ export class ProfileIssuesFilter extends IssueFilterHelperStore implements IProf }); }); - this.rootIssueStore.projectIssues.fetchIssues(workspaceSlug, userId, "mutation"); + this.rootIssueStore.profileIssues.fetchIssues( + workspaceSlug, + undefined, + "mutation", + userId, + this.rootIssueStore.profileIssues.currentView + ); this.handleIssuesLocalFilters.set(EIssuesStoreType.PROFILE, type, workspaceSlug, userId, undefined, { filters: _filters.filters, }); @@ -201,6 +217,28 @@ export class ProfileIssuesFilter extends IssueFilterHelperStore implements IProf display_properties: _filters.displayProperties, }); break; + + case EIssueFilterType.KANBAN_FILTERS: + const updatedKanbanFilters = filters as TIssueKanbanFilters; + _filters.kanbanFilters = { ..._filters.kanbanFilters, ...updatedKanbanFilters }; + + const currentUserId = this.rootIssueStore.currentUserId; + if (currentUserId) + this.handleIssuesLocalFilters.set(EIssuesStoreType.PROJECT, type, workspaceSlug, userId, undefined, { + kanban_filters: _filters.kanbanFilters, + }); + + runInAction(() => { + Object.keys(updatedKanbanFilters).forEach((_key) => { + set( + this.filters, + [userId, "kanbanFilters", _key], + updatedKanbanFilters[_key as keyof TIssueKanbanFilters] + ); + }); + }); + + break; default: break; } diff --git a/web/store/issue/project-views/filter.store.ts b/web/store/issue/project-views/filter.store.ts index 5d0ec332b..83bc2e79e 100644 --- a/web/store/issue/project-views/filter.store.ts +++ b/web/store/issue/project-views/filter.store.ts @@ -1,4 +1,4 @@ -import { computed, makeObservable, observable, runInAction } from "mobx"; +import { action, computed, makeObservable, observable, runInAction } from "mobx"; import isEmpty from "lodash/isEmpty"; import set from "lodash/set"; // base class @@ -11,11 +11,12 @@ import { IIssueFilterOptions, IIssueDisplayFilterOptions, IIssueDisplayProperties, + TIssueKanbanFilters, IIssueFilters, TIssueParams, } from "@plane/types"; // constants -import { EIssueFilterType } from "constants/issue"; +import { EIssueFilterType, EIssuesStoreType } from "constants/issue"; // services import { ViewService } from "services/view.service"; @@ -31,7 +32,7 @@ export interface IProjectViewIssuesFilter { workspaceSlug: string, projectId: string, filterType: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties, + filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters, viewId?: string | undefined ) => Promise; } @@ -52,6 +53,9 @@ export class ProjectViewIssuesFilter extends IssueFilterHelperStore implements I // computed issueFilters: computed, appliedFilters: computed, + // actions + fetchFilters: action, + updateFilters: action, }); // root store this.rootIssueStore = _rootStore; @@ -97,10 +101,28 @@ export class ProjectViewIssuesFilter extends IssueFilterHelperStore implements I const displayFilters: IIssueDisplayFilterOptions = this.computedDisplayFilters(_filters?.display_filters); const displayProperties: IIssueDisplayProperties = this.computedDisplayProperties(_filters?.display_properties); + // fetching the kanban toggle helpers in the local storage + const kanbanFilters = { + group_by: [], + sub_group_by: [], + }; + const currentUserId = this.rootIssueStore.currentUserId; + if (currentUserId) { + const _kanbanFilters = this.handleIssuesLocalFilters.get( + EIssuesStoreType.PROJECT_VIEW, + workspaceSlug, + viewId, + currentUserId + ); + kanbanFilters.group_by = _kanbanFilters?.kanban_filters?.group_by || []; + kanbanFilters.sub_group_by = _kanbanFilters?.kanban_filters?.sub_group_by || []; + } + runInAction(() => { set(this.filters, [viewId, "filters"], filters); set(this.filters, [viewId, "displayFilters"], displayFilters); set(this.filters, [viewId, "displayProperties"], displayProperties); + set(this.filters, [viewId, "kanbanFilters"], kanbanFilters); }); } catch (error) { throw error; @@ -111,7 +133,7 @@ export class ProjectViewIssuesFilter extends IssueFilterHelperStore implements I workspaceSlug: string, projectId: string, type: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties, + filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters, viewId: string | undefined = undefined ) => { try { @@ -120,9 +142,10 @@ export class ProjectViewIssuesFilter extends IssueFilterHelperStore implements I if (isEmpty(this.filters) || isEmpty(this.filters[projectId]) || isEmpty(filters)) return; const _filters = { - filters: this.filters[projectId].filters as IIssueFilterOptions, - displayFilters: this.filters[projectId].displayFilters as IIssueDisplayFilterOptions, - displayProperties: this.filters[projectId].displayProperties as IIssueDisplayProperties, + filters: this.filters[viewId].filters as IIssueFilterOptions, + displayFilters: this.filters[viewId].displayFilters as IIssueDisplayFilterOptions, + displayProperties: this.filters[viewId].displayProperties as IIssueDisplayProperties, + kanbanFilters: this.filters[viewId].kanbanFilters as TIssueKanbanFilters, }; switch (type) { @@ -136,7 +159,7 @@ export class ProjectViewIssuesFilter extends IssueFilterHelperStore implements I }); }); - this.rootIssueStore.projectIssues.fetchIssues(workspaceSlug, projectId, "mutation"); + this.rootIssueStore.projectViewIssues.fetchIssues(workspaceSlug, projectId, "mutation", viewId); await this.issueFilterService.patchView(workspaceSlug, projectId, viewId, { filters: _filters.filters, }); @@ -197,6 +220,28 @@ export class ProjectViewIssuesFilter extends IssueFilterHelperStore implements I display_properties: _filters.displayProperties, }); break; + + case EIssueFilterType.KANBAN_FILTERS: + const updatedKanbanFilters = filters as TIssueKanbanFilters; + _filters.kanbanFilters = { ..._filters.kanbanFilters, ...updatedKanbanFilters }; + + const currentUserId = this.rootIssueStore.currentUserId; + if (currentUserId) + this.handleIssuesLocalFilters.set(EIssuesStoreType.PROJECT, type, workspaceSlug, viewId, currentUserId, { + kanban_filters: _filters.kanbanFilters, + }); + + runInAction(() => { + Object.keys(updatedKanbanFilters).forEach((_key) => { + set( + this.filters, + [viewId, "kanbanFilters", _key], + updatedKanbanFilters[_key as keyof TIssueKanbanFilters] + ); + }); + }); + + break; default: break; } diff --git a/web/store/issue/project/filter.store.ts b/web/store/issue/project/filter.store.ts index 2b47e4187..83a95aa6d 100644 --- a/web/store/issue/project/filter.store.ts +++ b/web/store/issue/project/filter.store.ts @@ -1,4 +1,4 @@ -import { computed, makeObservable, observable, runInAction } from "mobx"; +import { action, computed, makeObservable, observable, runInAction } from "mobx"; import isEmpty from "lodash/isEmpty"; import set from "lodash/set"; // base class @@ -11,11 +11,12 @@ import { IIssueFilterOptions, IIssueDisplayFilterOptions, IIssueDisplayProperties, + TIssueKanbanFilters, IIssueFilters, TIssueParams, } from "@plane/types"; // constants -import { EIssueFilterType } from "constants/issue"; +import { EIssueFilterType, EIssuesStoreType } from "constants/issue"; // services import { IssueFiltersService } from "services/issue_filter.service"; @@ -31,7 +32,7 @@ export interface IProjectIssuesFilter { workspaceSlug: string, projectId: string, filterType: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties + filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters ) => Promise; } @@ -51,6 +52,9 @@ export class ProjectIssuesFilter extends IssueFilterHelperStore implements IProj // computed issueFilters: computed, appliedFilters: computed, + // actions + fetchFilters: action, + updateFilters: action, }); // root store this.rootIssueStore = _rootStore; @@ -92,14 +96,32 @@ export class ProjectIssuesFilter extends IssueFilterHelperStore implements IProj try { const _filters = await this.issueFilterService.fetchProjectIssueFilters(workspaceSlug, projectId); - const filters: IIssueFilterOptions = this.computedFilters(_filters?.filters); - const displayFilters: IIssueDisplayFilterOptions = this.computedDisplayFilters(_filters?.display_filters); - const displayProperties: IIssueDisplayProperties = this.computedDisplayProperties(_filters?.display_properties); + const filters = this.computedFilters(_filters?.filters); + const displayFilters = this.computedDisplayFilters(_filters?.display_filters); + const displayProperties = this.computedDisplayProperties(_filters?.display_properties); + + // fetching the kanban toggle helpers in the local storage + const kanbanFilters = { + group_by: [], + sub_group_by: [], + }; + const currentUserId = this.rootIssueStore.currentUserId; + if (currentUserId) { + const _kanbanFilters = this.handleIssuesLocalFilters.get( + EIssuesStoreType.PROJECT, + workspaceSlug, + projectId, + currentUserId + ); + kanbanFilters.group_by = _kanbanFilters?.kanban_filters?.group_by || []; + kanbanFilters.sub_group_by = _kanbanFilters?.kanban_filters?.sub_group_by || []; + } runInAction(() => { set(this.filters, [projectId, "filters"], filters); set(this.filters, [projectId, "displayFilters"], displayFilters); set(this.filters, [projectId, "displayProperties"], displayProperties); + set(this.filters, [projectId, "kanbanFilters"], kanbanFilters); }); } catch (error) { throw error; @@ -110,7 +132,7 @@ export class ProjectIssuesFilter extends IssueFilterHelperStore implements IProj workspaceSlug: string, projectId: string, type: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties + filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters ) => { try { if (isEmpty(this.filters) || isEmpty(this.filters[projectId]) || isEmpty(filters)) return; @@ -119,6 +141,7 @@ export class ProjectIssuesFilter extends IssueFilterHelperStore implements IProj filters: this.filters[projectId].filters as IIssueFilterOptions, displayFilters: this.filters[projectId].displayFilters as IIssueDisplayFilterOptions, displayProperties: this.filters[projectId].displayProperties as IIssueDisplayProperties, + kanbanFilters: this.filters[projectId].kanbanFilters as TIssueKanbanFilters, }; switch (type) { @@ -193,6 +216,28 @@ export class ProjectIssuesFilter extends IssueFilterHelperStore implements IProj display_properties: _filters.displayProperties, }); break; + + case EIssueFilterType.KANBAN_FILTERS: + const updatedKanbanFilters = filters as TIssueKanbanFilters; + _filters.kanbanFilters = { ..._filters.kanbanFilters, ...updatedKanbanFilters }; + + const currentUserId = this.rootIssueStore.currentUserId; + if (currentUserId) + this.handleIssuesLocalFilters.set(EIssuesStoreType.PROJECT, type, workspaceSlug, projectId, currentUserId, { + kanban_filters: _filters.kanbanFilters, + }); + + runInAction(() => { + Object.keys(updatedKanbanFilters).forEach((_key) => { + set( + this.filters, + [projectId, "kanbanFilters", _key], + updatedKanbanFilters[_key as keyof TIssueKanbanFilters] + ); + }); + }); + + break; default: break; } diff --git a/web/store/issue/project/issue.store.ts b/web/store/issue/project/issue.store.ts index 3b45fd9cb..056bb5ebb 100644 --- a/web/store/issue/project/issue.store.ts +++ b/web/store/issue/project/issue.store.ts @@ -134,8 +134,6 @@ export class ProjectIssues extends IssueHelperStore implements IProjectIssues { updateIssue = async (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => { try { - if (!issueId || !this.issues[projectId]) return; - this.rootStore.issues.updateIssue(issueId, data); const response = await this.issueService.patchIssue(workspaceSlug, projectId, issueId, data); @@ -148,8 +146,6 @@ export class ProjectIssues extends IssueHelperStore implements IProjectIssues { removeIssue = async (workspaceSlug: string, projectId: string, issueId: string) => { try { - if (!issueId || !this.issues[projectId]) return; - const response = await this.issueService.deleteIssue(workspaceSlug, projectId, issueId); const issueIndex = this.issues[projectId].findIndex((_issueId) => _issueId === issueId); diff --git a/web/store/issue/root.store.ts b/web/store/issue/root.store.ts index 06efaebbb..edde040c4 100644 --- a/web/store/issue/root.store.ts +++ b/web/store/issue/root.store.ts @@ -86,6 +86,8 @@ export class IssueRootStore implements IIssueRootStore { issues: IIssueStore; + issueDetail: IIssueDetail; + workspaceIssuesFilter: IWorkspaceIssuesFilter; workspaceIssues: IWorkspaceIssues; @@ -113,8 +115,6 @@ export class IssueRootStore implements IIssueRootStore { issueKanBanView: IIssueKanBanViewStore; issueCalendarView: ICalendarStore; - issueDetail: IIssueDetail; - constructor(rootStore: RootStore) { makeObservable(this, { workspaceSlug: observable.ref, diff --git a/web/store/issue/workspace/filter.store.ts b/web/store/issue/workspace/filter.store.ts index 3f6aa97b9..4825f593c 100644 --- a/web/store/issue/workspace/filter.store.ts +++ b/web/store/issue/workspace/filter.store.ts @@ -1,4 +1,4 @@ -import { computed, makeObservable, observable, runInAction } from "mobx"; +import { action, computed, makeObservable, observable, runInAction } from "mobx"; import isEmpty from "lodash/isEmpty"; import set from "lodash/set"; // base class @@ -11,9 +11,9 @@ import { IIssueFilterOptions, IIssueDisplayFilterOptions, IIssueDisplayProperties, + TIssueKanbanFilters, IIssueFilters, TIssueParams, - IIssueFiltersResponse, } from "@plane/types"; // constants import { EIssueFilterType, EIssuesStoreType } from "constants/issue"; @@ -33,7 +33,7 @@ export interface IWorkspaceIssuesFilter { workspaceSlug: string, projectId: string | undefined, filterType: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties, + filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters, viewId?: string | undefined ) => Promise; } @@ -54,6 +54,9 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo // computed issueFilters: computed, appliedFilters: computed, + // actions + fetchFilters: action, + updateFilters: action, }); // root store this.rootIssueStore = _rootStore; @@ -93,19 +96,35 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo fetchFilters = async (workspaceSlug: string, viewId: TWorkspaceFilters) => { try { - let _filters: IIssueFiltersResponse; - if (["all-issues", "assigned", "created", "subscribed"].includes(viewId)) - _filters = this.handleIssuesLocalFilters.get(EIssuesStoreType.GLOBAL, workspaceSlug, undefined, viewId); - else _filters = await this.issueFilterService.getViewDetails(workspaceSlug, viewId); + let filters: IIssueFilterOptions; + let displayFilters: IIssueDisplayFilterOptions; + let displayProperties: IIssueDisplayProperties; + let kanbanFilters: TIssueKanbanFilters = { + group_by: [], + sub_group_by: [], + }; - const filters: IIssueFilterOptions = this.computedFilters(_filters?.filters); - const displayFilters: IIssueDisplayFilterOptions = this.computedDisplayFilters(_filters?.display_filters); - const displayProperties: IIssueDisplayProperties = this.computedDisplayProperties(_filters?.display_properties); + const _filters = this.handleIssuesLocalFilters.get(EIssuesStoreType.GLOBAL, workspaceSlug, undefined, viewId); + filters = this.computedFilters(_filters?.filters); + displayFilters = this.computedDisplayFilters(_filters?.displayFilters); + displayProperties = this.computedDisplayProperties(_filters?.displayProperties); + kanbanFilters = { + group_by: _filters?.kanbanFilters?.group_by || [], + sub_group_by: _filters?.kanbanFilters?.sub_group_by || [], + }; + + if (!["all-issues", "assigned", "created", "subscribed"].includes(viewId)) { + const _filters = await this.issueFilterService.getViewDetails(workspaceSlug, viewId); + filters = this.computedFilters(_filters?.filters); + displayFilters = this.computedDisplayFilters(_filters?.display_filters); + displayProperties = this.computedDisplayProperties(_filters?.display_properties); + } runInAction(() => { set(this.filters, [viewId, "filters"], filters); set(this.filters, [viewId, "displayFilters"], displayFilters); set(this.filters, [viewId, "displayProperties"], displayProperties); + set(this.filters, [viewId, "kanbanFilters"], kanbanFilters); }); } catch (error) { throw error; @@ -116,7 +135,7 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo workspaceSlug: string, projectId: string | undefined, type: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties, + filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters, viewId: string | undefined = undefined ) => { try { @@ -128,6 +147,7 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo filters: this.filters[viewId].filters as IIssueFilterOptions, displayFilters: this.filters[viewId].displayFilters as IIssueDisplayFilterOptions, displayProperties: this.filters[viewId].displayProperties as IIssueDisplayProperties, + kanbanFilters: this.filters[viewId].kanbanFilters as TIssueKanbanFilters, }; switch (type) { @@ -141,8 +161,7 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo }); }); - this.rootIssueStore.projectIssues.fetchIssues(workspaceSlug, viewId, "mutation"); - + this.rootIssueStore.workspaceIssues.fetchIssues(workspaceSlug, viewId, "mutation"); if (["all-issues", "assigned", "created", "subscribed"].includes(viewId)) this.handleIssuesLocalFilters.set(EIssuesStoreType.GLOBAL, type, workspaceSlug, undefined, viewId, { filters: _filters.filters, @@ -216,6 +235,28 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo display_properties: _filters.displayProperties, }); break; + + case EIssueFilterType.KANBAN_FILTERS: + const updatedKanbanFilters = filters as TIssueKanbanFilters; + _filters.kanbanFilters = { ..._filters.kanbanFilters, ...updatedKanbanFilters }; + + const currentUserId = this.rootIssueStore.currentUserId; + if (currentUserId) + this.handleIssuesLocalFilters.set(EIssuesStoreType.PROJECT, type, workspaceSlug, undefined, viewId, { + kanban_filters: _filters.kanbanFilters, + }); + + runInAction(() => { + Object.keys(updatedKanbanFilters).forEach((_key) => { + set( + this.filters, + [viewId, "kanbanFilters", _key], + updatedKanbanFilters[_key as keyof TIssueKanbanFilters] + ); + }); + }); + + break; default: break; } diff --git a/yarn.lock b/yarn.lock index aea383166..4318bee68 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1928,11 +1928,6 @@ "@radix-ui/react-primitive" "1.0.0" "@radix-ui/react-use-callback-ref" "1.0.0" -"@radix-ui/react-icons@^1.3.0": - version "1.3.0" - resolved "https://registry.yarnpkg.com/@radix-ui/react-icons/-/react-icons-1.3.0.tgz#c61af8f323d87682c5ca76b856d60c2312dbcb69" - integrity sha512-jQxj/0LKgp+j9BiTXz3O3sgs26RNet2iLWmsPyRz2SIcR4q/4SbazXfnYwbAr+vLYKSfc7qxzyGQA1HLlYiuNw== - "@radix-ui/react-id@1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.0.0.tgz#8d43224910741870a45a8c9d092f25887bb6d11e" @@ -2807,7 +2802,7 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@^18.2.42": +"@types/react@*", "@types/react@18.2.42", "@types/react@^18.2.42": version "18.2.42" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.42.tgz#6f6b11a904f6d96dda3c2920328a97011a00aba7" integrity sha512-c1zEr96MjakLYus/wPnuWDo1/zErfdU9rNsIGmE+NV71nx88FG9Ttgo5dqorXTu/LImX2f63WBP986gJkMPNbA== From df97b35a99dc3fa3819f755ecf1c00202057ff7f Mon Sep 17 00:00:00 2001 From: rahulramesha <71900764+rahulramesha@users.noreply.github.com> Date: Thu, 11 Jan 2024 18:19:19 +0530 Subject: [PATCH 25/68] chore: Refactor Spreadsheet view for better code maintainability and performance (#3322) * refcator spreadsheet to use table and roow based approach rather than column based * update spreadsheet and optimized layout * fix issues in spread sheet * close quick action menu on click --------- Co-authored-by: Rahul R --- packages/ui/package.json | 1 + packages/ui/src/dropdowns/custom-menu.tsx | 60 +++-- packages/ui/src/dropdowns/helper.tsx | 2 + .../issue-layouts/list/list-view-types.d.ts | 1 + .../quick-action-dropdowns/all-issue.tsx | 18 +- .../quick-action-dropdowns/archived-issue.tsx | 14 +- .../quick-action-dropdowns/cycle-issue.tsx | 20 +- .../quick-action-dropdowns/module-issue.tsx | 18 +- .../quick-action-dropdowns/project-issue.tsx | 18 +- .../roots/all-issue-layout-root.tsx | 6 +- .../spreadsheet/base-spreadsheet-root.tsx | 40 +-- .../spreadsheet/columns/assignee-column.tsx | 62 ++--- .../spreadsheet/columns/attachment-column.tsx | 39 +-- .../spreadsheet/columns/columns-list.tsx | 176 ------------- .../spreadsheet/columns/created-on-column.tsx | 37 +-- .../spreadsheet/columns/due-date-column.tsx | 57 ++--- .../spreadsheet/columns/estimate-column.tsx | 61 ++--- .../spreadsheet/columns/header-column.tsx | 123 +++++++++ .../spreadsheet/columns/index.ts | 4 +- .../spreadsheet/columns/issue/index.ts | 2 - .../columns/issue/issue-column.tsx | 114 --------- .../issue/spreadsheet-issue-column.tsx | 81 ------ .../spreadsheet/columns/label-column.tsx | 77 ++---- .../spreadsheet/columns/link-column.tsx | 39 +-- .../spreadsheet/columns/priority-column.tsx | 55 ++--- .../spreadsheet/columns/start-date-column.tsx | 60 ++--- .../spreadsheet/columns/state-column.tsx | 63 ++--- .../spreadsheet/columns/sub-issue-column.tsx | 37 +-- .../spreadsheet/columns/updated-on-column.tsx | 42 +--- .../issues/issue-layouts/spreadsheet/index.ts | 1 - .../issue-layouts/spreadsheet/issue-row.tsx | 186 ++++++++++++++ .../spreadsheet/spreadsheet-column.tsx | 233 ------------------ .../spreadsheet/spreadsheet-header.tsx | 59 +++++ .../spreadsheet/spreadsheet-view.tsx | 173 +++++-------- web/constants/spreadsheet.ts | 44 +++- 35 files changed, 749 insertions(+), 1274 deletions(-) delete mode 100644 web/components/issues/issue-layouts/spreadsheet/columns/columns-list.tsx create mode 100644 web/components/issues/issue-layouts/spreadsheet/columns/header-column.tsx delete mode 100644 web/components/issues/issue-layouts/spreadsheet/columns/issue/index.ts delete mode 100644 web/components/issues/issue-layouts/spreadsheet/columns/issue/issue-column.tsx delete mode 100644 web/components/issues/issue-layouts/spreadsheet/columns/issue/spreadsheet-issue-column.tsx create mode 100644 web/components/issues/issue-layouts/spreadsheet/issue-row.tsx delete mode 100644 web/components/issues/issue-layouts/spreadsheet/spreadsheet-column.tsx create mode 100644 web/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx diff --git a/packages/ui/package.json b/packages/ui/package.json index b643d47d4..def464623 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -36,6 +36,7 @@ "@headlessui/react": "^1.7.17", "@popperjs/core": "^2.11.8", "react-color": "^2.19.3", + "react-dom": "^18.2.0", "react-popper": "^2.3.0" } } diff --git a/packages/ui/src/dropdowns/custom-menu.tsx b/packages/ui/src/dropdowns/custom-menu.tsx index d6b0281ce..094a8092f 100644 --- a/packages/ui/src/dropdowns/custom-menu.tsx +++ b/packages/ui/src/dropdowns/custom-menu.tsx @@ -1,5 +1,5 @@ import * as React from "react"; - +import ReactDOM from "react-dom"; // react-poppper import { usePopper } from "react-popper"; // hooks @@ -29,8 +29,10 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { optionsClassName = "", verticalEllipsis = false, width = "auto", + portalElement, menuButtonOnClick, tabIndex, + closeOnSelect, } = props; const [referenceElement, setReferenceElement] = React.useState(null); @@ -51,6 +53,39 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen); useOutsideClickDetector(dropdownRef, closeDropdown); + let menuItems = ( + { + if (closeOnSelect) closeDropdown(); + }} + static + > +
    + {children} +
    +
    + ); + + if (portalElement) { + menuItems = ReactDOM.createPortal(menuItems, portalElement); + } + return ( { )} )} - {isOpen && ( - -
    - {children} -
    -
    - )} + {isOpen && menuItems} )}
    diff --git a/packages/ui/src/dropdowns/helper.tsx b/packages/ui/src/dropdowns/helper.tsx index 618d5b6bd..9c0ae0566 100644 --- a/packages/ui/src/dropdowns/helper.tsx +++ b/packages/ui/src/dropdowns/helper.tsx @@ -24,6 +24,8 @@ export interface ICustomMenuDropdownProps extends IDropdownProps { noBorder?: boolean; verticalEllipsis?: boolean; menuButtonOnClick?: (...args: any) => void; + closeOnSelect?: boolean; + portalElement?: Element | null; } export interface ICustomSelectProps extends IDropdownProps { diff --git a/web/components/issues/issue-layouts/list/list-view-types.d.ts b/web/components/issues/issue-layouts/list/list-view-types.d.ts index 674ae92d1..9e3bb8701 100644 --- a/web/components/issues/issue-layouts/list/list-view-types.d.ts +++ b/web/components/issues/issue-layouts/list/list-view-types.d.ts @@ -4,4 +4,5 @@ export interface IQuickActionProps { handleUpdate?: (data: TIssue) => Promise; handleRemoveFromView?: () => Promise; customActionButton?: React.ReactElement; + portalElement?: HTMLDivElement | null; } diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx index efd9490d7..d8448cc0e 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx @@ -13,7 +13,7 @@ import { TIssue } from "@plane/types"; import { IQuickActionProps } from "../list/list-view-types"; export const AllIssueQuickActions: React.FC = (props) => { - const { issue, handleDelete, handleUpdate, customActionButton } = props; + const { issue, handleDelete, handleUpdate, customActionButton, portalElement } = props; // states const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); const [issueToEdit, setIssueToEdit] = useState(undefined); @@ -59,11 +59,15 @@ export const AllIssueQuickActions: React.FC = (props) => { if (issueToEdit && handleUpdate) await handleUpdate({ ...issueToEdit, ...data }); }} /> - + { - e.preventDefault(); - e.stopPropagation(); handleCopyIssueLink(); }} > @@ -74,8 +78,6 @@ export const AllIssueQuickActions: React.FC = (props) => { { - e.preventDefault(); - e.stopPropagation(); setIssueToEdit(issue); setCreateUpdateIssueModal(true); }} @@ -87,8 +89,6 @@ export const AllIssueQuickActions: React.FC = (props) => { { - e.preventDefault(); - e.stopPropagation(); setCreateUpdateIssueModal(true); }} > @@ -99,8 +99,6 @@ export const AllIssueQuickActions: React.FC = (props) => { { - e.preventDefault(); - e.stopPropagation(); setDeleteIssueModal(true); }} > diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx index 8d6735277..100ae99db 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx @@ -12,7 +12,7 @@ import { copyUrlToClipboard } from "helpers/string.helper"; import { IQuickActionProps } from "../list/list-view-types"; export const ArchivedIssueQuickActions: React.FC = (props) => { - const { issue, handleDelete, customActionButton } = props; + const { issue, handleDelete, customActionButton, portalElement } = props; const router = useRouter(); const { workspaceSlug } = router.query; @@ -40,11 +40,15 @@ export const ArchivedIssueQuickActions: React.FC = (props) => handleClose={() => setDeleteIssueModal(false)} onSubmit={handleDelete} /> - + { - e.preventDefault(); - e.stopPropagation(); handleCopyIssueLink(); }} > @@ -55,8 +59,6 @@ export const ArchivedIssueQuickActions: React.FC = (props) => { - e.preventDefault(); - e.stopPropagation(); setDeleteIssueModal(true); }} > diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx index 6d7e08152..7d708145b 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx @@ -13,7 +13,7 @@ import { TIssue } from "@plane/types"; import { IQuickActionProps } from "../list/list-view-types"; export const CycleIssueQuickActions: React.FC = (props) => { - const { issue, handleDelete, handleUpdate, handleRemoveFromView, customActionButton } = props; + const { issue, handleDelete, handleUpdate, handleRemoveFromView, customActionButton, portalElement } = props; // states const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); const [issueToEdit, setIssueToEdit] = useState(undefined); @@ -59,11 +59,15 @@ export const CycleIssueQuickActions: React.FC = (props) => { if (issueToEdit && handleUpdate) await handleUpdate({ ...issueToEdit, ...data }); }} /> - + { - e.preventDefault(); - e.stopPropagation(); handleCopyIssueLink(); }} > @@ -74,8 +78,6 @@ export const CycleIssueQuickActions: React.FC = (props) => { { - e.preventDefault(); - e.stopPropagation(); setIssueToEdit({ ...issue, cycle: cycleId?.toString() ?? null, @@ -90,8 +92,6 @@ export const CycleIssueQuickActions: React.FC = (props) => { { - e.preventDefault(); - e.stopPropagation(); handleRemoveFromView && handleRemoveFromView(); }} > @@ -102,8 +102,6 @@ export const CycleIssueQuickActions: React.FC = (props) => { { - e.preventDefault(); - e.stopPropagation(); setCreateUpdateIssueModal(true); }} > @@ -114,8 +112,6 @@ export const CycleIssueQuickActions: React.FC = (props) => { { - e.preventDefault(); - e.stopPropagation(); setDeleteIssueModal(true); }} > diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx index 27d16c781..ac12c0d9b 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx @@ -13,7 +13,7 @@ import { TIssue } from "@plane/types"; import { IQuickActionProps } from "../list/list-view-types"; export const ModuleIssueQuickActions: React.FC = (props) => { - const { issue, handleDelete, handleUpdate, handleRemoveFromView, customActionButton } = props; + const { issue, handleDelete, handleUpdate, handleRemoveFromView, customActionButton, portalElement } = props; // states const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); const [issueToEdit, setIssueToEdit] = useState(undefined); @@ -59,11 +59,15 @@ export const ModuleIssueQuickActions: React.FC = (props) => { if (issueToEdit && handleUpdate) await handleUpdate({ ...issueToEdit, ...data }); }} /> - + { - e.preventDefault(); - e.stopPropagation(); handleCopyIssueLink(); }} > @@ -74,8 +78,6 @@ export const ModuleIssueQuickActions: React.FC = (props) => { { - e.preventDefault(); - e.stopPropagation(); setIssueToEdit({ ...issue, module: moduleId?.toString() ?? null }); setCreateUpdateIssueModal(true); }} @@ -87,8 +89,6 @@ export const ModuleIssueQuickActions: React.FC = (props) => { { - e.preventDefault(); - e.stopPropagation(); handleRemoveFromView && handleRemoveFromView(); }} > @@ -99,8 +99,6 @@ export const ModuleIssueQuickActions: React.FC = (props) => { { - e.preventDefault(); - e.stopPropagation(); setCreateUpdateIssueModal(true); }} > diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx index 083a22e35..c0fa556b3 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx @@ -16,7 +16,7 @@ import { IQuickActionProps } from "../list/list-view-types"; import { EUserProjectRoles } from "constants/project"; export const ProjectIssueQuickActions: React.FC = (props) => { - const { issue, handleDelete, handleUpdate, customActionButton } = props; + const { issue, handleDelete, handleUpdate, customActionButton, portalElement } = props; // router const router = useRouter(); const { workspaceSlug } = router.query; @@ -68,11 +68,15 @@ export const ProjectIssueQuickActions: React.FC = (props) => if (issueToEdit && handleUpdate) await handleUpdate({ ...issueToEdit, ...data }); }} /> - + { - e.preventDefault(); - e.stopPropagation(); handleCopyIssueLink(); }} > @@ -85,8 +89,6 @@ export const ProjectIssueQuickActions: React.FC = (props) => <> { - e.preventDefault(); - e.stopPropagation(); setIssueToEdit(issue); setCreateUpdateIssueModal(true); }} @@ -98,8 +100,6 @@ export const ProjectIssueQuickActions: React.FC = (props) => { - e.preventDefault(); - e.stopPropagation(); setCreateUpdateIssueModal(true); }} > @@ -110,8 +110,6 @@ export const ProjectIssueQuickActions: React.FC = (props) => { - e.preventDefault(); - e.stopPropagation(); setDeleteIssueModal(true); }} > diff --git a/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx b/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx index 0b4e334c7..cf585a6fc 100644 --- a/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx @@ -3,7 +3,7 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import useSWR from "swr"; // hooks -import { useGlobalView, useIssues, useLabel, useUser } from "hooks/store"; +import { useGlobalView, useIssues, useUser } from "hooks/store"; // components import { GlobalViewsAppliedFiltersRoot } from "components/issues"; import { SpreadsheetView } from "components/issues/issue-layouts"; @@ -37,9 +37,6 @@ export const AllIssueLayoutRoot: React.FC = observer((props) => { membership: { currentWorkspaceAllProjectsRole }, } = useUser(); const { fetchAllGlobalViews } = useGlobalView(); - const { - workspace: { workspaceLabels }, - } = useLabel(); // derived values const currentIssueView = type ?? globalViewId; @@ -134,7 +131,6 @@ export const AllIssueLayoutRoot: React.FC = observer((props) => { handleDelete={async () => handleIssues(issue, EIssueActions.DELETE)} /> )} - labels={workspaceLabels || undefined} handleIssues={handleIssues} canEditProperties={canEditProperties} viewId={currentIssueView} diff --git a/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx b/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx index 543e33aad..31c27e729 100644 --- a/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx @@ -2,7 +2,7 @@ import { FC, useCallback } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; // hooks -import { useIssues, useLabel, useProjectState, useUser } from "hooks/store"; +import { useIssues, useUser } from "hooks/store"; // views import { SpreadsheetView } from "./spreadsheet-view"; // types @@ -40,10 +40,6 @@ export const BaseSpreadsheetRoot = observer((props: IBaseSpreadsheetRoot) => { const { membership: { currentProjectRole }, } = useUser(); - const { - project: { projectLabels }, - } = useLabel(); - const { projectStates } = useProjectState(); // derived values const { enableInlineEditing, enableQuickAdd, enableIssueCreation } = issueStore?.viewFlags || {}; // user role validation @@ -86,27 +82,31 @@ export const BaseSpreadsheetRoot = observer((props: IBaseSpreadsheetRoot) => { [issueFiltersStore, projectId, workspaceSlug, viewId] ); + const renderQuickActions = useCallback( + (issue: TIssue, customActionButton?: React.ReactElement, portalElement?: HTMLDivElement | null) => ( + handleIssues(issue, EIssueActions.DELETE)} + handleUpdate={ + issueActions[EIssueActions.UPDATE] ? async (data) => handleIssues(data, EIssueActions.UPDATE) : undefined + } + handleRemoveFromView={ + issueActions[EIssueActions.REMOVE] ? async () => handleIssues(issue, EIssueActions.REMOVE) : undefined + } + portalElement={portalElement} + /> + ), + [handleIssues] + ); + return ( ( - handleIssues(issue, EIssueActions.DELETE)} - handleUpdate={ - issueActions[EIssueActions.UPDATE] ? async (data) => handleIssues(data, EIssueActions.UPDATE) : undefined - } - handleRemoveFromView={ - issueActions[EIssueActions.REMOVE] ? async () => handleIssues(issue, EIssueActions.REMOVE) : undefined - } - /> - )} - labels={projectLabels ?? []} - states={projectStates} + quickActions={renderQuickActions} handleIssues={handleIssues} canEditProperties={canEditProperties} quickAddCallback={issueStore.quickAddIssue} diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/assignee-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/assignee-column.tsx index 89d8367f3..2656143ac 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/assignee-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/assignee-column.tsx @@ -1,56 +1,34 @@ import React from "react"; -// hooks -import { useIssueDetail } from "hooks/store"; +import { observer } from "mobx-react-lite"; // components import { ProjectMemberDropdown } from "components/dropdowns"; // types import { TIssue } from "@plane/types"; type Props = { - issueId: string; + issue: TIssue; onChange: (issue: TIssue, data: Partial) => void; - expandedIssues: string[]; disabled: boolean; }; -export const SpreadsheetAssigneeColumn: React.FC = ({ issueId, onChange, expandedIssues, disabled }) => { - const isExpanded = expandedIssues.indexOf(issueId) > -1; - - const { subIssues: subIssuesStore, issue } = useIssueDetail(); - - const issueDetail = issue.getIssueById(issueId); - const subIssues = subIssuesStore.subIssuesByIssueId(issueId); +export const SpreadsheetAssigneeColumn: React.FC = observer((props: Props) => { + const { issue, onChange, disabled } = props; return ( - <> - {issueDetail && ( -
    - onChange(issueDetail, { assignee_ids: data })} - projectId={issueDetail?.project_id} - disabled={disabled} - multiple - placeholder="Assignees" - buttonVariant={issueDetail.assignee_ids?.length > 0 ? "transparent-without-text" : "transparent-with-text"} - buttonClassName="text-left" - buttonContainerClassName="w-full" - /> -
    - )} - - {isExpanded && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssueId) => ( - - ))} - +
    + onChange(issue, { assignee_ids: data })} + projectId={issue?.project_id} + disabled={disabled} + multiple + placeholder="Assignees" + buttonVariant={ + issue?.assignee_ids && issue.assignee_ids.length > 0 ? "transparent-without-text" : "transparent-with-text" + } + buttonClassName="text-left" + buttonContainerClassName="w-full" + /> +
    ); -}; +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/attachment-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/attachment-column.tsx index 4b4bdbb53..c17a433b8 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/attachment-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/attachment-column.tsx @@ -1,39 +1,18 @@ import React from "react"; -// hooks +import { observer } from "mobx-react-lite"; // types -import { useIssueDetail } from "hooks/store"; +import { TIssue } from "@plane/types"; type Props = { - issueId: string; - expandedIssues: string[]; + issue: TIssue; }; -export const SpreadsheetAttachmentColumn: React.FC = (props) => { - const { issueId, expandedIssues } = props; - - const isExpanded = expandedIssues.indexOf(issueId) > -1; - - const { subIssues: subIssuesStore, issue } = useIssueDetail(); - - const issueDetail = issue.getIssueById(issueId); - const subIssues = subIssuesStore.subIssuesByIssueId(issueId); - - // const { subIssues, isLoading } = useSubIssue(issue.project_id, issue.id, isExpanded); +export const SpreadsheetAttachmentColumn: React.FC = observer((props) => { + const { issue } = props; return ( - <> -
    - {issueDetail?.attachment_count} {issueDetail?.attachment_count === 1 ? "attachment" : "attachments"} -
    - - {isExpanded && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssueId: string) => ( -
    - -
    - ))} - +
    + {issue?.attachment_count} {issue?.attachment_count === 1 ? "attachment" : "attachments"} +
    ); -}; +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/columns-list.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/columns-list.tsx deleted file mode 100644 index e7f046796..000000000 --- a/web/components/issues/issue-layouts/spreadsheet/columns/columns-list.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import { observer } from "mobx-react-lite"; -// hooks -import { useProject } from "hooks/store"; -// components -import { SpreadsheetColumn } from "components/issues"; -// types -import { TIssue, IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueLabel, IState } from "@plane/types"; - -type Props = { - displayFilters: IIssueDisplayFilterOptions; - displayProperties: IIssueDisplayProperties; - canEditProperties: (projectId: string | undefined) => boolean; - expandedIssues: string[]; - handleDisplayFilterUpdate: (data: Partial) => void; - handleUpdateIssue: (issue: TIssue, data: Partial) => void; - issues: TIssue[] | undefined; - labels?: IIssueLabel[] | undefined; - states?: IState[] | undefined; -}; - -export const SpreadsheetColumnsList: React.FC = observer((props) => { - const { - canEditProperties, - displayFilters, - displayProperties, - expandedIssues, - handleDisplayFilterUpdate, - handleUpdateIssue, - issues, - labels, - states, - } = props; - // store hooks - const { currentProjectDetails } = useProject(); - - const isEstimateEnabled: boolean = currentProjectDetails?.estimate !== null; - - return ( - <> - {displayProperties.state && ( - - )} - {displayProperties.priority && ( - - )} - {displayProperties.assignee && ( - - )} - {displayProperties.labels && ( - - )}{" "} - {displayProperties.start_date && ( - - )} - {displayProperties.due_date && ( - - )} - {displayProperties.estimate && isEstimateEnabled && ( - - )} - {displayProperties.created_on && ( - - )} - {displayProperties.updated_on && ( - - )} - {displayProperties.link && ( - - )} - {displayProperties.attachment_count && ( - - )} - {displayProperties.sub_issue_count && ( - - )} - - ); -}); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/created-on-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/created-on-column.tsx index 176b8ea14..8d373efb4 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/created-on-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/created-on-column.tsx @@ -1,38 +1,19 @@ import React from "react"; -// hooks -import { useIssueDetail } from "hooks/store"; +import { observer } from "mobx-react-lite"; // helpers import { renderFormattedDate } from "helpers/date-time.helper"; // types +import { TIssue } from "@plane/types"; type Props = { - issueId: string; - expandedIssues: string[]; + issue: TIssue; }; -export const SpreadsheetCreatedOnColumn: React.FC = ({ issueId, expandedIssues }) => { - const isExpanded = expandedIssues.indexOf(issueId) > -1; - - const { subIssues: subIssuesStore, issue } = useIssueDetail(); - - const issueDetail = issue.getIssueById(issueId); - const subIssues = subIssuesStore.subIssuesByIssueId(issueId); - +export const SpreadsheetCreatedOnColumn: React.FC = observer((props: Props) => { + const { issue } = props; return ( - <> - {issueDetail && ( -
    - {renderFormattedDate(issueDetail.created_at)} -
    - )} - {isExpanded && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssueId: string) => ( -
    - -
    - ))} - +
    + {renderFormattedDate(issue.created_at)} +
    ); -}; +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx index 32c871b90..dbc27a3db 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx @@ -1,6 +1,5 @@ import React from "react"; -// hooks -import { useIssueDetail } from "hooks/store"; +import { observer } from "mobx-react-lite"; // components import { DateDropdown } from "components/dropdowns"; // helpers @@ -9,49 +8,25 @@ import { renderFormattedPayloadDate } from "helpers/date-time.helper"; import { TIssue } from "@plane/types"; type Props = { - issueId: string; + issue: TIssue; onChange: (issue: TIssue, data: Partial) => void; - expandedIssues: string[]; disabled: boolean; }; -export const SpreadsheetDueDateColumn: React.FC = ({ issueId, onChange, expandedIssues, disabled }) => { - const isExpanded = expandedIssues.indexOf(issueId) > -1; - - // const { subIssues, isLoading, mutateSubIssues } = useSubIssue(issue.project_id, issue.id, isExpanded); - const { subIssues: subIssuesStore, issue } = useIssueDetail(); - - const issueDetail = issue.getIssueById(issueId); - const subIssues = subIssuesStore.subIssuesByIssueId(issueId); +export const SpreadsheetDueDateColumn: React.FC = observer((props: Props) => { + const { issue, onChange, disabled } = props; return ( - <> - {issueDetail && ( -
    - onChange(issueDetail, { target_date: data ? renderFormattedPayloadDate(data) : null })} - disabled={disabled} - placeholder="Due date" - buttonVariant="transparent-with-text" - buttonClassName="rounded-none text-left" - buttonContainerClassName="w-full" - /> -
    - )} - - {isExpanded && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssueId) => ( - - ))} - +
    + onChange(issue, { target_date: data ? renderFormattedPayloadDate(data) : null })} + disabled={disabled} + placeholder="Due date" + buttonVariant="transparent-with-text" + buttonClassName="rounded-none text-left" + buttonContainerClassName="w-full" + /> +
    ); -}; +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/estimate-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/estimate-column.tsx index 041da65c6..50878ccce 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/estimate-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/estimate-column.tsx @@ -1,56 +1,29 @@ -// hooks -import { useIssueDetail } from "hooks/store"; // components import { EstimateDropdown } from "components/dropdowns"; +import { observer } from "mobx-react-lite"; // types import { TIssue } from "@plane/types"; type Props = { - issueId: string; - onChange: (issue: TIssue, formData: Partial) => void; - expandedIssues: string[]; + issue: TIssue; + onChange: (issue: TIssue, data: Partial) => void; disabled: boolean; }; -export const SpreadsheetEstimateColumn: React.FC = (props) => { - const { issueId, onChange, expandedIssues, disabled } = props; - - const isExpanded = expandedIssues.indexOf(issueId) > -1; - - // const { subIssues, isLoading, mutateSubIssues } = useSubIssue(issue.project_id, issue.id, isExpanded); - const { subIssues: subIssuesStore, issue } = useIssueDetail(); - - const issueDetail = issue.getIssueById(issueId); - const subIssues = subIssuesStore.subIssuesByIssueId(issueId); +export const SpreadsheetEstimateColumn: React.FC = observer((props: Props) => { + const { issue, onChange, disabled } = props; return ( - <> - {issueDetail && ( -
    - onChange(issueDetail, { estimate_point: data })} - projectId={issueDetail.project_id} - disabled={disabled} - buttonVariant="transparent-with-text" - buttonClassName="rounded-none text-left" - buttonContainerClassName="w-full" - /> -
    - )} - - {isExpanded && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssueId) => ( - - ))} - +
    + onChange(issue, { estimate_point: data })} + projectId={issue.project_id} + disabled={disabled} + buttonVariant="transparent-with-text" + buttonClassName="rounded-none text-left" + buttonContainerClassName="w-full" + /> +
    ); -}; +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/header-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/header-column.tsx new file mode 100644 index 000000000..040000218 --- /dev/null +++ b/web/components/issues/issue-layouts/spreadsheet/columns/header-column.tsx @@ -0,0 +1,123 @@ +//ui +import { CustomMenu } from "@plane/ui"; +import { + ArrowDownWideNarrow, + ArrowUpNarrowWide, + CheckIcon, + ChevronDownIcon, + Eraser, + ListFilter, + MoveRight, +} from "lucide-react"; +//hooks +import useLocalStorage from "hooks/use-local-storage"; +//types +import { IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssueOrderByOptions } from "@plane/types"; +//constants +import { SPREADSHEET_PROPERTY_DETAILS } from "constants/spreadsheet"; + +interface Props { + property: keyof IIssueDisplayProperties; + displayFilters: IIssueDisplayFilterOptions; + handleDisplayFilterUpdate: (data: Partial) => void; +} + +export const SpreadsheetHeaderColumn = (props: Props) => { + const { displayFilters, handleDisplayFilterUpdate, property } = props; + + const { storedValue: selectedMenuItem, setValue: setSelectedMenuItem } = useLocalStorage( + "spreadsheetViewSorting", + "" + ); + const { storedValue: activeSortingProperty, setValue: setActiveSortingProperty } = useLocalStorage( + "spreadsheetViewActiveSortingProperty", + "" + ); + const propertyDetails = SPREADSHEET_PROPERTY_DETAILS[property]; + + const handleOrderBy = (order: TIssueOrderByOptions, itemKey: string) => { + handleDisplayFilterUpdate({ order_by: order }); + + setSelectedMenuItem(`${order}_${itemKey}`); + setActiveSortingProperty(order === "-created_at" ? "" : itemKey); + }; + + return ( + +
    + {} + {propertyDetails.title} +
    +
    + {activeSortingProperty === property && ( +
    + +
    + )} +
    +
    + } + width="xl" + placement="bottom-end" + > + handleOrderBy(propertyDetails.ascendingOrderKey, property)}> +
    +
    + + {propertyDetails.ascendingOrderTitle} + + {propertyDetails.descendingOrderTitle} +
    + + {selectedMenuItem === `${propertyDetails.ascendingOrderKey}_${property}` && } +
    +
    + handleOrderBy(propertyDetails.descendingOrderKey, property)}> +
    +
    + + {propertyDetails.descendingOrderTitle} + + {propertyDetails.ascendingOrderTitle} +
    + + {selectedMenuItem === `${propertyDetails.descendingOrderKey}_${property}` && ( + + )} +
    +
    + {selectedMenuItem && + selectedMenuItem !== "" && + displayFilters?.order_by !== "-created_at" && + selectedMenuItem.includes(property) && ( + handleOrderBy("-created_at", property)} + > +
    + + Clear sorting +
    +
    + )} + + ); +}; diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/index.ts b/web/components/issues/issue-layouts/spreadsheet/columns/index.ts index a6c4979b3..acfd02fc5 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/index.ts +++ b/web/components/issues/issue-layouts/spreadsheet/columns/index.ts @@ -1,7 +1,5 @@ -export * from "./issue"; export * from "./assignee-column"; export * from "./attachment-column"; -export * from "./columns-list"; export * from "./created-on-column"; export * from "./due-date-column"; export * from "./estimate-column"; @@ -11,4 +9,4 @@ export * from "./priority-column"; export * from "./start-date-column"; export * from "./state-column"; export * from "./sub-issue-column"; -export * from "./updated-on-column"; +export * from "./updated-on-column"; \ No newline at end of file diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/issue/index.ts b/web/components/issues/issue-layouts/spreadsheet/columns/issue/index.ts deleted file mode 100644 index b8d09d1df..000000000 --- a/web/components/issues/issue-layouts/spreadsheet/columns/issue/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./spreadsheet-issue-column"; -export * from "./issue-column"; diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/issue/issue-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/issue/issue-column.tsx deleted file mode 100644 index 612bba9df..000000000 --- a/web/components/issues/issue-layouts/spreadsheet/columns/issue/issue-column.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import React, { useRef, useState } from "react"; -import { useRouter } from "next/router"; -import { ChevronRight, MoreHorizontal } from "lucide-react"; -// components -import { Tooltip } from "@plane/ui"; -// hooks -import useOutsideClickDetector from "hooks/use-outside-click-detector"; -// types -import { TIssue, IIssueDisplayProperties } from "@plane/types"; -import { useProject } from "hooks/store"; - -type Props = { - issue: TIssue; - expanded: boolean; - handleToggleExpand: (issueId: string) => void; - properties: IIssueDisplayProperties; - quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; - canEditProperties: (projectId: string | undefined) => boolean; - nestingLevel: number; -}; - -export const IssueColumn: React.FC = ({ - issue, - expanded, - handleToggleExpand, - properties, - quickActions, - canEditProperties, - nestingLevel, -}) => { - // router - const router = useRouter(); - // hooks - const { getProjectById } = useProject(); - // states - const [isMenuActive, setIsMenuActive] = useState(false); - - const menuActionRef = useRef(null); - - const handleIssuePeekOverview = (issue: TIssue) => { - const { query } = router; - - router.push({ - pathname: router.pathname, - query: { ...query, peekIssueId: issue?.id, peekProjectId: issue?.project_id }, - }); - }; - - const paddingLeft = `${nestingLevel * 54}px`; - - useOutsideClickDetector(menuActionRef, () => setIsMenuActive(false)); - - const customActionButton = ( -
    setIsMenuActive(!isMenuActive)} - > - -
    - ); - - return ( - <> -
    - {properties.key && ( -
    -
    - - {getProjectById(issue.project_id)?.identifier}-{issue.sequence_id} - - - {canEditProperties(issue.project_id) && ( - - )} -
    - - {issue.sub_issues_count > 0 && ( -
    - -
    - )} -
    - )} -
    - -
    handleIssuePeekOverview(issue)} - > - {issue.name} -
    -
    -
    -
    - - ); -}; diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/issue/spreadsheet-issue-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/issue/spreadsheet-issue-column.tsx deleted file mode 100644 index d906e522a..000000000 --- a/web/components/issues/issue-layouts/spreadsheet/columns/issue/spreadsheet-issue-column.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import React from "react"; - -// components -import { IssueColumn } from "components/issues"; -// hooks -import { useIssueDetail } from "hooks/store"; -// types -import { TIssue, IIssueDisplayProperties } from "@plane/types"; - -type Props = { - issueId: string; - expandedIssues: string[]; - setExpandedIssues: React.Dispatch>; - properties: IIssueDisplayProperties; - quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; - canEditProperties: (projectId: string | undefined) => boolean; - nestingLevel?: number; -}; - -export const SpreadsheetIssuesColumn: React.FC = ({ - issueId, - expandedIssues, - setExpandedIssues, - properties, - quickActions, - canEditProperties, - nestingLevel = 0, -}) => { - const handleToggleExpand = (issueId: string) => { - setExpandedIssues((prevState) => { - const newArray = [...prevState]; - const index = newArray.indexOf(issueId); - - if (index > -1) newArray.splice(index, 1); - else newArray.push(issueId); - - return newArray; - }); - }; - - const isExpanded = expandedIssues.indexOf(issueId) > -1; - - // const { subIssues, isLoading } = useSubIssue(issue.project_id, issue.id, isExpanded); - - const { subIssues: subIssuesStore, issue } = useIssueDetail(); - - const issueDetail = issue.getIssueById(issueId); - const subIssues = subIssuesStore.subIssuesByIssueId(issueId); - - return ( - <> - {issueDetail && ( - - )} - - {isExpanded && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssueId: string) => ( - - ))} - - ); -}; diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/label-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/label-column.tsx index a2fef5a5e..82015056e 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/label-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/label-column.tsx @@ -1,70 +1,39 @@ import React from "react"; - +import { observer } from "mobx-react-lite"; // components import { IssuePropertyLabels } from "../../properties"; // hooks -import { useIssueDetail, useLabel } from "hooks/store"; +import { useLabel } from "hooks/store"; // types -import { TIssue, IIssueLabel } from "@plane/types"; +import { TIssue } from "@plane/types"; type Props = { - issueId: string; - onChange: (issue: TIssue, formData: Partial) => void; - labels: IIssueLabel[] | undefined; - expandedIssues: string[]; + issue: TIssue; + onChange: (issue: TIssue, data: Partial) => void; disabled: boolean; }; -export const SpreadsheetLabelColumn: React.FC = (props) => { - const { issueId, onChange, labels, expandedIssues, disabled } = props; +export const SpreadsheetLabelColumn: React.FC = observer((props: Props) => { + const { issue, onChange, disabled } = props; // hooks const { labelMap } = useLabel(); - const isExpanded = expandedIssues.indexOf(issueId) > -1; - - // const { subIssues, isLoading, mutateSubIssues } = useSubIssue(issue.project_id, issue.id, isExpanded); - - const { subIssues: subIssuesStore, issue } = useIssueDetail(); - - const issueDetail = issue.getIssueById(issueId); - const subIssues = subIssuesStore.subIssuesByIssueId(issueId); - - const defaultLabelOptions = issueDetail?.label_ids?.map((id) => labelMap[id]) || []; + const defaultLabelOptions = issue?.label_ids?.map((id) => labelMap[id]) || []; return ( - <> - {issueDetail && ( - { - onChange(issueDetail, { label_ids: data }); - }} - className="h-11 w-full border-b-[0.5px] border-custom-border-200 hover:bg-custom-background-80" - buttonClassName="px-2.5 h-full" - hideDropdownArrow - maxRender={1} - disabled={disabled} - placeholderText="Select labels" - /> - )} - - {isExpanded && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssueId: string) => ( -
    - -
    - ))} - + { + onChange(issue, { label_ids: data }); + }} + className="h-11 w-full border-b-[0.5px] border-custom-border-200 hover:bg-custom-background-80" + buttonClassName="px-2.5 h-full" + hideDropdownArrow + maxRender={1} + disabled={disabled} + placeholderText="Select labels" + /> ); -}; +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/link-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/link-column.tsx index a86dcedd7..2d3e7b670 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/link-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/link-column.tsx @@ -1,39 +1,18 @@ import React from "react"; -// hooks -import { useIssueDetail } from "hooks/store"; +import { observer } from "mobx-react-lite"; // types +import { TIssue } from "@plane/types"; type Props = { - issueId: string; - expandedIssues: string[]; + issue: TIssue; }; -export const SpreadsheetLinkColumn: React.FC = (props) => { - const { issueId, expandedIssues } = props; - - const isExpanded = expandedIssues.indexOf(issueId) > -1; - - // const { subIssues, isLoading } = useSubIssue(issue.project_id, issue.id, isExpanded); - - const { subIssues: subIssuesStore, issue } = useIssueDetail(); - - const issueDetail = issue.getIssueById(issueId); - const subIssues = subIssuesStore.subIssuesByIssueId(issueId); +export const SpreadsheetLinkColumn: React.FC = observer((props: Props) => { + const { issue } = props; return ( - <> -
    - {issueDetail?.link_count} {issueDetail?.link_count === 1 ? "link" : "links"} -
    - - {isExpanded && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssueId: string) => ( -
    - -
    - ))} - +
    + {issue?.link_count} {issue?.link_count === 1 ? "link" : "links"} +
    ); -}; +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/priority-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/priority-column.tsx index 5462a9e13..0a8321740 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/priority-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/priority-column.tsx @@ -1,54 +1,29 @@ import React from "react"; -// hooks -import { useIssueDetail } from "hooks/store"; +import { observer } from "mobx-react-lite"; // components import { PriorityDropdown } from "components/dropdowns"; // types import { TIssue } from "@plane/types"; type Props = { - issueId: string; + issue: TIssue; onChange: (issue: TIssue, data: Partial) => void; - expandedIssues: string[]; disabled: boolean; }; -export const SpreadsheetPriorityColumn: React.FC = (props) => { - const { issueId, onChange, expandedIssues, disabled } = props; - // store hooks - const { subIssues: subIssuesStore, issue } = useIssueDetail(); - // derived values - const issueDetail = issue.getIssueById(issueId); - const subIssues = subIssuesStore.subIssuesByIssueId(issueId); - const isExpanded = expandedIssues.indexOf(issueId) > -1; +export const SpreadsheetPriorityColumn: React.FC = observer((props: Props) => { + const { issue, onChange, disabled } = props; return ( - <> - {issueDetail && ( -
    - onChange(issueDetail, { priority: data })} - disabled={disabled} - buttonVariant="transparent-with-text" - buttonClassName="rounded-none text-left" - buttonContainerClassName="w-full" - /> -
    - )} - - {isExpanded && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssueId: string) => ( - - ))} - +
    + onChange(issue, { priority: data })} + disabled={disabled} + buttonVariant="transparent-with-text" + buttonClassName="rounded-none text-left" + buttonContainerClassName="w-full" + /> +
    ); -}; +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx index 09248e320..778f9cdac 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx @@ -1,6 +1,5 @@ import React from "react"; -// hooks -import { useIssueDetail } from "hooks/store"; +import { observer } from "mobx-react-lite"; // components import { DateDropdown } from "components/dropdowns"; // helpers @@ -9,50 +8,25 @@ import { renderFormattedPayloadDate } from "helpers/date-time.helper"; import { TIssue } from "@plane/types"; type Props = { - issueId: string; - onChange: (issue: TIssue, formData: Partial) => void; - expandedIssues: string[]; + issue: TIssue; + onChange: (issue: TIssue, data: Partial) => void; disabled: boolean; }; -export const SpreadsheetStartDateColumn: React.FC = ({ issueId, onChange, expandedIssues, disabled }) => { - const isExpanded = expandedIssues.indexOf(issueId) > -1; - - // const { subIssues, isLoading, mutateSubIssues } = useSubIssue(issue.project_id, issue.id, isExpanded); - - const { subIssues: subIssuesStore, issue } = useIssueDetail(); - - const issueDetail = issue.getIssueById(issueId); - const subIssues = subIssuesStore.subIssuesByIssueId(issueId); +export const SpreadsheetStartDateColumn: React.FC = observer((props: Props) => { + const { issue, onChange, disabled } = props; return ( - <> - {issueDetail && ( -
    - onChange(issueDetail, { start_date: data ? renderFormattedPayloadDate(data) : null })} - disabled={disabled} - placeholder="Start date" - buttonVariant="transparent-with-text" - buttonClassName="rounded-none text-left" - buttonContainerClassName="w-full" - /> -
    - )} - - {isExpanded && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssueId) => ( - - ))} - +
    + onChange(issue, { start_date: data ? renderFormattedPayloadDate(data) : null })} + disabled={disabled} + placeholder="Start date" + buttonVariant="transparent-with-text" + buttonClassName="rounded-none text-left" + buttonContainerClassName="w-full" + /> +
    ); -}; +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/state-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/state-column.tsx index 39508ca37..0050c8acf 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/state-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/state-column.tsx @@ -1,59 +1,30 @@ import React from "react"; -// hooks -import { useIssueDetail } from "hooks/store"; +import { observer } from "mobx-react-lite"; // components import { StateDropdown } from "components/dropdowns"; // types -import { TIssue, IState } from "@plane/types"; +import { TIssue } from "@plane/types"; type Props = { - issueId: string; + issue: TIssue; onChange: (issue: TIssue, data: Partial) => void; - states: IState[] | undefined; - expandedIssues: string[]; disabled: boolean; }; -export const SpreadsheetStateColumn: React.FC = (props) => { - const { issueId, onChange, states, expandedIssues, disabled } = props; - const { subIssues: subIssuesStore, issue } = useIssueDetail(); - - const issueDetail = issue.getIssueById(issueId); - const subIssues = subIssuesStore.subIssuesByIssueId(issueId); - - const isExpanded = expandedIssues.indexOf(issueId) > -1; - - // const { subIssues, isLoading, mutateSubIssues } = useSubIssue(issue.project_id, issue.id, isExpanded); +export const SpreadsheetStateColumn: React.FC = observer((props) => { + const { issue, onChange, disabled } = props; return ( - <> - {issueDetail && ( -
    - onChange(issueDetail, { state_id: data })} - disabled={disabled} - buttonVariant="transparent-with-text" - buttonClassName="rounded-none text-left" - buttonContainerClassName="w-full" - /> -
    - )} - - {isExpanded && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssueId) => ( - - ))} - +
    + onChange(issue, { state_id: data })} + disabled={disabled} + buttonVariant="transparent-with-text" + buttonClassName="rounded-none text-left" + buttonContainerClassName="w-full" + /> +
    ); -}; +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/sub-issue-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/sub-issue-column.tsx index e641c1e01..c0e41d2c0 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/sub-issue-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/sub-issue-column.tsx @@ -1,37 +1,18 @@ import React from "react"; +import { observer } from "mobx-react-lite"; // hooks -import { useIssueDetail } from "hooks/store"; +import { TIssue } from "@plane/types"; type Props = { - issueId: string; - expandedIssues: string[]; + issue: TIssue; }; -export const SpreadsheetSubIssueColumn: React.FC = (props) => { - const { issueId, expandedIssues } = props; - - const isExpanded = expandedIssues.indexOf(issueId) > -1; - - // const { subIssues, isLoading } = useSubIssue(issue.project_id, issue.id, isExpanded); - const { subIssues: subIssuesStore, issue } = useIssueDetail(); - - const issueDetail = issue.getIssueById(issueId); - const subIssues = subIssuesStore.subIssuesByIssueId(issueId); +export const SpreadsheetSubIssueColumn: React.FC = observer((props: Props) => { + const { issue } = props; return ( - <> -
    - {issueDetail?.sub_issues_count} {issueDetail?.sub_issues_count === 1 ? "sub-issue" : "sub-issues"} -
    - - {isExpanded && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssueId: string) => ( -
    - -
    - ))} - +
    + {issue?.sub_issues_count} {issue?.sub_issues_count === 1 ? "sub-issue" : "sub-issues"} +
    ); -}; +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/updated-on-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/updated-on-column.tsx index 3ce036d69..f84989192 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/updated-on-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/updated-on-column.tsx @@ -1,43 +1,19 @@ import React from "react"; -// hooks -// import useSubIssue from "hooks/use-sub-issue"; +import { observer } from "mobx-react-lite"; // helpers import { renderFormattedDate } from "helpers/date-time.helper"; // types -import { useIssueDetail } from "hooks/store"; +import { TIssue } from "@plane/types"; type Props = { - issueId: string; - expandedIssues: string[]; + issue: TIssue; }; -export const SpreadsheetUpdatedOnColumn: React.FC = (props) => { - const { issueId, expandedIssues } = props; - - const isExpanded = expandedIssues.indexOf(issueId) > -1; - - // const { subIssues, isLoading } = useSubIssue(issue.project_id, issue.id, isExpanded); - const { subIssues: subIssuesStore, issue } = useIssueDetail(); - - const issueDetail = issue.getIssueById(issueId); - const subIssues = subIssuesStore.subIssuesByIssueId(issueId); - +export const SpreadsheetUpdatedOnColumn: React.FC = observer((props: Props) => { + const { issue } = props; return ( - <> - {issueDetail && ( -
    - {renderFormattedDate(issueDetail.updated_at)} -
    - )} - - {isExpanded && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssueId: string) => ( -
    - -
    - ))} - +
    + {renderFormattedDate(issue.updated_at)} +
    ); -}; +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/index.ts b/web/components/issues/issue-layouts/spreadsheet/index.ts index 10fc26219..8f7c4a7fd 100644 --- a/web/components/issues/issue-layouts/spreadsheet/index.ts +++ b/web/components/issues/issue-layouts/spreadsheet/index.ts @@ -1,5 +1,4 @@ export * from "./columns"; export * from "./roots"; -export * from "./spreadsheet-column"; export * from "./spreadsheet-view"; export * from "./quick-add-issue-form"; diff --git a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx new file mode 100644 index 000000000..0091f000c --- /dev/null +++ b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx @@ -0,0 +1,186 @@ +import { IIssueDisplayProperties, TIssue, TIssueMap } from "@plane/types"; +import { SPREADSHEET_PROPERTY_DETAILS, SPREADSHEET_PROPERTY_LIST } from "constants/spreadsheet"; +import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; +import { Tooltip } from "@plane/ui"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; +import { useIssueDetail, useProject } from "hooks/store"; +import { useRef, useState } from "react"; +import { useRouter } from "next/router"; +import { ChevronRight, MoreHorizontal } from "lucide-react"; +import { EIssueActions } from "../types"; +import { observer } from "mobx-react-lite"; + +interface Props { + displayProperties: IIssueDisplayProperties; + isEstimateEnabled: boolean; + quickActions: ( + issue: TIssue, + customActionButton?: React.ReactElement, + portalElement?: HTMLDivElement | null + ) => React.ReactNode; + canEditProperties: (projectId: string | undefined) => boolean; + handleIssues: (issue: TIssue, action: EIssueActions) => Promise; + portalElement: React.MutableRefObject; + nestingLevel: number; + issueId: string; +} + +export const SpreadsheetIssueRow = observer((props: Props) => { + const { + displayProperties, + issueId, + isEstimateEnabled, + nestingLevel, + portalElement, + handleIssues, + quickActions, + canEditProperties, + } = props; + // router + const router = useRouter(); + + const { workspaceSlug } = router.query; + + const { getProjectById } = useProject(); + // states + const [isMenuActive, setIsMenuActive] = useState(false); + const [isExpanded, setExpanded] = useState(false); + + const menuActionRef = useRef(null); + + const handleIssuePeekOverview = (issue: TIssue) => { + const { query } = router; + + router.push({ + pathname: router.pathname, + query: { ...query, peekIssueId: issue?.id, peekProjectId: issue?.project_id }, + }); + }; + + const { subIssues: subIssuesStore, issue } = useIssueDetail(); + + const issueDetail = issue.getIssueById(issueId); + const subIssues = subIssuesStore.subIssuesByIssueId(issueId); + + const paddingLeft = `${nestingLevel * 54}px`; + + useOutsideClickDetector(menuActionRef, () => setIsMenuActive(false)); + + const handleToggleExpand = () => { + setExpanded((prevState) => { + if (!prevState && workspaceSlug && issueDetail) + subIssuesStore.fetchSubIssues(workspaceSlug.toString(), issueDetail.project_id, issueDetail.id); + return !prevState; + }); + }; + + const customActionButton = ( +
    setIsMenuActive(!isMenuActive)} + > + +
    + ); + + if (!issueDetail) return null; + + const disableUserActions = !canEditProperties(issueDetail.project_id); + + return ( + <> + + {/* first column/ issue name and key column */} + + +
    +
    + + {getProjectById(issueDetail.project_id)?.identifier}-{issueDetail.sequence_id} + + + {canEditProperties(issueDetail.project_id) && ( + + )} +
    + + {issueDetail.sub_issues_count > 0 && ( +
    + +
    + )} +
    +
    +
    + +
    handleIssuePeekOverview(issueDetail)} + > + {issueDetail.name} +
    +
    +
    + + {/* Rest of the columns */} + {SPREADSHEET_PROPERTY_LIST.map((property) => { + const { Column } = SPREADSHEET_PROPERTY_DETAILS[property]; + + const shouldRenderProperty = property === "estimate" ? isEstimateEnabled : true; + + return ( + + + ) => + handleIssues({ ...issue, ...data }, EIssueActions.UPDATE) + } + disabled={disableUserActions} + /> + + + ); + })} + + + {isExpanded && + subIssues && + subIssues.length > 0 && + subIssues.map((subIssueId: string) => ( + + ))} + + ); +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-column.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-column.tsx deleted file mode 100644 index 0a0fbe9c0..000000000 --- a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-column.tsx +++ /dev/null @@ -1,233 +0,0 @@ -import { - ArrowDownWideNarrow, - ArrowUpNarrowWide, - CheckIcon, - ChevronDownIcon, - Eraser, - ListFilter, - MoveRight, -} from "lucide-react"; -// hooks -import useLocalStorage from "hooks/use-local-storage"; -// components -import { - SpreadsheetAssigneeColumn, - SpreadsheetAttachmentColumn, - SpreadsheetCreatedOnColumn, - SpreadsheetDueDateColumn, - SpreadsheetEstimateColumn, - SpreadsheetLabelColumn, - SpreadsheetLinkColumn, - SpreadsheetPriorityColumn, - SpreadsheetStartDateColumn, - SpreadsheetStateColumn, - SpreadsheetSubIssueColumn, - SpreadsheetUpdatedOnColumn, -} from "components/issues"; -// ui -import { CustomMenu } from "@plane/ui"; -// types -import { TIssue, IIssueDisplayFilterOptions, IIssueLabel, IState, TIssueOrderByOptions } from "@plane/types"; -// constants -import { SPREADSHEET_PROPERTY_DETAILS } from "constants/spreadsheet"; - -type Props = { - canEditProperties: (projectId: string | undefined) => boolean; - displayFilters: IIssueDisplayFilterOptions; - expandedIssues: string[]; - handleDisplayFilterUpdate: (data: Partial) => void; - handleUpdateIssue: (issue: TIssue, data: Partial) => void; - issues: TIssue[] | undefined; - property: string; - labels?: IIssueLabel[] | undefined; - states?: IState[] | undefined; -}; - -export const SpreadsheetColumn: React.FC = (props) => { - const { - canEditProperties, - displayFilters, - expandedIssues, - handleDisplayFilterUpdate, - handleUpdateIssue, - issues, - property, - labels, - states, - } = props; - - const { storedValue: selectedMenuItem, setValue: setSelectedMenuItem } = useLocalStorage( - "spreadsheetViewSorting", - "" - ); - const { storedValue: activeSortingProperty, setValue: setActiveSortingProperty } = useLocalStorage( - "spreadsheetViewActiveSortingProperty", - "" - ); - - const handleOrderBy = (order: TIssueOrderByOptions, itemKey: string) => { - handleDisplayFilterUpdate({ order_by: order }); - - setSelectedMenuItem(`${order}_${itemKey}`); - setActiveSortingProperty(order === "-created_at" ? "" : itemKey); - }; - - const propertyDetails = SPREADSHEET_PROPERTY_DETAILS[property]; - - return ( -
    -
    - -
    - {} - {propertyDetails.title} -
    -
    - {activeSortingProperty === property && ( -
    - -
    - )} -
    -
    - } - width="xl" - placement="bottom-end" - > - handleOrderBy(propertyDetails.ascendingOrderKey, property)}> -
    -
    - - {propertyDetails.ascendingOrderTitle} - - {propertyDetails.descendingOrderTitle} -
    - - {selectedMenuItem === `${propertyDetails.ascendingOrderKey}_${property}` && ( - - )} -
    -
    - handleOrderBy(propertyDetails.descendingOrderKey, property)}> -
    -
    - - {propertyDetails.descendingOrderTitle} - - {propertyDetails.ascendingOrderTitle} -
    - - {selectedMenuItem === `${propertyDetails.descendingOrderKey}_${property}` && ( - - )} -
    -
    - {selectedMenuItem && - selectedMenuItem !== "" && - displayFilters?.order_by !== "-created_at" && - selectedMenuItem.includes(property) && ( - handleOrderBy("-created_at", property)} - > -
    - - Clear sorting -
    -
    - )} - -
    - -
    - {issues?.map((issue) => { - const disableUserActions = !canEditProperties(issue.project_id); - return ( -
    - {property === "state" ? ( - ) => handleUpdateIssue(issue, data)} - states={states} - /> - ) : property === "priority" ? ( - ) => handleUpdateIssue(issue, data)} - /> - ) : property === "estimate" ? ( - ) => handleUpdateIssue(issue, data)} - /> - ) : property === "assignee" ? ( - ) => handleUpdateIssue(issue, data)} - /> - ) : property === "labels" ? ( - ) => handleUpdateIssue(issue, data)} - /> - ) : property === "start_date" ? ( - ) => handleUpdateIssue(issue, data)} - /> - ) : property === "due_date" ? ( - ) => handleUpdateIssue(issue, data)} - /> - ) : property === "created_on" ? ( - - ) : property === "updated_on" ? ( - - ) : property === "link" ? ( - - ) : property === "attachment_count" ? ( - - ) : property === "sub_issue_count" ? ( - - ) : null} -
    - ); - })} -
    -
    - ); -}; diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx new file mode 100644 index 000000000..704c9f904 --- /dev/null +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx @@ -0,0 +1,59 @@ +// ui +import { LayersIcon } from "@plane/ui"; +// types +import { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types"; +// constants +import { SPREADSHEET_PROPERTY_LIST } from "constants/spreadsheet"; +// components +import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; +import { SpreadsheetHeaderColumn } from "./columns/header-column"; + + +interface Props { + displayProperties: IIssueDisplayProperties; + displayFilters: IIssueDisplayFilterOptions; + handleDisplayFilterUpdate: (data: Partial) => void; + isEstimateEnabled: boolean; +} + +export const SpreadsheetHeader = (props: Props) => { + const { displayProperties, displayFilters, handleDisplayFilterUpdate, isEstimateEnabled } = props; + + return ( + + + + + + #ID + + + + + Issue + + + + {SPREADSHEET_PROPERTY_LIST.map((property) => { + const shouldRenderProperty = property === "estimate" ? isEstimateEnabled : true; + + return ( + + + + + + ); + })} + + + ); +}; diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx index 0e5d2ba94..adf1e53f4 100644 --- a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx @@ -1,20 +1,26 @@ import React, { useEffect, useRef, useState } from "react"; import { observer } from "mobx-react-lite"; // components -import { SpreadsheetColumnsList, SpreadsheetIssuesColumn, SpreadsheetQuickAddIssueForm } from "components/issues"; -import { Spinner, LayersIcon } from "@plane/ui"; +import { Spinner } from "@plane/ui"; +import { SpreadsheetQuickAddIssueForm } from "components/issues"; // types -import { TIssue, IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueLabel, IState } from "@plane/types"; +import { TIssue, IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types"; import { EIssueActions } from "../types"; +import { useProject } from "hooks/store"; +import { SpreadsheetHeader } from "./spreadsheet-header"; +import { SpreadsheetIssueRow } from "./issue-row"; +import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; type Props = { displayProperties: IIssueDisplayProperties; displayFilters: IIssueDisplayFilterOptions; handleDisplayFilterUpdate: (data: Partial) => void; issues: TIssue[] | undefined; - labels?: IIssueLabel[] | undefined; - states?: IState[] | undefined; - quickActions: (issue: TIssue, customActionButton: any) => React.ReactNode; + quickActions: ( + issue: TIssue, + customActionButton?: React.ReactElement, + portalElement?: HTMLDivElement | null + ) => React.ReactNode; handleIssues: (issue: TIssue, action: EIssueActions) => Promise; openIssuesListModal?: (() => void) | null; quickAddCallback?: ( @@ -35,8 +41,6 @@ export const SpreadsheetView: React.FC = observer((props) => { displayFilters, handleDisplayFilterUpdate, issues, - labels, - states, quickActions, handleIssues, quickAddCallback, @@ -46,16 +50,36 @@ export const SpreadsheetView: React.FC = observer((props) => { disableIssueCreation, } = props; // states - const [expandedIssues, setExpandedIssues] = useState([]); - const [isScrolled, setIsScrolled] = useState(false); + const isScrolled = useRef(false); // refs - const containerRef = useRef(null); + const containerRef = useRef(null); + const portalRef = useRef(null); + + const { currentProjectDetails } = useProject(); + + const isEstimateEnabled: boolean = currentProjectDetails?.estimate !== null; const handleScroll = () => { if (!containerRef.current) return; - const scrollLeft = containerRef.current.scrollLeft; - setIsScrolled(scrollLeft > 0); + + const columnShadow = "8px 22px 22px 10px rgba(0, 0, 0, 0.05)"; // shadow for regular columns + const headerShadow = "8px -22px 22px 10px rgba(0, 0, 0, 0.05)"; // shadow for headers + + //The shadow styles are added this way to avoid re-render of all the rows of table, which could be costly + if (scrollLeft > 0 !== isScrolled.current) { + const firtColumns = containerRef.current.querySelectorAll("table tr td:first-child, th:first-child"); + + for (let i = 0; i < firtColumns.length; i++) { + const shadow = i === 0 ? headerShadow : columnShadow; + if (scrollLeft > 0) { + (firtColumns[i] as HTMLElement).style.boxShadow = shadow; + } else { + (firtColumns[i] as HTMLElement).style.boxShadow = "none"; + } + } + isScrolled.current = scrollLeft > 0; + } }; useEffect(() => { @@ -76,105 +100,38 @@ export const SpreadsheetView: React.FC = observer((props) => { ); return ( -
    -
    -
    - {issues && issues.length > 0 && ( - <> -
    -
    -
    - {displayProperties.key && ( - - #ID - - )} - - - Issue - -
    - - {issues.map((issue, index) => - issue ? ( - - ) : null - )} -
    -
    - - +
    +
    + + + + {issues.map(({ id }) => ( + handleIssues({ ...issue, ...data }, EIssueActions.UPDATE)} - issues={issues} - labels={labels} - states={states} + nestingLevel={0} + isEstimateEnabled={isEstimateEnabled} + handleIssues={handleIssues} + portalElement={portalRef} /> - + ))} + +
    +
    +
    +
    + {enableQuickCreateIssue && !disableIssueCreation && ( + )} -
    {/* empty div to show right most border */} -
    - -
    -
    - {enableQuickCreateIssue && !disableIssueCreation && ( - - )} -
    - - {/* {!disableUserActions && - !isInlineCreateIssueFormOpen && - (type === "issue" ? ( - - ) : ( - - - New Issue - - } - optionsClassName="left-5 !w-36" - noBorder - > - setIsInlineCreateIssueFormOpen(true)}> - Create new - - {openIssuesListModal && ( - Add an existing issue - )} - - ))} */}
    diff --git a/web/constants/spreadsheet.ts b/web/constants/spreadsheet.ts index 1f759b43c..6a5e55a62 100644 --- a/web/constants/spreadsheet.ts +++ b/web/constants/spreadsheet.ts @@ -1,8 +1,22 @@ -import { TIssueOrderByOptions } from "@plane/types"; +import { IIssueDisplayProperties, TIssue, TIssueOrderByOptions } from "@plane/types"; import { LayersIcon, DoubleCircleIcon, UserGroupIcon } from "@plane/ui"; import { CalendarDays, Link2, Signal, Tag, Triangle, Paperclip, CalendarClock, CalendarCheck } from "lucide-react"; import { FC } from "react"; import { ISvgIcons } from "@plane/ui/src/icons/type"; +import { + SpreadsheetAssigneeColumn, + SpreadsheetAttachmentColumn, + SpreadsheetCreatedOnColumn, + SpreadsheetDueDateColumn, + SpreadsheetEstimateColumn, + SpreadsheetLabelColumn, + SpreadsheetLinkColumn, + SpreadsheetPriorityColumn, + SpreadsheetStartDateColumn, + SpreadsheetStateColumn, + SpreadsheetSubIssueColumn, + SpreadsheetUpdatedOnColumn, +} from "components/issues/issue-layouts/spreadsheet"; export const SPREADSHEET_PROPERTY_DETAILS: { [key: string]: { @@ -12,6 +26,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: { descendingOrderKey: TIssueOrderByOptions; descendingOrderTitle: string; icon: FC; + Column: React.FC<{ issue: TIssue; onChange: (issue: TIssue, data: Partial) => void; disabled: boolean }>; }; } = { assignee: { @@ -21,6 +36,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: { descendingOrderKey: "-assignees__first_name", descendingOrderTitle: "Z", icon: UserGroupIcon, + Column: SpreadsheetAssigneeColumn, }, created_on: { title: "Created on", @@ -29,6 +45,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: { descendingOrderKey: "created_at", descendingOrderTitle: "Old", icon: CalendarDays, + Column: SpreadsheetCreatedOnColumn, }, due_date: { title: "Due date", @@ -37,6 +54,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: { descendingOrderKey: "target_date", descendingOrderTitle: "Old", icon: CalendarCheck, + Column: SpreadsheetDueDateColumn, }, estimate: { title: "Estimate", @@ -45,6 +63,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: { descendingOrderKey: "-estimate_point", descendingOrderTitle: "High", icon: Triangle, + Column: SpreadsheetEstimateColumn, }, labels: { title: "Labels", @@ -53,6 +72,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: { descendingOrderKey: "-labels__name", descendingOrderTitle: "Z", icon: Tag, + Column: SpreadsheetLabelColumn, }, priority: { title: "Priority", @@ -61,6 +81,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: { descendingOrderKey: "-priority", descendingOrderTitle: "Urgent", icon: Signal, + Column: SpreadsheetPriorityColumn, }, start_date: { title: "Start date", @@ -69,6 +90,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: { descendingOrderKey: "start_date", descendingOrderTitle: "Old", icon: CalendarClock, + Column: SpreadsheetStartDateColumn, }, state: { title: "State", @@ -77,6 +99,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: { descendingOrderKey: "-state__name", descendingOrderTitle: "Z", icon: DoubleCircleIcon, + Column: SpreadsheetStateColumn, }, updated_on: { title: "Updated on", @@ -85,6 +108,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: { descendingOrderKey: "updated_at", descendingOrderTitle: "Old", icon: CalendarDays, + Column: SpreadsheetUpdatedOnColumn, }, link: { title: "Link", @@ -93,6 +117,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: { descendingOrderKey: "link_count", descendingOrderTitle: "Least", icon: Link2, + Column: SpreadsheetLinkColumn, }, attachment_count: { title: "Attachment", @@ -101,6 +126,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: { descendingOrderKey: "attachment_count", descendingOrderTitle: "Least", icon: Paperclip, + Column: SpreadsheetAttachmentColumn, }, sub_issue_count: { title: "Sub-issue", @@ -109,5 +135,21 @@ export const SPREADSHEET_PROPERTY_DETAILS: { descendingOrderKey: "sub_issues_count", descendingOrderTitle: "Least", icon: LayersIcon, + Column: SpreadsheetSubIssueColumn, }, }; + +export const SPREADSHEET_PROPERTY_LIST: (keyof IIssueDisplayProperties)[] = [ + "state", + "priority", + "assignee", + "labels", + "start_date", + "due_date", + "estimate", + "created_on", + "updated_on", + "link", + "attachment_count", + "sub_issue_count", +]; From 96868760a343fce80a53aab7e46ded135db2f44e Mon Sep 17 00:00:00 2001 From: rahulramesha <71900764+rahulramesha@users.noreply.github.com> Date: Thu, 11 Jan 2024 18:21:41 +0530 Subject: [PATCH 26/68] update swr config to not fetch everything on focus (#3350) Co-authored-by: Rahul R --- .../roots/project-layout-root.tsx | 20 ++++++------------- web/constants/swr-config.ts | 3 +++ 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/web/components/issues/issue-layouts/roots/project-layout-root.tsx b/web/components/issues/issue-layouts/roots/project-layout-root.tsx index da9811c61..bfff19cd8 100644 --- a/web/components/issues/issue-layouts/roots/project-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/project-layout-root.tsx @@ -27,20 +27,12 @@ export const ProjectLayoutRoot: FC = observer(() => { // hooks const { issues, issuesFilter } = useIssues(EIssuesStoreType.PROJECT); - const {} = useSWR( - workspaceSlug && projectId ? `PROJECT_ISSUES_${workspaceSlug}_${projectId}` : null, - async () => { - if (workspaceSlug && projectId) { - await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString()); - await issues?.fetchIssues( - workspaceSlug.toString(), - projectId.toString(), - issues?.groupedIssueIds ? "mutation" : "init-loader" - ); - } - }, - { revalidateOnFocus: false, refreshInterval: 600000, revalidateOnMount: true } - ); + useSWR(workspaceSlug && projectId ? `PROJECT_ISSUES_${workspaceSlug}_${projectId}` : null, async () => { + if (workspaceSlug && projectId) { + await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString()); + await issues?.fetchIssues(workspaceSlug.toString(), projectId.toString(), issues?.groupedIssueIds ? "mutation" : "init-loader"); + } + }); const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout; diff --git a/web/constants/swr-config.ts b/web/constants/swr-config.ts index 063d5db54..38478fcea 100644 --- a/web/constants/swr-config.ts +++ b/web/constants/swr-config.ts @@ -1,5 +1,8 @@ export const SWR_CONFIG = { refreshWhenHidden: false, revalidateIfStale: false, + revalidateOnFocus: false, + revalidateOnMount: true, + refreshInterval: 600000, errorRetryCount: 3, }; From 2cd5dbcd02aaffd79094e643c3c0e67ae6b07521 Mon Sep 17 00:00:00 2001 From: guru_sainath Date: Thu, 11 Jan 2024 18:26:58 +0530 Subject: [PATCH 27/68] chore: Error Handling and Validation Updates (#3351) * fix: handled undefined issue_id in list layout * chore: updated label select dropdown in the issue detail * fix: peekoverview issue is resolved * chore: user role validation for issue details. * fix: Link, Attachement, parent mutation * build-error: build error resolved in peekoverview * chore: user role validation for issue details. * chore: user role validation for `issue description`, `parent`, `relation` and `subscription`. * chore: issue subscription mutation * chore: user role validation for `labels` in issue details. --------- Co-authored-by: Prateek Shourya --- .../issues/attachment/attachment-detail.tsx | 28 +-- .../issues/attachment/attachments-list.tsx | 20 ++- .../delete-attachment-confirmation-modal.tsx | 7 +- web/components/issues/attachment/root.tsx | 13 +- web/components/issues/description-form.tsx | 65 +++---- .../issue-detail/label/create-label.tsx | 8 +- .../issues/issue-detail/label/index.ts | 2 + .../issue-detail/label/label-list-item.tsx | 15 +- .../issues/issue-detail/label/label-list.tsx | 4 +- .../issues/issue-detail/label/root.tsx | 27 ++- .../issue-detail/label/select-existing.tsx | 9 - .../label/select/label-select.tsx | 159 ++++++++++++++++++ .../issues/issue-detail/label/select/root.tsx | 24 +++ .../issues/issue-detail/links/links.tsx | 9 +- .../issues/issue-detail/links/root.tsx | 15 +- .../issues/issue-detail/main-content.tsx | 14 +- .../issues/issue-detail/parent-select.tsx | 14 +- .../issues/issue-detail/relation-select.tsx | 34 ++-- web/components/issues/issue-detail/root.tsx | 25 ++- .../issues/issue-detail/sidebar.tsx | 45 ++--- .../issues/issue-detail/subscription.tsx | 27 ++- .../issues/peek-overview/properties.tsx | 2 - web/components/issues/peek-overview/root.tsx | 54 +++--- 23 files changed, 431 insertions(+), 189 deletions(-) delete mode 100644 web/components/issues/issue-detail/label/select-existing.tsx create mode 100644 web/components/issues/issue-detail/label/select/label-select.tsx create mode 100644 web/components/issues/issue-detail/label/select/root.tsx diff --git a/web/components/issues/attachment/attachment-detail.tsx b/web/components/issues/attachment/attachment-detail.tsx index 58ded14e1..bd07f6a44 100644 --- a/web/components/issues/attachment/attachment-detail.tsx +++ b/web/components/issues/attachment/attachment-detail.tsx @@ -13,16 +13,20 @@ import { getFileIcon } from "components/icons"; import { truncateText } from "helpers/string.helper"; import { renderFormattedDate } from "helpers/date-time.helper"; import { convertBytesToSize, getFileExtension, getFileName } from "helpers/attachment.helper"; -// type -import { TIssueAttachmentsList } from "./attachments-list"; +// types +import { TAttachmentOperations } from "./root"; -export type TIssueAttachmentsDetail = TIssueAttachmentsList & { +type TAttachmentOperationsRemoveModal = Exclude; + +type TIssueAttachmentsDetail = { attachmentId: string; + handleAttachmentOperations: TAttachmentOperationsRemoveModal; + disabled?: boolean; }; export const IssueAttachmentsDetail: FC = (props) => { // props - const { attachmentId, handleAttachmentOperations } = props; + const { attachmentId, handleAttachmentOperations, disabled } = props; // store hooks const { getUserDetails } = useMember(); const { @@ -75,13 +79,15 @@ export const IssueAttachmentsDetail: FC = (props) => {
    - + {!disabled && ( + + )}
    ); diff --git a/web/components/issues/attachment/attachments-list.tsx b/web/components/issues/attachment/attachments-list.tsx index 6644d7e8c..2129a4f61 100644 --- a/web/components/issues/attachment/attachments-list.tsx +++ b/web/components/issues/attachment/attachments-list.tsx @@ -7,25 +7,35 @@ import { IssueAttachmentsDetail } from "./attachment-detail"; // types import { TAttachmentOperations } from "./root"; -export type TAttachmentOperationsRemoveModal = Exclude; +type TAttachmentOperationsRemoveModal = Exclude; -export type TIssueAttachmentsList = { +type TIssueAttachmentsList = { + issueId: string; handleAttachmentOperations: TAttachmentOperationsRemoveModal; + disabled?: boolean; }; export const IssueAttachmentsList: FC = observer((props) => { - const { handleAttachmentOperations } = props; + const { issueId, handleAttachmentOperations, disabled } = props; // store hooks const { - attachment: { issueAttachments }, + attachment: { getAttachmentsByIssueId }, } = useIssueDetail(); + const issueAttachments = getAttachmentsByIssueId(issueId); + + if (!issueAttachments) return <>; + return ( <> {issueAttachments && issueAttachments.length > 0 && issueAttachments.map((attachmentId) => ( - + ))} ); diff --git a/web/components/issues/attachment/delete-attachment-confirmation-modal.tsx b/web/components/issues/attachment/delete-attachment-confirmation-modal.tsx index 6c26bf850..e01d2828e 100644 --- a/web/components/issues/attachment/delete-attachment-confirmation-modal.tsx +++ b/web/components/issues/attachment/delete-attachment-confirmation-modal.tsx @@ -8,12 +8,15 @@ import { Button } from "@plane/ui"; import { getFileName } from "helpers/attachment.helper"; // types import type { TIssueAttachment } from "@plane/types"; -import { TIssueAttachmentsList } from "./attachments-list"; +import { TAttachmentOperations } from "./root"; -type Props = TIssueAttachmentsList & { +export type TAttachmentOperationsRemoveModal = Exclude; + +type Props = { isOpen: boolean; setIsOpen: Dispatch>; data: TIssueAttachment; + handleAttachmentOperations: TAttachmentOperationsRemoveModal; }; export const IssueAttachmentDeleteModal: FC = (props) => { diff --git a/web/components/issues/attachment/root.tsx b/web/components/issues/attachment/root.tsx index 209058f9f..79a6dc840 100644 --- a/web/components/issues/attachment/root.tsx +++ b/web/components/issues/attachment/root.tsx @@ -10,8 +10,7 @@ export type TIssueAttachmentRoot = { workspaceSlug: string; projectId: string; issueId: string; - is_archived: boolean; - is_editable: boolean; + disabled?: boolean; }; export type TAttachmentOperations = { @@ -21,7 +20,7 @@ export type TAttachmentOperations = { export const IssueAttachmentRoot: FC = (props) => { // props - const { workspaceSlug, projectId, issueId, is_archived, is_editable } = props; + const { workspaceSlug, projectId, issueId, disabled = false } = props; // hooks const { createAttachment, removeAttachment } = useIssueDetail(); const { setToastAlert } = useToast(); @@ -72,10 +71,14 @@ export const IssueAttachmentRoot: FC = (props) => {
    + -
    ); diff --git a/web/components/issues/description-form.tsx b/web/components/issues/description-form.tsx index cd678735d..8dc3d01d3 100644 --- a/web/components/issues/description-form.tsx +++ b/web/components/issues/description-form.tsx @@ -5,7 +5,7 @@ import useReloadConfirmations from "hooks/use-reload-confirmation"; import debounce from "lodash/debounce"; // components import { TextArea } from "@plane/ui"; -import { RichTextEditor } from "@plane/rich-text-editor"; +import { RichReadOnlyEditor, RichTextEditor } from "@plane/rich-text-editor"; // types import { TIssue } from "@plane/types"; import { TIssueOperations } from "./issue-detail"; @@ -29,7 +29,7 @@ export interface IssueDetailsProps { project_id?: string; }; issueOperations: TIssueOperations; - isAllowed: boolean; + disabled: boolean; isSubmitting: "submitting" | "submitted" | "saved"; setIsSubmitting: (value: "submitting" | "submitted" | "saved") => void; } @@ -37,7 +37,7 @@ export interface IssueDetailsProps { const fileService = new FileService(); export const IssueDescriptionForm: FC = (props) => { - const { workspaceSlug, projectId, issueId, issue, issueOperations, isAllowed, isSubmitting, setIsSubmitting } = props; + const { workspaceSlug, projectId, issueId, issue, issueOperations, disabled, isSubmitting, setIsSubmitting } = props; // states const [characterLimit, setCharacterLimit] = useState(false); @@ -119,7 +119,7 @@ export const IssueDescriptionForm: FC = (props) => { return (
    - {isAllowed ? ( + {!disabled ? ( = (props) => { 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" hasError={Boolean(errors?.name)} role="textbox" - disabled={!isAllowed} /> )} /> ) : (

    {issue.name}

    )} - {characterLimit && isAllowed && ( + {characterLimit && !disabled && (
    255 ? "text-red-500" : ""}`}> {watch("name").length} @@ -162,29 +161,37 @@ export const IssueDescriptionForm: FC = (props) => { ( - { - setShowAlert(true); - setIsSubmitting("submitting"); - onChange(description_html); - debouncedFormSave(); - }} - mentionSuggestions={mentionSuggestions} - mentionHighlights={mentionHighlights} - /> - )} + render={({ field: { onChange } }) => + !disabled ? ( + { + setShowAlert(true); + setIsSubmitting("submitting"); + onChange(description_html); + debouncedFormSave(); + }} + mentionSuggestions={mentionSuggestions} + mentionHighlights={mentionHighlights} + /> + ) : ( + + ) + } />
    diff --git a/web/components/issues/issue-detail/label/create-label.tsx b/web/components/issues/issue-detail/label/create-label.tsx index 94af347d6..7babaee00 100644 --- a/web/components/issues/issue-detail/label/create-label.tsx +++ b/web/components/issues/issue-detail/label/create-label.tsx @@ -74,15 +74,11 @@ export const LabelCreate: FC = (props) => { return ( <>
    - {isCreateToggle ? ( - - ) : ( - - )} + {isCreateToggle ? : }
    {isCreateToggle ? "Cancel" : "New"}
    diff --git a/web/components/issues/issue-detail/label/index.ts b/web/components/issues/issue-detail/label/index.ts index 005620ddd..83f1e73bc 100644 --- a/web/components/issues/issue-detail/label/index.ts +++ b/web/components/issues/issue-detail/label/index.ts @@ -3,3 +3,5 @@ export * from "./root"; export * from "./label-list"; export * from "./label-list-item"; export * from "./create-label"; +export * from "./select/root"; +export * from "./select/label-select"; diff --git a/web/components/issues/issue-detail/label/label-list-item.tsx b/web/components/issues/issue-detail/label/label-list-item.tsx index 3368e9a56..926d287aa 100644 --- a/web/components/issues/issue-detail/label/label-list-item.tsx +++ b/web/components/issues/issue-detail/label/label-list-item.tsx @@ -10,10 +10,11 @@ type TLabelListItem = { issueId: string; labelId: string; labelOperations: TLabelOperations; + disabled: boolean; }; export const LabelListItem: FC = (props) => { - const { workspaceSlug, projectId, issueId, labelId, labelOperations } = props; + const { workspaceSlug, projectId, issueId, labelId, labelOperations, disabled } = props; // hooks const { issue: { getIssueById }, @@ -34,7 +35,9 @@ export const LabelListItem: FC = (props) => { return (
    = (props) => { }} />
    {label.name}
    -
    - -
    + {!disabled && ( +
    + +
    + )}
    ); }; diff --git a/web/components/issues/issue-detail/label/label-list.tsx b/web/components/issues/issue-detail/label/label-list.tsx index b29e9b920..fd714e002 100644 --- a/web/components/issues/issue-detail/label/label-list.tsx +++ b/web/components/issues/issue-detail/label/label-list.tsx @@ -11,10 +11,11 @@ type TLabelList = { projectId: string; issueId: string; labelOperations: TLabelOperations; + disabled: boolean; }; export const LabelList: FC = (props) => { - const { workspaceSlug, projectId, issueId, labelOperations } = props; + const { workspaceSlug, projectId, issueId, labelOperations, disabled } = props; // hooks const { issue: { getIssueById }, @@ -33,6 +34,7 @@ export const LabelList: FC = (props) => { issueId={issueId} labelId={labelId} labelOperations={labelOperations} + disabled={disabled} /> ))} diff --git a/web/components/issues/issue-detail/label/root.tsx b/web/components/issues/issue-detail/label/root.tsx index f0ffdd19d..93e303f61 100644 --- a/web/components/issues/issue-detail/label/root.tsx +++ b/web/components/issues/issue-detail/label/root.tsx @@ -1,8 +1,7 @@ import { FC, useMemo } from "react"; import { observer } from "mobx-react-lite"; // components -import { LabelList, LabelCreate } from "./"; - +import { LabelList, LabelCreate, IssueLabelSelectRoot } from "./"; // hooks import { useIssueDetail, useLabel } from "hooks/store"; // types @@ -77,16 +76,26 @@ export const IssueLabel: FC = observer((props) => { projectId={projectId} issueId={issueId} labelOperations={labelOperations} + disabled={disabled} /> - {/*
    select existing labels
    */} + {!disabled && ( + + )} - + {!disabled && ( + + )}
    ); }); diff --git a/web/components/issues/issue-detail/label/select-existing.tsx b/web/components/issues/issue-detail/label/select-existing.tsx deleted file mode 100644 index f4c287e86..000000000 --- a/web/components/issues/issue-detail/label/select-existing.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { FC } from "react"; - -type TLabelExistingSelect = {}; - -export const LabelExistingSelect: FC = (props) => { - const {} = props; - - return <>; -}; diff --git a/web/components/issues/issue-detail/label/select/label-select.tsx b/web/components/issues/issue-detail/label/select/label-select.tsx new file mode 100644 index 000000000..c553ef333 --- /dev/null +++ b/web/components/issues/issue-detail/label/select/label-select.tsx @@ -0,0 +1,159 @@ +import { Fragment, useState } from "react"; +import { observer } from "mobx-react-lite"; +import { usePopper } from "react-popper"; +import { Check, Search, Tag } from "lucide-react"; +// hooks +import { useIssueDetail, useLabel } from "hooks/store"; +// components +import { Combobox } from "@headlessui/react"; + +export interface IIssueLabelSelect { + workspaceSlug: string; + projectId: string; + issueId: string; + onSelect: (_labelIds: string[]) => void; +} + +export const IssueLabelSelect: React.FC = observer((props) => { + const { workspaceSlug, projectId, issueId, onSelect } = props; + // store hooks + const { + issue: { getIssueById }, + } = useIssueDetail(); + const { + project: { fetchProjectLabels, projectLabels }, + } = useLabel(); + // states + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [query, setQuery] = useState(""); + + const issue = getIssueById(issueId); + + const fetchLabels = () => { + setIsLoading(true); + if (workspaceSlug && projectId) fetchProjectLabels(workspaceSlug, projectId).then(() => setIsLoading(false)); + }; + + const options = (projectLabels ?? []).map((label) => ({ + value: label.id, + query: label.name, + content: ( +
    + +
    {label.name}
    +
    + ), + })); + + const filteredOptions = + query === "" ? options : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase())); + + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: "bottom-start", + modifiers: [ + { + name: "preventOverflow", + options: { + padding: 12, + }, + }, + ], + }); + + const issueLabels = issue?.label_ids ?? []; + + const label = ( +
    +
    + +
    +
    Select Label
    +
    + ); + + if (!issue) return <>; + + return ( + <> + onSelect(value)} + multiple + > + + + + + +
    +
    + + setQuery(e.target.value)} + placeholder="Search" + displayValue={(assigned: any) => assigned?.name} + /> +
    +
    + {isLoading ? ( +

    Loading...

    + ) : filteredOptions.length > 0 ? ( + filteredOptions.map((option) => ( + + `flex cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5 hover:bg-custom-background-80 ${ + selected ? "text-custom-text-100" : "text-custom-text-200" + }` + } + > + {({ selected }) => ( + <> + {option.content} + {selected && ( +
    + +
    + )} + + )} +
    + )) + ) : ( + +

    No matching results

    +
    + )} +
    +
    +
    +
    + + ); +}); diff --git a/web/components/issues/issue-detail/label/select/root.tsx b/web/components/issues/issue-detail/label/select/root.tsx new file mode 100644 index 000000000..c31e1bc61 --- /dev/null +++ b/web/components/issues/issue-detail/label/select/root.tsx @@ -0,0 +1,24 @@ +import { FC } from "react"; +// components +import { IssueLabelSelect } from "./label-select"; +// types +import { TLabelOperations } from "../root"; + +type TIssueLabelSelectRoot = { + workspaceSlug: string; + projectId: string; + issueId: string; + labelOperations: TLabelOperations; +}; + +export const IssueLabelSelectRoot: FC = (props) => { + const { workspaceSlug, projectId, issueId, labelOperations } = props; + + const handleLabel = async (_labelIds: string[]) => { + await labelOperations.updateIssue(workspaceSlug, projectId, issueId, { label_ids: _labelIds }); + }; + + return ( + + ); +}; diff --git a/web/components/issues/issue-detail/links/links.tsx b/web/components/issues/issue-detail/links/links.tsx index dbcb411ce..368bddb91 100644 --- a/web/components/issues/issue-detail/links/links.tsx +++ b/web/components/issues/issue-detail/links/links.tsx @@ -9,20 +9,25 @@ import { TLinkOperations } from "./root"; export type TLinkOperationsModal = Exclude; export type TIssueLinkList = { + issueId: string; linkOperations: TLinkOperationsModal; }; export const IssueLinkList: FC = observer((props) => { // props - const { linkOperations } = props; + const { issueId, linkOperations } = props; // hooks const { - link: { issueLinks }, + link: { getLinksByIssueId }, } = useIssueDetail(); const { membership: { currentProjectRole }, } = useUser(); + const issueLinks = getLinksByIssueId(issueId); + + if (!issueLinks) return <>; + return (
    {issueLinks && diff --git a/web/components/issues/issue-detail/links/root.tsx b/web/components/issues/issue-detail/links/root.tsx index 5a0fb2bdf..1c226b7a7 100644 --- a/web/components/issues/issue-detail/links/root.tsx +++ b/web/components/issues/issue-detail/links/root.tsx @@ -19,13 +19,12 @@ export type TIssueLinkRoot = { workspaceSlug: string; projectId: string; issueId: string; - is_editable: boolean; - is_archived: boolean; + disabled?: boolean; }; export const IssueLinkRoot: FC = (props) => { // props - const { workspaceSlug, projectId, issueId, is_editable, is_archived } = props; + const { workspaceSlug, projectId, issueId, disabled = false } = props; // hooks const { toggleIssueLinkModal: toggleIssueLinkModalStore, createLink, updateLink, removeLink } = useIssueDetail(); // state @@ -108,17 +107,17 @@ export const IssueLinkRoot: FC = (props) => { linkOperations={handleLinkOperations} /> -
    +

    Links

    - {is_editable && ( + {!disabled && ( @@ -126,7 +125,7 @@ export const IssueLinkRoot: FC = (props) => {
    - +
    diff --git a/web/components/issues/issue-detail/main-content.tsx b/web/components/issues/issue-detail/main-content.tsx index 116a0a006..6e7ac4289 100644 --- a/web/components/issues/issue-detail/main-content.tsx +++ b/web/components/issues/issue-detail/main-content.tsx @@ -11,8 +11,6 @@ import { SubIssuesRoot } from "../sub-issues"; import { StateGroupIcon } from "@plane/ui"; // types import { TIssueOperations } from "./root"; -// constants -import { EUserProjectRoles } from "constants/project"; type Props = { workspaceSlug: string; @@ -28,10 +26,7 @@ export const IssueMainContent: React.FC = observer((props) => { // states const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved"); // hooks - const { - currentUser, - membership: { currentProjectRole }, - } = useUser(); + const { currentUser } = useUser(); const { getProjectById } = useProject(); const { projectStates } = useProjectState(); const { @@ -44,8 +39,6 @@ export const IssueMainContent: React.FC = observer((props) => { const projectDetails = projectId ? getProjectById(projectId) : null; const currentIssueState = projectStates?.find((s) => s.id === issue.state_id); - const isAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; - return ( <>
    @@ -78,7 +71,7 @@ export const IssueMainContent: React.FC = observer((props) => { isSubmitting={isSubmitting} issue={issue} issueOperations={issueOperations} - isAllowed={isAllowed || !is_editable} + disabled={!is_editable} /> {currentUser && ( @@ -107,8 +100,7 @@ export const IssueMainContent: React.FC = observer((props) => { workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} - is_archived={is_archived} - is_editable={is_editable} + disabled={!is_editable} /> {/*
    diff --git a/web/components/issues/issue-detail/parent-select.tsx b/web/components/issues/issue-detail/parent-select.tsx index ad1bb6dda..2a7fb3d83 100644 --- a/web/components/issues/issue-detail/parent-select.tsx +++ b/web/components/issues/issue-detail/parent-select.tsx @@ -30,16 +30,20 @@ export const IssueParentSelect: React.FC = observer( const issue = getIssueById(issueId); - const parentIssue = issue && issue.parent_id ? getIssueById(issue.parent_id) : undefined; + const parentIssue = issue?.parent_id ? getIssueById(issue.parent_id) : undefined; const parentIssueProjectDetails = parentIssue && parentIssue.project_id ? getProjectById(parentIssue.project_id) : undefined; const handleParentIssue = async (_issueId: string | null = null) => { setUpdating(true); - await issueOperations.update(workspaceSlug, projectId, issueId, { parent_id: _issueId }).finally(() => { + try { + await issueOperations.update(workspaceSlug, projectId, issueId, { parent_id: _issueId }); + await issueOperations.fetch(workspaceSlug, projectId, issueId); toggleParentIssueModal(false); setUpdating(false); - }); + } catch (error) { + console.error("something went wrong while fetching the issue"); + } }; if (!issue) return <>; @@ -61,14 +65,14 @@ export const IssueParentSelect: React.FC = observer( disabled={disabled} >
    toggleParentIssueModal(true)}> - {parentIssue ? ( + {issue?.parent_id && parentIssue ? ( `${parentIssueProjectDetails?.identifier}-${parentIssue.sequence_id}` ) : ( Select issue )}
    - {parentIssue && ( + {issue?.parent_id && parentIssue && !disabled && (
    handleParentIssue(null)}>
    diff --git a/web/components/issues/issue-detail/relation-select.tsx b/web/components/issues/issue-detail/relation-select.tsx index 801c04ebd..30a81f2dd 100644 --- a/web/components/issues/issue-detail/relation-select.tsx +++ b/web/components/issues/issue-detail/relation-select.tsx @@ -126,22 +126,24 @@ export const IssueRelationSelect: React.FC = observer((pro {issueRelationObject[relationKey].icon(10)} {`${projectDetails?.identifier}-${currentIssue?.sequence_id}`} - + {!disabled && ( + + )}
    ); }) diff --git a/web/components/issues/issue-detail/root.tsx b/web/components/issues/issue-detail/root.tsx index 876f55369..b52857e0a 100644 --- a/web/components/issues/issue-detail/root.tsx +++ b/web/components/issues/issue-detail/root.tsx @@ -8,12 +8,15 @@ import { EmptyState } from "components/common"; // images import emptyIssue from "public/empty-state/issue.svg"; // hooks -import { useIssueDetail } from "hooks/store"; +import { useIssueDetail, useUser } from "hooks/store"; import useToast from "hooks/use-toast"; // types import { TIssue } from "@plane/types"; +// constants +import { EUserProjectRoles } from "constants/project"; export type TIssueOperations = { + fetch: (workspaceSlug: string, projectId: string, issueId: string) => Promise; update: (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => Promise; remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise; addIssueToCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise; @@ -27,16 +30,16 @@ export type TIssueDetailRoot = { projectId: string; issueId: string; is_archived?: boolean; - is_editable?: boolean; }; export const IssueDetailRoot: FC = (props) => { - const { workspaceSlug, projectId, issueId, is_archived = false, is_editable = true } = props; + const { workspaceSlug, projectId, issueId, is_archived = false } = props; // router const router = useRouter(); // hooks const { issue: { getIssueById }, + fetchIssue, updateIssue, removeIssue, addIssueToCycle, @@ -45,9 +48,19 @@ export const IssueDetailRoot: FC = (props) => { removeIssueFromModule, } = useIssueDetail(); const { setToastAlert } = useToast(); + const { + membership: { currentProjectRole }, + } = useUser(); const issueOperations: TIssueOperations = useMemo( () => ({ + fetch: async (workspaceSlug: string, projectId: string, issueId: string) => { + try { + await fetchIssue(workspaceSlug, projectId, issueId); + } catch (error) { + console.error("Error fetching the parent issue"); + } + }, update: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => { try { await updateIssue(workspaceSlug, projectId, issueId, data); @@ -146,6 +159,7 @@ export const IssueDetailRoot: FC = (props) => { }, }), [ + fetchIssue, updateIssue, removeIssue, addIssueToCycle, @@ -156,7 +170,10 @@ export const IssueDetailRoot: FC = (props) => { ] ); + // Issue details const issue = getIssueById(issueId); + // Check if issue is editable, based on user role + const is_editable = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; return ( <> @@ -189,7 +206,7 @@ export const IssueDetailRoot: FC = (props) => { issueId={issueId} issueOperations={issueOperations} is_archived={is_archived} - is_editable={true} + is_editable={is_editable} />
    diff --git a/web/components/issues/issue-detail/sidebar.tsx b/web/components/issues/issue-detail/sidebar.tsx index ce4071f06..a80f88730 100644 --- a/web/components/issues/issue-detail/sidebar.tsx +++ b/web/components/issues/issue-detail/sidebar.tsx @@ -25,8 +25,6 @@ import { ContrastIcon, DiceIcon, DoubleCircleIcon, StateGroupIcon, UserGroupIcon import { copyTextToClipboard } from "helpers/string.helper"; // types import type { TIssueOperations } from "./root"; -// fetch-keys -import { EUserProjectRoles } from "constants/project"; type Props = { workspaceSlug: string; @@ -72,10 +70,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { const { inboxIssueId } = router.query; // store hooks const { getProjectById } = useProject(); - const { - currentUser, - membership: { currentProjectRole }, - } = useUser(); + const { currentUser } = useUser(); const { projectStates } = useProjectState(); const { areEstimatesEnabledForCurrentProject } = useEstimate(); const { setToastAlert } = useToast(); @@ -124,8 +119,6 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { const maxDate = issue.target_date ? new Date(issue.target_date) : null; maxDate?.setDate(maxDate.getDate()); - const isAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; - const currentIssueState = projectStates?.find((s) => s.id === issue.state_id); return ( @@ -166,7 +159,6 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { projectId={projectId} issueId={issueId} currentUserId={currentUser?.id} - disabled={!isAllowed || !is_editable} /> )} @@ -193,7 +185,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => {
    -
    +
    {showFirstSection && (
    {(fieldsToShow.includes("all") || fieldsToShow.includes("state")) && ( @@ -208,7 +200,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { value={issue?.state_id ?? undefined} onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { state_id: val })} projectId={projectId?.toString() ?? ""} - disabled={!isAllowed || !is_editable} + disabled={!is_editable} buttonVariant="background-with-text" />
    @@ -228,7 +220,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val }) } - disabled={!isAllowed || !is_editable} + disabled={!is_editable} projectId={projectId?.toString() ?? ""} placeholder="Assignees" multiple @@ -252,7 +244,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { issueOperations.update(workspaceSlug, projectId, issueId, { priority: val })} - disabled={!isAllowed || !is_editable} + disabled={!is_editable} buttonVariant="background-with-text" />
    @@ -274,7 +266,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { issueOperations.update(workspaceSlug, projectId, issueId, { estimate_point: val }) } projectId={projectId} - disabled={!isAllowed || !is_editable} + disabled={!is_editable} buttonVariant="background-with-text" />
    @@ -297,7 +289,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { projectId={projectId} issueId={issueId} issueOperations={issueOperations} - disabled={!isAllowed || !is_editable} + disabled={!is_editable} />
    @@ -309,7 +301,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { projectId={projectId} issueId={issueId} relationKey="blocking" - disabled={!isAllowed || !is_editable} + disabled={!is_editable} /> )} @@ -319,7 +311,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { projectId={projectId} issueId={issueId} relationKey="blocked_by" - disabled={!isAllowed || !is_editable} + disabled={!is_editable} /> )} @@ -329,7 +321,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { projectId={projectId} issueId={issueId} relationKey="duplicate" - disabled={!isAllowed || !is_editable} + disabled={!is_editable} /> )} @@ -339,7 +331,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { projectId={projectId} issueId={issueId} relationKey="relates_to" - disabled={!isAllowed || !is_editable} + disabled={!is_editable} /> )} @@ -358,7 +350,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { } className="border-none bg-custom-background-80" maxDate={maxDate ?? undefined} - disabled={!isAllowed || !is_editable} + disabled={!is_editable} />
    @@ -379,7 +371,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { } className="border-none bg-custom-background-80" minDate={minDate ?? undefined} - disabled={!isAllowed || !is_editable} + disabled={!is_editable} />
    @@ -401,7 +393,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { projectId={projectId} issueId={issueId} issueOperations={issueOperations} - disabled={!isAllowed || !is_editable} + disabled={!is_editable} />
    @@ -419,7 +411,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { projectId={projectId} issueId={issueId} issueOperations={issueOperations} - disabled={!isAllowed || !is_editable} + disabled={!is_editable} />
    @@ -429,7 +421,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => {
    {(fieldsToShow.includes("all") || fieldsToShow.includes("label")) && ( -
    +

    Label

    @@ -439,7 +431,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} - disabled={!isAllowed || !is_editable} + disabled={!is_editable} />
    @@ -450,8 +442,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} - is_editable={is_editable} - is_archived={is_archived} + disabled={!is_editable} /> )}
    diff --git a/web/components/issues/issue-detail/subscription.tsx b/web/components/issues/issue-detail/subscription.tsx index 8f76eca25..7093f8627 100644 --- a/web/components/issues/issue-detail/subscription.tsx +++ b/web/components/issues/issue-detail/subscription.tsx @@ -5,17 +5,17 @@ import { observer } from "mobx-react-lite"; import { Button } from "@plane/ui"; // hooks import { useIssueDetail } from "hooks/store"; +import useToast from "hooks/use-toast"; export type TIssueSubscription = { workspaceSlug: string; projectId: string; issueId: string; currentUserId: string; - disabled?: boolean; }; export const IssueSubscription: FC = observer((props) => { - const { workspaceSlug, projectId, issueId, currentUserId, disabled } = props; + const { workspaceSlug, projectId, issueId, currentUserId } = props; // hooks const { issue: { getIssueById }, @@ -23,16 +23,32 @@ export const IssueSubscription: FC = observer((props) => { createSubscription, removeSubscription, } = useIssueDetail(); + const { setToastAlert } = useToast(); // state const [loading, setLoading] = useState(false); const issue = getIssueById(issueId); const subscription = getSubscriptionByIssueId(issueId); - const handleSubscription = () => { + const handleSubscription = async () => { setLoading(true); - if (subscription?.subscribed) removeSubscription(workspaceSlug, projectId, issueId); - else createSubscription(workspaceSlug, projectId, issueId); + try { + if (subscription?.subscribed) await removeSubscription(workspaceSlug, projectId, issueId); + else await createSubscription(workspaceSlug, projectId, issueId); + setToastAlert({ + type: "success", + title: `Issue ${subscription?.subscribed ? `unsubscribed` : `subscribed`} successfully.!`, + message: `Issue ${subscription?.subscribed ? `unsubscribed` : `subscribed`} successfully.!`, + }); + setLoading(false); + } catch (error) { + setLoading(false); + setToastAlert({ + type: "error", + title: "Error", + message: "Something went wrong. Please try again later.", + }); + } }; if (issue?.created_by === currentUserId || issue?.assignee_ids.includes(currentUserId)) return <>; @@ -45,7 +61,6 @@ export const IssueSubscription: FC = observer((props) => { variant="outline-primary" className="hover:!bg-custom-primary-100/20" onClick={handleSubscription} - disabled={disabled} > {loading ? "Loading..." : subscription?.subscribed ? "Unsubscribe" : "Subscribe"} diff --git a/web/components/issues/peek-overview/properties.tsx b/web/components/issues/peek-overview/properties.tsx index 7f21f01b7..e6c6d88f3 100644 --- a/web/components/issues/peek-overview/properties.tsx +++ b/web/components/issues/peek-overview/properties.tsx @@ -258,8 +258,6 @@ export const PeekOverviewProperties: FC = observer((pro workspaceSlug={workspaceSlug?.toString() ?? ""} projectId={projectId?.toString() ?? ""} issueId={issue?.id} - is_editable={uneditable} - is_archived={isAllowed} />
    diff --git a/web/components/issues/peek-overview/root.tsx b/web/components/issues/peek-overview/root.tsx index 8253600fd..ab177ed8c 100644 --- a/web/components/issues/peek-overview/root.tsx +++ b/web/components/issues/peek-overview/root.tsx @@ -71,32 +71,6 @@ export const IssuePeekOverview: FC = observer((props) => { }); } }, [peekIssue, fetchIssue]); - if (!peekIssue?.workspaceSlug || !peekIssue?.projectId || !peekIssue?.issueId) return <>; - - const issue = getIssueById(peekIssue.issueId) || undefined; - - const redirectToIssueDetail = () => { - router.push({ - pathname: `/${peekIssue.workspaceSlug}/projects/${peekIssue.projectId}/${ - isArchived ? "archived-issues" : "issues" - }/${peekIssue.issueId}`, - }); - }; - const handleCopyText = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - copyUrlToClipboard( - `${peekIssue.workspaceSlug}/projects/${peekIssue.projectId}/${isArchived ? "archived-issues" : "issues"}/${ - peekIssue.issueId - }` - ).then(() => { - setToastAlert({ - type: "success", - title: "Link Copied!", - message: "Issue link copied to clipboard.", - }); - }); - }; const issueOperations: TIssuePeekOperations = useMemo( () => ({ @@ -168,6 +142,34 @@ export const IssuePeekOverview: FC = observer((props) => { [addIssueToCycle, removeIssueFromCycle, addIssueToModule, removeIssueFromModule, setToastAlert] ); + if (!peekIssue?.workspaceSlug || !peekIssue?.projectId || !peekIssue?.issueId) return <>; + + const issue = getIssueById(peekIssue.issueId) || undefined; + + const redirectToIssueDetail = () => { + router.push({ + pathname: `/${peekIssue.workspaceSlug}/projects/${peekIssue.projectId}/${ + isArchived ? "archived-issues" : "issues" + }/${peekIssue.issueId}`, + }); + }; + + const handleCopyText = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + copyUrlToClipboard( + `${peekIssue.workspaceSlug}/projects/${peekIssue.projectId}/${isArchived ? "archived-issues" : "issues"}/${ + peekIssue.issueId + }` + ).then(() => { + setToastAlert({ + type: "success", + title: "Link Copied!", + message: "Issue link copied to clipboard.", + }); + }); + }; + const issueUpdate = async (_data: Partial) => { if (!issue) return; await updateIssue(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId, _data); From 27762ea500750ae3ce19f820eb6df9c737a33fb8 Mon Sep 17 00:00:00 2001 From: "M. Palanikannan" <73993394+Palanikannan1437@users.noreply.github.com> Date: Thu, 11 Jan 2024 18:29:41 +0530 Subject: [PATCH 28/68] fix: inline code blocks, code blocks and links have saner behaviour (#3318) * fix: removed backticks in inline code blocks * added better error handling while cancelling uploads * fix: inline code blocks, code blocks and links have saner behaviour - Inline code blocks are now exitable, don't have backticks, have better padding vertically and better regex matching - Code blocks on the top and bottom of the document are now exitable via Up and Down Arrow keys - Links are now exitable while being autolinkable via a custom re-write of the tiptap-link-extension * fix: more robust link checking --- packages/editor/core/package.json | 3 +- packages/editor/core/src/styles/editor.css | 5 + .../src/ui/extensions/code-inline/index.tsx | 31 +++ .../core/src/ui/extensions/code/index.tsx | 76 +++++- .../custom-link/helpers/autolink.ts | 118 ++++++++++ .../custom-link/helpers/clickHandler.ts | 42 ++++ .../custom-link/helpers/pasteHandler.ts | 52 +++++ .../src/ui/extensions/custom-link/index.tsx | 219 ++++++++++++++++++ .../editor/core/src/ui/extensions/index.tsx | 23 +- .../core/src/ui/read-only/extensions.tsx | 4 +- web/services/file.service.ts | 14 +- yarn.lock | 11 +- 12 files changed, 567 insertions(+), 31 deletions(-) create mode 100644 packages/editor/core/src/ui/extensions/code-inline/index.tsx create mode 100644 packages/editor/core/src/ui/extensions/custom-link/helpers/autolink.ts create mode 100644 packages/editor/core/src/ui/extensions/custom-link/helpers/clickHandler.ts create mode 100644 packages/editor/core/src/ui/extensions/custom-link/helpers/pasteHandler.ts create mode 100644 packages/editor/core/src/ui/extensions/custom-link/index.tsx diff --git a/packages/editor/core/package.json b/packages/editor/core/package.json index ef2be61e3..7d640e333 100644 --- a/packages/editor/core/package.json +++ b/packages/editor/core/package.json @@ -30,10 +30,10 @@ "dependencies": { "@tiptap/core": "^2.1.13", "@tiptap/extension-blockquote": "^2.1.13", + "@tiptap/extension-code": "^2.1.13", "@tiptap/extension-code-block-lowlight": "^2.1.13", "@tiptap/extension-color": "^2.1.13", "@tiptap/extension-image": "^2.1.13", - "@tiptap/extension-link": "^2.1.13", "@tiptap/extension-list-item": "^2.1.13", "@tiptap/extension-mention": "^2.1.13", "@tiptap/extension-task-item": "^2.1.13", @@ -48,6 +48,7 @@ "clsx": "^1.2.1", "highlight.js": "^11.8.0", "jsx-dom-cjs": "^8.0.3", + "linkifyjs": "^4.1.3", "lowlight": "^3.0.0", "lucide-react": "^0.294.0", "react-moveable": "^0.54.2", diff --git a/packages/editor/core/src/styles/editor.css b/packages/editor/core/src/styles/editor.css index 86822664b..b0d2a1021 100644 --- a/packages/editor/core/src/styles/editor.css +++ b/packages/editor/core/src/styles/editor.css @@ -12,6 +12,11 @@ display: none; } +.ProseMirror code::before, +.ProseMirror code::after { + display: none; +} + .ProseMirror .is-empty::before { content: attr(data-placeholder); float: left; diff --git a/packages/editor/core/src/ui/extensions/code-inline/index.tsx b/packages/editor/core/src/ui/extensions/code-inline/index.tsx new file mode 100644 index 000000000..539dc9346 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/code-inline/index.tsx @@ -0,0 +1,31 @@ +import { markInputRule, markPasteRule } from "@tiptap/core"; +import Code from "@tiptap/extension-code"; + +export const inputRegex = /(? { + const { state } = editor; + const { selection } = state; + const { $from, empty } = selection; + + if (!empty || $from.parent.type !== this.type) { + return false; + } + + // Use ProseMirror's insertText transaction to insert the tab character + const tr = state.tr.insertText("\t", $from.pos, $from.pos); + editor.view.dispatch(tr); + + return true; + }, + ArrowUp: ({ editor }) => { + const { state } = editor; + const { selection } = state; + const { $from, empty } = selection; + + if (!empty || $from.parent.type !== this.type) { + return false; + } + + const isAtStart = $from.parentOffset === 0; + + if (!isAtStart) { + return false; + } + + // Check if codeBlock is the first node + const isFirstNode = $from.depth === 1 && $from.index($from.depth - 1) === 0; + + if (isFirstNode) { + // Insert a new paragraph at the start of the document and move the cursor to it + return editor.commands.command(({ tr }) => { + const node = editor.schema.nodes.paragraph.create(); + tr.insert(0, node); + tr.setSelection(Selection.near(tr.doc.resolve(1))); + return true; + }); + } + + return false; + }, + ArrowDown: ({ editor }) => { + if (!this.options.exitOnArrowDown) { + return false; + } + const { state } = editor; const { selection, doc } = state; const { $from, empty } = selection; @@ -18,7 +69,28 @@ export const CustomCodeBlock = CodeBlockLowlight.extend({ return false; } - return editor.commands.insertContent(" "); + const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2; + + if (!isAtEnd) { + return false; + } + + const after = $from.after(); + + if (after === undefined) { + return false; + } + + const nodeAfter = doc.nodeAt(after); + + if (nodeAfter) { + return editor.commands.command(({ tr }) => { + tr.setSelection(Selection.near(doc.resolve(after))); + return true; + }); + } + + return editor.commands.exitCode(); }, }; }, diff --git a/packages/editor/core/src/ui/extensions/custom-link/helpers/autolink.ts b/packages/editor/core/src/ui/extensions/custom-link/helpers/autolink.ts new file mode 100644 index 000000000..cf67e13d9 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-link/helpers/autolink.ts @@ -0,0 +1,118 @@ +import { + combineTransactionSteps, + findChildrenInRange, + getChangedRanges, + getMarksBetween, + NodeWithPos, +} from "@tiptap/core"; +import { MarkType } from "@tiptap/pm/model"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { find } from "linkifyjs"; + +type AutolinkOptions = { + type: MarkType; + validate?: (url: string) => boolean; +}; + +export function autolink(options: AutolinkOptions): Plugin { + return new Plugin({ + key: new PluginKey("autolink"), + appendTransaction: (transactions, oldState, newState) => { + const docChanges = transactions.some((transaction) => transaction.docChanged) && !oldState.doc.eq(newState.doc); + const preventAutolink = transactions.some((transaction) => transaction.getMeta("preventAutolink")); + + if (!docChanges || preventAutolink) { + return; + } + + const { tr } = newState; + const transform = combineTransactionSteps(oldState.doc, [...transactions]); + const changes = getChangedRanges(transform); + + changes.forEach(({ newRange }) => { + // Now let’s see if we can add new links. + const nodesInChangedRanges = findChildrenInRange(newState.doc, newRange, (node) => node.isTextblock); + + let textBlock: NodeWithPos | undefined; + let textBeforeWhitespace: string | undefined; + + if (nodesInChangedRanges.length > 1) { + // Grab the first node within the changed ranges (ex. the first of two paragraphs when hitting enter). + textBlock = nodesInChangedRanges[0]; + textBeforeWhitespace = newState.doc.textBetween( + textBlock.pos, + textBlock.pos + textBlock.node.nodeSize, + undefined, + " " + ); + } else if ( + nodesInChangedRanges.length && + // We want to make sure to include the block seperator argument to treat hard breaks like spaces. + newState.doc.textBetween(newRange.from, newRange.to, " ", " ").endsWith(" ") + ) { + textBlock = nodesInChangedRanges[0]; + textBeforeWhitespace = newState.doc.textBetween(textBlock.pos, newRange.to, undefined, " "); + } + + if (textBlock && textBeforeWhitespace) { + const wordsBeforeWhitespace = textBeforeWhitespace.split(" ").filter((s) => s !== ""); + + if (wordsBeforeWhitespace.length <= 0) { + return false; + } + + const lastWordBeforeSpace = wordsBeforeWhitespace[wordsBeforeWhitespace.length - 1]; + const lastWordAndBlockOffset = textBlock.pos + textBeforeWhitespace.lastIndexOf(lastWordBeforeSpace); + + if (!lastWordBeforeSpace) { + return false; + } + + find(lastWordBeforeSpace) + .filter((link) => link.isLink) + // Calculate link position. + .map((link) => ({ + ...link, + from: lastWordAndBlockOffset + link.start + 1, + to: lastWordAndBlockOffset + link.end + 1, + })) + // ignore link inside code mark + .filter((link) => { + if (!newState.schema.marks.code) { + return true; + } + + return !newState.doc.rangeHasMark(link.from, link.to, newState.schema.marks.code); + }) + // validate link + .filter((link) => { + if (options.validate) { + return options.validate(link.value); + } + return true; + }) + // Add link mark. + .forEach((link) => { + if (getMarksBetween(link.from, link.to, newState.doc).some((item) => item.mark.type === options.type)) { + return; + } + + tr.addMark( + link.from, + link.to, + options.type.create({ + href: link.href, + }) + ); + }); + } + }); + + if (!tr.steps.length) { + return; + } + + return tr; + }, + }); +} diff --git a/packages/editor/core/src/ui/extensions/custom-link/helpers/clickHandler.ts b/packages/editor/core/src/ui/extensions/custom-link/helpers/clickHandler.ts new file mode 100644 index 000000000..0854092a9 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-link/helpers/clickHandler.ts @@ -0,0 +1,42 @@ +import { getAttributes } from "@tiptap/core"; +import { MarkType } from "@tiptap/pm/model"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; + +type ClickHandlerOptions = { + type: MarkType; +}; + +export function clickHandler(options: ClickHandlerOptions): Plugin { + return new Plugin({ + key: new PluginKey("handleClickLink"), + props: { + handleClick: (view, pos, event) => { + if (event.button !== 0) { + return false; + } + + const eventTarget = event.target as HTMLElement; + + if (eventTarget.nodeName !== "A") { + return false; + } + + const attrs = getAttributes(view.state, options.type.name); + const link = event.target as HTMLLinkElement; + + const href = link?.href ?? attrs.href; + const target = link?.target ?? attrs.target; + + if (link && href) { + if (view.editable) { + window.open(href, target); + } + + return true; + } + + return false; + }, + }, + }); +} diff --git a/packages/editor/core/src/ui/extensions/custom-link/helpers/pasteHandler.ts b/packages/editor/core/src/ui/extensions/custom-link/helpers/pasteHandler.ts new file mode 100644 index 000000000..83e38054c --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-link/helpers/pasteHandler.ts @@ -0,0 +1,52 @@ +import { Editor } from "@tiptap/core"; +import { MarkType } from "@tiptap/pm/model"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { find } from "linkifyjs"; + +type PasteHandlerOptions = { + editor: Editor; + type: MarkType; +}; + +export function pasteHandler(options: PasteHandlerOptions): Plugin { + return new Plugin({ + key: new PluginKey("handlePasteLink"), + props: { + handlePaste: (view, event, slice) => { + const { state } = view; + const { selection } = state; + const { empty } = selection; + + if (empty) { + return false; + } + + let textContent = ""; + + slice.content.forEach((node) => { + textContent += node.textContent; + }); + + const link = find(textContent).find((item) => item.isLink && item.value === textContent); + + if (!textContent || !link) { + return false; + } + + const html = event.clipboardData?.getData("text/html"); + + const hrefRegex = /href="([^"]*)"/; + + const existingLink = html?.match(hrefRegex); + + const url = existingLink ? existingLink[1] : link.href; + + options.editor.commands.setMark(options.type, { + href: url, + }); + + return true; + }, + }, + }); +} diff --git a/packages/editor/core/src/ui/extensions/custom-link/index.tsx b/packages/editor/core/src/ui/extensions/custom-link/index.tsx new file mode 100644 index 000000000..e66d18904 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-link/index.tsx @@ -0,0 +1,219 @@ +import { Mark, markPasteRule, mergeAttributes } from "@tiptap/core"; +import { Plugin } from "@tiptap/pm/state"; +import { find, registerCustomProtocol, reset } from "linkifyjs"; + +import { autolink } from "src/ui/extensions/custom-link/helpers/autolink"; +import { clickHandler } from "src/ui/extensions/custom-link/helpers/clickHandler"; +import { pasteHandler } from "src/ui/extensions/custom-link/helpers/pasteHandler"; + +export interface LinkProtocolOptions { + scheme: string; + optionalSlashes?: boolean; +} + +export interface LinkOptions { + autolink: boolean; + inclusive: boolean; + protocols: Array; + openOnClick: boolean; + linkOnPaste: boolean; + HTMLAttributes: Record; + validate?: (url: string) => boolean; +} + +declare module "@tiptap/core" { + interface Commands { + link: { + setLink: (attributes: { + href: string; + target?: string | null; + rel?: string | null; + class?: string | null; + }) => ReturnType; + toggleLink: (attributes: { + href: string; + target?: string | null; + rel?: string | null; + class?: string | null; + }) => ReturnType; + unsetLink: () => ReturnType; + }; + } +} + +export const CustomLinkExtension = Mark.create({ + name: "link", + + priority: 1000, + + keepOnSplit: false, + + onCreate() { + this.options.protocols.forEach((protocol) => { + if (typeof protocol === "string") { + registerCustomProtocol(protocol); + return; + } + registerCustomProtocol(protocol.scheme, protocol.optionalSlashes); + }); + }, + + onDestroy() { + reset(); + }, + + inclusive() { + return this.options.inclusive; + }, + + addOptions() { + return { + openOnClick: true, + linkOnPaste: true, + autolink: true, + inclusive: false, + protocols: [], + HTMLAttributes: { + target: "_blank", + rel: "noopener noreferrer nofollow", + class: null, + }, + validate: undefined, + }; + }, + + addAttributes() { + return { + href: { + default: null, + }, + target: { + default: this.options.HTMLAttributes.target, + }, + rel: { + default: this.options.HTMLAttributes.rel, + }, + class: { + default: this.options.HTMLAttributes.class, + }, + }; + }, + + parseHTML() { + return [ + { + tag: "a[href]", + getAttrs: (node) => { + if (typeof node === "string" || !(node instanceof HTMLElement)) { + return null; + } + const href = node.getAttribute("href")?.toLowerCase() || ""; + if (href.startsWith("javascript:") || href.startsWith("data:") || href.startsWith("vbscript:")) { + return false; + } + return {}; + }, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + const href = HTMLAttributes.href?.toLowerCase() || ""; + if (href.startsWith("javascript:") || href.startsWith("data:") || href.startsWith("vbscript:")) { + return ["a", mergeAttributes(this.options.HTMLAttributes, { ...HTMLAttributes, href: "" }), 0]; + } + return ["a", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; + }, + + addCommands() { + return { + setLink: + (attributes) => + ({ chain }) => + chain().setMark(this.name, attributes).setMeta("preventAutolink", true).run(), + + toggleLink: + (attributes) => + ({ chain }) => + chain() + .toggleMark(this.name, attributes, { extendEmptyMarkRange: true }) + .setMeta("preventAutolink", true) + .run(), + + unsetLink: + () => + ({ chain }) => + chain().unsetMark(this.name, { extendEmptyMarkRange: true }).setMeta("preventAutolink", true).run(), + }; + }, + + addPasteRules() { + return [ + markPasteRule({ + find: (text) => + find(text) + .filter((link) => { + if (this.options.validate) { + return this.options.validate(link.value); + } + return true; + }) + .filter((link) => link.isLink) + .map((link) => ({ + text: link.value, + index: link.start, + data: link, + })), + type: this.type, + getAttributes: (match, pasteEvent) => { + const html = pasteEvent?.clipboardData?.getData("text/html"); + const hrefRegex = /href="([^"]*)"/; + + const existingLink = html?.match(hrefRegex); + + if (existingLink) { + return { + href: existingLink[1], + }; + } + + return { + href: match.data?.href, + }; + }, + }), + ]; + }, + + addProseMirrorPlugins() { + const plugins: Plugin[] = []; + + if (this.options.autolink) { + plugins.push( + autolink({ + type: this.type, + validate: this.options.validate, + }) + ); + } + + if (this.options.openOnClick) { + plugins.push( + clickHandler({ + type: this.type, + }) + ); + } + + if (this.options.linkOnPaste) { + plugins.push( + pasteHandler({ + editor: this.editor, + type: this.type, + }) + ); + } + + return plugins; + }, +}); diff --git a/packages/editor/core/src/ui/extensions/index.tsx b/packages/editor/core/src/ui/extensions/index.tsx index 396d0a821..fab0d5b74 100644 --- a/packages/editor/core/src/ui/extensions/index.tsx +++ b/packages/editor/core/src/ui/extensions/index.tsx @@ -1,5 +1,4 @@ import StarterKit from "@tiptap/starter-kit"; -import TiptapLink from "@tiptap/extension-link"; import TiptapUnderline from "@tiptap/extension-underline"; import TextStyle from "@tiptap/extension-text-style"; import { Color } from "@tiptap/extension-color"; @@ -19,13 +18,15 @@ import { isValidHttpUrl } from "src/lib/utils"; import { Mentions } from "src/ui/mentions"; import { CustomKeymap } from "src/ui/extensions/keymap"; -import { CustomCodeBlock } from "src/ui/extensions/code"; +import { CustomCodeBlockExtension } from "src/ui/extensions/code"; import { CustomQuoteExtension } from "src/ui/extensions/quote"; import { ListKeymap } from "src/ui/extensions/custom-list-keymap"; import { DeleteImage } from "src/types/delete-image"; import { IMentionSuggestion } from "src/types/mention-suggestion"; import { RestoreImage } from "src/types/restore-image"; +import { CustomLinkExtension } from "src/ui/extensions/custom-link"; +import { CustomCodeInlineExtension } from "./code-inline"; export const CoreEditorExtensions = ( mentionConfig: { @@ -52,12 +53,7 @@ export const CoreEditorExtensions = ( class: "leading-normal -mb-2", }, }, - code: { - HTMLAttributes: { - class: "rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000", - spellcheck: "false", - }, - }, + code: false, codeBlock: false, horizontalRule: false, dropcursor: { @@ -70,10 +66,12 @@ export const CoreEditorExtensions = ( }), CustomKeymap, ListKeymap, - TiptapLink.configure({ - autolink: false, + CustomLinkExtension.configure({ + openOnClick: true, + autolink: true, + linkOnPaste: true, protocols: ["http", "https"], - validate: (url) => isValidHttpUrl(url), + validate: (url: string) => isValidHttpUrl(url), HTMLAttributes: { class: "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", @@ -92,13 +90,14 @@ export const CoreEditorExtensions = ( class: "not-prose pl-2", }, }), - CustomCodeBlock, TaskItem.configure({ HTMLAttributes: { class: "flex items-start my-4", }, nested: true, }), + CustomCodeBlockExtension, + CustomCodeInlineExtension, Markdown.configure({ html: true, transformCopiedText: true, diff --git a/packages/editor/core/src/ui/read-only/extensions.tsx b/packages/editor/core/src/ui/read-only/extensions.tsx index 5795d6c4a..b0879d8cd 100644 --- a/packages/editor/core/src/ui/read-only/extensions.tsx +++ b/packages/editor/core/src/ui/read-only/extensions.tsx @@ -1,5 +1,4 @@ import StarterKit from "@tiptap/starter-kit"; -import TiptapLink from "@tiptap/extension-link"; import TiptapUnderline from "@tiptap/extension-underline"; import TextStyle from "@tiptap/extension-text-style"; import { Color } from "@tiptap/extension-color"; @@ -18,6 +17,7 @@ import { ReadOnlyImageExtension } from "src/ui/extensions/image/read-only-image" import { isValidHttpUrl } from "src/lib/utils"; import { Mentions } from "src/ui/mentions"; import { IMentionSuggestion } from "src/types/mention-suggestion"; +import { CustomLinkExtension } from "src/ui/extensions/custom-link"; export const CoreReadOnlyEditorExtensions = (mentionConfig: { mentionSuggestions: IMentionSuggestion[]; @@ -59,7 +59,7 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: { gapcursor: false, }), Gapcursor, - TiptapLink.configure({ + CustomLinkExtension.configure({ protocols: ["http", "https"], validate: (url) => isValidHttpUrl(url), HTMLAttributes: { diff --git a/web/services/file.service.ts b/web/services/file.service.ts index 6c75094cc..4085a7309 100644 --- a/web/services/file.service.ts +++ b/web/services/file.service.ts @@ -65,12 +65,16 @@ export class FileService extends APIService { getUploadFileFunction(workspaceSlug: string): (file: File) => Promise { return async (file: File) => { - const formData = new FormData(); - formData.append("asset", file); - formData.append("attributes", JSON.stringify({})); + try { + const formData = new FormData(); + formData.append("asset", file); + formData.append("attributes", JSON.stringify({})); - const data = await this.uploadFile(workspaceSlug, formData); - return data.asset; + const data = await this.uploadFile(workspaceSlug, formData); + return data.asset; + } catch (e) { + console.error(e); + } }; } diff --git a/yarn.lock b/yarn.lock index 4318bee68..282a02537 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2492,13 +2492,6 @@ resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-2.1.13.tgz#1e9521dea002c8d6de833d9fd928d4617623eab8" integrity sha512-HyDJfuDn5hzwGKZiANcvgz6wcum6bEgb4wmJnfej8XanTMJatNVv63TVxCJ10dSc9KGpPVcIkg6W8/joNXIEbw== -"@tiptap/extension-link@^2.1.13": - version "2.1.13" - resolved "https://registry.yarnpkg.com/@tiptap/extension-link/-/extension-link-2.1.13.tgz#ae4abd7c43292e3a1841488bfc7a687b2f014249" - integrity sha512-wuGMf3zRtMHhMrKm9l6Tft5M2N21Z0UP1dZ5t1IlOAvOeYV2QZ5UynwFryxGKLO0NslCBLF/4b/HAdNXbfXWUA== - dependencies: - linkifyjs "^4.1.0" - "@tiptap/extension-list-item@^2.1.13": version "2.1.13" resolved "https://registry.yarnpkg.com/@tiptap/extension-list-item/-/extension-list-item-2.1.13.tgz#3c62127df97974f3196866ec00ee397f4c9acdc4" @@ -2802,7 +2795,7 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@18.2.42", "@types/react@^18.2.42": +"@types/react@*", "@types/react@^18.2.42": version "18.2.42" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.42.tgz#6f6b11a904f6d96dda3c2920328a97011a00aba7" integrity sha512-c1zEr96MjakLYus/wPnuWDo1/zErfdU9rNsIGmE+NV71nx88FG9Ttgo5dqorXTu/LImX2f63WBP986gJkMPNbA== @@ -6071,7 +6064,7 @@ linkify-it@^5.0.0: dependencies: uc.micro "^2.0.0" -linkifyjs@^4.1.0: +linkifyjs@^4.1.3: version "4.1.3" resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-4.1.3.tgz#0edbc346428a7390a23ea2e5939f76112c9ae07f" integrity sha512-auMesunaJ8yfkHvK4gfg1K0SaKX/6Wn9g2Aac/NwX+l5VdmFZzo/hdPGxEOETj+ryRa4/fiOPjeeKURSAJx1sg== From a679b42200c0687fada40ab8da2a6074720b9d02 Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Thu, 11 Jan 2024 18:40:26 +0530 Subject: [PATCH 29/68] fix: create sync action (#3353) * fix: create sync action changes * fix: typo changes --- .github/workflows/create-sync-pr.yml | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/.github/workflows/create-sync-pr.yml b/.github/workflows/create-sync-pr.yml index 0f85e940c..add08d1ed 100644 --- a/.github/workflows/create-sync-pr.yml +++ b/.github/workflows/create-sync-pr.yml @@ -3,14 +3,14 @@ name: Create Sync Action on: pull_request: branches: - - develop # Change this to preview + - preview types: - closed -env: +env: SOURCE_BRANCH_NAME: ${{github.event.pull_request.base.ref}} jobs: - create_pr: + sync_changes: # Only run the job when a PR is merged if: github.event.pull_request.merged == true runs-on: ubuntu-latest @@ -33,23 +33,14 @@ jobs: sudo apt update sudo apt install gh -y - - name: Create Pull Request + - name: Push Changes to Target Repo env: GH_TOKEN: ${{ secrets.ACCESS_TOKEN }} run: | TARGET_REPO="${{ secrets.SYNC_TARGET_REPO_NAME }}" TARGET_BRANCH="${{ secrets.SYNC_TARGET_BRANCH_NAME }}" - TARGET_BASE_BRANCH="${{ secrets.SYNC_TARGET_BASE_BRANCH_NAME }}" SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}" git checkout $SOURCE_BRANCH git remote add target-origin "https://$GH_TOKEN@github.com/$TARGET_REPO.git" git push target-origin $SOURCE_BRANCH:$TARGET_BRANCH - - PR_TITLE=${{secrets.SYNC_PR_TITLE}} - - gh pr create \ - --base $TARGET_BASE_BRANCH \ - --head $TARGET_BRANCH \ - --title "$PR_TITLE" \ - --repo $TARGET_REPO From 7ff91fdb82194d301b836a615bbd901143b6fcab Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Thu, 11 Jan 2024 21:01:05 +0530 Subject: [PATCH 30/68] fix: create more toggle fixes in create issue modal (#3355) * fix: create more issue bugfixes * fix: removing all warning --- .../editor/core/src/ui/extensions/index.tsx | 1 + .../core/src/ui/extensions/quote/index.tsx | 3 +- .../extensions/src/extensions/drag-drop.tsx | 31 ++++++++++--------- .../progress/circular-progress-indicator.tsx | 4 +-- .../cycles/active-cycle-details.tsx | 7 ++--- web/components/cycles/cycles-list.tsx | 7 ++++- .../issue-layouts/calendar/issue-blocks.tsx | 3 -- .../kanban/headers/group-by-card.tsx | 1 - .../issue-layouts/kanban/kanban-group.tsx | 1 - .../list/quick-add-issue-form.tsx | 2 +- .../quick-action-dropdowns/all-issue.tsx | 8 ++--- .../quick-action-dropdowns/archived-issue.tsx | 4 +-- .../quick-action-dropdowns/cycle-issue.tsx | 10 +++--- .../quick-action-dropdowns/module-issue.tsx | 8 ++--- .../quick-action-dropdowns/project-issue.tsx | 8 ++--- .../roots/all-issue-layout-root.tsx | 2 +- .../issue-layouts/spreadsheet/issue-row.tsx | 22 ++++++++----- .../spreadsheet/quick-add-issue-form.tsx | 5 --- .../spreadsheet/spreadsheet-view.tsx | 3 +- .../issues/issue-modal/draft-issue-layout.tsx | 23 ++++++++++++-- web/components/issues/issue-modal/form.tsx | 15 +++++---- web/components/issues/issue-modal/modal.tsx | 13 ++++++-- .../issues/peek-overview/properties.tsx | 9 +++--- web/components/issues/peek-overview/view.tsx | 5 +-- .../project/create-project-modal.tsx | 2 +- web/components/workspace/sidebar-menu.tsx | 3 +- web/lib/app-provider.tsx | 5 ++- .../projects/[projectId]/pages/[pageId].tsx | 6 +--- .../settings/webhooks/[webhookId].tsx | 4 +-- yarn.lock | 2 +- 30 files changed, 119 insertions(+), 98 deletions(-) diff --git a/packages/editor/core/src/ui/extensions/index.tsx b/packages/editor/core/src/ui/extensions/index.tsx index fab0d5b74..19d8ce894 100644 --- a/packages/editor/core/src/ui/extensions/index.tsx +++ b/packages/editor/core/src/ui/extensions/index.tsx @@ -56,6 +56,7 @@ export const CoreEditorExtensions = ( code: false, codeBlock: false, horizontalRule: false, + blockquote: false, dropcursor: { color: "rgba(var(--color-text-100))", width: 2, diff --git a/packages/editor/core/src/ui/extensions/quote/index.tsx b/packages/editor/core/src/ui/extensions/quote/index.tsx index a2c968401..9dcae6ad7 100644 --- a/packages/editor/core/src/ui/extensions/quote/index.tsx +++ b/packages/editor/core/src/ui/extensions/quote/index.tsx @@ -1,10 +1,9 @@ -import { isAtStartOfNode } from "@tiptap/core"; import Blockquote from "@tiptap/extension-blockquote"; export const CustomQuoteExtension = Blockquote.extend({ addKeyboardShortcuts() { return { - Enter: ({ editor }) => { + Enter: () => { const { $from, $to, $head } = this.editor.state.selection; const parent = $head.node(-1); diff --git a/packages/editor/extensions/src/extensions/drag-drop.tsx b/packages/editor/extensions/src/extensions/drag-drop.tsx index 269caad93..acdc99cf4 100644 --- a/packages/editor/extensions/src/extensions/drag-drop.tsx +++ b/packages/editor/extensions/src/extensions/drag-drop.tsx @@ -43,22 +43,23 @@ function absoluteRect(node: Element) { } function nodeDOMAtCoords(coords: { x: number; y: number }) { - return document.elementsFromPoint(coords.x, coords.y).find((elem: Element) => { - return ( - elem.parentElement?.matches?.(".ProseMirror") || - elem.matches( - [ - "li", - "p:not(:first-child)", - "pre", - "blockquote", - "h1, h2, h3", - "[data-type=horizontalRule]", - ".tableWrapper", - ].join(", ") - ) + return document + .elementsFromPoint(coords.x, coords.y) + .find( + (elem: Element) => + elem.parentElement?.matches?.(".ProseMirror") || + elem.matches( + [ + "li", + "p:not(:first-child)", + "pre", + "blockquote", + "h1, h2, h3", + "[data-type=horizontalRule]", + ".tableWrapper", + ].join(", ") + ) ); - }); } function nodePosAtDOM(node: Element, view: EditorView) { diff --git a/packages/ui/src/progress/circular-progress-indicator.tsx b/packages/ui/src/progress/circular-progress-indicator.tsx index d445480c7..66c70475f 100644 --- a/packages/ui/src/progress/circular-progress-indicator.tsx +++ b/packages/ui/src/progress/circular-progress-indicator.tsx @@ -35,9 +35,9 @@ export const CircularProgressIndicator: React.FC = ( width="45.2227" height="45.2227" filterUnits="userSpaceOnUse" - color-interpolation-filters="sRGB" + colorInterpolationFilters="sRGB" > - + diff --git a/web/components/cycles/active-cycle-details.tsx b/web/components/cycles/active-cycle-details.tsx index eb06c693a..ed946bd82 100644 --- a/web/components/cycles/active-cycle-details.tsx +++ b/web/components/cycles/active-cycle-details.tsx @@ -1,6 +1,5 @@ import { MouseEvent } from "react"; import Link from "next/link"; -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import useSWR from "swr"; // hooks @@ -69,15 +68,13 @@ interface IActiveCycleDetails { } export const ActiveCycleDetails: React.FC = observer((props) => { - // router - const router = useRouter(); + // props const { workspaceSlug, projectId } = props; - + // store hooks const { issues: { issues, fetchActiveCycleIssues }, issueMap, } = useIssues(EIssuesStoreType.CYCLE); - // store hooks const { commandPalette: { toggleCreateCycleModal }, } = useApplication(); diff --git a/web/components/cycles/cycles-list.tsx b/web/components/cycles/cycles-list.tsx index 686937b71..87796340e 100644 --- a/web/components/cycles/cycles-list.tsx +++ b/web/components/cycles/cycles-list.tsx @@ -31,7 +31,12 @@ export const CyclesList: FC = observer((props) => {
    {cycleIds.map((cycleId) => ( - + ))}
    = observer((props) => { const { issues, issueIdList, quickActions, showAllIssues = false } = props; - // router - const router = useRouter(); // hooks const { router: { workspaceSlug, projectId }, diff --git a/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx b/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx index decc816f6..5095b9bec 100644 --- a/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx +++ b/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx @@ -40,7 +40,6 @@ export const HeaderGroupByCard: FC = observer((props) => { handleKanbanFilters, issuePayload, disableIssueCreation, - currentStore, addIssuesToView, } = props; const verticalAlignPosition = sub_group_by ? false : kanbanFilters?.group_by.includes(column_id); diff --git a/web/components/issues/issue-layouts/kanban/kanban-group.tsx b/web/components/issues/issue-layouts/kanban/kanban-group.tsx index cec36ad0c..0858190d3 100644 --- a/web/components/issues/issue-layouts/kanban/kanban-group.tsx +++ b/web/components/issues/issue-layouts/kanban/kanban-group.tsx @@ -55,7 +55,6 @@ export const KanbanGroup = (props: IKanbanGroup) => { disableIssueCreation, quickAddCallback, viewId, - groupByVisibilityToggle, } = props; // hooks const projectState = useProjectState(); diff --git a/web/components/issues/issue-layouts/list/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/list/quick-add-issue-form.tsx index a2239d2a4..540d4d7f6 100644 --- a/web/components/issues/issue-layouts/list/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/list/quick-add-issue-form.tsx @@ -4,7 +4,7 @@ import { useForm } from "react-hook-form"; import { PlusIcon } from "lucide-react"; import { observer } from "mobx-react-lite"; // hooks -import { useProject, useWorkspace } from "hooks/store"; +import { useProject } from "hooks/store"; import useToast from "hooks/use-toast"; import useKeypress from "hooks/use-keypress"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx index d8448cc0e..9d2d83071 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx @@ -67,7 +67,7 @@ export const AllIssueQuickActions: React.FC = (props) => { ellipsis > { + onClick={() => { handleCopyIssueLink(); }} > @@ -77,7 +77,7 @@ export const AllIssueQuickActions: React.FC = (props) => {
    { + onClick={() => { setIssueToEdit(issue); setCreateUpdateIssueModal(true); }} @@ -88,7 +88,7 @@ export const AllIssueQuickActions: React.FC = (props) => {
    { + onClick={() => { setCreateUpdateIssueModal(true); }} > @@ -98,7 +98,7 @@ export const AllIssueQuickActions: React.FC = (props) => {
    { + onClick={() => { setDeleteIssueModal(true); }} > diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx index 100ae99db..264093778 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx @@ -48,7 +48,7 @@ export const ArchivedIssueQuickActions: React.FC = (props) => ellipsis > { + onClick={() => { handleCopyIssueLink(); }} > @@ -58,7 +58,7 @@ export const ArchivedIssueQuickActions: React.FC = (props) => { + onClick={() => { setDeleteIssueModal(true); }} > diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx index 7d708145b..130d9a3b7 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx @@ -67,7 +67,7 @@ export const CycleIssueQuickActions: React.FC = (props) => { ellipsis > { + onClick={() => { handleCopyIssueLink(); }} > @@ -77,7 +77,7 @@ export const CycleIssueQuickActions: React.FC = (props) => { { + onClick={() => { setIssueToEdit({ ...issue, cycle: cycleId?.toString() ?? null, @@ -91,7 +91,7 @@ export const CycleIssueQuickActions: React.FC = (props) => { { + onClick={() => { handleRemoveFromView && handleRemoveFromView(); }} > @@ -101,7 +101,7 @@ export const CycleIssueQuickActions: React.FC = (props) => { { + onClick={() => { setCreateUpdateIssueModal(true); }} > @@ -111,7 +111,7 @@ export const CycleIssueQuickActions: React.FC = (props) => { { + onClick={() => { setDeleteIssueModal(true); }} > diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx index ac12c0d9b..49a086496 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx @@ -67,7 +67,7 @@ export const ModuleIssueQuickActions: React.FC = (props) => { ellipsis > { + onClick={() => { handleCopyIssueLink(); }} > @@ -77,7 +77,7 @@ export const ModuleIssueQuickActions: React.FC = (props) => { { + onClick={() => { setIssueToEdit({ ...issue, module: moduleId?.toString() ?? null }); setCreateUpdateIssueModal(true); }} @@ -88,7 +88,7 @@ export const ModuleIssueQuickActions: React.FC = (props) => { { + onClick={() => { handleRemoveFromView && handleRemoveFromView(); }} > @@ -98,7 +98,7 @@ export const ModuleIssueQuickActions: React.FC = (props) => { { + onClick={() => { setCreateUpdateIssueModal(true); }} > diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx index c0fa556b3..c39633965 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx @@ -76,7 +76,7 @@ export const ProjectIssueQuickActions: React.FC = (props) => ellipsis > { + onClick={() => { handleCopyIssueLink(); }} > @@ -88,7 +88,7 @@ export const ProjectIssueQuickActions: React.FC = (props) => {isEditingAllowed && ( <> { + onClick={() => { setIssueToEdit(issue); setCreateUpdateIssueModal(true); }} @@ -99,7 +99,7 @@ export const ProjectIssueQuickActions: React.FC = (props) => { + onClick={() => { setCreateUpdateIssueModal(true); }} > @@ -109,7 +109,7 @@ export const ProjectIssueQuickActions: React.FC = (props) => { + onClick={() => { setDeleteIssueModal(true); }} > diff --git a/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx b/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx index cf585a6fc..18d60bf68 100644 --- a/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx @@ -66,7 +66,7 @@ export const AllIssueLayoutRoot: React.FC = observer((props) => { }; const issueIds = (groupedIssueIds ?? []) as TUnGroupedIssues; - const issuesArray = issueIds?.filter((id) => id && issueMap?.[id]).map((id) => issueMap?.[id]); + const issuesArray = issueIds?.filter((id: string) => id && issueMap?.[id]).map((id: string) => issueMap?.[id]); const issueActions = useMemo( () => ({ diff --git a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx index 0091f000c..60847ec54 100644 --- a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx @@ -1,14 +1,20 @@ -import { IIssueDisplayProperties, TIssue, TIssueMap } from "@plane/types"; -import { SPREADSHEET_PROPERTY_DETAILS, SPREADSHEET_PROPERTY_LIST } from "constants/spreadsheet"; -import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; -import { Tooltip } from "@plane/ui"; -import useOutsideClickDetector from "hooks/use-outside-click-detector"; -import { useIssueDetail, useProject } from "hooks/store"; import { useRef, useState } from "react"; import { useRouter } from "next/router"; -import { ChevronRight, MoreHorizontal } from "lucide-react"; -import { EIssueActions } from "../types"; import { observer } from "mobx-react-lite"; +// icons +import { ChevronRight, MoreHorizontal } from "lucide-react"; +// constants +import { SPREADSHEET_PROPERTY_DETAILS, SPREADSHEET_PROPERTY_LIST } from "constants/spreadsheet"; +// components +import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; +// ui +import { Tooltip } from "@plane/ui"; +// hooks +import useOutsideClickDetector from "hooks/use-outside-click-detector"; +import { useIssueDetail, useProject } from "hooks/store"; +// types +import { IIssueDisplayProperties, TIssue } from "@plane/types"; +import { EIssueActions } from "../types"; interface Props { displayProperties: IIssueDisplayProperties; diff --git a/web/components/issues/issue-layouts/spreadsheet/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/spreadsheet/quick-add-issue-form.tsx index 44eb3a198..605e8bea1 100644 --- a/web/components/issues/issue-layouts/spreadsheet/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/quick-add-issue-form.tsx @@ -1,5 +1,4 @@ import { useEffect, useState, useRef } from "react"; -import { useRouter } from "next/router"; import { useForm } from "react-hook-form"; import { observer } from "mobx-react-lite"; import { PlusIcon } from "lucide-react"; @@ -56,10 +55,6 @@ const Inputs = (props: any) => { export const SpreadsheetQuickAddIssueForm: React.FC = observer((props) => { const { formKey, prePopulatedData, quickAddCallback, viewId } = props; - - // router - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; // store hooks const { currentWorkspace } = useWorkspace(); const { currentProjectDetails } = useProject(); diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx index adf1e53f4..89926f281 100644 --- a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { useEffect, useRef } from "react"; import { observer } from "mobx-react-lite"; // components import { Spinner } from "@plane/ui"; @@ -9,7 +9,6 @@ import { EIssueActions } from "../types"; import { useProject } from "hooks/store"; import { SpreadsheetHeader } from "./spreadsheet-header"; import { SpreadsheetIssueRow } from "./issue-row"; -import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; type Props = { displayProperties: IIssueDisplayProperties; diff --git a/web/components/issues/issue-modal/draft-issue-layout.tsx b/web/components/issues/issue-modal/draft-issue-layout.tsx index 15cba3c04..274df2981 100644 --- a/web/components/issues/issue-modal/draft-issue-layout.tsx +++ b/web/components/issues/issue-modal/draft-issue-layout.tsx @@ -14,6 +14,8 @@ import type { TIssue } from "@plane/types"; export interface DraftIssueProps { changesMade: Partial | null; data?: Partial; + isCreateMoreToggleEnabled: boolean; + onCreateMoreToggleChange: (value: boolean) => void; onChange: (formData: Partial | null) => void; onClose: (saveDraftIssueInLocalStorage?: boolean) => void; onSubmit: (formData: Partial) => Promise; @@ -23,7 +25,16 @@ export interface DraftIssueProps { const issueDraftService = new IssueDraftService(); export const DraftIssueLayout: React.FC = observer((props) => { - const { changesMade, data, onChange, onClose, onSubmit, projectId } = props; + const { + changesMade, + data, + onChange, + onClose, + onSubmit, + projectId, + isCreateMoreToggleEnabled, + onCreateMoreToggleChange, + } = props; // states const [issueDiscardModal, setIssueDiscardModal] = useState(false); // router @@ -76,7 +87,15 @@ export const DraftIssueLayout: React.FC = observer((props) => { onClose(false); }} /> - + ); }); diff --git a/web/components/issues/issue-modal/form.tsx b/web/components/issues/issue-modal/form.tsx index 687e32302..1853f9546 100644 --- a/web/components/issues/issue-modal/form.tsx +++ b/web/components/issues/issue-modal/form.tsx @@ -51,6 +51,8 @@ const defaultValues: Partial = { export interface IssueFormProps { data?: Partial; + isCreateMoreToggleEnabled: boolean; + onCreateMoreToggleChange: (value: boolean) => void; onChange?: (formData: Partial | null) => void; onClose: () => void; onSubmit: (values: Partial) => Promise; @@ -62,14 +64,15 @@ const aiService = new AIService(); const fileService = new FileService(); export const IssueFormRoot: FC = observer((props) => { - const { data, onChange, onClose, onSubmit, projectId } = props; + const { data, onChange, onClose, onSubmit, projectId, isCreateMoreToggleEnabled, onCreateMoreToggleChange } = props; + console.log("onCreateMoreToggleChange", typeof onCreateMoreToggleChange); // states const [labelModal, setLabelModal] = useState(false); const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false); const [selectedParentIssue, setSelectedParentIssue] = useState(null); const [gptAssistantModal, setGptAssistantModal] = useState(false); const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false); - const [createMore, setCreateMore] = useState(false); + // refs const editorRef = useRef(null); // router @@ -276,7 +279,7 @@ export const IssueFormRoot: FC = observer((props) => { />
    - {issueName && issueName.trim() !== "" && ( + {issueName && issueName.trim() !== "" && envConfig?.has_openai_configured && ( - )} */} + )} - {/* {isAllowed && (fieldsToShow.includes("all") || fieldsToShow.includes("delete")) && ( + {is_editable && (fieldsToShow.includes("all") || fieldsToShow.includes("delete")) && ( - )} */} + )}
    diff --git a/web/components/issues/issue-layouts/list/roots/module-root.tsx b/web/components/issues/issue-layouts/list/roots/module-root.tsx index 947cfe55b..7dbe38090 100644 --- a/web/components/issues/issue-layouts/list/roots/module-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/module-root.tsx @@ -25,12 +25,12 @@ export const ModuleListLayout: React.FC = observer(() => { [EIssueActions.UPDATE]: async (issue: TIssue) => { if (!workspaceSlug || !moduleId) return; - await issues.updateIssue(workspaceSlug.toString(), issue.project_id, issue.id, issue); + await issues.updateIssue(workspaceSlug.toString(), issue.project_id, issue.id, issue, moduleId.toString()); }, [EIssueActions.DELETE]: async (issue: TIssue) => { if (!workspaceSlug || !moduleId) return; - await issues.removeIssue(workspaceSlug.toString(), issue.project_id, issue.id); + await issues.removeIssue(workspaceSlug.toString(), issue.project_id, issue.id, moduleId.toString()); }, [EIssueActions.REMOVE]: async (issue: TIssue) => { if (!workspaceSlug || !moduleId) return; diff --git a/web/components/issues/issue-layouts/roots/cycle-layout-root.tsx b/web/components/issues/issue-layouts/roots/cycle-layout-root.tsx index e5398ef90..be518ea04 100644 --- a/web/components/issues/issue-layouts/roots/cycle-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/cycle-layout-root.tsx @@ -13,6 +13,7 @@ import { CycleKanBanLayout, CycleListLayout, CycleSpreadsheetLayout, + IssuePeekOverview, } from "components/issues"; import { TransferIssues, TransferIssuesModal } from "components/cycles"; // ui @@ -73,19 +74,23 @@ export const CycleLayoutRoot: React.FC = observer(() => { cycleId={cycleId.toString()} /> ) : ( -
    - {activeLayout === "list" ? ( - - ) : activeLayout === "kanban" ? ( - - ) : activeLayout === "calendar" ? ( - - ) : activeLayout === "gantt_chart" ? ( - - ) : activeLayout === "spreadsheet" ? ( - - ) : null} -
    + <> +
    + {activeLayout === "list" ? ( + + ) : activeLayout === "kanban" ? ( + + ) : activeLayout === "calendar" ? ( + + ) : activeLayout === "gantt_chart" ? ( + + ) : activeLayout === "spreadsheet" ? ( + + ) : null} +
    + {/* peek overview */} + + )} )} diff --git a/web/components/issues/issue-layouts/roots/module-layout-root.tsx b/web/components/issues/issue-layouts/roots/module-layout-root.tsx index 4478a0faa..808cad91b 100644 --- a/web/components/issues/issue-layouts/roots/module-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/module-layout-root.tsx @@ -6,6 +6,7 @@ import useSWR from "swr"; import { useIssues } from "hooks/store"; // components import { + IssuePeekOverview, ModuleAppliedFiltersRoot, ModuleCalendarLayout, ModuleEmptyState, @@ -16,6 +17,7 @@ import { } from "components/issues"; // ui import { Spinner } from "@plane/ui"; +// constants import { EIssuesStoreType } from "constants/issue"; export const ModuleLayoutRoot: React.FC = observer(() => { @@ -62,19 +64,23 @@ export const ModuleLayoutRoot: React.FC = observer(() => { moduleId={moduleId.toString()} /> ) : ( -
    - {activeLayout === "list" ? ( - - ) : activeLayout === "kanban" ? ( - - ) : activeLayout === "calendar" ? ( - - ) : activeLayout === "gantt_chart" ? ( - - ) : activeLayout === "spreadsheet" ? ( - - ) : null} -
    + <> +
    + {activeLayout === "list" ? ( + + ) : activeLayout === "kanban" ? ( + + ) : activeLayout === "calendar" ? ( + + ) : activeLayout === "gantt_chart" ? ( + + ) : activeLayout === "spreadsheet" ? ( + + ) : null} +
    + {/* peek overview */} + + )} )} diff --git a/web/components/issues/issue-layouts/roots/project-layout-root.tsx b/web/components/issues/issue-layouts/roots/project-layout-root.tsx index bfff19cd8..1edba5563 100644 --- a/web/components/issues/issue-layouts/roots/project-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/project-layout-root.tsx @@ -30,7 +30,11 @@ export const ProjectLayoutRoot: FC = observer(() => { useSWR(workspaceSlug && projectId ? `PROJECT_ISSUES_${workspaceSlug}_${projectId}` : null, async () => { if (workspaceSlug && projectId) { await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString()); - await issues?.fetchIssues(workspaceSlug.toString(), projectId.toString(), issues?.groupedIssueIds ? "mutation" : "init-loader"); + await issues?.fetchIssues( + workspaceSlug.toString(), + projectId.toString(), + issues?.groupedIssueIds ? "mutation" : "init-loader" + ); } }); diff --git a/web/components/issues/issue-modal/form.tsx b/web/components/issues/issue-modal/form.tsx index 1853f9546..d7c543079 100644 --- a/web/components/issues/issue-modal/form.tsx +++ b/web/components/issues/issue-modal/form.tsx @@ -1,4 +1,4 @@ -import React, { FC, useState, useRef } from "react"; +import React, { FC, useState, useRef, useEffect } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { Controller, useForm } from "react-hook-form"; @@ -6,7 +6,7 @@ import { LayoutPanelTop, Sparkle, X } from "lucide-react"; // editor import { RichTextEditorWithRef } from "@plane/rich-text-editor"; // hooks -import { useApplication, useEstimate, useMention, useProject } from "hooks/store"; +import { useApplication, useEstimate, useIssueDetail, useMention, useProject } from "hooks/store"; import useToast from "hooks/use-toast"; // services import { AIService } from "services/ai.service"; @@ -85,6 +85,9 @@ export const IssueFormRoot: FC = observer((props) => { const { getProjectById } = useProject(); const { areEstimatesEnabledForProject } = useEstimate(); const { mentionHighlights, mentionSuggestions } = useMention(); + const { + issue: { getIssueById }, + } = useIssueDetail(); // toast alert const { setToastAlert } = useToast(); // form info @@ -179,6 +182,28 @@ export const IssueFormRoot: FC = observer((props) => { const projectDetails = getProjectById(projectId); + // executing this useEffect when the parent_id coming from the component prop + useEffect(() => { + const parentId = watch("parent_id") || undefined; + if (!parentId) return; + if (parentId === selectedParentIssue?.id || selectedParentIssue) return; + + const issue = getIssueById(parentId); + if (!issue) return; + + const projectDetails = getProjectById(issue.project_id); + if (!projectDetails) return; + + setSelectedParentIssue({ + id: issue.id, + name: issue.name, + project_id: issue.project_id, + project__identifier: projectDetails.identifier, + project__name: projectDetails.name, + sequence_id: issue.sequence_id, + } as ISearchIssueResponse); + }, [watch, getIssueById, getProjectById, selectedParentIssue]); + return ( <> {projectId && ( diff --git a/web/components/issues/issue-modal/modal.tsx b/web/components/issues/issue-modal/modal.tsx index 55a25b33d..81a07d761 100644 --- a/web/components/issues/issue-modal/modal.tsx +++ b/web/components/issues/issue-modal/modal.tsx @@ -18,7 +18,7 @@ export interface IssuesModalProps { data?: Partial; isOpen: boolean; onClose: () => void; - onSubmit?: (res: Partial) => Promise; + onSubmit?: (res: TIssue) => Promise; withDraftIssueWrapper?: boolean; } @@ -58,52 +58,46 @@ export const CreateUpdateIssueModal: React.FC = observer((prop onClose(); }; - const handleCreateIssue = async (payload: Partial): Promise => { - if (!workspaceSlug || !payload.project_id) return null; + const handleCreateIssue = async (payload: Partial): Promise => { + if (!workspaceSlug || !payload.project_id) return undefined; - await createIssue(workspaceSlug.toString(), payload.project_id, payload) - .then(async (res) => { - setToastAlert({ - type: "success", - title: "Success!", - message: "Issue created successfully.", - }); - !createMore && handleClose(); - return res; - }) - .catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Issue could not be created. Please try again.", - }); + try { + const response = await createIssue(workspaceSlug.toString(), payload.project_id, payload); + setToastAlert({ + type: "success", + title: "Success!", + message: "Issue created successfully.", }); - - return null; + !createMore && handleClose(); + return response; + } catch (error) { + setToastAlert({ + type: "error", + title: "Error!", + message: "Issue could not be created. Please try again.", + }); + } }; - const handleUpdateIssue = async (payload: Partial): Promise => { - if (!workspaceSlug || !payload.project_id || !data?.id) return null; + const handleUpdateIssue = async (payload: Partial): Promise => { + if (!workspaceSlug || !payload.project_id || !data?.id) return undefined; - await updateIssue(workspaceSlug.toString(), payload.project_id, data.id, payload) - .then((res) => { - setToastAlert({ - type: "success", - title: "Success!", - message: "Issue updated successfully.", - }); - handleClose(); - return { ...payload, ...res }; - }) - .catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Issue could not be updated. Please try again.", - }); + try { + const response = await updateIssue(workspaceSlug.toString(), payload.project_id, data.id, payload); + setToastAlert({ + type: "success", + title: "Success!", + message: "Issue updated successfully.", }); - - return null; + handleClose(); + return response; + } catch (error) { + setToastAlert({ + type: "error", + title: "Error!", + message: "Issue could not be created. Please try again.", + }); + } }; const handleFormSubmit = async (formData: Partial) => { @@ -114,7 +108,7 @@ export const CreateUpdateIssueModal: React.FC = observer((prop description_html: formData.description_html ?? "

    ", }; - let res: TIssue | null = null; + let res: TIssue | undefined = undefined; if (!data?.id) res = await handleCreateIssue(payload); else res = await handleUpdateIssue(payload); @@ -126,7 +120,7 @@ export const CreateUpdateIssueModal: React.FC = observer((prop if (formData.module_id && res && (!data?.id || formData.module_id !== data?.module_id)) await addIssueToModule(workspaceSlug.toString(), formData.project_id, formData.module_id, [res.id]); - if (res && onSubmit) await onSubmit(res); + if (res != undefined && onSubmit) await onSubmit(res); }; const handleFormChange = (formData: Partial | null) => setChangesMade(formData); diff --git a/web/components/issues/peek-overview/issue-detail.tsx b/web/components/issues/peek-overview/issue-detail.tsx index 3eb7037f2..8a0ab0fe7 100644 --- a/web/components/issues/peek-overview/issue-detail.tsx +++ b/web/components/issues/peek-overview/issue-detail.tsx @@ -1,133 +1,32 @@ -import { ChangeEvent, FC, useCallback, useEffect, useState } from "react"; -import { Controller, useForm } from "react-hook-form"; -import debounce from "lodash/debounce"; -// packages -import { RichTextEditor } from "@plane/rich-text-editor"; +import { FC } from "react"; // hooks -import { useMention, useProject, useUser } from "hooks/store"; -import useReloadConfirmations from "hooks/use-reload-confirmation"; +import { useIssueDetail, useProject, useUser } from "hooks/store"; // components -import { IssuePeekOverviewReactions } from "components/issues"; -// ui -import { TextArea } from "@plane/ui"; -// types -import { TIssue, IUser } from "@plane/types"; -// services -import { FileService } from "services/file.service"; -// constants -import { EUserProjectRoles } from "constants/project"; - -const fileService = new FileService(); +import { IssueDescriptionForm, TIssueOperations } from "components/issues"; +import { IssueReaction } from "../issue-detail/reactions"; interface IPeekOverviewIssueDetails { workspaceSlug: string; - issue: TIssue; - issueReactions: any; - user: IUser | null; - issueUpdate: (issue: Partial) => void; - issueReactionCreate: (reaction: string) => void; - issueReactionRemove: (reaction: string) => void; + projectId: string; + issueId: string; + issueOperations: TIssueOperations; + is_archived: boolean; + disabled: boolean; isSubmitting: "submitting" | "submitted" | "saved"; setIsSubmitting: (value: "submitting" | "submitted" | "saved") => void; } export const PeekOverviewIssueDetails: FC = (props) => { - const { - workspaceSlug, - issue, - issueReactions, - user, - issueUpdate, - issueReactionCreate, - issueReactionRemove, - isSubmitting, - setIsSubmitting, - } = props; - // states - const [characterLimit, setCharacterLimit] = useState(false); + const { workspaceSlug, projectId, issueId, issueOperations, disabled, isSubmitting, setIsSubmitting } = props; // store hooks - const { - membership: { currentProjectRole }, - } = useUser(); - const { mentionHighlights, mentionSuggestions } = useMention(); const { getProjectById } = useProject(); - // derived values - const isAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; - // toast alert - const { setShowAlert } = useReloadConfirmations(); - // form info + const { currentUser } = useUser(); const { - handleSubmit, - watch, - reset, - control, - formState: { errors }, - } = useForm({ - defaultValues: { - name: issue.name, - description_html: issue.description_html, - }, - }); - - const handleDescriptionFormSubmit = useCallback( - async (formData: Partial) => { - if (!formData?.name || formData?.name.length === 0 || formData?.name.length > 255) return; - - await issueUpdate({ - ...issue, - name: formData.name ?? "", - description_html: formData.description_html ?? "

    ", - }); - }, - [issue, issueUpdate] - ); - - const [localTitleValue, setLocalTitleValue] = useState(""); - const [localIssueDescription, setLocalIssueDescription] = useState({ - id: issue.id, - description_html: issue.description_html, - }); - - // adding issue.description_html or issue.name to dependency array causes - // editor rerendering on every save - useEffect(() => { - if (issue.id) { - setLocalIssueDescription({ id: issue.id, description_html: issue.description_html }); - setLocalTitleValue(issue.name); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [issue.id]); // TODO: Verify the exhaustive-deps warning - - // ADDING handleDescriptionFormSubmit TO DEPENDENCY ARRAY PRODUCES ADVERSE EFFECTS - // TODO: Verify the exhaustive-deps warning - // eslint-disable-next-line react-hooks/exhaustive-deps - const debouncedFormSave = useCallback( - debounce(async () => { - handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting("submitted")); - }, 1500), - [handleSubmit] - ); - - useEffect(() => { - if (isSubmitting === "submitted") { - setShowAlert(false); - setTimeout(async () => { - setIsSubmitting("saved"); - }, 2000); - } else if (isSubmitting === "submitting") { - setShowAlert(true); - } - }, [isSubmitting, setShowAlert, setIsSubmitting]); - - // reset form values - useEffect(() => { - if (!issue) return; - - reset({ - ...issue, - }); - }, [issue, reset]); - + issue: { getIssueById }, + } = useIssueDetail(); + // derived values + const issue = getIssueById(issueId); + if (!issue) return <>; const projectDetails = getProjectById(issue?.project_id); return ( @@ -135,82 +34,24 @@ export const PeekOverviewIssueDetails: FC = (props) = {projectDetails?.identifier}-{issue?.sequence_id} - -
    - {isAllowed ? ( - ( -