feat: init
Some checks failed
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
chuan
2025-11-11 01:56:44 +08:00
commit bba4bb40c8
4638 changed files with 447437 additions and 0 deletions

View File

@@ -0,0 +1,48 @@
"use client";
import type { ReactNode } from "react";
import React, { createContext } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// hooks
import useLocalStorage from "@/hooks/use-local-storage";
export interface AppRailContextType {
isEnabled: boolean;
shouldRenderAppRail: boolean;
toggleAppRail: (value?: boolean) => void;
}
const AppRailContext = createContext<AppRailContextType | undefined>(undefined);
export { AppRailContext };
interface AppRailProviderProps {
children: ReactNode;
}
export const AppRailProvider = observer(({ children }: AppRailProviderProps) => {
const { workspaceSlug } = useParams();
const { storedValue: isAppRailVisible, setValue: setIsAppRailVisible } = useLocalStorage<boolean>(
`APP_RAIL_${workspaceSlug}`,
false
);
const isEnabled = false;
const toggleAppRail = (value?: boolean) => {
if (value === undefined) {
setIsAppRailVisible(!isAppRailVisible);
} else {
setIsAppRailVisible(value);
}
};
const contextValue: AppRailContextType = {
isEnabled,
shouldRenderAppRail: !!isAppRailVisible && isEnabled,
toggleAppRail,
};
return <AppRailContext.Provider value={contextValue}>{children}</AppRailContext.Provider>;
});

View File

@@ -0,0 +1,10 @@
import { useContext } from "react";
// components
import { IssueModalContext } from "@/components/issues/issue-modal/context";
import type { TIssueModalContext } from "@/components/issues/issue-modal/context";
export const useIssueModal = (): TIssueModalContext => {
const context = useContext(IssueModalContext);
if (context === undefined) throw new Error("useIssueModal must be used within IssueModalProvider");
return context;
};

View File

@@ -0,0 +1,2 @@
export * from "./use-editor-config";
export * from "./use-editor-mention";

View File

@@ -0,0 +1,100 @@
import { useCallback } from "react";
// plane imports
import type { TFileHandler } from "@plane/editor";
import { getEditorAssetDownloadSrc, getEditorAssetSrc } from "@plane/utils";
// hooks
import { useEditorAsset } from "@/hooks/store/use-editor-asset";
// plane web hooks
import { useExtendedEditorConfig } from "@/plane-web/hooks/editor/use-extended-editor-config";
import { useFileSize } from "@/plane-web/hooks/use-file-size";
// services
import { FileService } from "@/services/file.service";
const fileService = new FileService();
type TArgs = {
projectId?: string;
uploadFile: TFileHandler["upload"];
workspaceId: string;
workspaceSlug: string;
};
export const useEditorConfig = () => {
// store hooks
const { assetsUploadPercentage } = useEditorAsset();
// file size
const { maxFileSize } = useFileSize();
const { getExtendedEditorFileHandlers } = useExtendedEditorConfig();
const getEditorFileHandlers = useCallback(
(args: TArgs): TFileHandler => {
const { projectId, uploadFile, workspaceId, workspaceSlug } = args;
return {
assetsUploadStatus: assetsUploadPercentage,
cancel: fileService.cancelUpload,
checkIfAssetExists: async (assetId: string) => {
const res = await fileService.checkIfAssetExists(workspaceSlug, assetId);
return res?.exists ?? false;
},
delete: async (src: string) => {
if (src?.startsWith("http")) {
await fileService.deleteOldWorkspaceAsset(workspaceId, src);
} else {
await fileService.deleteNewAsset(
getEditorAssetSrc({
assetId: src,
projectId,
workspaceSlug,
}) ?? ""
);
}
},
getAssetDownloadSrc: async (path) => {
if (!path) return "";
if (path?.startsWith("http")) {
return path;
} else {
return (
getEditorAssetDownloadSrc({
assetId: path,
projectId,
workspaceSlug,
}) ?? ""
);
}
},
getAssetSrc: async (path) => {
if (!path) return "";
if (path?.startsWith("http")) {
return path;
} else {
return (
getEditorAssetSrc({
assetId: path,
projectId,
workspaceSlug,
}) ?? ""
);
}
},
restore: async (src: string) => {
if (src?.startsWith("http")) {
await fileService.restoreOldEditorAsset(workspaceId, src);
} else {
await fileService.restoreNewAsset(workspaceSlug, src);
}
},
upload: uploadFile,
validation: {
maxFileSize,
},
...getExtendedEditorFileHandlers({ projectId, workspaceSlug }),
};
},
[assetsUploadPercentage, getExtendedEditorFileHandlers, maxFileSize]
);
return {
getEditorFileHandlers,
};
};

View File

@@ -0,0 +1,76 @@
import { useCallback } from "react";
// plane editor
import type { TMentionSection, TMentionSuggestion } from "@plane/editor";
// plane types
import type { TSearchEntities, TSearchEntityRequestPayload, TSearchResponse, TUserSearchResponse } from "@plane/types";
// plane ui
import { Avatar } from "@plane/ui";
// helpers
import { getFileURL } from "@plane/utils";
// plane web constants
import { EDITOR_MENTION_TYPES } from "@/plane-web/constants/editor";
// plane web hooks
import { useAdditionalEditorMention } from "@/plane-web/hooks/use-additional-editor-mention";
type TArgs = {
searchEntity: (payload: TSearchEntityRequestPayload) => Promise<TSearchResponse>;
};
export const useEditorMention = (args: TArgs) => {
const { searchEntity } = args;
// additional mentions
const { updateAdditionalSections } = useAdditionalEditorMention();
// fetch mentions handler
const fetchMentions = useCallback(
async (query: string): Promise<TMentionSection[]> => {
try {
const res = await searchEntity({
count: 5,
query_type: EDITOR_MENTION_TYPES,
query,
});
const suggestionSections: TMentionSection[] = [];
if (!res) {
throw new Error("No response found");
}
Object.keys(res).map((key) => {
const responseKey = key as TSearchEntities;
const response = res[responseKey];
if (responseKey === "user_mention" && response && response.length > 0) {
const items: TMentionSuggestion[] = (response as TUserSearchResponse[]).map((user) => ({
icon: (
<Avatar
className="flex-shrink-0"
src={getFileURL(user.member__avatar_url)}
name={user.member__display_name}
/>
),
id: user.member__id,
entity_identifier: user.member__id,
entity_name: "user_mention",
title: user.member__display_name,
}));
suggestionSections.push({
key: "users",
title: "Users",
items,
});
}
});
updateAdditionalSections({
response: res,
sections: suggestionSections,
});
return suggestionSections;
} catch (error) {
console.error("Error in fetching mentions for project pages:", error);
throw error;
}
},
[searchEntity, updateAdditionalSections]
);
return {
fetchMentions,
};
};

View File

@@ -0,0 +1,3 @@
export * from "./use-project-estimate";
export * from "./use-estimate";
export * from "./use-estimate-point";

View File

@@ -0,0 +1,16 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// mobx store
import type { IEstimatePoint } from "@/store/estimates/estimate-point";
export const useEstimatePoint = (
estimateId: string | undefined,
estimatePointId: string | undefined
): IEstimatePoint => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useEstimatePoint must be used within StoreProvider");
if (!estimateId || !estimatePointId) return {} as IEstimatePoint;
return context.projectEstimate.estimates?.[estimateId]?.estimatePoints?.[estimatePointId] || {};
};

View File

@@ -0,0 +1,13 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// mobx store
import type { IEstimate } from "@/plane-web/store/estimates/estimate";
export const useEstimate = (estimateId: string | undefined): IEstimate => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useEstimate must be used within StoreProvider");
if (!estimateId) return {} as IEstimate;
return context.projectEstimate.estimates?.[estimateId] ?? {};
};

View File

@@ -0,0 +1,12 @@
import { useContext } from "react";
// context
import { StoreContext } from "@/lib/store-context";
// mobx store
import type { IProjectEstimateStore } from "@/store/estimates/project-estimate.store";
export const useProjectEstimates = (): IProjectEstimateStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useProjectPage must be used within StoreProvider");
return context.projectEstimate;
};

View File

@@ -0,0 +1,2 @@
export * from "./use-workspace-notifications";
export * from "./use-notification";

View File

@@ -0,0 +1,13 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// mobx store
import type { INotification } from "@/store/notifications/notification";
export const useNotification = (notificationId: string | undefined): INotification => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useNotification must be used within StoreProvider");
if (!notificationId) return {} as INotification;
return context.workspaceNotification.notifications?.[notificationId] ?? {};
};

View File

@@ -0,0 +1,12 @@
import { useContext } from "react";
// context
import { StoreContext } from "@/lib/store-context";
// mobx store
import type { IWorkspaceNotificationStore } from "@/store/notifications/workspace-notifications.store";
export const useWorkspaceNotifications = (): IWorkspaceNotificationStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useWorkspaceNotifications must be used within StoreProvider");
return context.workspaceNotification;
};

View File

@@ -0,0 +1,11 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// types
import type { IAnalyticsStore } from "@/plane-web/store/analytics.store";
export const useAnalytics = (): IAnalyticsStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useAnalytics must be used within StoreProvider");
return context.analytics;
};

View File

@@ -0,0 +1,10 @@
import { useContext } from "react";
// store
import { StoreContext } from "@/lib/store-context";
import type { IThemeStore } from "@/store/theme.store";
export const useAppTheme = (): IThemeStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useAppTheme must be used within StoreProvider");
return context.theme;
};

View File

@@ -0,0 +1,11 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// types
import type { ICalendarStore } from "@/store/issue/issue_calendar_view.store";
export const useCalendarView = (): ICalendarStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useLabel must be used within StoreProvider");
return context.issue.issueCalendarView;
};

View File

@@ -0,0 +1,11 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// types
import type { ICommandPaletteStore } from "@/plane-web/store/command-palette.store";
export const useCommandPalette = (): ICommandPaletteStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useCommandPalette must be used within StoreProvider");
return context.commandPalette;
};

View File

@@ -0,0 +1,11 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// types
import type { ICycleFilterStore } from "@/store/cycle_filter.store";
export const useCycleFilter = (): ICycleFilterStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useCycleFilter must be used within StoreProvider");
return context.cycleFilter;
};

View File

@@ -0,0 +1,11 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// types
import type { ICycleStore } from "@/plane-web/store/cycle";
export const useCycle = (): ICycleStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useCycle must be used within StoreProvider");
return context.cycle;
};

View File

@@ -0,0 +1,11 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// types
import type { IDashboardStore } from "@/store/dashboard.store";
export const useDashboard = (): IDashboardStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useDashboard must be used within StoreProvider");
return context.dashboard;
};

View File

@@ -0,0 +1,10 @@
import { useContext } from "react";
// store
import { StoreContext } from "@/lib/store-context";
import type { IEditorAssetStore } from "@/store/editor/asset.store";
export const useEditorAsset = (): IEditorAssetStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useEditorAsset must be used within StoreProvider");
return context.editorAssetStore;
};

View File

@@ -0,0 +1,10 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
import type { IFavoriteStore } from "@/store/favorite.store";
export const useFavorite = (): IFavoriteStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useFavorites must be used within StoreProvider");
return context.favorite;
};

View File

@@ -0,0 +1,11 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// types
import type { IGlobalViewStore } from "@/plane-web/store/global-view.store";
export const useGlobalView = (): IGlobalViewStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useGlobalView must be used within StoreProvider");
return context.globalView;
};

View File

@@ -0,0 +1,11 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// types
import type { IHomeStore } from "@/store/workspace/home";
export const useHome = (): IHomeStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useDashboard must be used within StoreProvider");
return context.workspaceRoot.home;
};

View File

@@ -0,0 +1,10 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
import type { IInboxIssueStore } from "@/store/inbox/inbox-issue.store";
export const useInboxIssues = (inboxIssueId: string): IInboxIssueStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useInboxIssues must be used within StoreProvider");
return context.projectInbox.getIssueInboxByIssueId(inboxIssueId);
};

View File

@@ -0,0 +1,10 @@
import { useContext } from "react";
// store
import { StoreContext } from "@/lib/store-context";
import type { IInstanceStore } from "@/store/instance.store";
export const useInstance = (): IInstanceStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useInstance must be used within StoreProvider");
return context.instance;
};

View File

@@ -0,0 +1,14 @@
import { useContext } from "react";
import type { TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
// mobx store
import { StoreContext } from "@/lib/store-context";
// types
import type { IIssueDetail } from "@/plane-web/store/issue/issue-details/root.store";
export const useIssueDetail = (serviceType: TIssueServiceType = EIssueServiceType.ISSUES): IIssueDetail => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useIssueDetail must be used within StoreProvider");
if (serviceType === EIssueServiceType.EPICS) return context.issue.epicDetail;
else return context.issue.issueDetail;
};

View File

@@ -0,0 +1,157 @@
import { useContext } from "react";
import { merge } from "lodash-es";
import type { TIssueMap } from "@plane/types";
import { EIssuesStoreType } from "@plane/types";
import { StoreContext } from "@/lib/store-context";
// plane web types
import type { IProjectEpics, IProjectEpicsFilter } from "@/plane-web/store/issue/epic";
// types
import type { ITeamIssues, ITeamIssuesFilter } from "@/plane-web/store/issue/team";
import type { ITeamProjectWorkItemsFilter, ITeamProjectWorkItems } from "@/plane-web/store/issue/team-project";
import type { ITeamViewIssues, ITeamViewIssuesFilter } from "@/plane-web/store/issue/team-views";
import type { IWorkspaceIssues } from "@/plane-web/store/issue/workspace/issue.store";
import type { IArchivedIssues, IArchivedIssuesFilter } from "@/store/issue/archived";
import type { ICycleIssues, ICycleIssuesFilter } from "@/store/issue/cycle";
import type { IModuleIssues, IModuleIssuesFilter } from "@/store/issue/module";
import type { IProfileIssues, IProfileIssuesFilter } from "@/store/issue/profile";
import type { IProjectIssues, IProjectIssuesFilter } from "@/store/issue/project";
import type { IProjectViewIssues, IProjectViewIssuesFilter } from "@/store/issue/project-views";
import type { IWorkspaceIssuesFilter } from "@/store/issue/workspace";
import type { IWorkspaceDraftIssues, IWorkspaceDraftIssuesFilter } from "@/store/issue/workspace-draft";
// constants
type defaultIssueStore = {
issueMap: TIssueMap;
};
export type TStoreIssues = {
[EIssuesStoreType.GLOBAL]: defaultIssueStore & {
issues: IWorkspaceIssues;
issuesFilter: IWorkspaceIssuesFilter;
};
[EIssuesStoreType.WORKSPACE_DRAFT]: defaultIssueStore & {
issues: IWorkspaceDraftIssues;
issuesFilter: IWorkspaceDraftIssuesFilter;
};
[EIssuesStoreType.PROFILE]: defaultIssueStore & {
issues: IProfileIssues;
issuesFilter: IProfileIssuesFilter;
};
[EIssuesStoreType.TEAM]: defaultIssueStore & {
issues: ITeamIssues;
issuesFilter: ITeamIssuesFilter;
};
[EIssuesStoreType.PROJECT]: defaultIssueStore & {
issues: IProjectIssues;
issuesFilter: IProjectIssuesFilter;
};
[EIssuesStoreType.CYCLE]: defaultIssueStore & {
issues: ICycleIssues;
issuesFilter: ICycleIssuesFilter;
};
[EIssuesStoreType.MODULE]: defaultIssueStore & {
issues: IModuleIssues;
issuesFilter: IModuleIssuesFilter;
};
[EIssuesStoreType.TEAM_VIEW]: defaultIssueStore & {
issues: ITeamViewIssues;
issuesFilter: ITeamViewIssuesFilter;
};
[EIssuesStoreType.PROJECT_VIEW]: defaultIssueStore & {
issues: IProjectViewIssues;
issuesFilter: IProjectViewIssuesFilter;
};
[EIssuesStoreType.ARCHIVED]: defaultIssueStore & {
issues: IArchivedIssues;
issuesFilter: IArchivedIssuesFilter;
};
[EIssuesStoreType.DEFAULT]: defaultIssueStore & {
issues: IProjectIssues;
issuesFilter: IProjectIssuesFilter;
};
[EIssuesStoreType.EPIC]: defaultIssueStore & {
issues: IProjectEpics;
issuesFilter: IProjectEpicsFilter;
};
[EIssuesStoreType.TEAM_PROJECT_WORK_ITEMS]: defaultIssueStore & {
issues: ITeamProjectWorkItems;
issuesFilter: ITeamProjectWorkItemsFilter;
};
};
export const useIssues = <T extends EIssuesStoreType>(storeType?: T): TStoreIssues[T] => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useIssues must be used within StoreProvider");
const defaultStore: defaultIssueStore = {
issueMap: context.issue.issues.issuesMap,
};
switch (storeType) {
case EIssuesStoreType.GLOBAL:
return merge(defaultStore, {
issues: context.issue.workspaceIssues,
issuesFilter: context.issue.workspaceIssuesFilter,
}) as TStoreIssues[T];
case EIssuesStoreType.WORKSPACE_DRAFT:
return merge(defaultStore, {
issues: context.issue.workspaceDraftIssues,
issuesFilter: context.issue.workspaceDraftIssuesFilter,
}) as TStoreIssues[T];
case EIssuesStoreType.PROFILE:
return merge(defaultStore, {
issues: context.issue.profileIssues,
issuesFilter: context.issue.profileIssuesFilter,
}) as TStoreIssues[T];
case EIssuesStoreType.TEAM:
return merge(defaultStore, {
issues: context.issue.teamIssues,
issuesFilter: context.issue.teamIssuesFilter,
}) as TStoreIssues[T];
case EIssuesStoreType.PROJECT:
return merge(defaultStore, {
issues: context.issue.projectIssues,
issuesFilter: context.issue.projectIssuesFilter,
}) as TStoreIssues[T];
case EIssuesStoreType.CYCLE:
return merge(defaultStore, {
issues: context.issue.cycleIssues,
issuesFilter: context.issue.cycleIssuesFilter,
}) as TStoreIssues[T];
case EIssuesStoreType.MODULE:
return merge(defaultStore, {
issues: context.issue.moduleIssues,
issuesFilter: context.issue.moduleIssuesFilter,
}) as TStoreIssues[T];
case EIssuesStoreType.TEAM_VIEW:
return merge(defaultStore, {
issues: context.issue.teamViewIssues,
issuesFilter: context.issue.teamViewIssuesFilter,
}) as TStoreIssues[T];
case EIssuesStoreType.PROJECT_VIEW:
return merge(defaultStore, {
issues: context.issue.projectViewIssues,
issuesFilter: context.issue.projectViewIssuesFilter,
}) as TStoreIssues[T];
case EIssuesStoreType.ARCHIVED:
return merge(defaultStore, {
issues: context.issue.archivedIssues,
issuesFilter: context.issue.archivedIssuesFilter,
}) as TStoreIssues[T];
case EIssuesStoreType.EPIC:
return merge(defaultStore, {
issues: context.issue.projectEpics,
issuesFilter: context.issue.projectEpicsFilter,
}) as TStoreIssues[T];
case EIssuesStoreType.TEAM_PROJECT_WORK_ITEMS:
return merge(defaultStore, {
issues: context.issue.teamProjectWorkItems,
issuesFilter: context.issue.teamProjectWorkItemsFilter,
}) as TStoreIssues[T];
default:
return merge(defaultStore, {
issues: context.issue.projectIssues,
issuesFilter: context.issue.projectIssuesFilter,
}) as TStoreIssues[T];
}
};

