diff --git a/web/components/core/list/index.ts b/web/components/core/list/index.ts new file mode 100644 index 000000000..d5489c45e --- /dev/null +++ b/web/components/core/list/index.ts @@ -0,0 +1,2 @@ +export * from "./list-item"; +export * from "./list-root"; diff --git a/web/components/core/list/list-item.tsx b/web/components/core/list/list-item.tsx new file mode 100644 index 000000000..a9f844f79 --- /dev/null +++ b/web/components/core/list/list-item.tsx @@ -0,0 +1,53 @@ +import React, { FC } from "react"; +import Link from "next/link"; +// ui +import { Tooltip } from "@plane/ui"; + +interface IListItemProps { + title: string; + itemLink: string; + onItemClick?: (e: React.MouseEvent) => void; + prependTitleElement?: JSX.Element; + appendTitleElement?: JSX.Element; + actionableItems?: JSX.Element; + isMobile?: boolean; +} + +export const ListItem: FC = (props) => { + const { + title, + prependTitleElement, + appendTitleElement, + actionableItems, + itemLink, + onItemClick, + isMobile = false, + } = props; + return ( +
+ +
+
+
+
+ {prependTitleElement && {prependTitleElement}} + + {title} + +
+ {appendTitleElement && {appendTitleElement}} +
+
+ +
+ + {actionableItems && ( +
+
+ {actionableItems} +
+
+ )} +
+ ); +}; diff --git a/web/components/core/list/list-root.tsx b/web/components/core/list/list-root.tsx new file mode 100644 index 000000000..c9c465f27 --- /dev/null +++ b/web/components/core/list/list-root.tsx @@ -0,0 +1,10 @@ +import React, { FC } from "react"; + +interface IListContainer { + children: React.ReactNode; +} + +export const ListLayout: FC = (props) => { + const { children } = props; + return
{children}
; +}; diff --git a/web/components/cycles/cycles-view-header.tsx b/web/components/cycles/cycles-view-header.tsx index 50cf6df97..97cf18cf9 100644 --- a/web/components/cycles/cycles-view-header.tsx +++ b/web/components/cycles/cycles-view-header.tsx @@ -76,7 +76,7 @@ export const CyclesViewHeader: React.FC = observer((props) => { }; return ( -
+
{CYCLE_TABS_LIST.map((tab) => ( = observer((props) => { + const { workspaceSlug, projectId, cycleId, cycleDetails, isArchived } = props; + // hooks + const { isMobile } = usePlatformOS(); + // store hooks + const { addCycleToFavorites, removeCycleFromFavorites } = useCycle(); + const { captureEvent } = useEventTracker(); + const { + membership: { currentProjectRole }, + } = useUser(); + const { getUserDetails } = useMember(); + + // derived values + const endDate = getDate(cycleDetails.end_date); + const startDate = getDate(cycleDetails.start_date); + const cycleStatus = cycleDetails.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft"; + const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; + const renderDate = cycleDetails.start_date || cycleDetails.end_date; + const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus); + const daysLeft = findHowManyDaysLeft(cycleDetails.end_date) ?? 0; + + // handlers + const handleAddToFavorites = (e: MouseEvent) => { + e.preventDefault(); + if (!workspaceSlug || !projectId) return; + + const addToFavoritePromise = addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).then( + () => { + captureEvent(CYCLE_FAVORITED, { + cycle_id: cycleId, + element: "List layout", + state: "SUCCESS", + }); + } + ); + + setPromiseToast(addToFavoritePromise, { + loading: "Adding cycle to favorites...", + success: { + title: "Success!", + message: () => "Cycle added to favorites.", + }, + error: { + title: "Error!", + message: () => "Couldn't add the cycle to favorites. Please try again.", + }, + }); + }; + + const handleRemoveFromFavorites = (e: MouseEvent) => { + e.preventDefault(); + if (!workspaceSlug || !projectId) return; + + const removeFromFavoritePromise = removeCycleFromFavorites( + workspaceSlug?.toString(), + projectId.toString(), + cycleId + ).then(() => { + captureEvent(CYCLE_UNFAVORITED, { + cycle_id: cycleId, + element: "List layout", + state: "SUCCESS", + }); + }); + + setPromiseToast(removeFromFavoritePromise, { + loading: "Removing cycle from favorites...", + success: { + title: "Success!", + message: () => "Cycle removed from favorites.", + }, + error: { + title: "Error!", + message: () => "Couldn't remove the cycle from favorites. Please try again.", + }, + }); + }; + return ( + <> +
+ {renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`} +
+ + {currentCycle && ( +
+ {currentCycle.value === "current" + ? `${daysLeft} ${daysLeft > 1 ? "days" : "day"} left` + : `${currentCycle.label}`} +
+ )} + + +
+ {cycleDetails.assignee_ids && cycleDetails.assignee_ids?.length > 0 ? ( + + {cycleDetails.assignee_ids?.map((assignee_id) => { + const member = getUserDetails(assignee_id); + return ; + })} + + ) : ( + + + + )} +
+
+ + {isEditingAllowed && !isArchived && ( + { + if (cycleDetails.is_favorite) handleRemoveFromFavorites(e); + else handleAddToFavorites(e); + }} + selected={!!cycleDetails.is_favorite} + /> + )} + + + ); +}); diff --git a/web/components/cycles/list/cycles-list-item.tsx b/web/components/cycles/list/cycles-list-item.tsx index a6262dfe7..d02d0951f 100644 --- a/web/components/cycles/list/cycles-list-item.tsx +++ b/web/components/cycles/list/cycles-list-item.tsx @@ -1,24 +1,17 @@ import { FC, MouseEvent } from "react"; import { observer } from "mobx-react"; -import Link from "next/link"; import { useRouter } from "next/router"; // icons -import { Check, Info, User2 } from "lucide-react"; +import { Check, Info } from "lucide-react"; // types import type { TCycleGroups } from "@plane/types"; // ui -import { Tooltip, CircularProgressIndicator, CycleGroupIcon, AvatarGroup, Avatar, setPromiseToast } from "@plane/ui"; +import { CircularProgressIndicator } from "@plane/ui"; // components -import { FavoriteStar } from "@/components/core"; -import { CycleQuickActions } from "@/components/cycles"; -// constants -import { CYCLE_STATUS } from "@/constants/cycle"; -import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "@/constants/event-tracker"; -import { EUserProjectRoles } from "@/constants/project"; -// helpers -import { findHowManyDaysLeft, getDate, renderFormattedDate } from "@/helpers/date-time.helper"; +import { ListItem } from "@/components/core/list"; +import { CycleListItemAction } from "@/components/cycles/list"; // hooks -import { useEventTracker, useCycle, useUser, useMember } from "@/hooks/store"; +import { useCycle } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; type TCyclesListItem = { @@ -33,75 +26,36 @@ type TCyclesListItem = { }; export const CyclesListItem: FC = observer((props) => { - const { cycleId, workspaceSlug, projectId, isArchived } = props; + const { cycleId, workspaceSlug, projectId, isArchived = false } = props; // router const router = useRouter(); // hooks const { isMobile } = usePlatformOS(); // store hooks - const { captureEvent } = useEventTracker(); - const { - membership: { currentProjectRole }, - } = useUser(); - const { getCycleById, addCycleToFavorites, removeCycleFromFavorites } = useCycle(); - const { getUserDetails } = useMember(); + const { getCycleById } = useCycle(); - const handleAddToFavorites = (e: MouseEvent) => { - e.preventDefault(); - if (!workspaceSlug || !projectId) return; + // derived values + const cycleDetails = getCycleById(cycleId); - const addToFavoritePromise = addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).then( - () => { - captureEvent(CYCLE_FAVORITED, { - cycle_id: cycleId, - element: "List layout", - state: "SUCCESS", - }); - } - ); + if (!cycleDetails) return null; - setPromiseToast(addToFavoritePromise, { - loading: "Adding cycle to favorites...", - success: { - title: "Success!", - message: () => "Cycle added to favorites.", - }, - error: { - title: "Error!", - message: () => "Couldn't add the cycle to favorites. Please try again.", - }, - }); - }; + // computed + // TODO: change this logic once backend fix the response + const cycleStatus = cycleDetails.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft"; + const isCompleted = cycleStatus === "completed"; - const handleRemoveFromFavorites = (e: MouseEvent) => { - e.preventDefault(); - if (!workspaceSlug || !projectId) return; + const cycleTotalIssues = + cycleDetails.backlog_issues + + cycleDetails.unstarted_issues + + cycleDetails.started_issues + + cycleDetails.completed_issues + + cycleDetails.cancelled_issues; - const removeFromFavoritePromise = removeCycleFromFavorites( - workspaceSlug?.toString(), - projectId.toString(), - cycleId - ).then(() => { - captureEvent(CYCLE_UNFAVORITED, { - cycle_id: cycleId, - element: "List layout", - state: "SUCCESS", - }); - }); + const completionPercentage = (cycleDetails.completed_issues / cycleTotalIssues) * 100; - setPromiseToast(removeFromFavoritePromise, { - loading: "Removing cycle from favorites...", - success: { - title: "Success!", - message: () => "Cycle removed from favorites.", - }, - error: { - title: "Error!", - message: () => "Couldn't remove the cycle from favorites. Please try again.", - }, - }); - }; + const progress = isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage); + // handlers const openCycleOverview = (e: MouseEvent) => { const { query } = router; e.preventDefault(); @@ -121,139 +75,48 @@ export const CyclesListItem: FC = observer((props) => { } }; - const cycleDetails = getCycleById(cycleId); - - if (!cycleDetails) return null; - - // computed - // TODO: change this logic once backend fix the response - const cycleStatus = cycleDetails.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft"; - const isCompleted = cycleStatus === "completed"; - const endDate = getDate(cycleDetails.end_date); - const startDate = getDate(cycleDetails.start_date); - - const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; - - const cycleTotalIssues = - cycleDetails.backlog_issues + - cycleDetails.unstarted_issues + - cycleDetails.started_issues + - cycleDetails.completed_issues + - cycleDetails.cancelled_issues; - - const renderDate = cycleDetails.start_date || cycleDetails.end_date; - - // const areYearsEqual = startDate.getFullYear() === endDate.getFullYear(); - - const completionPercentage = (cycleDetails.completed_issues / cycleTotalIssues) * 100; - - const progress = isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage); - - const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus); - - const daysLeft = findHowManyDaysLeft(cycleDetails.end_date) ?? 0; - return ( -
- { - if (isArchived) { - openCycleOverview(e); - } - }} - > -
-
-
-
- - {isCompleted ? ( - progress === 100 ? ( - - ) : ( - {`!`} - ) - ) : progress === 100 ? ( - - ) : ( - {`${progress}%`} - )} - -
- -
- - - - {cycleDetails.name} - - -
- - -
-
- {renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`} -
-
- -
- -
-
- {currentCycle && ( -
- {currentCycle.value === "current" - ? `${daysLeft} ${daysLeft > 1 ? "days" : "day"} left` - : `${currentCycle.label}`} -
+ { + if (isArchived) { + openCycleOverview(e); + } + }} + prependTitleElement={ + + {isCompleted ? ( + progress === 100 ? ( + + ) : ( + {`!`} + ) + ) : progress === 100 ? ( + + ) : ( + {`${progress}%`} )} - -
- -
- {cycleDetails.assignee_ids && cycleDetails.assignee_ids?.length > 0 ? ( - - {cycleDetails.assignee_ids?.map((assignee_id) => { - const member = getUserDetails(assignee_id); - return ; - })} - - ) : ( - - - - )} -
-
- - {isEditingAllowed && !isArchived && ( - { - if (cycleDetails.is_favorite) handleRemoveFromFavorites(e); - else handleAddToFavorites(e); - }} - selected={!!cycleDetails.is_favorite} - /> - )} - -
-
-
-
+ + } + appendTitleElement={ + + } + actionableItems={ + + } + isMobile={isMobile} + /> ); }); diff --git a/web/components/cycles/list/index.ts b/web/components/cycles/list/index.ts index 46a3557d7..5eda32861 100644 --- a/web/components/cycles/list/index.ts +++ b/web/components/cycles/list/index.ts @@ -1,3 +1,4 @@ export * from "./cycles-list-item"; export * from "./cycles-list-map"; export * from "./root"; +export * from "./cycle-list-item-action"; diff --git a/web/components/cycles/list/root.tsx b/web/components/cycles/list/root.tsx index 622ca1ae0..52a7e569e 100644 --- a/web/components/cycles/list/root.tsx +++ b/web/components/cycles/list/root.tsx @@ -3,6 +3,7 @@ import { observer } from "mobx-react-lite"; import { ChevronRight } from "lucide-react"; import { Disclosure } from "@headlessui/react"; // components +import { ListLayout } from "@/components/core/list"; import { CyclePeekOverview, CyclesListMap } from "@/components/cycles"; // helpers import { cn } from "@/helpers/common.helper"; @@ -21,7 +22,7 @@ export const CyclesList: FC = observer((props) => { return (
-
+ = observer((props) => { )} -
+
diff --git a/web/components/cycles/quick-actions.tsx b/web/components/cycles/quick-actions.tsx index 9ba012cc4..bcbb84efd 100644 --- a/web/components/cycles/quick-actions.tsx +++ b/web/components/cycles/quick-actions.tsx @@ -168,14 +168,17 @@ export const CycleQuickActions: React.FC = observer((props) => { )} -
+ {!isCompleted && isEditingAllowed && ( - - - - Delete cycle - - + <> +
+ + + + Delete cycle + + + )} diff --git a/web/components/headers/cycles.tsx b/web/components/headers/cycles.tsx index 22cd9adec..d2781247e 100644 --- a/web/components/headers/cycles.tsx +++ b/web/components/headers/cycles.tsx @@ -1,15 +1,16 @@ import { FC } from "react"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; +// icons import { Plus } from "lucide-react"; -// hooks // ui import { Breadcrumbs, Button, ContrastIcon } from "@plane/ui"; -// helpers // components import { BreadcrumbLink } from "@/components/common"; import { ProjectLogo } from "@/components/project"; +// constants import { EUserProjectRoles } from "@/constants/project"; +// hooks import { useApplication, useEventTracker, useProject, useUser } from "@/hooks/store"; export const CyclesHeader: FC = observer(() => { @@ -30,52 +31,48 @@ export const CyclesHeader: FC = observer(() => { currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); return ( -
-
-
-
- - - - - ) - } - /> - } - /> - } /> - } - /> - -
+
+
+
+ + + + + ) + } + /> + } + /> + } />} + /> +
- {canUserCreateCycle && ( -
- -
- )}
+ {canUserCreateCycle && ( +
+ +
+ )}
); }); diff --git a/web/components/headers/modules-list.tsx b/web/components/headers/modules-list.tsx index 6b28dc40b..4043afb6b 100644 --- a/web/components/headers/modules-list.tsx +++ b/web/components/headers/modules-list.tsx @@ -1,33 +1,21 @@ -import { useCallback, useRef, useState } from "react"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; -import { ListFilter, Plus, Search, X } from "lucide-react"; -import { TModuleFilters } from "@plane/types"; -// hooks -import { Breadcrumbs, Button, Tooltip, DiceIcon } from "@plane/ui"; -import { BreadcrumbLink } from "@/components/common"; -import { FiltersDropdown } from "@/components/issues"; -import { ModuleFiltersSelection, ModuleOrderByDropdown } from "@/components/modules"; -import { ProjectLogo } from "@/components/project"; -import { MODULE_VIEW_LAYOUTS } from "@/constants/module"; -import { EUserProjectRoles } from "@/constants/project"; -import { cn } from "@/helpers/common.helper"; -import { useApplication, useEventTracker, useMember, useModuleFilter, useProject, useUser } from "@/hooks/store"; -import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; -// components -// constants -// hooks -import { usePlatformOS } from "@/hooks/use-platform-os"; +// icons +import { Plus } from "lucide-react"; // ui -// helpers -// types +import { Breadcrumbs, Button, DiceIcon } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common"; +import { ProjectLogo } from "@/components/project"; +// constants +import { EUserProjectRoles } from "@/constants/project"; +// hooks +import { useApplication, useEventTracker, useProject, useUser } from "@/hooks/store"; export const ModulesListHeader: React.FC = observer(() => { - // refs - const inputRef = useRef(null); // router const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug } = router.query; // store hooks const { commandPalette: commandPaletteStore } = useApplication(); const { setTrackElement } = useEventTracker(); @@ -35,54 +23,6 @@ export const ModulesListHeader: React.FC = observer(() => { membership: { currentProjectRole }, } = useUser(); const { currentProjectDetails } = useProject(); - const { isMobile } = usePlatformOS(); - const { - workspace: { workspaceMemberIds }, - } = useMember(); - const { - currentProjectDisplayFilters: displayFilters, - currentProjectFilters: filters, - searchQuery, - updateDisplayFilters, - updateFilters, - updateSearchQuery, - } = useModuleFilter(); - // states - const [isSearchOpen, setIsSearchOpen] = useState(searchQuery !== "" ? true : false); - // outside click detector hook - useOutsideClickDetector(inputRef, () => { - if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false); - }); - - const handleFilters = useCallback( - (key: keyof TModuleFilters, value: string | string[]) => { - if (!projectId) return; - const newValues = filters?.[key] ?? []; - - if (Array.isArray(value)) - value.forEach((val) => { - if (!newValues.includes(val)) newValues.push(val); - else newValues.splice(newValues.indexOf(val), 1); - }); - else { - if (filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); - else newValues.push(value); - } - - updateFilters(projectId.toString(), { [key]: newValues }); - }, - [filters, projectId, updateFilters] - ); - - const handleInputKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Escape") { - if (searchQuery && searchQuery.trim() !== "") updateSearchQuery(""); - else { - setIsSearchOpen(false); - inputRef.current?.blur(); - } - } - }; // auth const canUserCreateModule = @@ -117,97 +57,6 @@ export const ModulesListHeader: React.FC = observer(() => {
-
- {!isSearchOpen && ( - - )} -
- - updateSearchQuery(e.target.value)} - onKeyDown={handleInputKeyDown} - /> - {isSearchOpen && ( - - )} -
-
-
- {MODULE_VIEW_LAYOUTS.map((layout) => ( - - - - ))} -
- { - if (!projectId || val === displayFilters?.order_by) return; - updateDisplayFilters(projectId.toString(), { - order_by: val, - }); - }} - /> - } title="Filters" placement="bottom-end"> - { - if (!projectId) return; - updateDisplayFilters(projectId.toString(), val); - }} - handleFiltersUpdate={handleFilters} - memberIds={workspaceMemberIds ?? undefined} - /> - {canUserCreateModule && ( - - + + {issueCount} + + + ) : null} +
+ {currentProjectDetails?.is_deployed && deployUrl && ( + + + Public + + )}
+
+ handleLayoutChange(layout)} + selectedLayout={activeLayout} + /> + + + + + + +
+ + {canUserCreateIssue && ( + <> + + + + )}
); diff --git a/web/components/inbox/modals/create-edit-modal/create-root.tsx b/web/components/inbox/modals/create-edit-modal/create-root.tsx index de8e0d3ce..6c5ee3ce2 100644 --- a/web/components/inbox/modals/create-edit-modal/create-root.tsx +++ b/web/components/inbox/modals/create-edit-modal/create-root.tsx @@ -136,9 +136,12 @@ export const InboxIssueCreateRoot: FC = observer((props) />
-
setCreateMore((prevData) => !prevData)}> +
setCreateMore((prevData) => !prevData)} + > + {}} size="sm" /> Create more - {}} size="md" />
-
-
- -
- -
-
- {moduleStatus && ( - - {moduleStatus.label} - + { + if (isArchived) { + openModuleOverview(e); + } + }} + prependTitleElement={ + + {completedModuleCheck ? ( + progress === 100 ? ( + + ) : ( + {`!`} + ) + ) : progress === 100 ? ( + + ) : ( + {`${progress}%`} )} -
-
-
- {renderDate && ( - - {renderFormattedDate(startDate) ?? "_ _"} - {renderFormattedDate(endDate) ?? "_ _"} - - )} -
- -
- -
- {moduleDetails.member_ids.length > 0 ? ( - - {moduleDetails.member_ids.map((member_id) => { - const member = getUserDetails(member_id); - return ; - })} - - ) : ( - - - - )} -
-
- - {isEditingAllowed && !isArchived && ( - { - if (moduleDetails.is_favorite) handleRemoveFromFavorites(e); - else handleAddToFavorites(e); - }} - selected={moduleDetails.is_favorite} - /> - )} - {workspaceSlug && projectId && ( - - )} -
-
-
-
+ + } + appendTitleElement={ + + } + actionableItems={ + + } + isMobile={isMobile} + /> ); }); diff --git a/web/components/modules/module-view-header.tsx b/web/components/modules/module-view-header.tsx new file mode 100644 index 000000000..9367aad33 --- /dev/null +++ b/web/components/modules/module-view-header.tsx @@ -0,0 +1,178 @@ +import React, { FC, useCallback, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { useRouter } from "next/router"; +import { ListFilter, Search, X } from "lucide-react"; +import { cn } from "@plane/editor-core"; +// types +import { TModuleFilters } from "@plane/types"; +// ui +import { Tooltip } from "@plane/ui"; +// components +import { FiltersDropdown } from "@/components/issues"; +import { ModuleFiltersSelection, ModuleOrderByDropdown } from "@/components/modules/dropdowns"; +// constants +import { MODULE_VIEW_LAYOUTS } from "@/constants/module"; +// hooks +import { useMember, useModuleFilter } from "@/hooks/store"; +import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; +import { usePlatformOS } from "@/hooks/use-platform-os"; + +export const ModuleViewHeader: FC = observer(() => { + // refs + const inputRef = useRef(null); + + // router + const router = useRouter(); + const { projectId } = router.query; + + // hooks + const { isMobile } = usePlatformOS(); + + // store hooks + const { + workspace: { workspaceMemberIds }, + } = useMember(); + const { + currentProjectDisplayFilters: displayFilters, + currentProjectFilters: filters, + searchQuery, + updateDisplayFilters, + updateFilters, + updateSearchQuery, + } = useModuleFilter(); + + // states + const [isSearchOpen, setIsSearchOpen] = useState(searchQuery !== "" ? true : false); + + // handlers + const handleFilters = useCallback( + (key: keyof TModuleFilters, value: string | string[]) => { + if (!projectId) return; + const newValues = filters?.[key] ?? []; + + if (Array.isArray(value)) + value.forEach((val) => { + if (!newValues.includes(val)) newValues.push(val); + else newValues.splice(newValues.indexOf(val), 1); + }); + else { + if (filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); + else newValues.push(value); + } + + updateFilters(projectId.toString(), { [key]: newValues }); + }, + [filters, projectId, updateFilters] + ); + + const handleInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + if (searchQuery && searchQuery.trim() !== "") updateSearchQuery(""); + else { + setIsSearchOpen(false); + inputRef.current?.blur(); + } + } + }; + + // outside click detector hook + useOutsideClickDetector(inputRef, () => { + if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false); + }); + return ( +
+
+ {!isSearchOpen && ( + + )} +
+ + updateSearchQuery(e.target.value)} + onKeyDown={handleInputKeyDown} + /> + {isSearchOpen && ( + + )} +
+
+ + { + if (!projectId || val === displayFilters?.order_by) return; + updateDisplayFilters(projectId.toString(), { + order_by: val, + }); + }} + /> + } title="Filters" placement="bottom-end"> + { + if (!projectId) return; + updateDisplayFilters(projectId.toString(), val); + }} + handleFiltersUpdate={handleFilters} + memberIds={workspaceMemberIds ?? undefined} + /> + +
+ {MODULE_VIEW_LAYOUTS.map((layout) => ( + + + + ))} +
+
+ ); +}); diff --git a/web/components/modules/modules-list-view.tsx b/web/components/modules/modules-list-view.tsx index 82d63fdcc..36de22d6c 100644 --- a/web/components/modules/modules-list-view.tsx +++ b/web/components/modules/modules-list-view.tsx @@ -2,6 +2,7 @@ import { observer } from "mobx-react-lite"; import Image from "next/image"; import { useRouter } from "next/router"; // components +import { ListLayout } from "@/components/core/list"; import { EmptyState } from "@/components/empty-state"; import { ModuleCardItem, ModuleListItem, ModulePeekOverview, ModulesListGanttChartView } from "@/components/modules"; import { CycleModuleBoardLayout, CycleModuleListLayout, GanttLayoutLoader } from "@/components/ui"; @@ -69,11 +70,11 @@ export const ModulesListView: React.FC = observer(() => { {displayFilters?.layout === "list" && (
-
+ {filteredModuleIds.map((moduleId) => ( ))} -
+ = observer((props) => { const { workspace: { workspaceMemberIds }, } = useMember(); - const { projectLabels } = useLabel(); const handleRemoveFilter = useCallback( (key: keyof TPageFilterProps, value: string | null) => { @@ -48,7 +47,7 @@ export const PagesListHeaderRoot: React.FC = observer((props) => { return ( <> -
+
@@ -64,7 +63,6 @@ export const PagesListHeaderRoot: React.FC = observer((props) => { diff --git a/web/components/pages/list/block-item-action.tsx b/web/components/pages/list/block-item-action.tsx new file mode 100644 index 000000000..caa7cb4eb --- /dev/null +++ b/web/components/pages/list/block-item-action.tsx @@ -0,0 +1,94 @@ +import React, { FC } from "react"; +import { observer } from "mobx-react"; +import { Circle, Earth, Info, Lock, Minus } from "lucide-react"; +// ui +import { Avatar, TOAST_TYPE, Tooltip, setToast } from "@plane/ui"; +// components +import { FavoriteStar } from "@/components/core"; +import { PageQuickActions } from "@/components/pages/dropdowns"; +// helpers +import { renderFormattedDate } from "@/helpers/date-time.helper"; +// hooks +import { useMember, usePage } from "@/hooks/store"; + +type Props = { + workspaceSlug: string; + projectId: string; + pageId: string; +}; + +export const BlockItemAction: FC = observer((props) => { + const { workspaceSlug, projectId, pageId } = props; + + // store hooks + const { access, created_at, is_favorite, owned_by, addToFavorites, removeFromFavorites } = usePage(pageId); + const { getUserDetails } = useMember(); + + // derived values + const ownerDetails = owned_by ? getUserDetails(owned_by) : undefined; + + // handlers + const handleFavorites = () => { + if (is_favorite) + removeFromFavorites().then(() => + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Page removed from favorites.", + }) + ); + else + addToFavorites().then(() => + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Page added to favorites.", + }) + ); + }; + return ( + <> + {/* page details */} +
+ {/* Labels + */} +
+ + + +
+ + {/* 10m read + */} +
+ + {access === 0 ? : } + +
+
+ + {/* vertical divider */} + + + {/* page info */} + + + + + + + {/* favorite/unfavorite */} + { + e.preventDefault(); + e.stopPropagation(); + handleFavorites(); + }} + selected={is_favorite} + /> + + {/* quick actions dropdown */} + + + ); +}); diff --git a/web/components/pages/list/block.tsx b/web/components/pages/list/block.tsx index 82a7cedc2..70d88df13 100644 --- a/web/components/pages/list/block.tsx +++ b/web/components/pages/list/block.tsx @@ -1,15 +1,11 @@ import { FC } from "react"; import { observer } from "mobx-react"; -import Link from "next/link"; -import { Circle, Info, Lock, Minus, UsersRound } from "lucide-react"; -import { Avatar, TOAST_TYPE, Tooltip, setToast } from "@plane/ui"; // components -import { FavoriteStar } from "@/components/core"; -import { PageQuickActions } from "@/components/pages"; -// helpers -import { renderFormattedDate } from "@/helpers/date-time.helper"; +import { ListItem } from "@/components/core/list"; +import { BlockItemAction } from "@/components/pages/list"; // hooks -import { useMember, usePage } from "@/hooks/store"; +import { usePage } from "@/hooks/store"; +import { usePlatformOS } from "@/hooks/use-platform-os"; type TPageListBlock = { workspaceSlug: string; @@ -20,84 +16,15 @@ type TPageListBlock = { export const PageListBlock: FC = observer((props) => { const { workspaceSlug, projectId, pageId } = props; // hooks - const { access, created_at, is_favorite, name, owned_by, addToFavorites, removeFromFavorites } = usePage(pageId); - const { getUserDetails } = useMember(); - // derived values - const ownerDetails = owned_by ? getUserDetails(owned_by) : undefined; - - const handleFavorites = () => { - if (is_favorite) - removeFromFavorites().then(() => - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Success!", - message: "Page removed from favorites.", - }) - ); - else - addToFavorites().then(() => - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Success!", - message: "Page added to favorites.", - }) - ); - }; + const { name } = usePage(pageId); + const { isMobile } = usePlatformOS(); return ( - - {/* page title */} - -
{name}
-
- - {/* page properties */} -
- {/* page details */} -
- {/* Labels - */} -
- - - -
- - {/* 10m read - */} -
- - {access === 0 ? : } - -
-
- - {/* vertical divider */} - - - {/* page info */} - - - - - - - {/* favorite/unfavorite */} - { - e.preventDefault(); - e.stopPropagation(); - handleFavorites(); - }} - selected={is_favorite} - /> - - {/* quick actions dropdown */} - -
- + } + isMobile={isMobile} + /> ); }); diff --git a/web/components/pages/list/filters/index.ts b/web/components/pages/list/filters/index.ts index be7c679b7..e95258f0e 100644 --- a/web/components/pages/list/filters/index.ts +++ b/web/components/pages/list/filters/index.ts @@ -1,4 +1,3 @@ export * from "./created-at"; export * from "./created-by"; -export * from "./labels"; export * from "./root"; diff --git a/web/components/pages/list/filters/labels.tsx b/web/components/pages/list/filters/labels.tsx deleted file mode 100644 index 9a391ca22..000000000 --- a/web/components/pages/list/filters/labels.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import React, { useMemo, useState } from "react"; -import sortBy from "lodash/sortBy"; -import { observer } from "mobx-react"; -// types -import { IIssueLabel } from "@plane/types"; -// ui -import { Loader } from "@plane/ui"; -// components -import { FilterHeader, FilterOption } from "@/components/issues"; - -const LabelIcons = ({ color }: { color: string }) => ( - -); - -type Props = { - appliedFilters: string[] | null; - handleUpdate: (val: string) => void; - labels: IIssueLabel[] | undefined; - searchQuery: string; -}; - -export const FilterLabels: React.FC = observer((props) => { - const { appliedFilters, handleUpdate, labels, searchQuery } = props; - - const [itemsToRender, setItemsToRender] = useState(5); - const [previewEnabled, setPreviewEnabled] = useState(true); - - const appliedFiltersCount = appliedFilters?.length ?? 0; - - const sortedOptions = useMemo(() => { - const filteredOptions = (labels || []).filter((label) => - label.name.toLowerCase().includes(searchQuery.toLowerCase()) - ); - - return sortBy(filteredOptions, [ - (label) => !(appliedFilters ?? []).includes(label.id), - (label) => label.name.toLowerCase(), - ]); - }, [appliedFilters, labels, searchQuery]); - - const handleViewToggle = () => { - if (!sortedOptions) return; - - if (itemsToRender === sortedOptions.length) setItemsToRender(5); - else setItemsToRender(sortedOptions.length); - }; - - return ( - <> - 0 ? ` (${appliedFiltersCount})` : ""}`} - isPreviewEnabled={previewEnabled} - handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} - /> - {previewEnabled && ( -
- {sortedOptions ? ( - sortedOptions.length > 0 ? ( - <> - {sortedOptions.slice(0, itemsToRender).map((label) => ( - handleUpdate(label?.id)} - icon={} - title={label.name} - /> - ))} - {sortedOptions.length > 5 && ( - - )} - - ) : ( -

No matches found

- ) - ) : ( - - - - - - )} -
- )} - - ); -}); diff --git a/web/components/pages/list/filters/root.tsx b/web/components/pages/list/filters/root.tsx index 475f89054..a9a152e0a 100644 --- a/web/components/pages/list/filters/root.tsx +++ b/web/components/pages/list/filters/root.tsx @@ -1,20 +1,19 @@ import { useState } from "react"; import { observer } from "mobx-react-lite"; import { Search, X } from "lucide-react"; -import { IIssueLabel, TPageFilterProps, TPageFilters } from "@plane/types"; +import { TPageFilterProps, TPageFilters } from "@plane/types"; // components import { FilterOption } from "@/components/issues"; -import { FilterCreatedBy, FilterCreatedDate, FilterLabels } from "@/components/pages"; +import { FilterCreatedBy, FilterCreatedDate } from "@/components/pages"; type Props = { filters: TPageFilters; handleFiltersUpdate: (filterKey: T, filterValue: TPageFilters[T]) => void; - labels?: IIssueLabel[] | undefined; memberIds?: string[] | undefined; }; export const PageFiltersSelection: React.FC = observer((props) => { - const { filters, handleFiltersUpdate, labels, memberIds } = props; + const { filters, handleFiltersUpdate, memberIds } = props; // states const [filtersSearchQuery, setFiltersSearchQuery] = useState(""); @@ -93,16 +92,6 @@ export const PageFiltersSelection: React.FC = observer((props) => { memberIds={memberIds} />
- - {/* labels */} -
- handleFilters("labels", val)} - searchQuery={filtersSearchQuery} - labels={labels} - /> -
); diff --git a/web/components/pages/list/index.ts b/web/components/pages/list/index.ts index b8270f3f2..88f7633a0 100644 --- a/web/components/pages/list/index.ts +++ b/web/components/pages/list/index.ts @@ -1,6 +1,7 @@ export * from "./applied-filters"; export * from "./filters"; export * from "./block"; +export * from "./block-item-action"; export * from "./order-by"; export * from "./root"; export * from "./search-input"; diff --git a/web/components/pages/list/root.tsx b/web/components/pages/list/root.tsx index 226bdaf70..edfdba596 100644 --- a/web/components/pages/list/root.tsx +++ b/web/components/pages/list/root.tsx @@ -2,6 +2,8 @@ import { FC } from "react"; import { observer } from "mobx-react"; // types import { TPageNavigationTabs } from "@plane/types"; +// components +import { ListLayout } from "@/components/core/list"; // hooks import { useProjectPages } from "@/hooks/store"; // components @@ -22,10 +24,10 @@ export const PagesListRoot: FC = observer((props) => { if (!filteredPageIds) return <>; return ( -
+ {filteredPageIds.map((pageId) => ( ))} -
+ ); }); diff --git a/web/components/views/index.ts b/web/components/views/index.ts index b7ebe5081..396f07506 100644 --- a/web/components/views/index.ts +++ b/web/components/views/index.ts @@ -3,3 +3,4 @@ export * from "./form"; export * from "./modal"; export * from "./view-list-item"; export * from "./views-list"; +export * from "./view-list-item-action"; diff --git a/web/components/views/view-list-item-action.tsx b/web/components/views/view-list-item-action.tsx new file mode 100644 index 000000000..9827ba79d --- /dev/null +++ b/web/components/views/view-list-item-action.tsx @@ -0,0 +1,134 @@ +import React, { FC, useState } from "react"; +import { observer } from "mobx-react"; +import { useRouter } from "next/router"; +// icons +import { LinkIcon, PencilIcon, TrashIcon } from "lucide-react"; +// types +import { IProjectView } from "@plane/types"; +// ui +import { CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; +// components +import { FavoriteStar } from "@/components/core"; +import { DeleteProjectViewModal, CreateUpdateProjectViewModal } from "@/components/views"; +// constants +import { EUserProjectRoles } from "@/constants/project"; +// helpers +import { calculateTotalFilters } from "@/helpers/filter.helper"; +import { copyUrlToClipboard } from "@/helpers/string.helper"; +// hooks +import { useProjectView, useUser } from "@/hooks/store"; + +type Props = { + view: IProjectView; +}; + +export const ViewListItemAction: FC = observer((props) => { + const { view } = props; + // states + const [createUpdateViewModal, setCreateUpdateViewModal] = useState(false); + const [deleteViewModal, setDeleteViewModal] = useState(false); + // router + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + // store + const { + membership: { currentProjectRole }, + } = useUser(); + const { addViewToFavorites, removeViewFromFavorites } = useProjectView(); + + // derived values + const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; + // @ts-expect-error key types are not compatible + const totalFilters = calculateTotalFilters(view.filters ?? {}); + + // handlers + const handleAddToFavorites = () => { + if (!workspaceSlug || !projectId) return; + + addViewToFavorites(workspaceSlug.toString(), projectId.toString(), view.id); + }; + + const handleRemoveFromFavorites = () => { + if (!workspaceSlug || !projectId) return; + + removeViewFromFavorites(workspaceSlug.toString(), projectId.toString(), view.id); + }; + + const handleCopyText = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/views/${view.id}`).then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Link Copied!", + message: "View link copied to clipboard.", + }); + }); + }; + + return ( + <> + {workspaceSlug && projectId && view && ( + setCreateUpdateViewModal(false)} + workspaceSlug={workspaceSlug.toString()} + projectId={projectId.toString()} + data={view} + /> + )} + setDeleteViewModal(false)} /> +

