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,119 +0,0 @@
import isEmpty from "lodash/isEmpty";
import { IIssueLabel, IIssueLabelTree } from "@plane/types";
export const groupBy = (array: any[], key: string) => {
const innerKey = key.split("."); // split the key by dot
return array.reduce((result, currentValue) => {
const key = innerKey.reduce((obj, i) => obj?.[i], currentValue) ?? "None"; // get the value of the inner key
(result[key] = result[key] || []).push(currentValue);
return result;
}, {});
};
export const orderArrayBy = (orgArray: any[], key: string, ordering: "ascending" | "descending" = "ascending") => {
if (!orgArray || !Array.isArray(orgArray) || orgArray.length === 0) return [];
const array = [...orgArray];
if (key[0] === "-") {
ordering = "descending";
key = key.slice(1);
}
const innerKey = key.split("."); // split the key by dot
return array.sort((a, b) => {
const keyA = innerKey.reduce((obj, i) => obj[i], a); // get the value of the inner key
const keyB = innerKey.reduce((obj, i) => obj[i], b); // get the value of the inner key
if (keyA < keyB) {
return ordering === "ascending" ? -1 : 1;
}
if (keyA > keyB) {
return ordering === "ascending" ? 1 : -1;
}
return 0;
});
};
export const checkDuplicates = (array: any[]) => new Set(array).size !== array.length;
export const findStringWithMostCharacters = (strings: string[]): string => {
if (!strings || strings.length === 0) return "";
return strings.reduce((longestString, currentString) =>
currentString.length > longestString.length ? currentString : longestString
);
};
export const checkIfArraysHaveSameElements = (arr1: any[] | null, arr2: any[] | null): boolean => {
if (!arr1 || !arr2) return false;
if (!Array.isArray(arr1) || !Array.isArray(arr2)) return false;
if (arr1.length === 0 && arr2.length === 0) return true;
return arr1.length === arr2.length && arr1.every((e) => arr2.includes(e));
};
type GroupedItems<T> = { [key: string]: T[] };
export const groupByField = <T>(array: T[], field: keyof T): GroupedItems<T> =>
array.reduce((grouped: GroupedItems<T>, item: T) => {
const key = String(item[field]);
grouped[key] = (grouped[key] || []).concat(item);
return grouped;
}, {});
export const sortByField = (array: any[], field: string): any[] =>
array.sort((a, b) => (a[field] < b[field] ? -1 : a[field] > b[field] ? 1 : 0));
export const orderGroupedDataByField = <T>(groupedData: GroupedItems<T>, orderBy: keyof T): GroupedItems<T> => {
for (const key in groupedData) {
if (groupedData.hasOwnProperty(key)) {
groupedData[key] = groupedData[key].sort((a, b) => {
if (a[orderBy] < b[orderBy]) return -1;
if (a[orderBy] > b[orderBy]) return 1;
return 0;
});
}
}
return groupedData;
};
export const buildTree = (array: IIssueLabel[], parent = null) => {
const tree: IIssueLabelTree[] = [];
array.forEach((item: any) => {
if (item.parent === parent) {
const children = buildTree(array, item.id);
item.children = children;
tree.push(item);
}
});
return tree;
};
/**
* Returns Valid keys from object whose value is not falsy
* @param obj
* @returns
*/
export const getValidKeysFromObject = (obj: any) => {
if (!obj || isEmpty(obj) || typeof obj !== "object" || Array.isArray(obj)) return [];
return Object.keys(obj).filter((key) => !!obj[key]);
};
/**
* Convert an array into an object of keys and boolean strue
* @param arrayStrings
* @returns
*/
export const convertStringArrayToBooleanObject = (arrayStrings: string[]) => {
const obj: { [key: string]: boolean } = {};
for (const arrayString of arrayStrings) {
obj[arrayString] = true;
}
return obj;
};

View file