View File

@@ -0,0 +1,11 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// types
import type { IIssueKanBanViewStore } from "@/store/issue/issue_kanban_view.store";
export const useKanbanView = (): IIssueKanBanViewStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useLabel must be used within StoreProvider");
return context.issue.issueKanBanView;
};

View File

@@ -0,0 +1,11 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// types
import type { ILabelStore } from "@/store/label.store";
export const useLabel = (): ILabelStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useLabel must be used within StoreProvider");
return context.label;
};

View File

@@ -0,0 +1,11 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// types;
import type { IMemberRootStore } from "@/store/member";
export const useMember = (): IMemberRootStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useMember must be used within StoreProvider");
return context.memberRoot;
};

View File

@@ -0,0 +1,11 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// types
import type { IModuleFilterStore } from "@/store/module_filter.store";
export const useModuleFilter = (): IModuleFilterStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useModuleFilter must be used within StoreProvider");
return context.moduleFilter;
};

View File

@@ -0,0 +1,11 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// types
import type { IModuleStore } from "@/store/module.store";
export const useModule = (): IModuleStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useModule must be used within StoreProvider");
return context.module;
};

View File

@@ -0,0 +1,9 @@
import { useContext } from "react";
// store
import { StoreContext } from "@/lib/store-context";
export const useMultipleSelectStore = () => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useMultipleSelectStore must be used within StoreProvider");
return context.multipleSelect;
};

View File

@@ -0,0 +1,11 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// types
import type { IProjectFilterStore } from "@/store/project/project_filter.store";
export const useProjectFilter = (): IProjectFilterStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useProjectFilter must be used within StoreProvider");
return context.projectRoot.projectFilter;
};

View File

@@ -0,0 +1,10 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
import type { IProjectInboxStore } from "@/plane-web/store/project-inbox.store";
export const useProjectInbox = (): IProjectInboxStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useProjectInbox must be used within StoreProvider");
return context.projectInbox;
};

View File

@@ -0,0 +1,11 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// types
import type { IProjectPublishStore } from "@/store/project/project-publish.store";
export const useProjectPublish = (): IProjectPublishStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useProjectPublish must be used within StoreProvider");
return context.projectRoot.publish;
};

View File

@@ -0,0 +1,11 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// Plane-web
import type { IStateStore } from "@/plane-web/store/state.store";
export const useProjectState = (): IStateStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useProjectState must be used within StoreProvider");
return context.state;
};

View File

@@ -0,0 +1,11 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// types
import type { IProjectViewStore } from "@/plane-web/store/project-view.store";
export const useProjectView = (): IProjectViewStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useProjectView must be used within StoreProvider");
return context.projectView;
};

View File

@@ -0,0 +1,11 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// types
import type { IProjectStore } from "@/store/project/project.store";
export const useProject = (): IProjectStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useProject must be used within StoreProvider");
return context.projectRoot.project;
};

View File

@@ -0,0 +1,10 @@
import { useContext } from "react";
// store
import { StoreContext } from "@/lib/store-context";
import type { IRouterStore } from "@/store/router.store";
export const useRouterParams = (): IRouterStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useRouterParams must be used within StoreProvider");
return context.router;
};

View File

@@ -0,0 +1,11 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// types
import type { ITransientStore } from "@/store/transient.store";
export const useTransient = (): ITransientStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useTransient must be used within StoreProvider");
return context.transient;
};

View File

@@ -0,0 +1,11 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// types
import type { IWebhookStore } from "@/store/workspace/webhook.store";
export const useWebhook = (): IWebhookStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useWebhook must be used within StoreProvider");
return context.workspaceRoot.webhook;
};

View File

@@ -0,0 +1,11 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// types
import type { IWorkspaceRootStore } from "@/store/workspace";
export const useWorkspace = (): IWorkspaceRootStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useWorkspace must be used within StoreProvider");
return context.workspaceRoot;
};

View File

@@ -0,0 +1,4 @@
export * from "./user-user";
export * from "./user-user-profile";
export * from "./user-user-settings";
export * from "./user-permissions";

View File

@@ -0,0 +1,12 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// plane web imports
import type { IUserPermissionStore } from "@/plane-web/store/user/permission.store";
export const useUserPermissions = (): IUserPermissionStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useUserPermissions must be used within StoreProvider");
return context.user.permission;
};

View File

@@ -0,0 +1,11 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// types
import type { IUserProfileStore } from "@/store/user/profile.store";
export const useUserProfile = (): IUserProfileStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useUserProfile must be used within StoreProvider");
return context.user.userProfile;
};

View File

@@ -0,0 +1,11 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// types
import type { IUserSettingsStore } from "@/store/user/settings.store";
export const useUserSettings = (): IUserSettingsStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useUserSettings must be used within StoreProvider");
return context.user.userSettings;
};

View File

@@ -0,0 +1,11 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// types
import type { IUserStore } from "@/store/user";
export const useUser = (): IUserStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useUser must be used within StoreProvider");
return context.user;
};

View File

@@ -0,0 +1,13 @@
// plane imports
import type { IWorkItemFilterInstance } from "@plane/shared-state";
import type { EIssuesStoreType } from "@plane/types";
// local imports
import { useWorkItemFilters } from "./use-work-item-filters";
export const useWorkItemFilterInstance = (
entityType: EIssuesStoreType,
entityId: string
): IWorkItemFilterInstance | undefined => {
const { getFilter } = useWorkItemFilters();
return getFilter(entityType, entityId);
};

View File

@@ -0,0 +1,11 @@
import { useContext } from "react";
// plane imports
import type { IWorkItemFilterStore } from "@plane/shared-state";
// context
import { StoreContext } from "@/lib/store-context";
export const useWorkItemFilters = (): IWorkItemFilterStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useWorkItemFilters must be used within StoreProvider");
return context.workItemFilters;
};

View File

@@ -0,0 +1,2 @@
export * from "./use-workspace-draft-issue";
export * from "./use-workspace-draft-issue-filters";

View File

@@ -0,0 +1,12 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// types
import type { IWorkspaceDraftIssues } from "@/store/issue/workspace-draft";
export const useWorkspaceDraftIssueFilters = (): IWorkspaceDraftIssues => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useWorkspaceDraftIssueFilters must be used within StoreProvider");
return context.issue.workspaceDraftIssues;
};

View File

@@ -0,0 +1,12 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// types
import type { IWorkspaceDraftIssues } from "@/store/issue/workspace-draft";
export const useWorkspaceDraftIssues = (): IWorkspaceDraftIssues => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useWorkspaceDraftIssues must be used within StoreProvider");
return context.issue.workspaceDraftIssues;
};

View File

@@ -0,0 +1,10 @@
import { useContext } from "react";
import { AppRailContext } from "./context/app-rail-context";
export const useAppRail = () => {
const context = useContext(AppRailContext);
if (context === undefined) {
throw new Error("useAppRail must be used within AppRailProvider");
}
return context;
};

View File

@@ -0,0 +1,4 @@
// router from n-progress-bar
import { useRouter } from "@/lib/b-progress";
export const useAppRouter = () => useRouter();

View File

@@ -0,0 +1,73 @@
import { useEffect, useRef } from "react";
import { debounce } from "lodash-es";
const AUTO_SAVE_TIME = 30000;
const useAutoSave = (handleSaveDescription: () => void) => {
const intervalIdRef = useRef<any>(null);
const handleSaveDescriptionRef = useRef(handleSaveDescription);
// Update the ref to always point to the latest handleSaveDescription
useEffect(() => {
handleSaveDescriptionRef.current = handleSaveDescription;
}, [handleSaveDescription]);
// Set up the interval to run every 10 seconds
useEffect(() => {
intervalIdRef.current = setInterval(() => {
try {
handleSaveDescriptionRef.current();
} catch (error) {
console.error("Autosave before manual save failed:", error);
}
}, AUTO_SAVE_TIME);
return () => {
if (intervalIdRef.current) {
clearInterval(intervalIdRef.current);
}
};
}, []);
// Debounced save function for manual save (Ctrl+S or Cmd+S) and clearing the
// interval for auto save and setting up the interval again
useEffect(() => {
const debouncedSave = debounce(() => {
try {
handleSaveDescriptionRef.current();
} catch (error) {
console.error("Manual save failed:", error);
}
if (intervalIdRef.current) {
clearInterval(intervalIdRef.current);
intervalIdRef.current = setInterval(() => {
try {
handleSaveDescriptionRef.current();
} catch (error) {
console.error("Autosave after manual save failed:", error);
}
}, AUTO_SAVE_TIME);
}
}, 500);
const handleSave = (e: KeyboardEvent) => {
const { ctrlKey, metaKey, key } = e;
const cmdClicked = ctrlKey || metaKey;
if (cmdClicked && key.toLowerCase() === "s") {
e.preventDefault();
e.stopPropagation();
debouncedSave();
}
};
window.addEventListener("keydown", handleSave);
return () => {
window.removeEventListener("keydown", handleSave);
};
}, []);
};
export default useAutoSave;

View File

@@ -0,0 +1,123 @@
import type { RefObject } from "react";
import { useEffect, useRef } from "react";
const SCROLL_BY = 3;
const AUTO_SCROLL_THRESHOLD = 15;
const MAX_SPEED_THRESHOLD = 5;
export const useAutoScroller = (
containerRef: RefObject<HTMLDivElement>,
shouldScroll = false,
leftOffset = 0,
topOffset = 0
) => {
const containerDimensions = useRef<DOMRect | undefined>();
const intervalId = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
const mousePosition = useRef<{ clientX: number; clientY: number } | undefined>(undefined);
const clearRegisteredTimeout = () => {
clearInterval(intervalId.current);
};
const onDragEnd = () => clearRegisteredTimeout();
const handleAutoScroll = (e: MouseEvent) => {
const rect = containerDimensions.current;
clearInterval(intervalId.current);
if (!rect || !shouldScroll || (e.clientX === 0 && e.clientY === 0)) return;
let diffX = 0,
diffY = 0;
if (mousePosition.current) {
diffX = e.clientX - mousePosition.current.clientX;
diffY = e.clientY - mousePosition.current.clientY;
}
mousePosition.current = { clientX: e.clientX, clientY: e.clientY };
const { left, top, width, height } = rect;
const mouseX = e.clientX - left - leftOffset;
const mouseY = e.clientY - top - topOffset;
const currWidth = width - leftOffset;
const currHeight = height - topOffset;
// Get Threshold in percentages
const thresholdX = (currWidth / 100) * AUTO_SCROLL_THRESHOLD;
const thresholdY = (currHeight / 100) * AUTO_SCROLL_THRESHOLD;
const maxSpeedX = (currWidth / 100) * MAX_SPEED_THRESHOLD;
const maxSpeedY = (currHeight / 100) * MAX_SPEED_THRESHOLD;
let scrollByX = 0,
scrollByY = 0;
// Check mouse positions against thresholds
if (mouseX < thresholdX && diffX <= 0) {
scrollByX = -1 * SCROLL_BY;
if (mouseX < maxSpeedX) {
scrollByX *= 2;
}
}
if (mouseX > currWidth - thresholdX && diffX >= 0) {
scrollByX = SCROLL_BY;
if (mouseX > currWidth - maxSpeedX) {
scrollByX *= 2;
}
}
if (mouseY < thresholdY && diffY <= 0) {
scrollByY = -1 * SCROLL_BY;
if (mouseX < maxSpeedY) {
scrollByY *= 2;
}
}
if (mouseY > currHeight - thresholdY && diffY >= 0) {
scrollByY = SCROLL_BY;
if (mouseY > currHeight - maxSpeedY) {
scrollByY *= 2;
}
}
// if mouse position breaches threshold, then start to scroll
if (scrollByX || scrollByY) {
containerRef.current?.scrollBy(scrollByX, scrollByY);
intervalId.current = setInterval(() => {
containerRef.current?.scrollBy(scrollByX, scrollByY);
}, 16);
}
};
useEffect(() => {
const containerElement = containerRef.current;
if (!containerElement || !shouldScroll) return;
containerElement.addEventListener("drag", handleAutoScroll);
containerElement.addEventListener("mousemove", handleAutoScroll);
document.addEventListener("mouseup", onDragEnd);
document.addEventListener("dragend", onDragEnd);
return () => {
containerElement?.removeEventListener("drag", handleAutoScroll);
containerElement?.removeEventListener("mousemove", handleAutoScroll);
document.removeEventListener("mouseup", onDragEnd);
document.removeEventListener("dragend", onDragEnd);
};
}, [shouldScroll, intervalId]);
useEffect(() => {
const containerElement = containerRef.current;
if (!containerElement || !shouldScroll) {
clearRegisteredTimeout();
containerDimensions.current = undefined;
}
containerDimensions.current = containerElement?.getBoundingClientRect();
}, [shouldScroll]);
};

View File

@@ -0,0 +1,109 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import type { EditorRefApi, TDocumentEventsServer } from "@plane/editor";
import type { TDocumentEventsClient } from "@plane/editor/lib";
import { DocumentCollaborativeEvents, getServerEventName } from "@plane/editor/lib";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
// store
import type { TPageInstance } from "@/store/pages/base-page";
export type CollaborativeAction = {
execute: (shouldSync?: boolean, recursive?: boolean) => Promise<void>;
errorMessage: string;
};
type CollaborativeActionEvent =
| { type: "sendMessageToServer"; message: TDocumentEventsServer; recursive?: boolean }
| { type: "receivedMessageFromServer"; message: TDocumentEventsClient };
type Props = {
page: TPageInstance;
};
export const useCollaborativePageActions = (props: Props) => {
const { page } = props;
const editorRef = page.editor.editorRef;
// currentUserAction local state to track if the current action is being processed, a
// local action is basically the action performed by the current user to avoid double operations
const [currentActionBeingProcessed, setCurrentActionBeingProcessed] = useState<TDocumentEventsClient | null>(null);
// @ts-expect-error - TODO: fix this
const actionHandlerMap: Record<TDocumentEventsClient, CollaborativeAction> = useMemo(
() => ({
[DocumentCollaborativeEvents.lock.client]: {
execute: (shouldSync?: boolean, recursive?: boolean) => page.lock({ shouldSync, recursive }),
errorMessage: "Page could not be locked. Please try again later.",
},
[DocumentCollaborativeEvents.unlock.client]: {
execute: (shouldSync?: boolean, recursive?: boolean) => page.unlock({ shouldSync, recursive }),
errorMessage: "Page could not be unlocked. Please try again later.",
},
[DocumentCollaborativeEvents.archive.client]: {
execute: (shouldSync?: boolean) => page.archive({ shouldSync }),
errorMessage: "Page could not be archived. Please try again later.",
},
[DocumentCollaborativeEvents.unarchive.client]: {
execute: (shouldSync?: boolean) => page.restore({ shouldSync }),
errorMessage: "Page could not be restored. Please try again later.",
},
[DocumentCollaborativeEvents["make-public"].client]: {
execute: (shouldSync?: boolean) => page.makePublic({ shouldSync }),
errorMessage: "Page could not be made public. Please try again later.",
},
[DocumentCollaborativeEvents["make-private"].client]: {
execute: (shouldSync?: boolean) => page.makePrivate({ shouldSync }),
errorMessage: "Page could not be made private. Please try again later.",
},
}),
[page]
);
const executeCollaborativeAction = useCallback(
async (event: CollaborativeActionEvent) => {
const isPerformedByCurrentUser = event.type === "sendMessageToServer";
const clientAction = isPerformedByCurrentUser ? DocumentCollaborativeEvents[event.message].client : event.message;
const actionDetails = actionHandlerMap[clientAction];
try {
await actionDetails.execute(isPerformedByCurrentUser, isPerformedByCurrentUser ? event?.recursive : undefined);
if (isPerformedByCurrentUser) {
const serverEventName = getServerEventName(clientAction);
if (serverEventName) {
editorRef?.emitRealTimeUpdate(serverEventName);
}
}
} catch {
if (actionDetails?.errorMessage) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: actionDetails.errorMessage,
});
}
}
},
[actionHandlerMap, editorRef]
);
useEffect(() => {
const realTimeStatelessMessageListener = editorRef?.listenToRealTimeUpdate();
const handleStatelessMessage = (message: { payload: TDocumentEventsClient }) => {
if (currentActionBeingProcessed === message.payload) {
setCurrentActionBeingProcessed(null);
return;
}
if (message.payload) {
executeCollaborativeAction({ type: "receivedMessageFromServer", message: message.payload });
}
};
realTimeStatelessMessageListener?.on("stateless", handleStatelessMessage);
return () => {
realTimeStatelessMessageListener?.off("stateless", handleStatelessMessage);
};
}, [editorRef, currentActionBeingProcessed, executeCollaborativeAction]);
return {
executeCollaborativeAction,
};
};

