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

@ -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;
}
};