+ {totalFilters} {totalFilters === 1 ? "filter" : "filters"} +

+ {isEditingAllowed && ( + { + e.preventDefault(); + e.stopPropagation(); + if (view.is_favorite) handleRemoveFromFavorites(); + else handleAddToFavorites(); + }} + selected={view.is_favorite} + /> + )} + + + {isEditingAllowed && ( + <> + { + e.preventDefault(); + e.stopPropagation(); + setCreateUpdateViewModal(true); + }} + > + + + Edit View + + + { + e.preventDefault(); + e.stopPropagation(); + setDeleteViewModal(true); + }} + > + + + Delete View + + + + )} + + + + Copy view link + + + + + ); +}); diff --git a/web/components/views/view-list-item.tsx b/web/components/views/view-list-item.tsx index c3731696e..5cab840f6 100644 --- a/web/components/views/view-list-item.tsx +++ b/web/components/views/view-list-item.tsx @@ -1,151 +1,32 @@ -import React, { useState } from "react"; +import { FC } from "react"; import { observer } from "mobx-react-lite"; -import Link from "next/link"; import { useRouter } from "next/router"; -import { LinkIcon, PencilIcon, TrashIcon } from "lucide-react"; // types import { IProjectView } from "@plane/types"; -// ui -import { CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; // components -import { FavoriteStar } from "@/components/core"; -import { CreateUpdateProjectViewModal, DeleteProjectViewModal } from "@/components/views"; -// constants -import { EUserProjectRoles } from "@/constants/project"; -// helpers -import { calculateTotalFilters } from "@/helpers/filter.helper"; -import { copyUrlToClipboard } from "@/helpers/string.helper"; +import { ListItem } from "@/components/core/list"; +import { ViewListItemAction } from "@/components/views"; // hooks -import { useProjectView, useUser } from "@/hooks/store"; +import { usePlatformOS } from "@/hooks/use-platform-os"; type Props = { view: IProjectView; }; -export const ProjectViewListItem: React.FC = observer((props) => { +export const ProjectViewListItem: FC = observer((props) => { const { view } = props; - // states - const [createUpdateViewModal, setCreateUpdateViewModal] = useState(false); - const [deleteViewModal, setDeleteViewModal] = useState(false); // router const router = useRouter(); const { workspaceSlug, projectId } = router.query; // store hooks - const { - membership: { currentProjectRole }, - } = useUser(); - const { addViewToFavorites, removeViewFromFavorites } = useProjectView(); - - const handleAddToFavorites = () => { - if (!workspaceSlug || !projectId) return; - - addViewToFavorites(workspaceSlug.toString(), projectId.toString(), view.id); - }; - - const handleRemoveFromFavorites = () => { - if (!workspaceSlug || !projectId) return; - - removeViewFromFavorites(workspaceSlug.toString(), projectId.toString(), view.id); - }; - - const handleCopyText = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/views/${view.id}`).then(() => { - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Link Copied!", - message: "View link copied to clipboard.", - }); - }); - }; - - // @ts-expect-error key types are not compatible - const totalFilters = calculateTotalFilters(view.filters ?? {}); - - const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; + const { isMobile } = usePlatformOS(); return ( - <> - {workspaceSlug && projectId && view && ( - setCreateUpdateViewModal(false)} - workspaceSlug={workspaceSlug.toString()} - projectId={projectId.toString()} - data={view} - /> - )} - setDeleteViewModal(false)} /> -
- -
-
-
-
-