View File

@@ -0,0 +1,17 @@
import { useEffect, useState } from "react";
export const useCurrentTime = () => {
const [currentTime, setCurrentTime] = useState(new Date());
// update the current time every second
useEffect(() => {
const intervalId = setInterval(() => {
setCurrentTime(new Date());
}, 1000);
return () => clearInterval(intervalId);
}, []);
return {
currentTime,
};
};

View File

@@ -0,0 +1,19 @@
import { useState, useEffect } from "react";
const useDebounce = (value: any, milliSeconds: number) => {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, milliSeconds);
return () => {
clearTimeout(handler);
};
}, [value, milliSeconds]);
return debouncedValue;
};
export default useDebounce;

View File

@@ -0,0 +1,36 @@
import { useCallback } from "react";
type TUseDropdownKeyDown = {
(
onEnterKeyDown: () => void,
onEscKeyDown: () => void,
stopPropagation?: boolean
): (event: React.KeyboardEvent<HTMLElement>) => void;
};
export const useDropdownKeyDown: TUseDropdownKeyDown = (onEnterKeyDown, onEscKeyDown, stopPropagation = true) => {
const stopEventPropagation = useCallback(
(event: React.KeyboardEvent<HTMLElement>) => {
if (stopPropagation) {
event.stopPropagation();
event.preventDefault();
}
},
[stopPropagation]
);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLElement>) => {
if (event.key === "Enter" && !event.nativeEvent.isComposing) {
stopEventPropagation(event);
onEnterKeyDown();
} else if (event.key === "Escape") {
stopEventPropagation(event);
onEscKeyDown();
} else if (event.key === "Tab") onEscKeyDown();
},
[onEnterKeyDown, onEscKeyDown, stopEventPropagation]
);
return handleKeyDown;
};

View File

@@ -0,0 +1,80 @@
import { useEffect } from "react";
// plane helpers
import { useOutsideClickDetector } from "@plane/hooks";
// hooks
import { useDropdownKeyDown } from "@/hooks/use-dropdown-key-down";
import { usePlatformOS } from "./use-platform-os";
type TArguments = {
dropdownRef: React.RefObject<HTMLDivElement>;
inputRef?: React.RefObject<HTMLInputElement | null>;
isOpen: boolean;
onClose?: () => void;
onOpen?: () => Promise<void> | void;
query?: string;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
setQuery?: React.Dispatch<React.SetStateAction<string>>;
};
export const useDropdown = (args: TArguments) => {
const { dropdownRef, inputRef, isOpen, onClose, onOpen, query, setIsOpen, setQuery } = args;
const { isMobile } = usePlatformOS();
/**
* @description clear the search input when the user presses the escape key, if the search input is not empty
* @param {React.KeyboardEvent<HTMLInputElement>} e
*/
const searchInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (query !== "" && e.key === "Escape") {
e.stopPropagation();
setQuery?.("");
}
};
/**
* @description close the dropdown, clear the search input, and call the onClose callback
*/
const handleClose = () => {
if (!isOpen) return;
setIsOpen(false);
onClose?.();
setQuery?.("");
};
// toggle the dropdown, call the onOpen callback if the dropdown is closed, and call the onClose callback if the dropdown is open
const toggleDropdown = () => {
if (!isOpen) onOpen?.();
setIsOpen((prevIsOpen) => !prevIsOpen);
if (isOpen) onClose?.();
};
const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose);
/**
* @description toggle the dropdown on click
* @param {React.MouseEvent<HTMLButtonElement, MouseEvent>} e
*/
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
e.preventDefault();
toggleDropdown();
};
// close the dropdown when the user clicks outside of the dropdown
useOutsideClickDetector(dropdownRef, handleClose);
// focus the search input when the dropdown is open
useEffect(() => {
if (isOpen && inputRef?.current && !isMobile) {
inputRef.current.focus();
}
}, [inputRef, isOpen, isMobile]);
return {
handleClose,
handleKeyDown,
handleOnClick,
searchInputKeyDown,
};
};

View File

@@ -0,0 +1,50 @@
import type React from "react";
import { useEffect } from "react";
const useExtendedSidebarOutsideClickDetector = (
ref: React.RefObject<HTMLElement>,
callback: () => void,
targetId: string
) => {
const handleClick = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
// check for the closest element with attribute name data-prevent-outside-click
const preventOutsideClickElement = (event.target as HTMLElement | undefined)?.closest(
"[data-prevent-outside-click]"
);
// if the closest element with attribute name data-prevent-outside-click is found, return
if (preventOutsideClickElement) {
return;
}
// check if the click target is the current issue element or its children
let targetElement = event.target as HTMLElement | null;
while (targetElement) {
if (targetElement.id === targetId) {
// if the click target is the current issue element, return
return;
}
targetElement = targetElement.parentElement;
}
const delayOutsideClickElement = (event.target as HTMLElement | undefined)?.closest("[data-delay-outside-click]");
if (delayOutsideClickElement) {
// if the click target is the closest element with attribute name data-delay-outside-click, delay the callback
setTimeout(() => {
callback();
}, 0);
return;
}
// else, call the callback immediately
callback();
}
};
useEffect(() => {
document.addEventListener("mousedown", handleClick);
return () => {
document.removeEventListener("mousedown", handleClick);
};
}, []);
};
export default useExtendedSidebarOutsideClickDetector;

View File

@@ -0,0 +1,77 @@
// plane imports
import type { IFavorite } from "@plane/types";
// components
import { getPageName } from "@plane/utils";
import {
generateFavoriteItemLink,
getFavoriteItemIcon,
} from "@/components/workspace/sidebar/favorites/favorite-items/common";
// helpers
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
import { useModule } from "@/hooks/store/use-module";
import { useProject } from "@/hooks/store/use-project";
import { useProjectView } from "@/hooks/store/use-project-view";
// plane web hooks
import { EPageStoreType, usePage } from "@/plane-web/hooks/store";
import { useAdditionalFavoriteItemDetails } from "@/plane-web/hooks/use-additional-favorite-item-details";
export const useFavoriteItemDetails = (workspaceSlug: string, favorite: IFavorite) => {
const {
entity_identifier: favoriteItemId,
entity_data: { logo_props: favoriteItemLogoProps },
entity_type: favoriteItemEntityType,
} = favorite;
const favoriteItemName = favorite?.entity_data?.name || favorite?.name;
// store hooks
const { getViewById } = useProjectView();
const { getProjectById } = useProject();
const { getCycleById } = useCycle();
const { getModuleById } = useModule();
// additional details
const { getAdditionalFavoriteItemDetails } = useAdditionalFavoriteItemDetails();
// derived values
const pageDetail = usePage({
pageId: favoriteItemId ?? "",
storeType: EPageStoreType.PROJECT,
});
const viewDetails = getViewById(favoriteItemId ?? "");
const cycleDetail = getCycleById(favoriteItemId ?? "");
const moduleDetail = getModuleById(favoriteItemId ?? "");
const currentProjectDetails = getProjectById(favorite.project_id ?? "");
let itemIcon;
let itemTitle;
const itemLink = generateFavoriteItemLink(workspaceSlug.toString(), favorite);
switch (favoriteItemEntityType) {
case "project":
itemTitle = currentProjectDetails?.name ?? favoriteItemName;
itemIcon = getFavoriteItemIcon("project", currentProjectDetails?.logo_props || favoriteItemLogoProps);
break;
case "page":
itemTitle = getPageName(pageDetail?.name ?? favoriteItemName);
itemIcon = getFavoriteItemIcon("page", pageDetail?.logo_props ?? favoriteItemLogoProps);
break;
case "view":
itemTitle = viewDetails?.name ?? favoriteItemName;
itemIcon = getFavoriteItemIcon("view", viewDetails?.logo_props || favoriteItemLogoProps);
break;
case "cycle":
itemTitle = cycleDetail?.name ?? favoriteItemName;
itemIcon = getFavoriteItemIcon("cycle");
break;
case "module":
itemTitle = moduleDetail?.name ?? favoriteItemName;
itemIcon = getFavoriteItemIcon("module");
break;
default: {
const additionalDetails = getAdditionalFavoriteItemDetails(workspaceSlug, favorite);
itemTitle = additionalDetails.itemTitle;
itemIcon = additionalDetails.itemIcon;
break;
}
}
return { itemIcon, itemTitle, itemLink };
};

View File

@@ -0,0 +1,124 @@
"use client";
import { useParams } from "next/navigation";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { EIssuesStoreType, TIssue, TIssueGroupByOptions, TIssueOrderByOptions } from "@plane/types";
import type { GroupDropLocation } from "@/components/issues/issue-layouts/utils";
import { handleGroupDragDrop } from "@/components/issues/issue-layouts/utils";
import { ISSUE_FILTER_DEFAULT_DATA } from "@/store/issue/helpers/base-issues.store";
import { useIssueDetail } from "./store/use-issue-detail";
import { useIssues } from "./store/use-issues";
import { useIssuesActions } from "./use-issues-actions";
type DNDStoreType =
| EIssuesStoreType.PROJECT
| EIssuesStoreType.MODULE
| EIssuesStoreType.CYCLE
| EIssuesStoreType.PROJECT_VIEW
| EIssuesStoreType.PROFILE
| EIssuesStoreType.ARCHIVED
| EIssuesStoreType.WORKSPACE_DRAFT
| EIssuesStoreType.TEAM
| EIssuesStoreType.TEAM_VIEW
| EIssuesStoreType.EPIC
| EIssuesStoreType.TEAM_PROJECT_WORK_ITEMS;
export const useGroupIssuesDragNDrop = (
storeType: DNDStoreType,
orderBy: TIssueOrderByOptions | undefined,
groupBy: TIssueGroupByOptions | undefined,
subGroupBy?: TIssueGroupByOptions
) => {
const { workspaceSlug } = useParams();
const {
issue: { getIssueById },
} = useIssueDetail();
const { updateIssue } = useIssuesActions(storeType);
const {
issues: { getIssueIds, addCycleToIssue, removeCycleFromIssue, changeModulesInIssue },
} = useIssues(storeType);
/**
* update Issue on Drop, checks if modules or cycles are changed and then calls appropriate functions
* @param projectId
* @param issueId
* @param data
* @param issueUpdates
*/
const updateIssueOnDrop = async (
projectId: string,
issueId: string,
data: Partial<TIssue>,
issueUpdates: {
[groupKey: string]: {
ADD: string[];
REMOVE: string[];
};
}
) => {
const errorToastProps = {
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Error while updating work item",
};
const moduleKey = ISSUE_FILTER_DEFAULT_DATA["module"];
const cycleKey = ISSUE_FILTER_DEFAULT_DATA["cycle"];
const isModuleChanged = Object.keys(data).includes(moduleKey);
const isCycleChanged = Object.keys(data).includes(cycleKey);
if (isCycleChanged && workspaceSlug) {
if (data[cycleKey]) {
addCycleToIssue(workspaceSlug.toString(), projectId, data[cycleKey]?.toString() ?? "", issueId).catch(() =>
setToast(errorToastProps)
);
} else {
removeCycleFromIssue(workspaceSlug.toString(), projectId, issueId).catch(() => setToast(errorToastProps));
}
delete data[cycleKey];
}
if (isModuleChanged && workspaceSlug && issueUpdates[moduleKey]) {
changeModulesInIssue(
workspaceSlug.toString(),
projectId,
issueId,
issueUpdates[moduleKey].ADD,
issueUpdates[moduleKey].REMOVE
).catch(() => setToast(errorToastProps));
delete data[moduleKey];
}
updateIssue && updateIssue(projectId, issueId, data).catch(() => setToast(errorToastProps));
};
const handleOnDrop = async (source: GroupDropLocation, destination: GroupDropLocation) => {
if (
source.columnId &&
destination.columnId &&
destination.columnId === source.columnId &&
destination.id === source.id
)
return;
await handleGroupDragDrop(
source,
destination,
getIssueById,
getIssueIds,
updateIssueOnDrop,
groupBy,
subGroupBy,
orderBy !== "sort_order"
).catch((err) => {
setToast({
title: "Error!",
type: TOAST_TYPE.ERROR,
message: err?.detail ?? "Failed to perform this action",
});
});
};
return handleOnDrop;
};

View File

@@ -0,0 +1,62 @@
import { useRef, useState } from "react";
import { useParams } from "next/navigation";
const useIntegrationPopup = ({
provider,
stateParams,
github_app_name,
slack_client_id,
}: {
provider: string | undefined;
stateParams?: string;
github_app_name?: string;
slack_client_id?: string;
}) => {
const [authLoader, setAuthLoader] = useState(false);
const { workspaceSlug, projectId } = useParams();
const providerUrls: { [key: string]: string } = {
github: `https://github.com/apps/${github_app_name}/installations/new?state=${workspaceSlug?.toString()}`,
slack: `https://slack.com/oauth/v2/authorize?scope=chat:write,im:history,im:write,links:read,links:write,users:read,users:read.email&amp;user_scope=&amp;&client_id=${slack_client_id}&state=${workspaceSlug?.toString()}`,
slackChannel: `https://slack.com/oauth/v2/authorize?scope=incoming-webhook&client_id=${slack_client_id}&state=${workspaceSlug?.toString()},${projectId?.toString()}${
stateParams ? "," + stateParams : ""
}`,
};
const popup = useRef<any>();
const checkPopup = () => {
const check = setInterval(() => {
if (!popup || popup.current.closed || popup.current.closed === undefined) {
clearInterval(check);
setAuthLoader(false);
}
}, 1000);
};
const openPopup = () => {
if (!provider) return;
const width = 600,
height = 600;
const left = window.innerWidth / 2 - width / 2;
const top = window.innerHeight / 2 - height / 2;
const url = providerUrls[provider];
return window.open(url, "", `width=${width}, height=${height}, top=${top}, left=${left}`);
};
const startAuth = () => {
popup.current = openPopup();
checkPopup();
setAuthLoader(true);
};
return {
startAuth,
isConnecting: authLoader,
};
};
export default useIntegrationPopup;

View File

@@ -0,0 +1,42 @@
import type { RefObject } from "react";
import { useEffect } from "react";
export type UseIntersectionObserverProps = {
containerRef: RefObject<HTMLDivElement | null> | undefined;
elementRef: HTMLElement | null;
callback: () => void;
rootMargin?: string;
};
export const useIntersectionObserver = (
containerRef: RefObject<HTMLDivElement | null>,
elementRef: HTMLElement | null,
callback: (() => void) | undefined,
rootMargin?: string
) => {
useEffect(() => {
if (elementRef) {
const observer = new IntersectionObserver(
(entries) => {
if (entries[entries.length - 1].isIntersecting) {
callback && callback();
}
},
{
root: containerRef?.current,
rootMargin,
}
);
observer.observe(elementRef);
return () => {
if (elementRef) {
// eslint-disable-next-line react-hooks/exhaustive-deps
observer.unobserve(elementRef);
}
};
}
// When i am passing callback as a dependency, it is causing infinite loop,
// Please make sure you fix this eslint lint disable error with caution
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rootMargin, callback, elementRef, containerRef.current]);
};

View File

@@ -0,0 +1,43 @@
import { createContext, useContext } from "react";
import { useParams } from "next/navigation";
import { EIssuesStoreType } from "@plane/types";
import { useIssues } from "./store/use-issues";
export const IssuesStoreContext = createContext<EIssuesStoreType | undefined>(undefined);
export const useIssueStoreType = () => {
const storeType = useContext(IssuesStoreContext);
const { globalViewId, viewId, projectId, cycleId, moduleId, userId, epicId, teamspaceId } = useParams();
// If store type exists in context, use that store type
if (storeType) return storeType;
// else check the router params to determine the issue store
if (globalViewId) return EIssuesStoreType.GLOBAL;
if (userId) return EIssuesStoreType.PROFILE;
if (teamspaceId && viewId) return EIssuesStoreType.TEAM_VIEW;
if (teamspaceId && projectId) return EIssuesStoreType.TEAM_PROJECT_WORK_ITEMS;
if (viewId) return EIssuesStoreType.PROJECT_VIEW;
if (cycleId) return EIssuesStoreType.CYCLE;
if (moduleId) return EIssuesStoreType.MODULE;
if (epicId) return EIssuesStoreType.EPIC;
if (projectId) return EIssuesStoreType.PROJECT;
if (teamspaceId) return EIssuesStoreType.TEAM;
return EIssuesStoreType.PROJECT;
};
export const useIssuesStore = () => {
const storeType = useIssueStoreType();
return useIssues(storeType);
};

View File

@@ -0,0 +1,51 @@
import { useRouter } from "next/navigation";
// types
import type { TIssue } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
// helpers
import { generateWorkItemLink } from "@plane/utils";
// hooks
import { useIssueDetail } from "./store/use-issue-detail";
import { useProject } from "./store/use-project";
const useIssuePeekOverviewRedirection = (isEpic: boolean = false) => {
// router
const router = useRouter();
// store hooks
const { getIsIssuePeeked, setPeekIssue } = useIssueDetail(
isEpic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES
);
const { getProjectIdentifierById } = useProject();
const handleRedirection = (
workspaceSlug: string | undefined,
issue: TIssue | undefined,
isMobile = false,
nestingLevel?: number
) => {
if (!issue) return;
const { project_id, id, archived_at, tempId } = issue;
const projectIdentifier = getProjectIdentifierById(issue?.project_id);
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: project_id,
issueId: id,
projectIdentifier,
sequenceId: issue?.sequence_id,
isEpic,
isArchived: !!archived_at,
});
if (workspaceSlug && project_id && id && !getIsIssuePeeked(id) && !tempId) {
if (isMobile) {
router.push(workItemLink);
} else {
setPeekIssue({ workspaceSlug, projectId: project_id, issueId: id, nestingLevel, isArchived: !!archived_at });
}
}
};
return { handleRedirection };
};
export default useIssuePeekOverviewRedirection;

View File