@ -1,32 +0,0 @@
export const generateFileName = (fileName: string) => {
const date = new Date();
const timestamp = date.getTime();
const _fileName = getFileName(fileName);
const nameWithoutExtension = _fileName.length > 80 ? _fileName.substring(0, 80) : _fileName;
const extension = getFileExtension(fileName);
return `${nameWithoutExtension}-${timestamp}.${extension}`;
};
export const getFileExtension = (filename: string) => filename.slice(((filename.lastIndexOf(".") - 1) >>> 0) + 2);
export const getFileName = (fileName: string) => {
const dotIndex = fileName.lastIndexOf(".");
const nameWithoutExtension = fileName.substring(0, dotIndex);
return nameWithoutExtension;
};
export const convertBytesToSize = (bytes: number) => {
let size;
if (bytes < 1024 * 1024) {
size = Math.round(bytes / 1024) + " KB";
} else {
size = Math.round(bytes / (1024 * 1024)) + " MB";
}
return size;
};

View file

@ -1,7 +1,7 @@
import { ReactNode } from "react";
import Link from "next/link";
// helpers
import { SUPPORT_EMAIL } from "./common.helper";
// plane imports
import { SUPPORT_EMAIL } from "@plane/constants";
export enum EPageTypes {
PUBLIC = "PUBLIC",

View file

@ -1,114 +0,0 @@
import { EStartOfTheWeek } from "@plane/constants";
// helpers
import { ICalendarDate, ICalendarPayload } from "@/components/issues";
import { DAYS_LIST } from "@/constants/calendar";
import { getWeekNumberOfDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
// types
export const formatDate = (date: Date, format: string): string => {
const day = date.getDate();
const month = date.getMonth() + 1;
const year = date.getFullYear();
const hours = date.getHours();
const minutes = date.getMinutes();
const seconds = date.getSeconds();
const daysOfWeek = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
const monthsOfYear = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
const formattedDate = format
.replace("dd", day.toString().padStart(2, "0"))
.replace("d", day.toString())
.replace("eee", daysOfWeek[date.getDay()])
.replace("Month", monthsOfYear[month - 1])
.replace("yyyy", year.toString())
.replace("yyy", year.toString().slice(-3))
.replace("hh", hours.toString().padStart(2, "0"))
.replace("mm", minutes.toString().padStart(2, "0"))
.replace("ss", seconds.toString().padStart(2, "0"));
return formattedDate;
};
/**
* @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 function getOrderedDays<T>(
items: T[],
getDayIndex: (item: T) => number,
startOfWeek: EStartOfTheWeek = EStartOfTheWeek.SUNDAY
): T[] {
return [...items].sort((a, b) => {
const dayA = (7 + getDayIndex(a) - startOfWeek) % 7;
const dayB = (7 + getDayIndex(b) - startOfWeek) % 7;
return dayA - dayB;
});
}

View file

@ -1,144 +0,0 @@
export type TRgb = { r: number; g: number; b: number };
export const hexToRgb = (hex: string): TRgb => {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return { r, g, b };
};
export const rgbToHex = (rgb: TRgb): string => {
const { r, g, b } = rgb;
const hexR = r.toString(16).padStart(2, "0");
const hexG = g.toString(16).padStart(2, "0");
const hexB = b.toString(16).padStart(2, "0");
return `#${hexR}${hexG}${hexB}`;
};
/**
* 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 function getLuminance({ r, g, b }: { r: number; g: number; b: number }) {
// 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)`,
};
}

View file

@ -1 +0,0 @@
export * from "ce/helpers/command-palette";

View file

@ -1,44 +0,0 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "";
export const ADMIN_BASE_URL = process.env.NEXT_PUBLIC_ADMIN_BASE_URL || "";
export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "";
export const SPACE_BASE_URL = process.env.NEXT_PUBLIC_SPACE_BASE_URL || "";
export const SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || "";
export const LIVE_BASE_URL = process.env.NEXT_PUBLIC_LIVE_BASE_URL || "";
export const LIVE_BASE_PATH = process.env.NEXT_PUBLIC_LIVE_BASE_PATH || "";
export const LIVE_URL = `${LIVE_BASE_URL}${LIVE_BASE_PATH}`;
export const SUPPORT_EMAIL = process.env.NEXT_PUBLIC_SUPPORT_EMAIL || "";
export const GOD_MODE_URL = encodeURI(`${ADMIN_BASE_URL}${ADMIN_BASE_PATH}/`);
export const debounce = (func: any, wait: number, immediate: boolean = false) => {
let timeout: any;
return function executedFunction(...args: any) {
const later = () => {
timeout = null;
if (!immediate) func(...args);
};
const callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) func(...args);
};
};
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
export const convertRemToPixel = (rem: number): number => rem * 0.9 * 16;
export const getProgress = (completed: number | undefined, total: number | undefined) =>
total && total > 0 ? Math.round(((completed ?? 0) / total) * 100) : 0;

View file

@ -1,172 +0,0 @@
import { startOfToday, format } from "date-fns";
import { isEmpty, orderBy, uniqBy } from "lodash";
import sortBy from "lodash/sortBy";
import { ICycle, TCycleFilters } from "@plane/types";
// helpers
import { findTotalDaysInRange, generateDateArray, getDate } from "@/helpers/date-time.helper";
import { satisfiesDateFilter } from "@/helpers/filter.helper";
export type TProgressChartData = {
date: string;
scope: number;
completed: number;
backlog: number;
started: number;
unstarted: number;
cancelled: number;
pending: number;
ideal: number;
actual: number;
}[];
/**
* @description orders cycles based on their status
* @param {ICycle[]} cycles
* @returns {ICycle[]}
*/
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;
};
/**
* @description filters cycles based on the filter
* @param {ICycle} cycle
* @param {TCycleFilters} filter
* @returns {boolean}
*/
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;
};
const scope = (p: any, isTypeIssue: boolean) => (isTypeIssue ? p.total_issues : p.total_estimate_points);
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
);
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;
};
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

