diff --git a/packages/constants/src/issue.ts b/packages/constants/src/issue.ts index 5db398c7634..9f6a1a2e2e9 100644 --- a/packages/constants/src/issue.ts +++ b/packages/constants/src/issue.ts @@ -11,6 +11,7 @@ export enum EIssueGroupByToServerOptions { "target_date" = "target_date", "project" = "project_id", "created_by" = "created_by", + "team_project" = "project_id", } export enum EIssueGroupBYServerToProperty { diff --git a/packages/types/src/common.d.ts b/packages/types/src/common.d.ts index 5fe31ad0006..7e755fcc28e 100644 --- a/packages/types/src/common.d.ts +++ b/packages/types/src/common.d.ts @@ -22,3 +22,5 @@ export type TLogoProps = { background_color?: string; }; }; + +export type TNameDescriptionLoader = "submitting" | "submitted" | "saved"; diff --git a/packages/types/src/enums.ts b/packages/types/src/enums.ts index df6a462b02e..e37e2f4a5d5 100644 --- a/packages/types/src/enums.ts +++ b/packages/types/src/enums.ts @@ -59,4 +59,5 @@ export enum EFileAssetType { USER_AVATAR = "USER_AVATAR", USER_COVER = "USER_COVER", WORKSPACE_LOGO = "WORKSPACE_LOGO", + TEAM_SPACE_DESCRIPTION = "TEAM_SPACE_DESCRIPTION", } diff --git a/packages/types/src/issues.d.ts b/packages/types/src/issues.d.ts index 9bbfa36b1f0..b6d32bdf816 100644 --- a/packages/types/src/issues.d.ts +++ b/packages/types/src/issues.d.ts @@ -211,7 +211,8 @@ export type GroupByColumnTypes = | "priority" | "labels" | "assignees" - | "created_by"; + | "created_by" + | "team_project"; export interface IGroupByColumn { id: string; diff --git a/packages/types/src/view-props.d.ts b/packages/types/src/view-props.d.ts index 57baa4cfdd1..aa1c75cdbdc 100644 --- a/packages/types/src/view-props.d.ts +++ b/packages/types/src/view-props.d.ts @@ -18,6 +18,7 @@ export type TIssueGroupByOptions = | "cycle" | "module" | "target_date" + | "team_project" | null; export type TIssueOrderByOptions = @@ -69,6 +70,7 @@ export type TIssueParams = | "start_date" | "target_date" | "project" + | "team_project" | "group_by" | "sub_group_by" | "order_by" @@ -92,6 +94,7 @@ export interface IIssueFilterOptions { cycle?: string[] | null; module?: string[] | null; project?: string[] | null; + team_project?: string[] | null; start_date?: string[] | null; state?: string[] | null; state_group?: string[] | null; diff --git a/packages/ui/src/breadcrumbs/index.ts b/packages/ui/src/breadcrumbs/index.ts index 669f5575772..05a8bdbf1b6 100644 --- a/packages/ui/src/breadcrumbs/index.ts +++ b/packages/ui/src/breadcrumbs/index.ts @@ -1 +1,2 @@ export * from "./breadcrumbs"; +export * from "./navigation-dropdown"; diff --git a/packages/ui/src/breadcrumbs/navigation-dropdown.tsx b/packages/ui/src/breadcrumbs/navigation-dropdown.tsx new file mode 100644 index 00000000000..a716ca65e19 --- /dev/null +++ b/packages/ui/src/breadcrumbs/navigation-dropdown.tsx @@ -0,0 +1,96 @@ +"use client"; + +import * as React from "react"; +import { CheckIcon, ChevronDownIcon } from "lucide-react"; +// ui +import { CustomMenu, TContextMenuItem } from "../dropdowns"; +// helpers +import { cn } from "../../helpers"; + +type TBreadcrumbNavigationDropdownProps = { + selectedItemKey: string; + navigationItems: TContextMenuItem[]; + navigationDisabled?: boolean; +}; + +export const BreadcrumbNavigationDropdown = (props: TBreadcrumbNavigationDropdownProps) => { + const { selectedItemKey, navigationItems, navigationDisabled = false } = props; + // derived values + const selectedItem = navigationItems.find((item) => item.key === selectedItemKey); + const selectedItemIcon = selectedItem?.icon ? ( + + ) : undefined; + + // if no selected item, return null + if (!selectedItem) return null; + + const NavigationButton = ({ className }: { className?: string }) => ( +
  • + {selectedItemIcon && ( +
    {selectedItemIcon}
    + )} +
    {selectedItem.title}
    +
  • + ); + + if (navigationDisabled) { + return ; + } + + return ( + + + + + } + placement="bottom-start" + closeOnSelect + > + {navigationItems.map((item) => { + if (item.shouldRender === false) return null; + return ( + { + e.preventDefault(); + e.stopPropagation(); + if (item.key === selectedItemKey) return; + item.action(); + }} + className={cn( + "flex items-center gap-2", + { + "text-custom-text-400": item.disabled, + }, + item.className + )} + disabled={item.disabled} + > + {item.icon && } +
    +
    {item.title}
    + {item.description && ( +

    + {item.description} +

    + )} +
    + {item.key === selectedItemKey && } +
    + ); + })} +
    + ); +}; diff --git a/packages/ui/src/icons/index.ts b/packages/ui/src/icons/index.ts index f8a2b1c849b..573efd99fb7 100644 --- a/packages/ui/src/icons/index.ts +++ b/packages/ui/src/icons/index.ts @@ -16,6 +16,7 @@ export * from "./epic-icon"; export * from "./full-screen-panel-icon"; export * from "./github-icon"; export * from "./gitlab-icon"; +export * from "./info-fill-icon"; export * from "./info-icon"; export * from "./layer-stack"; export * from "./layers-icon"; @@ -38,3 +39,5 @@ export * from "./done-icon"; export * from "./pending-icon"; export * from "./pi-chat"; export * from "./workspace-icon"; +export * from "./teams"; +export * from "./lead-icon"; diff --git a/packages/ui/src/icons/lead-icon.tsx b/packages/ui/src/icons/lead-icon.tsx new file mode 100644 index 00000000000..75575d35ec5 --- /dev/null +++ b/packages/ui/src/icons/lead-icon.tsx @@ -0,0 +1,26 @@ +import * as React from "react"; + +import { ISvgIcons } from "./type"; + +export const LeadIcon: React.FC = ({ className = "text-current", ...rest }) => ( + + + + + + + + + + + + +); diff --git a/packages/ui/src/icons/teams.tsx b/packages/ui/src/icons/teams.tsx new file mode 100644 index 00000000000..b730555989e --- /dev/null +++ b/packages/ui/src/icons/teams.tsx @@ -0,0 +1,19 @@ +import * as React from "react"; + +import { ISvgIcons } from "./type"; + +export const TeamsIcon: React.FC = ({ className = "text-current", ...rest }) => ( + + + + +); diff --git a/packages/ui/src/tabs/tabs.tsx b/packages/ui/src/tabs/tabs.tsx index a323d9721fd..92bc3ad7276 100644 --- a/packages/ui/src/tabs/tabs.tsx +++ b/packages/ui/src/tabs/tabs.tsx @@ -1,4 +1,4 @@ -import React, { FC, Fragment } from "react"; +import React, { FC, Fragment, useEffect, useState } from "react"; import { Tab } from "@headlessui/react"; import { LucideProps } from "lucide-react"; // helpers @@ -11,11 +11,12 @@ type TabItem = { label?: React.ReactNode; content: React.ReactNode; disabled?: boolean; + onClick?: () => void; }; type TTabsProps = { tabs: TabItem[]; - storageKey: string; + storageKey?: string; actions?: React.ReactNode; defaultTab?: string; containerClassName?: string; @@ -23,6 +24,8 @@ type TTabsProps = { tabListClassName?: string; tabClassName?: string; tabPanelClassName?: string; + size?: "sm" | "md" | "lg"; + storeInLocalStorage?: boolean; }; export const Tabs: FC = (props: TTabsProps) => { @@ -36,15 +39,28 @@ export const Tabs: FC = (props: TTabsProps) => { tabListClassName = "", tabClassName = "", tabPanelClassName = "", + size = "md", + storeInLocalStorage = true, } = props; // local storage - const { storedValue, setValue } = useLocalStorage(`tab-${storageKey}`, defaultTab); + const { storedValue, setValue } = useLocalStorage( + storeInLocalStorage && storageKey ? `tab-${storageKey}` : `tab-${tabs[0]?.key}`, + defaultTab + ); + // state + const [selectedTab, setSelectedTab] = useState(storedValue ?? defaultTab); + + useEffect(() => { + if (storeInLocalStorage) { + setValue(selectedTab); + } + }, [selectedTab, setValue, storeInLocalStorage, storageKey]); const currentTabIndex = (tabKey: string): number => tabs.findIndex((tab) => tab.key === tabKey); return (
    - +
    = (props: TTabsProps) => { : tab.disabled ? "text-custom-text-400 cursor-not-allowed" : "text-custom-text-400 hover:text-custom-text-300 hover:bg-custom-background-80/60", + { + "text-xs": size === "sm", + "text-sm": size === "md", + "text-base": size === "lg", + }, tabClassName ) } key={tab.key} onClick={() => { - if (!tab.disabled) setValue(tab.key); + if (!tab.disabled) setSelectedTab(tab.key); + tab.onClick?.(); }} disabled={tab.disabled} > diff --git a/space/core/store/helpers/base-issues.store.ts b/space/core/store/helpers/base-issues.store.ts index 004aa06c630..7abfa324a8d 100644 --- a/space/core/store/helpers/base-issues.store.ts +++ b/space/core/store/helpers/base-issues.store.ts @@ -26,7 +26,7 @@ import { CoreRootStore } from "../root.store"; // constants // helpers -export type TIssueDisplayFilterOptions = Exclude | "target_date"; +export type TIssueDisplayFilterOptions = Exclude | "target_date"; export enum EIssueGroupedAction { ADD = "ADD", diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx index a1f7071a449..7a29f055306 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx @@ -77,7 +77,12 @@ const CycleDetailPage = observer(() => { "0px 1px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 4px 0px rgba(16, 24, 40, 0.06), 0px 1px 8px -1px rgba(16, 24, 40, 0.06)", }} > - +
    )}
    diff --git a/web/app/[workspaceSlug]/(projects)/sidebar.tsx b/web/app/[workspaceSlug]/(projects)/sidebar.tsx index ac55fdec81e..11d23e35f05 100644 --- a/web/app/[workspaceSlug]/(projects)/sidebar.tsx +++ b/web/app/[workspaceSlug]/(projects)/sidebar.tsx @@ -21,6 +21,7 @@ import { useFavorite } from "@/hooks/store/use-favorite"; import useSize from "@/hooks/use-window-size"; // plane web components import { SidebarAppSwitcher } from "@/plane-web/components/sidebar"; +import { SidebarTeamsList } from "@/plane-web/components/workspace/sidebar/teams-sidebar-list"; import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; export const AppSidebar: FC = observer(() => { @@ -47,7 +48,7 @@ export const AppSidebar: FC = observer(() => { }); useEffect(() => { - if (windowSize[0] < 768) !sidebarCollapsed && toggleSidebar(); + if (windowSize[0] < 768 && !sidebarCollapsed) toggleSidebar(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [windowSize]); @@ -73,9 +74,12 @@ export const AppSidebar: FC = observer(() => { "px-4": !sidebarCollapsed, })} > + {/* Workspace switcher and settings */}
    - + {/* App switcher */} + {canPerformWorkspaceMemberActions && } + {/* Quick actions */}

    { "vertical-scrollbar px-4": !sidebarCollapsed, })} > + {/* User Menu */} - + {/* Workspace Menu */}
    + {/* Favorites Menu */} {canPerformWorkspaceMemberActions && !isFavoriteEmpty && } - + {/* Teams List */} + + {/* Projects List */}
    + {/* Help Section */} diff --git a/web/ce/components/cycles/active-cycle/root.tsx b/web/ce/components/cycles/active-cycle/root.tsx index a173cfda03a..5ebddc63f23 100644 --- a/web/ce/components/cycles/active-cycle/root.tsx +++ b/web/ce/components/cycles/active-cycle/root.tsx @@ -1,5 +1,6 @@ "use client"; +import { useMemo } from "react"; import { observer } from "mobx-react"; import { Disclosure } from "@headlessui/react"; // ui @@ -22,68 +23,80 @@ import { ActiveCycleIssueDetails } from "@/store/issue/cycle"; interface IActiveCycleDetails { workspaceSlug: string; projectId: string; + cycleId?: string; + showHeader?: boolean; } export const ActiveCycleRoot: React.FC = observer((props) => { - const { workspaceSlug, projectId } = props; - const { currentProjectActiveCycle, currentProjectActiveCycleId } = useCycle(); + const { workspaceSlug, projectId, cycleId: propsCycleId, showHeader = true } = props; + const { currentProjectActiveCycleId } = useCycle(); + // derived values + const cycleId = propsCycleId ?? currentProjectActiveCycleId; + // fetch cycle details const { handleFiltersUpdate, cycle: activeCycle, cycleIssueDetails, - } = useCyclesDetails({ workspaceSlug, projectId, cycleId: currentProjectActiveCycleId }); + } = useCyclesDetails({ workspaceSlug, projectId, cycleId }); + + const ActiveCyclesComponent = useMemo( + () => ( + <> + {!cycleId || !activeCycle ? ( + + ) : ( +
    + {cycleId && ( + + )} + +
    + + + +
    +
    +
    + )} + + ), + [cycleId, activeCycle, workspaceSlug, projectId, handleFiltersUpdate, cycleIssueDetails] + ); return ( <> - - {({ open }) => ( - <> - - - - - {!currentProjectActiveCycle ? ( - - ) : ( -
    - {currentProjectActiveCycleId && ( - - )} - -
    - - - -
    -
    -
    - )} -
    - - )} -
    + {showHeader ? ( + + {({ open }) => ( + <> + + + + {ActiveCyclesComponent} + + )} + + ) : ( + <>{ActiveCyclesComponent} + )} ); }); diff --git a/web/ce/components/issues/filters/index.ts b/web/ce/components/issues/filters/index.ts index 2cd80e3a7e5..f0f36b6c97e 100644 --- a/web/ce/components/issues/filters/index.ts +++ b/web/ce/components/issues/filters/index.ts @@ -1,2 +1,3 @@ export * from "./applied-filters"; export * from "./issue-types"; +export * from "./team-project"; diff --git a/web/ce/components/issues/filters/team-project.tsx b/web/ce/components/issues/filters/team-project.tsx new file mode 100644 index 00000000000..4f4787fef8b --- /dev/null +++ b/web/ce/components/issues/filters/team-project.tsx @@ -0,0 +1,12 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string) => void; + searchQuery: string; +}; + +export const FilterTeamProjects: React.FC = observer(() => null); diff --git a/web/ce/components/issues/issue-layouts/utils.tsx b/web/ce/components/issues/issue-layouts/utils.tsx new file mode 100644 index 00000000000..48dca43bd92 --- /dev/null +++ b/web/ce/components/issues/issue-layouts/utils.tsx @@ -0,0 +1,4 @@ +// types +import { IGroupByColumn } from "@plane/types"; + +export const getTeamProjectColumns = (): IGroupByColumn[] | undefined => undefined; diff --git a/web/ce/components/workspace/sidebar/teams-sidebar-list.tsx b/web/ce/components/workspace/sidebar/teams-sidebar-list.tsx new file mode 100644 index 00000000000..92cbdfc5f5f --- /dev/null +++ b/web/ce/components/workspace/sidebar/teams-sidebar-list.tsx @@ -0,0 +1 @@ +export const SidebarTeamsList = () => null; diff --git a/web/ce/constants/dashboard.ts b/web/ce/constants/dashboard.ts index 8872982fc5b..0df2719a772 100644 --- a/web/ce/constants/dashboard.ts +++ b/web/ce/constants/dashboard.ts @@ -1,17 +1,19 @@ "use client"; // icons -import { Home, Inbox, PenSquare } from "lucide-react"; +import { Briefcase, Home, Inbox, Layers, PenSquare, BarChart2 } from "lucide-react"; // ui -import { UserActivityIcon } from "@plane/ui"; +import { UserActivityIcon, ContrastIcon } from "@plane/ui"; import { Props } from "@/components/icons/types"; +// constants import { TLinkOptions } from "@/constants/dashboard"; +// plane web constants import { EUserPermissions } from "@/plane-web/constants/user-permissions"; // plane web types -import { TSidebarUserMenuItemKeys } from "@/plane-web/types/dashboard"; +import { TSidebarUserMenuItemKeys, TSidebarWorkspaceMenuItemKeys } from "@/plane-web/types/dashboard"; -export type TSidebarUserMenuItems = { - key: TSidebarUserMenuItemKeys; +export type TSidebarMenuItems = { + key: T; label: string; href: string; access: EUserPermissions[]; @@ -19,6 +21,8 @@ export type TSidebarUserMenuItems = { Icon: React.FC; }; +export type TSidebarUserMenuItems = TSidebarMenuItems; + export const SIDEBAR_USER_MENU_ITEMS: TSidebarUserMenuItems[] = [ { key: "home", @@ -54,3 +58,47 @@ export const SIDEBAR_USER_MENU_ITEMS: TSidebarUserMenuItems[] = [ Icon: PenSquare, }, ]; + +export type TSidebarWorkspaceMenuItems = TSidebarMenuItems; + +export const SIDEBAR_WORKSPACE_MENU: Partial> = { + projects: { + key: "projects", + label: "Projects", + href: `/projects`, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/projects/`, + Icon: Briefcase, + }, + "all-issues": { + key: "all-issues", + label: "Views", + href: `/workspace-views/all-issues`, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], + highlight: (pathname: string, baseUrl: string) => pathname.includes(`${baseUrl}/workspace-views/`), + Icon: Layers, + }, + "active-cycles": { + key: "active-cycles", + label: "Cycles", + href: `/active-cycles`, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/active-cycles/`, + Icon: ContrastIcon, + }, + analytics: { + key: "analytics", + label: "Analytics", + href: `/analytics`, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + highlight: (pathname: string, baseUrl: string) => pathname.includes(`${baseUrl}/analytics/`), + Icon: BarChart2, + }, +}; + +export const SIDEBAR_WORKSPACE_MENU_ITEMS: TSidebarWorkspaceMenuItems[] = [ + SIDEBAR_WORKSPACE_MENU?.projects, + SIDEBAR_WORKSPACE_MENU?.["all-issues"], + SIDEBAR_WORKSPACE_MENU?.["active-cycles"], + SIDEBAR_WORKSPACE_MENU?.analytics, +].filter((item): item is TSidebarWorkspaceMenuItems => item !== undefined); diff --git a/web/ce/constants/issues.ts b/web/ce/constants/issues.ts index dc6ffbcb8c2..99b8ef90de1 100644 --- a/web/ce/constants/issues.ts +++ b/web/ce/constants/issues.ts @@ -1,4 +1,6 @@ import { TIssueActivityComment } from "@plane/types"; +// constants +import { ILayoutDisplayFiltersOptions } from "@/constants/issue"; export enum EActivityFilterType { ACTIVITY = "ACTIVITY", @@ -19,7 +21,7 @@ export const ACTIVITY_FILTER_TYPE_OPTIONS: Record void; @@ -32,3 +34,7 @@ export const filterActivityOnSelectedFilters = ( activity.filter((activity) => filter.includes(activity.activity_type as TActivityFilters)); export const ENABLE_ISSUE_DEPENDENCIES = false; + +export const ADDITIONAL_ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { + [pageType: string]: { [layoutType: string]: ILayoutDisplayFiltersOptions }; +} = {}; diff --git a/web/ce/helpers/dashboard.helper.ts b/web/ce/helpers/dashboard.helper.ts index b2fba63adb0..c96c818a1f0 100644 --- a/web/ce/helpers/dashboard.helper.ts +++ b/web/ce/helpers/dashboard.helper.ts @@ -1,5 +1,8 @@ // plane web types -import { TSidebarUserMenuItemKeys } from "@/plane-web/types/dashboard"; +import { TSidebarUserMenuItemKeys, TSidebarWorkspaceMenuItemKeys } from "@/plane-web/types/dashboard"; // eslint-disable-next-line @typescript-eslint/no-unused-vars export const isUserFeatureEnabled = (featureKey: TSidebarUserMenuItemKeys) => true; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const isWorkspaceFeatureEnabled = (featureKey: TSidebarWorkspaceMenuItemKeys, workspaceSlug: string) => true; diff --git a/web/ce/helpers/issue-action-helper.ts b/web/ce/helpers/issue-action-helper.ts new file mode 100644 index 00000000000..b1644e2aa26 --- /dev/null +++ b/web/ce/helpers/issue-action-helper.ts @@ -0,0 +1,15 @@ +import { IssueActions } from "@/hooks/use-issues-actions"; + +export const useTeamIssueActions: () => IssueActions = () => ({ + fetchIssues: () => Promise.resolve(undefined), + fetchNextIssues: () => Promise.resolve(undefined), + removeIssue: () => Promise.resolve(undefined), + updateFilters: () => Promise.resolve(undefined), +}); + +export const useTeamViewIssueActions: () => IssueActions = () => ({ + fetchIssues: () => Promise.resolve(undefined), + fetchNextIssues: () => Promise.resolve(undefined), + removeIssue: () => Promise.resolve(undefined), + updateFilters: () => Promise.resolve(undefined), +}); diff --git a/web/ce/store/command-palette.store.ts b/web/ce/store/command-palette.store.ts index 47c9280cd41..1b6fabf1875 100644 --- a/web/ce/store/command-palette.store.ts +++ b/web/ce/store/command-palette.store.ts @@ -1,12 +1,26 @@ -import { makeObservable } from "mobx"; +import { computed, makeObservable } from "mobx"; // types / constants import { BaseCommandPaletteStore, IBaseCommandPaletteStore } from "@/store/base-command-palette.store"; -export type ICommandPaletteStore = IBaseCommandPaletteStore; +export interface ICommandPaletteStore extends IBaseCommandPaletteStore { + // computed + isAnyModalOpen: boolean; +} export class CommandPaletteStore extends BaseCommandPaletteStore implements ICommandPaletteStore { constructor() { super(); - makeObservable(this, {}); + makeObservable(this, { + // computed + isAnyModalOpen: computed, + }); + } + + /** + * Checks whether any modal is open or not in the base command palette. + * @returns boolean + */ + get isAnyModalOpen(): boolean { + return Boolean(super.getCoreModalsState()); } } diff --git a/web/ce/store/issue/team-views/filter.store.ts b/web/ce/store/issue/team-views/filter.store.ts new file mode 100644 index 00000000000..9c33f94051c --- /dev/null +++ b/web/ce/store/issue/team-views/filter.store.ts @@ -0,0 +1,12 @@ +import { IProjectViewIssuesFilter, ProjectViewIssuesFilter } from "@/store/issue/project-views"; +import { IIssueRootStore } from "@/store/issue/root.store"; + +// @ts-nocheck - This class will never be used, extending similar class to avoid type errors +export type ITeamViewIssuesFilter = IProjectViewIssuesFilter; + +// @ts-nocheck - This class will never be used, extending similar class to avoid type errors +export class TeamViewIssuesFilter extends ProjectViewIssuesFilter implements IProjectViewIssuesFilter { + constructor(_rootStore: IIssueRootStore) { + super(_rootStore); + } +} diff --git a/web/ce/store/issue/team-views/index.ts b/web/ce/store/issue/team-views/index.ts new file mode 100644 index 00000000000..0fe6c946b0c --- /dev/null +++ b/web/ce/store/issue/team-views/index.ts @@ -0,0 +1,2 @@ +export * from "./filter.store"; +export * from "./issue.store"; diff --git a/web/ce/store/issue/team-views/issue.store.ts b/web/ce/store/issue/team-views/issue.store.ts new file mode 100644 index 00000000000..328370f853d --- /dev/null +++ b/web/ce/store/issue/team-views/issue.store.ts @@ -0,0 +1,13 @@ +import { IProjectViewIssues, ProjectViewIssues } from "@/store/issue/project-views"; +import { IIssueRootStore } from "@/store/issue/root.store"; +import { ITeamViewIssuesFilter } from "./filter.store"; + +// @ts-nocheck - This class will never be used, extending similar class to avoid type errors +export type ITeamViewIssues = IProjectViewIssues; + +// @ts-nocheck - This class will never be used, extending similar class to avoid type errors +export class TeamViewIssues extends ProjectViewIssues implements IProjectViewIssues { + constructor(_rootStore: IIssueRootStore, teamViewFilterStore: ITeamViewIssuesFilter) { + super(_rootStore, teamViewFilterStore); + } +} diff --git a/web/ce/store/issue/team/filter.store.ts b/web/ce/store/issue/team/filter.store.ts new file mode 100644 index 00000000000..42b2d5dd248 --- /dev/null +++ b/web/ce/store/issue/team/filter.store.ts @@ -0,0 +1,12 @@ +import { IProjectIssuesFilter, ProjectIssuesFilter } from "@/store/issue/project"; +import { IIssueRootStore } from "@/store/issue/root.store"; + +// @ts-nocheck - This class will never be used, extending similar class to avoid type errors +export type ITeamIssuesFilter = IProjectIssuesFilter; + +// @ts-nocheck - This class will never be used, extending similar class to avoid type errors +export class TeamIssuesFilter extends ProjectIssuesFilter implements IProjectIssuesFilter { + constructor(_rootStore: IIssueRootStore) { + super(_rootStore); + } +} diff --git a/web/ce/store/issue/team/index.ts b/web/ce/store/issue/team/index.ts new file mode 100644 index 00000000000..0fe6c946b0c --- /dev/null +++ b/web/ce/store/issue/team/index.ts @@ -0,0 +1,2 @@ +export * from "./filter.store"; +export * from "./issue.store"; diff --git a/web/ce/store/issue/team/issue.store.ts b/web/ce/store/issue/team/issue.store.ts new file mode 100644 index 00000000000..2e397943640 --- /dev/null +++ b/web/ce/store/issue/team/issue.store.ts @@ -0,0 +1,13 @@ +import { IProjectIssues, ProjectIssues } from "@/store/issue/project"; +import { IIssueRootStore } from "@/store/issue/root.store"; +import { ITeamIssuesFilter } from "./filter.store"; + +// @ts-nocheck - This class will never be used, extending similar class to avoid type errors +export type ITeamIssues = IProjectIssues; + +// @ts-nocheck - This class will never be used, extending similar class to avoid type errors +export class TeamIssues extends ProjectIssues implements IProjectIssues { + constructor(_rootStore: IIssueRootStore, teamIssueFilterStore: ITeamIssuesFilter) { + super(_rootStore, teamIssueFilterStore); + } +} diff --git a/web/ce/types/dashboard.ts b/web/ce/types/dashboard.ts index d615ac4afce..de35f60c6a7 100644 --- a/web/ce/types/dashboard.ts +++ b/web/ce/types/dashboard.ts @@ -1 +1,3 @@ export type TSidebarUserMenuItemKeys = "home" | "your-work" | "notifications" | "drafts"; + +export type TSidebarWorkspaceMenuItemKeys = "projects" | "all-issues" | "active-cycles" | "analytics"; diff --git a/web/core/components/cycles/analytics-sidebar/root.tsx b/web/core/components/cycles/analytics-sidebar/root.tsx index fd8c984a690..b709c0e6317 100644 --- a/web/core/components/cycles/analytics-sidebar/root.tsx +++ b/web/core/components/cycles/analytics-sidebar/root.tsx @@ -2,7 +2,6 @@ import React from "react"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; // ui import { Loader } from "@plane/ui"; // components @@ -13,19 +12,19 @@ import useCyclesDetails from "../active-cycle/use-cycles-details"; type Props = { handleClose: () => void; isArchived?: boolean; - cycleId?: string; + cycleId: string; + projectId: string; + workspaceSlug: string; }; export const CycleDetailsSidebar: React.FC = observer((props) => { - const { handleClose, isArchived } = props; - // router - const { workspaceSlug, projectId, cycleId } = useParams(); + const { handleClose, isArchived, projectId, workspaceSlug, cycleId } = props; // store hooks const { cycle: cycleDetails } = useCyclesDetails({ - workspaceSlug: workspaceSlug.toString(), - projectId: projectId.toString(), - cycleId: cycleId?.toString() || props.cycleId, + workspaceSlug, + projectId, + cycleId, }); if (!cycleDetails) @@ -47,21 +46,17 @@ export const CycleDetailsSidebar: React.FC = observer((props) => {
    - +
    {workspaceSlug && projectId && cycleDetails?.id && ( - + )}
    ); diff --git a/web/core/components/cycles/cycle-peek-overview.tsx b/web/core/components/cycles/cycle-peek-overview.tsx index 759569cfa9f..187425b8d72 100644 --- a/web/core/components/cycles/cycle-peek-overview.tsx +++ b/web/core/components/cycles/cycle-peek-overview.tsx @@ -9,12 +9,13 @@ import { useAppRouter } from "@/hooks/use-app-router"; import { CycleDetailsSidebar } from "./"; type Props = { - projectId: string; + projectId?: string; workspaceSlug: string; isArchived?: boolean; }; -export const CyclePeekOverview: React.FC = observer(({ projectId, workspaceSlug, isArchived = false }) => { +export const CyclePeekOverview: React.FC = observer((props) => { + const { projectId: propsProjectId, workspaceSlug, isArchived } = props; // router const router = useAppRouter(); const pathname = usePathname(); @@ -23,22 +24,25 @@ export const CyclePeekOverview: React.FC = observer(({ projectId, workspa // refs const ref = React.useRef(null); // store hooks - const { fetchCycleDetails, fetchArchivedCycleDetails } = useCycle(); + const { getCycleById, fetchCycleDetails, fetchArchivedCycleDetails } = useCycle(); + // derived values + const cycleDetails = peekCycle ? getCycleById(peekCycle.toString()) : undefined; + const projectId = propsProjectId || cycleDetails?.project_id; const handleClose = () => { const query = generateQueryParams(searchParams, ["peekCycle"]); - router.push(`${pathname}?${query}`); + router.push(`${pathname}?${query}`, {}, { showProgressBar: false }); }; useEffect(() => { - if (!peekCycle) return; + if (!peekCycle || !projectId) return; if (isArchived) fetchArchivedCycleDetails(workspaceSlug, projectId, peekCycle.toString()); else fetchCycleDetails(workspaceSlug, projectId, peekCycle.toString()); }, [fetchArchivedCycleDetails, fetchCycleDetails, isArchived, peekCycle, projectId, workspaceSlug]); return ( <> - {peekCycle && ( + {peekCycle && projectId && (
    = observer(({ projectId, workspa "0px 1px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 4px 0px rgba(16, 24, 40, 0.06), 0px 1px 8px -1px rgba(16, 24, 40, 0.06)", }} > - +
    )} diff --git a/web/core/components/cycles/form.tsx b/web/core/components/cycles/form.tsx index 660d33cdb80..7651c5d4433 100644 --- a/web/core/components/cycles/form.tsx +++ b/web/core/components/cycles/form.tsx @@ -75,6 +75,7 @@ export const CycleForm: React.FC = (props) => { onChange(val); setActiveProject(val); }} + multiple={false} buttonVariant="border-with-text" renderCondition={(project) => shouldRenderProject(project)} tabIndex={getIndex("cover_image")} diff --git a/web/core/components/cycles/list/cycle-list-item-action.tsx b/web/core/components/cycles/list/cycle-list-item-action.tsx index 989e0436e36..73dca345d7c 100644 --- a/web/core/components/cycles/list/cycle-list-item-action.tsx +++ b/web/core/components/cycles/list/cycle-list-item-action.tsx @@ -14,10 +14,9 @@ import { CycleQuickActions } from "@/components/cycles"; import { DateRangeDropdown } from "@/components/dropdowns"; import { ButtonAvatars } from "@/components/dropdowns/member/avatar"; // constants -import { CYCLE_STATUS } from "@/constants/cycle"; import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "@/constants/event-tracker"; // helpers -import { findHowManyDaysLeft, getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"; +import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"; import { getFileURL } from "@/helpers/file.helper"; // hooks import { generateQueryParams } from "@/helpers/router.helper"; @@ -69,11 +68,11 @@ export const CycleListItemAction: FC = observer((props) => { const cycleStatus = cycleDetails.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft"; const isEditingAllowed = allowPermissions( [EUserPermissions.ADMIN, EUserPermissions.MEMBER], - EUserPermissionsLevel.PROJECT + EUserPermissionsLevel.PROJECT, + workspaceSlug, + projectId ); const renderIcon = Boolean(cycleDetails.start_date) || Boolean(cycleDetails.end_date); - const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus); - const daysLeft = findHowManyDaysLeft(cycleDetails.end_date) ?? 0; // handlers const handleAddToFavorites = (e: MouseEvent) => { @@ -201,9 +200,9 @@ export const CycleListItemAction: FC = observer((props) => { const query = generateQueryParams(searchParams, ["peekCycle"]); if (searchParams.has("peekCycle") && searchParams.get("peekCycle") === cycleId) { - router.push(`${pathname}?${query}`); + router.push(`${pathname}?${query}`, {}, { showProgressBar: false }); } else { - router.push(`${pathname}?${query && `${query}&`}peekCycle=${cycleId}`); + router.push(`${pathname}?${query && `${query}&`}peekCycle=${cycleId}`, {}, { showProgressBar: false }); } }; diff --git a/web/core/components/cycles/list/cycle-list-project-group-header.tsx b/web/core/components/cycles/list/cycle-list-project-group-header.tsx new file mode 100644 index 00000000000..d663eca0d86 --- /dev/null +++ b/web/core/components/cycles/list/cycle-list-project-group-header.tsx @@ -0,0 +1,44 @@ +"use client"; + +import React, { FC } from "react"; +import { observer } from "mobx-react"; +import { ChevronRight } from "lucide-react"; +// icons +import { Row, Logo } from "@plane/ui"; +// helpers +import { cn } from "@/helpers/common.helper"; +import { useProject } from "@/hooks/store/use-project"; + +type Props = { + projectId: string; + count?: number; + showCount?: boolean; + isExpanded?: boolean; +}; + +export const CycleListProjectGroupHeader: FC = observer((props) => { + const { projectId, count, showCount = false, isExpanded = false } = props; + // store hooks + const { getProjectById } = useProject(); + // derived values + const project = getProjectById(projectId); + + if (!project) return null; + return ( + + +
    + +
    +
    +
    {project.name}
    + {showCount &&
    {`${count ?? "0"}`}
    } +
    +
    + ); +}); diff --git a/web/core/components/cycles/list/cycles-list-item.tsx b/web/core/components/cycles/list/cycles-list-item.tsx index 8d531216a4b..5954a0a7f33 100644 --- a/web/core/components/cycles/list/cycles-list-item.tsx +++ b/web/core/components/cycles/list/cycles-list-item.tsx @@ -4,7 +4,7 @@ import { FC, MouseEvent, useRef } from "react"; import { observer } from "mobx-react"; import { usePathname, useSearchParams } from "next/navigation"; // icons -import { Check, Info } from "lucide-react"; +import { Check } from "lucide-react"; // types import type { TCycleGroups } from "@plane/types"; // ui @@ -72,9 +72,9 @@ export const CyclesListItem: FC = observer((props) => { const query = generateQueryParams(searchParams, ["peekCycle"]); if (searchParams.has("peekCycle") && searchParams.get("peekCycle") === cycleId) { - router.push(`${pathname}?${query}`); + router.push(`${pathname}?${query}`, {}, { showProgressBar: false }); } else { - router.push(`${pathname}?${query && `${query}&`}peekCycle=${cycleId}`); + router.push(`${pathname}?${query && `${query}&`}peekCycle=${cycleId}`, {}, { showProgressBar: false }); } }; diff --git a/web/core/components/cycles/list/index.ts b/web/core/components/cycles/list/index.ts index 4eebc577943..25419a0560a 100644 --- a/web/core/components/cycles/list/index.ts +++ b/web/core/components/cycles/list/index.ts @@ -3,3 +3,4 @@ export * from "./cycles-list-map"; export * from "./root"; export * from "./cycle-list-item-action"; export * from "./cycle-list-group-header"; +export * from "./cycle-list-project-group-header"; diff --git a/web/core/components/dropdowns/layout.tsx b/web/core/components/dropdowns/layout.tsx index 2557e57a2a2..7864d1849df 100644 --- a/web/core/components/dropdowns/layout.tsx +++ b/web/core/components/dropdowns/layout.tsx @@ -10,18 +10,24 @@ import { EIssueLayoutTypes, ISSUE_LAYOUT_MAP } from "@/constants/issue"; type TLayoutDropDown = { onChange: (value: EIssueLayoutTypes) => void; value: EIssueLayoutTypes; + disabledLayouts?: EIssueLayoutTypes[]; }; export const LayoutDropDown = observer((props: TLayoutDropDown) => { - const { onChange, value = EIssueLayoutTypes.LIST } = props; + const { onChange, value = EIssueLayoutTypes.LIST, disabledLayouts = [] } = props; + // derived values + const availableLayouts = useMemo( + () => Object.values(ISSUE_LAYOUT_MAP).filter((layout) => !disabledLayouts.includes(layout.key)), + [disabledLayouts] + ); const options = useMemo( () => - Object.values(ISSUE_LAYOUT_MAP).map((issueLayout) => ({ + availableLayouts.map((issueLayout) => ({ data: issueLayout.key, value: issueLayout.key, })), - [] + [availableLayouts] ); const buttonContent = useCallback((isOpen: boolean, buttonValue: string | string[] | undefined) => { diff --git a/web/core/components/dropdowns/project.tsx b/web/core/components/dropdowns/project.tsx index 3f973cd1618..f94014eb8b8 100644 --- a/web/core/components/dropdowns/project.tsx +++ b/web/core/components/dropdowns/project.tsx @@ -1,4 +1,4 @@ -import { Fragment, ReactNode, useRef, useState } from "react"; +import { ReactNode, useRef, useState } from "react"; import { observer } from "mobx-react"; import { usePopper } from "react-popper"; import { Check, ChevronDown, Search } from "lucide-react"; @@ -25,12 +25,21 @@ type Props = TDropdownProps & { button?: ReactNode; dropdownArrow?: boolean; dropdownArrowClassName?: string; - onChange: (val: string) => void; onClose?: () => void; renderCondition?: (project: TProject) => boolean; - value: string | null; renderByDefault?: boolean; -}; +} & ( + | { + multiple: false; + onChange: (val: string) => void; + value: string | null; + } + | { + multiple: true; + onChange: (val: string[]) => void; + value: string[]; + } + ); export const ProjectDropdown: React.FC = observer((props) => { const { @@ -43,6 +52,7 @@ export const ProjectDropdown: React.FC = observer((props) => { dropdownArrow = false, dropdownArrowClassName = "", hideIcon = false, + multiple, onChange, onClose, placeholder = "Project", @@ -99,8 +109,6 @@ export const ProjectDropdown: React.FC = observer((props) => { const filteredOptions = query === "" ? options : options?.filter((o) => o?.query.toLowerCase().includes(query.toLowerCase())); - const selectedProject = value ? getProjectById(value) : null; - const { handleClose, handleKeyDown, handleOnClick, searchInputKeyDown } = useDropdown({ dropdownRef, inputRef, @@ -111,9 +119,40 @@ export const ProjectDropdown: React.FC = observer((props) => { setQuery, }); - const dropdownOnChange = (val: string) => { + const dropdownOnChange = (val: string & string[]) => { onChange(val); - handleClose(); + if (!multiple) handleClose(); + }; + + const getDisplayName = (value: string | string[] | null, placeholder: string = "") => { + if (Array.isArray(value)) { + const firstProject = getProjectById(value[0]); + return value.length ? (value.length === 1 ? firstProject?.name : `${value.length} projects`) : placeholder; + } else { + return value ? (getProjectById(value)?.name ?? placeholder) : placeholder; + } + }; + + const getProjectIcon = (value: string | string[] | null) => { + const renderIcon = (projectDetails: TProject) => ( + + + + ); + + if (Array.isArray(value)) { + return ( +
    + {value.map((projectId) => { + const projectDetails = getProjectById(projectId); + return projectDetails ? renderIcon(projectDetails) : null; + })} +
    + ); + } else { + const projectDetails = getProjectById(value); + return projectDetails ? renderIcon(projectDetails) : null; + } }; const comboButton = ( @@ -147,18 +186,14 @@ export const ProjectDropdown: React.FC = observer((props) => { className={buttonClassName} isActive={isOpen} tooltipHeading="Project" - tooltipContent={selectedProject?.name ?? placeholder} + tooltipContent={value?.length ? `${value.length} project${value.length !== 1 ? "s" : ""}` : placeholder} showTooltip={showTooltip} variant={buttonVariant} renderToolTipByDefault={renderByDefault} > - {!hideIcon && selectedProject && ( - - - - )} + {!hideIcon && getProjectIcon(value)} {BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && ( - {selectedProject?.name ?? placeholder} + {getDisplayName(value, placeholder)} )} {dropdownArrow && (