@@ -0,0 +1,815 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { useCallback, useMemo } from "react";
// types
import { useParams } from "next/navigation";
import type { TSupportedFilterTypeForUpdate } from "@plane/constants";
import { EDraftIssuePaginationType } from "@plane/constants";
import type {
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IssuePaginationOptions,
TIssue,
TIssuesResponse,
TLoader,
TProfileViews,
TSupportedFilterForUpdate,
} from "@plane/types";
import { EIssuesStoreType } from "@plane/types";
import {
useTeamIssueActions,
useTeamProjectWorkItemsActions,
useTeamViewIssueActions,
} from "@/plane-web/helpers/issue-action-helper";
import { useIssues } from "./store/use-issues";
export interface IssueActions {
fetchIssues: (
loadType: TLoader,
options: IssuePaginationOptions,
viewId?: string
) => Promise<TIssuesResponse | undefined>;
fetchNextIssues: (groupId?: string, subGroupId?: string) => Promise<TIssuesResponse | undefined>;
removeIssue: (projectId: string | undefined | null, issueId: string) => Promise<void>;
createIssue?: (projectId: string | undefined | null, data: Partial<TIssue>) => Promise<TIssue | undefined>;
quickAddIssue?: (projectId: string | undefined | null, data: TIssue) => Promise<TIssue | undefined>;
updateIssue?: (projectId: string | undefined | null, issueId: string, data: Partial<TIssue>) => Promise<void>;
removeIssueFromView?: (projectId: string | undefined | null, issueId: string) => Promise<void>;
archiveIssue?: (projectId: string | undefined | null, issueId: string) => Promise<void>;
restoreIssue?: (projectId: string | undefined | null, issueId: string) => Promise<void>;
updateFilters: (
projectId: string,
filterType: TSupportedFilterTypeForUpdate,
filters: TSupportedFilterForUpdate
) => Promise<void>;
}
export const useIssuesActions = (storeType: EIssuesStoreType): IssueActions => {
const teamIssueActions = useTeamIssueActions();
const projectIssueActions = useProjectIssueActions();
const projectEpicsActions = useProjectEpicsActions();
const cycleIssueActions = useCycleIssueActions();
const moduleIssueActions = useModuleIssueActions();
const teamViewIssueActions = useTeamViewIssueActions();
const projectViewIssueActions = useProjectViewIssueActions();
const globalIssueActions = useGlobalIssueActions();
const profileIssueActions = useProfileIssueActions();
const archivedIssueActions = useArchivedIssueActions();
const workspaceDraftIssueActions = useWorkspaceDraftIssueActions();
const teamProjectWorkItemsActions = useTeamProjectWorkItemsActions();
switch (storeType) {
case EIssuesStoreType.TEAM_VIEW:
return teamViewIssueActions;
case EIssuesStoreType.PROJECT_VIEW:
return projectViewIssueActions;
case EIssuesStoreType.PROFILE:
return profileIssueActions;
case EIssuesStoreType.TEAM:
return teamIssueActions;
case EIssuesStoreType.ARCHIVED:
return archivedIssueActions;
case EIssuesStoreType.CYCLE:
return cycleIssueActions;
case EIssuesStoreType.MODULE:
return moduleIssueActions;
case EIssuesStoreType.GLOBAL:
return globalIssueActions;
case EIssuesStoreType.WORKSPACE_DRAFT:
//@ts-expect-error type mismatch
return workspaceDraftIssueActions;
case EIssuesStoreType.EPIC:
return projectEpicsActions;
case EIssuesStoreType.TEAM_PROJECT_WORK_ITEMS:
return teamProjectWorkItemsActions;
case EIssuesStoreType.PROJECT:
default:
return projectIssueActions;
}
};
const useProjectIssueActions = () => {
// router
const { workspaceSlug: routerWorkspaceSlug, projectId: routerProjectId } = useParams();
const workspaceSlug = routerWorkspaceSlug?.toString();
const projectId = routerProjectId?.toString();
// store hooks
const { issues, issuesFilter } = useIssues(EIssuesStoreType.PROJECT);
const fetchIssues = useCallback(
async (loadType: TLoader, options: IssuePaginationOptions) => {
if (!workspaceSlug || !projectId) return;
return issues.fetchIssues(workspaceSlug.toString(), projectId.toString(), loadType, options);
},
[issues.fetchIssues, workspaceSlug, projectId]
);
const fetchNextIssues = useCallback(
async (groupId?: string, subGroupId?: string) => {
if (!workspaceSlug || !projectId) return;
return issues.fetchNextIssues(workspaceSlug.toString(), projectId.toString(), groupId, subGroupId);
},
[issues.fetchIssues, workspaceSlug, projectId]
);
const createIssue = useCallback(
async (projectId: string | undefined | null, data: Partial<TIssue>) => {
if (!workspaceSlug || !projectId) return;
return await issues.createIssue(workspaceSlug, projectId, data);
},
[issues.createIssue, workspaceSlug]
);
const quickAddIssue = useCallback(
async (projectId: string | undefined | null, data: TIssue) => {
if (!workspaceSlug || !projectId) return;
return await issues.quickAddIssue(workspaceSlug, projectId, data);
},
[issues.quickAddIssue, workspaceSlug]
);
const updateIssue = useCallback(
async (projectId: string | undefined | null, issueId: string, data: Partial<TIssue>) => {
if (!workspaceSlug || !projectId) return;
return await issues.updateIssue(workspaceSlug, projectId, issueId, data);
},
[issues.updateIssue, workspaceSlug]
);
const removeIssue = useCallback(
async (projectId: string | undefined | null, issueId: string) => {
if (!workspaceSlug || !projectId) return;
return await issues.removeIssue(workspaceSlug, projectId, issueId);
},
[issues.removeIssue, workspaceSlug]
);
const archiveIssue = useCallback(
async (projectId: string | undefined | null, issueId: string) => {
if (!workspaceSlug || !projectId) return;
return await issues.archiveIssue(workspaceSlug, projectId, issueId);
},
[issues.archiveIssue, workspaceSlug]
);
const updateFilters = useCallback(
async (projectId: string, filterType: TSupportedFilterTypeForUpdate, filters: TSupportedFilterForUpdate) => {
if (!workspaceSlug) return;
return await issuesFilter.updateFilters(workspaceSlug, projectId, filterType, filters);
},
[issuesFilter.updateFilters, workspaceSlug]
);
return useMemo(
() => ({
fetchIssues,
fetchNextIssues,
createIssue,
quickAddIssue,
updateIssue,
removeIssue,
archiveIssue,
updateFilters,
}),
[fetchIssues, fetchNextIssues, createIssue, quickAddIssue, updateIssue, removeIssue, archiveIssue, updateFilters]
);
};
const useProjectEpicsActions = () => {
// router
const { workspaceSlug: routerWorkspaceSlug, projectId: routerProjectId } = useParams();
const workspaceSlug = routerWorkspaceSlug?.toString();
const projectId = routerProjectId?.toString();
// store hooks
const { issues, issuesFilter } = useIssues(EIssuesStoreType.EPIC);
const fetchIssues = useCallback(
async (loadType: TLoader, options: IssuePaginationOptions) => {
if (!workspaceSlug || !projectId) return;
return issues.fetchIssues(workspaceSlug.toString(), projectId.toString(), loadType, options);
},
[issues.fetchIssues, workspaceSlug, projectId]
);
const fetchNextIssues = useCallback(
async (groupId?: string, subGroupId?: string) => {
if (!workspaceSlug || !projectId) return;
return issues.fetchNextIssues(workspaceSlug.toString(), projectId.toString(), groupId, subGroupId);
},
[issues.fetchIssues, workspaceSlug, projectId]
);
const createIssue = useCallback(
async (projectId: string | undefined | null, data: Partial<TIssue>) => {
if (!workspaceSlug || !projectId) return;
return await issues.createIssue(workspaceSlug, projectId, data);
},
[issues.createIssue, workspaceSlug]
);
const quickAddIssue = useCallback(
async (projectId: string | undefined | null, data: TIssue) => {
if (!workspaceSlug || !projectId) return;
return await issues.quickAddIssue(workspaceSlug, projectId, data);
},
[issues.quickAddIssue, workspaceSlug]
);
const updateIssue = useCallback(
async (projectId: string | undefined | null, issueId: string, data: Partial<TIssue>) => {
if (!workspaceSlug || !projectId) return;
return await issues.updateIssue(workspaceSlug, projectId, issueId, data);
},
[issues.updateIssue, workspaceSlug]
);
const removeIssue = useCallback(
async (projectId: string | undefined | null, issueId: string) => {
if (!workspaceSlug || !projectId) return;
return await issues.removeIssue(workspaceSlug, projectId, issueId);
},
[issues.removeIssue, workspaceSlug]
);
const archiveIssue = useCallback(
async (projectId: string | undefined | null, issueId: string) => {
if (!workspaceSlug || !projectId) return;
return await issues.archiveIssue(workspaceSlug, projectId, issueId);
},
[issues.archiveIssue, workspaceSlug]
);
const updateFilters = useCallback(
async (projectId: string, filterType: TSupportedFilterTypeForUpdate, filters: TSupportedFilterForUpdate) => {
if (!workspaceSlug) return;
return await issuesFilter.updateFilters(workspaceSlug, projectId, filterType, filters);
},
[issuesFilter.updateFilters, workspaceSlug]
);
return useMemo(
() => ({
fetchIssues,
fetchNextIssues,
createIssue,
quickAddIssue,
updateIssue,
removeIssue,
archiveIssue,
updateFilters,
}),
[fetchIssues, fetchNextIssues, createIssue, quickAddIssue, updateIssue, removeIssue, archiveIssue, updateFilters]
);
};
const useCycleIssueActions = () => {
// router
const { workspaceSlug: routerWorkspaceSlug, projectId: routerProjectId, cycleId: routerCycleId } = useParams();
const workspaceSlug = routerWorkspaceSlug?.toString();
const projectId = routerProjectId?.toString();
const cycleId = routerCycleId?.toString();
// store hooks
const { issues, issuesFilter } = useIssues(EIssuesStoreType.CYCLE);
const fetchIssues = useCallback(
async (loadType: TLoader, options: IssuePaginationOptions, cycleId?: string) => {
if (!workspaceSlug || !projectId || !cycleId) return;
return issues.fetchIssues(workspaceSlug.toString(), projectId.toString(), loadType, options, cycleId.toString());
},
[issues.fetchIssues, workspaceSlug, projectId]
);
const fetchNextIssues = useCallback(
async (groupId?: string, subGroupId?: string) => {
if (!workspaceSlug || !projectId || !cycleId) return;
return issues.fetchNextIssues(
workspaceSlug.toString(),
projectId.toString(),
cycleId.toString(),
groupId,
subGroupId
);
},
[issues.fetchIssues, workspaceSlug, projectId, cycleId]
);
const createIssue = useCallback(
async (projectId: string | undefined | null, data: Partial<TIssue>) => {
if (!cycleId || !workspaceSlug || !projectId) return;
return await issues.createIssue(workspaceSlug, projectId, data, cycleId);
},
[issues.createIssue, cycleId, workspaceSlug]
);
const quickAddIssue = useCallback(
async (projectId: string | undefined | null, data: TIssue) => {
if (!cycleId || !workspaceSlug || !projectId) return;
return await issues.quickAddIssue(workspaceSlug, projectId, data, cycleId);
},
[issues.quickAddIssue, workspaceSlug, cycleId]
);
const updateIssue = useCallback(
async (projectId: string | undefined | null, issueId: string, data: Partial<TIssue>) => {
if (!workspaceSlug || !projectId) return;
return await issues.updateIssue(workspaceSlug, projectId, issueId, data);
},
[issues.updateIssue, workspaceSlug]
);
const removeIssue = useCallback(
async (projectId: string | undefined | null, issueId: string) => {
if (!workspaceSlug || !projectId) return;
return await issues.removeIssue(workspaceSlug, projectId, issueId);
},
[issues.removeIssue, workspaceSlug]
);
const removeIssueFromView = useCallback(
async (projectId: string | undefined | null, issueId: string) => {
if (!cycleId || !workspaceSlug || !projectId) return;
return await issues.removeIssueFromCycle(workspaceSlug, projectId, cycleId, issueId);
},
[issues.removeIssueFromCycle, cycleId, workspaceSlug]
);
const archiveIssue = useCallback(
async (projectId: string | undefined | null, issueId: string) => {
if (!workspaceSlug || !projectId) return;
return await issues.archiveIssue(workspaceSlug, projectId, issueId);
},
[issues.archiveIssue, workspaceSlug]
);
const updateFilters = useCallback(
async (projectId: string, filterType: TSupportedFilterTypeForUpdate, filters: TSupportedFilterForUpdate) => {
if (!cycleId || !workspaceSlug) return;
return await issuesFilter.updateFilters(workspaceSlug, projectId, filterType, filters, cycleId);
},
[issuesFilter.updateFilters, cycleId, workspaceSlug]
);
return useMemo(
() => ({
fetchIssues,
fetchNextIssues,
createIssue,
quickAddIssue,
updateIssue,
removeIssue,
removeIssueFromView,
archiveIssue,
updateFilters,
}),
[
fetchIssues,
fetchNextIssues,
createIssue,
quickAddIssue,
updateIssue,
removeIssue,
removeIssueFromView,
archiveIssue,
updateFilters,
]
);
};
const useModuleIssueActions = () => {
// router
const { workspaceSlug: routerWorkspaceSlug, projectId: routerProjectId, moduleId: routerModuleId } = useParams();
const workspaceSlug = routerWorkspaceSlug?.toString();
const projectId = routerProjectId?.toString();
const moduleId = routerModuleId?.toString();
// store hooks
const { issues, issuesFilter } = useIssues(EIssuesStoreType.MODULE);
const fetchIssues = useCallback(
async (loadType: TLoader, options: IssuePaginationOptions, moduleId?: string) => {
if (!workspaceSlug || !projectId || !moduleId) return;
return issues.fetchIssues(workspaceSlug.toString(), projectId.toString(), loadType, options, moduleId.toString());
},
[issues.fetchIssues, workspaceSlug, projectId]
);
const fetchNextIssues = useCallback(
async (groupId?: string, subGroupId?: string) => {
if (!workspaceSlug || !projectId || !moduleId) return;
return issues.fetchNextIssues(
workspaceSlug.toString(),
projectId.toString(),
moduleId.toString(),
groupId,
subGroupId
);
},
[issues.fetchIssues, workspaceSlug, projectId, moduleId]
);
const createIssue = useCallback(
async (projectId: string | undefined | null, data: Partial<TIssue>) => {
if (!moduleId || !workspaceSlug || !projectId) return;
return await issues.createIssue(workspaceSlug, projectId, data, moduleId);
},
[issues.createIssue, moduleId, workspaceSlug]
);
const quickAddIssue = useCallback(
async (projectId: string | undefined | null, data: TIssue) => {
if (!moduleId || !workspaceSlug || !projectId) return;
return await issues.quickAddIssue(workspaceSlug, projectId, data, moduleId);
},
[issues.quickAddIssue, workspaceSlug, moduleId]
);
const updateIssue = useCallback(
async (projectId: string | undefined | null, issueId: string, data: Partial<TIssue>) => {
if (!workspaceSlug || !projectId) return;
return await issues.updateIssue(workspaceSlug, projectId, issueId, data);
},
[issues.updateIssue, workspaceSlug]
);
const removeIssue = useCallback(
async (projectId: string | undefined | null, issueId: string) => {
if (!workspaceSlug || !projectId) return;
return await issues.removeIssue(workspaceSlug, projectId, issueId);
},
[issues.removeIssue, workspaceSlug]
);
const removeIssueFromView = useCallback(
async (projectId: string | undefined | null, issueId: string) => {
if (!moduleId || !workspaceSlug || !projectId) return;
return await issues.removeIssuesFromModule(workspaceSlug, projectId, moduleId, [issueId]);
},
[issues.removeIssuesFromModule, moduleId, workspaceSlug]
);
const archiveIssue = useCallback(
async (projectId: string | undefined | null, issueId: string) => {
if (!workspaceSlug || !projectId) return;
return await issues.archiveIssue(workspaceSlug, projectId, issueId);
},
[issues.archiveIssue, moduleId, workspaceSlug]
);
const updateFilters = useCallback(
async (projectId: string, filterType: TSupportedFilterTypeForUpdate, filters: TSupportedFilterForUpdate) => {
if (!moduleId || !workspaceSlug) return;
return await issuesFilter.updateFilters(workspaceSlug, projectId, filterType, filters, moduleId);
},
[issuesFilter.updateFilters, moduleId]
);
return useMemo(
() => ({
fetchIssues,
fetchNextIssues,
createIssue,
quickAddIssue,
updateIssue,
removeIssue,
removeIssueFromView,
archiveIssue,
updateFilters,
}),
[fetchIssues, createIssue, updateIssue, removeIssue, removeIssueFromView, archiveIssue, updateFilters]
);
};
const useProfileIssueActions = () => {
// router
const { workspaceSlug: routerWorkspaceSlug, userId: routerUserId } = useParams();
const workspaceSlug = routerWorkspaceSlug?.toString();
const userId = routerUserId?.toString();
// store hooks
const { issues, issuesFilter } = useIssues(EIssuesStoreType.PROFILE);
const fetchIssues = useCallback(
async (loadType: TLoader, options: IssuePaginationOptions, viewId?: string) => {
if (!workspaceSlug || !userId || !viewId) return;
return issues.fetchIssues(
workspaceSlug.toString(),
userId.toString(),
loadType,
options,
viewId as TProfileViews
);
},
[issues.fetchIssues, workspaceSlug, userId]
);
const fetchNextIssues = useCallback(
async (groupId?: string, subGroupId?: string) => {
if (!workspaceSlug || !userId) return;
return issues.fetchNextIssues(workspaceSlug.toString(), userId.toString(), groupId, subGroupId);
},
[issues.fetchIssues, workspaceSlug, userId]
);
const createIssue = useCallback(
async (projectId: string | undefined | null, data: Partial<TIssue>) => {
if (!workspaceSlug || !projectId) return;
return await issues.createIssue(workspaceSlug, projectId, data);
},
[issues.createIssue, workspaceSlug]
);
const updateIssue = useCallback(
async (projectId: string | undefined | null, issueId: string, data: Partial<TIssue>) => {
if (!workspaceSlug || !projectId) return;
return await issues.updateIssue(workspaceSlug, projectId, issueId, data);
},
[issues.updateIssue, workspaceSlug]
);
const removeIssue = useCallback(
async (projectId: string | undefined | null, issueId: string) => {
if (!workspaceSlug || !projectId) return;
return await issues.removeIssue(workspaceSlug, projectId, issueId);
},
[issues.removeIssue, workspaceSlug]
);
const archiveIssue = useCallback(
async (projectId: string | undefined | null, issueId: string) => {
if (!workspaceSlug || !projectId) return;
return await issues.archiveIssue(workspaceSlug, projectId, issueId);
},
[issues.archiveIssue, workspaceSlug]
);
const updateFilters = useCallback(
async (projectId: string, filterType: TSupportedFilterTypeForUpdate, filters: TSupportedFilterForUpdate) => {
if (!userId || !workspaceSlug) return;
return await issuesFilter.updateFilters(workspaceSlug, projectId, filterType, filters, userId);
},
[issuesFilter.updateFilters, userId, workspaceSlug]
);
return useMemo(
() => ({
fetchIssues,
fetchNextIssues,
createIssue,
updateIssue,
removeIssue,
archiveIssue,
updateFilters,
}),
[fetchIssues, createIssue, updateIssue, removeIssue, archiveIssue, updateFilters]
);
};
const useProjectViewIssueActions = () => {
// router
const { workspaceSlug: routerWorkspaceSlug, projectId: routerProjectId, viewId: routerViewId } = useParams();
const workspaceSlug = routerWorkspaceSlug?.toString();
const projectId = routerProjectId?.toString();
const viewId = routerViewId?.toString();
// store hooks
const { issues, issuesFilter } = useIssues(EIssuesStoreType.PROJECT_VIEW);
const fetchIssues = useCallback(
async (loadType: TLoader, options: IssuePaginationOptions, viewId?: string) => {
if (!workspaceSlug || !projectId || !viewId) return;
return issues.fetchIssues(workspaceSlug.toString(), projectId.toString(), viewId, loadType, options);
},
[issues.fetchIssues, workspaceSlug, projectId]
);
const fetchNextIssues = useCallback(
async (groupId?: string, subGroupId?: string) => {
if (!workspaceSlug || !projectId || !viewId) return;
return issues.fetchNextIssues(workspaceSlug.toString(), projectId.toString(), viewId, groupId, subGroupId);
},
[issues.fetchIssues, workspaceSlug, projectId]
);
const createIssue = useCallback(
async (projectId: string | undefined | null, data: Partial<TIssue>) => {
if (!workspaceSlug || !projectId) return;
return await issues.createIssue(workspaceSlug, projectId, data);
},
[issues.createIssue, workspaceSlug]
);
const quickAddIssue = useCallback(
async (projectId: string | undefined | null, data: TIssue) => {
if (!workspaceSlug || !projectId) return;
return await issues.quickAddIssue(workspaceSlug, projectId, data);
},
[issues.quickAddIssue, workspaceSlug]
);
const updateIssue = useCallback(
async (projectId: string | undefined | null, issueId: string, data: Partial<TIssue>) => {
if (!workspaceSlug || !projectId) return;
return await issues.updateIssue(workspaceSlug, projectId, issueId, data);
},
[issues.updateIssue, workspaceSlug]
);
const removeIssue = useCallback(
async (projectId: string | undefined | null, issueId: string) => {
if (!workspaceSlug || !projectId) return;
return await issues.removeIssue(workspaceSlug, projectId, issueId);
},
[issues.removeIssue, workspaceSlug]
);
const archiveIssue = useCallback(
async (projectId: string | undefined | null, issueId: string) => {
if (!workspaceSlug || !projectId) return;
return await issues.archiveIssue(workspaceSlug, projectId, issueId);
},
[issues.archiveIssue, workspaceSlug]
);
const updateFilters = useCallback(
async (projectId: string, filterType: TSupportedFilterTypeForUpdate, filters: TSupportedFilterForUpdate) => {
if (!viewId || !workspaceSlug) return;
return await issuesFilter.updateFilters(workspaceSlug, projectId, filterType, filters, viewId);
},
[issuesFilter.updateFilters, viewId, workspaceSlug]
);
return useMemo(
() => ({
fetchIssues,
fetchNextIssues,
createIssue,
quickAddIssue,
updateIssue,
removeIssue,
archiveIssue,
updateFilters,
}),
[fetchIssues, fetchNextIssues, createIssue, quickAddIssue, updateIssue, removeIssue, archiveIssue, updateFilters]
);
};
const useArchivedIssueActions = () => {
// router
const { workspaceSlug: routerWorkspaceSlug, projectId: routerProjectId } = useParams();
const workspaceSlug = routerWorkspaceSlug?.toString();
const projectId = routerProjectId?.toString();
// store hooks
const { issues, issuesFilter } = useIssues(EIssuesStoreType.ARCHIVED);
const fetchIssues = useCallback(
async (loadType: TLoader, options: IssuePaginationOptions) => {
if (!workspaceSlug || !projectId) return;
return issues.fetchIssues(workspaceSlug.toString(), projectId.toString(), loadType, options);
},
[issues.fetchIssues, workspaceSlug, projectId]
);
const fetchNextIssues = useCallback(
async (groupId?: string, subGroupId?: string) => {
if (!workspaceSlug || !projectId) return;
return issues.fetchNextIssues(workspaceSlug.toString(), projectId.toString(), groupId, subGroupId);
},
[issues.fetchIssues, workspaceSlug, projectId]
);
const removeIssue = useCallback(
async (projectId: string | undefined | null, issueId: string) => {
if (!workspaceSlug || !projectId) return;
return await issues.removeIssue(workspaceSlug, projectId, issueId);
},
[issues.removeIssue]
);
const restoreIssue = useCallback(
async (projectId: string | undefined | null, issueId: string) => {
if (!workspaceSlug || !projectId) return;
return await issues.restoreIssue(workspaceSlug, projectId, issueId);
},
[issues.restoreIssue]
);
const updateFilters = useCallback(
async (projectId: string, filterType: TSupportedFilterTypeForUpdate, filters: TSupportedFilterForUpdate) => {
if (!workspaceSlug) return;
return await issuesFilter.updateFilters(workspaceSlug, projectId, filterType, filters);
},
[issuesFilter.updateFilters]
);
return useMemo(
() => ({
fetchIssues,
fetchNextIssues,
removeIssue,
restoreIssue,
updateFilters,
}),
[fetchIssues, fetchNextIssues, removeIssue, restoreIssue, updateFilters]
);
};
const useGlobalIssueActions = () => {
// router
const { workspaceSlug: routerWorkspaceSlug, globalViewId: routerGlobalViewId } = useParams();
const workspaceSlug = routerWorkspaceSlug?.toString();
const globalViewId = routerGlobalViewId?.toString();
// store hooks
const { issues, issuesFilter } = useIssues(EIssuesStoreType.GLOBAL);
const fetchIssues = useCallback(
async (loadType: TLoader, options: IssuePaginationOptions) => {
if (!workspaceSlug || !globalViewId) return;
return issues.fetchIssues(workspaceSlug.toString(), globalViewId.toString(), loadType, options);
},
[issues.fetchIssues, workspaceSlug, globalViewId]
);
const fetchNextIssues = useCallback(
async (groupId?: string, subGroupId?: string) => {
if (!workspaceSlug || !globalViewId) return;
return issues.fetchNextIssues(workspaceSlug.toString(), globalViewId.toString(), groupId, subGroupId);
},
[issues.fetchIssues, workspaceSlug, globalViewId]
);
const createIssue = useCallback(
async (projectId: string | undefined | null, data: Partial<TIssue>) => {
if (!workspaceSlug || !projectId) return;
return await issues.createIssue(workspaceSlug, projectId, data);
},
[issues.createIssue, workspaceSlug]
);
const updateIssue = useCallback(
async (projectId: string | undefined | null, issueId: string, data: Partial<TIssue>) => {
if (!workspaceSlug || !projectId) return;
return await issues.updateIssue(workspaceSlug, projectId, issueId, data);
},
[issues.updateIssue, workspaceSlug]
);
const removeIssue = useCallback(
async (projectId: string | undefined | null, issueId: string) => {
if (!workspaceSlug || !projectId) return;
return await issues.removeIssue(workspaceSlug, projectId, issueId);
},
[issues.removeIssue, workspaceSlug]
);
const updateFilters = useCallback(
async (projectId: string, filterType: TSupportedFilterTypeForUpdate, filters: TSupportedFilterForUpdate) => {
if (!globalViewId || !workspaceSlug) return;
return await issuesFilter.updateFilters(workspaceSlug, projectId, filterType, filters, globalViewId);
},
[issuesFilter.updateFilters, globalViewId, workspaceSlug]
);
return useMemo(
() => ({
fetchIssues,
fetchNextIssues,
createIssue,
updateIssue,
removeIssue,
updateFilters,
}),
[createIssue, updateIssue, removeIssue, updateFilters]
);
};
const useWorkspaceDraftIssueActions = () => {
// router
const { workspaceSlug: routerWorkspaceSlug, globalViewId: routerGlobalViewId } = useParams();
const workspaceSlug = routerWorkspaceSlug?.toString();
const globalViewId = routerGlobalViewId?.toString();
// store hooks
const { issues, issuesFilter } = useIssues(EIssuesStoreType.WORKSPACE_DRAFT);
const fetchIssues = useCallback(
async (loadType: TLoader, _options: IssuePaginationOptions) => {
if (!workspaceSlug) return;
return issues.fetchIssues(workspaceSlug.toString(), loadType, EDraftIssuePaginationType.INIT);
},
[workspaceSlug, issues]
);
const fetchNextIssues = useCallback(async () => {
if (!workspaceSlug) return;
return issues.fetchIssues(workspaceSlug.toString(), "pagination", EDraftIssuePaginationType.NEXT);
}, [workspaceSlug, issues]);
const createIssue = useCallback(
async (projectId: string | undefined | null, data: Partial<TIssue>) => {
if (!workspaceSlug || !projectId) return;
return await issues.createIssue(workspaceSlug, data);
},
[issues, workspaceSlug]
);
const updateIssue = useCallback(
async (projectId: string | undefined | null, issueId: string, data: Partial<TIssue>) => {
if (!workspaceSlug || !projectId) return;
return await issues.updateIssue(workspaceSlug, issueId, data);
},
[issues, workspaceSlug]
);
const removeIssue = useCallback(
async (projectId: string | undefined | null, issueId: string) => {
if (!workspaceSlug || !projectId) return;
return await issues.removeIssue(issueId);
},
[issues, workspaceSlug]
);
// const moveToIssue = useCallback(
// async (workspaceSlug: string, issueId: string, data: Partial<TIssue>) => {
// if (!workspaceSlug || !issueId || !data) return;
// return await issues.moveToIssues(workspaceSlug, issueId, data);
// },
// [issues]
// );
const updateFilters = useCallback(
async (projectId: string, filterType: TSupportedFilterTypeForUpdate, filters: TSupportedFilterForUpdate) => {
filters = filters as IIssueDisplayFilterOptions | IIssueDisplayProperties;
if (!globalViewId || !workspaceSlug) return;
return await issuesFilter.updateFilters(workspaceSlug, filterType, filters);
},
[globalViewId, workspaceSlug, issuesFilter]
);
return useMemo(
() => ({
fetchIssues,
fetchNextIssues,
createIssue,
updateIssue,
removeIssue,
updateFilters,
}),
[fetchIssues, fetchNextIssues, createIssue, updateIssue, removeIssue, updateFilters]
);
};