@ -4,7 +4,9 @@ import { endOfMonth, endOfWeek, endOfYear, startOfMonth, startOfWeek, startOfYea
import { DURATION_FILTER_OPTIONS, EDurationFilters } from "@plane/constants";
import { TIssuesListTypes } from "@plane/types";
// constants
import { renderFormattedDate, renderFormattedPayloadDate } from "./date-time.helper";
import { renderFormattedDate, renderFormattedPayloadDate } from "@plane/utils";
// -------------------- DEPRECATED --------------------
/**
* @description returns date range based on the duration filter

View file

@ -1,477 +0,0 @@
import { differenceInDays, format, formatDistanceToNow, isAfter, isEqual, isValid, parseISO } from "date-fns";
import isNumber from "lodash/isNumber";
// Format Date Helpers
/**
* @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
*/
export const renderFormattedDate = (
date: string | Date | undefined | null,
formatToken: string = "MMM dd, yyyy"
): 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
let formattedDate;
try {
// Format the date in the format provided or default format (MMM dd, yyyy)
formattedDate = format(parsedDate, formatToken);
} catch (e) {
// Format the date in format (MMM dd, yyyy) in case of any error
formattedDate = format(parsedDate, "MMM dd, yyyy");
}
return formattedDate;
};
/**
* @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} startDate
* @param {string} endDate
* @param {boolean} inclusive
* @example checkIfStringIsDate("2021-01-01", "2021-01-08") // 8
*/
export const findTotalDaysInRange = (
startDate: Date | string | undefined | null,
endDate: Date | string | undefined | null,
inclusive: boolean = true
): number | undefined => {
// Parse the dates to check if they are valid
const parsedStartDate = getDate(startDate);
const parsedEndDate = getDate(endDate);
// return if undefined
if (!parsedStartDate || !parsedEndDate) return;
// Check if the parsed dates are valid before calculating the difference
if (!isValid(parsedStartDate) || !isValid(parsedEndDate)) return 0; // Return 0 for invalid dates
// Calculate the difference in days
const diffInDays = differenceInDays(parsedEndDate, parsedStartDate);
// Return the difference in days based on inclusive flag
return inclusive ? diffInDays + 1 : diffInDays;
};
/**
* 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) => {
// 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
* @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 | 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;
// 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;
};
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`;
}
const diffInMinutes = diffInSeconds / 60;
if (diffInMinutes < 60) {
return `${Math.floor(diffInMinutes)}m`;
}
const diffInHours = diffInMinutes / 60;
if (diffInHours < 24) {
return `${Math.floor(diffInHours)}h`;
}
const diffInDays = diffInHours / 24;
if (diffInDays < 30) {
return `${Math.floor(diffInDays)}d`;
}
const diffInMonths = diffInDays / 30;
if (diffInMonths < 12) {
return `${Math.floor(diffInMonths)}mo`;
}
const diffInYears = diffInMonths / 12;
return `${Math.floor(diffInYears)}y`;
}
// Date Validation Helpers
/**
* @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();
// 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
* @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;
};
/**
* @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,
date2: Date | string | null | undefined
): 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);
};
/**
* 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);
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);
};
/**
* returns the date string in ISO format regardless of the timezone in input date string
* @param dateString
* @returns
*/
export const convertToISODateString = (dateString: string | undefined) => {
if (!dateString) return dateString;
const date = new Date(dateString);
return date.toISOString();
};
/**
* returns the date string in Epoch regardless of the timezone in input date string
* @param dateString
* @returns
*/
export const convertToEpoch = (dateString: string | undefined) => {
if (!dateString) return dateString;
const date = new Date(dateString);
return date.getTime();
};
/**
* get current Date time in UTC ISO format
* @returns
*/
export const getCurrentDateTimeInISO = () => {
const date = new Date();
return date.toISOString();
};
/**
* @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 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: hours, minutes: minutes };
};
/**
* @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 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;
const minutes = wordsCount / wordsPerMinute;
return minutes * 60;
};
/**
* @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) => {
// 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);
// Create an empty array to store the dates
const dateArray = [];
// Use a while loop to generate dates between the range
while (start <= end) {
// Increment the date by 1 day (86400000 milliseconds)
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],
});
}
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;
};

View file

@ -1,292 +0,0 @@
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";
// types
import { ICycle, IEstimatePoint, IModule, IState, TIssue } from "@plane/types";
// helper
import { getDate } from "./date-time.helper";
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

@ -1,14 +0,0 @@
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); // Cleanup after the download link is clicked
};

View file

@ -1,37 +0,0 @@
// helpers
import { getFileURL } from "@/helpers/file.helper";
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,11 +1,8 @@
// plane imports
import { RANDOM_EMOJI_CODES } from "@plane/constants";
import { LUCIDE_ICONS_LIST } from "@plane/ui";
export const getRandomEmoji = () => RANDOM_EMOJI_CODES[Math.floor(Math.random() * RANDOM_EMOJI_CODES.length)];
export const getRandomIconName = () => LUCIDE_ICONS_LIST[Math.floor(Math.random() * LUCIDE_ICONS_LIST.length)].name;
/**
* Renders an emoji or icon
* @param {string | { name: string; color: string }} emoji - The emoji or icon to render
* @returns {React.ReactNode} The rendered emoji or icon
*/
export const renderEmoji = (
emoji:
| string
@ -13,7 +10,7 @@ export const renderEmoji = (
name: string;
color: string;
}
) => {
): React.ReactNode => {
if (!emoji) return;
if (typeof emoji === "object")
@ -24,54 +21,3 @@ export const renderEmoji = (
);
else return isNaN(parseInt(emoji)) ? emoji : String.fromCodePoint(parseInt(emoji));
};
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: 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
}
if (!acc[reaction[key]]) {
acc[reaction[key]] = [];
}
acc[reaction[key]].push(reaction);
return acc;
},
{} as { [key: string]: any[] }
);
return groupedReactions;
};
export const convertHexEmojiToDecimal = (emojiUnified: string): string => {
if (!emojiUnified) return "";
return emojiUnified
.toString()
.split("-")
.map((e) => parseInt(e, 16))
.join("-");
};
export const emojiCodeToUnicode = (emoji: string) => {
if (!emoji) return "";
// convert emoji code to unicode
const uniCodeEmoji = emoji
.toString()
.split("-")
.map((emoji) => parseInt(emoji, 10).toString(16))
.join("-");
return uniCodeEmoji;
};

