Compare commits

..

29 Commits

Author SHA1 Message Date
Anmol Singh Bhatia 41e812a811 fix: quick action copy link action (#3686) 2024-02-16 20:07:38 +05:30
guru_sainath a94c607031 fix: updated issue title and description components (#3687)
* fix: issue description fixes

* chore: description html in the archive issue

* chore: changed retrieve viewset

* chore: implemented new issue title description components in inbox, issue-detail and fixed issue in archived store

* chore: removed consoles and empty description update in issue detail

* fix: draft issue empty state image

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@plane.so>
2024-02-16 20:07:04 +05:30
Anmol Singh Bhatia 665a07f15a chore: dropdown and peek overview improvement (#3682)
* chore: dropdown focus state improvement

* fix: peek overview dropdown placement fix
2024-02-16 20:01:58 +05:30
rahulramesha 85a8af5125 fix: spreadsheet views sorting (#3683)
* fix sorting in spreadsheet all issues

* removing focus border since it is being handled globally
2024-02-16 18:20:44 +05:30
Anmol Singh Bhatia 7628419a26 fix: inbox description and mutation (#3677)
* fix: inbox status mutation fix

* fix: inbox description fix

* chore: added description in inbox issue

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-02-15 19:31:38 +05:30
Manish Gupta 2cd16c61ba arm build for release tag (#3676) 2024-02-15 14:52:18 +05:30
sriram veeraghanta 8f7b05b73f Merge branch 'preview' of github.com:makeplane/plane into develop 2024-02-15 13:48:13 +05:30
Manish Gupta 98d545cfb9 github action modified (#3673) 2024-02-15 13:47:26 +05:30
Anmol Singh Bhatia 2548a9f062 fix: peek overview issue description initial load bug (#3670)
* fix: peek overview issue description initial load bug

* fix: peekoverview issue description infinite loading for certain data sets

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-02-15 03:17:16 +05:30
Bavisetti Narayan d901277a20 chore: parent can be added to issue (#3665) 2024-02-14 18:16:27 +05:30
rahulramesha 489555f788 fix: horizontal scroll in gantt chart while dragging (#3664)
* fix horizontal scroll in gantt chart while dragging

* add aline indicator for quick add

* add border color for line above quick add in gantt to make it look better in dark mode
2024-02-14 18:15:13 +05:30
Anmol Singh Bhatia b827a1af27 fix: issue details page parent issue redirection bug (#3662) 2024-02-14 18:13:29 +05:30
Ramesh Kumar Chandra 1d2e331cec fix: mobile responsive sidebar close on item tap (#3661) 2024-02-14 14:55:33 +05:30
Anmol Singh Bhatia e51e4761b9 fix: cycle favorite mutation (#3659)
* fix: cycle favorite mutation

* fix: issue details sidebar overflow fix
2024-02-14 14:17:46 +05:30
Anmol Singh Bhatia 9299478539 chore: module & cycle empty state improvement (#3658) 2024-02-14 13:03:37 +05:30
guru_sainath fb4cffdd1c fix: infinity loader in the issue description in issue peek overview and issue detail page (#3656) 2024-02-14 12:46:52 +05:30
Anmol Singh Bhatia 83bf28bb83 chore: loading state improvement (#3657) 2024-02-14 12:40:03 +05:30
guru_sainath 25a2816a76 chore: empty state loaders and filter cancel state update for modules and cycles (#3653) 2024-02-13 20:58:44 +05:30
rahulramesha 571b89632c fix: issue spillover on switching workspace (#3652)
* fix global issues spillover to other workspace

* fix the same for profile issues as well
2024-02-13 20:50:42 +05:30
sriram veeraghanta 5b5698ad97 Merge branch 'preview' of github.com:makeplane/plane into develop 2024-02-13 19:13:07 +05:30
Anmol Singh Bhatia b1989bae1b feat: loading states update (#3639)
* dev: implement layout skeleton loader and helper function

* chore: implemented layout loader

* chore: settings loader added

* chore: cycle, module, view, pages, notification and projects loader added

* chore: kanban loader improvement

* chore: loader utils updated
2024-02-13 19:12:10 +05:30
rahulramesha 83139989c2 fix: sort order in the same list for Kanban (#3651)
* fixing kanban dnd by stooping the modification of the original array by spreading to change the array reference

* fix sort order in the same list

* minor change in condition
2024-02-13 19:11:20 +05:30
Anmol Singh Bhatia 1bf06821bb fix: add existing issue modal fix (#3649) 2024-02-13 18:57:56 +05:30
Anmol Singh Bhatia eea3b4fa54 chore: new empty state (#3640)
* chore: empty state asset updated

* chore: empty state config updated

* chore: cycle and module issues layout empty state added

* chore: workspace and project settings empty state added
2024-02-13 16:35:20 +05:30
rahulramesha f64284f6a0 fixing kanban dnd by stooping the modification of the original array by spreading to change the array reference (#3646) 2024-02-13 16:33:19 +05:30
Anmol Singh Bhatia 06496ff0f0 chore: app sidebar state (#3644)
* chore: app sidebar state fix

* chore: app sidebar state fix
2024-02-13 16:33:01 +05:30
Aaryan Khandelwal 247937d93a fix: scroll sync (#3645) 2024-02-13 14:33:24 +05:30
AbId KhAn e0a18578f5 fix local development setup issue due to docker compose local file makeplane/plane#3641 (#3642) 2024-02-13 12:39:58 +05:30
sriram veeraghanta e93bbec4cd fix: pages tabs responsive fixes (#3635) 2024-02-12 22:14:11 +05:30
312 changed files with 2526 additions and 1356 deletions
+5 -5
View File
@@ -28,7 +28,7 @@ jobs:
- id: set_env_variables
name: Set Environment Variables
run: |
if [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
if [ "${{ env.TARGET_BRANCH }}" == "master" ] || [ "${{ github.event_name }}" == "release" ]; then
echo "BUILDX_DRIVER=cloud" >> $GITHUB_OUTPUT
echo "BUILDX_VERSION=lab:latest" >> $GITHUB_OUTPUT
echo "BUILDX_PLATFORMS=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
@@ -54,7 +54,7 @@ jobs:
steps:
- name: Set Frontend Docker Tag
run: |
if [ "${{ env.TARGET_BRANCH }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
if [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:stable
@@ -105,7 +105,7 @@ jobs:
steps:
- name: Set Space Docker Tag
run: |
if [ "${{ env.TARGET_BRANCH }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
if [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:stable
@@ -156,7 +156,7 @@ jobs:
steps:
- name: Set Backend Docker Tag
run: |
if [ "${{ env.TARGET_BRANCH }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
if [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:stable
@@ -208,7 +208,7 @@ jobs:
steps:
- name: Set Proxy Docker Tag
run: |
if [ "${{ env.TARGET_BRANCH }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then
if [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:stable
+2 -2
View File
@@ -27,7 +27,7 @@ from plane.app.serializers import (
InboxSerializer,
InboxIssueSerializer,
IssueCreateSerializer,
IssueStateInboxSerializer,
IssueDetailSerializer,
)
from plane.utils.issue_filters import issue_filters
from plane.bgtasks.issue_activites_task import issue_activity
@@ -333,7 +333,7 @@ class InboxIssueViewSet(BaseViewSet):
def retrieve(self, request, slug, project_id, inbox_id, issue_id):
issue = self.get_queryset().filter(pk=issue_id).first()
serializer = IssueSerializer(issue, expand=self.expand,)
serializer = IssueDetailSerializer(issue, expand=self.expand,)
return Response(serializer.data, status=status.HTTP_200_OK)
def destroy(self, request, slug, project_id, inbox_id, issue_id):
+6 -6
View File
@@ -1209,13 +1209,13 @@ class IssueArchiveViewSet(BaseViewSet):
return Response(issues, status=status.HTTP_200_OK)
def retrieve(self, request, slug, project_id, pk=None):
issue = Issue.objects.get(
workspace__slug=slug,
project_id=project_id,
archived_at__isnull=False,
pk=pk,
issue = self.get_queryset().filter(pk=pk).first()
return Response(
IssueDetailSerializer(
issue, fields=self.fields, expand=self.expand
).data,
status=status.HTTP_200_OK,
)
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
def unarchive(self, request, slug, project_id, pk=None):
issue = Issue.objects.get(
+1 -6
View File
@@ -247,12 +247,7 @@ class IssueSearchEndpoint(BaseAPIView):
if parent == "true" and issue_id:
issue = Issue.issue_objects.get(pk=issue_id)
issues = issues.filter(
~Q(pk=issue_id), ~Q(pk=issue.parent_id), parent__isnull=True
).exclude(
pk__in=Issue.issue_objects.filter(
parent__isnull=False
).values_list("parent_id", flat=True)
)
~Q(pk=issue_id), ~Q(pk=issue.parent_id), ~Q(parent_id=issue_id))
if issue_relation == "true" and issue_id:
issue = Issue.issue_objects.get(pk=issue_id)
issues = issues.filter(
+1 -1
View File
@@ -137,7 +137,7 @@ services:
dockerfile: Dockerfile.dev
args:
DOCKER_BUILDKIT: 1
restart: no
restart: "no"
networks:
- dev_env
volumes:
+8 -2
View File
@@ -1,5 +1,11 @@
import { EUserProjectRoles } from "constants/project";
import type { IUser, IUserLite, IWorkspace, IWorkspaceLite, TStateGroups } from ".";
import type {
IUser,
IUserLite,
IWorkspace,
IWorkspaceLite,
TStateGroups,
} from ".";
export interface IProject {
archive_in: number;
@@ -117,7 +123,7 @@ export type TProjectIssuesSearchParams = {
parent?: boolean;
issue_relation?: boolean;
cycle?: boolean;
module?: string[];
module?: string;
sub_issue?: boolean;
issue_id?: string;
workspace_search: boolean;
@@ -78,7 +78,6 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
useEffect(() => {
if (!isOpen || !workspaceSlug || !projectId) return;
if (issues.length <= 0) setIsSearching(true);
projectService
.projectIssuesSearch(workspaceSlug as string, projectId as string, {
@@ -88,16 +87,7 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
})
.then((res) => setIssues(res))
.finally(() => setIsSearching(false));
}, [issues, debouncedSearchTerm, isOpen, isWorkspaceLevel, projectId, searchParams, workspaceSlug]);
useEffect(() => {
setSearchTerm("");
setIssues([]);
setSelectedIssues([]);
setIsSearching(false);
setIsSubmitting(false);
setIsWorkspaceLevel(false);
}, [isOpen]);
}, [debouncedSearchTerm, isOpen, isWorkspaceLevel, projectId, searchParams, workspaceSlug]);
return (
<>
@@ -3,12 +3,20 @@ import { Menu } from "lucide-react";
import { useApplication } from "hooks/store";
import { observer } from "mobx-react";
export const SidebarHamburgerToggle: FC = observer(() => {
const { theme: themStore } = useApplication();
type Props = {
onClick?: () => void;
}
export const SidebarHamburgerToggle: FC<Props> = observer((props) => {
const { onClick } = props
const { theme: themeStore } = useApplication();
return (
<div
className="w-7 h-7 flex-shrink-0 rounded flex justify-center items-center bg-custom-background-80 transition-all hover:bg-custom-background-90 cursor-pointer group md:hidden"
onClick={() => themStore.toggleSidebar()}
onClick={() => {
if (onClick) onClick()
else themeStore.toggleMobileSidebar()
}}
>
<Menu size={14} className="text-custom-text-200 group-hover:text-custom-text-100 transition-all" />
</div>
@@ -34,7 +34,8 @@ import { ICycle, TCycleGroups } from "@plane/types";
// constants
import { EIssuesStoreType } from "constants/issue";
import { CYCLE_ISSUES_WITH_PARAMS } from "constants/fetch-keys";
import { CYCLE_EMPTY_STATE_DETAILS, CYCLE_STATE_GROUPS_DETAILS } from "constants/cycle";
import { CYCLE_STATE_GROUPS_DETAILS } from "constants/cycle";
import { CYCLE_EMPTY_STATE_DETAILS } from "constants/empty-state";
interface IActiveCycleDetails {
workspaceSlug: string;
+5 -4
View File
@@ -1,6 +1,7 @@
import { FC, MouseEvent, useState } from "react";
import { useRouter } from "next/router";
import Link from "next/link";
import { observer } from "mobx-react";
// hooks
import { useEventTracker, useCycle, useUser } from "hooks/store";
import useToast from "hooks/use-toast";
@@ -26,7 +27,7 @@ export interface ICyclesBoardCard {
cycleId: string;
}
export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
const { cycleId, workspaceSlug, projectId } = props;
// states
const [updateModal, setUpdateModal] = useState(false);
@@ -69,8 +70,8 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
? cycleTotalIssues === 0
? "0 Issue"
: cycleTotalIssues === cycleDetails.completed_issues
? `${cycleTotalIssues} Issue${cycleTotalIssues > 1 ? "s" : ""}`
: `${cycleDetails.completed_issues}/${cycleTotalIssues} Issues`
? `${cycleTotalIssues} Issue${cycleTotalIssues > 1 ? "s" : ""}`
: `${cycleDetails.completed_issues}/${cycleTotalIssues} Issues`
: "0 Issue";
const handleCopyText = (e: MouseEvent<HTMLButtonElement>) => {
@@ -295,4 +296,4 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
</Link>
</div>
);
};
});
+1 -1
View File
@@ -7,7 +7,7 @@ import { useUser } from "hooks/store";
import { CyclePeekOverview, CyclesBoardCard } from "components/cycles";
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
// constants
import { CYCLE_EMPTY_STATE_DETAILS } from "constants/cycle";
import { CYCLE_EMPTY_STATE_DETAILS } from "constants/empty-state";
export interface ICyclesBoard {
cycleIds: string[];
+3 -2
View File
@@ -1,6 +1,7 @@
import { FC, MouseEvent, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { observer } from "mobx-react";
// hooks
import { useEventTracker, useCycle, useUser } from "hooks/store";
import useToast from "hooks/use-toast";
@@ -30,7 +31,7 @@ type TCyclesListItem = {
projectId: string;
};
export const CyclesListItem: FC<TCyclesListItem> = (props) => {
export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
const { cycleId, workspaceSlug, projectId } = props;
// states
const [updateModal, setUpdateModal] = useState(false);
@@ -289,4 +290,4 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
</Link>
</>
);
};
});
+1 -1
View File
@@ -9,7 +9,7 @@ import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
// ui
import { Loader } from "@plane/ui";
// constants
import { CYCLE_EMPTY_STATE_DETAILS } from "constants/cycle";
import { CYCLE_EMPTY_STATE_DETAILS } from "constants/empty-state";
export interface ICyclesList {
cycleIds: string[];
+20 -42
View File
@@ -5,7 +5,7 @@ import { useCycle } from "hooks/store";
// components
import { CyclesBoard, CyclesList, CyclesListGanttChartView } from "components/cycles";
// ui components
import { Loader } from "@plane/ui";
import { CycleModuleBoardLayout, CycleModuleListLayout, GanttLayoutLoader } from "components/ui";
// types
import { TCycleLayout, TCycleView } from "@plane/types";
@@ -25,6 +25,7 @@ export const CyclesView: FC<ICyclesView> = observer((props) => {
currentProjectDraftCycleIds,
currentProjectUpcomingCycleIds,
currentProjectCycleIds,
loader,
} = useCycle();
const cyclesList =
@@ -36,55 +37,32 @@ export const CyclesView: FC<ICyclesView> = observer((props) => {
? currentProjectUpcomingCycleIds
: currentProjectCycleIds;
if (loader || !cyclesList)
return (
<>
{layout === "list" && <CycleModuleListLayout />}
{layout === "board" && <CycleModuleBoardLayout />}
{layout === "gantt" && <GanttLayoutLoader />}
</>
);
return (
<>
{layout === "list" && (
<>
{cyclesList ? (
<CyclesList cycleIds={cyclesList} filter={filter} workspaceSlug={workspaceSlug} projectId={projectId} />
) : (
<Loader className="space-y-4 p-8">
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
</Loader>
)}
</>
<CyclesList cycleIds={cyclesList} filter={filter} workspaceSlug={workspaceSlug} projectId={projectId} />
)}
{layout === "board" && (
<>
{cyclesList ? (
<CyclesBoard
cycleIds={cyclesList}
filter={filter}
workspaceSlug={workspaceSlug}
projectId={projectId}
peekCycle={peekCycle}
/>
) : (
<Loader className="grid grid-cols-1 gap-9 p-8 md:grid-cols-2 lg:grid-cols-3">
<Loader.Item height="200px" />
<Loader.Item height="200px" />
<Loader.Item height="200px" />
</Loader>
)}
</>
<CyclesBoard
cycleIds={cyclesList}
filter={filter}
workspaceSlug={workspaceSlug}
projectId={projectId}
peekCycle={peekCycle}
/>
)}
{layout === "gantt" && (
<>
{cyclesList ? (
<CyclesListGanttChartView cycleIds={cyclesList} workspaceSlug={workspaceSlug} />
) : (
<Loader className="space-y-4">
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
</Loader>
)}
</>
)}
{layout === "gantt" && <CyclesListGanttChartView cycleIds={cyclesList} workspaceSlug={workspaceSlug} />}
</>
);
});
+10 -9
View File
@@ -61,6 +61,7 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
@@ -111,23 +112,15 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
const filteredOptions =
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
// fetch cycles of the project if not already present in the store
useEffect(() => {
if (!workspaceSlug) return;
if (!cycleIds) fetchAllCycles(workspaceSlug, projectId);
}, [cycleIds, fetchAllCycles, projectId, workspaceSlug]);
const selectedCycle = value ? getCycleById(value) : null;
const onOpen = () => {
if (referenceElement) referenceElement.focus();
if (workspaceSlug && !cycleIds) fetchAllCycles(workspaceSlug, projectId);
};
const handleClose = () => {
if (!isOpen) return;
setIsOpen(false);
if (referenceElement) referenceElement.blur();
onClose && onClose();
};
@@ -151,6 +144,12 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
useOutsideClickDetector(dropdownRef, handleClose);
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
return (
<Combobox
as="div"
@@ -216,6 +215,8 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
as="input"
ref={inputRef}
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
+10 -3
View File
@@ -1,4 +1,4 @@
import { Fragment, ReactNode, useRef, 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";
@@ -60,6 +60,7 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
@@ -110,13 +111,11 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
const onOpen = () => {
if (!activeEstimate && workspaceSlug) fetchProjectEstimates(workspaceSlug, projectId);
if (referenceElement) referenceElement.focus();
};
const handleClose = () => {
if (!isOpen) return;
setIsOpen(false);
if (referenceElement) referenceElement.blur();
onClose && onClose();
};
@@ -140,6 +139,12 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
useOutsideClickDetector(dropdownRef, handleClose);
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
return (
<Combobox
as="div"
@@ -205,6 +210,8 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
as="input"
ref={inputRef}
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
@@ -1,4 +1,4 @@
import { Fragment, useRef, 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";
@@ -50,6 +50,7 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
@@ -103,13 +104,11 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
const onOpen = () => {
if (!projectMemberIds && workspaceSlug) fetchProjectMembers(workspaceSlug, projectId);
if (referenceElement) referenceElement.focus();
};
const handleClose = () => {
if (!isOpen) return;
setIsOpen(false);
if (referenceElement) referenceElement.blur();
onClose && onClose();
};
@@ -133,6 +132,12 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
useOutsideClickDetector(dropdownRef, handleClose);
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
return (
<Combobox
as="div"
@@ -203,6 +208,8 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
as="input"
ref={inputRef}
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
@@ -1,4 +1,4 @@
import { Fragment, useRef, 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";
@@ -44,6 +44,7 @@ export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((
const [isOpen, setIsOpen] = useState<boolean>(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
@@ -91,19 +92,13 @@ export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((
};
if (multiple) comboboxProps.multiple = true;
const onOpen = () => {
if (referenceElement) referenceElement.focus();
};
const handleClose = () => {
if (!isOpen) return;
setIsOpen(false);
if (referenceElement) referenceElement.blur();
onClose && onClose();
};
const toggleDropdown = () => {
if (!isOpen) onOpen();
setIsOpen((prevIsOpen) => !prevIsOpen);
};
@@ -122,6 +117,12 @@ export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((
useOutsideClickDetector(dropdownRef, handleClose);
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
return (
<Combobox
as="div"
@@ -192,6 +193,8 @@ export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
as="input"
ref={inputRef}
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
+10 -9
View File
@@ -166,6 +166,7 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
@@ -216,21 +217,13 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
const filteredOptions =
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
// fetch modules of the project if not already present in the store
useEffect(() => {
if (!workspaceSlug) return;
if (!moduleIds) fetchModules(workspaceSlug, projectId);
}, [moduleIds, fetchModules, projectId, workspaceSlug]);
const onOpen = () => {
if (referenceElement) referenceElement.focus();
if (!moduleIds && workspaceSlug) fetchModules(workspaceSlug, projectId);
};
const handleClose = () => {
if (!isOpen) return;
setIsOpen(false);
if (referenceElement) referenceElement.blur();
onClose && onClose();
};
@@ -261,6 +254,12 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
};
if (multiple) comboboxProps.multiple = true;
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
return (
<Combobox
as="div"
@@ -331,6 +330,8 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
as="input"
ref={inputRef}
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
+10 -7
View File
@@ -1,4 +1,4 @@
import { Fragment, ReactNode, useRef, useState } from "react";
import { Fragment, ReactNode, useEffect, useRef, useState } from "react";
import { Combobox } from "@headlessui/react";
import { usePopper } from "react-popper";
import { Check, ChevronDown, Search } from "lucide-react";
@@ -272,6 +272,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
@@ -305,19 +306,13 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
const filteredOptions =
query === "" ? options : options.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
const onOpen = () => {
if (referenceElement) referenceElement.focus();
};
const handleClose = () => {
if (!isOpen) return;
setIsOpen(false);
if (referenceElement) referenceElement.blur();
onClose && onClose();
};
const toggleDropdown = () => {
if (!isOpen) onOpen();
setIsOpen((prevIsOpen) => !prevIsOpen);
};
@@ -342,6 +337,12 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
? BackgroundButton
: TransparentButton;
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
return (
<Combobox
as="div"
@@ -409,6 +410,8 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
as="input"
ref={inputRef}
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
+10 -7
View File
@@ -1,4 +1,4 @@
import { Fragment, ReactNode, useRef, 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";
@@ -50,6 +50,7 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
@@ -94,19 +95,13 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
const selectedProject = value ? getProjectById(value) : null;
const onOpen = () => {
if (referenceElement) referenceElement.focus();
};
const handleClose = () => {
if (!isOpen) return;
setIsOpen(false);
onClose && onClose();
if (referenceElement) referenceElement.blur();
};
const toggleDropdown = () => {
if (!isOpen) onOpen();
setIsOpen((prevIsOpen) => !prevIsOpen);
};
@@ -125,6 +120,12 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
useOutsideClickDetector(dropdownRef, handleClose);
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
return (
<Combobox
as="div"
@@ -198,6 +199,8 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
as="input"
ref={inputRef}
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
+10 -3
View File
@@ -1,4 +1,4 @@
import { Fragment, ReactNode, useRef, 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";
@@ -52,6 +52,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
@@ -92,14 +93,12 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
const onOpen = () => {
if (!statesList && workspaceSlug) fetchProjectStates(workspaceSlug, projectId);
if (referenceElement) referenceElement.focus();
};
const handleClose = () => {
if (!isOpen) return;
setIsOpen(false);
onClose && onClose();
if (referenceElement) referenceElement.blur();
};
const toggleDropdown = () => {
@@ -122,6 +121,12 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
useOutsideClickDetector(dropdownRef, handleClose);
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
return (
<Combobox
as="div"
@@ -193,6 +198,8 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
as="input"
ref={inputRef}
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
+17 -17
View File
@@ -1,21 +1,21 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { Plus } from "lucide-react";
import { useTheme } from "next-themes";
// store hooks
import { useEstimate, useProject } from "hooks/store";
import { useEstimate, useProject, useUser } from "hooks/store";
import useToast from "hooks/use-toast";
// components
import { CreateUpdateEstimateModal, DeleteEstimateModal, EstimateListItem } from "components/estimates";
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
// ui
import { Button, Loader } from "@plane/ui";
import { EmptyState } from "components/common";
// images
import emptyEstimate from "public/empty-state/estimate.svg";
// types
import { IEstimate } from "@plane/types";
// helpers
import { orderArrayBy } from "helpers/array.helper";
// constants
import { PROJECT_SETTINGS_EMPTY_STATE_DETAILS } from "constants/empty-state";
export const EstimatesList: React.FC = observer(() => {
// states
@@ -25,9 +25,12 @@ export const EstimatesList: React.FC = observer(() => {
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// theme
const { resolvedTheme } = useTheme();
// store hooks
const { updateProject, currentProjectDetails } = useProject();
const { projectEstimates, getProjectEstimateById } = useEstimate();
const { currentUser } = useUser();
// toast alert
const { setToastAlert } = useToast();
@@ -55,6 +58,10 @@ export const EstimatesList: React.FC = observer(() => {
});
};
const emptyStateDetail = PROJECT_SETTINGS_EMPTY_STATE_DETAILS["estimate"];
const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light";
const emptyStateImage = getEmptyStateImagePath("project-settings", "estimates", isLightMode);
return (
<>
<CreateUpdateEstimateModal
@@ -108,19 +115,12 @@ export const EstimatesList: React.FC = observer(() => {
))}
</section>
) : (
<div className="w-full py-8">
<div className="h-full w-full py-8">
<EmptyState
title="No estimates yet"
description="Estimates help you communicate the complexity of an issue."
image={emptyEstimate}
primaryButton={{
icon: <Plus className="h-4 w-4" />,
text: "Add Estimate",
onClick: () => {
setEstimateFormOpen(true);
setEstimateToUpdate(undefined);
},
}}
title={emptyStateDetail.title}
description={emptyStateDetail.description}
image={emptyStateImage}
size="lg"
/>
</div>
)
+23 -12
View File
@@ -3,25 +3,28 @@ import { useState } from "react";
import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/router";
import { useTheme } from "next-themes";
import useSWR, { mutate } from "swr";
import { observer } from "mobx-react-lite";
// hooks
import { useUser } from "hooks/store";
import useUserAuth from "hooks/use-user-auth";
// services
import { IntegrationService } from "services/integrations";
// components
import { Exporter, SingleExport } from "components/exporter";
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
// ui
import { Button, Loader } from "@plane/ui";
import { Button } from "@plane/ui";
import { ImportExportSettingsLoader } from "components/ui";
// icons
import { MoveLeft, MoveRight, RefreshCw } from "lucide-react";
// fetch-keys
import { EXPORT_SERVICES_LIST } from "constants/fetch-keys";
// constants
import { EXPORTERS_LIST } from "constants/workspace";
import { observer } from "mobx-react-lite";
import { useUser } from "hooks/store";
import { WORKSPACE_SETTINGS_EMPTY_STATE_DETAILS } from "constants/empty-state";
// services
const integrationService = new IntegrationService();
@@ -34,6 +37,8 @@ const IntegrationGuide = observer(() => {
// router
const router = useRouter();
const { workspaceSlug, provider } = router.query;
// theme
const { resolvedTheme } = useTheme();
// store hooks
const { currentUser, currentUserLoader } = useUser();
// custom hooks
@@ -46,6 +51,10 @@ const IntegrationGuide = observer(() => {
: null
);
const emptyStateDetail = WORKSPACE_SETTINGS_EMPTY_STATE_DETAILS["export"];
const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light";
const emptyStateImage = getEmptyStateImagePath("workspace-settings", "exports", isLightMode);
const handleCsvClose = () => {
router.replace(`/${workspaceSlug?.toString()}/settings/exports`);
};
@@ -140,15 +149,17 @@ const IntegrationGuide = observer(() => {
</div>
</div>
) : (
<p className="px-4 py-6 text-sm text-custom-text-200">No previous export available.</p>
<div className="h-full w-full flex items-center justify-center">
<EmptyState
title={emptyStateDetail.title}
description={emptyStateDetail.description}
image={emptyStateImage}
size="sm"
/>
</div>
)
) : (
<Loader className="mt-6 grid grid-cols-1 gap-3">
<Loader.Item height="40px" width="100%" />
<Loader.Item height="40px" width="100%" />
<Loader.Item height="40px" width="100%" />
<Loader.Item height="40px" width="100%" />
</Loader>
<ImportExportSettingsLoader />
)}
</div>
</div>
@@ -86,7 +86,7 @@ export const GanttChartBlocksList: FC<GanttChartBlocksProps> = observer((props)
className="h-full"
style={{
width: `${itemsContainerWidth}px`,
marginTop: `${HEADER_HEIGHT}px`,
transform: `translateY(${HEADER_HEIGHT}px)`,
}}
>
{blocks?.map((block) => {
@@ -33,6 +33,7 @@ type Props = {
sidebarToRender: (props: any) => React.ReactNode;
title: string;
updateCurrentViewRenderPayload: (direction: "left" | "right", currentView: TGanttViews) => void;
quickAdd?: React.JSX.Element | undefined;
};
export const GanttChartMainContent: React.FC<Props> = (props) => {
@@ -52,6 +53,7 @@ export const GanttChartMainContent: React.FC<Props> = (props) => {
sidebarToRender,
title,
updateCurrentViewRenderPayload,
quickAdd,
} = props;
// chart hook
const { currentView, currentViewData, updateScrollLeft } = useChart();
@@ -101,6 +103,7 @@ export const GanttChartMainContent: React.FC<Props> = (props) => {
enableReorder={enableReorder}
sidebarToRender={sidebarToRender}
title={title}
quickAdd={quickAdd}
/>
<div className="relative min-h-full h-max flex-shrink-0 flex-grow">
<ActiveChartView />
@@ -31,6 +31,7 @@ type ChartViewRootProps = {
enableAddBlock: boolean;
bottomSpacing: boolean;
showAllBlocks: boolean;
quickAdd?: React.JSX.Element | undefined;
};
export const ChartViewRoot: FC<ChartViewRootProps> = (props) => {
@@ -49,6 +50,7 @@ export const ChartViewRoot: FC<ChartViewRootProps> = (props) => {
enableAddBlock,
bottomSpacing,
showAllBlocks,
quickAdd,
} = props;
// states
const [itemsContainerWidth, setItemsContainerWidth] = useState(0);
@@ -200,6 +202,7 @@ export const ChartViewRoot: FC<ChartViewRootProps> = (props) => {
sidebarToRender={sidebarToRender}
title={title}
updateCurrentViewRenderPayload={updateCurrentViewRenderPayload}
quickAdd={quickAdd}
/>
</div>
);
+3
View File
@@ -12,6 +12,7 @@ type GanttChartRootProps = {
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
blockToRender: (data: any) => React.ReactNode;
sidebarToRender: (props: any) => React.ReactNode;
quickAdd?: React.JSX.Element | undefined;
enableBlockLeftResize?: boolean;
enableBlockRightResize?: boolean;
enableBlockMove?: boolean;
@@ -37,6 +38,7 @@ export const GanttChartRoot: FC<GanttChartRootProps> = (props) => {
enableAddBlock = false,
bottomSpacing = false,
showAllBlocks = false,
quickAdd,
} = props;
return (
@@ -56,6 +58,7 @@ export const GanttChartRoot: FC<GanttChartRootProps> = (props) => {
enableAddBlock={enableAddBlock}
bottomSpacing={bottomSpacing}
showAllBlocks={showAllBlocks}
quickAdd={quickAdd}
/>
</ChartContextProvider>
);
+3 -25
View File
@@ -7,42 +7,23 @@ import { useIssueDetail } from "hooks/store";
// ui
import { Loader } from "@plane/ui";
// components
import { GanttQuickAddIssueForm, IssueGanttSidebarBlock } from "components/issues";
import { IssueGanttSidebarBlock } from "components/issues";
// helpers
import { findTotalDaysInRange } from "helpers/date-time.helper";
import { cn } from "helpers/common.helper";
// types
import { IGanttBlock, IBlockUpdateData } from "components/gantt-chart/types";
import { TIssue } from "@plane/types";
import { BLOCK_HEIGHT } from "../constants";
type Props = {
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
blocks: IGanttBlock[] | null;
enableReorder: boolean;
enableQuickIssueCreate?: boolean;
quickAddCallback?: (
workspaceSlug: string,
projectId: string,
data: TIssue,
viewId?: string
) => Promise<TIssue | undefined>;
viewId?: string;
disableIssueCreation?: boolean;
showAllBlocks?: boolean;
};
export const IssueGanttSidebar: React.FC<Props> = observer((props) => {
const {
blockUpdateHandler,
blocks,
enableReorder,
enableQuickIssueCreate,
quickAddCallback,
viewId,
disableIssueCreation,
showAllBlocks = false,
} = props;
export const IssueGanttSidebar: React.FC<Props> = observer((props: Props) => {
const { blockUpdateHandler, blocks, enableReorder, showAllBlocks = false } = props;
const { activeBlock, dispatch } = useChart();
const { peekIssue } = useIssueDetail();
@@ -187,9 +168,6 @@ export const IssueGanttSidebar: React.FC<Props> = observer((props) => {
)}
</Droppable>
</DragDropContext>
{enableQuickIssueCreate && !disableIssueCreation && (
<GanttQuickAddIssueForm quickAddCallback={quickAddCallback} viewId={viewId} />
)}
</>
);
});
+5 -3
View File
@@ -9,16 +9,17 @@ type Props = {
enableReorder: boolean;
sidebarToRender: (props: any) => React.ReactNode;
title: string;
quickAdd?: React.JSX.Element | undefined;
};
export const GanttChartSidebar: React.FC<Props> = (props) => {
const { blocks, blockUpdateHandler, enableReorder, sidebarToRender, title } = props;
const { blocks, blockUpdateHandler, enableReorder, sidebarToRender, title, quickAdd } = props;
return (
<div
// DO NOT REMOVE THE ID
id="gantt-sidebar"
className="sticky top-0 left-0 z-10 min-h-full h-max flex-shrink-0 border-r-[0.5px] border-custom-border-200 bg-custom-background-100"
className="sticky left-0 z-10 min-h-full h-max flex-shrink-0 border-r-[0.5px] border-custom-border-200 bg-custom-background-100"
style={{
width: `${SIDEBAR_WIDTH}px`,
}}
@@ -33,9 +34,10 @@ export const GanttChartSidebar: React.FC<Props> = (props) => {
<h6>Duration</h6>
</div>
<div className="min-h-full h-max bg-custom-background-100">
<div className="min-h-full h-max bg-custom-background-100 overflow-x-hidden overflow-y-auto">
{sidebarToRender && sidebarToRender({ title, blockUpdateHandler, blocks, enableReorder })}
</div>
{quickAdd ? quickAdd : null}
</div>
);
};
+3 -2
View File
@@ -1,4 +1,5 @@
import React from "react";
import { observer } from "mobx-react";
// hooks
import { useInboxIssues } from "hooks/store";
// constants
@@ -13,7 +14,7 @@ type Props = {
showDescription?: boolean;
};
export const InboxIssueStatus: React.FC<Props> = (props) => {
export const InboxIssueStatus: React.FC<Props> = observer((props) => {
const { workspaceSlug, projectId, inboxId, issueId, iconSize = 18, showDescription = false } = props;
// hooks
const {
@@ -52,4 +53,4 @@ export const InboxIssueStatus: React.FC<Props> = (props) => {
)}
</div>
);
};
});
+20 -8
View File
@@ -4,6 +4,7 @@ import Image from "next/image";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
import { observer } from "mobx-react-lite";
import { useTheme } from "next-themes";
// hooks
import { useUser } from "hooks/store";
import useUserAuth from "hooks/use-user-auth";
@@ -11,8 +12,10 @@ import useUserAuth from "hooks/use-user-auth";
import { IntegrationService } from "services/integrations";
// components
import { DeleteImportModal, GithubImporterRoot, JiraImporterRoot, SingleImport } from "components/integration";
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
// ui
import { Button, Loader } from "@plane/ui";
import { Button } from "@plane/ui";
import { ImportExportSettingsLoader } from "components/ui";
// icons
import { RefreshCw } from "lucide-react";
// types
@@ -21,6 +24,7 @@ import { IImporterService } from "@plane/types";
import { IMPORTER_SERVICES_LIST } from "constants/fetch-keys";
// constants
import { IMPORTERS_LIST } from "constants/workspace";
import { WORKSPACE_SETTINGS_EMPTY_STATE_DETAILS } from "constants/empty-state";
// services
const integrationService = new IntegrationService();
@@ -33,6 +37,8 @@ const IntegrationGuide = observer(() => {
// router
const router = useRouter();
const { workspaceSlug, provider } = router.query;
// theme
const { resolvedTheme } = useTheme();
// store hooks
const { currentUser, currentUserLoader } = useUser();
// custom hooks
@@ -43,6 +49,10 @@ const IntegrationGuide = observer(() => {
workspaceSlug ? () => integrationService.getImporterServicesList(workspaceSlug as string) : null
);
const emptyStateDetail = WORKSPACE_SETTINGS_EMPTY_STATE_DETAILS["import"];
const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light";
const emptyStateImage = getEmptyStateImagePath("workspace-settings", "imports", isLightMode);
const handleDeleteImport = (importService: IImporterService) => {
setImportToDelete(importService);
setDeleteImportModal(true);
@@ -134,15 +144,17 @@ const IntegrationGuide = observer(() => {
</div>
</div>
) : (
<p className="px-4 py-6 text-sm text-custom-text-200">No previous imports available.</p>
<div className="h-full w-full flex items-center justify-center">
<EmptyState
title={emptyStateDetail.title}
description={emptyStateDetail.description}
image={emptyStateImage}
size="sm"
/>
</div>
)
) : (
<Loader className="mt-6 grid grid-cols-1 gap-3">
<Loader.Item height="40px" width="100%" />
<Loader.Item height="40px" width="100%" />
<Loader.Item height="40px" width="100%" />
<Loader.Item height="40px" width="100%" />
</Loader>
<ImportExportSettingsLoader />
)}
</div>
</div>
@@ -168,7 +168,7 @@ export const SingleIntegrationCard: React.FC<Props> = observer(({ integration })
)
) : (
<Loader>
<Loader.Item height="35px" width="150px" />
<Loader.Item height="32px" width="64px" />
</Loader>
)}
</div>
+10 -25
View File
@@ -13,7 +13,6 @@ import { TIssueOperations } from "./issue-detail";
import { FileService } from "services/file.service";
import { useMention, useWorkspace } from "hooks/store";
import { observer } from "mobx-react";
import { isNil } from "lodash";
export interface IssueDescriptionFormValues {
name: string;
@@ -42,10 +41,9 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = observer((props) => {
const { workspaceSlug, projectId, issueId, issue, issueOperations, disabled, isSubmitting, setIsSubmitting } = props;
const workspaceStore = useWorkspace();
const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug)?.id as string;
// states
const [characterLimit, setCharacterLimit] = useState(false);
// hooks
const { setShowAlert } = useReloadConfirmations();
// store hooks
const { mentionHighlights, mentionSuggestions } = useMention();
@@ -58,8 +56,8 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = observer((props) => {
formState: { errors },
} = useForm<TIssue>({
defaultValues: {
name: "",
description_html: "",
name: issue?.name,
description_html: issue?.description_html,
},
});
@@ -69,24 +67,6 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = observer((props) => {
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) {
setLocalTitleValue(issue.name);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [issue.id]); // TODO: verify the exhaustive-deps warning
useEffect(() => {
if (issue.description_html) {
setLocalIssueDescription((state) => {
if (!isNil(state.description_html)) return state;
return { id: issue.id, description_html: issue.description_html };
});
}
}, [issue.description_html]);
const handleDescriptionFormSubmit = useCallback(
async (formData: Partial<TIssue>) => {
if (!formData?.name || formData?.name.length === 0 || formData?.name.length > 255) return;
@@ -123,7 +103,12 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = observer((props) => {
reset({
...issue,
});
}, [issue, reset]);
setLocalIssueDescription({
id: issue.id,
description_html: issue.description_html === "" ? "<p></p>" : issue.description_html,
});
setLocalTitleValue(issue.name);
}, [issue, issue.description_html, reset]);
// ADDING handleDescriptionFormSubmit TO DEPENDENCY ARRAY PRODUCES ADVERSE EFFECTS
// TODO: Verify the exhaustive-deps warning
@@ -177,7 +162,7 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = observer((props) => {
</div>
<span>{errors.name ? errors.name.message : null}</span>
<div className="relative">
{issue.description_html ? (
{localIssueDescription.description_html ? (
<Controller
name="description_html"
control={control}
@@ -0,0 +1,95 @@
import { FC, useState, useEffect } from "react";
import { observer } from "mobx-react";
// components
import { Loader } from "@plane/ui";
import { RichReadOnlyEditor, RichTextEditor } from "@plane/rich-text-editor";
// store hooks
import { useMention, useWorkspace } from "hooks/store";
// services
import { FileService } from "services/file.service";
const fileService = new FileService();
// types
import { TIssueOperations } from "./issue-detail";
// hooks
import useDebounce from "hooks/use-debounce";
import useReloadConfirmations from "hooks/use-reload-confirmation";
export type IssueDescriptionInputProps = {
disabled?: boolean;
value: string | undefined | null;
workspaceSlug: string;
setIsSubmitting: (value: "submitting" | "submitted" | "saved") => void;
issueOperations: TIssueOperations;
projectId: string;
issueId: string;
};
export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((props) => {
const { disabled, value, workspaceSlug, setIsSubmitting, issueId, issueOperations, projectId } = props;
// states
const [descriptionHTML, setDescriptionHTML] = useState(value);
// store hooks
const { mentionHighlights, mentionSuggestions } = useMention();
const workspaceStore = useWorkspace();
// hooks
const { setShowAlert } = useReloadConfirmations();
const debouncedValue = useDebounce(descriptionHTML, 1500);
// computed values
const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug)?.id as string;
useEffect(() => {
setDescriptionHTML(value);
}, [value]);
useEffect(() => {
if (debouncedValue || debouncedValue === "") {
issueOperations
.update(workspaceSlug, projectId, issueId, { description_html: debouncedValue }, false)
.finally(() => {
setIsSubmitting("saved");
});
}
// DO NOT Add more dependencies here. It will cause multiple requests to be sent.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedValue]);
if (!descriptionHTML && descriptionHTML !== "") {
return (
<Loader>
<Loader.Item height="150px" />
</Loader>
);
}
if (disabled) {
return (
<RichReadOnlyEditor
value={descriptionHTML}
customClassName="!p-0 !pt-2 text-custom-text-200"
noBorder={disabled}
mentionHighlights={mentionHighlights}
/>
);
}
return (
<RichTextEditor
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
deleteFile={fileService.getDeleteImageFunction(workspaceId)}
restoreFile={fileService.getRestoreImageFunction(workspaceId)}
value={descriptionHTML}
setShouldShowAlert={setShowAlert}
setIsSubmitting={setIsSubmitting}
dragDropEnabled
customClassName="min-h-[150px] shadow-sm"
onChange={(description: Object, description_html: string) => {
setShowAlert(true);
setIsSubmitting("submitting");
setDescriptionHTML(description_html);
}}
mentionSuggestions={mentionSuggestions}
mentionHighlights={mentionHighlights}
/>
);
});
@@ -3,7 +3,9 @@ import { observer } from "mobx-react-lite";
// hooks
import { useIssueDetail, useProjectState, useUser } from "hooks/store";
// components
import { IssueDescriptionForm, IssueUpdateStatus, TIssueOperations } from "components/issues";
import { IssueUpdateStatus, TIssueOperations } from "components/issues";
import { IssueTitleInput } from "../../title-input";
import { IssueDescriptionInput } from "../../description-input";
import { IssueReaction } from "../reactions";
import { IssueActivity } from "../issue-activity";
import { InboxIssueStatus } from "../../../inbox/inbox-issue-status";
@@ -57,15 +59,24 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
<IssueUpdateStatus isSubmitting={isSubmitting} issueDetail={issue} />
</div>
<IssueDescriptionForm
<IssueTitleInput
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
projectId={issue.project_id}
issueId={issue.id}
setIsSubmitting={(value) => setIsSubmitting(value)}
isSubmitting={isSubmitting}
issue={issue}
issueOperations={issueOperations}
disabled={!is_editable}
value={issue.name}
/>
<IssueDescriptionInput
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
issueId={issue.id}
setIsSubmitting={(value) => setIsSubmitting(value)}
issueOperations={issueOperations}
disabled={!is_editable}
value={issue.description_html}
/>
{currentUser && (
@@ -81,7 +81,6 @@ export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => {
render={({ field: { value, onChange } }) => (
<LiteTextEditorWithRef
onEnterKeyPress={(e) => {
console.log("yo");
handleSubmit(onSubmit)(e);
}}
cancelUploadImage={fileService.cancelUpload}
@@ -3,7 +3,9 @@ import { observer } from "mobx-react-lite";
// hooks
import { useIssueDetail, useProjectState, useUser } from "hooks/store";
// components
import { IssueDescriptionForm, IssueAttachmentRoot, IssueUpdateStatus } from "components/issues";
import { IssueAttachmentRoot, IssueUpdateStatus } from "components/issues";
import { IssueTitleInput } from "../title-input";
import { IssueDescriptionInput } from "../description-input";
import { IssueParentDetail } from "./parent";
import { IssueReaction } from "./reactions";
import { SubIssuesRoot } from "../sub-issues";
@@ -61,15 +63,24 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
<IssueUpdateStatus isSubmitting={isSubmitting} issueDetail={issue} />
</div>
<IssueDescriptionForm
<IssueTitleInput
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
projectId={issue.project_id}
issueId={issue.id}
setIsSubmitting={(value) => setIsSubmitting(value)}
isSubmitting={isSubmitting}
issue={issue}
issueOperations={issueOperations}
disabled={!is_editable}
value={issue.name}
/>
<IssueDescriptionInput
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
issueId={issue.id}
setIsSubmitting={(value) => setIsSubmitting(value)}
issueOperations={issueOperations}
disabled={!is_editable}
value={issue.description_html}
/>
{currentUser && (
@@ -6,7 +6,7 @@ import { IssueParentSiblings } from "./siblings";
// ui
import { CustomMenu } from "@plane/ui";
// hooks
import { useIssueDetail, useIssues, useProject, useProjectState } from "hooks/store";
import { useIssues, useProject, useProjectState } from "hooks/store";
// types
import { TIssueOperations } from "../root";
import { TIssue } from "@plane/types";
@@ -23,7 +23,6 @@ export const IssueParentDetail: FC<TIssueParentDetail> = (props) => {
const { workspaceSlug, projectId, issueId, issue, issueOperations } = props;
// hooks
const { issueMap } = useIssues();
const { peekIssue } = useIssueDetail();
const { getProjectById } = useProject();
const { getProjectStates } = useProjectState();
@@ -39,7 +38,7 @@ export const IssueParentDetail: FC<TIssueParentDetail> = (props) => {
return (
<>
<div className="mb-5 flex w-min items-center gap-3 whitespace-nowrap rounded-md border border-custom-border-300 bg-custom-background-80 px-2.5 py-1 text-xs">
<Link href={`/${peekIssue?.workspaceSlug}/projects/${parentIssue?.project_id}/issues/${parentIssue.id}`}>
<Link href={`/${workspaceSlug}/projects/${parentIssue?.project_id}/issues/${parentIssue.id}`}>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2.5">
<span className="block h-2 w-2 rounded-full" style={{ backgroundColor: stateColor }} />
+9 -27
View File
@@ -15,7 +15,7 @@ import {
CalendarCheck2,
} from "lucide-react";
// hooks
import { useEstimate, useIssueDetail, useProject, useProjectState, useUser } from "hooks/store";
import { useEstimate, useIssueDetail, useProject, useUser } from "hooks/store";
import useToast from "hooks/use-toast";
// components
import {
@@ -56,11 +56,9 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, issueOperations, is_archived, is_editable } = props;
// router
const router = useRouter();
const { inboxIssueId } = router.query;
// store hooks
const { getProjectById } = useProject();
const { currentUser } = useUser();
const { projectStates } = useProjectState();
const { areEstimatesEnabledForCurrentProject } = useEstimate();
const { setToastAlert } = useToast();
const {
@@ -91,8 +89,6 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
const maxDate = issue.target_date ? new Date(issue.target_date) : null;
maxDate?.setDate(maxDate.getDate());
const currentIssueState = projectStates?.find((s) => s.id === issue.state_id);
return (
<>
{workspaceSlug && projectId && issue && (
@@ -108,22 +104,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
)}
<div className="flex h-full w-full flex-col divide-y-2 divide-custom-border-200 overflow-hidden">
<div className="flex items-center justify-between px-5 pb-3">
<div className="flex items-center gap-x-2">
{currentIssueState ? (
<StateGroupIcon
className="h-4 w-4"
stateGroup={currentIssueState.group}
color={currentIssueState.color}
/>
) : inboxIssueId ? (
<StateGroupIcon className="h-4 w-4" stateGroup="backlog" color="#ff7700" />
) : null}
<h4 className="text-lg font-medium text-custom-text-300">
{projectDetails?.identifier}-{issue?.sequence_id}
</h4>
</div>
<div className="flex items-center justify-end px-5 pb-3">
<div className="flex flex-wrap items-center gap-2">
{currentUser && !is_archived && (
<IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
@@ -187,8 +168,9 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
buttonVariant={issue?.assignee_ids?.length > 1 ? "transparent-without-text" : "transparent-with-text"}
className="w-3/5 flex-grow group"
buttonContainerClassName="w-full text-left"
buttonClassName={`text-sm justify-between ${issue?.assignee_ids.length > 0 ? "" : "text-custom-text-400"
}`}
buttonClassName={`text-sm justify-between ${
issue?.assignee_ids.length > 0 ? "" : "text-custom-text-400"
}`}
hideIcon={issue.assignee_ids?.length === 0}
dropdownArrow
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
@@ -232,8 +214,8 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
buttonClassName={`text-sm ${issue?.start_date ? "" : "text-custom-text-400"}`}
hideIcon
clearIconClassName="h-3 w-3 hidden group-hover:inline"
// TODO: add this logic
// showPlaceholderIcon
// TODO: add this logic
// showPlaceholderIcon
/>
</div>
@@ -258,8 +240,8 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
buttonClassName={`text-sm ${issue?.target_date ? "" : "text-custom-text-400"}`}
hideIcon
clearIconClassName="h-3 w-3 hidden group-hover:inline"
// TODO: add this logic
// showPlaceholderIcon
// TODO: add this logic
// showPlaceholderIcon
/>
</div>
@@ -9,6 +9,7 @@ import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
// constants
import { EUserProjectRoles } from "constants/project";
import { EIssueFilterType, EIssuesStoreType } from "constants/issue";
import { EMPTY_FILTER_STATE_DETAILS, EMPTY_ISSUE_STATE_DETAILS } from "constants/empty-state";
// types
import { IIssueFilterOptions } from "@plane/types";
@@ -65,20 +66,19 @@ export const ProjectArchivedEmptyState: React.FC = observer(() => {
const emptyStateProps: EmptyStateProps =
issueFilterCount > 0
? {
title: "No issues found matching the filters applied",
title: EMPTY_FILTER_STATE_DETAILS["archived"].title,
image: currentLayoutEmptyStateImagePath,
secondaryButton: {
text: "Clear all filters",
text: EMPTY_FILTER_STATE_DETAILS["archived"].secondaryButton?.text,
onClick: handleClearAllFilters,
},
}
: {
title: "No archived issues yet",
description:
"Archived issues help you remove issues you completed or cancelled from focus. You can set automation to auto archive issues and find them here.",
title: EMPTY_ISSUE_STATE_DETAILS["archived"].title,
description: EMPTY_ISSUE_STATE_DETAILS["archived"].description,
image: EmptyStateImagePath,
primaryButton: {
text: "Set Automation",
text: EMPTY_ISSUE_STATE_DETAILS["archived"].primaryButton.text,
onClick: () => router.push(`/${workspaceSlug}/projects/${projectId}/settings/automations`),
},
size: "sm",
@@ -1,32 +1,46 @@
import { useState } from "react";
import { observer } from "mobx-react-lite";
import { PlusIcon } from "lucide-react";
import { useTheme } from "next-themes";
// hooks
import { useApplication, useEventTracker, useIssueDetail, useIssues, useUser } from "hooks/store";
import useToast from "hooks/use-toast";
// components
import { EmptyState } from "components/common";
import { ExistingIssuesListModal } from "components/core";
// ui
import { Button } from "@plane/ui";
// assets
import emptyIssue from "public/empty-state/issue.svg";
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
// types
import { ISearchIssueResponse } from "@plane/types";
import { ISearchIssueResponse, TIssueLayouts } from "@plane/types";
// constants
import { EUserProjectRoles } from "constants/project";
import { EIssuesStoreType } from "constants/issue";
import { CYCLE_EMPTY_STATE_DETAILS, EMPTY_FILTER_STATE_DETAILS } from "constants/empty-state";
type Props = {
workspaceSlug: string | undefined;
projectId: string | undefined;
cycleId: string | undefined;
activeLayout: TIssueLayouts | undefined;
handleClearAllFilters: () => void;
isEmptyFilters?: boolean;
};
interface EmptyStateProps {
title: string;
image: string;
description?: string;
comicBox?: { title: string; description: string };
primaryButton?: { text: string; icon?: React.ReactNode; onClick: () => void };
secondaryButton?: { text: string; icon?: React.ReactNode; onClick: () => void };
size?: "lg" | "sm" | undefined;
disabled?: boolean | undefined;
}
export const CycleEmptyState: React.FC<Props> = observer((props) => {
const { workspaceSlug, projectId, cycleId } = props;
const { workspaceSlug, projectId, cycleId, activeLayout, handleClearAllFilters, isEmptyFilters = false } = props;
// states
const [cycleIssuesListModal, setCycleIssuesListModal] = useState(false);
// theme
const { resolvedTheme } = useTheme();
// store hooks
const { issues } = useIssues(EIssuesStoreType.CYCLE);
const { updateIssue, fetchIssue } = useIssueDetail();
@@ -36,6 +50,7 @@ export const CycleEmptyState: React.FC<Props> = observer((props) => {
const { setTrackElement } = useEventTracker();
const {
membership: { currentProjectRole: userRole },
currentUser,
} = useUser();
const { setToastAlert } = useToast();
@@ -60,8 +75,44 @@ export const CycleEmptyState: React.FC<Props> = observer((props) => {
});
};
const emptyStateDetail = CYCLE_EMPTY_STATE_DETAILS["no-issues"];
const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light";
const currentLayoutEmptyStateImagePath = getEmptyStateImagePath("empty-filters", activeLayout ?? "list", isLightMode);
const emptyStateImage = getEmptyStateImagePath("cycle-issues", activeLayout ?? "list", isLightMode);
const isEditingAllowed = !!userRole && userRole >= EUserProjectRoles.MEMBER;
const emptyStateProps: EmptyStateProps = isEmptyFilters
? {
title: EMPTY_FILTER_STATE_DETAILS["project"].title,
image: currentLayoutEmptyStateImagePath,
secondaryButton: {
text: EMPTY_FILTER_STATE_DETAILS["project"].secondaryButton.text,
onClick: handleClearAllFilters,
},
}
: {
title: emptyStateDetail.title,
description: emptyStateDetail.description,
image: emptyStateImage,
primaryButton: {
text: emptyStateDetail.primaryButton.text,
icon: <PlusIcon className="h-3 w-3" strokeWidth={2} />,
onClick: () => {
setTrackElement("Cycle issue empty state");
toggleCreateIssueModal(true, EIssuesStoreType.CYCLE);
},
},
secondaryButton: {
text: emptyStateDetail.secondaryButton.text,
icon: <PlusIcon className="h-3 w-3" strokeWidth={2} />,
onClick: () => setCycleIssuesListModal(true),
},
size: "sm",
disabled: !isEditingAllowed,
};
return (
<>
<ExistingIssuesListModal
@@ -73,30 +124,7 @@ export const CycleEmptyState: React.FC<Props> = observer((props) => {
handleOnSubmit={handleAddIssuesToCycle}
/>
<div className="grid h-full w-full place-items-center">
<EmptyState
title="Cycle issues will appear here"
description="Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done."
image={emptyIssue}
primaryButton={{
text: "New issue",
icon: <PlusIcon className="h-3 w-3" strokeWidth={2} />,
onClick: () => {
setTrackElement("Cycle issue empty state");
toggleCreateIssueModal(true, EIssuesStoreType.CYCLE);
},
}}
secondaryButton={
<Button
variant="neutral-primary"
prependIcon={<PlusIcon className="h-3 w-3" strokeWidth={2} />}
onClick={() => setCycleIssuesListModal(true)}
disabled={!isEditingAllowed}
>
Add an existing issue
</Button>
}
disabled={!isEditingAllowed}
/>
<EmptyState {...emptyStateProps} />
</div>
</>
);
@@ -9,6 +9,7 @@ import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
// constants
import { EUserProjectRoles } from "constants/project";
import { EIssueFilterType, EIssuesStoreType } from "constants/issue";
import { EMPTY_FILTER_STATE_DETAILS, EMPTY_ISSUE_STATE_DETAILS } from "constants/empty-state";
// types
import { IIssueFilterOptions } from "@plane/types";
@@ -41,7 +42,7 @@ export const ProjectDraftEmptyState: React.FC = observer(() => {
const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light";
const currentLayoutEmptyStateImagePath = getEmptyStateImagePath("empty-filters", activeLayout ?? "list", isLightMode);
const EmptyStateImagePath = getEmptyStateImagePath("draft", "empty-issues", isLightMode);
const EmptyStateImagePath = getEmptyStateImagePath("draft", "draft-issues-empty", isLightMode);
const issueFilterCount = size(
Object.fromEntries(
@@ -65,17 +66,16 @@ export const ProjectDraftEmptyState: React.FC = observer(() => {
const emptyStateProps: EmptyStateProps =
issueFilterCount > 0
? {
title: "No issues found matching the filters applied",
title: EMPTY_FILTER_STATE_DETAILS["draft"].title,
image: currentLayoutEmptyStateImagePath,
secondaryButton: {
text: "Clear all filters",
text: EMPTY_FILTER_STATE_DETAILS["draft"].secondaryButton.text,
onClick: handleClearAllFilters,
},
}
: {
title: "No draft issues yet",
description:
"Quickly stepping away but want to keep your place? No worries save a draft now. Your issues will be right here waiting for you.",
title: EMPTY_ISSUE_STATE_DETAILS["draft"].title,
description: EMPTY_ISSUE_STATE_DETAILS["draft"].description,
image: EmptyStateImagePath,
size: "sm",
disabled: !isEditingAllowed,
@@ -1,41 +1,55 @@
import { useState } from "react";
import { observer } from "mobx-react-lite";
import { PlusIcon } from "lucide-react";
import { useTheme } from "next-themes";
// hooks
import { useApplication, useEventTracker, useIssues, useUser } from "hooks/store";
import useToast from "hooks/use-toast";
// components
import { EmptyState } from "components/common";
import { ExistingIssuesListModal } from "components/core";
// ui
import { Button } from "@plane/ui";
// assets
import emptyIssue from "public/empty-state/issue.svg";
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
// types
import { ISearchIssueResponse } from "@plane/types";
import { ISearchIssueResponse, TIssueLayouts } from "@plane/types";
// constants
import { EUserProjectRoles } from "constants/project";
import { EIssuesStoreType } from "constants/issue";
import { EMPTY_FILTER_STATE_DETAILS, MODULE_EMPTY_STATE_DETAILS } from "constants/empty-state";
type Props = {
workspaceSlug: string | undefined;
projectId: string | undefined;
moduleId: string | undefined;
activeLayout: TIssueLayouts | undefined;
handleClearAllFilters: () => void;
isEmptyFilters?: boolean;
};
interface EmptyStateProps {
title: string;
image: string;
description?: string;
comicBox?: { title: string; description: string };
primaryButton?: { text: string; icon?: React.ReactNode; onClick: () => void };
secondaryButton?: { text: string; icon?: React.ReactNode; onClick: () => void };
size?: "lg" | "sm" | undefined;
disabled?: boolean | undefined;
}
export const ModuleEmptyState: React.FC<Props> = observer((props) => {
const { workspaceSlug, projectId, moduleId } = props;
const { workspaceSlug, projectId, moduleId, activeLayout, handleClearAllFilters, isEmptyFilters = false } = props;
// states
const [moduleIssuesListModal, setModuleIssuesListModal] = useState(false);
// theme
const { resolvedTheme } = useTheme();
// store hooks
const { issues } = useIssues(EIssuesStoreType.MODULE);
const {
commandPalette: { toggleCreateIssueModal },
} = useApplication();
const { setTrackElement } = useEventTracker();
const {
membership: { currentProjectRole: userRole },
currentUser,
} = useUser();
// toast alert
const { setToastAlert } = useToast();
@@ -55,8 +69,43 @@ export const ModuleEmptyState: React.FC<Props> = observer((props) => {
);
};
const emptyStateDetail = MODULE_EMPTY_STATE_DETAILS["no-issues"];
const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light";
const currentLayoutEmptyStateImagePath = getEmptyStateImagePath("empty-filters", activeLayout ?? "list", isLightMode);
const emptyStateImage = getEmptyStateImagePath("module-issues", activeLayout ?? "list", isLightMode);
const isEditingAllowed = !!userRole && userRole >= EUserProjectRoles.MEMBER;
const emptyStateProps: EmptyStateProps = isEmptyFilters
? {
title: EMPTY_FILTER_STATE_DETAILS["project"].title,
image: currentLayoutEmptyStateImagePath,
secondaryButton: {
text: EMPTY_FILTER_STATE_DETAILS["project"].secondaryButton.text,
onClick: handleClearAllFilters,
},
}
: {
title: emptyStateDetail.title,
description: emptyStateDetail.description,
image: emptyStateImage,
primaryButton: {
text: emptyStateDetail.primaryButton.text,
icon: <PlusIcon className="h-3 w-3" strokeWidth={2} />,
onClick: () => {
setTrackElement("Module issue empty state");
toggleCreateIssueModal(true, EIssuesStoreType.MODULE);
},
},
secondaryButton: {
text: emptyStateDetail.secondaryButton.text,
icon: <PlusIcon className="h-3 w-3" strokeWidth={2} />,
onClick: () => setModuleIssuesListModal(true),
},
disabled: !isEditingAllowed,
};
return (
<>
<ExistingIssuesListModal
@@ -64,34 +113,11 @@ export const ModuleEmptyState: React.FC<Props> = observer((props) => {
projectId={projectId}
isOpen={moduleIssuesListModal}
handleClose={() => setModuleIssuesListModal(false)}
searchParams={{ module: moduleId != undefined ? [moduleId.toString()] : [] }}
searchParams={{ module: moduleId != undefined ? moduleId.toString() : "" }}
handleOnSubmit={handleAddIssuesToModule}
/>
<div className="grid h-full w-full place-items-center">
<EmptyState
title="Module issues will appear here"
description="Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done."
image={emptyIssue}
primaryButton={{
text: "New issue",
icon: <PlusIcon className="h-3 w-3" strokeWidth={2} />,
onClick: () => {
setTrackElement("Module issue empty state");
toggleCreateIssueModal(true, EIssuesStoreType.MODULE);
},
}}
secondaryButton={
<Button
variant="neutral-primary"
prependIcon={<PlusIcon className="h-3 w-3" strokeWidth={2} />}
onClick={() => setModuleIssuesListModal(true)}
disabled={!isEditingAllowed}
>
Add an existing issue
</Button>
}
disabled={!isEditingAllowed}
/>
<EmptyState {...emptyStateProps} />
</div>
</>
);
@@ -9,6 +9,7 @@ import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
// constants
import { EUserProjectRoles } from "constants/project";
import { EIssueFilterType, EIssuesStoreType } from "constants/issue";
import { EMPTY_FILTER_STATE_DETAILS, EMPTY_ISSUE_STATE_DETAILS } from "constants/empty-state";
// types
import { IIssueFilterOptions } from "@plane/types";
@@ -67,26 +68,23 @@ export const ProjectEmptyState: React.FC = observer(() => {
const emptyStateProps: EmptyStateProps =
issueFilterCount > 0
? {
title: "No issues found matching the filters applied",
title: EMPTY_FILTER_STATE_DETAILS["project"].title,
image: currentLayoutEmptyStateImagePath,
secondaryButton: {
text: "Clear all filters",
text: EMPTY_FILTER_STATE_DETAILS["project"].secondaryButton.text,
onClick: handleClearAllFilters,
},
}
: {
title: "Create an issue and assign it to someone, even yourself",
description:
"Think of issues as jobs, tasks, work, or JTBD. Which we like. An issue and its sub-issues are usually time-based actionables assigned to members of your team. Your team creates, assigns, and completes issues to move your project towards its goal.",
title: EMPTY_ISSUE_STATE_DETAILS["project"].title,
description: EMPTY_ISSUE_STATE_DETAILS["project"].description,
image: EmptyStateImagePath,
comicBox: {
title: "Issues are building blocks in Plane.",
description:
"Redesign the Plane UI, Rebrand the company, or Launch the new fuel injection system are examples of issues that likely have sub-issues.",
title: EMPTY_ISSUE_STATE_DETAILS["project"].comicBox.title,
description: EMPTY_ISSUE_STATE_DETAILS["project"].comicBox.description,
},
primaryButton: {
text: "Create your first issue",
text: EMPTY_ISSUE_STATE_DETAILS["project"].primaryButton.text,
onClick: () => {
setTrackElement("Project issue empty state");
commandPaletteStore.toggleCreateIssueModal(true, EIssuesStoreType.PROJECT);
@@ -36,22 +36,34 @@ export const CycleAppliedFiltersRoot: React.FC = observer(() => {
const handleRemoveFilter = (key: keyof IIssueFilterOptions, value: string | null) => {
if (!workspaceSlug || !projectId || !cycleId) return;
if (!value) {
updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, {
[key]: null,
});
updateFilters(
workspaceSlug,
projectId,
EIssueFilterType.FILTERS,
{
[key]: null,
},
cycleId
);
return;
}
let newValues = issueFilters?.filters?.[key] ?? [];
newValues = newValues.filter((val) => val !== value);
updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, {
[key]: newValues,
});
updateFilters(
workspaceSlug,
projectId,
EIssueFilterType.FILTERS,
{
[key]: newValues,
},
cycleId
);
};
const handleClearAllFilters = () => {
if (!workspaceSlug || !projectId) return;
if (!workspaceSlug || !projectId || !cycleId) return;
const newFilters: IIssueFilterOptions = {};
Object.keys(userFilters ?? {}).forEach((key) => {
newFilters[key as keyof IIssueFilterOptions] = null;
@@ -33,24 +33,36 @@ export const ModuleAppliedFiltersRoot: React.FC = observer(() => {
});
const handleRemoveFilter = (key: keyof IIssueFilterOptions, value: string | null) => {
if (!workspaceSlug || !projectId) return;
if (!workspaceSlug || !projectId || !moduleId) return;
if (!value) {
updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, {
[key]: null,
});
updateFilters(
workspaceSlug,
projectId,
EIssueFilterType.FILTERS,
{
[key]: null,
},
moduleId
);
return;
}
let newValues = issueFilters?.filters?.[key] ?? [];
newValues = newValues.filter((val) => val !== value);
updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, {
[key]: newValues,
});
updateFilters(
workspaceSlug,
projectId,
EIssueFilterType.FILTERS,
{
[key]: newValues,
},
moduleId
);
};
const handleClearAllFilters = () => {
if (!workspaceSlug || !projectId) return;
if (!workspaceSlug || !projectId || !moduleId) return;
const newFilters: IIssueFilterOptions = {};
Object.keys(userFilters ?? {}).forEach((key) => {
newFilters[key as keyof IIssueFilterOptions] = null;
@@ -4,7 +4,7 @@ import { observer } from "mobx-react-lite";
// hooks
import { useIssues, useUser } from "hooks/store";
// components
import { IssueGanttBlock } from "components/issues";
import { GanttQuickAddIssueForm, IssueGanttBlock } from "components/issues";
import {
GanttChartRoot,
IBlockUpdateData,
@@ -70,21 +70,17 @@ export const BaseGanttRoot: React.FC<IBaseGanttRoot> = observer((props: IBaseGan
blocks={issues ? renderIssueBlocksStructure(issues as TIssue[]) : null}
blockUpdateHandler={updateIssueBlockStructure}
blockToRender={(data: TIssue) => <IssueGanttBlock issueId={data.id} />}
sidebarToRender={(props) => (
<IssueGanttSidebar
{...props}
quickAddCallback={issueStore.quickAddIssue}
viewId={viewId}
enableQuickIssueCreate
disableIssueCreation={!enableIssueCreation || !isAllowed}
showAllBlocks
/>
)}
sidebarToRender={(props) => <IssueGanttSidebar {...props} showAllBlocks />}
enableBlockLeftResize={isAllowed}
enableBlockRightResize={isAllowed}
enableBlockMove={isAllowed}
enableReorder={appliedDisplayFilters?.order_by === "sort_order" && isAllowed}
enableAddBlock={isAllowed}
quickAdd={
enableIssueCreation && isAllowed ? (
<GanttQuickAddIssueForm quickAddCallback={issueStore.quickAddIssue} viewId={viewId} />
) : undefined
}
showAllBlocks
/>
</div>
@@ -159,7 +159,7 @@ export const GanttQuickAddIssueForm: React.FC<IGanttQuickAddIssueForm> = observe
) : (
<button
type="button"
className="sticky bottom-0 z-[1] flex w-full cursor-pointer items-center gap-2 p-3 py-3 text-custom-primary-100 bg-custom-background-100"
className="sticky bottom-0 z-[1] flex w-full cursor-pointer items-center gap-2 p-3 py-3 text-custom-primary-100 bg-custom-background-100 border-custom-border-200 border-t-[1px]"
onClick={() => setIsOpen(true)}
>
<PlusIcon className="h-3.5 w-3.5 stroke-2" />
@@ -138,14 +138,14 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = memo((props) => {
>
<div
className={cn(
"rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 px-3 py-2 text-sm transition-all hover:border-custom-border-400",
"rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 text-sm transition-all hover:border-custom-border-400",
{ "hover:cursor-grab": !isDragDisabled },
{ "border-custom-primary-100": snapshot.isDragging },
{ "border border-custom-primary-70 hover:border-custom-primary-70": peekIssueId === issue.id }
)}
>
<RenderIfVisible
classNames="space-y-2"
classNames="space-y-2 px-3 py-2"
root={scrollableContainerRef}
defaultHeight="100px"
horizonatlOffset={50}
@@ -59,7 +59,7 @@ export const HeaderGroupByCard: FC<IHeaderGroupByCard> = observer((props) => {
const { setToastAlert } = useToast();
const renderExistingIssueModal = moduleId || cycleId;
const ExistingIssuesListModalPayload = moduleId ? { module: [moduleId.toString()] } : { cycle: true };
const ExistingIssuesListModalPayload = moduleId ? { module: moduleId.toString() } : { cycle: true };
const handleAddIssuesToView = async (data: ISearchIssueResponse[]) => {
if (!workspaceSlug || !projectId) return;
@@ -67,10 +67,13 @@ export const handleDragDrop = async (
let updateIssue: any = {};
const sourceColumnId = (source?.droppableId && source?.droppableId.split("__")) || null;
const destinationColumnId = (destination?.droppableId && destination?.droppableId.split("__")) || null;
const sourceDroppableId = source?.droppableId;
const destinationDroppableId = destination?.droppableId;
if (!sourceColumnId || !destinationColumnId) return;
const sourceColumnId = (sourceDroppableId && sourceDroppableId.split("__")) || null;
const destinationColumnId = (destinationDroppableId && destinationDroppableId.split("__")) || null;
if (!sourceColumnId || !destinationColumnId || !sourceDroppableId || !destinationDroppableId) return;
const sourceGroupByColumnId = sourceColumnId[0] || null;
const destinationGroupByColumnId = destinationColumnId[0] || null;
@@ -101,9 +104,13 @@ export const handleDragDrop = async (
else return await store?.removeIssue(workspaceSlug, projectId, removed);
}
} else {
const sourceIssues = subGroupBy
? (issueWithIds as TSubGroupedIssues)[sourceSubGroupByColumnId][sourceGroupByColumnId]
: (issueWithIds as TGroupedIssues)[sourceGroupByColumnId];
//spreading the array to stop changing the original reference
//since we are removing an id from array further down
const sourceIssues = [
...(subGroupBy
? (issueWithIds as TSubGroupedIssues)[sourceSubGroupByColumnId][sourceGroupByColumnId]
: (issueWithIds as TGroupedIssues)[sourceGroupByColumnId]),
];
const destinationIssues = subGroupBy
? (issueWithIds as TSubGroupedIssues)[sourceSubGroupByColumnId][destinationGroupByColumnId]
: (issueWithIds as TGroupedIssues)[destinationGroupByColumnId];
@@ -119,7 +126,11 @@ export const handleDragDrop = async (
// for both horizontal and vertical dnd
updateIssue = {
...updateIssue,
...handleSortOrder(destinationIssues, destination.index, issueMap),
...handleSortOrder(
sourceDroppableId === destinationDroppableId ? sourceIssues : destinationIssues,
destination.index,
issueMap
),
};
if (subGroupBy && sourceSubGroupByColumnId && destinationSubGroupByColumnId) {
@@ -50,9 +50,8 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
return (
<div
className={cn("min-h-12 relative flex items-center gap-3 bg-custom-background-100 p-3 text-sm", {
"border border-custom-primary-70 hover:border-custom-primary-70":
peekIssue && peekIssue.issueId === issue.id,
"last:border-b-transparent": peekIssue?.issueId !== issue.id
"border border-custom-primary-70 hover:border-custom-primary-70": peekIssue && peekIssue.issueId === issue.id,
"last:border-b-transparent": peekIssue?.issueId !== issue.id,
})}
>
{displayProperties && displayProperties?.key && (
@@ -41,7 +41,7 @@ export const HeaderGroupByCard = observer(
const { setToastAlert } = useToast();
const renderExistingIssueModal = moduleId || cycleId;
const ExistingIssuesListModalPayload = moduleId ? { module: [moduleId.toString()] } : { cycle: true };
const ExistingIssuesListModalPayload = moduleId ? { module: moduleId.toString() } : { cycle: true };
const handleAddIssuesToView = async (data: ISearchIssueResponse[]) => {
if (!workspaceSlug || !projectId) return;
@@ -1,3 +1,5 @@
import { TIssue } from "@plane/types";
export interface IQuickActionProps {
issue: TIssue;
handleDelete: () => Promise<void>;
@@ -2,6 +2,7 @@ import { useState } from "react";
import { useRouter } from "next/router";
import { CustomMenu } from "@plane/ui";
import { Copy, Link, Pencil, Trash2 } from "lucide-react";
import omit from "lodash/omit";
// hooks
import useToast from "hooks/use-toast";
import { useEventTracker } from "hooks/store";
@@ -30,7 +31,7 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
const { setToastAlert } = useToast();
const handleCopyIssueLink = () => {
copyUrlToClipboard(`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`).then(() =>
copyUrlToClipboard(`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`).then(() =>
setToastAlert({
type: "success",
title: "Link copied",
@@ -39,11 +40,13 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
);
};
const duplicateIssuePayload = {
...issue,
name: `${issue.name} (copy)`,
};
delete duplicateIssuePayload.id;
const duplicateIssuePayload = omit(
{
...issue,
name: `${issue.name} (copy)`,
},
["id"]
);
return (
<>
@@ -87,7 +90,7 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
<CustomMenu.MenuItem
onClick={() => {
setTrackElement("Global issues");
setIssueToEdit(issue);
setIssueToEdit(issue);
setCreateUpdateIssueModal(true);
}}
>
@@ -99,7 +102,7 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
<CustomMenu.MenuItem
onClick={() => {
setTrackElement("Global issues");
setCreateUpdateIssueModal(true);
setCreateUpdateIssueModal(true);
}}
>
<div className="flex items-center gap-2">
@@ -110,7 +113,7 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
<CustomMenu.MenuItem
onClick={() => {
setTrackElement("Global issues");
setDeleteIssueModal(true);
setDeleteIssueModal(true);
}}
>
<div className="flex items-center gap-2">
@@ -4,7 +4,7 @@ import { CustomMenu } from "@plane/ui";
import { Link, Trash2 } from "lucide-react";
// hooks
import useToast from "hooks/use-toast";
import { useEventTracker, useIssues ,useUser} from "hooks/store";
import { useEventTracker, useIssues, useUser } from "hooks/store";
// components
import { DeleteArchivedIssueModal } from "components/issues";
// helpers
@@ -37,7 +37,7 @@ export const ArchivedIssueQuickActions: React.FC<IQuickActionProps> = (props) =>
const activeLayout = `${issuesFilter.issueFilters?.displayFilters?.layout} layout`;
const handleCopyIssueLink = () => {
copyUrlToClipboard(`${workspaceSlug}/projects/${issue.project}/archived-issues/${issue.id}`).then(() =>
copyUrlToClipboard(`${workspaceSlug}/projects/${issue.project_id}/archived-issues/${issue.id}`).then(() =>
setToastAlert({
type: "success",
title: "Link copied",
@@ -75,7 +75,7 @@ export const ArchivedIssueQuickActions: React.FC<IQuickActionProps> = (props) =>
<CustomMenu.MenuItem
onClick={() => {
setTrackElement(activeLayout);
setDeleteIssueModal(true);
setDeleteIssueModal(true);
}}
>
<div className="flex items-center gap-2">
@@ -2,9 +2,10 @@ import { useState } from "react";
import { useRouter } from "next/router";
import { CustomMenu } from "@plane/ui";
import { Copy, Link, Pencil, Trash2, XCircle } from "lucide-react";
import omit from "lodash/omit";
// hooks
import useToast from "hooks/use-toast";
import { useEventTracker, useIssues,useUser } from "hooks/store";
import { useEventTracker, useIssues, useUser } from "hooks/store";
// components
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
// helpers
@@ -49,7 +50,7 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
const activeLayout = `${issuesFilter.issueFilters?.displayFilters?.layout} layout`;
const handleCopyIssueLink = () => {
copyUrlToClipboard(`${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`).then(() =>
copyUrlToClipboard(`${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`).then(() =>
setToastAlert({
type: "success",
title: "Link copied",
@@ -58,11 +59,13 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
);
};
const duplicateIssuePayload = {
...issue,
name: `${issue.name} (copy)`,
};
delete duplicateIssuePayload.id;
const duplicateIssuePayload = omit(
{
...issue,
name: `${issue.name} (copy)`,
},
["id"]
);
return (
<>
@@ -107,10 +110,10 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
onClick={() => {
setIssueToEdit({
...issue,
cycle: cycleId?.toString() ?? null,
cycle_id: cycleId?.toString() ?? null,
});
setTrackElement(activeLayout);
setCreateUpdateIssueModal(true);
setCreateUpdateIssueModal(true);
}}
>
<div className="flex items-center gap-2">
@@ -131,7 +134,7 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
<CustomMenu.MenuItem
onClick={() => {
setTrackElement(activeLayout);
setCreateUpdateIssueModal(true);
setCreateUpdateIssueModal(true);
}}
>
<div className="flex items-center gap-2">
@@ -142,7 +145,7 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
<CustomMenu.MenuItem
onClick={() => {
setTrackElement(activeLayout);
setDeleteIssueModal(true);
setDeleteIssueModal(true);
}}
>
<div className="flex items-center gap-2">
@@ -2,9 +2,10 @@ import { useState } from "react";
import { useRouter } from "next/router";
import { CustomMenu } from "@plane/ui";
import { Copy, Link, Pencil, Trash2, XCircle } from "lucide-react";
import omit from "lodash/omit";
// hooks
import useToast from "hooks/use-toast";
import { useIssues, useEventTracker ,useUser } from "hooks/store";
import { useIssues, useEventTracker, useUser } from "hooks/store";
// components
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
// helpers
@@ -49,7 +50,7 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
const activeLayout = `${issuesFilter.issueFilters?.displayFilters?.layout} layout`;
const handleCopyIssueLink = () => {
copyUrlToClipboard(`${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`).then(() =>
copyUrlToClipboard(`${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`).then(() =>
setToastAlert({
type: "success",
title: "Link copied",
@@ -58,11 +59,13 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
);
};
const duplicateIssuePayload = {
...issue,
name: `${issue.name} (copy)`,
};
delete duplicateIssuePayload.id;
const duplicateIssuePayload = omit(
{
...issue,
name: `${issue.name} (copy)`,
},
["id"]
);
return (
<>
@@ -105,9 +108,9 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
<>
<CustomMenu.MenuItem
onClick={() => {
setIssueToEdit({ ...issue, module: moduleId?.toString() ?? null });
setIssueToEdit({ ...issue, module_ids: moduleId ? [moduleId.toString()] : [] });
setTrackElement(activeLayout);
setCreateUpdateIssueModal(true);
setCreateUpdateIssueModal(true);
}}
>
<div className="flex items-center gap-2">
@@ -128,7 +131,7 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
<CustomMenu.MenuItem
onClick={() => {
setTrackElement(activeLayout);
setCreateUpdateIssueModal(true);
setCreateUpdateIssueModal(true);
}}
>
<div className="flex items-center gap-2">
@@ -141,7 +144,7 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
e.preventDefault();
e.stopPropagation();
setTrackElement(activeLayout);
setDeleteIssueModal(true);
setDeleteIssueModal(true);
}}
>
<div className="flex items-center gap-2">
@@ -2,6 +2,7 @@ import { useState } from "react";
import { useRouter } from "next/router";
import { CustomMenu } from "@plane/ui";
import { Copy, Link, Pencil, Trash2 } from "lucide-react";
import omit from "lodash/omit";
// hooks
import { useEventTracker, useIssues, useUser } from "hooks/store";
import useToast from "hooks/use-toast";
@@ -39,7 +40,7 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = (props) =>
const { setToastAlert } = useToast();
const handleCopyIssueLink = () => {
copyUrlToClipboard(`${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`).then(() =>
copyUrlToClipboard(`${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`).then(() =>
setToastAlert({
type: "success",
title: "Link copied",
@@ -48,11 +49,13 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = (props) =>
);
};
const duplicateIssuePayload = {
...issue,
name: `${issue.name} (copy)`,
};
delete duplicateIssuePayload.id;
const duplicateIssuePayload = omit(
{
...issue,
name: `${issue.name} (copy)`,
},
["id"]
);
const isDraftIssue = router?.asPath?.includes("draft-issues") || false;
@@ -1,4 +1,4 @@
import React, { useCallback, useMemo } from "react";
import React, { Fragment, useCallback, useMemo } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import useSWR from "swr";
@@ -12,15 +12,15 @@ import { GlobalViewsAppliedFiltersRoot, IssuePeekOverview } from "components/iss
import { SpreadsheetView } from "components/issues/issue-layouts";
import { AllIssueQuickActions } from "components/issues/issue-layouts/quick-action-dropdowns";
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
// ui
import { Spinner } from "@plane/ui";
import { SpreadsheetLayoutLoader } from "components/ui";
// types
import { TIssue, IIssueDisplayFilterOptions } from "@plane/types";
import { EIssueActions } from "../types";
// constants
import { EUserProjectRoles } from "constants/project";
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue";
import { ALL_ISSUES_EMPTY_STATE_DETAILS, EUserWorkspaceRoles } from "constants/workspace";
import { EUserWorkspaceRoles } from "constants/workspace";
import { ALL_ISSUES_EMPTY_STATE_DETAILS } from "constants/empty-state";
export const AllIssueLayoutRoot: React.FC = observer(() => {
// router
@@ -159,7 +159,7 @@ export const AllIssueLayoutRoot: React.FC = observer(() => {
globalViewId.toString()
);
},
[updateFilters, workspaceSlug]
[updateFilters, workspaceSlug, globalViewId]
);
const renderQuickActions = useCallback(
@@ -177,66 +177,62 @@ export const AllIssueLayoutRoot: React.FC = observer(() => {
const isEditingAllowed = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER;
if (loader === "init-loader" || !globalViewId || globalViewId !== dataViewId || !issueIds) {
return <SpreadsheetLayoutLoader />;
}
return (
<div className="relative flex h-full w-full flex-col overflow-hidden">
{!globalViewId || globalViewId !== dataViewId || loader === "init-loader" || !issueIds ? (
<div className="flex h-full w-full items-center justify-center">
<Spinner />
</div>
) : (
<>
<GlobalViewsAppliedFiltersRoot globalViewId={globalViewId} />
{(issueIds ?? {}).length == 0 ? (
<EmptyState
image={emptyStateImage}
title={(workspaceProjectIds ?? []).length > 0 ? currentViewDetails.title : "No project"}
description={
(workspaceProjectIds ?? []).length > 0
? currentViewDetails.description
: "To create issues or manage your work, you need to create a project or be a part of one."
}
size="sm"
primaryButton={
(workspaceProjectIds ?? []).length > 0
? currentView !== "custom-view" && currentView !== "subscribed"
? {
text: "Create new issue",
onClick: () => {
setTrackElement("All issues empty state");
commandPaletteStore.toggleCreateIssueModal(true, EIssuesStoreType.PROJECT);
},
}
: undefined
: {
text: "Start your first project",
<div className="relative h-full w-full overflow-auto">
<GlobalViewsAppliedFiltersRoot globalViewId={globalViewId} />
{issueIds.length === 0 ? (
<EmptyState
image={emptyStateImage}
title={(workspaceProjectIds ?? []).length > 0 ? currentViewDetails.title : "No project"}
description={
(workspaceProjectIds ?? []).length > 0
? currentViewDetails.description
: "To create issues or manage your work, you need to create a project or be a part of one."
}
size="sm"
primaryButton={
(workspaceProjectIds ?? []).length > 0
? currentView !== "custom-view" && currentView !== "subscribed"
? {
text: "Create new issue",
onClick: () => {
setTrackElement("All issues empty state");
commandPaletteStore.toggleCreateProjectModal(true);
commandPaletteStore.toggleCreateIssueModal(true, EIssuesStoreType.PROJECT);
},
}
}
disabled={!isEditingAllowed}
: undefined
: {
text: "Start your first project",
onClick: () => {
setTrackElement("All issues empty state");
commandPaletteStore.toggleCreateProjectModal(true);
},
}
}
disabled={!isEditingAllowed}
/>
) : (
<Fragment>
<SpreadsheetView
displayProperties={issueFilters?.displayProperties ?? {}}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFilterUpdate={handleDisplayFiltersUpdate}
issueIds={issueIds}
quickActions={renderQuickActions}
handleIssues={handleIssues}
canEditProperties={canEditProperties}
viewId={globalViewId}
/>
) : (
<div className="relative h-full w-full overflow-auto">
<SpreadsheetView
displayProperties={issueFilters?.displayProperties ?? {}}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFilterUpdate={handleDisplayFiltersUpdate}
issueIds={issueIds}
quickActions={renderQuickActions}
handleIssues={handleIssues}
canEditProperties={canEditProperties}
viewId={globalViewId}
/>
</div>
)}
</>
)}
{/* peek overview */}
<IssuePeekOverview />
{/* peek overview */}
<IssuePeekOverview />
</Fragment>
)}
</div>
</div>
);
});
@@ -1,4 +1,4 @@
import React from "react";
import React, { Fragment } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import useSWR from "swr";
@@ -13,7 +13,7 @@ import {
} from "components/issues";
import { EIssuesStoreType } from "constants/issue";
// ui
import { Spinner } from "@plane/ui";
import { ListLayoutLoader } from "components/ui";
export const ArchivedIssueLayoutRoot: React.FC = observer(() => {
// router
@@ -36,30 +36,26 @@ export const ArchivedIssueLayoutRoot: React.FC = observer(() => {
}
);
if (issues?.loader === "init-loader" || !issues?.groupedIssueIds) {
return <ListLayoutLoader />;
}
if (!workspaceSlug || !projectId) return <></>;
return (
<div className="relative flex h-full w-full flex-col overflow-hidden">
<ArchivedIssueAppliedFiltersRoot />
{issues?.loader === "init-loader" ? (
<div className="flex h-full w-full items-center justify-center">
<Spinner />
{issues?.groupedIssueIds?.length === 0 ? (
<div className="relative h-full w-full overflow-y-auto">
<ProjectArchivedEmptyState />
</div>
) : (
<>
{!issues?.groupedIssueIds ? (
<ProjectArchivedEmptyState />
) : (
<>
<div className="relative h-full w-full overflow-auto">
<ArchivedIssueListLayout />
</div>
{/* peek overview */}
<IssuePeekOverview is_archived />
</>
)}
</>
<Fragment>
<div className="relative h-full w-full overflow-auto">
<ArchivedIssueListLayout />
</div>
<IssuePeekOverview is_archived={true} />
</Fragment>
)}
</div>
);
@@ -1,7 +1,8 @@
import React, { useState } from "react";
import React, { Fragment, useState } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import useSWR from "swr";
import size from "lodash/size";
// hooks
import { useCycle, useIssues } from "hooks/store";
// components
@@ -16,10 +17,11 @@ import {
IssuePeekOverview,
} from "components/issues";
import { TransferIssues, TransferIssuesModal } from "components/cycles";
// ui
import { Spinner } from "@plane/ui";
import { ActiveLoader } from "components/ui";
// constants
import { EIssuesStoreType } from "constants/issue";
import { EIssueFilterType, EIssuesStoreType } from "constants/issue";
// types
import { IIssueFilterOptions } from "@plane/types";
export const CycleLayoutRoot: React.FC = observer(() => {
const router = useRouter();
@@ -52,47 +54,73 @@ export const CycleLayoutRoot: React.FC = observer(() => {
const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined;
const cycleStatus = cycleDetails?.status?.toLocaleLowerCase() ?? "draft";
const userFilters = issuesFilter?.issueFilters?.filters;
const issueFilterCount = size(
Object.fromEntries(
Object.entries(userFilters ?? {}).filter(([, value]) => value && Array.isArray(value) && value.length > 0)
)
);
const handleClearAllFilters = () => {
if (!workspaceSlug || !projectId || !cycleId) return;
const newFilters: IIssueFilterOptions = {};
Object.keys(userFilters ?? {}).forEach((key) => {
newFilters[key as keyof IIssueFilterOptions] = null;
});
issuesFilter.updateFilters(
workspaceSlug.toString(),
projectId.toString(),
EIssueFilterType.FILTERS,
{
...newFilters,
},
cycleId.toString()
);
};
if (!workspaceSlug || !projectId || !cycleId) return <></>;
if (issues?.loader === "init-loader" || !issues?.groupedIssueIds) {
return <>{activeLayout && <ActiveLoader layout={activeLayout} />}</>;
}
return (
<>
<TransferIssuesModal handleClose={() => setTransferIssuesModal(false)} isOpen={transferIssuesModal} />
<div className="relative flex h-full w-full flex-col overflow-hidden">
{cycleStatus === "completed" && <TransferIssues handleClick={() => setTransferIssuesModal(true)} />}
<CycleAppliedFiltersRoot />
{issues?.loader === "init-loader" || !issues?.groupedIssueIds ? (
<div className="flex h-full w-full items-center justify-center">
<Spinner />
{issues?.groupedIssueIds?.length === 0 ? (
<div className="relative h-full w-full overflow-y-auto">
<CycleEmptyState
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
cycleId={cycleId.toString()}
activeLayout={activeLayout}
handleClearAllFilters={handleClearAllFilters}
isEmptyFilters={issueFilterCount > 0}
/>
</div>
) : (
<>
{issues?.groupedIssueIds?.length === 0 ? (
<CycleEmptyState
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
cycleId={cycleId.toString()}
/>
) : (
<>
<div className="h-full w-full overflow-auto">
{activeLayout === "list" ? (
<CycleListLayout />
) : activeLayout === "kanban" ? (
<CycleKanBanLayout />
) : activeLayout === "calendar" ? (
<CycleCalendarLayout />
) : activeLayout === "gantt_chart" ? (
<CycleGanttLayout />
) : activeLayout === "spreadsheet" ? (
<CycleSpreadsheetLayout />
) : null}
</div>
{/* peek overview */}
<IssuePeekOverview />
</>
)}
</>
<Fragment>
<div className="h-full w-full overflow-auto">
{activeLayout === "list" ? (
<CycleListLayout />
) : activeLayout === "kanban" ? (
<CycleKanBanLayout />
) : activeLayout === "calendar" ? (
<CycleCalendarLayout />
) : activeLayout === "gantt_chart" ? (
<CycleGanttLayout />
) : activeLayout === "spreadsheet" ? (
<CycleSpreadsheetLayout />
) : null}
</div>
{/* peek overview */}
<IssuePeekOverview />
</Fragment>
)}
</div>
</>
@@ -9,8 +9,8 @@ import { DraftIssueAppliedFiltersRoot } from "../filters/applied-filters/roots/d
import { DraftIssueListLayout } from "../list/roots/draft-issue-root";
import { ProjectDraftEmptyState } from "../empty-states";
import { IssuePeekOverview } from "components/issues/peek-overview";
import { ActiveLoader } from "components/ui";
// ui
import { Spinner } from "@plane/ui";
import { DraftKanBanLayout } from "../kanban/roots/draft-issue-root";
// constants
import { EIssuesStoreType } from "constants/issue";
@@ -39,30 +39,29 @@ export const DraftIssueLayoutRoot: React.FC = observer(() => {
const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout || undefined;
if (!workspaceSlug || !projectId) return <></>;
if (issues?.loader === "init-loader" || !issues?.groupedIssueIds) {
return <>{activeLayout && <ActiveLoader layout={activeLayout} />}</>;
}
return (
<div className="relative flex h-full w-full flex-col overflow-hidden">
<DraftIssueAppliedFiltersRoot />
{issues?.loader === "init-loader" ? (
<div className="flex h-full w-full items-center justify-center">
<Spinner />
{issues?.groupedIssueIds?.length === 0 ? (
<div className="relative h-full w-full overflow-y-auto">
<ProjectDraftEmptyState />
</div>
) : (
<>
{!issues?.groupedIssueIds ? (
<ProjectDraftEmptyState />
) : (
<div className="relative h-full w-full overflow-auto">
{activeLayout === "list" ? (
<DraftIssueListLayout />
) : activeLayout === "kanban" ? (
<DraftKanBanLayout />
) : null}
{/* issue peek overview */}
<IssuePeekOverview />
</div>
)}
</>
<div className="relative h-full w-full overflow-auto">
{activeLayout === "list" ? (
<DraftIssueListLayout />
) : activeLayout === "kanban" ? (
<DraftKanBanLayout />
) : null}
{/* issue peek overview */}
<IssuePeekOverview />
</div>
)}
</div>
);
@@ -1,7 +1,8 @@
import React from "react";
import React, { Fragment } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import useSWR from "swr";
import size from "lodash/size";
// mobx store
import { useIssues } from "hooks/store";
// components
@@ -15,10 +16,11 @@ import {
ModuleListLayout,
ModuleSpreadsheetLayout,
} from "components/issues";
// ui
import { Spinner } from "@plane/ui";
import { ActiveLoader } from "components/ui";
// constants
import { EIssuesStoreType } from "constants/issue";
import { EIssueFilterType, EIssuesStoreType } from "constants/issue";
// types
import { IIssueFilterOptions } from "@plane/types";
export const ModuleLayoutRoot: React.FC = observer(() => {
// router
@@ -44,45 +46,72 @@ export const ModuleLayoutRoot: React.FC = observer(() => {
}
);
const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout || undefined;
const userFilters = issuesFilter?.issueFilters?.filters;
const issueFilterCount = size(
Object.fromEntries(
Object.entries(userFilters ?? {}).filter(([, value]) => value && Array.isArray(value) && value.length > 0)
)
);
const handleClearAllFilters = () => {
if (!workspaceSlug || !projectId || !moduleId) return;
const newFilters: IIssueFilterOptions = {};
Object.keys(userFilters ?? {}).forEach((key) => {
newFilters[key as keyof IIssueFilterOptions] = null;
});
issuesFilter.updateFilters(
workspaceSlug.toString(),
projectId.toString(),
EIssueFilterType.FILTERS,
{
...newFilters,
},
moduleId.toString()
);
};
if (!workspaceSlug || !projectId || !moduleId) return <></>;
const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout || undefined;
if (issues?.loader === "init-loader" || !issues?.groupedIssueIds) {
return <>{activeLayout && <ActiveLoader layout={activeLayout} />}</>;
}
return (
<div className="relative flex h-full w-full flex-col overflow-hidden">
<ModuleAppliedFiltersRoot />
{issues?.loader === "init-loader" || !issues?.groupedIssueIds ? (
<div className="flex h-full w-full items-center justify-center">
<Spinner />
{issues?.groupedIssueIds?.length === 0 ? (
<div className="relative h-full w-full overflow-y-auto">
<ModuleEmptyState
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
moduleId={moduleId.toString()}
activeLayout={activeLayout}
handleClearAllFilters={handleClearAllFilters}
isEmptyFilters={issueFilterCount > 0}
/>
</div>
) : (
<>
{issues?.groupedIssueIds?.length === 0 ? (
<ModuleEmptyState
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
moduleId={moduleId.toString()}
/>
) : (
<>
<div className="h-full w-full overflow-auto">
{activeLayout === "list" ? (
<ModuleListLayout />
) : activeLayout === "kanban" ? (
<ModuleKanBanLayout />
) : activeLayout === "calendar" ? (
<ModuleCalendarLayout />
) : activeLayout === "gantt_chart" ? (
<ModuleGanttLayout />
) : activeLayout === "spreadsheet" ? (
<ModuleSpreadsheetLayout />
) : null}
</div>
{/* peek overview */}
<IssuePeekOverview />
</>
)}
</>
<Fragment>
<div className="h-full w-full overflow-auto">
{activeLayout === "list" ? (
<ModuleListLayout />
) : activeLayout === "kanban" ? (
<ModuleKanBanLayout />
) : activeLayout === "calendar" ? (
<ModuleCalendarLayout />
) : activeLayout === "gantt_chart" ? (
<ModuleGanttLayout />
) : activeLayout === "spreadsheet" ? (
<ModuleSpreadsheetLayout />
) : null}
</div>
{/* peek overview */}
<IssuePeekOverview />
</Fragment>
)}
</div>
);
@@ -1,4 +1,4 @@
import { FC } from "react";
import { FC, Fragment } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import useSWR from "swr";
@@ -17,6 +17,8 @@ import {
import { Spinner } from "@plane/ui";
// hooks
import { useIssues } from "hooks/store";
// helpers
import { ActiveLoader } from "components/ui";
// constants
import { EIssuesStoreType } from "constants/issue";
@@ -41,48 +43,42 @@ export const ProjectLayoutRoot: FC = observer(() => {
const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout;
if (!workspaceSlug || !projectId) return <></>;
if (issues?.loader === "init-loader" || !issues?.groupedIssueIds) {
return <>{activeLayout && <ActiveLoader layout={activeLayout} />}</>;
}
return (
<div className="relative flex h-full w-full flex-col overflow-hidden">
<ProjectAppliedFiltersRoot />
{issues?.loader === "init-loader" || !issues?.groupedIssueIds ? (
<div className="flex h-full w-full items-center justify-center">
<Spinner />
</div>
{issues?.groupedIssueIds?.length === 0 ? (
<ProjectEmptyState />
) : (
<>
{issues?.groupedIssueIds?.length === 0 ? (
<div className="relative h-full w-full overflow-y-auto">
<ProjectEmptyState />
</div>
) : (
<>
<div className="relative h-full w-full overflow-auto bg-custom-background-90">
{/* mutation loader */}
{issues?.loader === "mutation" && (
<div className="fixed w-[40px] h-[40px] z-50 right-[20px] top-[70px] flex justify-center items-center bg-custom-background-80 shadow-sm rounded">
<Spinner className="w-4 h-4" />
</div>
)}
{activeLayout === "list" ? (
<ListLayout />
) : activeLayout === "kanban" ? (
<KanBanLayout />
) : activeLayout === "calendar" ? (
<CalendarLayout />
) : activeLayout === "gantt_chart" ? (
<GanttLayout />
) : activeLayout === "spreadsheet" ? (
<ProjectSpreadsheetLayout />
) : null}
<Fragment>
<div className="relative h-full w-full overflow-auto bg-custom-background-90">
{/* mutation loader */}
{issues?.loader === "mutation" && (
<div className="fixed w-[40px] h-[40px] z-50 right-[20px] top-[70px] flex justify-center items-center bg-custom-background-80 shadow-sm rounded">
<Spinner className="w-4 h-4" />
</div>
)}
{activeLayout === "list" ? (
<ListLayout />
) : activeLayout === "kanban" ? (
<KanBanLayout />
) : activeLayout === "calendar" ? (
<CalendarLayout />
) : activeLayout === "gantt_chart" ? (
<GanttLayout />
) : activeLayout === "spreadsheet" ? (
<ProjectSpreadsheetLayout />
) : null}
</div>
{/* peek overview */}
<IssuePeekOverview />
</>
)}
</>
{/* peek overview */}
<IssuePeekOverview />
</Fragment>
)}
</div>
);
@@ -1,4 +1,4 @@
import React, { useMemo } from "react";
import React, { Fragment, useMemo } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import useSWR from "swr";
@@ -15,7 +15,7 @@ import {
ProjectViewListLayout,
ProjectViewSpreadsheetLayout,
} from "components/issues";
import { Spinner } from "@plane/ui";
import { ActiveLoader } from "components/ui";
// constants
import { EIssuesStoreType } from "constants/issue";
// types
@@ -63,39 +63,38 @@ export const ProjectViewLayoutRoot: React.FC = observer(() => {
const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout;
if (!workspaceSlug || !projectId || !viewId) return <></>;
if (issues?.loader === "init-loader" || !issues?.groupedIssueIds) {
return <>{activeLayout && <ActiveLoader layout={activeLayout} />}</>;
}
return (
<div className="relative flex h-full w-full flex-col overflow-hidden">
<ProjectViewAppliedFiltersRoot />
{issues?.loader === "init-loader" || !issues?.groupedIssueIds ? (
<div className="flex h-full w-full items-center justify-center">
<Spinner />
{issues?.groupedIssueIds?.length === 0 ? (
<div className="relative h-full w-full overflow-y-auto">
<ProjectViewEmptyState />
</div>
) : (
<>
{issues?.groupedIssueIds?.length === 0 ? (
<ProjectViewEmptyState />
) : (
<>
<div className="relative h-full w-full overflow-auto">
{activeLayout === "list" ? (
<ProjectViewListLayout issueActions={issueActions} />
) : activeLayout === "kanban" ? (
<ProjectViewKanBanLayout issueActions={issueActions} />
) : activeLayout === "calendar" ? (
<ProjectViewCalendarLayout issueActions={issueActions} />
) : activeLayout === "gantt_chart" ? (
<ProjectViewGanttLayout issueActions={issueActions} />
) : activeLayout === "spreadsheet" ? (
<ProjectViewSpreadsheetLayout issueActions={issueActions} />
) : null}
</div>
<Fragment>
<div className="relative h-full w-full overflow-auto">
{activeLayout === "list" ? (
<ProjectViewListLayout issueActions={issueActions} />
) : activeLayout === "kanban" ? (
<ProjectViewKanBanLayout issueActions={issueActions} />
) : activeLayout === "calendar" ? (
<ProjectViewCalendarLayout issueActions={issueActions} />
) : activeLayout === "gantt_chart" ? (
<ProjectViewGanttLayout issueActions={issueActions} />
) : activeLayout === "spreadsheet" ? (
<ProjectViewSpreadsheetLayout issueActions={issueActions} />
) : null}
</div>
{/* peek overview */}
<IssuePeekOverview />
</>
)}
</>
{/* peek overview */}
<IssuePeekOverview />
</Fragment>
)}
</div>
);
@@ -88,7 +88,7 @@ export const BaseSpreadsheetRoot = observer((props: IBaseSpreadsheetRoot) => {
viewId
);
},
[issueFiltersStore, projectId, workspaceSlug, viewId]
[issueFiltersStore?.updateFilters, projectId, workspaceSlug, viewId]
);
const renderQuickActions = useCallback(
@@ -38,7 +38,7 @@ export const IssueColumn = observer((props: Props) => {
>
<td
tabIndex={0}
className="h-11 w-full min-w-[8rem] bg-custom-background-100 text-sm after:absolute after:w-full after:bottom-[-1px] after:border after:border-custom-border-100 border-r-[1px] border-custom-border-100 focus:border-custom-primary-70"
className="h-11 w-full min-w-[8rem] bg-custom-background-100 text-sm after:absolute after:w-full after:bottom-[-1px] after:border after:border-custom-border-100 border-r-[1px] border-custom-border-100"
ref={tableCellRef}
>
<Column
@@ -28,7 +28,7 @@ export const SpreadsheetHeaderColumn = observer((props: Props) => {
shouldRenderProperty={shouldRenderProperty}
>
<th
className="h-11 w-full min-w-[8rem] items-center bg-custom-background-90 text-sm font-medium px-4 py-1 border border-b-0 border-t-0 border-custom-border-100 focus:border-custom-primary-70"
className="h-11 w-full min-w-[8rem] items-center bg-custom-background-90 text-sm font-medium px-4 py-1 border border-b-0 border-t-0 border-custom-border-100"
ref={tableHeaderCellRef}
tabIndex={0}
>
@@ -1,10 +1,15 @@
import { FC } from "react";
// hooks
import { useIssueDetail, useProject, useUser } from "hooks/store";
// components
import { IssueDescriptionForm, TIssueOperations } from "components/issues";
import { IssueReaction } from "../issue-detail/reactions";
import { FC, useCallback, useEffect, useState } from "react";
import { observer } from "mobx-react";
// store hooks
import { useIssueDetail, useProject, useUser } from "hooks/store";
// hooks
import useReloadConfirmations from "hooks/use-reload-confirmation";
// components
import { TIssueOperations } from "components/issues";
import { IssueReaction } from "../issue-detail/reactions";
import { IssueTitleInput } from "../title-input";
import { IssueDescriptionInput } from "../description-input";
import { debounce } from "lodash";
interface IPeekOverviewIssueDetails {
workspaceSlug: string;
@@ -17,17 +22,18 @@ interface IPeekOverviewIssueDetails {
}
export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = observer((props) => {
const { workspaceSlug, projectId, issueId, issueOperations, disabled, isSubmitting, setIsSubmitting } = props;
const { workspaceSlug, issueId, issueOperations, disabled, setIsSubmitting } = props;
// store hooks
const { getProjectById } = useProject();
const { currentUser } = useUser();
const {
issue: { getIssueById },
} = useIssueDetail();
// derived values
const issue = getIssueById(issueId);
if (!issue) return <></>;
const projectDetails = getProjectById(issue?.project_id);
return (
@@ -35,20 +41,28 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = observer(
<span className="text-base font-medium text-custom-text-400">
{projectDetails?.identifier}-{issue?.sequence_id}
</span>
<IssueDescriptionForm
<IssueTitleInput
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
projectId={issue.project_id}
issueId={issue.id}
setIsSubmitting={(value) => setIsSubmitting(value)}
isSubmitting={isSubmitting}
issue={issue}
issueOperations={issueOperations}
disabled={disabled}
value={issue.name}
/>
<IssueDescriptionInput
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
issueId={issue.id}
setIsSubmitting={(value) => setIsSubmitting(value)}
issueOperations={issueOperations}
disabled={disabled}
value={issue.description_html}
/>
{currentUser && (
<IssueReaction
workspaceSlug={workspaceSlug}
projectId={projectId}
projectId={issue.project_id}
issueId={issueId}
currentUser={currentUser}
/>
+12 -10
View File
@@ -69,20 +69,11 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
// state
const [loader, setLoader] = useState(false);
useEffect(() => {
if (peekIssue) {
setLoader(true);
fetchIssue(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId).finally(() => {
setLoader(false);
});
}
}, [peekIssue, fetchIssue]);
const issueOperations: TIssuePeekOperations = useMemo(
() => ({
fetch: async (workspaceSlug: string, projectId: string, issueId: string) => {
try {
await fetchIssue(workspaceSlug, projectId, issueId);
await fetchIssue(workspaceSlug, projectId, issueId, is_archived);
} catch (error) {
console.error("Error fetching the parent issue");
}
@@ -324,9 +315,20 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
removeModulesFromIssue,
setToastAlert,
onIssueUpdate,
captureIssueEvent,
router.asPath,
]
);
useEffect(() => {
if (peekIssue) {
setLoader(true);
issueOperations.fetch(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId).finally(() => {
setLoader(false);
});
}
}, [peekIssue, issueOperations]);
if (!peekIssue?.workspaceSlug || !peekIssue?.projectId || !peekIssue?.issueId) return <></>;
const issue = getIssueById(peekIssue.issueId) || undefined;
+3 -11
View File
@@ -126,7 +126,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
/>
)}
<div className="w-full truncate !text-base">
<div className="w-full !text-base">
{issueId && (
<div
ref={issuePeekOverviewRef}
@@ -230,11 +230,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
disabled={disabled}
/>
<IssueActivity
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
/>
<IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
</div>
) : (
<div className={`flex h-full w-full overflow-auto`}>
@@ -250,11 +246,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
setIsSubmitting={(value) => setIsSubmitting(value)}
/>
<IssueActivity
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
/>
<IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
</div>
</div>
<div
+10 -1
View File
@@ -1,4 +1,4 @@
import React, { Fragment, useRef, useState } from "react";
import React, { Fragment, useEffect, useRef, useState } from "react";
import { useRouter } from "next/router";
import { Combobox } from "@headlessui/react";
import { usePopper } from "react-popper";
@@ -36,6 +36,7 @@ export const IssueLabelSelect: React.FC<Props> = observer((props) => {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// popper
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: "bottom-start",
@@ -76,6 +77,12 @@ export const IssueLabelSelect: React.FC<Props> = observer((props) => {
useOutsideClickDetector(dropdownRef, handleClose);
useEffect(() => {
if (isDropdownOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isDropdownOpen]);
return (
<Combobox
as="div"
@@ -125,6 +132,8 @@ export const IssueLabelSelect: React.FC<Props> = observer((props) => {
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
as="input"
ref={inputRef}
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
onChange={(event) => setQuery(event.target.value)}
placeholder="Search"
+70
View File
@@ -0,0 +1,70 @@
import { FC, useState, useEffect, useCallback } from "react";
import { observer } from "mobx-react";
// components
import { TextArea } from "@plane/ui";
// types
import { TIssueOperations } from "./issue-detail";
// hooks
import useDebounce from "hooks/use-debounce";
import useReloadConfirmations from "hooks/use-reload-confirmation";
export type IssueTitleInputProps = {
disabled?: boolean;
value: string | undefined | null;
workspaceSlug: string;
setIsSubmitting: (value: "submitting" | "submitted" | "saved") => void;
issueOperations: TIssueOperations;
projectId: string;
issueId: string;
};
export const IssueTitleInput: FC<IssueTitleInputProps> = observer((props) => {
const { disabled, value, workspaceSlug, setIsSubmitting, issueId, issueOperations, projectId } = props;
// states
const [title, setTitle] = useState("");
// hooks
const { setShowAlert } = useReloadConfirmations();
const debouncedValue = useDebounce(title, 1500);
useEffect(() => {
if (value) setTitle(value);
}, [value]);
useEffect(() => {
if (debouncedValue) {
issueOperations.update(workspaceSlug, projectId, issueId, { name: debouncedValue }, false).finally(() => {
setIsSubmitting("saved");
});
}
// DO NOT Add more dependencies here. It will cause multiple requests to be sent.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedValue]);
const handleTitleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setShowAlert(true);
setIsSubmitting("submitting");
setTitle(e.target.value);
},
[setIsSubmitting, setShowAlert]
);
return (
<div className="relative">
<TextArea
className={`min-h-min block w-full resize-none overflow-hidden rounded border-none bg-transparent px-3 py-2 text-2xl font-medium outline-none ring-0 focus:ring-1 focus:ring-custom-primary ${
title?.length === 0 ? "!ring-red-400" : ""
}`}
disabled={disabled}
value={title}
onChange={handleTitleChange}
maxLength={255}
placeholder="Issue title"
/>
<div className="pointer-events-none absolute bottom-1 right-1 z-[2] rounded bg-custom-background-100 p-0.5 text-xs text-custom-text-200">
<span className={`${title.length === 0 || title.length > 255 ? "text-red-500" : ""}`}>{title.length}</span>
/255
</div>
</div>
);
});
@@ -9,8 +9,9 @@ import {
DropResult,
Droppable,
} from "@hello-pangea/dnd";
import { useTheme } from "next-themes";
// hooks
import { useLabel } from "hooks/store";
import { useLabel, useUser } from "hooks/store";
import useDraggableInPortal from "hooks/use-draggable-portal";
// components
import {
@@ -19,13 +20,13 @@ import {
ProjectSettingLabelGroup,
ProjectSettingLabelItem,
} from "components/labels";
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
// ui
import { Button, Loader } from "@plane/ui";
import { EmptyState } from "components/common";
// images
import emptyLabel from "public/empty-state/label.svg";
// types
import { IIssueLabel } from "@plane/types";
// constants
import { PROJECT_SETTINGS_EMPTY_STATE_DETAILS } from "constants/empty-state";
const LABELS_ROOT = "labels.root";
@@ -40,7 +41,10 @@ export const ProjectSettingsLabelList: React.FC = observer(() => {
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// theme
const { resolvedTheme } = useTheme();
// store hooks
const { currentUser } = useUser();
const { projectLabels, updateLabelPosition, projectLabelsTree } = useLabel();
// portal
const renderDraggable = useDraggableInPortal();
@@ -50,6 +54,10 @@ export const ProjectSettingsLabelList: React.FC = observer(() => {
setLabelForm(true);
};
const emptyStateDetail = PROJECT_SETTINGS_EMPTY_STATE_DETAILS["labels"];
const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light";
const emptyStateImage = getEmptyStateImagePath("project-settings", "labels", isLightMode);
const onDragEnd = (result: DropResult) => {
const { combine, draggableId, destination, source } = result;
@@ -94,7 +102,7 @@ export const ProjectSettingsLabelList: React.FC = observer(() => {
Add label
</Button>
</div>
<div className="w-full py-8">
<div className="h-full w-full py-8">
{showLabelForm && (
<div className="w-full rounded border border-custom-border-200 px-3.5 py-2 my-2">
<CreateUpdateLabelInline
@@ -111,15 +119,14 @@ export const ProjectSettingsLabelList: React.FC = observer(() => {
)}
{projectLabels ? (
projectLabels.length === 0 && !showLabelForm ? (
<EmptyState
title="No labels yet"
description="Create labels to help organize and filter issues in you project"
image={emptyLabel}
primaryButton={{
text: "Add label",
onClick: () => newLabel(),
}}
/>
<div className="flex items-center justify-center h-full w-full">
<EmptyState
title={emptyStateDetail.title}
description={emptyStateDetail.description}
image={emptyStateImage}
size="lg"
/>
</div>
) : (
projectLabelsTree && (
<DragDropContext
+13 -23
View File
@@ -8,9 +8,10 @@ import useLocalStorage from "hooks/use-local-storage";
import { ModuleCardItem, ModuleListItem, ModulePeekOverview, ModulesListGanttChartView } from "components/modules";
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
// ui
import { Loader, Spinner } from "@plane/ui";
import { CycleModuleBoardLayout, CycleModuleListLayout, GanttLayoutLoader } from "components/ui";
// constants
import { EUserProjectRoles } from "constants/project";
import { MODULE_EMPTY_STATE_DETAILS } from "constants/empty-state";
export const ModulesListView: React.FC = observer(() => {
// router
@@ -34,23 +35,13 @@ export const ModulesListView: React.FC = observer(() => {
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
if (loader)
if (loader || !projectModuleIds)
return (
<div className="flex items-center justify-center h-full w-full">
<Spinner />
</div>
);
if (!projectModuleIds)
return (
<Loader className="grid grid-cols-3 gap-4 p-8">
<Loader.Item height="176px" />
<Loader.Item height="176px" />
<Loader.Item height="176px" />
<Loader.Item height="176px" />
<Loader.Item height="176px" />
<Loader.Item height="176px" />
</Loader>
<>
{modulesView === "list" && <CycleModuleListLayout />}
{modulesView === "grid" && <CycleModuleBoardLayout />}
{modulesView === "gantt_chart" && <GanttLayoutLoader />}
</>
);
return (
@@ -97,16 +88,15 @@ export const ModulesListView: React.FC = observer(() => {
</>
) : (
<EmptyState
title="Map your project milestones to Modules and track aggregated work easily."
description="A group of issues that belong to a logical, hierarchical parent form a module. Think of them as a way to track work by project milestones. They have their own periods and deadlines as well as analytics to help you see how close or far you are from a milestone."
title={MODULE_EMPTY_STATE_DETAILS["modules"].title}
description={MODULE_EMPTY_STATE_DETAILS["modules"].description}
image={EmptyStateImagePath}
comicBox={{
title: "Modules help group work by hierarchy.",
description:
"A cart module, a chassis module, and a warehouse module are all good example of this grouping.",
title: MODULE_EMPTY_STATE_DETAILS["modules"].comicBox.title,
description: MODULE_EMPTY_STATE_DETAILS["modules"].comicBox.description,
}}
primaryButton={{
text: "Build your first module",
text: MODULE_EMPTY_STATE_DETAILS["modules"].primaryButton.text,
onClick: () => {
setTrackElement("Module empty state");
commandPaletteStore.toggleCreateModuleModal(true);
@@ -9,7 +9,8 @@ import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components
import { EmptyState } from "components/common";
import { SnoozeNotificationModal, NotificationCard, NotificationHeader } from "components/notifications";
import { Loader, Tooltip } from "@plane/ui";
import { Tooltip } from "@plane/ui";
import { NotificationsLoader } from "components/ui";
// images
import emptyNotification from "public/empty-state/notification.svg";
// helpers
@@ -188,13 +189,7 @@ export const NotificationPopover = observer(() => {
</div>
)
) : (
<Loader className="space-y-4 overflow-y-auto p-5">
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
</Loader>
<NotificationsLoader />
)}
</Popover.Panel>
</Transition>
@@ -14,6 +14,7 @@ import { Spinner } from "@plane/ui";
// constants
import { EUserWorkspaceRoles } from "constants/workspace";
import { PRODUCT_TOUR_COMPLETED } from "constants/event-tracker";
import { WORKSPACE_EMPTY_STATE_DETAILS } from "constants/empty-state";
export const WorkspaceDashboardView = observer(() => {
// theme
@@ -78,20 +79,18 @@ export const WorkspaceDashboardView = observer(() => {
) : (
<EmptyState
image={emptyStateImage}
title="Overview of your projects, activity, and metrics"
description=" Welcome to Plane, we are excited to have you here. Create your first project and track your issues, and this
page will transform into a space that helps you progress. Admins will also see items which help their team
progress."
title={WORKSPACE_EMPTY_STATE_DETAILS["dashboard"].title}
description={WORKSPACE_EMPTY_STATE_DETAILS["dashboard"].description}
primaryButton={{
text: "Build your first project",
text: WORKSPACE_EMPTY_STATE_DETAILS["dashboard"].primaryButton.text,
onClick: () => {
setTrackElement("Dashboard empty state");
toggleCreateProjectModal(true);
},
}}
comicBox={{
title: "Everything starts with a project in Plane",
description: "A project could be a products roadmap, a marketing campaign, or launching a new car.",
title: WORKSPACE_EMPTY_STATE_DETAILS["dashboard"].comicBox.title,
description: WORKSPACE_EMPTY_STATE_DETAILS["dashboard"].comicBox.description,
}}
size="lg"
disabled={!isEditingAllowed}
@@ -68,7 +68,6 @@ export const CreateUpdatePageModal: FC<Props> = (props) => {
state: "SUCCESS",
},
});
console.log("Page updated successfully", pageStore);
} else {
await createProjectPage(formData);
}
@@ -11,7 +11,7 @@ import { PagesListItem } from "./list-item";
import { Loader } from "@plane/ui";
// constants
import { EUserProjectRoles } from "constants/project";
import { PAGE_EMPTY_STATE_DETAILS } from "constants/page";
import { PAGE_EMPTY_STATE_DETAILS } from "constants/empty-state";
type IPagesListView = {
pageIds: string[];
@@ -13,6 +13,7 @@ import { Loader } from "@plane/ui";
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
// constants
import { EUserProjectRoles } from "constants/project";
import { PAGE_EMPTY_STATE_DETAILS } from "constants/empty-state";
export const RecentPagesList: FC = observer(() => {
// theme
@@ -63,11 +64,11 @@ export const RecentPagesList: FC = observer(() => {
) : (
<>
<EmptyState
title="Write a note, a doc, or a full knowledge base"
description="Pages help you organise your thoughts to create wikis, discussions or even document heated takes for your project. Use it wisely! Pages will be sorted and grouped by last updated."
title={PAGE_EMPTY_STATE_DETAILS["Recent"].title}
description={PAGE_EMPTY_STATE_DETAILS["Recent"].description}
image={EmptyStateImagePath}
primaryButton={{
text: "Create new page",
text: PAGE_EMPTY_STATE_DETAILS["Recent"].primaryButton.text,
onClick: () => commandPaletteStore.toggleCreatePageModal(true),
}}
size="sm"
+33 -34
View File
@@ -1,4 +1,4 @@
import React from "react";
import React, { useEffect } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
import { observer } from "mobx-react-lite";
@@ -7,14 +7,14 @@ import { useTheme } from "next-themes";
import { ProfileIssuesListLayout } from "components/issues/issue-layouts/list/roots/profile-issues-root";
import { ProfileIssuesKanBanLayout } from "components/issues/issue-layouts/kanban/roots/profile-issues-root";
import { IssuePeekOverview, ProfileIssuesAppliedFiltersRoot } from "components/issues";
import { Spinner } from "@plane/ui";
import { KanbanLayoutLoader, ListLayoutLoader } from "components/ui";
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
// hooks
import { useIssues, useUser } from "hooks/store";
// constants
import { EUserWorkspaceRoles } from "constants/workspace";
import { PROFILE_EMPTY_STATE_DETAILS } from "constants/profile";
import { EIssuesStoreType } from "constants/issue";
import { PROFILE_EMPTY_STATE_DETAILS } from "constants/empty-state";
interface IProfileIssuesPage {
type: "assigned" | "subscribed" | "created";
@@ -36,10 +36,14 @@ export const ProfileIssuesPage = observer((props: IProfileIssuesPage) => {
currentUser,
} = useUser();
const {
issues: { loader, groupedIssueIds, fetchIssues },
issues: { loader, groupedIssueIds, fetchIssues, setViewId },
issuesFilter: { issueFilters, fetchFilters },
} = useIssues(EIssuesStoreType.PROFILE);
useEffect(() => {
setViewId(type);
}, [type]);
useSWR(
workspaceSlug && userId ? `CURRENT_WORKSPACE_PROFILE_ISSUES_${workspaceSlug}_${userId}_${type}` : null,
async () => {
@@ -57,38 +61,33 @@ export const ProfileIssuesPage = observer((props: IProfileIssuesPage) => {
const isEditingAllowed = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER;
if (!groupedIssueIds || loader === "init-loader")
return <>{activeLayout === "list" ? <ListLayoutLoader /> : <KanbanLayoutLoader />}</>;
if (groupedIssueIds.length === 0) {
return (
<EmptyState
image={emptyStateImage}
title={PROFILE_EMPTY_STATE_DETAILS[type].title}
description={PROFILE_EMPTY_STATE_DETAILS[type].description}
size="sm"
disabled={!isEditingAllowed}
/>
);
}
return (
<>
{loader === "init-loader" ? (
<div className="flex h-full w-full items-center justify-center">
<Spinner />
</div>
) : (
<>
{groupedIssueIds ? (
<>
<ProfileIssuesAppliedFiltersRoot />
<div className="-z-1 relative h-full w-full overflow-auto">
{activeLayout === "list" ? (
<ProfileIssuesListLayout />
) : activeLayout === "kanban" ? (
<ProfileIssuesKanBanLayout />
) : null}
</div>
{/* peek overview */}
<IssuePeekOverview />
</>
) : (
<EmptyState
image={emptyStateImage}
title={PROFILE_EMPTY_STATE_DETAILS[type].title}
description={PROFILE_EMPTY_STATE_DETAILS[type].description}
size="sm"
disabled={!isEditingAllowed}
/>
)}
</>
)}
<ProfileIssuesAppliedFiltersRoot />
<div className="-z-1 relative h-full w-full overflow-auto">
{activeLayout === "list" ? (
<ProfileIssuesListLayout />
) : activeLayout === "kanban" ? (
<ProfileIssuesKanBanLayout />
) : null}
</div>
{/* peek overview */}
<IssuePeekOverview />
</>
);
});
+8 -17
View File
@@ -4,10 +4,11 @@ import { useTheme } from "next-themes";
import { useApplication, useEventTracker, useProject, useUser } from "hooks/store";
// components
import { ProjectCard } from "components/project";
import { Loader } from "@plane/ui";
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
import { ProjectsLoader } from "components/ui";
// constants
import { EUserWorkspaceRoles } from "constants/workspace";
import { WORKSPACE_EMPTY_STATE_DETAILS } from "constants/empty-state";
export const ProjectCardList = observer(() => {
// theme
@@ -26,17 +27,7 @@ export const ProjectCardList = observer(() => {
const isEditingAllowed = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER;
if (!workspaceProjectIds)
return (
<Loader className="grid grid-cols-3 gap-4">
<Loader.Item height="100px" />
<Loader.Item height="100px" />
<Loader.Item height="100px" />
<Loader.Item height="100px" />
<Loader.Item height="100px" />
<Loader.Item height="100px" />
</Loader>
);
if (!workspaceProjectIds) return <ProjectsLoader />;
return (
<>
@@ -59,18 +50,18 @@ export const ProjectCardList = observer(() => {
) : (
<EmptyState
image={emptyStateImage}
title="Start a Project"
description="Think of each project as the parent for goal-oriented work. Projects are where Jobs, Cycles, and Modules live and, along with your colleagues, help you achieve that goal."
title={WORKSPACE_EMPTY_STATE_DETAILS["projects"].title}
description={WORKSPACE_EMPTY_STATE_DETAILS["projects"].description}
primaryButton={{
text: "Start your first project",
text: WORKSPACE_EMPTY_STATE_DETAILS["projects"].primaryButton.text,
onClick: () => {
setTrackElement("Project empty state");
commandPaletteStore.toggleCreateProjectModal(true);
},
}}
comicBox={{
title: "Everything starts with a project in Plane",
description: "A project could be a products roadmap, a marketing campaign, or launching a new car.",
title: WORKSPACE_EMPTY_STATE_DETAILS["projects"].comicBox.title,
description: WORKSPACE_EMPTY_STATE_DETAILS["projects"].comicBox.description,
}}
size="lg"
disabled={!isEditingAllowed}
+1 -1
View File
@@ -79,7 +79,7 @@ export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => {
return updateProject(workspaceSlug.toString(), project.id, payload)
.then((res) => {
const changed_properties = Object.keys(dirtyFields);
console.log(dirtyFields);
captureProjectEvent({
eventName: PROJECT_UPDATED,
payload: {
+3 -7
View File
@@ -6,7 +6,8 @@ import { useEventTracker, useMember } from "hooks/store";
// components
import { ProjectMemberListItem, SendProjectInvitationModal } from "components/project";
// ui
import { Button, Loader } from "@plane/ui";
import { Button } from "@plane/ui";
import { MembersSettingsLoader } from "components/ui";
export const ProjectMemberList: React.FC = observer(() => {
// states
@@ -56,12 +57,7 @@ export const ProjectMemberList: React.FC = observer(() => {
</Button>
</div>
{!projectMemberIds ? (
<Loader className="space-y-5">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
<MembersSettingsLoader />
) : (
<div className="divide-y divide-custom-border-100">
{projectMemberIds.length > 0
+17 -24
View File
@@ -18,7 +18,7 @@ import {
MoreHorizontal,
} from "lucide-react";
// hooks
import { useApplication,useEventTracker, useProject } from "hooks/store";
import { useApplication, useEventTracker, useProject } from "hooks/store";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
import useToast from "hooks/use-toast";
// helpers
@@ -131,7 +131,7 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
const handleProjectClick = () => {
if (window.innerWidth < 768) {
themeStore.toggleSidebar();
themeStore.toggleMobileSidebar();
}
};
@@ -147,9 +147,8 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
{({ open }) => (
<>
<div
className={`group relative flex w-full items-center rounded-md px-2 py-1 text-custom-sidebar-text-10 hover:bg-custom-sidebar-background-80 ${
snapshot?.isDragging ? "opacity-60" : ""
} ${isMenuActive ? "!bg-custom-sidebar-background-80" : ""}`}
className={`group relative flex w-full items-center rounded-md px-2 py-1 text-custom-sidebar-text-10 hover:bg-custom-sidebar-background-80 ${snapshot?.isDragging ? "opacity-60" : ""
} ${isMenuActive ? "!bg-custom-sidebar-background-80" : ""}`}
>
{provided && (
<Tooltip
@@ -158,11 +157,9 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
>
<button
type="button"
className={`absolute -left-2.5 top-1/2 hidden -translate-y-1/2 rounded p-0.5 text-custom-sidebar-text-400 ${
isCollapsed ? "" : "group-hover:!flex"
} ${project.sort_order === null ? "cursor-not-allowed opacity-60" : ""} ${
isMenuActive ? "!flex" : ""
}`}
className={`absolute -left-2.5 top-1/2 hidden -translate-y-1/2 rounded p-0.5 text-custom-sidebar-text-400 ${isCollapsed ? "" : "group-hover:!flex"
} ${project.sort_order === null ? "cursor-not-allowed opacity-60" : ""} ${isMenuActive ? "!flex" : ""
}`}
{...provided?.dragHandleProps}
>
<MoreVertical className="h-3.5" />
@@ -173,14 +170,12 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
<Tooltip tooltipContent={`${project.name}`} position="right" className="ml-2" disabled={!isCollapsed}>
<Disclosure.Button
as="div"
className={`flex flex-grow cursor-pointer select-none items-center truncate text-left text-sm font-medium ${
isCollapsed ? "justify-center" : `justify-between`
}`}
className={`flex flex-grow cursor-pointer select-none items-center truncate text-left text-sm font-medium ${isCollapsed ? "justify-center" : `justify-between`
}`}
>
<div
className={`flex w-full flex-grow items-center gap-x-2 truncate ${
isCollapsed ? "justify-center" : ""
}`}
className={`flex w-full flex-grow items-center gap-x-2 truncate ${isCollapsed ? "justify-center" : ""
}`}
>
{project.emoji ? (
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
@@ -200,9 +195,8 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
</div>
{!isCollapsed && (
<ChevronDown
className={`hidden h-4 w-4 flex-shrink-0 ${open ? "rotate-180" : ""} ${
isMenuActive ? "!block" : ""
} mb-0.5 text-custom-sidebar-text-400 duration-300 group-hover:!block`}
className={`hidden h-4 w-4 flex-shrink-0 ${open ? "rotate-180" : ""} ${isMenuActive ? "!block" : ""
} mb-0.5 text-custom-sidebar-text-400 duration-300 group-hover:!block`}
/>
)}
</Disclosure.Button>
@@ -326,11 +320,10 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
disabled={!isCollapsed}
>
<div
className={`group flex items-center gap-2.5 rounded-md px-2 py-1.5 text-xs font-medium outline-none ${
router.asPath.includes(item.href)
? "bg-custom-primary-100/10 text-custom-primary-100"
: "text-custom-sidebar-text-300 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
} ${isCollapsed ? "justify-center" : ""}`}
className={`group flex items-center gap-2.5 rounded-md px-2 py-1.5 text-xs font-medium outline-none ${router.asPath.includes(item.href)
? "bg-custom-primary-100/10 text-custom-primary-100"
: "text-custom-sidebar-text-300 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
} ${isCollapsed ? "justify-center" : ""}`}
>
<item.Icon className="h-4 w-4 stroke-[1.5]" />
{!isCollapsed && item.name}
+1
View File
@@ -7,3 +7,4 @@ export * from "./markdown-to-component";
export * from "./integration-and-import-export-banner";
export * from "./range-datepicker";
export * from "./profile-empty-state";
export * from "./loader";
@@ -0,0 +1,38 @@
export const CycleModuleBoardLayout = () => (
<div className="h-full w-full animate-pulse">
<div className="flex h-full w-full justify-between">
<div className="grid h-full w-full grid-cols-1 gap-6 overflow-y-auto p-8 lg:grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4 auto-rows-max transition-all">
{[...Array(5)].map(() => (
<div className="flex h-44 w-full flex-col justify-between rounded border border-custom-border-100 bg-custom-background-100 p-4 text-sm">
<div className="flex items-center justify-between">
<span className="h-6 w-24 bg-custom-background-80 rounded" />
<div className="flex items-center gap-2">
<span className="h-6 w-20 bg-custom-background-80 rounded" />
<span className="h-6 w-6 bg-custom-background-80 rounded" />
</div>
</div>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="h-5 w-5 bg-custom-background-80 rounded" />
<span className="h-5 w-20 bg-custom-background-80 rounded" />
</div>
<span className="h-5 w-5 bg-custom-background-80 rounded-full" />
</div>
<span className="h-1.5 bg-custom-background-80 rounded" />
<div className="flex items-center justify-between">
<div className="flex items-center">
<span className="h-4 w-16 bg-custom-background-80 rounded" />
</div>
<div className="flex items-center gap-2">
<span className="h-4 w-4 bg-custom-background-80 rounded" />
<span className="h-4 w-4 bg-custom-background-80 rounded" />
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
@@ -0,0 +1,28 @@
export const CycleModuleListLayout = () => (
<div className="h-full overflow-y-auto animate-pulse">
<div className="flex h-full w-full justify-between">
<div className="flex h-full w-full flex-col overflow-y-auto">
{[...Array(5)].map(() => (
<div className="flex w-full items-center justify-between gap-5 border-b border-custom-border-100 flex-col sm:flex-row px-5 py-6">
<div className="relative flex w-full items-center gap-3 justify-between overflow-hidden">
<div className="relative w-full flex items-center gap-3 overflow-hidden">
<div className="flex items-center gap-4 truncate">
<span className="h-10 w-10 bg-custom-background-80 rounded-full" />
<span className="h-5 w-20 bg-custom-background-80 rounded" />
</div>
</div>
<span className="h-6 w-20 bg-custom-background-80 rounded" />
</div>
<div className="flex w-full sm:w-auto relative overflow-hidden items-center gap-2.5 justify-between sm:justify-end sm:flex-shrink-0 ">
<div className="flex-shrink-0 relative flex items-center gap-3">
<span className="h-5 w-5 bg-custom-background-80 rounded" />
<span className="h-5 w-5 bg-custom-background-80 rounded" />
<span className="h-5 w-5 bg-custom-background-80 rounded" />
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
+9
View File
@@ -0,0 +1,9 @@
export * from "./layouts";
export * from "./settings";
export * from "./pages-loader";
export * from "./notification-loader";
export * from "./cycle-module-board-loader";
export * from "./cycle-module-list-loader";
export * from "./view-list-loader";
export * from "./projects-loader";
export * from "./utils";
@@ -0,0 +1,48 @@
import { getRandomInt } from "../utils";
const CalendarDay = () => {
const dataCount = getRandomInt(0, 1);
const dataBlocks = Array.from({ length: dataCount }, (_, index) => (
<span key={index} className="h-8 w-full bg-custom-background-80 rounded mb-2" />
));
return (
<div className="flex w-full flex-col min-h-[9rem]">
<div className="flex items-center justify-end p-2 w-full">
<span className="h-6 w-6 bg-custom-background-80 rounded" />
</div>
<div className="flex flex-col gap-2.5 p-2">{dataBlocks}</div>
</div>
);
};
export const CalendarLayoutLoader = () => (
<div className="h-full w-full overflow-y-auto bg-custom-background-100 pt-4 animate-pulse">
<div className="mb-4 flex items-center justify-between gap-2 px-3">
<div className="flex items-center gap-2">
<span className="h-7 w-10 bg-custom-background-80 rounded" />
<span className="h-7 w-32 bg-custom-background-80 rounded" />
</div>
<div className="flex items-center gap-2">
<span className="h-7 w-12 bg-custom-background-80 rounded" />
<span className="h-7 w-20 bg-custom-background-80 rounded" />
</div>
</div>
<span className="relative grid divide-x-[0.5px] divide-custom-border-200 text-sm font-medium grid-cols-5">
{[...Array(5)].map((_, index) => (
<span key={index} className="h-11 w-full bg-custom-background-80" />
))}
</span>
<div className="h-full w-full overflow-y-auto">
<div className="grid h-full w-full grid-cols-1 divide-y-[0.5px] divide-custom-border-200 overflow-y-auto">
{[...Array(6)].map((_, index) => (
<div key={index} className="grid divide-x-[0.5px] divide-custom-border-200 grid-cols-5">
{[...Array(5)].map((_, index) => (
<CalendarDay key={index} />
))}
</div>
))}
</div>
</div>
</div>
);
@@ -0,0 +1,50 @@
import { getRandomLength } from "../utils";
export const GanttLayoutLoader = () => (
<div className="flex flex-col h-full overflow-x-auto animate-pulse">
<div className="min-h-10 w-full border-b border-custom-border-200 ">
<span className="h-6 w-12 bg-custom-background-80 rounded" />
</div>
<div className="flex h-full">
<div className="h-full w-[25.5rem] border-r border-custom-border-200">
<div className="flex items-end h-[3.75rem] py-2 px-4 border-b border-custom-border-200">
<div className="flex items-center pl-6 justify-between w-full">
<span className="h-5 w-14 bg-custom-background-80 rounded" />
<span className="h-5 w-16 bg-custom-background-80 rounded" />
</div>
</div>
<div className="flex flex-col gap-3 h-11 p-4 w-full">
{[...Array(6)].map((_, index) => (
<div key={index} className="flex items-center gap-3 h-11 pl-6 w-full">
<span className="h-6 w-6 bg-custom-background-80 rounded" />
<span className={`h-6 w-${getRandomLength(["32", "52", "72"])} bg-custom-background-80 rounded`} />
</div>
))}
</div>
</div>
<div className="h-full w-full border-r border-custom-border-200">
<div className="flex flex-col justify-between gap-2 h-[3.75rem] py-1.5 px-4 border-b border-custom-border-200">
<div className="flex items-center justify-center">
<span className="h-5 w-20 bg-custom-background-80 rounded" />
</div>
<div className="flex items-center gap-3 justify-between w-full">
{[...Array(15)].map((_, index) => (
<span key={index} className="h-5 w-10 bg-custom-background-80 rounded" />
))}
</div>
</div>
<div className="flex flex-col gap-3 h-11 p-4 w-full">
{[...Array(6)].map((_, index) => (
<div
key={index}
className={`flex items-center gap-3 h-11 w-full`}
style={{ paddingLeft: getRandomLength(["115px", "208px", "260px"]) }}
>
<span className={`h-6 w-40 w-${getRandomLength(["32", "52", "72"])} bg-custom-background-80 rounded`} />
</div>
))}
</div>
</div>
</div>
</div>
);
@@ -0,0 +1,5 @@
export * from "./list-layout-loader";
export * from "./kanban-layout-loader";
export * from "./calendar-layout-loader";
export * from "./spreadsheet-layout-loader";
export * from "./gantt-layout-loader";
@@ -0,0 +1,18 @@
export const KanbanLayoutLoader = ({ cardsInEachColumn = [2, 3, 2, 4, 3] }: { cardsInEachColumn?: number[] }) => (
<div className="flex gap-5 px-3.5 py-1.5 overflow-x-auto">
{cardsInEachColumn.map((cardsInColumn, columnIndex) => (
<div key={columnIndex} className="flex flex-col gap-3 animate-pulse">
<div className="flex items-center justify-between h-9 w-80">
<div className="flex item-center gap-1.5">
<span className="h-6 w-6 bg-custom-background-80 rounded" />
<span className="h-6 w-24 bg-custom-background-80 rounded" />
</div>
<span className="h-6 w-6 bg-custom-background-80 rounded" />
</div>
{Array.from({ length: cardsInColumn }, (_, cardIndex) => (
<span key={cardIndex} className="h-28 w-80 bg-custom-background-80 rounded" />
))}
</div>
))}
</div>
);
@@ -0,0 +1,45 @@
import { getRandomInt, getRandomLength } from "../utils";
const ListItemRow = () => (
<div className="flex items-center justify-between h-11 p-3 border-b border-custom-border-200">
<div className="flex items-center gap-3">
<span className="h-5 w-10 bg-custom-background-80 rounded" />
<span className={`h-5 w-${getRandomLength(["32", "52", "72"])} bg-custom-background-80 rounded`} />
</div>
<div className="flex items-center gap-2">
{[...Array(6)].map((_, index) => (
<>
{getRandomInt(1, 2) % 2 === 0 ? (
<span key={index} className="h-5 w-5 bg-custom-background-80 rounded" />
) : (
<span className="h-5 w-16 bg-custom-background-80 rounded" />
)}
</>
))}
</div>
</div>
);
const ListSection = ({ itemCount }: { itemCount: number }) => (
<div className="flex flex-shrink-0 flex-col">
<div className="sticky top-0 z-[2] w-full flex-shrink-0 border-b border-custom-border-200 bg-custom-background-90 px-3 py-1">
<div className="flex items-center gap-2 py-1.5 w-full">
<span className="h-6 w-6 bg-custom-background-80 rounded" />
<span className="h-6 w-24 bg-custom-background-80 rounded" />
</div>
</div>
<div className="relative h-full w-full">
{[...Array(itemCount)].map((_, index) => (
<ListItemRow key={index} />
))}
</div>
</div>
);
export const ListLayoutLoader = () => (
<div className="flex flex-shrink-0 flex-col animate-pulse">
{[6, 5, 2].map((itemCount, index) => (
<ListSection key={index} itemCount={itemCount} />
))}
</div>
);
@@ -0,0 +1,38 @@
import { getRandomLength } from "../utils";
export const SpreadsheetLayoutLoader = () => (
<div className="horizontal-scroll-enable h-full w-full animate-pulse">
<table>
<thead>
<tr>
<th className="h-11 min-w-[28rem] bg-custom-background-90 border-r border-custom-border-100" />
{[...Array(10)].map((_, index) => (
<th
key={index}
className="h-11 w-full min-w-[8rem] bg-custom-background-90 border-r border-custom-border-100"
/>
))}
</tr>
</thead>
<tbody>
{[...Array(16)].map((_, rowIndex) => (
<tr key={rowIndex} className="border-b border-custom-border-100">
<td className="h-11 min-w-[28rem] border-r border-custom-border-100">
<div className="flex items-center gap-3 px-3">
<span className="h-5 w-10 bg-custom-background-80 rounded" />
<span className={`h-5 w-${getRandomLength(["32", "52", "72"])} bg-custom-background-80 rounded`} />
</div>
</td>
{[...Array(10)].map((_, colIndex) => (
<td key={colIndex} className="h-11 w-full min-w-[8rem] border-r border-custom-border-100">
<div className="flex items-center justify-center gap-3 px-3">
<span className="h-5 w-20 bg-custom-background-80 rounded" />
</div>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
@@ -0,0 +1,16 @@
export const NotificationsLoader = () => (
<div className="divide-y divide-custom-border-100 animate-pulse overflow-hidden">
{[...Array(3)].map(() => (
<div className="flex w-full items-center gap-4 p-3">
<span className="min-h-12 min-w-12 bg-custom-background-80 rounded-full" />
<div className="flex flex-col gap-2.5 w-full">
<span className="h-5 w-36 bg-custom-background-80 rounded" />
<div className="flex items-center justify-between gap-2 w-full">
<span className="h-5 w-28 bg-custom-background-80 rounded" />
<span className="h-5 w-16 bg-custom-background-80 rounded" />
</div>
</div>
</div>
))}
</div>
);
+28
View File
@@ -0,0 +1,28 @@
export const PagesLoader = () => (
<div className="flex h-full flex-col space-y-5 overflow-hidden p-6">
<div className="flex justify-between gap-4">
<h3 className="text-2xl font-semibold text-custom-text-100">Pages</h3>
</div>
<div className="flex items-center gap-3">
{[...Array(5)].map(() => (
<span className="h-8 w-20 bg-custom-background-80 rounded-full" />
))}
</div>
<div className="divide-y divide-custom-border-200">
{[...Array(5)].map(() => (
<div className="h-12 w-full flex items-center justify-between px-3">
<div className="flex items-center gap-1.5">
<span className="h-5 w-5 bg-custom-background-80 rounded" />
<span className="h-5 w-20 bg-custom-background-80 rounded" />
</div>
<div className="flex items-center gap-1.5">
<span className="h-5 w-16 bg-custom-background-80 rounded" />
<span className="h-5 w-5 bg-custom-background-80 rounded" />
<span className="h-5 w-5 bg-custom-background-80 rounded" />
<span className="h-5 w-5 bg-custom-background-80 rounded" />
</div>
</div>
))}
</div>
</div>
);
@@ -0,0 +1,34 @@
export const ProjectsLoader = () => (
<div className="h-full w-full overflow-y-auto p-8 animate-pulse">
<div className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
{[...Array(3)].map(() => (
<div className="flex cursor-pointer flex-col rounded border border-custom-border-200 bg-custom-background-100">
<div className="relative min-h-[118px] w-full rounded-t border-b border-custom-border-200 ">
<div className="absolute inset-0 z-[1] bg-gradient-to-t from-black/20 to-transparent">
<div className="absolute bottom-4 z-10 flex h-10 w-full items-center justify-between gap-3 px-4">
<div className="flex flex-grow items-center gap-2.5 truncate">
<span className="min-h-9 min-w-9 bg-custom-background-80 rounded" />
<div className="flex w-full flex-col justify-between gap-0.5 truncate">
<span className="h-4 w-28 bg-custom-background-80 rounded" />
<span className="h-4 w-16 bg-custom-background-80 rounded" />
</div>
</div>
<div className="flex h-full flex-shrink-0 items-center gap-2">
<span className="h-6 w-6 bg-custom-background-80 rounded" />
<span className="h-6 w-6 bg-custom-background-80 rounded" />
</div>
</div>
</div>
</div>
<div className="flex h-[104px] w-full flex-col justify-between rounded-b p-4">
<span className="h-4 w-36 bg-custom-background-80 rounded" />
<div className="item-center flex justify-between">
<span className="h-5 w-20 bg-custom-background-80 rounded" />
<span className="h-5 w-5 bg-custom-background-80 rounded" />
</div>
</div>
</div>
))}
</div>
</div>
);

Some files were not shown because too many files have changed in this diff Show More