View File

@@ -0,0 +1,19 @@
import { useEffect } from "react";
const useKeypress = (key: string, callback: (event: KeyboardEvent) => void) => {
useEffect(() => {
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === key) {
callback(event);
}
};
document.addEventListener("keydown", handleKeydown);
return () => {
document.removeEventListener("keydown", handleKeydown);
};
}, [key, callback]);
};
export default useKeypress;

View File

@@ -0,0 +1,58 @@
import { useState, useEffect, useCallback } from "react";
export const getValueFromLocalStorage = (key: string, defaultValue: any) => {
if (typeof window === undefined || typeof window === "undefined") return defaultValue;
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : defaultValue;
} catch (error) {
window.localStorage.removeItem(key);
return defaultValue;
}
};
export const setValueIntoLocalStorage = (key: string, value: any) => {
if (typeof window === undefined || typeof window === "undefined") return false;
try {
window.localStorage.setItem(key, JSON.stringify(value));
return true;
} catch (error) {
return false;
}
};
// TODO: Remove this once we migrate to the new hooks from plane/helpers
const useLocalStorage = <T,>(key: string, initialValue: T) => {
const [storedValue, setStoredValue] = useState<T | null>(() => getValueFromLocalStorage(key, initialValue));
const setValue = useCallback(
(value: T) => {
window.localStorage.setItem(key, JSON.stringify(value));
setStoredValue(value);
window.dispatchEvent(new Event(`local-storage:${key}`));
},
[key]
);
const clearValue = useCallback(() => {
window.localStorage.removeItem(key);
setStoredValue(null);
window.dispatchEvent(new Event(`local-storage:${key}`));
}, [key]);
const reHydrate = useCallback(() => {
const data = getValueFromLocalStorage(key, initialValue);
setStoredValue(data);
}, [key, initialValue]);
useEffect(() => {
window.addEventListener(`local-storage:${key}`, reHydrate);
return () => {
window.removeEventListener(`local-storage:${key}`, reHydrate);
};
}, [key, reHydrate]);
return { storedValue, setValue, clearValue } as const;
};
export default useLocalStorage;

View File