View file

@ -1,34 +0,0 @@
// plane web constants
import { EEstimateSystem } from "@/plane-web/constants/estimates";
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,96 +0,0 @@
// types
import { TFileMetaDataLite, TFileSignedURLResponse } from "@plane/types";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
/**
* @description from the provided signed URL response, generate a payload to be used to upload the file
* @param {TFileSignedURLResponse} signedURLResponse
* @param {File} file
* @returns {FormData} file upload request payload
*/
export const generateFileUploadPayload = (signedURLResponse: TFileSignedURLResponse, file: File): FormData => {
const formData = new FormData();
Object.entries(signedURLResponse.upload_data.fields).forEach(([key, value]) => formData.append(key, value));
formData.append("file", file);
return formData;
};
/**
* @description combine the file path with the base URL
* @param {string} path
* @returns {string} final URL with the base URL
*/
export const getFileURL = (path: string): string | undefined => {
if (!path) return undefined;
const isValidURL = path.startsWith("http");
if (isValidURL) return path;
return `${API_BASE_URL}${path}`;
};
/**
* @description returns the necessary file meta data to upload a file
* @param {File} file
* @returns {TFileMetaDataLite} payload with file info
*/
export const getFileMetaDataForUpload = (file: File): TFileMetaDataLite => ({
name: file.name,
size: file.size,
type: file.type,
});
/**
* @description this function returns the assetId from the asset source
* @param {string} src
* @returns {string} assetId
*/
export const getAssetIdFromUrl = (src: string): string => {
// remove the last char if it is a slash
if (src.charAt(src.length - 1) === "/") src = src.slice(0, -1);
const sourcePaths = src.split("/");
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 (error) {
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);
});
};

