diff --git a/apps/web/core/components/issues/issue-layouts/kanban/base-kanban-root.tsx b/apps/web/core/components/issues/issue-layouts/kanban/base-kanban-root.tsx index 5f5ca94c851..7d63c6e0c8b 100644 --- a/apps/web/core/components/issues/issue-layouts/kanban/base-kanban-root.tsx +++ b/apps/web/core/components/issues/issue-layouts/kanban/base-kanban-root.tsx @@ -105,7 +105,7 @@ export const BaseKanBanRoot = observer(function BaseKanBanRoot(props: IBaseKanBa fetchNextIssues(groupId, subgroupId); } }, - [fetchNextIssues] + [issues, fetchNextIssues] ); const groupedIssueIds = issues?.groupedIssueIds; @@ -130,9 +130,11 @@ export const BaseKanBanRoot = observer(function BaseKanBanRoot(props: IBaseKanBa const handleOnDrop = useGroupIssuesDragNDrop(storeType, orderBy, group_by, sub_group_by); const canEditProperties = useCallback( - (projectId: string | undefined) => { + (targetProjectId: string | undefined) => { const isEditingAllowedBasedOnProject = - canEditPropertiesBasedOnProject && projectId ? canEditPropertiesBasedOnProject(projectId) : isEditingAllowed; + canEditPropertiesBasedOnProject && targetProjectId + ? canEditPropertiesBasedOnProject(targetProjectId) + : isEditingAllowed; return enableInlineEditing && isEditingAllowedBasedOnProject; }, @@ -232,6 +234,23 @@ export const BaseKanBanRoot = observer(function BaseKanBanRoot(props: IBaseKanBa ); const collapsedGroups = issuesFilter?.issueFilters?.kanbanFilters || { group_by: [], sub_group_by: [] }; + const groupWidths = issuesFilter?.issueFilters?.kanbanFilters?.group_widths || {}; + + const handleResizeColumnWidth = useCallback( + async (columnId: string, width: number) => { + if (!workspaceSlug) return; + const kanbanFilters = issuesFilter?.issueFilters?.kanbanFilters || { group_by: [], sub_group_by: [] }; + try { + await updateFilters(projectId?.toString() ?? "", EIssueFilterType.KANBAN_FILTERS, { + ...kanbanFilters, + group_widths: { ...kanbanFilters.group_widths, [columnId]: Math.round(width) }, + }); + } catch (error: unknown) { + console.error("Failed to save Kanban column width", error); + } + }, + [workspaceSlug, issuesFilter, projectId, updateFilters] + ); return ( <> @@ -278,6 +297,8 @@ export const BaseKanBanRoot = observer(function BaseKanBanRoot(props: IBaseKanBa quickActions={renderQuickActions} handleCollapsedGroups={handleCollapsedGroups} collapsedGroups={collapsedGroups} + groupWidths={groupWidths} + onResizeColumnWidth={handleResizeColumnWidth} enableQuickIssueCreate={enableQuickAdd} showEmptyGroup={userDisplayFilters?.show_empty_groups ?? true} quickAddCallback={quickAddIssue} diff --git a/apps/web/core/components/issues/issue-layouts/kanban/default.tsx b/apps/web/core/components/issues/issue-layouts/kanban/default.tsx index d63cf00ab8e..435f79b9e76 100644 --- a/apps/web/core/components/issues/issue-layouts/kanban/default.tsx +++ b/apps/web/core/components/issues/issue-layouts/kanban/default.tsx @@ -19,12 +19,19 @@ import type { TIssueOrderByOptions, } from "@plane/types"; // constants +import { + STATE_GROUP_COLUMN_DEFAULT_WIDTH, + STATE_GROUP_COLUMN_MAX_WIDTH, + STATE_GROUP_COLUMN_MIN_WIDTH, +} from "@plane/constants"; import { ContentWrapper } from "@plane/ui"; +import { cn } from "@plane/utils"; // components import RenderIfVisible from "@/components/core/render-if-visible-HOC"; import { KanbanColumnLoader } from "@/components/ui/loader/layouts/kanban-layout-loader"; // hooks import { useKanbanView } from "@/hooks/store/use-kanban-view"; +import { useKanbanColumnResize } from "@/hooks/use-kanban-column-resize"; import { useIssueStoreType } from "@/hooks/use-issue-layout-store"; // types // parent components @@ -56,6 +63,8 @@ export interface IKanBan { quickActions: TRenderQuickActions; collapsedGroups: TIssueKanbanFilters; handleCollapsedGroups: (toggle: "group_by" | "sub_group_by", value: string) => void; + groupWidths?: Record; + onResizeColumnWidth?: (columnId: string, width: number) => void; loadMoreIssues: (groupId?: string, subGroupId?: string) => void; enableQuickIssueCreate?: boolean; quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise; @@ -82,6 +91,8 @@ export const KanBan = observer(function KanBan(props: IKanBan) { quickActions, collapsedGroups, handleCollapsedGroups, + groupWidths = {}, + onResizeColumnWidth, enableQuickIssueCreate, quickAddCallback, loadMoreIssues, @@ -142,6 +153,16 @@ export const KanBan = observer(function KanBan(props: IKanBan) { const isGroupByCreatedBy = group_by === "created_by"; const approximateCardHeight = getApproximateCardHeight(displayProperties); const isSubGroup = !!sub_group_id && sub_group_id !== "null"; + const { isResizing, resizingColumnId, currentWidth, startResize } = useKanbanColumnResize({ + minWidth: STATE_GROUP_COLUMN_MIN_WIDTH, + maxWidth: STATE_GROUP_COLUMN_MAX_WIDTH, + onResizeEnd: onResizeColumnWidth ?? (() => {}), + }); + + const getEffectiveWidth = (columnId: string) => { + if (isResizing && resizingColumnId === columnId) return currentWidth; + return groupWidths[columnId] ?? STATE_GROUP_COLUMN_DEFAULT_WIDTH; + }; return ( @@ -161,9 +182,8 @@ export const KanBan = observer(function KanBan(props: IKanBan) { return (
{sub_group_by === null && (
@@ -231,6 +251,19 @@ export const KanBan = observer(function KanBan(props: IKanBan) { /> )} + + {!isSubGroup && groupByVisibilityToggle.showIssues && ( +
startResize(e, subList.id, getEffectiveWidth(subList.id))} + /> + )}
); })} diff --git a/apps/web/core/hooks/use-kanban-column-resize.ts b/apps/web/core/hooks/use-kanban-column-resize.ts new file mode 100644 index 00000000000..d25015b456b --- /dev/null +++ b/apps/web/core/hooks/use-kanban-column-resize.ts @@ -0,0 +1,70 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; + +interface IUseKanbanColumnResize { + minWidth: number; + maxWidth: number; + onResizeEnd: (columnId: string, width: number) => void; +} + +export const useKanbanColumnResize = ({ minWidth, maxWidth, onResizeEnd }: IUseKanbanColumnResize) => { + // state + const [resizingColumnId, setResizingColumnId] = useState(null); + const [currentWidth, setCurrentWidth] = useState(minWidth); + // refs + const startXRef = useRef(0); + const startWidthRef = useRef(minWidth); + const currentWidthRef = useRef(minWidth); + const onResizeEndRef = useRef(onResizeEnd); + onResizeEndRef.current = onResizeEnd; + + const startResize = useCallback((e: React.MouseEvent, columnId: string, startWidth: number) => { + e.preventDefault(); + e.stopPropagation(); + startXRef.current = e.clientX; + startWidthRef.current = startWidth; + currentWidthRef.current = startWidth; + setCurrentWidth(startWidth); + setResizingColumnId(columnId); + }, []); + + useEffect(() => { + if (!resizingColumnId) return; + + const handleMouseMove = (e: MouseEvent) => { + const deltaX = e.clientX - startXRef.current; + const nextWidth = Math.min(Math.max(startWidthRef.current + deltaX, minWidth), maxWidth); + currentWidthRef.current = nextWidth; + setCurrentWidth(nextWidth); + }; + + const handleMouseUp = () => { + onResizeEndRef.current(resizingColumnId, currentWidthRef.current); + setResizingColumnId(null); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + }, [resizingColumnId, minWidth, maxWidth]); + + return { + isResizing: resizingColumnId !== null, + resizingColumnId, + currentWidth, + startResize, + }; +}; diff --git a/apps/web/core/store/issue/project/filter.store.ts b/apps/web/core/store/issue/project/filter.store.ts index 657eee4f9ae..181858f185e 100644 --- a/apps/web/core/store/issue/project/filter.store.ts +++ b/apps/web/core/store/issue/project/filter.store.ts @@ -142,9 +142,10 @@ export class ProjectIssuesFilter extends IssueFilterHelperStore implements IProj const displayProperties = this.computedDisplayProperties(_filters?.display_properties); // fetching the kanban toggle helpers in the local storage - const kanbanFilters = { + const kanbanFilters: TIssueKanbanFilters = { group_by: [], sub_group_by: [], + group_widths: {}, }; const currentUserId = this.rootIssueStore.currentUserId; if (currentUserId) { @@ -156,6 +157,7 @@ export class ProjectIssuesFilter extends IssueFilterHelperStore implements IProj ); kanbanFilters.group_by = _kanbanFilters?.kanban_filters?.group_by || []; kanbanFilters.sub_group_by = _kanbanFilters?.kanban_filters?.sub_group_by || []; + kanbanFilters.group_widths = _kanbanFilters?.kanban_filters?.group_widths || {}; } runInAction(() => { diff --git a/packages/constants/src/state.ts b/packages/constants/src/state.ts index 36adef17b72..84054b264e5 100644 --- a/packages/constants/src/state.ts +++ b/packages/constants/src/state.ts @@ -51,6 +51,10 @@ export const STATE_GROUPS: { }, }; +export const STATE_GROUP_COLUMN_DEFAULT_WIDTH = 350; +export const STATE_GROUP_COLUMN_MIN_WIDTH = 350; +export const STATE_GROUP_COLUMN_MAX_WIDTH = 1000; + export const ARCHIVABLE_STATE_GROUPS = [STATE_GROUPS.completed.key, STATE_GROUPS.cancelled.key]; export const COMPLETED_STATE_GROUPS = [STATE_GROUPS.completed.key]; export const PENDING_STATE_GROUPS = [ diff --git a/packages/types/src/view-props.ts b/packages/types/src/view-props.ts index 638afa0fd13..584a5bb1333 100644 --- a/packages/types/src/view-props.ts +++ b/packages/types/src/view-props.ts @@ -180,6 +180,7 @@ export interface IIssueDisplayProperties { export type TIssueKanbanFilters = { group_by: string[]; sub_group_by: string[]; + group_widths?: Record; }; export interface IIssueFilters {