@@ -0,0 +1,407 @@
"use client";
import { useCallback, useEffect, useMemo } from "react";
// hooks
import { useMultipleSelectStore } from "@/hooks/store/use-multiple-select-store";
//
import useReloadConfirmations from "./use-reload-confirmation";
export type TEntityDetails = {
entityID: string;
groupID: string;
};
type Props = {
containerRef: React.MutableRefObject<HTMLElement | null>;
disabled: boolean;
entities: Record<string, string[]>; // { groupID: entityIds[] }
};
export type TSelectionSnapshot = {
isSelectionActive: boolean;
selectedEntityIds: string[];
};
export type TSelectionHelper = {
handleClearSelection: () => void;
handleEntityClick: (event: React.MouseEvent, entityID: string, groupId: string) => void;
getIsEntitySelected: (entityID: string) => boolean;
getIsEntityActive: (entityID: string) => boolean;
handleGroupClick: (groupID: string) => void;
isGroupSelected: (groupID: string) => "empty" | "partial" | "complete";
isSelectionDisabled: boolean;
};
export const useMultipleSelect = (props: Props) => {
const { containerRef, disabled, entities } = props;
// router
// const router = useAppRouter();
// store hooks
const {
selectedEntityIds,
updateSelectedEntityDetails,
bulkUpdateSelectedEntityDetails,
getActiveEntityDetails,
updateActiveEntityDetails,
getPreviousActiveEntity,
updatePreviousActiveEntity,
getNextActiveEntity,
updateNextActiveEntity,
getLastSelectedEntityDetails,
clearSelection,
getIsEntitySelected,
getIsEntityActive,
getEntityDetailsFromEntityID,
} = useMultipleSelectStore();
useReloadConfirmations(
selectedEntityIds && selectedEntityIds.length > 0,
"Are you sure you want to leave? Your current bulk operation selections will be lost.",
true,
() => {
clearSelection();
}
);
const groups = useMemo(() => Object.keys(entities), [entities]);
const entitiesList: TEntityDetails[] = useMemo(
() =>
groups
?.map((groupID) =>
entities?.[groupID]?.map((entityID) => ({
entityID,
groupID,
}))
)
.flat(1),
[entities, groups]
);
const getPreviousAndNextEntities = useCallback(
(entityID: string) => {
const currentEntityIndex = entitiesList.findIndex((entity) => entity?.entityID === entityID);
// entity position
const isFirstEntity = currentEntityIndex === 0;
const isLastEntity = currentEntityIndex === entitiesList.length - 1;
let previousEntity: TEntityDetails | null = null;
let nextEntity: TEntityDetails | null = null;
if (isLastEntity) {
nextEntity = null;
} else {
nextEntity = entitiesList[currentEntityIndex + 1];
}
if (isFirstEntity) {
previousEntity = null;
} else {
previousEntity = entitiesList[currentEntityIndex - 1];
}
return {
previousEntity,
nextEntity,
};
},
[entitiesList]
);
const handleActiveEntityChange = useCallback(
(entityDetails: TEntityDetails | null, shouldScroll: boolean = true) => {
if (disabled) return;
if (!entityDetails) {
updateActiveEntityDetails(null);
updatePreviousActiveEntity(null);
updateNextActiveEntity(null);
return;
}
updateActiveEntityDetails(entityDetails);
// scroll to get the active element in view
const activeElement = document.querySelector(
`[data-entity-id="${entityDetails.entityID}"][data-entity-group-id="${entityDetails.groupID}"]`
);
if (activeElement && containerRef.current && shouldScroll) {
const SCROLL_OFFSET = 200;
const containerRect = containerRef.current.getBoundingClientRect();
const elementRect = activeElement.getBoundingClientRect();
const isInView =
elementRect.top >= containerRect.top + SCROLL_OFFSET &&
elementRect.bottom <= containerRect.bottom - SCROLL_OFFSET;
if (!isInView) {
containerRef.current.scrollBy({
top: elementRect.top < containerRect.top + SCROLL_OFFSET ? -50 : 50,
});
}
}
const { previousEntity: previousActiveEntity, nextEntity: nextActiveEntity } = getPreviousAndNextEntities(
entityDetails.entityID
);
updatePreviousActiveEntity(previousActiveEntity);
updateNextActiveEntity(nextActiveEntity);
},
[
containerRef,
disabled,
getPreviousAndNextEntities,
updateActiveEntityDetails,
updateNextActiveEntity,
updatePreviousActiveEntity,
]
);
const handleEntitySelection = useCallback(
(
entityDetails: TEntityDetails | TEntityDetails[],
shouldScroll: boolean = true,
forceAction: "force-add" | "force-remove" | null = null
) => {
if (disabled) return;
if (Array.isArray(entityDetails)) {
bulkUpdateSelectedEntityDetails(entityDetails, forceAction === "force-add" ? "add" : "remove");
if (forceAction === "force-add" && entityDetails.length > 0) {
handleActiveEntityChange(entityDetails[entityDetails.length - 1], shouldScroll);
}
return;
}
if (forceAction) {
if (forceAction === "force-add") {
console.log("force adding");
updateSelectedEntityDetails(entityDetails, "add");
handleActiveEntityChange(entityDetails, shouldScroll);
}
if (forceAction === "force-remove") {
updateSelectedEntityDetails(entityDetails, "remove");
}
return;
}
const isSelected = getIsEntitySelected(entityDetails.entityID);
if (isSelected) {
updateSelectedEntityDetails(entityDetails, "remove");
handleActiveEntityChange(entityDetails, shouldScroll);
} else {
updateSelectedEntityDetails(entityDetails, "add");
handleActiveEntityChange(entityDetails, shouldScroll);
}
},
[
bulkUpdateSelectedEntityDetails,
disabled,
getIsEntitySelected,
handleActiveEntityChange,
updateSelectedEntityDetails,
]
);
/**
* @description toggle entity selection
* @param {React.MouseEvent} event
* @param {string} entityID
* @param {string} groupID
*/
const handleEntityClick = useCallback(
(e: React.MouseEvent, entityID: string, groupID: string) => {
if (disabled) return;
const lastSelectedEntityDetails = getLastSelectedEntityDetails();
if (e.shiftKey && lastSelectedEntityDetails) {
const currentEntityIndex = entitiesList.findIndex((entity) => entity?.entityID === entityID);
const lastEntityIndex = entitiesList.findIndex(
(entity) => entity?.entityID === lastSelectedEntityDetails.entityID
);
if (lastEntityIndex < currentEntityIndex) {
for (let i = lastEntityIndex + 1; i <= currentEntityIndex; i++) {
const entityDetails = entitiesList[i];
if (entityDetails) {
handleEntitySelection(entityDetails, false);
}
}
} else if (lastEntityIndex > currentEntityIndex) {
for (let i = currentEntityIndex; i <= lastEntityIndex - 1; i++) {
const entityDetails = entitiesList[i];
if (entityDetails) {
handleEntitySelection(entityDetails, false);
}
}
} else {
const startIndex = lastEntityIndex + 1;
const endIndex = currentEntityIndex;
for (let i = startIndex; i <= endIndex; i++) {
const entityDetails = entitiesList[i];
if (entityDetails) {
handleEntitySelection(entityDetails, false);
}
}
}
return;
}
handleEntitySelection({ entityID, groupID }, false);
},
[disabled, entitiesList, handleEntitySelection, getLastSelectedEntityDetails]
);
/**
* @description check if any entity of the group is selected
* @param {string} groupID
* @returns {boolean}
*/
const isGroupSelected = useCallback(
(groupID: string) => {
const groupEntities = entitiesList.filter((entity) => entity.groupID === groupID);
const totalSelected = groupEntities.filter((entity) => getIsEntitySelected(entity?.entityID ?? "")).length;
if (totalSelected === 0) return "empty";
if (totalSelected === groupEntities.length) return "complete";
return "partial";
},
[entitiesList, getIsEntitySelected]
);
/**
* @description toggle group selection
* @param {string} groupID
*/
const handleGroupClick = useCallback(
(groupID: string) => {
if (disabled) return;
const groupEntities = entitiesList.filter((entity) => entity.groupID === groupID);
const groupSelectionStatus = isGroupSelected(groupID);
handleEntitySelection(groupEntities, false, groupSelectionStatus === "empty" ? "force-add" : "force-remove");
},
[disabled, entitiesList, handleEntitySelection, isGroupSelected]
);
// select entities on shift + arrow up/down key press
useEffect(() => {
if (disabled) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (!e.shiftKey) return;
const activeEntityDetails = getActiveEntityDetails();
const nextActiveEntity = getNextActiveEntity();
const previousActiveEntity = getPreviousActiveEntity();
if (e.key === "ArrowDown" && activeEntityDetails) {
if (!nextActiveEntity) return;
handleEntitySelection(nextActiveEntity);
}
if (e.key === "ArrowUp" && activeEntityDetails) {
if (!previousActiveEntity) return;
handleEntitySelection(previousActiveEntity);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [
disabled,
getActiveEntityDetails,
handleEntitySelection,
getLastSelectedEntityDetails,
getNextActiveEntity,
getPreviousActiveEntity,
]);
useEffect(() => {
if (disabled) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.shiftKey) return;
const activeEntityDetails = getActiveEntityDetails();
// set active entity id to the first entity
if (["ArrowUp", "ArrowDown"].includes(e.key) && !activeEntityDetails) {
const firstElementDetails = entitiesList[0];
if (!firstElementDetails) return;
handleActiveEntityChange(firstElementDetails);
}
if (e.key === "ArrowDown" && activeEntityDetails) {
if (!activeEntityDetails) return;
const { nextEntity: nextActiveEntity } = getPreviousAndNextEntities(activeEntityDetails.entityID);
if (nextActiveEntity) {
handleActiveEntityChange(nextActiveEntity);
}
}
if (e.key === "ArrowUp" && activeEntityDetails) {
if (!activeEntityDetails) return;
const { previousEntity: previousActiveEntity } = getPreviousAndNextEntities(activeEntityDetails.entityID);
if (previousActiveEntity) {
handleActiveEntityChange(previousActiveEntity);
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [disabled, getActiveEntityDetails, entitiesList, groups, getPreviousAndNextEntities, handleActiveEntityChange]);
// clear selection on route change
// useEffect(() => {
// const handleRouteChange = () => clearSelection();
// router.events.on("routeChangeComplete", handleRouteChange);
// return () => {
// router.events.off("routeChangeComplete", handleRouteChange);
// };
// }, [clearSelection, router.events]);
// when entities list change, remove entityIds from the selected entities array, which are not present in the new list
useEffect(() => {
if (disabled) return;
selectedEntityIds.map((entityID) => {
const isEntityPresent = entitiesList.find((en) => en?.entityID === entityID);
if (!isEntityPresent) {
const entityDetails = getEntityDetailsFromEntityID(entityID);
if (entityDetails) {
handleEntitySelection(entityDetails);
}
}
});
}, [disabled, entitiesList, getEntityDetailsFromEntityID, handleEntitySelection, selectedEntityIds]);
/**
* @description helper functions for selection
*/
const helpers: TSelectionHelper = useMemo(
() => ({
handleClearSelection: clearSelection,
handleEntityClick,
getIsEntitySelected,
getIsEntityActive,
handleGroupClick,
isGroupSelected,
isSelectionDisabled: disabled,
}),
[
clearSelection,
disabled,
getIsEntityActive,
getIsEntitySelected,
handleEntityClick,
handleGroupClick,
isGroupSelected,
]
);
return helpers;
};

View File

@@ -0,0 +1,22 @@
import { useState, useEffect } from "react";
const useOnlineStatus = () => {
// states
const [isOnline, setIsOnline] = useState(typeof navigator !== "undefined" ? navigator.onLine : true);
const updateOnlineStatus = () => setIsOnline(navigator.onLine);
useEffect(() => {
window.addEventListener("online", updateOnlineStatus);
window.addEventListener("offline", updateOnlineStatus);
return () => {
window.removeEventListener("online", updateOnlineStatus);
window.removeEventListener("offline", updateOnlineStatus);
};
}, []);
return { isOnline };
};
export default useOnlineStatus;

View File

@@ -0,0 +1,56 @@
import { useCallback, useEffect } from "react";
// plane editor
import { getBinaryDataFromDocumentEditorHTMLString } from "@plane/editor";
import type { EditorRefApi } from "@plane/editor";
// plane types
import type { TDocumentPayload } from "@plane/types";
// hooks
import useAutoSave from "@/hooks/use-auto-save";
type TArgs = {
editorRef: React.RefObject<EditorRefApi>;
fetchPageDescription: () => Promise<ArrayBuffer>;
hasConnectionFailed: boolean;
updatePageDescription: (data: TDocumentPayload) => Promise<void>;
};
export const usePageFallback = (args: TArgs) => {
const { editorRef, fetchPageDescription, hasConnectionFailed, updatePageDescription } = args;
const handleUpdateDescription = useCallback(async () => {
if (!hasConnectionFailed) return;
const editor = editorRef.current;
if (!editor) return;
try {
const latestEncodedDescription = await fetchPageDescription();
let latestDecodedDescription: Uint8Array;
if (latestEncodedDescription && latestEncodedDescription.byteLength > 0) {
latestDecodedDescription = new Uint8Array(latestEncodedDescription);
} else {
latestDecodedDescription = getBinaryDataFromDocumentEditorHTMLString("<p></p>");
}
editor.setProviderDocument(latestDecodedDescription);
const { binary, html, json } = editor.getDocument();
if (!binary || !json) return;
const encodedBinary = Buffer.from(binary).toString("base64");
await updatePageDescription({
description_binary: encodedBinary,
description_html: html,
description: json,
});
} catch (error) {
console.error("Error in updating description using fallback logic:", error);
}
}, [editorRef, fetchPageDescription, hasConnectionFailed, updatePageDescription]);
useEffect(() => {
if (hasConnectionFailed) {
handleUpdateDescription();
}
}, [handleUpdateDescription, hasConnectionFailed]);
useAutoSave(handleUpdateDescription);
};

View File

@@ -0,0 +1,116 @@
import { useCallback, useMemo } from "react";
// plane editor
import type { TEditorFontSize, TEditorFontStyle } from "@plane/editor";
// hooks
import useLocalStorage from "@/hooks/use-local-storage";
export type TPagesPersonalizationConfig = {
full_width: boolean;
font_size: TEditorFontSize;
font_style: TEditorFontStyle;
sticky_toolbar: boolean;
};
const DEFAULT_PERSONALIZATION_VALUES: TPagesPersonalizationConfig = {
full_width: false,
font_size: "large-font",
font_style: "sans-serif",
sticky_toolbar: true,
};
export const usePageFilters = () => {
// local storage
const { storedValue: pagesConfig, setValue: setPagesConfig } = useLocalStorage<TPagesPersonalizationConfig>(
"pages_config",
DEFAULT_PERSONALIZATION_VALUES
);
// stored values
const isFullWidth = useMemo(
() => (pagesConfig?.full_width === undefined ? DEFAULT_PERSONALIZATION_VALUES.full_width : pagesConfig?.full_width),
[pagesConfig?.full_width]
);
const isStickyToolbarEnabled = useMemo(
() =>
pagesConfig?.sticky_toolbar === undefined
? DEFAULT_PERSONALIZATION_VALUES.sticky_toolbar
: pagesConfig?.sticky_toolbar,
[pagesConfig?.sticky_toolbar]
);
const fontSize = useMemo(
() => pagesConfig?.font_size ?? DEFAULT_PERSONALIZATION_VALUES.font_size,
[pagesConfig?.font_size]
);
const fontStyle = useMemo(
() => pagesConfig?.font_style ?? DEFAULT_PERSONALIZATION_VALUES.font_style,
[pagesConfig?.font_style]
);
// update action
const handleUpdateConfig = useCallback(
(payload: Partial<TPagesPersonalizationConfig>) => {
setPagesConfig({
...(pagesConfig ?? DEFAULT_PERSONALIZATION_VALUES),
...payload,
});
},
[pagesConfig, setPagesConfig]
);
/**
* @description action to update full_width value
* @param {boolean} value
*/
const handleFullWidth = useCallback(
(value: boolean) => {
handleUpdateConfig({
full_width: value,
});
},
[handleUpdateConfig]
);
/**
* @description action to update font_size value
* @param {TEditorFontSize} value
*/
const handleFontSize = useCallback(
(value: TEditorFontSize) => {
handleUpdateConfig({
font_size: value,
});
},
[handleUpdateConfig]
);
/**
* @description action to update font_size value
* @param {TEditorFontSize} value
*/
const handleFontStyle = useCallback(
(value: TEditorFontStyle) => {
handleUpdateConfig({
font_style: value,
});
},
[handleUpdateConfig]
);
/**
* @description action to update full_width value
* @param {boolean} value
*/
const handleStickyToolbar = useCallback(
(value: boolean) => {
handleUpdateConfig({
sticky_toolbar: value,
});
},
[handleUpdateConfig]
);
return {
fontSize,
handleFontSize,
fontStyle,
handleFontStyle,
isFullWidth,
handleFullWidth,
isStickyToolbarEnabled,
handleStickyToolbar,
};
};

View File

@@ -0,0 +1,310 @@
import { useMemo } from "react";
// plane imports
import { IS_FAVORITE_MENU_OPEN, PROJECT_PAGE_TRACKER_EVENTS } from "@plane/constants";
import type { EditorRefApi } from "@plane/editor";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { EPageAccess } from "@plane/types";
import { copyUrlToClipboard } from "@plane/utils";
// helpers
import { captureSuccess, captureError } from "@/helpers/event-tracker.helper";
// hooks
import { useCollaborativePageActions } from "@/hooks/use-collaborative-page-actions";
// store types
import type { TPageInstance } from "@/store/pages/base-page";
// local storage
import useLocalStorage from "./use-local-storage";
export type TPageOperations = {
toggleLock: () => void;
toggleAccess: () => void;
toggleFavorite: () => void;
openInNewTab: () => void;
copyLink: () => void;
duplicate: () => void;
toggleArchive: () => void;
};
type Props = {
page: TPageInstance;
};
export const usePageOperations = (
props: Props
): {
pageOperations: TPageOperations;
} => {
const { page } = props;
// derived values
const {
access,
addToFavorites,
archived_at,
duplicate,
is_favorite,
is_locked,
getRedirectionLink,
removePageFromFavorites,
} = page;
// collaborative actions
const { executeCollaborativeAction } = useCollaborativePageActions(props);
// local storage
const { setValue: toggleFavoriteMenu, storedValue: isFavoriteMenuOpen } = useLocalStorage<boolean>(
IS_FAVORITE_MENU_OPEN,
false
);
// page operations
const pageOperations: TPageOperations = useMemo(() => {
const pageLink = getRedirectionLink();
return {
copyLink: () => {
copyUrlToClipboard(pageLink).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link Copied!",
message: "Page link copied to clipboard.",
});
});
},
duplicate: async () => {
try {
await duplicate();
captureSuccess({
eventName: PROJECT_PAGE_TRACKER_EVENTS.duplicate,
payload: {
id: page.id,
state: "SUCCESS",
},
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Page duplicated successfully.",
});
} catch (error: any) {
captureError({
eventName: PROJECT_PAGE_TRACKER_EVENTS.duplicate,
error,
});
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Page could not be duplicated. Please try again later.",
});
}
},
move: async () => {},
openInNewTab: () => window.open(pageLink, "_blank"),
toggleAccess: async () => {
const changedPageType = access === EPageAccess.PUBLIC ? "private" : "public";
const eventName = PROJECT_PAGE_TRACKER_EVENTS.access_update;
try {
if (access === EPageAccess.PUBLIC)
await executeCollaborativeAction({ type: "sendMessageToServer", message: "make-private" });
else await executeCollaborativeAction({ type: "sendMessageToServer", message: "make-public" });
captureSuccess({
eventName,
payload: {
id: page.id,
from_access: access === EPageAccess.PUBLIC ? "Public" : "Private",
to_access: access === EPageAccess.PUBLIC ? "Private" : "Public",
state: "SUCCESS",
},
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: `The page has been marked ${changedPageType} and moved to the ${changedPageType} section.`,
});
} catch (error: any) {
captureError({
eventName,
error,
});
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: `The page couldn't be marked ${changedPageType}. Please try again.`,
});
}
},
toggleArchive: async () => {
if (archived_at) {
try {
await executeCollaborativeAction({ type: "sendMessageToServer", message: "unarchive" });
captureSuccess({
eventName: PROJECT_PAGE_TRACKER_EVENTS.restore,
payload: {
id: page.id,
state: "SUCCESS",
},
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Page restored successfully.",
});
} catch (error: any) {
captureError({
eventName: PROJECT_PAGE_TRACKER_EVENTS.restore,
error,
});
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Page could not be restored. Please try again later.",
});
}
} else {
try {
await executeCollaborativeAction({ type: "sendMessageToServer", message: "archive" });
captureSuccess({
eventName: PROJECT_PAGE_TRACKER_EVENTS.archive,
payload: {
id: page.id,
state: "SUCCESS",
},
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Page archived successfully.",
});
} catch (error: any) {
captureError({
eventName: PROJECT_PAGE_TRACKER_EVENTS.archive,
error,
});
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Page could not be archived. Please try again later.",
});
}
}
},
toggleFavorite: () => {
if (is_favorite) {
removePageFromFavorites()
.then(() => {
captureSuccess({
eventName: PROJECT_PAGE_TRACKER_EVENTS.unfavorite,
payload: {
id: page.id,
state: "SUCCESS",
},
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Page removed from favorites.",
});
})
.catch((error) => {
captureError({
eventName: PROJECT_PAGE_TRACKER_EVENTS.unfavorite,
error,
});
});
} else {
addToFavorites()
.then(() => {
captureSuccess({
eventName: PROJECT_PAGE_TRACKER_EVENTS.favorite,
payload: {
id: page.id,
state: "SUCCESS",
},
});
if (!isFavoriteMenuOpen) toggleFavoriteMenu(true);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Page added to favorites.",
});
})
.catch((error) => {
captureError({
eventName: PROJECT_PAGE_TRACKER_EVENTS.favorite,
error,
});
});
}
},
toggleLock: async () => {
if (is_locked) {
try {
await executeCollaborativeAction({ type: "sendMessageToServer", message: "unlock" });
captureSuccess({
eventName: PROJECT_PAGE_TRACKER_EVENTS.unlock,
payload: {
id: page.id,
state: "SUCCESS",
},
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Page unlocked successfully.",
});
} catch (error: any) {
captureError({
eventName: PROJECT_PAGE_TRACKER_EVENTS.unlock,
error,
});
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Page could not be unlocked. Please try again later.",
});
}
} else {
try {
await executeCollaborativeAction({ type: "sendMessageToServer", message: "lock" });
captureSuccess({
eventName: PROJECT_PAGE_TRACKER_EVENTS.lock,
payload: {
id: page.id,
state: "SUCCESS",
},
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Page locked successfully.",
});
} catch (error: any) {
captureError({
eventName: PROJECT_PAGE_TRACKER_EVENTS.lock,
error,
});
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Page could not be locked. Please try again later.",
});
}
}
},
};
}, [
access,
addToFavorites,
archived_at,
duplicate,
executeCollaborativeAction,
getRedirectionLink,
is_favorite,
is_locked,
isFavoriteMenuOpen,
page.id,
removePageFromFavorites,
toggleFavoriteMenu,
]);
return {
pageOperations,
};
};