View file

@ -1,78 +0,0 @@
import { differenceInCalendarDays } from "date-fns/differenceInCalendarDays";
// helpers
import { IIssueFilters } from "@plane/types";
import { getDate } from "./date-time.helper";
// import { IIssueFilterOptions } from "@plane/types";
/**
* @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,3 +1,5 @@
// ------------ DEPRECATED (Use re-charts and its helpers instead) ------------
export const generateYAxisTickValues = (data: number[]) => {
if (!data || !Array.isArray(data) || data.length === 0) return [];

View file

@ -1,73 +0,0 @@
import { subDays } from "date-fns";
import { renderFormattedPayloadDate } from "./date-time.helper";
export enum EInboxIssueCurrentTab {
OPEN = "open",
CLOSED = "closed",
}
export enum EInboxIssueStatus {
PENDING = -2,
DECLINED = -1,
SNOOZED = 0,
ACCEPTED = 1,
DUPLICATE = 2,
}
export enum EPastDurationFilters {
TODAY = "today",
YESTERDAY = "yesterday",
LAST_7_DAYS = "last_7_days",
LAST_30_DAYS = "last_30_days",
}
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`;
}
}
};
export const PAST_DURATION_FILTER_OPTIONS: {
name: string;
value: string;
}[] = [
{
name: "Today",
value: EPastDurationFilters.TODAY,
},
{
name: "Yesterday",
value: EPastDurationFilters.YESTERDAY,
},
{
name: "Last 7 days",
value: EPastDurationFilters.LAST_7_DAYS,
},
{
name: "Last 30 days",
value: EPastDurationFilters.LAST_30_DAYS,
},
];

View file

@ -1,16 +0,0 @@
import set from "lodash/set";
// types
import type { TIssue } from "@plane/types";
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

@ -1,335 +0,0 @@
import { differenceInCalendarDays } from "date-fns/differenceInCalendarDays";
import isEmpty from "lodash/isEmpty";
import { v4 as uuidv4 } from "uuid";
// plane constants
import { EIssueLayoutTypes, ISSUE_DISPLAY_FILTERS_BY_PAGE, STATE_GROUPS } from "@plane/constants";
// types
import {
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
TGroupedIssues,
TIssue,
TIssueGroupByOptions,
TIssueOrderByOptions,
TIssueParams,
TStateGroups,
TSubGroupedIssues,
TUnGroupedIssues,
} from "@plane/types";
import { IGanttBlock } from "@/components/gantt-chart";
// helpers
import { orderArrayBy } from "@/helpers/array.helper";
import { getDate } from "@/helpers/date-time.helper";
import { isEditorEmpty } from "@/helpers/editor.helper";
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;
};

View file

@ -1,81 +0,0 @@
import sortBy from "lodash/sortBy";
import { IModule, TModuleDisplayFilters, TModuleFilters, TModuleOrderByOptions } from "@plane/types";
// helpers
import { getDate } from "@/helpers/date-time.helper";
import { satisfiesDateFilter } from "@/helpers/filter.helper";
/**
* @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

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

View file

@ -1,85 +0,0 @@
import sortBy from "lodash/sortBy";
import { TPage, TPageFilterProps, TPageFiltersSortBy, TPageFiltersSortKey, TPageNavigationTabs } from "@plane/types";
// helpers
import { getDate } from "@/helpers/date-time.helper";
import { satisfiesDateFilter } from "@/helpers/filter.helper";
/**
* @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

@ -1,67 +0,0 @@
import zxcvbn from "zxcvbn";
export enum E_PASSWORD_STRENGTH {
EMPTY = "empty",
LENGTH_NOT_VALID = "length_not_valid",
STRENGTH_NOT_VALID = "strength_not_valid",
STRENGTH_VALID = "strength_valid",
}
const PASSWORD_MIN_LENGTH = 8;
// const PASSWORD_NUMBER_REGEX = /\d/;
// const PASSWORD_CHAR_CAPS_REGEX = /[A-Z]/;
// const PASSWORD_SPECIAL_CHAR_REGEX = /[`!@#$%^&*()_\-+=\[\]{};':"\\|,.<>\/?~ ]/;
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),
// },
];
export const getPasswordStrength = (password: string): E_PASSWORD_STRENGTH => {
let passwordStrength: E_PASSWORD_STRENGTH = E_PASSWORD_STRENGTH.EMPTY;
if (!password || password === "" || password.length <= 0) {
return passwordStrength;
}
if (password.length >= PASSWORD_MIN_LENGTH) {
passwordStrength = E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID;
} else {
passwordStrength = E_PASSWORD_STRENGTH.LENGTH_NOT_VALID;
return passwordStrength;
}
const passwordCriteriaValidation = PASSWORD_CRITERIA.map((criteria) => criteria.isCriteriaValid(password)).every(
(criterion) => criterion
);
const passwordStrengthScore = zxcvbn(password).score;
if (passwordCriteriaValidation === false || passwordStrengthScore <= 2) {
passwordStrength = E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID;
return passwordStrength;
}
if (passwordCriteriaValidation === true && passwordStrengthScore >= 3) {
passwordStrength = E_PASSWORD_STRENGTH.STRENGTH_VALID;
}
return passwordStrength;
};

View file

@ -1,103 +0,0 @@
import isNil from "lodash/isNil";
import orderBy from "lodash/orderBy";
import { IProjectView, TViewFilterProps, TViewFiltersSortBy, TViewFiltersSortKey } from "@plane/types";
import { getDate } from "@/helpers/date-time.helper";
import { SPACE_BASE_PATH, SPACE_BASE_URL } from "./common.helper";
import { satisfiesDateFilter } from "./filter.helper";
/**
* 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

@ -1,106 +0,0 @@
import sortBy from "lodash/sortBy";
// types
import { TProjectDisplayFilters, TProjectFilters, TProjectOrderByOptions } from "@plane/types";
// helpers
import { getDate } from "@/helpers/date-time.helper";
import { satisfiesDateFilter } from "@/helpers/filter.helper";
// plane web imports
import { TProject } from "@/plane-web/types";
/**
* 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

@ -1,9 +0,0 @@
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,50 +0,0 @@
// plane imports
import { STATE_GROUPS, TDraggableData } from "@plane/constants";
// types
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;
}
};

View file

@ -1,236 +0,0 @@
import DOMPurify from "isomorphic-dompurify";
export const addSpaceIfCamelCase = (str: string) => {
if (str === undefined || str === null) return "";
if (typeof str !== "string") str = `${str}`;
return str.replace(/([a-z])([A-Z])/g, "$1 $2");
};
export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " ");
export const capitalizeFirstLetter = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
export const truncateText = (str: string, length: number) => {
if (!str || str === "") return "";
return str.length > length ? `${str.substring(0, length)}...` : str;
};
export const createSimilarString = (str: string) => {
const shuffled = str
.split("")
.sort(() => Math.random() - 0.5)
.join("");
return shuffled;
};
const fallbackCopyTextToClipboard = (text: string) => {
const textArea = document.createElement("textarea");
textArea.value = text;
// Avoid scrolling to bottom
textArea.style.top = "0";
textArea.style.left = "0";
textArea.style.position = "fixed";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
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);
};
export const copyTextToClipboard = async (text: string) => {
if (!navigator.clipboard) {
fallbackCopyTextToClipboard(text);
return;
}
await navigator.clipboard.writeText(text);
};
export const generateRandomColor = (string: string): string => {
if (!string) return "rgb(var(--color-primary-100))";
string = `${string}`;
const uniqueId = string.length.toString() + string; // Unique identifier based on string length
const combinedString = uniqueId + string;
const hash = Array.from(combinedString).reduce((acc, char) => {
const charCode = char.charCodeAt(0);
return (acc << 5) - acc + charCode;
}, 0);
const hue = hash % 360;
const saturation = 70; // Higher saturation for pastel colors
const lightness = 60; // Mid-range lightness for pastel colors
const randomColor = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
return randomColor;
};
export const getFirstCharacters = (str: string) => {
const words = str.trim().split(" ");
if (words.length === 1) {
return words[0].charAt(0);
} else {
return words[0].charAt(0) + words[1].charAt(0);
}
};
/**
* @description: This function will remove all the HTML tags from the string
* @param {string} html
* @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
};
/**
*
* @example:
* const html = "<p>Some text</p>";
* const text = stripAndTruncateHTML(html);
* console.log(text); // Some text
*/
export const stripAndTruncateHTML = (html: string, length: number = 55) => truncateText(sanitizeHTML(html), length);
/**
* @description: This function return number count in string if number is more than 100 then it will return 99+
* @param {number} number
* @return {string}
* @example:
* const number = 100;
* const text = getNumberCount(number);
* console.log(text); // 99+
*/
export const getNumberCount = (number: number): string => {
if (number > 99) {
return "99+";
}
return number.toString();
};
export const objToQueryParams = (obj: any) => {
const params = new URLSearchParams();
if (!obj) return params.toString();
for (const [key, value] of Object.entries(obj)) {
if (value !== undefined && value !== null) params.append(key, value as string);
}
return params.toString();
};
/**
* @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;
for (let i = 0; i < text.length; i++) {
if (text[i].toLowerCase() === searchQuery[searchIndex]?.toLowerCase()) searchIndex++;
// All characters of searchQuery found in order
if (searchIndex === searchQuery.length) return true;
}
// Not all characters of searchQuery found in order
return false;
} catch (error) {
return false;
}
};
/**
* @returns {boolean} true if email is valid, false otherwise
* @description Returns true if email is valid, false otherwise
* @param {string} email string to check if it is a valid email
* @example checkEmailIsValid("hello world") => false
* @example checkEmailIsValid("example@plane.so") => true
*/
export const checkEmailValidity = (email: string): boolean => {
if (!email) return false;
const isEmailValid =
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
email
);
return isEmailValid;
};
export const isEmptyHtmlString = (htmlString: string, allowedHTMLTags: string[] = []) => {
// Remove HTML tags using regex
const cleanText = DOMPurify.sanitize(htmlString, { ALLOWED_TAGS: allowedHTMLTags });
// Trim the string and check if it's empty
return cleanText.trim() === "";
};
/**
* @description this function returns whether a comment is empty or not by checking for the following conditions-
* 1. If comment is undefined
* 2. If comment is an empty string
* 3. If comment is "<p></p>"
* @param {string | undefined} comment
* @returns {boolean}
*/
export const isCommentEmpty = (comment: string | undefined): boolean => {
// return true if comment is undefined
if (!comment) return true;
return (
comment?.trim() === "" ||
comment === "<p></p>" ||
isEmptyHtmlString(comment ?? "", ["img", "mention-component", "image-component"])
);
};
/**
* @description
* This function test whether a URL is valid or not.
*
* It accepts URLs with or without the protocol.
* @param {string} url
* @returns {boolean}
* @example
* checkURLValidity("https://example.com") => true
* checkURLValidity("example.com") => true
* checkURLValidity("example") => false
*/
export const checkURLValidity = (url: string): boolean => {
if (!url) return false;
// regex to support complex query parameters and fragments
const urlPattern =
/^(https?:\/\/)?((([a-z\d-]+\.)*[a-z\d-]+\.[a-z]{2,6})|(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}))(:\d+)?(\/[\w.-]*)*(\?[^#\s]*)?(#[\w-]*)?$/i;
return urlPattern.test(url);
};

View file

@ -1,10 +0,0 @@
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,123 +0,0 @@
import { TRgb, hexToRgb } from "@/helpers/color.helper";
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

@ -1,12 +0,0 @@
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";
}
};