refactor: move web utils to packages (#7145)

* refactor: move web utils to packages

* fix: build and lint errors

* chore: update drag handle plugin

* chore: update table cell type to fix build errors

* fix: build errors

* chore: sync few changes

* fix: build errors

* chore: minor fixes related to duplicate assets imports

* fix: build errors

* chore: minor changes
This commit is contained in:
Prateek Shourya 2025-06-16 17:18:41 +05:30 committed by GitHub
parent dffcc6dc10
commit 2014400bed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
614 changed files with 1999 additions and 3030 deletions

View file

@ -69,11 +69,12 @@ export enum EErrorAlertType {
export type TAuthErrorInfo = {
type: EErrorAlertType;
code: EAdminAuthErrorCodes;
code: EAuthErrorCodes;
title: string;
message: any;
message: React.ReactNode;
};
export enum EAdminAuthErrorCodes {
// Admin
ADMIN_ALREADY_EXIST = "5150",
@ -87,6 +88,13 @@ export enum EAdminAuthErrorCodes {
ADMIN_USER_DEACTIVATED = "5190",
}
export type TAdminAuthErrorInfo = {
type: EErrorAlertType;
code: EAdminAuthErrorCodes;
title: string;
message: React.ReactNode;
};
export enum EAuthErrorCodes {
// Global
INSTANCE_NOT_CONFIGURED = "5000",

View file

@ -1,28 +1,26 @@
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "";
export const API_BASE_PATH = process.env.NEXT_PUBLIC_API_BASE_PATH || "/";
export const API_BASE_PATH = process.env.NEXT_PUBLIC_API_BASE_PATH || "";
export const API_URL = encodeURI(`${API_BASE_URL}${API_BASE_PATH}`);
// God Mode Admin App Base Url
export const ADMIN_BASE_URL = process.env.NEXT_PUBLIC_ADMIN_BASE_URL || "";
export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "/";
export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "";
export const GOD_MODE_URL = encodeURI(`${ADMIN_BASE_URL}${ADMIN_BASE_PATH}`);
// Publish App Base Url
export const SPACE_BASE_URL = process.env.NEXT_PUBLIC_SPACE_BASE_URL || "";
export const SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || "/";
export const SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || "";
export const SITES_URL = encodeURI(`${SPACE_BASE_URL}${SPACE_BASE_PATH}`);
// Live App Base Url
export const LIVE_BASE_URL = process.env.NEXT_PUBLIC_LIVE_BASE_URL || "";
export const LIVE_BASE_PATH = process.env.NEXT_PUBLIC_LIVE_BASE_PATH || "/";
export const LIVE_BASE_PATH = process.env.NEXT_PUBLIC_LIVE_BASE_PATH || "";
export const LIVE_URL = encodeURI(`${LIVE_BASE_URL}${LIVE_BASE_PATH}`);
// Web App Base Url
export const WEB_BASE_URL = process.env.NEXT_PUBLIC_WEB_BASE_URL || "";
export const WEB_BASE_PATH = process.env.NEXT_PUBLIC_WEB_BASE_PATH || "/";
export const WEB_BASE_PATH = process.env.NEXT_PUBLIC_WEB_BASE_PATH || "";
export const WEB_URL = encodeURI(`${WEB_BASE_URL}${WEB_BASE_PATH}`);
// plane website url
export const WEBSITE_URL =
process.env.NEXT_PUBLIC_WEBSITE_URL || "https://plane.so";
export const WEBSITE_URL = process.env.NEXT_PUBLIC_WEBSITE_URL || "https://plane.so";
// support email
export const SUPPORT_EMAIL =
process.env.NEXT_PUBLIC_SUPPORT_EMAIL || "support@plane.so";
export const SUPPORT_EMAIL = process.env.NEXT_PUBLIC_SUPPORT_EMAIL || "support@plane.so";
// marketing links
export const MARKETING_PRICING_PAGE_LINK = "https://plane.so/pricing";
export const MARKETING_CONTACT_US_PAGE_LINK = "https://plane.so/contact";

View file

@ -0,0 +1,136 @@
// plane imports
import { TEstimateSystems } from "@plane/types";
export const MAX_ESTIMATE_POINT_INPUT_LENGTH = 20;
export enum EEstimateSystem {
POINTS = "points",
CATEGORIES = "categories",
TIME = "time",
}
export enum EEstimateUpdateStages {
CREATE = "create",
EDIT = "edit",
SWITCH = "switch",
}
export const estimateCount = {
min: 2,
max: 6,
};
export const ESTIMATE_SYSTEMS: TEstimateSystems = {
points: {
name: "Points",
i18n_name: "project_settings.estimates.systems.points.label",
templates: {
fibonacci: {
title: "Fibonacci",
i18n_title: "project_settings.estimates.systems.points.fibonacci",
values: [
{ id: undefined, key: 1, value: "1" },
{ id: undefined, key: 2, value: "2" },
{ id: undefined, key: 3, value: "3" },
{ id: undefined, key: 4, value: "5" },
{ id: undefined, key: 5, value: "8" },
{ id: undefined, key: 6, value: "13" },
],
},
linear: {
title: "Linear",
i18n_title: "project_settings.estimates.systems.points.linear",
values: [
{ id: undefined, key: 1, value: "1" },
{ id: undefined, key: 2, value: "2" },
{ id: undefined, key: 3, value: "3" },
{ id: undefined, key: 4, value: "4" },
{ id: undefined, key: 5, value: "5" },
{ id: undefined, key: 6, value: "6" },
],
},
squares: {
title: "Squares",
i18n_title: "project_settings.estimates.systems.points.squares",
values: [
{ id: undefined, key: 1, value: "1" },
{ id: undefined, key: 2, value: "4" },
{ id: undefined, key: 3, value: "9" },
{ id: undefined, key: 4, value: "16" },
{ id: undefined, key: 5, value: "25" },
{ id: undefined, key: 6, value: "36" },
],
},
custom: {
title: "Custom",
i18n_title: "project_settings.estimates.systems.points.custom",
values: [
{ id: undefined, key: 1, value: "1" },
{ id: undefined, key: 2, value: "2" },
],
hide: true,
},
},
is_available: true,
is_ee: false,
},
categories: {
name: "Categories",
i18n_name: "project_settings.estimates.systems.categories.label",
templates: {
t_shirt_sizes: {
title: "T-Shirt Sizes",
i18n_title: "project_settings.estimates.systems.categories.t_shirt_sizes",
values: [
{ id: undefined, key: 1, value: "XS" },
{ id: undefined, key: 2, value: "S" },
{ id: undefined, key: 3, value: "M" },
{ id: undefined, key: 4, value: "L" },
{ id: undefined, key: 5, value: "XL" },
{ id: undefined, key: 6, value: "XXL" },
],
},
easy_to_hard: {
title: "Easy to hard",
i18n_title: "project_settings.estimates.systems.categories.easy_to_hard",
values: [
{ id: undefined, key: 1, value: "Easy" },
{ id: undefined, key: 2, value: "Medium" },
{ id: undefined, key: 3, value: "Hard" },
{ id: undefined, key: 4, value: "Very Hard" },
],
},
custom: {
title: "Custom",
i18n_title: "project_settings.estimates.systems.categories.custom",
values: [
{ id: undefined, key: 1, value: "Easy" },
{ id: undefined, key: 2, value: "Hard" },
],
hide: true,
},
},
is_available: true,
is_ee: false,
},
time: {
name: "Time",
i18n_name: "project_settings.estimates.systems.time.label",
templates: {
hours: {
title: "Hours",
i18n_title: "project_settings.estimates.systems.time.hours",
values: [
{ id: undefined, key: 1, value: "1" },
{ id: undefined, key: 2, value: "2" },
{ id: undefined, key: 3, value: "3" },
{ id: undefined, key: 4, value: "4" },
{ id: undefined, key: 5, value: "5" },
{ id: undefined, key: 6, value: "6" },
],
},
},
is_available: true,
is_ee: true,
},
};

View file

@ -5,6 +5,7 @@ export * from "./endpoints";
export * from "./file";
export * from "./filter";
export * from "./graph";
export * from "./icons";
export * from "./instance";
export * from "./issue";
export * from "./metadata";
@ -21,7 +22,7 @@ export * from "./module";
export * from "./project";
export * from "./views";
export * from "./themes";
export * from "./inbox";
export * from "./intake";
export * from "./profile";
export * from "./workspace-drafts";
export * from "./label";
@ -33,4 +34,5 @@ export * from "./emoji";
export * from "./subscription";
export * from "./settings";
export * from "./icon";
export * from "./estimates";
export * from "./analytics";

View file

@ -95,3 +95,32 @@ export const INBOX_ISSUE_SORT_BY_OPTIONS = [
i18n_label: "common.sort.desc",
},
];
export enum EPastDurationFilters {
TODAY = "today",
YESTERDAY = "yesterday",
LAST_7_DAYS = "last_7_days",
LAST_30_DAYS = "last_30_days",
}
export const PAST_DURATION_FILTER_OPTIONS: {
name: string;
value: string;
}[] = [
{
name: "Today",
value: EPastDurationFilters.TODAY,
},
{
name: "Yesterday",
value: EPastDurationFilters.YESTERDAY,
},
{
name: "Last 7 days",
value: EPastDurationFilters.LAST_7_DAYS,
},
{
name: "Last 30 days",
value: EPastDurationFilters.LAST_30_DAYS,
},
];

View file

@ -1,4 +1,5 @@
"use client"
export type TStateGroups = "backlog" | "unstarted" | "started" | "completed" | "cancelled";
export type TDraggableData = {

View file

@ -0,0 +1,89 @@
// plane imports
import { TDocumentPayload, TDuplicateAssetData, TDuplicateAssetResponse } from "@plane/types";
import { TEditorAssetType } from "@plane/types/src/enums";
// local imports
import { convertHTMLDocumentToAllFormats } from "./yjs-utils";
/**
* @description function to extract all image assets from HTML content
* @param htmlContent
* @returns {string[]} array of image asset sources
*/
export const extractImageAssetsFromHTMLContent = (htmlContent: string): string[] => {
// create a DOM parser
const parser = new DOMParser();
// parse the HTML string into a DOM document
const doc = parser.parseFromString(htmlContent, "text/html");
// get all image components
const imageComponents = doc.querySelectorAll("image-component");
// collect all unique image sources
const imageSources = new Set<string>();
// extract sources from image components
imageComponents.forEach((component) => {
const src = component.getAttribute("src");
if (src) imageSources.add(src);
});
return Array.from(imageSources);
};
/**
* @description function to replace image assets in HTML content with new IDs
* @param props
* @returns {string} HTML content with replaced image assets
*/
export const replaceImageAssetsInHTMLContent = (props: {
htmlContent: string;
assetMap: Record<string, string>;
}): string => {
const { htmlContent, assetMap } = 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 sources in image components
const imageComponents = doc.querySelectorAll("image-component");
imageComponents.forEach((component) => {
const oldSrc = component.getAttribute("src");
if (oldSrc && assetMap[oldSrc]) {
component.setAttribute("src", assetMap[oldSrc]);
}
});
// serialize the document back into a string
return doc.body.innerHTML;
};
export const getEditorContentWithReplacedImageAssets = async (props: {
descriptionHTML: string;
entityId: string;
entityType: TEditorAssetType;
projectId: string | undefined;
variant: "rich" | "document";
duplicateAssetService: (params: TDuplicateAssetData) => Promise<TDuplicateAssetResponse>;
}): Promise<TDocumentPayload> => {
const { descriptionHTML, entityId, entityType, projectId, variant, duplicateAssetService } = props;
let replacedDescription = descriptionHTML;
// step 1: extract image assets from the description
const imageAssets = extractImageAssetsFromHTMLContent(descriptionHTML);
if (imageAssets.length !== 0) {
// step 2: duplicate the image assets
const duplicateAssetsResponse = await duplicateAssetService({
entity_id: entityId,
entity_type: entityType,
project_id: projectId,
asset_ids: imageAssets,
});
if (Object.keys(duplicateAssetsResponse ?? {}).length > 0) {
// step 3: replace the image assets in the description
replacedDescription = replaceImageAssetsInHTMLContent({
htmlContent: descriptionHTML,
assetMap: duplicateAssetsResponse,
});
}
}
// step 4: convert the description to the document payload
const documentPayload = convertHTMLDocumentToAllFormats({
document_html: replacedDescription,
variant,
});
return documentPayload;
};

View file

@ -3,6 +3,7 @@ import { generateHTML, generateJSON } from "@tiptap/html";
import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
import * as Y from "yjs";
// extensions
import { TDocumentPayload } from "@plane/types";
import {
CoreEditorExtensionsWithoutProps,
DocumentEditorExtensionsWithoutProps,
@ -140,3 +141,50 @@ export const getAllDocumentFormatsFromDocumentEditorBinaryData = (
contentHTML,
};
};
type TConvertHTMLDocumentToAllFormatsArgs = {
document_html: string;
variant: "rich" | "document";
};
/**
* @description Converts HTML content to all supported document formats (JSON, HTML, and binary)
* @param {TConvertHTMLDocumentToAllFormatsArgs} args - Arguments containing HTML content and variant type
* @param {string} args.document_html - The HTML content to convert
* @param {"rich" | "document"} args.variant - The type of editor variant to use for conversion
* @returns {TDocumentPayload} Object containing the document in all supported formats
* @throws {Error} If an invalid variant is provided
*/
export const convertHTMLDocumentToAllFormats = (args: TConvertHTMLDocumentToAllFormatsArgs): TDocumentPayload => {
const { document_html, variant } = args;
let allFormats: TDocumentPayload;
if (variant === "rich") {
// Convert HTML to binary format for rich text editor
const contentBinary = getBinaryDataFromRichTextEditorHTMLString(document_html);
// Generate all document formats from the binary data
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromRichTextEditorBinaryData(contentBinary);
allFormats = {
description: contentJSON,
description_html: contentHTML,
description_binary: contentBinaryEncoded,
};
} else if (variant === "document") {
// Convert HTML to binary format for document editor
const contentBinary = getBinaryDataFromDocumentEditorHTMLString(document_html);
// Generate all document formats from the binary data
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromDocumentEditorBinaryData(contentBinary);
allFormats = {
description: contentJSON,
description_html: contentHTML,
description_binary: contentBinaryEncoded,
};
} else {
throw new Error(`Invalid variant provided: ${variant}`);
}
return allFormats;
};

View file

@ -1,7 +1,6 @@
import { Fragment, Slice, Node, Schema } from "@tiptap/pm/model";
import { NodeSelection } from "@tiptap/pm/state";
// @ts-expect-error __serializeForClipboard's is not exported
import { __serializeForClipboard, EditorView } from "@tiptap/pm/view";
import { EditorView } from "@tiptap/pm/view";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
@ -417,7 +416,7 @@ const handleNodeSelection = (
}
const slice = view.state.selection.content();
const { dom, text } = __serializeForClipboard(view, slice);
const { dom, text } = view.serializeForClipboard(slice);
if (event instanceof DragEvent && event.dataTransfer) {
event.dataTransfer.clearData();

View file

@ -1,120 +1,76 @@
import * as React from "react"
import * as React from "react";
import { cn } from "@plane/utils"
import { cn } from "@plane/utils";
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
</div>
))
Table.displayName = "Table"
)
);
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("bg-custom-background-80 py-4 border-y border-custom-border-200", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("", className)}
{...props}
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<thead
ref={ref}
className={cn("bg-custom-background-80 py-4 border-y border-custom-border-200", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
)
);
TableHeader.displayName = "TableHeader";
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"bg-custom-background-300 font-medium",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => <tbody ref={ref} className={cn("", className)} {...props} />
);
TableBody.displayName = "TableBody";
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<tfoot ref={ref} className={cn("bg-custom-background-300 font-medium", className)} {...props} />
)
);
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"transition-colors data-[state=selected]:bg-custom-background-100",
className
)}
{...props}
ref={ref}
className={cn("transition-colors data-[state=selected]:bg-custom-background-100", className)}
{...props}
/>
))
TableRow.displayName = "TableRow"
)
);
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableHeaderCellElement,
React.ThHTMLAttributes<HTMLTableHeaderCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-custom-text-300 [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableHead = React.forwardRef<HTMLElement, React.ThHTMLAttributes<HTMLElement>>(({ className, ...props }, ref) => (
<th
ref={ref as any}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-custom-text-300 [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableDataCellElement,
React.TdHTMLAttributes<HTMLTableDataCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCell = React.forwardRef<HTMLElement, React.TdHTMLAttributes<HTMLElement>>(({ className, ...props }, ref) => (
<td
ref={ref as any}
className={cn("p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", className)}
{...props}
/>
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableDataCellElement,
React.HTMLAttributes<HTMLTableDataCellElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-custom-text-300", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
const TableCaption = React.forwardRef<HTMLElement, React.HTMLAttributes<HTMLElement>>(
({ className, ...props }, ref) => (
<caption ref={ref as any} className={cn("mt-4 text-sm text-custom-text-300", className)} {...props} />
)
);
TableCaption.displayName = "TableCaption";
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };

View file

@ -1,7 +1,8 @@
{
"extends": "@plane/typescript-config/react-library.json",
"compilerOptions": {
"outDir": "dist"
"jsx": "react",
"lib": ["esnext", "dom"]
},
"include": ["src"],
"exclude": ["node_modules", "dist"]

View file

@ -1,6 +1,7 @@
// plane imports
import { API_BASE_URL } from "@plane/constants";
// api service
import { TDuplicateAssetData, TDuplicateAssetResponse } from "@plane/types";
import { APIService } from "../api.service";
// helpers
import { getAssetIdFromUrl } from "./helper";
@ -64,4 +65,19 @@ export class FileService extends APIService {
throw error?.response?.data;
});
}
/**
* Duplicates assets
* @param {string} workspaceSlug - The workspace slug
* @param {TDuplicateAssetData} data - The data for the duplicate assets
* @returns {Promise<TDuplicateAssetResponse>} Promise resolving to a record of asset IDs
* @throws {Error} If the request fails
*/
async duplicateAssets(workspaceSlug: string, data: TDuplicateAssetData): Promise<TDuplicateAssetResponse> {
return this.post(`/api/assets/v2/workspaces/${workspaceSlug}/duplicate-assets/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View file

@ -2,3 +2,28 @@ export interface ICalendarRange {
startDate: Date;
endDate: Date;
}
export interface ICalendarDate {
date: Date;
year: number;
month: number;
day: number;
week: number; // week number wrt year, eg- 51, 52
is_current_month: boolean;
is_current_week: boolean;
is_today: boolean;
}
export interface ICalendarWeek {
[date: string]: ICalendarDate;
}
export interface ICalendarMonth {
[monthIndex: string]: {
[weekNumber: string]: ICalendarWeek;
};
}
export interface ICalendarPayload {
[year: string]: ICalendarMonth;
}

View file

@ -136,3 +136,16 @@ export type TPublicCycle = {
name: string;
status: string;
};
export type TProgressChartData = {
date: string;
scope: number;
completed: number;
backlog: number;
started: number;
unstarted: number;
cancelled: number;
pending: number;
ideal: number;
actual: number;
}[];

View file

@ -68,8 +68,18 @@ export enum EFileAssetType {
TEAM_SPACE_COMMENT_DESCRIPTION = "TEAM_SPACE_COMMENT_DESCRIPTION",
}
export type TEditorAssetType =
| EFileAssetType.COMMENT_DESCRIPTION
| EFileAssetType.ISSUE_DESCRIPTION
| EFileAssetType.DRAFT_ISSUE_DESCRIPTION
| EFileAssetType.PAGE_DESCRIPTION
| EFileAssetType.TEAM_SPACE_DESCRIPTION
| EFileAssetType.INITIATIVE_DESCRIPTION
| EFileAssetType.PROJECT_DESCRIPTION
| EFileAssetType.TEAM_SPACE_COMMENT_DESCRIPTION;
export enum EUpdateStatus {
OFF_TRACK = "OFF-TRACK",
ON_TRACK = "ON-TRACK",
AT_RISK = "AT-RISK",
}
}

View file

@ -1,16 +1,16 @@
import { EFileAssetType } from "./enums"
import { EFileAssetType } from "./enums";
export type TFileMetaDataLite = {
name: string;
// file size in bytes
size: number;
type: string;
}
};
export type TFileEntityInfo = {
entity_identifier: string;
entity_type: EFileAssetType;
}
};
export type TFileMetaData = TFileMetaDataLite & TFileEntityInfo;
@ -29,4 +29,13 @@ export type TFileSignedURLResponse = {
"x-amz-signature": string;
};
};
};
};
export type TDuplicateAssetData = {
entity_id: string;
entity_type: EFileAssetType;
project_id?: string;
asset_ids: string[];
};
export type TDuplicateAssetResponse = Record<string, string>; // asset_id -> new_asset_id

View file

@ -43,4 +43,5 @@ export * from "./home";
export * from "./stickies";
export * from "./utils";
export * from "./payment";
export * from "./layout";
export * from "./analytics";

52
packages/types/src/layout/gantt.d.ts vendored Normal file
View file

@ -0,0 +1,52 @@
export interface IGanttBlock {
data: any;
id: string;
name: string;
position?: {
marginLeft: number;
width: number;
};
sort_order: number | undefined;
start_date: string | undefined;
target_date: string | undefined;
}
export interface IBlockUpdateData {
sort_order?: {
destinationIndex: number;
newSortOrder: number;
sourceIndex: number;
};
start_date?: string;
target_date?: string;
}
export interface IBlockUpdateDependencyData {
id: string;
start_date?: string;
target_date?: string;
}
export type TGanttViews = "week" | "month" | "quarter";
// chart render types
export interface WeekMonthDataType {
key: number;
shortTitle: string;
title: string;
abbreviation: string;
}
export interface ChartDataType {
key: string;
i18n_title: string;
data: ChartDataTypeData;
}
export interface ChartDataTypeData {
startDate: Date;
currentDate: Date;
endDate: Date;
approxFilterRange: number;
dayWidth: number;
}

View file

@ -0,0 +1 @@
export * from "./gantt";

View file

@ -141,3 +141,7 @@ export interface ISearchIssueResponse {
workspace__slug: string;
type_id: string;
}
export type TPartialProject = IPartialProject;
export type TProject = TPartialProject & IProject;

View file

@ -1,14 +1,14 @@
import { Search } from "lucide-react";
import React, { useEffect, useState } from "react";
// icons
import { Search } from "lucide-react";
import { MATERIAL_ICONS_LIST } from "./icons";
import useFontFaceObserver from "use-font-face-observer";
import { MATERIAL_ICONS_LIST } from "@plane/constants";
import { cn } from "../../helpers";
import { Input } from "../form-fields";
import { InfoIcon } from "../icons";
// components
import { Input } from "../form-fields";
// hooks
import useFontFaceObserver from "use-font-face-observer";
// helpers
import { cn } from "../../helpers";
import { DEFAULT_COLORS, TIconsListProps, adjustColorForContrast } from "./emoji-icon-helper";
export const IconsList: React.FC<TIconsListProps> = (props) => {

View file

@ -1,5 +1,4 @@
export * from "./emoji-icon-picker-new";
export * from "./emoji-icon-picker";
export * from "./emoji-icon-helper";
export * from "./icons";
export * from "./logo";

View file

@ -1,9 +1,9 @@
import React, { FC } from "react";
import { Emoji } from "emoji-picker-react";
import React, { FC } from "react";
import useFontFaceObserver from "use-font-face-observer";
// icons
import { LUCIDE_ICONS_LIST } from "./icons";
// helpers
// plane imports
import { LUCIDE_ICONS_LIST } from "@plane/constants";
// local imports
import { emojiCodeToUnicode } from "./helpers";
export type TEmojiLogoProps = {

View file

@ -1,14 +1,12 @@
import React, { useEffect, useState } from "react";
// components
import { Input } from "../form-fields";
// helpers
import { cn } from "../../helpers";
import { DEFAULT_COLORS, TIconsListProps, adjustColorForContrast } from "./emoji-icon-helper";
// icons
import { InfoIcon } from "../icons";
// constants
import { LUCIDE_ICONS_LIST } from "./icons";
import { Search } from "lucide-react";
import React, { useEffect, useState } from "react";
// plane imports
import { LUCIDE_ICONS_LIST } from "@plane/constants";
// local imports
import { cn } from "../../helpers";
import { Input } from "../form-fields";
import { InfoIcon } from "../icons";
import { DEFAULT_COLORS, TIconsListProps, adjustColorForContrast } from "./emoji-icon-helper";
export const LucideIconsList: React.FC<TIconsListProps> = (props) => {
const { defaultColor, onChange, searchDisabled = false } = props;

View file

@ -1,11 +1,14 @@
"use client";
import { ReactNode } from "react";
import zxcvbn from "zxcvbn";
// plane imports
import {
E_PASSWORD_STRENGTH,
SPACE_PASSWORD_CRITERIA,
PASSWORD_MIN_LENGTH,
EErrorAlertType,
EAuthErrorCodes,
TAuthErrorInfo,
} from "@plane/constants";
/**
@ -30,50 +33,29 @@ export type PasswordCriterion = {
/**
* @description Password strength criteria
*/
export const PASSWORD_CRITERIA: PasswordCriterion[] = [
{ regex: /[a-z]/, description: "lowercase" },
{ regex: /[A-Z]/, description: "uppercase" },
{ regex: /[0-9]/, description: "number" },
{ regex: /[^a-zA-Z0-9]/, description: "special character" },
export const PASSWORD_CRITERIA = [
{
key: "min_8_char",
label: "Min 8 characters",
isCriteriaValid: (password: string) => password.length >= PASSWORD_MIN_LENGTH,
},
// {
// key: "min_1_upper_case",
// label: "Min 1 upper-case letter",
// isCriteriaValid: (password: string) => PASSWORD_NUMBER_REGEX.test(password),
// },
// {
// key: "min_1_number",
// label: "Min 1 number",
// isCriteriaValid: (password: string) => PASSWORD_CHAR_CAPS_REGEX.test(password),
// },
// {
// key: "min_1_special_char",
// label: "Min 1 special character",
// isCriteriaValid: (password: string) => PASSWORD_SPECIAL_CHAR_REGEX.test(password),
// },
];
/**
* @description Checks if password meets all criteria
* @param {string} password - Password to check
* @returns {boolean} Whether password meets all criteria
*/
export const checkPasswordCriteria = (password: string): boolean =>
PASSWORD_CRITERIA.every((criterion) => criterion.regex.test(password));
/**
* @description Checks password strength against criteria
* @param {string} password - Password to check
* @returns {PasswordStrength} Password strength level
* @example
* checkPasswordStrength("abc") // returns PasswordStrength.WEAK
* checkPasswordStrength("Abc123!@#") // returns PasswordStrength.STRONG
*/
export const checkPasswordStrength = (password: string): PasswordStrength => {
if (!password || password.length === 0) return PasswordStrength.EMPTY;
if (password.length < PASSWORD_MIN_LENGTH) return PasswordStrength.WEAK;
const criteriaCount = PASSWORD_CRITERIA.filter((criterion) => criterion.regex.test(password)).length;
const zxcvbnScore = zxcvbn(password).score;
if (criteriaCount <= 1 || zxcvbnScore <= 1) return PasswordStrength.WEAK;
if (criteriaCount === 2 || zxcvbnScore === 2) return PasswordStrength.FAIR;
if (criteriaCount === 3 || zxcvbnScore === 3) return PasswordStrength.GOOD;
return PasswordStrength.STRONG;
};
export type TAuthErrorInfo = {
type: EErrorAlertType;
code: EAuthErrorCodes;
title: string;
message: ReactNode;
};
// Password strength check
export const getPasswordStrength = (password: string): E_PASSWORD_STRENGTH => {
let passwordStrength: E_PASSWORD_STRENGTH = E_PASSWORD_STRENGTH.EMPTY;
@ -89,9 +71,9 @@ export const getPasswordStrength = (password: string): E_PASSWORD_STRENGTH => {
return passwordStrength;
}
const passwordCriteriaValidation = SPACE_PASSWORD_CRITERIA.map((criteria) =>
criteria.isCriteriaValid(password)
).every((criterion) => criterion);
const passwordCriteriaValidation = PASSWORD_CRITERIA.map((criteria) => criteria.isCriteriaValid(password)).every(
(criterion) => criterion
);
const passwordStrengthScore = zxcvbn(password).score;
if (passwordCriteriaValidation === false || passwordStrengthScore <= 2) {

View file

@ -0,0 +1,74 @@
// plane imports
import { EStartOfTheWeek } from "@plane/constants";
import { ICalendarDate, ICalendarPayload } from "@plane/types";
// local imports
import { getWeekNumberOfDate, renderFormattedPayloadDate } from "./datetime";
/**
* @returns {ICalendarPayload} calendar payload to render the calendar
* @param {ICalendarPayload | null} currentStructure current calendar payload
* @param {Date} startDate date of the month to render
* @description Returns calendar payload to render the calendar, if currentStructure is null, it will generate the payload for the month of startDate, else it will construct the payload for the month of startDate and append it to the currentStructure
*/
export const generateCalendarData = (currentStructure: ICalendarPayload | null, startDate: Date): ICalendarPayload => {
const calendarData: ICalendarPayload = currentStructure ?? {};
const startMonth = startDate.getMonth();
const startYear = startDate.getFullYear();
const currentDate = new Date(startYear, startMonth, 1);
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const totalDaysInMonth = new Date(year, month + 1, 0).getDate();
const firstDayOfMonth = new Date(year, month, 1).getDay(); // Sunday is 0, Monday is 1, ..., Saturday is 6
calendarData[`y-${year}`] ||= {};
calendarData[`y-${year}`][`m-${month}`] ||= {};
const numWeeks = Math.ceil((totalDaysInMonth + firstDayOfMonth) / 7);
for (let week = 0; week < numWeeks; week++) {
const currentWeekObject: { [date: string]: ICalendarDate } = {};
const weekNumber = getWeekNumberOfDate(new Date(year, month, week * 7 - firstDayOfMonth + 1));
for (let i = 0; i < 7; i++) {
const dayNumber = week * 7 + i - firstDayOfMonth;
const date = new Date(year, month, dayNumber + 1);
const formattedDatePayload = renderFormattedPayloadDate(date);
if (formattedDatePayload)
currentWeekObject[formattedDatePayload] = {
date,
year,
month,
day: dayNumber + 1,
week: weekNumber,
is_current_month: date.getMonth() === month,
is_current_week: getWeekNumberOfDate(date) === getWeekNumberOfDate(new Date()),
is_today: date.toDateString() === new Date().toDateString(),
};
}
calendarData[`y-${year}`][`m-${month}`][`w-${weekNumber}`] = currentWeekObject;
}
return calendarData;
};
/**
* Returns a new array sorted by the startOfWeek.
* @param items Array of items to sort.
* @param getDayIndex Function to get the day index (0-6) from an item.
* @param startOfWeek The day to start the week on.
*/
export const getOrderedDays = <T>(
items: T[],
getDayIndex: (item: T) => number,
startOfWeek: EStartOfTheWeek = EStartOfTheWeek.SUNDAY
): T[] => [...items].sort((a, b) => {
const dayA = (7 + getDayIndex(a) - startOfWeek) % 7;
const dayB = (7 + getDayIndex(b) - startOfWeek) % 7;
return dayA - dayB;
})

View file

@ -1,13 +1,13 @@
/**
* Represents an RGB color with numeric values for red, green, and blue components
* @typedef {Object} RGB
* @typedef {Object} TRgb
* @property {number} r - Red component (0-255)
* @property {number} g - Green component (0-255)
* @property {number} b - Blue component (0-255)
*/
export type RGB = { r: number; g: number; b: number };
export type TRgb = { r: number; g: number; b: number };
export type HSL = { h: number; s: number; l: number };
export type THsl = { h: number; s: number; l: number };
/**
* @description Validates and clamps color values to RGB range (0-255)
@ -40,7 +40,7 @@ export const toHex = (value: number) => validateColor(value).toString(16).padSta
* hexToRgb("#00ff00") // returns { r: 0, g: 255, b: 0 }
* hexToRgb("#0000ff") // returns { r: 0, g: 0, b: 255 }
*/
export const hexToRgb = (hex: string): RGB => {
export const hexToRgb = (hex: string): TRgb => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex.trim());
return result
? {
@ -63,7 +63,7 @@ export const hexToRgb = (hex: string): RGB => {
* rgbToHex({ r: 0, g: 255, b: 0 }) // returns "#00ff00"
* rgbToHex({ r: 0, g: 0, b: 255 }) // returns "#0000ff"
*/
export const rgbToHex = ({ r, g, b }: RGB): string => `#${toHex(r)}${toHex(g)}${toHex(b)}`;
export const rgbToHex = ({ r, g, b }: TRgb): string => `#${toHex(r)}${toHex(g)}${toHex(b)}`;
/**
* Converts Hex values to HSL values
@ -74,7 +74,7 @@ export const rgbToHex = ({ r, g, b }: RGB): string => `#${toHex(r)}${toHex(g)}${
* hexToHsl("#00ff00") // returns { h: 120, s: 100, l: 50 }
* hexToHsl("#0000ff") // returns { h: 240, s: 100, l: 50 }
*/
export const hexToHsl = (hex: string): HSL => {
export const hexToHsl = (hex: string): THsl => {
// return default value for invalid hex
if (!/^#[0-9A-Fa-f]{6}$/.test(hex)) return { h: 0, s: 0, l: 0 };
@ -124,7 +124,7 @@ export const hexToHsl = (hex: string): HSL => {
* hslToHex({ h: 120, s: 100, l: 50 }) // returns "#00ff00"
* hslToHex({ h: 240, s: 100, l: 50 }) // returns "#0000ff"
*/
export const hslToHex = ({ h, s, l }: HSL): string => {
export const hslToHex = ({ h, s, l }: THsl): string => {
if (h < 0 || h > 360) return "#000000";
if (s < 0 || s > 100) return "#000000";
if (l < 0 || l > 100) return "#000000";
@ -142,3 +142,158 @@ export const hslToHex = ({ h, s, l }: HSL): string => {
return `#${f(0)}${f(8)}${f(4)}`;
};
/**
* Calculate relative luminance of a color according to WCAG
* @param {Object} rgb - RGB color object with r, g, b properties
* @returns {number} Relative luminance value
*/
export const getLuminance = ({ r, g, b }: TRgb) => {
// Convert RGB to sRGB
const sR = r / 255;
const sG = g / 255;
const sB = b / 255;
// Convert sRGB to linear RGB with gamma correction
const R = sR <= 0.03928 ? sR / 12.92 : Math.pow((sR + 0.055) / 1.055, 2.4);
const G = sG <= 0.03928 ? sG / 12.92 : Math.pow((sG + 0.055) / 1.055, 2.4);
const B = sB <= 0.03928 ? sB / 12.92 : Math.pow((sB + 0.055) / 1.055, 2.4);
// Calculate luminance
return 0.2126 * R + 0.7152 * G + 0.0722 * B;
};
/**
* Calculate contrast ratio between two colors
* @param {Object} rgb1 - First RGB color object
* @param {Object} rgb2 - Second RGB color object
* @returns {number} Contrast ratio between the colors
*/
export function getContrastRatio(rgb1: { r: number; g: number; b: number }, rgb2: { r: number; g: number; b: number }) {
const luminance1 = getLuminance(rgb1);
const luminance2 = getLuminance(rgb2);
const lighter = Math.max(luminance1, luminance2);
const darker = Math.min(luminance1, luminance2);
return (lighter + 0.05) / (darker + 0.05);
}
/**
* Lighten a color by a specified amount
* @param {Object} rgb - RGB color object
* @param {number} amount - Amount to lighten (0-1)
* @returns {Object} Lightened RGB color
*/
export function lightenColor(rgb: { r: number; g: number; b: number }, amount: number) {
return {
r: rgb.r + (255 - rgb.r) * amount,
g: rgb.g + (255 - rgb.g) * amount,
b: rgb.b + (255 - rgb.b) * amount,
};
}
/**
* Darken a color by a specified amount
* @param {Object} rgb - RGB color object
* @param {number} amount - Amount to darken (0-1)
* @returns {Object} Darkened RGB color
*/
export function darkenColor(rgb: { r: number; g: number; b: number }, amount: number) {
return {
r: rgb.r * (1 - amount),
g: rgb.g * (1 - amount),
b: rgb.b * (1 - amount),
};
}
/**
* Generate appropriate foreground and background colors based on input color
* @param {string} color - Input color in hex format
* @returns {Object} Object containing foreground and background colors in hex format
*/
export function generateIconColors(color: string) {
// Parse input color
const rgbColor = hexToRgb(color);
const luminance = getLuminance(rgbColor);
// Initialize output colors
let foregroundColor = rgbColor;
// Constants for color adjustment
const MIN_CONTRAST_RATIO = 3.0; // Minimum acceptable contrast ratio
// For light colors, use as foreground and darken for background
if (luminance > 0.5) {
// Make sure the foreground color is dark enough for visibility
let adjustedForeground = foregroundColor;
const whiteContrast = getContrastRatio(foregroundColor, { r: 255, g: 255, b: 255 });
if (whiteContrast < MIN_CONTRAST_RATIO) {
// Darken the foreground color until it has enough contrast
let darkenAmount = 0.1;
while (darkenAmount <= 0.9) {
adjustedForeground = darkenColor(foregroundColor, darkenAmount);
if (getContrastRatio(adjustedForeground, { r: 255, g: 255, b: 255 }) >= MIN_CONTRAST_RATIO) {
break;
}
darkenAmount += 0.1;
}
foregroundColor = adjustedForeground;
}
}
// For dark colors, use as foreground and lighten for background
else {
// Make sure the foreground color is light enough for visibility
let adjustedForeground = foregroundColor;
const blackContrast = getContrastRatio(foregroundColor, { r: 0, g: 0, b: 0 });
if (blackContrast < MIN_CONTRAST_RATIO) {
// Lighten the foreground color until it has enough contrast
let lightenAmount = 0.1;
while (lightenAmount <= 0.9) {
adjustedForeground = lightenColor(foregroundColor, lightenAmount);
if (getContrastRatio(adjustedForeground, { r: 0, g: 0, b: 0 }) >= MIN_CONTRAST_RATIO) {
break;
}
lightenAmount += 0.1;
}
foregroundColor = adjustedForeground;
}
}
return {
foreground: rgbToHex({ r: foregroundColor.r, g: foregroundColor.g, b: foregroundColor.b }),
background: `rgba(${foregroundColor.r}, ${foregroundColor.g}, ${foregroundColor.b}, 0.25)`,
};
}
/**
* @description Generates a deterministic HSL color based on input string
* @param {string} input - Input string to generate color from
* @returns {THsl} An object containing the HSL values
* @example
* generateRandomColor("hello") // returns consistent HSL color for "hello"
* generateRandomColor("") // returns { h: 0, s: 0, l: 0 }
*/
export const generateRandomColor = (input: string): THsl => {
// If input is falsy, generate a random seed string.
// The random seed is created by converting a random number to base-36 and taking a substring.
const seed = input || Math.random().toString(36).substring(2, 8);
const uniqueId = seed.length.toString() + seed; // Unique identifier based on string length
const combinedString = uniqueId + seed;
// Create a hash value from the combined string.
const hash = Array.from(combinedString).reduce((acc, char) => {
const charCode = char.charCodeAt(0);
return (acc << 5) - acc + charCode;
}, 0);
// Derive the HSL values from the hash.
const hue = Math.abs(hash % 360);
const saturation = 70; // Maintains a good amount of color
const lightness = 70; // Increased lightness for a pastel look
return { h: hue, s: saturation, l: lightness };
};

View file

@ -58,3 +58,5 @@ export const isComplete = <T>(obj: CompleteOrEmpty<T>): obj is T => {
// Check if it has any own properties
return Object.keys(obj).length > 0;
};
export const convertRemToPixel = (rem: number): number => rem * 0.9 * 16;

193
packages/utils/src/cycle.ts Normal file
View file

@ -0,0 +1,193 @@
import { startOfToday, format } from "date-fns";
import isEmpty from "lodash/isEmpty";
import orderBy from "lodash/orderBy";
import sortBy from "lodash/sortBy";
import uniqBy from "lodash/uniqBy";
// plane imports
import { ICycle, TCycleFilters } from "@plane/types";
// local imports
import { findTotalDaysInRange, generateDateArray, getDate } from "./datetime";
import { satisfiesDateFilter } from "./filter";
/**
* Orders cycles based on their status
* @param {ICycle[]} cycles - Array of cycles to be ordered
* @param {boolean} sortByManual - Whether to sort by manual order
* @returns {ICycle[]} Ordered array of cycles
*/
export const orderCycles = (cycles: ICycle[], sortByManual: boolean): ICycle[] => {
if (cycles.length === 0) return [];
const acceptedStatuses = ["current", "upcoming", "draft"];
const STATUS_ORDER: {
[key: string]: number;
} = {
current: 1,
upcoming: 2,
draft: 3,
};
let filteredCycles = cycles.filter((c) => acceptedStatuses.includes(c.status?.toLowerCase() ?? ""));
if (sortByManual) filteredCycles = sortBy(filteredCycles, [(c) => c.sort_order]);
else
filteredCycles = sortBy(filteredCycles, [
(c) => STATUS_ORDER[c.status?.toLowerCase() ?? ""],
(c) => (c.status?.toLowerCase() === "upcoming" ? c.start_date : c.name.toLowerCase()),
]);
return filteredCycles;
};
/**
* Filters cycles based on provided filter criteria
* @param {ICycle} cycle - The cycle to be filtered
* @param {TCycleFilters} filter - Filter criteria to apply
* @returns {boolean} Whether the cycle passes the filter
*/
export const shouldFilterCycle = (cycle: ICycle, filter: TCycleFilters): boolean => {
let fallsInFilters = true;
Object.keys(filter).forEach((key) => {
const filterKey = key as keyof TCycleFilters;
if (filterKey === "status" && filter.status && filter.status.length > 0)
fallsInFilters = fallsInFilters && filter.status.includes(cycle.status?.toLowerCase() ?? "");
if (filterKey === "start_date" && filter.start_date && filter.start_date.length > 0) {
const startDate = getDate(cycle.start_date);
filter.start_date.forEach((dateFilter) => {
fallsInFilters = fallsInFilters && !!startDate && satisfiesDateFilter(startDate, dateFilter);
});
}
if (filterKey === "end_date" && filter.end_date && filter.end_date.length > 0) {
const endDate = getDate(cycle.end_date);
filter.end_date.forEach((dateFilter) => {
fallsInFilters = fallsInFilters && !!endDate && satisfiesDateFilter(endDate, dateFilter);
});
}
});
return fallsInFilters;
};
/**
* Calculates the scope based on whether it's an issue or estimate points
* @param {any} p - Progress data
* @param {boolean} isTypeIssue - Whether the type is an issue
* @returns {number} Calculated scope
*/
const scope = (p: any, isTypeIssue: boolean) => (isTypeIssue ? p.total_issues : p.total_estimate_points);
/**
* Calculates the ideal progress value
* @param {string} date - Current date
* @param {number} scope - Total scope
* @param {ICycle} cycle - Cycle data
* @returns {number} Ideal progress value
*/
const ideal = (date: string, scope: number, cycle: ICycle) =>
Math.floor(
((findTotalDaysInRange(date, cycle.end_date) || 0) /
(findTotalDaysInRange(cycle.start_date, cycle.end_date) || 0)) *
scope
);
/**
* Formats cycle data for version 1
* @param {boolean} isTypeIssue - Whether the type is an issue
* @param {ICycle} cycle - Cycle data
* @param {boolean} isBurnDown - Whether it's a burn down chart
* @param {Date|string} endDate - End date
* @returns {TProgressChartData} Formatted progress data
*/
const formatV1Data = (isTypeIssue: boolean, cycle: ICycle, isBurnDown: boolean, endDate: Date | string) => {
const today = format(startOfToday(), "yyyy-MM-dd");
const data = isTypeIssue ? cycle.distribution : cycle.estimate_distribution;
const extendedArray = generateDateArray(endDate, endDate).map((d) => d.date);
if (isEmpty(data)) return [];
const progress = [...Object.keys(data.completion_chart), ...extendedArray].map((p) => {
const pending = data.completion_chart[p] || 0;
const total = isTypeIssue ? cycle.total_issues : cycle.total_estimate_points;
const completed = scope(cycle, isTypeIssue) - pending;
return {
date: p,
scope: p! < today ? scope(cycle, isTypeIssue) : null,
completed,
backlog: isTypeIssue ? cycle.backlog_issues : cycle.backlog_estimate_points,
started: p === today ? cycle[isTypeIssue ? "started_issues" : "started_estimate_points"] : undefined,
unstarted: p === today ? cycle[isTypeIssue ? "unstarted_issues" : "unstarted_estimate_points"] : undefined,
cancelled: p === today ? cycle[isTypeIssue ? "cancelled_issues" : "cancelled_estimate_points"] : undefined,
pending: Math.abs(pending || 0),
ideal:
p < today
? ideal(p, total || 0, cycle)
: p <= cycle.end_date!
? ideal(today as string, total || 0, cycle)
: null,
actual: p <= today ? (isBurnDown ? Math.abs(pending) : completed) : undefined,
};
});
return progress;
};
/**
* Formats cycle data for version 2
* @param {boolean} isTypeIssue - Whether the type is an issue
* @param {ICycle} cycle - Cycle data
* @param {boolean} isBurnDown - Whether it's a burn down chart
* @param {Date|string} endDate - End date
* @returns {TProgressChartData} Formatted progress data
*/
const formatV2Data = (isTypeIssue: boolean, cycle: ICycle, isBurnDown: boolean, endDate: Date | string) => {
if (!cycle.progress) return [];
let today: Date | string = startOfToday();
const extendedArray = endDate > today ? generateDateArray(today as Date, endDate) : [];
if (isEmpty(cycle.progress)) return extendedArray;
today = format(startOfToday(), "yyyy-MM-dd");
const todaysData = cycle?.progress[cycle?.progress.length - 1];
const scopeToday = scope(todaysData, isTypeIssue);
const idealToday = ideal(todaysData.date, scopeToday, cycle);
let progress = [...orderBy(cycle?.progress, "date"), ...extendedArray].map((p) => {
const pending = isTypeIssue
? p.total_issues - p.completed_issues - p.cancelled_issues
: p.total_estimate_points - p.completed_estimate_points - p.cancelled_estimate_points;
const completed = isTypeIssue ? p.completed_issues : p.completed_estimate_points;
const dataDate = p.progress_date ? format(new Date(p.progress_date), "yyyy-MM-dd") : p.date;
return {
date: dataDate,
scope: dataDate! < today ? scope(p, isTypeIssue) : dataDate! <= cycle.end_date! ? scopeToday : null,
completed,
backlog: isTypeIssue ? p.backlog_issues : p.backlog_estimate_points,
started: isTypeIssue ? p.started_issues : p.started_estimate_points,
unstarted: isTypeIssue ? p.unstarted_issues : p.unstarted_estimate_points,
cancelled: isTypeIssue ? p.cancelled_issues : p.cancelled_estimate_points,
pending: Math.abs(pending),
ideal:
dataDate! < today
? ideal(dataDate, scope(p, isTypeIssue), cycle)
: dataDate! < cycle.end_date!
? idealToday
: null,
actual: dataDate! <= today ? (isBurnDown ? Math.abs(pending) : completed) : undefined,
};
});
progress = uniqBy(progress, "date");
return progress;
};
export const formatActiveCycle = (args: {
cycle: ICycle;
isBurnDown?: boolean | undefined;
isTypeIssue?: boolean | undefined;
}) => {
const { cycle, isBurnDown, isTypeIssue } = args;
const endDate: Date | string = new Date(cycle.end_date!);
return cycle.version === 1
? formatV1Data(isTypeIssue!, cycle, isBurnDown!, endDate)
: formatV2Data(isTypeIssue!, cycle, isBurnDown!, endDate);
};

View file

@ -1,45 +1,15 @@
import { differenceInDays, format, formatDistanceToNow, isAfter, isEqual, isValid, parseISO } from "date-fns";
import isNumber from "lodash/isNumber";
// Format Date Helpers
/**
* This method returns a date from string of type yyyy-mm-dd
* This method is recommended to use instead of new Date() as this does not introduce any timezone offsets
* @param date
* @returns date or undefined
*/
export const getDate = (date: string | Date | undefined | null): Date | undefined => {
try {
if (!date || date === "") return;
if (typeof date !== "string" && !(date instanceof String)) return date;
const [yearString, monthString, dayString] = date.substring(0, 10).split("-");
const year = parseInt(yearString);
const month = parseInt(monthString);
const day = parseInt(dayString);
// Using Number.isInteger instead of lodash's isNumber for better specificity and no external dependency
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return;
return new Date(year, month - 1, day);
} catch (e) {
return undefined;
}
};
/**
* @returns {string | null} formatted date in the format of MMM dd, yyyy
* @returns {string | null} formatted date in the desired format or platform default format (MMM dd, yyyy)
* @description Returns date in the formatted format
* @param {Date | string} date
* @param {string} formatToken (optional) // default MMM dd, yyyy
* @example renderFormattedDate("2024-01-01", "MM-DD-YYYY") // Jan 01, 2024
* @example renderFormattedDate("2024-01-01") // Jan 01, 2024
*/
/**
* @description Returns date in the formatted format
* @param {Date | string} date Date to format
* @param {string} formatToken Format token (optional, default: MMM dd, yyyy)
* @returns {string | undefined} Formatted date in the desired format
* @example
* renderFormattedDate("2024-01-01") // returns "Jan 01, 2024"
* renderFormattedDate("2024-01-01", "MM-DD-YYYY") // returns "01-01-2024"
*/
export const renderFormattedDate = (
date: string | Date | undefined | null,
formatToken: string = "MMM dd, yyyy"
@ -49,7 +19,7 @@ export const renderFormattedDate = (
// return if undefined
if (!parsedDate) return;
// Check if the parsed date is valid before formatting
if (!isValid(parsedDate)) return; // Return undefined for invalid dates
if (!isValid(parsedDate)) return; // Return null for invalid dates
let formattedDate;
try {
// Format the date in the format provided or default format (MMM dd, yyyy)
@ -62,13 +32,75 @@ export const renderFormattedDate = (
};
/**
* @returns {string} formatted date in the format of MMM dd
* @description Returns date in the formatted format
* @param {string | Date} date
* @example renderShortDateFormat("2024-01-01") // Jan 01
*/
export const renderFormattedDateWithoutYear = (date: string | Date): string => {
// Parse the date to check if it is valid
const parsedDate = getDate(date);
// return if undefined
if (!parsedDate) return "";
// Check if the parsed date is valid before formatting
if (!isValid(parsedDate)) return ""; // Return empty string for invalid dates
// Format the date in short format (MMM dd)
const formattedDate = format(parsedDate, "MMM dd");
return formattedDate;
};
/**
* @returns {string | null} formatted date in the format of yyyy-mm-dd to be used in payload
* @description Returns date in the formatted format to be used in payload
* @param {Date | string} date
* @example renderFormattedPayloadDate("Jan 01, 20224") // "2024-01-01"
*/
export const renderFormattedPayloadDate = (date: Date | string | undefined | null): string | undefined => {
// Parse the date to check if it is valid
const parsedDate = getDate(date);
// return if undefined
if (!parsedDate) return;
// Check if the parsed date is valid before formatting
if (!isValid(parsedDate)) return; // Return null for invalid dates
// Format the date in payload format (yyyy-mm-dd)
const formattedDate = format(parsedDate, "yyyy-MM-dd");
return formattedDate;
};
// Format Time Helpers
/**
* @returns {string} formatted date in the format of hh:mm a or HH:mm
* @description Returns date in 12 hour format if in12HourFormat is true else 24 hour format
* @param {string | Date} date
* @param {boolean} timeFormat (optional) // default 24 hour
* @example renderFormattedTime("2024-01-01 13:00:00") // 13:00
* @example renderFormattedTime("2024-01-01 13:00:00", "12-hour") // 01:00 PM
*/
export const renderFormattedTime = (date: string | Date, timeFormat: "12-hour" | "24-hour" = "24-hour"): string => {
// Parse the date to check if it is valid
const parsedDate = new Date(date);
// return if undefined
if (!parsedDate) return "";
// Check if the parsed date is valid
if (!isValid(parsedDate)) return ""; // Return empty string for invalid dates
// Format the date in 12 hour format if in12HourFormat is true
if (timeFormat === "12-hour") {
const formattedTime = format(parsedDate, "hh:mm a");
return formattedTime;
}
// Format the date in 24 hour format
const formattedTime = format(parsedDate, "HH:mm");
return formattedTime;
};
// Date Difference Helpers
/**
* @returns {number} total number of days in range
* @description Returns total number of days in range
* @param {string | Date} startDate - Start date
* @param {string | Date} endDate - End date
* @param {boolean} inclusive - Include start and end dates (optional, default: true)
* @returns {number | undefined} Total number of days
* @example
* findTotalDaysInRange("2024-01-01", "2024-01-08") // returns 8
* @param {string} startDate
* @param {string} endDate
* @param {boolean} inclusive
* @example checkIfStringIsDate("2021-01-01", "2021-01-08") // 8
*/
export const findTotalDaysInRange = (
startDate: Date | string | undefined | null,
@ -89,118 +121,139 @@ export const findTotalDaysInRange = (
};
/**
* @description Add number of days to the provided date
* @param {string | Date} startDate - Start date
* @param {number} numberOfDays - Number of days to add
* @returns {Date | undefined} Resulting date
* @example
* addDaysToDate("2024-01-01", 7) // returns Date(2024-01-08)
* Add number of days to the provided date and return a resulting new date
* @param startDate
* @param numberOfDays
* @returns
*/
export const addDaysToDate = (startDate: Date | string | undefined | null, numberOfDays: number): Date | undefined => {
export const addDaysToDate = (startDate: Date | string | undefined | null, numberOfDays: number) => {
// Parse the dates to check if they are valid
const parsedStartDate = getDate(startDate);
// return if undefined
if (!parsedStartDate) return;
const newDate = new Date(parsedStartDate);
newDate.setDate(newDate.getDate() + numberOfDays);
return newDate;
};
/**
* @returns {number} number of days left from today
* @description Returns number of days left from today
* @param {string | Date} date - Target date
* @param {boolean} inclusive - Include today (optional, default: true)
* @returns {number | undefined} Number of days left
* @example
* findHowManyDaysLeft("2024-01-08") // returns days between today and Jan 8, 2024
* @param {string | Date} date
* @param {boolean} inclusive (optional) // default true
* @example findHowManyDaysLeft("2024-01-01") // 3
*/
export const findHowManyDaysLeft = (
date: Date | string | undefined | null,
inclusive: boolean = true
): number | undefined => {
if (!date) return undefined;
// Pass the date to findTotalDaysInRange function to find the total number of days in range from today
return findTotalDaysInRange(new Date(), date, inclusive);
};
// Time Difference Helpers
/**
* @returns {string} formatted date in the form of amount of time passed since the event happened
* @description Returns time passed since the event happened
* @param {string | number | Date} time - Time to calculate from
* @returns {string} Formatted time ago string
* @example
* calculateTimeAgo("2023-01-01") // returns "1 year ago"
* @param {string | Date} time
* @example calculateTimeAgo("2023-01-01") // 1 year ago
*/
export const calculateTimeAgo = (time: string | number | Date | null): string => {
if (!time) return "";
// Parse the time to check if it is valid
const parsedTime = typeof time === "string" || typeof time === "number" ? parseISO(String(time)) : time;
if (!parsedTime) return "";
// return if undefined
if (!parsedTime) return ""; // Return empty string for invalid dates
// Format the time in the form of amount of time passed since the event happened
const distance = formatDistanceToNow(parsedTime, { addSuffix: true });
return distance;
};
/**
* @description Returns short form of time passed (e.g., 1y, 2mo, 3d)
* @param {string | number | Date} date - Date to calculate from
* @returns {string} Short form time ago
* @example
* calculateTimeAgoShort("2023-01-01") // returns "1y"
*/
export const calculateTimeAgoShort = (date: string | number | Date | null): string => {
if (!date) return "";
export function calculateTimeAgoShort(date: string | number | Date | null): string {
if (!date) {
return "";
}
const parsedDate = typeof date === "string" ? parseISO(date) : new Date(date);
const now = new Date();
const diffInSeconds = (now.getTime() - parsedDate.getTime()) / 1000;
if (diffInSeconds < 60) return `${Math.floor(diffInSeconds)}s`;
if (diffInSeconds < 60) {
return `${Math.floor(diffInSeconds)}s`;
}
const diffInMinutes = diffInSeconds / 60;
if (diffInMinutes < 60) return `${Math.floor(diffInMinutes)}m`;
if (diffInMinutes < 60) {
return `${Math.floor(diffInMinutes)}m`;
}
const diffInHours = diffInMinutes / 60;
if (diffInHours < 24) return `${Math.floor(diffInHours)}h`;
if (diffInHours < 24) {
return `${Math.floor(diffInHours)}h`;
}
const diffInDays = diffInHours / 24;
if (diffInDays < 30) return `${Math.floor(diffInDays)}d`;
if (diffInDays < 30) {
return `${Math.floor(diffInDays)}d`;
}
const diffInMonths = diffInDays / 30;
if (diffInMonths < 12) return `${Math.floor(diffInMonths)}mo`;
if (diffInMonths < 12) {
return `${Math.floor(diffInMonths)}mo`;
}
const diffInYears = diffInMonths / 12;
return `${Math.floor(diffInYears)}y`;
};
}
// Date Validation Helpers
/**
* @description Checks if a date is greater than today
* @param {string} dateStr - Date string to check
* @returns {boolean} True if date is greater than today
* @example
* isDateGreaterThanToday("2024-12-31") // returns true
* @returns {string} boolean value depending on whether the date is greater than today
* @description Returns boolean value depending on whether the date is greater than today
* @param {string} dateStr
* @example isDateGreaterThanToday("2024-01-01") // true
*/
export const isDateGreaterThanToday = (dateStr: string): boolean => {
// Return false if dateStr is not present
if (!dateStr) return false;
// Parse the date to check if it is valid
const date = parseISO(dateStr);
const today = new Date();
if (!isValid(date)) return false;
// Check if the parsed date is valid
if (!isValid(date)) return false; // Return false for invalid dates
// Return true if the date is greater than today
return isAfter(date, today);
};
// Week Related Helpers
/**
* @returns {number} week number of date
* @description Returns week number of date
* @param {Date} date - Date to get week number from
* @returns {number} Week number (1-52)
* @example
* getWeekNumberOfDate(new Date("2023-09-01")) // returns 35
* @param {Date} date
* @example getWeekNumber(new Date("2023-09-01")) // 35
*/
export const getWeekNumberOfDate = (date: Date): number => {
const currentDate = date;
// Adjust the starting day to Sunday (0) instead of Monday (1)
const startDate = new Date(currentDate.getFullYear(), 0, 1);
// Calculate the number of days between currentDate and startDate
const days = Math.floor((currentDate.getTime() - startDate.getTime()) / (24 * 60 * 60 * 1000));
// Adjust the calculation for weekNumber
const weekNumber = Math.ceil((days + 1) / 7);
return weekNumber;
};
/**
* @description Checks if two dates are equal
* @param {Date | string} date1 - First date
* @param {Date | string} date2 - Second date
* @returns {boolean} True if dates are equal
* @example
* checkIfDatesAreEqual("2024-01-01", "2024-01-01") // returns true
* @returns {boolean} boolean value depending on whether the dates are equal
* @description Returns boolean value depending on whether the dates are equal
* @param date1
* @param date2
* @example checkIfDatesAreEqual("2024-01-01", "2024-01-01") // true
* @example checkIfDatesAreEqual("2024-01-01", "2024-01-02") // false
*/
export const checkIfDatesAreEqual = (
date1: Date | string | null | undefined,
@ -208,101 +261,115 @@ export const checkIfDatesAreEqual = (
): boolean => {
const parsedDate1 = getDate(date1);
const parsedDate2 = getDate(date2);
// return if undefined
if (!parsedDate1 && !parsedDate2) return true;
if (!parsedDate1 || !parsedDate2) return false;
return isEqual(parsedDate1, parsedDate2);
};
/**
* @description Checks if a string matches date format YYYY-MM-DD
* @param {string} date - Date string to check
* @returns {boolean} True if string matches date format
* @example
* isInDateFormat("2024-01-01") // returns true
* This method returns a date from string of type yyyy-mm-dd
* This method is recommended to use instead of new Date() as this does not introduce any timezone offsets
* @param date
* @returns date or undefined
*/
export const isInDateFormat = (date: string): boolean => {
export const getDate = (date: string | Date | undefined | null): Date | undefined => {
try {
if (!date || date === "") return;
if (typeof date !== "string" && !(date instanceof String)) return date;
const [yearString, monthString, dayString] = date.substring(0, 10).split("-");
const year = parseInt(yearString);
const month = parseInt(monthString);
const day = parseInt(dayString);
if (!isNumber(year) || !isNumber(month) || !isNumber(day)) return;
return new Date(year, month - 1, day);
} catch (e) {
return undefined;
}
};
export const isInDateFormat = (date: string) => {
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
return datePattern.test(date);
};
/**
* @description Converts date string to ISO format
* @param {string} dateString - Date string to convert
* @returns {string | undefined} ISO date string
* @example
* convertToISODateString("2024-01-01") // returns "2024-01-01T00:00:00.000Z"
* returns the date string in ISO format regardless of the timezone in input date string
* @param dateString
* @returns
*/
export const convertToISODateString = (dateString: string | undefined): string | undefined => {
export const convertToISODateString = (dateString: string | undefined) => {
if (!dateString) return dateString;
const date = new Date(dateString);
return date.toISOString();
};
/**
* @description Converts date string to epoch timestamp
* @param {string} dateString - Date string to convert
* @returns {number | undefined} Epoch timestamp
* @example
* convertToEpoch("2024-01-01") // returns 1704067200000
* returns the date string in Epoch regardless of the timezone in input date string
* @param dateString
* @returns
*/
export const convertToEpoch = (dateString: string | undefined): number | undefined => {
if (!dateString) return undefined;
export const convertToEpoch = (dateString: string | undefined) => {
if (!dateString) return dateString;
const date = new Date(dateString);
return date.getTime();
};
/**
* @description Gets current date time in ISO format
* @returns {string} Current date time in ISO format
* @example
* getCurrentDateTimeInISO() // returns "2024-01-01T12:00:00.000Z"
* get current Date time in UTC ISO format
* @returns
*/
export const getCurrentDateTimeInISO = (): string => {
export const getCurrentDateTimeInISO = () => {
const date = new Date();
return date.toISOString();
};
/**
* @description Converts hours and minutes to total minutes
* @param {number} hours - Number of hours
* @param {number} minutes - Number of minutes
* @returns {number} Total minutes
* @example
* convertHoursMinutesToMinutes(2, 30) // returns 150
* @description converts hours and minutes to minutes
* @param { number } hours
* @param { number } minutes
* @returns { number } minutes
* @example convertHoursMinutesToMinutes(2, 30) // Output: 150
*/
export const convertHoursMinutesToMinutes = (hours: number, minutes: number): number => hours * 60 + minutes;
/**
* @description Converts total minutes to hours and minutes
* @param {number} mins - Total minutes
* @returns {{ hours: number; minutes: number }} Hours and minutes
* @example
* convertMinutesToHoursAndMinutes(150) // returns { hours: 2, minutes: 30 }
* @description converts minutes to hours and minutes
* @param { number } mins
* @returns { number, number } hours and minutes
* @example convertMinutesToHoursAndMinutes(150) // Output: { hours: 2, minutes: 30 }
*/
export const convertMinutesToHoursAndMinutes = (mins: number): { hours: number; minutes: number } => {
const hours = Math.floor(mins / 60);
const minutes = Math.floor(mins % 60);
return { hours, minutes };
return { hours: hours, minutes: minutes };
};
/**
* @description Converts minutes to hours and minutes string
* @param {number} totalMinutes - Total minutes
* @returns {string} Formatted string (e.g., "2h 30m")
* @example
* convertMinutesToHoursMinutesString(150) // returns "2h 30m"
* @description converts minutes to hours and minutes string
* @param { number } totalMinutes
* @returns { string } 0h 0m
* @example convertMinutesToHoursAndMinutes(150) // Output: 2h 10m
*/
export const convertMinutesToHoursMinutesString = (totalMinutes: number): string => {
const { hours, minutes } = convertMinutesToHoursAndMinutes(totalMinutes);
return `${hours ? `${hours}h ` : ``}${minutes ? `${minutes}m ` : ``}`;
};
/**
* @description Calculates read time in seconds from word count
* @param {number} wordsCount - Number of words
* @returns {number} Read time in seconds
* @example
* getReadTimeFromWordsCount(400) // returns 120
* @description calculates the read time for a document using the words count
* @param {number} wordsCount
* @returns {number} total number of seconds
* @example getReadTimeFromWordsCount(400) // Output: 120
* @example getReadTimeFromWordsCount(100) // Output: 30s
*/
export const getReadTimeFromWordsCount = (wordsCount: number): number => {
const wordsPerMinute = 200;
@ -311,29 +378,104 @@ export const getReadTimeFromWordsCount = (wordsCount: number): number => {
};
/**
* @description Generates array of dates between start and end dates
* @param {string | Date} startDate - Start date
* @param {string | Date} endDate - End date
* @returns {Array<{ date: string }>} Array of dates
* @example
* generateDateArray("2024-01-01", "2024-01-03")
* // returns [{ date: "2024-01-02" }, { date: "2024-01-03" }]
* @description generates an array of dates between the start and end dates
* @param startDate
* @param endDate
* @returns
*/
export const generateDateArray = (startDate: string | Date, endDate: string | Date): Array<{ date: string }> => {
export const generateDateArray = (startDate: string | Date, endDate: string | Date) => {
// Convert the start and end dates to Date objects if they aren't already
const start = new Date(startDate);
// start.setDate(start.getDate() + 1);
const end = new Date(endDate);
end.setDate(end.getDate() + 1);
end.setDate(end.getDate() + 2);
// Create an empty array to store the dates
const dateArray = [];
// Use a while loop to generate dates between the range
while (start <= end) {
start.setDate(start.getDate() + 1);
// Push the current date (converted to ISO string for consistency)
dateArray.push({
date: new Date(start).toISOString().split("T")[0],
});
// Increment the date by 1 day (86400000 milliseconds)
start.setDate(start.getDate() + 1);
}
return dateArray;
};
/**
* Processes relative date strings like "1_weeks", "2_months" etc and returns a Date
* @param value The relative date string (e.g., "1_weeks", "2_months")
* @returns Date object representing the calculated date
*/
export const processRelativeDate = (value: string): Date => {
const [amountStr, unit] = value.split("_");
const amount = parseInt(amountStr, 10);
if (isNaN(amount)) {
throw new Error(`Invalid relative amount: ${amountStr}`);
}
const date = new Date();
switch (unit) {
case "days":
date.setDate(date.getDate() + amount);
break;
case "weeks":
date.setDate(date.getDate() + amount * 7);
break;
case "months":
date.setMonth(date.getMonth() + amount);
break;
default:
throw new Error(`Unsupported time unit: ${unit}`);
}
return date;
};
/**
* Parses a date filter string and returns the comparison type and date
* @param filterValue The date filter string (e.g., "1_weeks;after;fromnow" or "2024-12-01;after")
* @returns Object containing the comparison type and target date
*/
export const parseDateFilter = (filterValue: string): { type: "after" | "before"; date: Date } => {
const parts = filterValue.split(";");
const dateStr = parts[0];
const type = parts[1] as "after" | "before";
let date: Date;
if (dateStr.includes("_")) {
// Handle relative dates (e.g., "1_weeks;after;fromnow")
date = processRelativeDate(dateStr);
} else {
// Handle absolute dates (e.g., "2024-12-01;after")
date = new Date(dateStr);
}
return { type, date };
};
/**
* Checks if a date meets the filter criteria
* @param dateToCheck The date to check
* @param filterDate The filter date to compare against
* @param type The type of comparison ('after' or 'before')
* @returns boolean indicating if the date meets the criteria
*/
export const checkDateCriteria = (dateToCheck: Date | null, filterDate: Date, type: "after" | "before"): boolean => {
if (!dateToCheck) return false;
const checkDate = new Date(dateToCheck);
const normalizedCheck = new Date(checkDate.setHours(0, 0, 0, 0));
const normalizedFilter = new Date(filterDate.getTime());
normalizedFilter.setHours(0, 0, 0, 0);
return type === "after" ? normalizedCheck >= normalizedFilter : normalizedCheck <= normalizedFilter;
};
/**
* Formats merged date range display with smart formatting
* - Single date: "Jan 24, 2025"
@ -388,4 +530,4 @@ export const formatDateRange = (
}
return "";
};
};

View file

@ -0,0 +1,293 @@
"use client";
import { format } from "date-fns";
import get from "lodash/get";
import set from "lodash/set";
// plane imports
import { STATE_GROUPS, COMPLETED_STATE_GROUPS } from "@plane/constants";
import { ICycle, IEstimatePoint, IModule, IState, TIssue } from "@plane/types";
// helper
import { getDate } from "./datetime";
export type DistributionObjectUpdate = {
id: string;
completed_issues?: number;
pending_issues?: number;
total_issues: number;
completed_estimates?: number;
pending_estimates?: number;
total_estimates: number;
};
type ChartUpdates = {
updates: {
path: string[];
value: number;
}[];
isCompleted?: boolean;
};
export type DistributionUpdates = {
pathUpdates: { path: string[]; value: number }[];
assigneeUpdates: DistributionObjectUpdate[];
labelUpdates: DistributionObjectUpdate[];
};
const STATE_DISTRIBUTION = {
[STATE_GROUPS.backlog.key]: {
key: STATE_GROUPS.backlog.key,
issues: "backlog_issues",
points: "backlog_estimate_points",
},
[STATE_GROUPS.unstarted.key]: {
key: STATE_GROUPS.unstarted.key,
issues: "unstarted_issues",
points: "unstarted_estimate_points",
},
[STATE_GROUPS.started.key]: {
key: STATE_GROUPS.started.key,
issues: "started_issues",
points: "started_estimate_points",
},
[STATE_GROUPS.completed.key]: {
key: STATE_GROUPS.completed.key,
issues: "completed_issues",
points: "completed_estimate_points",
},
[STATE_GROUPS.cancelled.key]: {
key: STATE_GROUPS.cancelled.key,
issues: "cancelled_issues",
points: "cancelled_estimate_points",
},
};
/**
* Get Distribution updates with the help of previous and next issue states
* @param prevIssueState
* @param nextIssueState
* @param stateMap
* @param estimatePointById
* @returns
*/
export const getDistributionPathsPostUpdate = (
prevIssueState: TIssue | undefined,
nextIssueState: TIssue | undefined,
stateMap: Record<string, IState>,
estimatePointById?: (estimatePointId: string) => IEstimatePoint | undefined
): DistributionUpdates => {
const prevIssueDistribution = getDistributionDataOfIssue(prevIssueState, -1, stateMap, estimatePointById);
const nextIssueDistribution = getDistributionDataOfIssue(nextIssueState, 1, stateMap, estimatePointById);
const prevChartDistribution = prevIssueDistribution.chartUpdates;
const nextChartDistribution = nextIssueDistribution.chartUpdates;
let chartUpdates: {
path: string[];
value: number;
}[];
// if the completed status of chart updates are same the get chart updates from both the issue states
if (prevChartDistribution.isCompleted === nextChartDistribution.isCompleted) {
chartUpdates = [...prevChartDistribution.updates, ...nextChartDistribution.updates];
} // if not the get chart updates from only the next update
else {
chartUpdates = [...nextChartDistribution.updates];
}
// merge the updates from both issue states into a single object
return {
pathUpdates: [...prevIssueDistribution.pathUpdates, ...nextIssueDistribution.pathUpdates, ...chartUpdates],
assigneeUpdates: [...prevIssueDistribution.assigneeUpdates, ...nextIssueDistribution.assigneeUpdates],
labelUpdates: [...prevIssueDistribution.labelUpdates, ...nextIssueDistribution.labelUpdates],
};
};
/**
* Get Distribution update for a single issue state
* @param issue
* @param multiplier
* @param stateMap
* @param estimatePointById
* @returns
*/
const getDistributionDataOfIssue = (
issue: TIssue | undefined,
multiplier: -1 | 1,
stateMap: Record<string, IState>,
estimatePointById?: (estimatePointId: string) => IEstimatePoint | undefined
): DistributionUpdates & { chartUpdates: ChartUpdates } => {
const pathUpdates: { path: string[]; value: number }[] = [];
// If issue does not exist, send a default object
if (!issue) return { pathUpdates, assigneeUpdates: [], labelUpdates: [], chartUpdates: { updates: [] } };
const state = stateMap[issue.state_id ?? ""];
const stateGroup = state.group;
// get if the state is in completed state
const isCompleted = COMPLETED_STATE_GROUPS.indexOf(stateGroup) > -1;
// get estimate point in number for the issue
const estimatePoint = parseFloat(estimatePointById?.(issue.estimate_point ?? "")?.value ?? "0");
// add all the path updates that can be updated directly on the distribution object
pathUpdates.push({ path: ["total_issues"], value: multiplier });
pathUpdates.push({ path: ["total_estimate_points"], value: multiplier * estimatePoint });
// path updates for state distributions
const stateDistribution = STATE_DISTRIBUTION[stateGroup];
pathUpdates.push({ path: [stateDistribution.issues], value: multiplier });
pathUpdates.push({ path: [stateDistribution.points], value: multiplier * estimatePoint });
// get assignee and label distribution updates
const assigneeUpdates = getObjectDistributionArray(issue.assignee_ids, isCompleted, estimatePoint, multiplier);
const labelUpdates = getObjectDistributionArray(issue.label_ids, isCompleted, estimatePoint, multiplier);
// chart updates based on date of completed or not completed
const chartUpdates = getChartUpdates(isCompleted, issue.completed_at, estimatePoint, multiplier);
return {
pathUpdates,
assigneeUpdates,
labelUpdates,
chartUpdates,
};
};
/**
* This is to get distribution update array for either assignees and labels object
* @param ids the assignee or label ids of issue
* @param isCompleted
* @param estimatePoint
* @param multiplier
* @returns
*/
const getObjectDistributionArray = (ids: string[], isCompleted: boolean, estimatePoint: number, multiplier: -1 | 1) => {
const objectDistributionArray: DistributionObjectUpdate[] = [];
// iterate over each id
for (const id of ids) {
const objectDistribution: DistributionObjectUpdate = {
id,
total_issues: multiplier,
total_estimates: estimatePoint * multiplier,
};
// update paths for issue counts and estimate counts
if (isCompleted) {
objectDistribution["completed_issues"] = multiplier;
objectDistribution["completed_estimates"] = estimatePoint * multiplier;
} else {
objectDistribution["pending_issues"] = multiplier;
objectDistribution["pending_estimates"] = estimatePoint * multiplier;
}
objectDistributionArray.push(objectDistribution);
}
return objectDistributionArray;
};
/**
* get chart distribution based of completed or not completed states
* @param isCompleted
* @param completedAt
* @param estimatePoint
* @param multiplier
* @returns
*/
const getChartUpdates = (
isCompleted: boolean,
completedAt: string | null,
estimatePoint: number,
multiplier: -1 | 1
) => {
// if completed At date does not exist use current date
let dateToUpdate = format(new Date(), "yyyy-MM-dd");
const completedAtDate = getDate(completedAt);
if (completedAt && completedAtDate) {
dateToUpdate = format(completedAtDate, "yyyy-MM-dd");
}
// multiplier based on isCompleted state, it determines if the current count is to be added or subtracted from the list
const completedAtMultiplier = isCompleted ? -1 : 1;
return {
updates: [
{ path: ["distribution", "completion_chart", dateToUpdate], value: multiplier * completedAtMultiplier },
{
path: ["estimate_distribution", "completion_chart", dateToUpdate],
value: multiplier * completedAtMultiplier * estimatePoint,
},
],
isCompleted,
};
};
/**
* Method to update distribution of either cycle or module object
* @param distributionObject
* @param distributionUpdates
*/
export const updateDistribution = (distributionObject: ICycle | IModule, distributionUpdates: DistributionUpdates) => {
const { pathUpdates, assigneeUpdates, labelUpdates } = distributionUpdates;
// iterate over path updates and directly apply changes on the distribution object
for (const update of pathUpdates) {
const { path, value } = update;
const currentValue: number = get(distributionObject, path);
if (currentValue !== undefined) set(distributionObject, path, (currentValue ?? 0) + value);
}
// for assignee update iterate through the assignee update and apply at the respective position
for (const assigneeUpdate of assigneeUpdates) {
const { id } = assigneeUpdate;
// find and update the assignee issue counts
if (Array.isArray(distributionObject.distribution?.assignees)) {
const issuesAssignee = distributionObject.distribution?.assignees?.find(
(assignee) => assignee.assignee_id === id
);
if (issuesAssignee) {
issuesAssignee.completed_issues += assigneeUpdate.completed_issues ?? 0;
issuesAssignee.pending_issues += assigneeUpdate.pending_issues ?? 0;
issuesAssignee.total_issues += assigneeUpdate.total_issues;
}
}
// find and update the assignee points
if (Array.isArray(distributionObject.estimate_distribution?.assignees)) {
const pointsAssignee = distributionObject.estimate_distribution?.assignees?.find(
(assignee) => assignee.assignee_id === id
);
if (pointsAssignee) {
pointsAssignee.completed_estimates += assigneeUpdate.completed_estimates ?? 0;
pointsAssignee.pending_estimates += assigneeUpdate.pending_estimates ?? 0;
pointsAssignee.total_estimates += assigneeUpdate.total_estimates;
}
}
}
for (const labelUpdate of labelUpdates) {
const { id } = labelUpdate;
// find and update the label issue counts
if (Array.isArray(distributionObject.distribution?.labels)) {
const issuesLabel = distributionObject.distribution?.labels?.find((label) => label.label_id === id);
if (issuesLabel) {
issuesLabel.completed_issues += labelUpdate.completed_issues ?? 0;
issuesLabel.pending_issues += labelUpdate.pending_issues ?? 0;
issuesLabel.total_issues += labelUpdate.total_issues;
}
}
// find and update the label points
if (Array.isArray(distributionObject.estimate_distribution?.labels)) {
const pointsLabel = distributionObject.estimate_distribution?.labels?.find((label) => label.label_id === id);
if (pointsLabel) {
pointsLabel.completed_estimates += labelUpdate.completed_estimates ?? 0;
pointsLabel.pending_estimates += labelUpdate.pending_estimates ?? 0;
pointsLabel.total_estimates += labelUpdate.total_estimates;
}
}
}
};

View file

@ -0,0 +1,37 @@
// local imports
import { getFileURL } from "./file";
type TEditorSrcArgs = {
assetId: string;
projectId?: string;
workspaceSlug: string;
};
/**
* @description generate the file source using assetId
* @param {TEditorSrcArgs} args
*/
export const getEditorAssetSrc = (args: TEditorSrcArgs): string | undefined => {
const { assetId, projectId, workspaceSlug } = args;
let url: string | undefined = "";
if (projectId) {
url = getFileURL(`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${assetId}/`);
} else {
url = getFileURL(`/api/assets/v2/workspaces/${workspaceSlug}/${assetId}/`);
}
return url;
};
export const getTextContent = (jsx: JSX.Element | React.ReactNode | null | undefined): string => {
if (!jsx) return "";
const div = document.createElement("div");
div.innerHTML = jsx.toString();
return div.textContent?.trim() ?? "";
};
export const isEditorEmpty = (description: string | undefined): boolean =>
!description ||
description === "<p></p>" ||
description === `<p class="editor-paragraph-block"></p>` ||
description.trim() === "";

View file

@ -1,3 +1,8 @@
"use client";
// plane imports
import { LUCIDE_ICONS_LIST, RANDOM_EMOJI_CODES } from "@plane/constants";
/**
* Converts a hyphen-separated hexadecimal emoji code to its decimal representation
* @param {string} emojiUnified - The unified emoji code in hexadecimal format (e.g., "1f600" or "1f1e6-1f1e8")
@ -41,24 +46,46 @@ export const emojiCodeToUnicode = (emoji: string): string => {
/**
* Groups reactions by a specified key
* @param {T[]} reactions - Array of reaction objects
* @param {any[]} reactions - Array of reaction objects
* @param {string} key - Key to group reactions by
* @returns {Object} Object with reactions grouped by the specified key
* @example
* const reactions = [{ reaction: "👍", id: 1 }, { reaction: "👍", id: 2 }, { reaction: "❤️", id: 3 }];
* groupReactions(reactions, "reaction") // returns { "👍": [{ reaction: "👍", id: 1 }, { reaction: "👍", id: 2 }], "❤️": [{ reaction: "❤️", id: 3 }] }
*/
export const groupReactions = <T extends { reaction: string }>(reactions: T[], key: string): { [key: string]: T[] } => {
export const groupReactions: (reactions: any[], key: string) => { [key: string]: any[] } = (
reactions: any,
key: string
) => {
if (!Array.isArray(reactions)) {
console.error("Expected an array of reactions, but got:", reactions);
return {};
}
const groupedReactions = reactions.reduce(
(acc: { [key: string]: T[] }, reaction: T) => {
if (!acc[reaction[key as keyof T] as string]) {
acc[reaction[key as keyof T] as string] = [];
(acc: any, reaction: any) => {
if (!reaction || typeof reaction !== "object" || !Object.prototype.hasOwnProperty.call(reaction, key)) {
console.warn("Skipping undefined reaction or missing key:", reaction);
return acc; // Skip undefined reactions or those without the specified key
}
acc[reaction[key as keyof T] as string].push(reaction);
if (!acc[reaction[key]]) {
acc[reaction[key]] = [];
}
acc[reaction[key]].push(reaction);
return acc;
},
{} as { [key: string]: T[] }
{} as { [key: string]: any[] }
);
return groupedReactions;
};
/**
* Returns a random emoji code from the RANDOM_EMOJI_CODES array
* @returns {string} A random emoji code
*/
export const getRandomEmoji = (): string => RANDOM_EMOJI_CODES[Math.floor(Math.random() * RANDOM_EMOJI_CODES.length)];
/**
* Returns a random icon name from the LUCIDE_ICONS_LIST array
*/
export const getRandomIconName = (): string =>
LUCIDE_ICONS_LIST[Math.floor(Math.random() * LUCIDE_ICONS_LIST.length)].name;

View file

@ -0,0 +1,34 @@
// plane web constants
import { EEstimateSystem } from "@plane/constants";
export const isEstimatePointValuesRepeated = (
estimatePoints: string[],
estimateType: EEstimateSystem,
newEstimatePoint?: string | undefined
) => {
const currentEstimatePoints = estimatePoints.map((estimatePoint) => estimatePoint.trim());
let isRepeated = false;
if (newEstimatePoint === undefined) {
if (estimateType === EEstimateSystem.CATEGORIES) {
const points = new Set(currentEstimatePoints);
if (points.size != currentEstimatePoints.length) isRepeated = true;
} else if ([EEstimateSystem.POINTS, EEstimateSystem.TIME].includes(estimateType)) {
currentEstimatePoints.map((point) => {
if (Number(point) === Number(newEstimatePoint)) isRepeated = true;
});
}
} else {
if (estimateType === EEstimateSystem.CATEGORIES) {
currentEstimatePoints.map((point) => {
if (point === newEstimatePoint.trim()) isRepeated = true;
});
} else if ([EEstimateSystem.POINTS, EEstimateSystem.TIME].includes(estimateType)) {
currentEstimatePoints.map((point) => {
if (Number(point) === Number(newEstimatePoint.trim())) isRepeated = true;
});
}
}
return isRepeated;
};

View file

@ -1,3 +1,4 @@
// plane imports
import { API_BASE_URL } from "@plane/constants";
import { TFileMetaDataLite, TFileSignedURLResponse } from "@plane/types";
@ -47,3 +48,66 @@ export const getAssetIdFromUrl = (src: string): string => {
const assetUrl = sourcePaths[sourcePaths.length - 1];
return assetUrl;
};
/**
* @description encode image via URL to base64
* @param {string} url
* @returns
*/
export const getBase64Image = async (url: string): Promise<string> => {
if (!url || typeof url !== "string") {
throw new Error("Invalid URL provided");
}
// Try to create a URL object to validate the URL
try {
new URL(url);
} catch {
throw new Error("Invalid URL format");
}
const response = await fetch(url);
// check if the response is OK
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.statusText}`);
}
const blob = await response.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
if (reader.result) {
resolve(reader.result as string);
} else {
reject(new Error("Failed to convert image to base64."));
}
};
reader.onerror = () => {
reject(new Error("Failed to read the image file."));
};
reader.readAsDataURL(blob);
});
};
/**
* @description downloads a CSV file
* @param {Array<Array<string>> | { [key: string]: string }} data - The data to be exported to CSV
* @param {string} name - The name of the file to be downloaded
*/
export const csvDownload = (data: Array<Array<string>> | { [key: string]: string }, name: string) => {
const rows = Array.isArray(data) ? [...data] : [Object.keys(data), Object.values(data)];
const csvContent = "data:text/csv;charset=utf-8," + rows.map((e) => e.join(",")).join("\n");
const encodedUri = encodeURI(csvContent);
const link = document.createElement("a");
link.href = encodedUri;
link.download = `${name}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};

View file

@ -0,0 +1,79 @@
import { differenceInCalendarDays } from "date-fns/differenceInCalendarDays";
// plane imports
import { IIssueFilters } from "@plane/types";
// local imports
import { getDate } from "./datetime";
/**
* @description calculates the total number of filters applied
* @param {T} filters
* @returns {number}
*/
export const calculateTotalFilters = <T>(filters: T): number =>
filters && Object.keys(filters).length > 0
? Object.keys(filters)
.map((key) => {
const value = filters[key as keyof T];
if (value === null) return 0;
if (Array.isArray(value)) return value.length;
if (typeof value === "boolean") return value ? 1 : 0;
return 0;
})
.reduce((curr, prev) => curr + prev, 0)
: 0;
/**
* @description checks if the date satisfies the filter
* @param {Date} date
* @param {string} filter
* @returns {boolean}
*/
export const satisfiesDateFilter = (date: Date, filter: string): boolean => {
const [value, operator, from] = filter.split(";");
const dateValue = getDate(value);
const differenceInDays = differenceInCalendarDays(date, new Date());
if (operator === "custom" && from === "custom") {
if (value === "today") return differenceInDays === 0;
if (value === "yesterday") return differenceInDays === -1;
if (value === "last_7_days") return differenceInDays >= -7;
if (value === "last_30_days") return differenceInDays >= -30;
}
if (!from && dateValue) {
if (operator === "after") return date >= dateValue;
if (operator === "before") return date <= dateValue;
}
if (from === "fromnow") {
if (operator === "before") {
if (value === "1_weeks") return differenceInDays <= -7;
if (value === "2_weeks") return differenceInDays <= -14;
if (value === "1_months") return differenceInDays <= -30;
}
if (operator === "after") {
if (value === "1_weeks") return differenceInDays >= 7;
if (value === "2_weeks") return differenceInDays >= 14;
if (value === "1_months") return differenceInDays >= 30;
if (value === "2_months") return differenceInDays >= 60;
}
}
return false;
};
/**
* @description checks if the issue filter is active
* @param {IIssueFilters} issueFilters
* @returns {boolean}
*/
export const isIssueFilterActive = (issueFilters: IIssueFilters | undefined): boolean => {
if (!issueFilters) return false;
const issueType = issueFilters?.displayFilters?.type;
const isFiltersApplied = calculateTotalFilters(issueFilters?.filters ?? {}) !== 0 || !!issueType;
return isFiltersApplied;
};

View file

@ -1,17 +1,30 @@
export * from "./array";
export * from "./attachment";
export * from "./auth";
export * from "./calendar";
export * from "./color";
export * from "./common";
export * from "./cycle";
export * from "./datetime";
export * from "./distribution-update";
export * from "./editor";
export * from "./emoji";
export * from "./estimates";
export * from "./file";
export * from "./filter";
export * from "./get-icon-for-link";
export * from "./issue";
export * from "./intake";
export * from "./math";
export * from "./module";
export * from "./notification";
export * from "./page";
export * from "./permission";
export * from "./state";
export * from "./project";
export * from "./project-views";
export * from "./router";
export * from "./string";
export * from "./subscription";
export * from "./tab-indices";
export * from "./theme";
export * from "./work-item";
export * from "./workspace";
export * from "./workspace";

View file

@ -0,0 +1,34 @@
import { subDays } from "date-fns";
// plane imports
import { EPastDurationFilters } from "@plane/constants";
// local imports
import { renderFormattedPayloadDate } from "./datetime";
export const getCustomDates = (duration: EPastDurationFilters): string => {
const today = new Date();
let firstDay, lastDay;
switch (duration) {
case EPastDurationFilters.TODAY: {
firstDay = renderFormattedPayloadDate(today);
lastDay = renderFormattedPayloadDate(today);
return `${firstDay};after,${lastDay};before`;
}
case EPastDurationFilters.YESTERDAY: {
const yesterday = subDays(today, 1);
firstDay = renderFormattedPayloadDate(yesterday);
lastDay = renderFormattedPayloadDate(yesterday);
return `${firstDay};after,${lastDay};before`;
}
case EPastDurationFilters.LAST_7_DAYS: {
firstDay = renderFormattedPayloadDate(subDays(today, 7));
lastDay = renderFormattedPayloadDate(today);
return `${firstDay};after,${lastDay};before`;
}
case EPastDurationFilters.LAST_30_DAYS: {
firstDay = renderFormattedPayloadDate(subDays(today, 30));
lastDay = renderFormattedPayloadDate(today);
return `${firstDay};after,${lastDay};before`;
}
}
};

View file

@ -1,37 +0,0 @@
import { differenceInCalendarDays } from "date-fns/differenceInCalendarDays";
import { ISSUE_PRIORITY_FILTERS, STATE_GROUPS, TIssuePriorities, TIssueFilterPriorityObject } from "@plane/constants";
import { TStateGroups } from "@plane/types";
import { getDate } from "./datetime";
export const getIssuePriorityFilters = (priorityKey: TIssuePriorities): TIssueFilterPriorityObject | undefined => {
const currentIssuePriority: TIssueFilterPriorityObject | undefined =
ISSUE_PRIORITY_FILTERS && ISSUE_PRIORITY_FILTERS.length > 0
? ISSUE_PRIORITY_FILTERS.find((_priority) => _priority.key === priorityKey)
: undefined;
if (currentIssuePriority) return currentIssuePriority;
return undefined;
};
/**
* @description check if the issue due date should be highlighted
* @param date
* @param stateGroup
* @returns boolean
*/
export const shouldHighlightIssueDueDate = (
date: string | Date | null,
stateGroup: TStateGroups | undefined
): boolean => {
if (!date || !stateGroup) return false;
// if the issue is completed or cancelled, don't highlight the due date
if ([STATE_GROUPS.completed.key, STATE_GROUPS.cancelled.key].includes(stateGroup)) return false;
const parsedDate = getDate(date);
if (!parsedDate) return false;
const targetDateDistance = differenceInCalendarDays(parsedDate, new Date());
// if the issue is overdue, highlight the due date
return targetDateDistance <= 0;
};

View file

@ -0,0 +1,2 @@
export const getProgress = (completed: number | undefined, total: number | undefined) =>
total && total > 0 ? Math.round(((completed ?? 0) / total) * 100) : 0;

View file

@ -0,0 +1,82 @@
import sortBy from "lodash/sortBy";
// plane imports
import { IModule, TModuleDisplayFilters, TModuleFilters, TModuleOrderByOptions } from "@plane/types";
// local imports
import { getDate } from "./datetime";
import { satisfiesDateFilter } from "./filter";
/**
* @description orders modules based on their status
* @param {IModule[]} modules
* @param {TModuleOrderByOptions | undefined} orderByKey
* @returns {IModule[]}
*/
export const orderModules = (modules: IModule[], orderByKey: TModuleOrderByOptions | undefined): IModule[] => {
let orderedModules: IModule[] = [];
if (modules.length === 0 || !orderByKey) return [];
if (orderByKey === "name") orderedModules = sortBy(modules, [(m) => m.name.toLowerCase()]);
if (orderByKey === "-name") orderedModules = sortBy(modules, [(m) => m.name.toLowerCase()]).reverse();
if (["progress", "-progress"].includes(orderByKey))
orderedModules = sortBy(modules, [
(m) => {
let progress = (m.completed_issues + m.cancelled_issues) / m.total_issues;
if (isNaN(progress)) progress = 0;
return orderByKey === "progress" ? progress : -progress;
},
"name",
]);
if (["issues_length", "-issues_length"].includes(orderByKey))
orderedModules = sortBy(modules, [
(m) => (orderByKey === "issues_length" ? m.total_issues : !m.total_issues),
"name",
]);
if (orderByKey === "target_date") orderedModules = sortBy(modules, [(m) => m.target_date]);
if (orderByKey === "-target_date") orderedModules = sortBy(modules, [(m) => !m.target_date]);
if (orderByKey === "created_at") orderedModules = sortBy(modules, [(m) => m.created_at]);
if (orderByKey === "-created_at") orderedModules = sortBy(modules, [(m) => !m.created_at]);
if (orderByKey === "sort_order") orderedModules = sortBy(modules, [(m) => m.sort_order]);
return orderedModules;
};
/**
* @description filters modules based on the filters
* @param {IModule} module
* @param {TModuleDisplayFilters} displayFilters
* @param {TModuleFilters} filters
* @returns {boolean}
*/
export const shouldFilterModule = (
module: IModule,
displayFilters: TModuleDisplayFilters,
filters: TModuleFilters
): boolean => {
let fallsInFilters = true;
Object.keys(filters).forEach((key) => {
const filterKey = key as keyof TModuleFilters;
if (filterKey === "status" && filters.status && filters.status.length > 0)
fallsInFilters = fallsInFilters && filters.status.includes(module.status?.toLowerCase() ?? "");
if (filterKey === "lead" && filters.lead && filters.lead.length > 0)
fallsInFilters = fallsInFilters && filters.lead.includes(`${module.lead_id}`);
if (filterKey === "members" && filters.members && filters.members.length > 0) {
const memberIds = module.member_ids;
fallsInFilters = fallsInFilters && filters.members.some((memberId) => memberIds.includes(memberId));
}
if (filterKey === "start_date" && filters.start_date && filters.start_date.length > 0) {
const startDate = getDate(module.start_date);
filters.start_date.forEach((dateFilter) => {
fallsInFilters = fallsInFilters && !!startDate && satisfiesDateFilter(startDate, dateFilter);
});
}
if (filterKey === "target_date" && filters.target_date && filters.target_date.length > 0) {
const endDate = getDate(module.target_date);
filters.target_date.forEach((dateFilter) => {
fallsInFilters = fallsInFilters && !!endDate && satisfiesDateFilter(endDate, dateFilter);
});
}
});
if (displayFilters.favorites && !module.is_favorite) fallsInFilters = false;
return fallsInFilters;
};

View file

@ -0,0 +1,8 @@
import { stripAndTruncateHTML } from "./string";
export const sanitizeCommentForNotification = (mentionContent: string | undefined) =>
mentionContent
? stripAndTruncateHTML(
mentionContent.replace(/<mention-component\b[^>]*\blabel="([^"]*)"[^>]*><\/mention-component>/g, "$1")
)
: mentionContent;

View file

@ -0,0 +1,87 @@
import sortBy from "lodash/sortBy";
// plane imports
import { TPage, TPageFilterProps, TPageFiltersSortBy, TPageFiltersSortKey, TPageNavigationTabs } from "@plane/types";
// local imports
import { getDate } from "./datetime";
import { satisfiesDateFilter } from "./filter";
/**
* @description filters pages based on the page type
* @param {TPageNavigationTabs} pageType
* @param {TPage[]} pages
* @returns {TPage[]}
*/
export const filterPagesByPageType = (pageType: TPageNavigationTabs, pages: TPage[]): TPage[] =>
pages.filter((page) => {
if (pageType === "public") return page.access === 0 && !page.archived_at;
if (pageType === "private") return page.access === 1 && !page.archived_at;
if (pageType === "archived") return page.archived_at;
return true;
});
/**
* @description orders pages based on their status
* @param {TPage[]} pages
* @param {TPageFiltersSortKey | undefined} sortByKey
* @param {TPageFiltersSortBy} sortByOrder
* @returns {TPage[]}
*/
export const orderPages = (
pages: TPage[],
sortByKey: TPageFiltersSortKey | undefined,
sortByOrder: TPageFiltersSortBy
): TPage[] => {
let orderedPages: TPage[] = [];
if (pages.length === 0 || !sortByKey) return [];
if (sortByKey === "name") {
orderedPages = sortBy(pages, [(m) => m.name?.toLowerCase()]);
if (sortByOrder === "desc") orderedPages = orderedPages.reverse();
}
if (sortByKey === "created_at") {
orderedPages = sortBy(pages, [(m) => m.created_at]);
if (sortByOrder === "desc") orderedPages = orderedPages.reverse();
}
if (sortByKey === "updated_at") {
orderedPages = sortBy(pages, [(m) => m.updated_at]);
if (sortByOrder === "desc") orderedPages = orderedPages.reverse();
}
return orderedPages;
};
/**
* @description filters pages based on the filters
* @param {TPage} page
* @param {TPageFilterProps | undefined} filters
* @returns {boolean}
*/
export const shouldFilterPage = (page: TPage, filters: TPageFilterProps | undefined): boolean => {
let fallsInFilters = true;
Object.keys(filters ?? {}).forEach((key) => {
const filterKey = key as keyof TPageFilterProps;
if (filterKey === "created_by" && filters?.created_by && filters.created_by.length > 0)
fallsInFilters = fallsInFilters && filters.created_by.includes(`${page.created_by}`);
if (filterKey === "created_at" && filters?.created_at && filters.created_at.length > 0) {
const createdDate = getDate(page.created_at);
filters?.created_at.forEach((dateFilter) => {
fallsInFilters = fallsInFilters && !!createdDate && satisfiesDateFilter(createdDate, dateFilter);
});
}
});
if (filters?.favorites && !page.is_favorite) fallsInFilters = false;
return fallsInFilters;
};
/**
* @description returns the name of the project after checking for untitled page
* @param {string | undefined} name
* @returns {string}
*/
export const getPageName = (name: string | undefined) => {
if (name === undefined) return "";
if (!name || name.trim() === "") return "Untitled";
return name;
};

View file

@ -0,0 +1 @@
export * from "./role";

View file

@ -1,4 +1,16 @@
import { EUserPermissions, EUserProjectRoles, EUserWorkspaceRoles } from "@plane/constants";
// plane imports
import { EUserProjectRoles, EUserWorkspaceRoles, EUserPermissions } from "@plane/constants";
export const getUserRole = (role: EUserPermissions | EUserWorkspaceRoles | EUserProjectRoles) => {
switch (role) {
case EUserPermissions.GUEST:
return "GUEST";
case EUserPermissions.MEMBER:
return "MEMBER";
case EUserPermissions.ADMIN:
return "ADMIN";
}
};
type TSupportedRole = EUserPermissions | EUserProjectRoles | EUserWorkspaceRoles;

View file

@ -0,0 +1,105 @@
import isNil from "lodash/isNil";
import orderBy from "lodash/orderBy";
// plane imports
import { SPACE_BASE_PATH, SPACE_BASE_URL } from "@plane/constants";
import { IProjectView, TViewFilterProps, TViewFiltersSortBy, TViewFiltersSortKey } from "@plane/types";
// local imports
import { getDate } from "./datetime";
import { satisfiesDateFilter } from "./filter";
/**
* order views base on TViewFiltersSortKey
* @param views
* @param sortByKey
* @param sortByOrder
* @returns
*/
export const orderViews = (
views: IProjectView[],
sortByKey: TViewFiltersSortKey | undefined,
sortByOrder: TViewFiltersSortBy
): IProjectView[] => {
if (views.length === 0 || !sortByKey) return [];
let iterableFunction;
if (sortByKey === "name") {
iterableFunction = (view: IProjectView) => view.name?.toLowerCase();
}
if (sortByKey === "created_at") {
iterableFunction = (view: IProjectView) => view.created_at;
}
if (sortByKey === "updated_at") {
iterableFunction = (view: IProjectView) => view.updated_at;
}
if (!iterableFunction) return [];
return orderBy(views, [iterableFunction], [sortByOrder]);
};
/**
* Checks if the passed down view should be filtered or not
* @param view
* @param filters
* @returns
*/
export const shouldFilterView = (view: IProjectView, filters: TViewFilterProps | undefined): boolean => {
let fallsInFilters = true;
Object.keys(filters ?? {}).forEach((key) => {
const filterKey = key as keyof TViewFilterProps;
if (filterKey === "owned_by" && filters?.owned_by && filters.owned_by.length > 0) {
fallsInFilters = fallsInFilters && filters.owned_by.includes(`${view.created_by}`);
}
if (filterKey === "created_at" && filters?.created_at && filters.created_at.length > 0) {
const createdDate = getDate(view.created_at);
filters?.created_at.forEach((dateFilter) => {
fallsInFilters = fallsInFilters && !!createdDate && satisfiesDateFilter(createdDate, dateFilter);
});
}
if (filterKey === "view_type" && filters?.view_type && filters?.view_type?.length > 0) {
fallsInFilters = filters.view_type.includes(view.access);
}
});
if (filters?.favorites && !view.is_favorite) fallsInFilters = false;
return fallsInFilters;
};
/**
* @description returns the name of the project after checking for untitled view
* @param {string | undefined} name
* @returns {string}
*/
export const getViewName = (name: string | undefined) => {
if (name === undefined) return "";
if (!name || name.trim() === "") return "Untitled";
return name;
};
/**
* Adds validation for the view creation filters
* @param data
* @returns
*/
export const getValidatedViewFilters = (data: Partial<IProjectView>) => {
if (data?.display_filters && data?.display_filters?.layout === "kanban" && isNil(data.display_filters.group_by)) {
data.display_filters.group_by = "state";
}
return data;
};
/**
* returns published view link
* @param anchor
* @returns
*/
export const getPublishViewLink = (anchor: string | undefined) => {
if (!anchor) return;
const SPACE_APP_URL = (SPACE_BASE_URL.trim() === "" ? window.location.origin : SPACE_BASE_URL) + SPACE_BASE_PATH;
return `${SPACE_APP_URL}/views/${anchor}`;
};

View file

@ -0,0 +1,104 @@
import sortBy from "lodash/sortBy";
// plane imports
import { TProject, TProjectDisplayFilters, TProjectFilters, TProjectOrderByOptions } from "@plane/types";
// local imports
import { getDate } from "./datetime";
import { satisfiesDateFilter } from "./filter";
/**
* Updates the sort order of the project.
* @param sortIndex
* @param destinationIndex
* @param projectId
* @returns number | undefined
*/
export const orderJoinedProjects = (
sourceIndex: number,
destinationIndex: number,
currentProjectId: string,
joinedProjects: TProject[]
): number | undefined => {
if (!currentProjectId || sourceIndex < 0 || destinationIndex < 0 || joinedProjects.length <= 0) return undefined;
let updatedSortOrder: number | undefined = undefined;
const sortOrderDefaultValue = 10000;
if (destinationIndex === 0) {
// updating project at the top of the project
const currentSortOrder = joinedProjects[destinationIndex].sort_order || 0;
updatedSortOrder = currentSortOrder - sortOrderDefaultValue;
} else if (destinationIndex === joinedProjects.length) {
// updating project at the bottom of the project
const currentSortOrder = joinedProjects[destinationIndex - 1].sort_order || 0;
updatedSortOrder = currentSortOrder + sortOrderDefaultValue;
} else {
// updating project in the middle of the project
const destinationTopProjectSortOrder = joinedProjects[destinationIndex - 1].sort_order || 0;
const destinationBottomProjectSortOrder = joinedProjects[destinationIndex].sort_order || 0;
const updatedValue = (destinationTopProjectSortOrder + destinationBottomProjectSortOrder) / 2;
updatedSortOrder = updatedValue;
}
return updatedSortOrder;
};
export const projectIdentifierSanitizer = (identifier: string): string =>
identifier.replace(/[^ÇŞĞIİÖÜA-Za-z0-9]/g, "");
/**
* @description filters projects based on the filter
* @param {TProject} project
* @param {TProjectFilters} filters
* @param {TProjectDisplayFilters} displayFilters
* @returns {boolean}
*/
export const shouldFilterProject = (
project: TProject,
displayFilters: TProjectDisplayFilters,
filters: TProjectFilters
): boolean => {
let fallsInFilters = true;
Object.keys(filters).forEach((key) => {
const filterKey = key as keyof TProjectFilters;
if (filterKey === "access" && filters.access && filters.access.length > 0)
fallsInFilters = fallsInFilters && filters.access.includes(`${project.network}`);
if (filterKey === "lead" && filters.lead && filters.lead.length > 0)
fallsInFilters = fallsInFilters && filters.lead.includes(`${project.project_lead}`);
if (filterKey === "members" && filters.members && filters.members.length > 0) {
const memberIds = project.members;
fallsInFilters = fallsInFilters && filters.members.some((memberId) => memberIds?.includes(memberId));
}
if (filterKey === "created_at" && filters.created_at && filters.created_at.length > 0) {
const createdDate = getDate(project.created_at);
filters.created_at.forEach((dateFilter) => {
fallsInFilters = fallsInFilters && !!createdDate && satisfiesDateFilter(createdDate, dateFilter);
});
}
});
if (displayFilters.my_projects && !project.member_role) fallsInFilters = false;
if (displayFilters.archived_projects && !project.archived_at) fallsInFilters = false;
if (project.archived_at) fallsInFilters = displayFilters.archived_projects ? fallsInFilters : false;
return fallsInFilters;
};
/**
* @description orders projects based on the orderByKey
* @param {TProject[]} projects
* @param {TProjectOrderByOptions | undefined} orderByKey
* @returns {TProject[]}
*/
export const orderProjects = (projects: TProject[], orderByKey: TProjectOrderByOptions | undefined): TProject[] => {
let orderedProjects: TProject[] = [];
if (projects.length === 0) return orderedProjects;
if (orderByKey === "sort_order") orderedProjects = sortBy(projects, [(p) => p.sort_order]);
if (orderByKey === "name") orderedProjects = sortBy(projects, [(p) => p.name.toLowerCase()]);
if (orderByKey === "-name") orderedProjects = sortBy(projects, [(p) => p.name.toLowerCase()]).reverse();
if (orderByKey === "created_at") orderedProjects = sortBy(projects, [(p) => p.created_at]);
if (orderByKey === "-created_at") orderedProjects = sortBy(projects, [(p) => !p.created_at]);
if (orderByKey === "members_length") orderedProjects = sortBy(projects, [(p) => p.members?.length]);
if (orderByKey === "-members_length") orderedProjects = sortBy(projects, [(p) => p.members?.length]).reverse();
return orderedProjects;
};

View file

@ -0,0 +1,9 @@
import { ReadonlyURLSearchParams } from "next/navigation";
export const generateQueryParams = (searchParams: ReadonlyURLSearchParams, excludedParamKeys?: string[]): string => {
const params = new URLSearchParams(searchParams);
excludedParamKeys && excludedParamKeys.forEach((key) => {
params.delete(key);
});
return params.toString();
};

View file

@ -1,13 +0,0 @@
import { STATE_GROUPS } from "@plane/constants";
import { IState } from "@plane/types";
export const sortStates = (states: IState[]) => {
if (!states || states.length === 0) return;
return states.sort((stateA, stateB) => {
if (stateA.group === stateB.group) {
return stateA.sequence - stateB.sequence;
}
return Object.keys(STATE_GROUPS).indexOf(stateA.group) - Object.keys(STATE_GROUPS).indexOf(stateB.group);
});
};

View file

@ -55,22 +55,6 @@ export const createSimilarString = (str: string) => {
return shuffled;
};
/**
* @description Copies text to clipboard
* @param {string} text - Text to copy
* @returns {Promise<void>} Promise that resolves when copying is complete
* @example
* await copyTextToClipboard("Hello, World!") // copies "Hello, World!" to clipboard
*/
export const copyTextToClipboard = async (text: string): Promise<void> => {
if (typeof navigator === "undefined") return;
try {
await navigator.clipboard.writeText(text);
} catch (err) {
console.error("Failed to copy text: ", err);
}
};
/**
* @description Copies full URL (origin + path) to clipboard
* @param {string} path - URL path to copy
@ -146,39 +130,30 @@ export const objToQueryParams = (obj: any) => {
export const capitalizeFirstLetter = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
/**
* @description: This function will remove all the HTML tags from the string
* @description : This function will remove all the HTML tags from the string
* @param {string} htmlString
* @return {string}
* @example :
* const html = "<p>Some text</p>";
const text = stripHTML(html);
console.log(text); // Some text
*/
export const sanitizeHTML = (htmlString: string) => {
const sanitizedText = DOMPurify.sanitize(htmlString, { ALLOWED_TAGS: [] }); // sanitize the string to remove all HTML tags
return sanitizedText.trim(); // trim the string to remove leading and trailing whitespaces
};
/**
* @description: This function will remove all the HTML tags from the string and truncate the string to the specified length
* @param {string} html
* @param {number} length
* @return {string}
* @example:
* const html = "<p>Some text</p>";
* const text = stripHTML(html);
* const text = stripAndTruncateHTML(html);
* console.log(text); // Some text
*/
/**
* @description Sanitizes HTML string by removing tags and properly escaping entities
* @param {string} htmlString - HTML string to sanitize
* @returns {string} Sanitized string with escaped HTML entities
* @example
* sanitizeHTML("<p>Hello & 'world'</p>") // returns "Hello &amp; &apos;world&apos;"
*/
export const sanitizeHTML = (htmlString: string) => {
if (!htmlString) return "";
// First use DOMPurify to remove all HTML tags while preserving text content
const sanitizedText = DOMPurify.sanitize(htmlString, {
ALLOWED_TAGS: [],
ALLOWED_ATTR: [],
USE_PROFILES: {
html: false,
svg: false,
svgFilters: false,
mathMl: false,
},
});
// Additional escaping for quotes and apostrophes
return sanitizedText.trim().replace(/'/g, "&apos;").replace(/"/g, "&quot;");
};
export const stripAndTruncateHTML = (html: string, length: number = 55) => truncateText(sanitizeHTML(html), length);
/**
* @returns {boolean} true if email is valid, false otherwise
@ -272,43 +247,74 @@ export const joinWithConjunction = (array: string[], separator: string = ", ", c
*/
export const ensureUrlHasProtocol = (url: string): string => (url.startsWith("http") ? url : `http://${url}`);
// Browser-only clipboard functions
// let copyTextToClipboard: (text: string) => Promise<void>;
/**
* @returns {boolean} true if searchQuery is substring of text in the same order, false otherwise
* @description Returns true if searchQuery is substring of text in the same order, false otherwise
* @param {string} text string to compare from
* @param {string} searchQuery
* @example substringMatch("hello world", "hlo") => true
* @example substringMatch("hello world", "hoe") => false
*/
export const substringMatch = (text: string, searchQuery: string): boolean => {
try {
let searchIndex = 0;
// if (typeof window !== "undefined") {
// const fallbackCopyTextToClipboard = (text: string) => {
// const textArea = document.createElement("textarea");
// textArea.value = text;
for (let i = 0; i < text.length; i++) {
if (text[i].toLowerCase() === searchQuery[searchIndex]?.toLowerCase()) searchIndex++;
// // Avoid scrolling to bottom
// textArea.style.top = "0";
// textArea.style.left = "0";
// textArea.style.position = "fixed";
// All characters of searchQuery found in order
if (searchIndex === searchQuery.length) return true;
}
// document.body.appendChild(textArea);
// textArea.focus();
// textArea.select();
// Not all characters of searchQuery found in order
return false;
} catch (error) {
return false;
}
};
// try {
// // FIXME: Even though we are using this as a fallback, execCommand is deprecated 👎. We should find a better way to do this.
// // https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand
// document.execCommand("copy");
// } catch (err) {}
/**
* @description Copies text to clipboard
* @param {string} text - Text to copy
* @returns {Promise<void>} Promise that resolves when copying is complete
* @example
* await copyTextToClipboard("Hello, World!") // copies "Hello, World!" to clipboard
*/
const fallbackCopyTextToClipboard = (text: string) => {
const textArea = document.createElement("textarea");
textArea.value = text;
// document.body.removeChild(textArea);
// };
// Avoid scrolling to bottom
textArea.style.top = "0";
textArea.style.left = "0";
textArea.style.position = "fixed";
// copyTextToClipboard = async (text: string) => {
// if (!navigator.clipboard) {
// fallbackCopyTextToClipboard(text);
// return;
// }
// await navigator.clipboard.writeText(text);
// };
// } else {
// copyTextToClipboard = async () => {
// throw new Error("copyTextToClipboard is only available in browser environments");
// };
// }
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
// export { copyTextToClipboard };
try {
// FIXME: Even though we are using this as a fallback, execCommand is deprecated 👎. We should find a better way to do this.
// https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand
document.execCommand("copy");
} catch (err) {
// catch fallback error
}
document.body.removeChild(textArea);
};
/**
* @description Copies text to clipboard
* @param {string} text - Text to copy
* @returns {Promise<void>} Promise that resolves when copying is complete
* @example
* await copyTextToClipboard("Hello, World!") // copies "Hello, World!" to clipboard
*/
export const copyTextToClipboard = async (text: string): Promise<void> => {
if (!navigator.clipboard) {
fallbackCopyTextToClipboard(text);
return;
}
await navigator.clipboard.writeText(text);
};

View file

@ -0,0 +1,11 @@
// plane imports
import { ETabIndices, TAB_INDEX_MAP } from "@plane/constants";
export const getTabIndex = (type?: ETabIndices, isMobile: boolean = false) => {
const getIndex = (key: string) =>
isMobile ? undefined : type && TAB_INDEX_MAP[type].findIndex((tabIndex) => tabIndex === key) + 1;
const baseTabIndex = isMobile ? -1 : 1;
return { getIndex, baseTabIndex };
};

View file

@ -1,2 +1,124 @@
// local imports
import { TRgb, hexToRgb } from "./color";
type TShades = {
10: TRgb;
20: TRgb;
30: TRgb;
40: TRgb;
50: TRgb;
60: TRgb;
70: TRgb;
80: TRgb;
90: TRgb;
100: TRgb;
200: TRgb;
300: TRgb;
400: TRgb;
500: TRgb;
600: TRgb;
700: TRgb;
800: TRgb;
900: TRgb;
};
const calculateShades = (hexValue: string): TShades => {
const shades: Partial<TShades> = {};
const { r, g, b } = hexToRgb(hexValue);
const convertHexToSpecificShade = (shade: number): TRgb => {
if (shade <= 100) {
const decimalValue = (100 - shade) / 100;
const newR = Math.floor(r + (255 - r) * decimalValue);
const newG = Math.floor(g + (255 - g) * decimalValue);
const newB = Math.floor(b + (255 - b) * decimalValue);
return {
r: newR,
g: newG,
b: newB,
};
} else {
const decimalValue = 1 - Math.ceil((shade - 100) / 100) / 10;
const newR = Math.ceil(r * decimalValue);
const newG = Math.ceil(g * decimalValue);
const newB = Math.ceil(b * decimalValue);
return {
r: newR,
g: newG,
b: newB,
};
}
};
for (let i = 10; i <= 900; i >= 100 ? (i += 100) : (i += 10))
shades[i as keyof TShades] = convertHexToSpecificShade(i);
return shades as TShades;
};
export const applyTheme = (palette: string, isDarkPalette: boolean) => {
if (!palette) return;
const themeElement = document?.querySelector("html");
// palette: [bg, text, primary, sidebarBg, sidebarText]
const values: string[] = palette.split(",");
values.push(isDarkPalette ? "dark" : "light");
const bgShades = calculateShades(values[0]);
const textShades = calculateShades(values[1]);
const primaryShades = calculateShades(values[2]);
const sidebarBackgroundShades = calculateShades(values[3]);
const sidebarTextShades = calculateShades(values[4]);
for (let i = 10; i <= 900; i >= 100 ? (i += 100) : (i += 10)) {
const shade = i as keyof TShades;
const bgRgbValues = `${bgShades[shade].r}, ${bgShades[shade].g}, ${bgShades[shade].b}`;
const textRgbValues = `${textShades[shade].r}, ${textShades[shade].g}, ${textShades[shade].b}`;
const primaryRgbValues = `${primaryShades[shade].r}, ${primaryShades[shade].g}, ${primaryShades[shade].b}`;
const sidebarBackgroundRgbValues = `${sidebarBackgroundShades[shade].r}, ${sidebarBackgroundShades[shade].g}, ${sidebarBackgroundShades[shade].b}`;
const sidebarTextRgbValues = `${sidebarTextShades[shade].r}, ${sidebarTextShades[shade].g}, ${sidebarTextShades[shade].b}`;
themeElement?.style.setProperty(`--color-background-${shade}`, bgRgbValues);
themeElement?.style.setProperty(`--color-text-${shade}`, textRgbValues);
themeElement?.style.setProperty(`--color-primary-${shade}`, primaryRgbValues);
themeElement?.style.setProperty(`--color-sidebar-background-${shade}`, sidebarBackgroundRgbValues);
themeElement?.style.setProperty(`--color-sidebar-text-${shade}`, sidebarTextRgbValues);
if (i >= 100 && i <= 400) {
const borderShade = i === 100 ? 70 : i === 200 ? 80 : i === 300 ? 90 : 100;
themeElement?.style.setProperty(
`--color-border-${shade}`,
`${bgShades[borderShade].r}, ${bgShades[borderShade].g}, ${bgShades[borderShade].b}`
);
themeElement?.style.setProperty(
`--color-sidebar-border-${shade}`,
`${sidebarBackgroundShades[borderShade].r}, ${sidebarBackgroundShades[borderShade].g}, ${sidebarBackgroundShades[borderShade].b}`
);
}
}
themeElement?.style.setProperty("--color-scheme", values[5]);
};
export const unsetCustomCssVariables = () => {
for (let i = 10; i <= 900; i >= 100 ? (i += 100) : (i += 10)) {
const dom = document.querySelector<HTMLElement>("[data-theme='custom']");
dom?.style.removeProperty(`--color-background-${i}`);
dom?.style.removeProperty(`--color-text-${i}`);
dom?.style.removeProperty(`--color-border-${i}`);
dom?.style.removeProperty(`--color-primary-${i}`);
dom?.style.removeProperty(`--color-sidebar-background-${i}`);
dom?.style.removeProperty(`--color-sidebar-text-${i}`);
dom?.style.removeProperty(`--color-sidebar-border-${i}`);
dom?.style.removeProperty("--color-scheme");
}
};
export const resolveGeneralTheme = (resolvedTheme: string | undefined) =>
resolvedTheme?.includes("light") ? "light" : resolvedTheme?.includes("dark") ? "dark" : "system";

View file

@ -0,0 +1,352 @@
import { differenceInCalendarDays } from "date-fns/differenceInCalendarDays";
import isEmpty from "lodash/isEmpty";
import { v4 as uuidv4 } from "uuid";
// plane imports
import {
EIssueLayoutTypes,
ISSUE_DISPLAY_FILTERS_BY_PAGE,
STATE_GROUPS,
TIssuePriorities,
ISSUE_PRIORITY_FILTERS,
TIssueFilterPriorityObject,
} from "@plane/constants";
import {
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IGanttBlock,
TGroupedIssues,
TIssue,
TIssueGroupByOptions,
TIssueOrderByOptions,
TIssueParams,
TStateGroups,
TSubGroupedIssues,
TUnGroupedIssues,
} from "@plane/types";
// local imports
import { orderArrayBy } from "../array";
import { getDate } from "../datetime";
import { isEditorEmpty } from "../editor";
type THandleIssuesMutation = (
formData: Partial<TIssue>,
oldGroupTitle: string,
selectedGroupBy: TIssueGroupByOptions,
issueIndex: number,
orderBy: TIssueOrderByOptions,
prevData?:
| {
[key: string]: TIssue[];
}
| TIssue[]
) =>
| {
[key: string]: TIssue[];
}
| TIssue[]
| undefined;
export const handleIssuesMutation: THandleIssuesMutation = (
formData,
oldGroupTitle,
selectedGroupBy,
issueIndex,
orderBy,
prevData
) => {
if (!prevData) return prevData;
if (Array.isArray(prevData)) {
const updatedIssue = {
...prevData[issueIndex],
...formData,
};
prevData.splice(issueIndex, 1, updatedIssue);
return [...prevData];
} else {
const oldGroup = prevData[oldGroupTitle ?? ""] ?? [];
let newGroup: TIssue[] = [];
if (selectedGroupBy === "priority") newGroup = prevData[formData.priority ?? ""] ?? [];
else if (selectedGroupBy === "state") newGroup = prevData[formData.state_id ?? ""] ?? [];
const updatedIssue = {
...oldGroup[issueIndex],
...formData,
};
if (selectedGroupBy !== Object.keys(formData)[0])
return {
...prevData,
[oldGroupTitle ?? ""]: orderArrayBy(
oldGroup.map((i) => (i.id === updatedIssue.id ? updatedIssue : i)),
orderBy
),
};
const groupThatIsUpdated = selectedGroupBy === "priority" ? formData.priority : formData.state_id;
return {
...prevData,
[oldGroupTitle ?? ""]: orderArrayBy(
oldGroup.filter((i) => i.id !== updatedIssue.id),
orderBy
),
[groupThatIsUpdated ?? ""]: orderArrayBy([...newGroup, updatedIssue], orderBy),
};
}
};
export const handleIssueQueryParamsByLayout = (
layout: EIssueLayoutTypes | undefined,
viewType: "my_issues" | "issues" | "profile_issues" | "archived_issues" | "draft_issues" | "team_issues"
): TIssueParams[] | null => {
const queryParams: TIssueParams[] = [];
if (!layout) return null;
const layoutOptions = ISSUE_DISPLAY_FILTERS_BY_PAGE[viewType][layout];
// add filters query params
layoutOptions.filters.forEach((option) => {
queryParams.push(option);
});
// add display filters query params
Object.keys(layoutOptions.display_filters).forEach((option) => {
queryParams.push(option as TIssueParams);
});
// add extra options query params
if (layoutOptions.extra_options.access) {
layoutOptions.extra_options.values.forEach((option) => {
queryParams.push(option);
});
}
return queryParams;
};
/**
*
* @description create a full issue payload with some default values. This function also parse the form field
* like assignees, labels, etc. and add them to the payload
* @param projectId project id to be added in the issue payload
* @param formData partial issue data from the form. This will override the default values
* @returns full issue payload with some default values
*/
export const createIssuePayload: (projectId: string, formData: Partial<TIssue>) => TIssue = (
projectId: string,
formData: Partial<TIssue>
) => {
const payload: TIssue = {
id: uuidv4(),
project_id: projectId,
priority: "none",
// tempId is used for optimistic updates. It is not a part of the API response.
tempId: uuidv4(),
// to be overridden by the form data
...formData,
} as TIssue;
return payload;
};
/**
* @description check if the issue due date should be highlighted
* @param date
* @param stateGroup
* @returns boolean
*/
export const shouldHighlightIssueDueDate = (
date: string | Date | null,
stateGroup: TStateGroups | undefined
): boolean => {
if (!date || !stateGroup) return false;
// if the issue is completed or cancelled, don't highlight the due date
if ([STATE_GROUPS.completed.key, STATE_GROUPS.cancelled.key].includes(stateGroup)) return false;
const parsedDate = getDate(date);
if (!parsedDate) return false;
const targetDateDistance = differenceInCalendarDays(parsedDate, new Date());
// if the issue is overdue, highlight the due date
return targetDateDistance <= 0;
};
export const getIssueBlocksStructure = (block: TIssue): IGanttBlock => ({
data: block,
id: block?.id,
name: block?.name,
sort_order: block?.sort_order,
start_date: block?.start_date ?? undefined,
target_date: block?.target_date ?? undefined,
});
export const formatTextList = (TextArray: string[]): string => {
const count = TextArray.length;
switch (count) {
case 0:
return "";
case 1:
return TextArray[0];
case 2:
return `${TextArray[0]} and ${TextArray[1]}`;
case 3:
return `${TextArray.slice(0, 2).join(", ")}, and ${TextArray[2]}`;
case 4:
return `${TextArray.slice(0, 3).join(", ")}, and ${TextArray[3]}`;
default:
return `${TextArray.slice(0, 3).join(", ")}, and +${count - 3} more`;
}
};
export const getDescriptionPlaceholderI18n = (isFocused: boolean, description: string | undefined): string => {
const isDescriptionEmpty = isEditorEmpty(description);
if (!isDescriptionEmpty || isFocused) return "common.press_for_commands";
else return "common.click_to_add_description";
};
export const issueCountBasedOnFilters = (
issueIds: TGroupedIssues | TUnGroupedIssues | TSubGroupedIssues,
layout: EIssueLayoutTypes,
groupBy: string | undefined,
subGroupBy: string | undefined
): number => {
let issuesCount = 0;
if (!layout) return issuesCount;
if (["spreadsheet", "gantt_chart"].includes(layout)) {
issuesCount = (issueIds as TUnGroupedIssues)?.length;
} else if (layout === "calendar") {
Object.keys(issueIds || {}).map((groupId) => {
issuesCount += (issueIds as TGroupedIssues)?.[groupId]?.length;
});
} else if (layout === "list") {
if (groupBy) {
Object.keys(issueIds || {}).map((groupId) => {
issuesCount += (issueIds as TGroupedIssues)?.[groupId]?.length;
});
} else {
issuesCount = (issueIds as TUnGroupedIssues)?.length;
}
} else if (layout === "kanban") {
if (groupBy && subGroupBy) {
Object.keys(issueIds || {}).map((groupId) => {
Object.keys((issueIds as TSubGroupedIssues)?.[groupId] || {}).map((subGroupId) => {
issuesCount += (issueIds as TSubGroupedIssues)?.[groupId]?.[subGroupId]?.length || 0;
});
});
} else if (groupBy) {
Object.keys(issueIds || {}).map((groupId) => {
issuesCount += (issueIds as TGroupedIssues)?.[groupId]?.length;
});
}
}
return issuesCount;
};
/**
* @description This method is used to apply the display filters on the issues
* @param {IIssueDisplayFilterOptions} displayFilters
* @returns {IIssueDisplayFilterOptions}
*/
export const getComputedDisplayFilters = (
displayFilters: IIssueDisplayFilterOptions = {},
defaultValues?: IIssueDisplayFilterOptions
): IIssueDisplayFilterOptions => {
const filters = displayFilters || defaultValues;
return {
calendar: {
show_weekends: filters?.calendar?.show_weekends || false,
layout: filters?.calendar?.layout || "month",
},
layout: filters?.layout || EIssueLayoutTypes.LIST,
order_by: filters?.order_by || "sort_order",
group_by: filters?.group_by || null,
sub_group_by: filters?.sub_group_by || null,
type: filters?.type || null,
sub_issue: filters?.sub_issue || false,
show_empty_groups: filters?.show_empty_groups || false,
};
};
/**
* @description This method is used to apply the display properties on the issues
* @param {IIssueDisplayProperties} displayProperties
* @returns {IIssueDisplayProperties}
*/
export const getComputedDisplayProperties = (
displayProperties: IIssueDisplayProperties = {}
): IIssueDisplayProperties => ({
assignee: displayProperties?.assignee ?? true,
start_date: displayProperties?.start_date ?? true,
due_date: displayProperties?.due_date ?? true,
labels: displayProperties?.labels ?? true,
priority: displayProperties?.priority ?? true,
state: displayProperties?.state ?? true,
sub_issue_count: displayProperties?.sub_issue_count ?? true,
attachment_count: displayProperties?.attachment_count ?? true,
link: displayProperties?.link ?? true,
estimate: displayProperties?.estimate ?? true,
key: displayProperties?.key ?? true,
created_on: displayProperties?.created_on ?? true,
updated_on: displayProperties?.updated_on ?? true,
modules: displayProperties?.modules ?? true,
cycle: displayProperties?.cycle ?? true,
issue_type: displayProperties?.issue_type ?? true,
});
/**
* This is to check if the issues list api should fall back to server or use local db
* @param queries
* @returns
*/
export const getIssuesShouldFallbackToServer = (queries: any) => {
// If there is expand query and is not grouped then fallback to server
if (!isEmpty(queries.expand as string) && !queries.group_by) return true;
// If query has mentions then fallback to server
if (!isEmpty(queries.mentions)) return true;
return false;
};
export const generateWorkItemLink = ({
workspaceSlug,
projectId,
issueId,
projectIdentifier,
sequenceId,
isArchived = false,
isEpic = false,
}: {
workspaceSlug: string | undefined | null;
projectId: string | undefined | null;
issueId: string | undefined | null;
projectIdentifier: string | undefined | null;
sequenceId: string | number | undefined | null;
isArchived?: boolean;
isEpic?: boolean;
}): string => {
const archiveIssueLink = `/${workspaceSlug}/projects/${projectId}/archives/issues/${issueId}`;
const epicLink = `/${workspaceSlug}/projects/${projectId}/epics/${issueId}`;
const workItemLink = `/${workspaceSlug}/browse/${projectIdentifier}-${sequenceId}/`;
return isArchived ? archiveIssueLink : isEpic ? epicLink : workItemLink;
};
export const getIssuePriorityFilters = (priorityKey: TIssuePriorities): TIssueFilterPriorityObject | undefined => {
const currentIssuePriority: TIssueFilterPriorityObject | undefined =
ISSUE_PRIORITY_FILTERS && ISSUE_PRIORITY_FILTERS.length > 0
? ISSUE_PRIORITY_FILTERS.find((_priority) => _priority.key === priorityKey)
: undefined;
if (currentIssuePriority) return currentIssuePriority;
return undefined;
};

View file

@ -1 +1,3 @@
export * from "./base";
export * from "./modal";
export * from "./state";

View file

@ -1,3 +1,4 @@
import set from "lodash/set";
// plane imports
import { DEFAULT_WORK_ITEM_FORM_VALUES } from "@plane/constants";
import { IPartialProject, ISearchIssueResponse, IState, TIssue } from "@plane/types";
@ -31,3 +32,17 @@ export const convertWorkItemDataToSearchResponse = (
state__name: state?.name ?? "",
workspace__slug: workspaceSlug,
});
export function getChangedIssuefields(formData: Partial<TIssue>, dirtyFields: { [key: string]: boolean | undefined }) {
const changedFields = {};
const dirtyFieldKeys = Object.keys(dirtyFields) as (keyof TIssue)[];
for (const dirtyField of dirtyFieldKeys) {
if (!!dirtyFields[dirtyField]) {
set(changedFields, [dirtyField], formData[dirtyField]);
}
}
return changedFields as Partial<TIssue>;
}

View file

@ -0,0 +1,49 @@
// plane imports
import { STATE_GROUPS, TDraggableData } from "@plane/constants";
import { IState, IStateResponse } from "@plane/types";
export const orderStateGroups = (unorderedStateGroups: IStateResponse | undefined): IStateResponse | undefined => {
if (!unorderedStateGroups) return undefined;
return Object.assign({ backlog: [], unstarted: [], started: [], completed: [], cancelled: [] }, unorderedStateGroups);
};
export const sortStates = (states: IState[]) => {
if (!states || states.length === 0) return;
return states.sort((stateA, stateB) => {
if (stateA.group === stateB.group) {
return stateA.sequence - stateB.sequence;
}
return Object.keys(STATE_GROUPS).indexOf(stateA.group) - Object.keys(STATE_GROUPS).indexOf(stateB.group);
});
};
export const getCurrentStateSequence = (
groupSates: IState[],
destinationData: TDraggableData,
edge: string | undefined
) => {
const defaultSequence = 65535;
if (!edge) return defaultSequence;
const currentStateIndex = groupSates.findIndex((state) => state.id === destinationData.id);
const currentStateSequence = groupSates[currentStateIndex]?.sequence || undefined;
if (!currentStateSequence) return defaultSequence;
if (edge === "top") {
const prevStateSequence = groupSates[currentStateIndex - 1]?.sequence || undefined;
if (prevStateSequence === undefined) {
return currentStateSequence - defaultSequence;
}
return (currentStateSequence + prevStateSequence) / 2;
} else if (edge === "bottom") {
const nextStateSequence = groupSates[currentStateIndex + 1]?.sequence || undefined;
if (nextStateSequence === undefined) {
return currentStateSequence + defaultSequence;
}
return (currentStateSequence + nextStateSequence) / 2;
}
};