View File

@@ -0,0 +1,215 @@
import { useCallback } from "react";
import { useParams } from "next/navigation";
// plane types
import type { TSearchEntities } from "@plane/types";
// helpers
import { getBase64Image } from "@plane/utils";
// hooks
import { useMember } from "@/hooks/store/use-member";
// plane web hooks
import { useAdditionalEditorMention } from "@/plane-web/hooks/use-additional-editor-mention";
export const useParseEditorContent = () => {
// params
const { workspaceSlug } = useParams();
// store hooks
const { getUserDetails } = useMember();
// parse additional content
const { parseAdditionalEditorContent } = useAdditionalEditorMention();
/**
* @description function to replace all the custom components from the html component to make it pdf compatible
* @param props
* @returns {Promise<string>}
*/
const replaceCustomComponentsFromHTMLContent = useCallback(
async (props: { htmlContent: string; noAssets?: boolean }): Promise<string> => {
const { htmlContent, noAssets = false } = props;
// create a DOM parser
const parser = new DOMParser();
// parse the HTML string into a DOM document
const doc = parser.parseFromString(htmlContent, "text/html");
// replace all mention-component elements
const mentionComponents = doc.querySelectorAll("mention-component");
mentionComponents.forEach((component) => {
// create a span element to replace the mention-component
const span = doc.createElement("span");
span.setAttribute("data-node-type", "mention-block");
// get the user id from the component
const id = component.getAttribute("entity_identifier") || "";
const entityType = (component.getAttribute("entity_name") || "user_mention") as TSearchEntities;
let textContent = "user";
if (entityType === "user_mention") {
const userDetails = getUserDetails(id);
textContent = userDetails?.display_name ?? "";
} else {
const mentionDetails = parseAdditionalEditorContent({
id,
entityType,
});
if (mentionDetails) {
textContent = mentionDetails.textContent;
}
}
span.textContent = `@${textContent}`;
// replace the mention-component with the span element
component.replaceWith(span);
});
// handle code inside pre elements
const preElements = doc.querySelectorAll("pre");
preElements.forEach((preElement) => {
const codeElement = preElement.querySelector("code");
if (codeElement) {
// create a div element with the required attributes for code blocks
const div = doc.createElement("div");
div.setAttribute("data-node-type", "code-block");
div.setAttribute("class", "courier");
// transfer the content from the code block
div.innerHTML = codeElement.innerHTML.replace(/\n/g, "<br>") || "";
// replace the pre element with the new div
preElement.replaceWith(div);
}
});
// handle inline code elements (not inside pre tags)
const inlineCodeElements = doc.querySelectorAll("code");
inlineCodeElements.forEach((codeElement) => {
// check if the code element is inside a pre element
if (!codeElement.closest("pre")) {
// create a span element with the required attributes for inline code blocks
const span = doc.createElement("span");
span.setAttribute("data-node-type", "inline-code-block");
span.setAttribute("class", "courier-bold");
// transfer the code content
span.textContent = codeElement.textContent || "";
// replace the standalone code element with the new span
codeElement.replaceWith(span);
}
});
// handle image-component elements
const imageComponents = doc.querySelectorAll("image-component");
if (noAssets) {
// if no assets is enabled, remove the image component elements
imageComponents.forEach((component) => component.remove());
// remove default img elements
const imageElements = doc.querySelectorAll("img");
imageElements.forEach((img) => img.remove());
} else {
// if no assets is not enabled, replace the image component elements with img elements
imageComponents.forEach((component) => {
// get the image src from the component
const src = component.getAttribute("src") ?? "";
const height = component.getAttribute("height") ?? "";
const width = component.getAttribute("width") ?? "";
// create an img element to replace the image-component
const img = doc.createElement("img");
img.src = src;
img.style.height = height;
img.style.width = width;
// replace the image-component with the img element
component.replaceWith(img);
});
}
// convert all images to base64
const imgElements = doc.querySelectorAll("img");
await Promise.all(
Array.from(imgElements).map(async (img) => {
// get the image src from the img element
const src = img.getAttribute("src");
if (src) {
try {
const base64Image = await getBase64Image(src);
img.src = base64Image;
} catch (error) {
// log the error if the image conversion fails
console.error("Failed to convert image to base64:", error);
}
}
})
);
// replace all checkbox elements
const checkboxComponents = doc.querySelectorAll("input[type='checkbox']");
checkboxComponents.forEach((component) => {
// get the checked status from the element
const checked = component.getAttribute("checked");
// create a div element to replace the input element
const div = doc.createElement("div");
div.classList.value = "input-checkbox";
// add the checked class if the checkbox is checked
if (checked === "checked" || checked === "true") div.classList.add("checked");
// replace the input element with the div element
component.replaceWith(div);
});
// remove all issue-embed-component elements
const issueEmbedComponents = doc.querySelectorAll("issue-embed-component");
issueEmbedComponents.forEach((component) => component.remove());
// serialize the document back into a string
let serializedDoc = doc.body.innerHTML;
// remove null colors from table elements
serializedDoc = serializedDoc.replace(/background-color: null/g, "").replace(/color: null/g, "");
return serializedDoc;
},
[getUserDetails]
);
/**
* @description function to replace all the custom components from the markdown content
* @param props
* @returns {string}
*/
const replaceCustomComponentsFromMarkdownContent = useCallback(
(props: { markdownContent: string; noAssets?: boolean }): string => {
const start = performance.now();
const { markdownContent, noAssets = false } = props;
let parsedMarkdownContent = markdownContent;
// replace the matched mention components with [display_name](redirect_url)
const mentionRegex =
/<mention-component[^>]*entity_identifier="([^"]+)"[^>]*entity_name="([^"]+)"[^>]*><\/mention-component>/g;
const originUrl = typeof window !== "undefined" && (window.location.origin ?? "");
parsedMarkdownContent = parsedMarkdownContent.replace(mentionRegex, (_match, id, entity_type) => {
const entityType = entity_type as TSearchEntities;
if (!id || !entityType) return "";
if (entityType === "user_mention") {
const userDetails = getUserDetails(id);
if (!userDetails) return "";
return `[${userDetails.display_name}](${originUrl}/${workspaceSlug}/profile/${id})`;
} else {
const mentionDetails = parseAdditionalEditorContent({
id,
entityType,
});
if (!mentionDetails) {
return "";
} else {
const { redirectionPath, textContent } = mentionDetails;
return `[${textContent}](${originUrl}/${redirectionPath})`;
}
}
});
// replace the matched image components with <img src={src} >
const imageComponentRegex = /<image-component[^>]*src="([^"]+)"[^>]*>[^]*<\/image-component>/g;
const imgTagRegex = /<img[^>]*src="([^"]+)"[^>]*\/?>/g;
if (noAssets) {
// remove all image components
parsedMarkdownContent = parsedMarkdownContent.replace(imageComponentRegex, "").replace(imgTagRegex, "");
} else {
// replace the matched image components with <img src={src} >
parsedMarkdownContent = parsedMarkdownContent.replace(
imageComponentRegex,
(_match, src) => `<img src="${src}" >`
);
}
// remove all issue-embed components
const issueEmbedRegex = /<issue-embed-component[^>]*>[^]*<\/issue-embed-component>/g;
parsedMarkdownContent = parsedMarkdownContent.replace(issueEmbedRegex, "");
const end = performance.now();
console.log("Exec time:", end - start);
return parsedMarkdownContent;
},
[getUserDetails, workspaceSlug]
);
return {
replaceCustomComponentsFromHTMLContent,
replaceCustomComponentsFromMarkdownContent,
};
};

View File

@@ -0,0 +1,50 @@
import type React from "react";
import { useEffect } from "react";
const usePeekOverviewOutsideClickDetector = (
ref: React.RefObject<HTMLElement>,
callback: () => void,
issueId: string
) => {
const handleClick = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
// check for the closest element with attribute name data-prevent-outside-click
const preventOutsideClickElement = (event.target as HTMLElement | undefined)?.closest(
"[data-prevent-outside-click]"
);
// if the closest element with attribute name data-prevent-outside-click is found, return
if (preventOutsideClickElement) {
return;
}
// check if the click target is the current issue element or its children
let targetElement = event.target as HTMLElement | null;
while (targetElement) {
if (targetElement.id === `issue-${issueId}`) {
// if the click target is the current issue element, return
return;
}
targetElement = targetElement.parentElement;
}
const delayOutsideClickElement = (event.target as HTMLElement | undefined)?.closest("[data-delay-outside-click]");
if (delayOutsideClickElement) {
// if the click target is the closest element with attribute name data-delay-outside-click, delay the callback
setTimeout(() => {
callback();
}, 0);
return;
}
// else, call the callback immediately
callback();
}
};
useEffect(() => {
document.addEventListener("mousedown", handleClick);
return () => {
document.removeEventListener("mousedown", handleClick);
};
});
};
export default usePeekOverviewOutsideClickDetector;

View File

@@ -0,0 +1,20 @@
"use client";
export const usePlatformOS = () => {
const userAgent = window.navigator.userAgent;
const isMobile = /iPhone|iPad|iPod|Android/i.test(userAgent);
let platform = "";
if (!isMobile) {
if (userAgent.indexOf("Win") !== -1) {
platform = "Windows";
} else if (userAgent.indexOf("Mac") !== -1) {
platform = "MacOS";
} else if (userAgent.indexOf("Linux") !== -1) {
platform = "Linux";
} else {
platform = "Unknown";
}
}
return { isMobile, platform };
};

View File

@@ -0,0 +1,94 @@
import { useProjectEstimates } from "./store/estimates";
import { useCycle } from "./store/use-cycle";
import { useLabel } from "./store/use-label";
import { useMember } from "./store/use-member";
import { useModule } from "./store/use-module";
import { useProjectState } from "./store/use-project-state";
export const useProjectIssueProperties = () => {
const { fetchProjectStates } = useProjectState();
const {
project: { fetchProjectMembers },
} = useMember();
const { fetchProjectLabels } = useLabel();
const { fetchAllCycles: fetchProjectAllCycles } = useCycle();
const { fetchModules: fetchProjectAllModules } = useModule();
const { getProjectEstimates } = useProjectEstimates();
// fetching project states
const fetchStates = async (
workspaceSlug: string | string[] | undefined,
projectId: string | string[] | undefined
) => {
if (workspaceSlug && projectId) {
await fetchProjectStates(workspaceSlug.toString(), projectId.toString());
}
};
// fetching project members
const fetchMembers = async (
workspaceSlug: string | string[] | undefined,
projectId: string | string[] | undefined
) => {
if (workspaceSlug && projectId) {
await fetchProjectMembers(workspaceSlug.toString(), projectId.toString());
}
};
// fetching project labels
const fetchLabels = async (
workspaceSlug: string | string[] | undefined,
projectId: string | string[] | undefined
) => {
if (workspaceSlug && projectId) {
await fetchProjectLabels(workspaceSlug.toString(), projectId.toString());
}
};
// fetching project cycles
const fetchCycles = async (
workspaceSlug: string | string[] | undefined,
projectId: string | string[] | undefined
) => {
if (workspaceSlug && projectId) {
await fetchProjectAllCycles(workspaceSlug.toString(), projectId.toString());
}
};
// fetching project modules
const fetchModules = async (
workspaceSlug: string | string[] | undefined,
projectId: string | string[] | undefined
) => {
if (workspaceSlug && projectId) {
await fetchProjectAllModules(workspaceSlug.toString(), projectId.toString());
}
};
// fetching project estimates
const fetchEstimates = async (
workspaceSlug: string | string[] | undefined,
projectId: string | string[] | undefined
) => {
if (workspaceSlug && projectId) {
await getProjectEstimates(workspaceSlug.toString(), projectId.toString());
}
};
const fetchAll = async (workspaceSlug: string | string[] | undefined, projectId: string | string[] | undefined) => {
if (workspaceSlug && projectId) {
await fetchStates(workspaceSlug, projectId);
await fetchMembers(workspaceSlug, projectId);
await fetchLabels(workspaceSlug, projectId);
await fetchCycles(workspaceSlug, projectId);
await fetchModules(workspaceSlug, projectId);
await fetchEstimates(workspaceSlug, projectId);
}
};
return {
fetchAll,
fetchStates,
fetchMembers,
fetchLabels,
fetchCycles,
fetchModules,
fetchEstimates,
};
};

View File

@@ -0,0 +1,38 @@
import { useCallback } from "react";
import { useSearchParams, usePathname } from "next/navigation";
type TParamsToAdd = {
[key: string]: string;
};
export const useQueryParams = () => {
// next navigation
const searchParams = useSearchParams();
const pathname = usePathname();
const updateQueryParams = useCallback(
({ paramsToAdd = {}, paramsToRemove = [] }: { paramsToAdd?: TParamsToAdd; paramsToRemove?: string[] }) => {
const currentParams = new URLSearchParams(searchParams.toString());
// add or update query parameters
Object.keys(paramsToAdd).forEach((key) => {
currentParams.set(key, paramsToAdd[key]);
});
// remove specified query parameters
paramsToRemove.forEach((key) => {
currentParams.delete(key);
});
// construct the new route with the updated query parameters
const query = currentParams.toString();
const newRoute = query ? `${pathname}?${query}` : pathname;
return newRoute;
},
[pathname, searchParams]
);
return {
updateQueryParams,
};
};

View File

@@ -0,0 +1,193 @@
import { useCallback, useMemo } from "react";
// plane imports
import type { EventToPayloadMap } from "@plane/editor";
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
// types
import type { IUserLite } from "@plane/types";
// components
import type { TEditorBodyHandlers } from "@/components/pages/editor/editor-body";
// hooks
import { useUser } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import type { EPageStoreType } from "@/plane-web/hooks/store";
import { usePageStore } from "@/plane-web/hooks/store";
// store
import type { TPageInstance } from "@/store/pages/base-page";
// Type for page update handlers with proper typing for action data
export type PageUpdateHandler<T extends keyof EventToPayloadMap = keyof EventToPayloadMap> = (params: {
pageIds: string[];
data: EventToPayloadMap[T];
performAction: boolean;
}) => void;
// Type for custom event handlers that can be provided to override default behavior
export type TCustomEventHandlers = {
[K in keyof EventToPayloadMap]?: PageUpdateHandler<K>;
};
interface UsePageEventsProps {
page: TPageInstance;
storeType: EPageStoreType;
getUserDetails: (userId: string) => IUserLite | undefined;
customRealtimeEventHandlers?: TCustomEventHandlers;
handlers: TEditorBodyHandlers;
}
export const useRealtimePageEvents = ({
page,
storeType,
getUserDetails,
customRealtimeEventHandlers,
handlers,
}: UsePageEventsProps) => {
const router = useAppRouter();
const { removePage, getPageById } = usePageStore(storeType);
const { data: currentUser } = useUser();
// Helper function to safely get user display text
const getUserDisplayText = useCallback(
(userId: string | undefined) => {
if (!userId) return "";
try {
const userDetails = getUserDetails(userId as string);
return userDetails?.display_name ? ` by ${userDetails.display_name}` : "";
} catch {
return "";
}
},
[getUserDetails]
);
const ACTION_HANDLERS = useMemo<
Partial<{
[K in keyof EventToPayloadMap]: PageUpdateHandler<K>;
}>
>(
() => ({
archived: ({ pageIds, data }) => {
pageIds.forEach((pageId) => {
const pageItem = getPageById(pageId);
if (pageItem) pageItem.archive({ archived_at: data.archived_at, shouldSync: false });
});
},
unarchived: ({ pageIds }) => {
pageIds.forEach((pageId) => {
const pageItem = getPageById(pageId);
if (pageItem) pageItem.restore({ shouldSync: false });
});
},
locked: ({ pageIds }) => {
pageIds.forEach((pageId) => {
const pageItem = getPageById(pageId);
if (pageItem) pageItem.lock({ shouldSync: false, recursive: false });
});
},
unlocked: ({ pageIds }) => {
pageIds.forEach((pageId) => {
const pageItem = getPageById(pageId);
if (pageItem) pageItem.unlock({ shouldSync: false, recursive: false });
});
},
"made-public": ({ pageIds }) => {
pageIds.forEach((pageId) => {
const pageItem = getPageById(pageId);
if (pageItem) pageItem.makePublic({ shouldSync: false });
});
},
"made-private": ({ pageIds }) => {
pageIds.forEach((pageId) => {
const pageItem = getPageById(pageId);
if (pageItem) pageItem.makePrivate({ shouldSync: false });
});
},
deleted: ({ pageIds, data }) => {
pageIds.forEach((pageId) => {
const pageItem = getPageById(pageId);
if (pageItem) {
removePage({ pageId, shouldSync: false });
if (page.id === pageId && data?.user_id !== currentUser?.id) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Page deleted",
message: `Page deleted${getUserDisplayText(data.user_id)}`,
});
router.push(handlers.getRedirectionLink());
} else if (page.id === pageId) {
router.push(handlers.getRedirectionLink());
}
}
});
},
property_updated: ({ pageIds, data }) => {
pageIds.forEach((pageId) => {
const pageInstance = getPageById(pageId);
const { name: updatedName, ...rest } = data;
if (updatedName != null) pageInstance?.updateTitle(updatedName);
pageInstance?.mutateProperties(rest);
});
},
error: ({ pageIds, data }) => {
const errorType = data.error_type;
const errorMessage = data.error_message || "An error occurred";
const errorCode = data.error_code;
if (page.id && pageIds.includes(page.id)) {
// Show toast notification
setToast({
type: TOAST_TYPE.ERROR,
title: errorType === "fetch" ? "Failed to load page" : "Failed to save page",
message: errorMessage,
});
// Handle specific error codes
const pageInstance = getPageById(page.id);
if (pageInstance) {
if (errorCode === "page_locked") {
// Lock the page if not already locked
if (!pageInstance.is_locked) {
pageInstance.mutateProperties({ is_locked: true });
}
} else if (errorCode === "page_archived") {
// Mark page as archived if not already
if (!pageInstance.archived_at) {
pageInstance.mutateProperties({ archived_at: new Date().toISOString() });
}
}
}
}
},
...customRealtimeEventHandlers,
}),
[getPageById, page, router, getUserDisplayText, removePage, currentUser, customRealtimeEventHandlers, handlers]
);
// The main function that will be returned from this hook
const updatePageProperties = useCallback(
<T extends keyof EventToPayloadMap>(
pageIds: string | string[],
actionType: T,
data: EventToPayloadMap[T],
performAction = false
) => {
// Convert to array if single string is passed
const normalizedPageIds = Array.isArray(pageIds) ? pageIds : [pageIds];
if (normalizedPageIds.length === 0) return;
// Get the handler for this message type
const handler = ACTION_HANDLERS[actionType];
if (handler) {
// Now TypeScript knows that handler and data match in type
handler({ pageIds: normalizedPageIds, data, performAction });
} else {
console.warn(`No handler for message type: ${actionType.toString()}`);
}
},
[ACTION_HANDLERS]
);
return { updatePageProperties };
};