{view.name}

- {view?.description &&

{view.description}

} -
-
-
-
-

- {totalFilters} {totalFilters === 1 ? "filter" : "filters"} -

- {isEditingAllowed && ( - { - e.preventDefault(); - e.stopPropagation(); - if (view.is_favorite) handleRemoveFromFavorites(); - else handleAddToFavorites(); - }} - selected={view.is_favorite} - /> - )} - - - {isEditingAllowed && ( - <> - { - e.preventDefault(); - e.stopPropagation(); - setCreateUpdateViewModal(true); - }} - > - - - Edit View - - - { - e.preventDefault(); - e.stopPropagation(); - setDeleteViewModal(true); - }} - > - - - Delete View - - - - )} - - - - Copy view link - - - -
-
-
-
- -
- + } + isMobile={isMobile} + /> ); }); diff --git a/web/components/views/views-list.tsx b/web/components/views/views-list.tsx index abaaacc08..526ec34ea 100644 --- a/web/components/views/views-list.tsx +++ b/web/components/views/views-list.tsx @@ -1,20 +1,28 @@ -import { useState } from "react"; +import { useRef, useState } from "react"; import { observer } from "mobx-react-lite"; -import { Search } from "lucide-react"; -// hooks +// ui +import { Search, X } from "lucide-react"; // components -import { Input } from "@plane/ui"; +import { ListLayout } from "@/components/core/list"; import { EmptyState } from "@/components/empty-state"; import { ViewListLoader } from "@/components/ui"; import { ProjectViewListItem } from "@/components/views"; -// ui // constants import { EmptyStateType } from "@/constants/empty-state"; +// helper +import { cn } from "@/helpers/common.helper"; +// hooks import { useApplication, useProjectView } from "@/hooks/store"; +import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; export const ProjectViewsList = observer(() => { // states - const [query, setQuery] = useState(""); + const [searchQuery, setSearchQuery] = useState(""); + const [isSearchOpen, setIsSearchOpen] = useState(searchQuery !== "" ? true : false); + + // refs + const inputRef = useRef(null); + // store hooks const { commandPalette: { toggleCreateViewModal }, @@ -23,33 +31,89 @@ export const ProjectViewsList = observer(() => { if (loader || !projectViewIds) return ; + // derived values const viewsList = projectViewIds.map((viewId) => getViewById(viewId)); - const filteredViewsList = viewsList.filter((v) => v?.name.toLowerCase().includes(query.toLowerCase())); + const filteredViewsList = viewsList.filter((v) => v?.name.toLowerCase().includes(searchQuery.toLowerCase())); + + // handlers + const handleInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + if (searchQuery && searchQuery.trim() !== "") setSearchQuery(""); + else { + setIsSearchOpen(false); + inputRef.current?.blur(); + } + } + }; + + // outside click detector hook + useOutsideClickDetector(inputRef, () => { + if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false); + }); return ( <> {viewsList.length > 0 ? (
-
-
- - setQuery(e.target.value)} - placeholder="Search" - mode="true-transparent" - /> +
+
+ View name +
+
+
+ {!isSearchOpen && ( + + )} +
+ + setSearchQuery(e.target.value)} + onKeyDown={handleInputKeyDown} + /> + {isSearchOpen && ( + + )} +
+
-
+ {filteredViewsList.length > 0 ? ( filteredViewsList.map((view) => ) ) : (

No results found

)} -
+
) : ( toggleCreateViewModal(true)} /> diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/modules/index.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/modules/index.tsx index 22973347f..cecaf01f1 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/modules/index.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/modules/index.tsx @@ -7,7 +7,7 @@ import { TModuleFilters } from "@plane/types"; import { PageHead } from "@/components/core"; import { EmptyState } from "@/components/empty-state"; import { ModulesListHeader } from "@/components/headers"; -import { ModuleAppliedFiltersList, ModulesListView } from "@/components/modules"; +import { ModuleAppliedFiltersList, ModuleViewHeader, ModulesListView } from "@/components/modules"; // types // hooks import ModulesListMobileHeader from "@/components/modules/moduels-list-mobile-header"; @@ -57,6 +57,12 @@ const ProjectModulesPage: NextPageWithLayout = observer(() => { <>
+
+
+ Module name +
+ +
{calculateTotalFilters(currentProjectFilters ?? {}) !== 0 && (