View File

@@ -0,0 +1,61 @@
import { useCallback, useEffect, useState } from "react";
//TODO: remove temp flag isActive later and use showAlert as the source of truth
const useReloadConfirmations = (isActive = true, message?: string, defaultShowAlert = false, onLeave?: () => void) => {
const [showAlert, setShowAlert] = useState(defaultShowAlert);
const alertMessage = message ?? "Are you sure you want to leave? Changes you made may not be saved.";
const handleBeforeUnload = useCallback(
(event: BeforeUnloadEvent) => {
if (!isActive || !showAlert) return;
event.preventDefault();
event.returnValue = "";
},
[isActive, showAlert]
);
const handleAnchorClick = useCallback(
(event: MouseEvent) => {
if (!isActive || !showAlert) return;
// Skip if event target is not available or defaultPrevented
if (!event.target || event.defaultPrevented) return;
// Skip control/command/option/alt+click
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
// check if the event target is an anchor or a child of an anchor tag
const eventTarget = event.target as HTMLElement;
if (!eventTarget.closest("a")) return; // This is intentionally not type safe
// check if anchor target is _blank
const anchorElement = eventTarget.closest("a") as HTMLAnchorElement;
const isAnchorTargetBlank = anchorElement.getAttribute("target") === "_blank";
if (isAnchorTargetBlank) return;
// show confirm dialog
const isLeaving = confirm(alertMessage);
if (isLeaving) {
onLeave && onLeave();
} else {
event.preventDefault();
event.stopPropagation();
}
},
[isActive, showAlert]
);
useEffect(() => {
// handle browser refresh
window.addEventListener("beforeunload", handleBeforeUnload, true);
// handle anchor tag click
window.addEventListener("click", handleAnchorClick, true);
// TODO: handle back / forward button click
return () => {
// cleanup
window.removeEventListener("beforeunload", handleBeforeUnload, true);
window.removeEventListener("click", handleAnchorClick, true);
};
}, [handleAnchorClick, handleBeforeUnload]);
return { setShowAlert };
};
export default useReloadConfirmations;

View File

@@ -0,0 +1,26 @@
import { useTheme } from "next-themes";
type AssetPathConfig = {
basePath: string;
additionalPath?: string;
extension?: string;
includeThemeInPath?: boolean;
};
export const useResolvedAssetPath = ({
basePath,
additionalPath = "",
extension = "webp",
includeThemeInPath = true,
}: AssetPathConfig) => {
// hooks
const { resolvedTheme } = useTheme();
// resolved theme
const theme = resolvedTheme === "light" ? "light" : "dark";
if (!includeThemeInPath) {
return `${additionalPath && additionalPath !== "" ? `${basePath}${additionalPath}` : basePath}.${extension}`;
}
return `${additionalPath && additionalPath !== "" ? `${basePath}${additionalPath}` : basePath}-${theme}.${extension}`;
};

View File

@@ -0,0 +1,11 @@
import { useContext } from "react";
// context
import { StoreContext } from "@/lib/store-context";
import type { IStickyStore } from "@/store/sticky/sticky.store";
// plane web stores
export const useSticky = (): IStickyStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useSticky must be used within StoreProvider");
return context.stickyStore;
};

View File

@@ -0,0 +1,56 @@
export const useTableKeyboardNavigation = () => {
const getPreviousRow = (element: HTMLElement) => {
const previousRow = element.closest("tr")?.previousSibling;
if (previousRow) return previousRow;
//if previous row does not exist in the parent check the row with the header of the table
return element.closest("tbody")?.previousSibling?.childNodes?.[0];
};
const getNextRow = (element: HTMLElement) => {
const nextRow = element.closest("tr")?.nextSibling;
if (nextRow) return nextRow;
//if next row does not exist in the parent check the row with the body of the table
return element.closest("thead")?.nextSibling?.childNodes?.[0];
};
const handleKeyBoardNavigation = function (e: React.KeyboardEvent<HTMLTableElement>) {
const element = e.target as HTMLElement;
if (!(element?.tagName === "TD" || element?.tagName === "TH")) return;
let c: HTMLElement | null = null;
if (e.key == "ArrowRight") {
// Right Arrow
c = element.nextSibling as HTMLElement;
} else if (e.key == "ArrowLeft") {
// Left Arrow
c = element.previousSibling as HTMLElement;
} else if (e.key == "ArrowUp") {
// Up Arrow
const index = Array.prototype.indexOf.call(element?.parentNode?.childNodes || [], element);
const prevRow = getPreviousRow(element);
c = prevRow?.childNodes?.[index] as HTMLElement;
} else if (e.key == "ArrowDown") {
// Down Arrow
const index = Array.prototype.indexOf.call(element?.parentNode?.childNodes || [], element);
const nextRow = getNextRow(element);
c = nextRow?.childNodes[index] as HTMLElement;
} else if (e.key == "Enter" || e.key == "Space") {
e.preventDefault();
(element?.querySelector(".clickable") as HTMLElement)?.click();
return;
}
if (!c) return;
e.preventDefault();
c?.focus();
c?.scrollIntoView({ behavior: "smooth", block: "center", inline: "end" });
};
return handleKeyBoardNavigation;
};

View File

@@ -0,0 +1,31 @@
import { useContext } from "react";
// lib
import { StoreContext } from "@/lib/store-context";
// Plane-web
import type { IBaseTimelineStore } from "@/plane-web/store/timeline/base-timeline.store";
//
import { ETimeLineTypeType, useTimeLineType } from "../components/gantt-chart/contexts";
export const useTimeLineChart = (timeLineType: ETimeLineTypeType): IBaseTimelineStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useTimeLineChart must be used within StoreProvider");
switch (timeLineType) {
case ETimeLineTypeType.ISSUE:
return context.timelineStore.issuesTimeLineStore;
case ETimeLineTypeType.MODULE:
return context.timelineStore.modulesTimeLineStore as IBaseTimelineStore;
case ETimeLineTypeType.PROJECT:
return context.timelineStore.projectTimeLineStore as IBaseTimelineStore;
case ETimeLineTypeType.GROUPED:
return context.timelineStore.groupedTimeLineStore as IBaseTimelineStore;
}
};
export const useTimeLineChartStore = () => {
const timelineType = useTimeLineType();
if (!timelineType) throw new Error("useTimeLineChartStore must be used within TimeLineTypeContext");
return useTimeLineChart(timelineType);
};

View File

@@ -0,0 +1,19 @@
import { useState, useEffect } from "react";
const TIMER = 30;
const useTimer = (initialValue: number = TIMER) => {
const [timer, setTimer] = useState(initialValue);
useEffect(() => {
const interval = setInterval(() => {
setTimer((prev) => prev - 1);
}, 1000);
return () => clearInterval(interval);
}, []);
return { timer, setTimer };
};
export default useTimer;

View File

@@ -0,0 +1,70 @@
import { useCallback } from "react";
import { format } from "date-fns";
import { useProject } from "@/hooks/store/use-project";
import { useUser } from "@/hooks/store/user";
export const useTimeZoneConverter = (projectId: string) => {
const { data: user } = useUser();
const { getProjectById } = useProject();
const userTimezone = user?.user_timezone;
const projectTimezone = getProjectById(projectId)?.timezone;
/**
* Render a date in the user's timezone
* @param date - The date to render
* @param formatToken - The format token to use
* @returns The formatted date
*/
const renderFormattedDateInUserTimezone = useCallback(
(date: string, formatToken: string = "MMM dd, yyyy") => {
// return if undefined
if (!date || !userTimezone) return;
// convert the date to the user's timezone
const convertedDate = new Date(date).toLocaleString("en-US", { timeZone: userTimezone });
// return the formatted date
return format(convertedDate, formatToken);
},
[userTimezone]
);
/**
* Get the project's UTC offset
* @returns The project's UTC offset
*/
const getProjectUTCOffset = useCallback(() => {
if (!projectTimezone) return;
// Get date in user's timezone
const projectDate = new Date(new Date().toLocaleString("en-US", { timeZone: projectTimezone }));
const utcDate = new Date(new Date().toLocaleString("en-US", { timeZone: "UTC" }));
// Calculate offset in minutes
const offsetInMinutes = (projectDate.getTime() - utcDate.getTime()) / 60000;
// return if undefined
if (!offsetInMinutes) return;
// Convert to hours and minutes
const hours = Math.floor(Math.abs(offsetInMinutes) / 60);
const minutes = Math.abs(offsetInMinutes) % 60;
// Format as +/-HH:mm
const sign = offsetInMinutes >= 0 ? "+" : "-";
return `UTC ${sign}${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
}, [projectTimezone]);
/**
* Check if the project's timezone is different from the user's timezone
* @returns True if the project's timezone is different from the user's timezone, false otherwise
*/
const isProjectTimeZoneDifferent = useCallback(() => {
if (!projectTimezone || !userTimezone) return false;
return projectTimezone !== userTimezone;
}, [projectTimezone, userTimezone]);
return {
renderFormattedDateInUserTimezone,
getProjectUTCOffset,
isProjectTimeZoneDifferent,
};
};

View File

@@ -0,0 +1,80 @@
import useSWR from "swr";
import type { TTimezoneObject } from "@plane/types";
// services
import timezoneService from "@/services/timezone.service";
// group timezones by value
const groupTimezones = (timezones: TTimezoneObject[]): TTimezoneObject[] => {
const groupedMap = timezones.reduce((acc, timezone: TTimezoneObject) => {
const key = timezone.value;
if (!acc.has(key)) {
acc.set(key, {
utc_offset: timezone.utc_offset,
gmt_offset: timezone.gmt_offset,
value: timezone.value,
label: timezone.label,
});
} else {
const existing = acc.get(key);
existing.label = `${existing.label}, ${timezone.label}`;
}
return acc;
}, new Map());
return Array.from(groupedMap.values());
};
const useTimezone = () => {
// fetching the timezone from the server
const {
data: timezones,
isLoading: timezoneIsLoading,
error: timezonesError,
} = useSWR("TIMEZONES_LIST", () => timezoneService.fetch(), {
refreshInterval: 0,
});
// derived values
const isDisabled = timezoneIsLoading || timezonesError || !timezones;
const getTimeZoneLabel = (timezone: TTimezoneObject | undefined) => {
if (!timezone) return undefined;
return (
<div className="flex gap-1.5">
<span className="text-custom-text-400">{timezone.utc_offset}</span>
<span className="text-custom-text-200">{timezone.label}</span>
</div>
);
};
const options = [
...groupTimezones(timezones?.timezones || [])?.map((timezone) => ({
value: timezone.value,
query: `${timezone.value} ${timezone.label}, ${timezone.gmt_offset}, ${timezone.utc_offset}`,
content: getTimeZoneLabel(timezone),
})),
{
value: "UTC",
query: "utc, coordinated universal time",
content: "UTC",
},
{
value: "Universal",
query: "universal, coordinated universal time",
content: "Universal",
},
];
const selectedTimezone = (value: string | undefined) => options.find((option) => option.value === value)?.content;
return {
timezones: options,
isLoading: timezoneIsLoading,
error: timezonesError,
disabled: isDisabled,
selectedValue: selectedTimezone,
};
};
export default useTimezone;

View File

@@ -0,0 +1,19 @@
import { useEffect, useState } from "react";
const useSize = () => {
const [windowSize, setWindowSize] = useState([window.innerWidth, window.innerHeight]);
useEffect(() => {
const windowSizeHandler = () => {
setWindowSize([window.innerWidth, window.innerHeight]);
};
window.addEventListener("resize", windowSizeHandler);
return () => {
window.removeEventListener("resize", windowSizeHandler);
};
}, []);
return windowSize;
};
export default useSize;

View File

@@ -0,0 +1,85 @@
import { useEffect } from "react";
import type { Control, FieldArrayWithId, FormState, UseFormWatch } from "react-hook-form";
import { useFieldArray, useForm } from "react-hook-form";
// plane imports
import { EUserPermissions } from "@plane/constants";
type EmailRole = {
email: string;
role: EUserPermissions;
};
export type InvitationFormValues = {
emails: EmailRole[];
};
const SEND_WORKSPACE_INVITATION_MODAL_DEFAULT_VALUES: InvitationFormValues = {
emails: [
{
email: "",
role: EUserPermissions.MEMBER,
},
],
};
type TUseWorkspaceInvitationProps = {
onSubmit: (data: InvitationFormValues) => Promise<void> | undefined;
onClose: () => void;
};
type TUseWorkspaceInvitationReturn = {
control: Control<InvitationFormValues>;
fields: FieldArrayWithId<InvitationFormValues, "emails", "id">[];
formState: FormState<InvitationFormValues>;
watch: UseFormWatch<InvitationFormValues>;
remove: (index: number) => void;
onFormSubmit: () => void;
handleClose: () => void;
appendField: () => void;
};
export const useWorkspaceInvitationActions = (props: TUseWorkspaceInvitationProps): TUseWorkspaceInvitationReturn => {
const { onSubmit, onClose } = props;
// form info
const { control, reset, watch, handleSubmit, formState } = useForm<InvitationFormValues>({
defaultValues: SEND_WORKSPACE_INVITATION_MODAL_DEFAULT_VALUES,
});
const { fields, append, remove } = useFieldArray({
control,
name: "emails",
});
const handleClose = () => {
onClose();
const timeout = setTimeout(() => {
reset(SEND_WORKSPACE_INVITATION_MODAL_DEFAULT_VALUES);
clearTimeout(timeout);
}, 350);
};
const appendField = () => {
append({ email: "", role: EUserPermissions.MEMBER });
};
const onSubmitForm = async (data: InvitationFormValues) => {
await onSubmit(data)?.then(() => {
reset(SEND_WORKSPACE_INVITATION_MODAL_DEFAULT_VALUES);
});
};
useEffect(() => {
if (fields.length === 0) append([{ email: "", role: EUserPermissions.MEMBER }]);
}, [fields, append]);
return {
control,
fields,
formState,
watch,
remove,
onFormSubmit: handleSubmit(onSubmitForm),
handleClose,
appendField,
};
};

View File

@@ -0,0 +1,49 @@
import useSWR from "swr";
// plane web imports
import { useWorkspaceIssuePropertiesExtended } from "@/plane-web/hooks/use-workspace-issue-properties-extended";
// plane imports
import { useProjectEstimates } from "./store/estimates";
import { useCycle } from "./store/use-cycle";
import { useLabel } from "./store/use-label";
import { useModule } from "./store/use-module";
export const useWorkspaceIssueProperties = (workspaceSlug: string | string[] | undefined) => {
const { fetchWorkspaceLabels } = useLabel();
const { getWorkspaceEstimates } = useProjectEstimates();
const { fetchWorkspaceModules } = useModule();
const { fetchWorkspaceCycles } = useCycle();
// fetch workspace Modules
useSWR(
workspaceSlug ? `WORKSPACE_MODULES_${workspaceSlug}` : null,
workspaceSlug ? () => fetchWorkspaceModules(workspaceSlug.toString()) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// fetch workspace Cycles
useSWR(
workspaceSlug ? `WORKSPACE_CYCLES_${workspaceSlug}` : null,
workspaceSlug ? () => fetchWorkspaceCycles(workspaceSlug.toString()) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// fetch workspace labels
useSWR(
workspaceSlug ? `WORKSPACE_LABELS_${workspaceSlug}` : null,
workspaceSlug ? () => fetchWorkspaceLabels(workspaceSlug.toString()) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// fetch workspace estimates
useSWR(
workspaceSlug ? `WORKSPACE_ESTIMATES_${workspaceSlug}` : null,
workspaceSlug ? () => getWorkspaceEstimates(workspaceSlug.toString()) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// fetch extended issue properties
useWorkspaceIssuePropertiesExtended(workspaceSlug);
};

View File

@@ -0,0 +1,24 @@
"use client";
import { useParams, usePathname } from "next/navigation";
/**
* Custom hook to detect different workspace paths
* @returns Object containing boolean flags for different workspace paths
*/
export const useWorkspacePaths = () => {
const { workspaceSlug } = useParams();
const pathname = usePathname();
const isSettingsPath = pathname.includes(`/${workspaceSlug}/settings`);
const isWikiPath = pathname.includes(`/${workspaceSlug}/pages`);
const isAiPath = pathname.includes(`/${workspaceSlug}/pi-chat`);
const isProjectsPath = pathname.includes(`/${workspaceSlug}/`) && !isWikiPath && !isAiPath && !isSettingsPath;
return {
isSettingsPath,
isWikiPath,
isAiPath,
isProjectsPath,
};
};