Initial commit: Plane
Some checks failed
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Admin Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Codespell / Check for spelling errors (push) Has been cancelled
Sync Repositories / sync_changes (push) Has been cancelled
Some checks failed
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Admin Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Codespell / Check for spelling errors (push) Has been cancelled
Sync Repositories / sync_changes (push) Has been cancelled
Synced from upstream: 8853637e981ed7d8a6cff32bd98e7afe20f54362
This commit is contained in:
3
packages/utils/.eslintignore
Normal file
3
packages/utils/.eslintignore
Normal file
@@ -0,0 +1,3 @@
|
||||
build/*
|
||||
dist/*
|
||||
out/*
|
||||
4
packages/utils/.eslintrc.cjs
Normal file
4
packages/utils/.eslintrc.cjs
Normal file
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ["@plane/eslint-config/library.js"],
|
||||
};
|
||||
6
packages/utils/.prettierignore
Normal file
6
packages/utils/.prettierignore
Normal file
@@ -0,0 +1,6 @@
|
||||
.turbo
|
||||
.next
|
||||
.vercel
|
||||
out/
|
||||
dist/
|
||||
build/
|
||||
5
packages/utils/.prettierrc
Normal file
5
packages/utils/.prettierrc
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
49
packages/utils/package.json
Normal file
49
packages/utils/package.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@plane/utils",
|
||||
"version": "1.1.0",
|
||||
"description": "Helper functions shared across multiple apps internally",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"dev": "tsdown --watch",
|
||||
"check:lint": "eslint . --max-warnings 20",
|
||||
"check:types": "tsc --noEmit",
|
||||
"check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"",
|
||||
"fix:lint": "eslint . --fix",
|
||||
"fix:format": "prettier --write \"**/*.{ts,tsx,md,json,css,scss}\"",
|
||||
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@plane/constants": "workspace:*",
|
||||
"@plane/types": "workspace:*",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "3.2.7",
|
||||
"lodash-es": "catalog:",
|
||||
"lucide-react": "catalog:",
|
||||
"react": "catalog:",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"uuid": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@types/lodash-es": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.cts"
|
||||
}
|
||||
196
packages/utils/src/array.ts
Normal file
196
packages/utils/src/array.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { isEmpty } from "lodash-es";
|
||||
import { IIssueLabel, IIssueLabelTree } from "@plane/types";
|
||||
|
||||
/**
|
||||
* @description Groups an array of objects by a specified key
|
||||
* @param {any[]} array Array to group
|
||||
* @param {string} key Key to group by (supports dot notation for nested objects)
|
||||
* @returns {Object} Grouped object with keys being the grouped values
|
||||
* @example
|
||||
* const array = [{type: 'A', value: 1}, {type: 'B', value: 2}, {type: 'A', value: 3}];
|
||||
* groupBy(array, 'type') // returns { A: [{type: 'A', value: 1}, {type: 'A', value: 3}], B: [{type: 'B', value: 2}] }
|
||||
*/
|
||||
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;
|
||||
}, {});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Orders an array by a specified key in ascending or descending order
|
||||
* @param {any[]} orgArray Original array to order
|
||||
* @param {string} key Key to order by (supports dot notation for nested objects)
|
||||
* @param {"ascending" | "descending"} ordering Sort order
|
||||
* @returns {any[]} Ordered array
|
||||
* @example
|
||||
* const array = [{value: 2}, {value: 1}, {value: 3}];
|
||||
* orderArrayBy(array, 'value', 'ascending') // returns [{value: 1}, {value: 2}, {value: 3}]
|
||||
*/
|
||||
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;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Checks if an array contains duplicate values
|
||||
* @param {any[]} array Array to check for duplicates
|
||||
* @returns {boolean} True if duplicates exist, false otherwise
|
||||
* @example
|
||||
* checkDuplicates([1, 2, 2, 3]) // returns true
|
||||
* checkDuplicates([1, 2, 3]) // returns false
|
||||
*/
|
||||
export const checkDuplicates = (array: any[]) => new Set(array).size !== array.length;
|
||||
|
||||
/**
|
||||
* @description Finds the string with the most characters in an array of strings
|
||||
* @param {string[]} strings Array of strings to check
|
||||
* @returns {string} String with the most characters
|
||||
* @example
|
||||
* findStringWithMostCharacters(['a', 'bb', 'ccc']) // returns 'ccc'
|
||||
*/
|
||||
export const findStringWithMostCharacters = (strings: string[]): string => {
|
||||
if (!strings || strings.length === 0) return "";
|
||||
|
||||
return strings.reduce((longestString, currentString) =>
|
||||
currentString.length > longestString.length ? currentString : longestString
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Checks if two arrays have the same elements regardless of order
|
||||
* @param {any[] | null} arr1 First array
|
||||
* @param {any[] | null} arr2 Second array
|
||||
* @returns {boolean} True if arrays have same elements, false otherwise
|
||||
* @example
|
||||
* checkIfArraysHaveSameElements([1, 2], [2, 1]) // returns true
|
||||
* checkIfArraysHaveSameElements([1, 2], [1, 3]) // returns false
|
||||
*/
|
||||
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[] };
|
||||
|
||||
/**
|
||||
* @description Groups an array of objects by a specified field
|
||||
* @param {T[]} array Array to group
|
||||
* @param {keyof T} field Field to group by
|
||||
* @returns {GroupedItems<T>} Grouped object
|
||||
* @example
|
||||
* const array = [{type: 'A', value: 1}, {type: 'B', value: 2}];
|
||||
* groupByField(array, 'type') // returns { A: [{type: 'A', value: 1}], B: [{type: 'B', value: 2}] }
|
||||
*/
|
||||
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;
|
||||
}, {});
|
||||
|
||||
/**
|
||||
* @description Sorts an array of objects by a specified field
|
||||
* @param {any[]} array Array to sort
|
||||
* @param {string} field Field to sort by
|
||||
* @returns {any[]} Sorted array
|
||||
* @example
|
||||
* const array = [{value: 2}, {value: 1}];
|
||||
* sortByField(array, 'value') // returns [{value: 1}, {value: 2}]
|
||||
*/
|
||||
export const sortByField = (array: any[], field: string): any[] =>
|
||||
array.sort((a, b) => (a[field] < b[field] ? -1 : a[field] > b[field] ? 1 : 0));
|
||||
|
||||
/**
|
||||
* @description Orders grouped data by a specified field
|
||||
* @param {GroupedItems<T>} groupedData Grouped data object
|
||||
* @param {keyof T} orderBy Field to order by
|
||||
* @returns {GroupedItems<T>} Ordered grouped data
|
||||
*/
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Builds a tree structure from an array of labels
|
||||
* @param {IIssueLabel[]} array Array of labels
|
||||
* @param {any} parent Parent ID
|
||||
* @returns {IIssueLabelTree[]} Tree structure
|
||||
*/
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Returns valid keys from object whose value is not falsy
|
||||
* @param {any} obj Object to check
|
||||
* @returns {string[]} Array of valid keys
|
||||
* @example
|
||||
* getValidKeysFromObject({a: 1, b: 0, c: null}) // returns ['a']
|
||||
*/
|
||||
export const getValidKeysFromObject = (obj: any) => {
|
||||
if (!obj || isEmpty(obj) || typeof obj !== "object" || Array.isArray(obj)) return [];
|
||||
|
||||
return Object.keys(obj).filter((key) => !!obj[key]);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Converts an array of strings into an object with boolean true values
|
||||
* @param {string[]} arrayStrings Array of strings
|
||||
* @returns {Object} Object with string keys and boolean values
|
||||
* @example
|
||||
* convertStringArrayToBooleanObject(['a', 'b']) // returns {a: true, b: true}
|
||||
*/
|
||||
export const convertStringArrayToBooleanObject = (arrayStrings: string[]) => {
|
||||
const obj: { [key: string]: boolean } = {};
|
||||
|
||||
for (const arrayString of arrayStrings) {
|
||||
obj[arrayString] = true;
|
||||
}
|
||||
|
||||
return obj;
|
||||
};
|
||||
32
packages/utils/src/attachment.ts
Normal file
32
packages/utils/src/attachment.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
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;
|
||||
};
|
||||
358
packages/utils/src/auth.ts
Normal file
358
packages/utils/src/auth.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
// plane imports
|
||||
import { E_PASSWORD_STRENGTH, EErrorAlertType, EAuthErrorCodes, TAuthErrorInfo } from "@plane/constants";
|
||||
|
||||
/**
|
||||
* @description Password strength levels
|
||||
*/
|
||||
export enum PasswordStrength {
|
||||
EMPTY = "empty",
|
||||
WEAK = "weak",
|
||||
FAIR = "fair",
|
||||
GOOD = "good",
|
||||
STRONG = "strong",
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate password strength based on various criteria
|
||||
*/
|
||||
export const getPasswordStrength = (password: string): E_PASSWORD_STRENGTH => {
|
||||
if (!password || password === "" || password.length <= 0) {
|
||||
return E_PASSWORD_STRENGTH.EMPTY;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return E_PASSWORD_STRENGTH.LENGTH_NOT_VALID;
|
||||
}
|
||||
|
||||
// Check all criteria
|
||||
const hasUpperCase = /[A-Z]/.test(password);
|
||||
const hasLowerCase = /[a-z]/.test(password);
|
||||
const hasDigit = /[0-9]/.test(password);
|
||||
const hasSpecialChar = /[!@#$%^&*()\-_+=\[\]{}|;:'",.<>?/]/.test(password);
|
||||
|
||||
if (hasUpperCase && hasLowerCase && hasDigit && hasSpecialChar) {
|
||||
return E_PASSWORD_STRENGTH.STRENGTH_VALID;
|
||||
}
|
||||
|
||||
return E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID;
|
||||
};
|
||||
|
||||
export type PasswordCriteria = {
|
||||
key: string;
|
||||
label: string;
|
||||
isValid: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get password criteria for validation display
|
||||
*/
|
||||
export const getPasswordCriteria = (password: string): PasswordCriteria[] => [
|
||||
{
|
||||
key: "length",
|
||||
label: "Min 8 characters",
|
||||
isValid: password.length >= 8,
|
||||
},
|
||||
{
|
||||
key: "uppercase",
|
||||
label: "Min 1 upper-case letter",
|
||||
isValid: /[A-Z]/.test(password),
|
||||
},
|
||||
{
|
||||
key: "lowercase",
|
||||
label: "Min 1 lower-case letter",
|
||||
isValid: /[a-z]/.test(password),
|
||||
},
|
||||
{
|
||||
key: "number",
|
||||
label: "Min 1 number",
|
||||
isValid: /[0-9]/.test(password),
|
||||
},
|
||||
{
|
||||
key: "special",
|
||||
label: "Min 1 special character",
|
||||
isValid: /[!@#$%^&*()\-_+=\[\]{}|;:'",.<>?/]/.test(password),
|
||||
},
|
||||
];
|
||||
|
||||
// Error code messages
|
||||
const errorCodeMessages: {
|
||||
[key in EAuthErrorCodes]: { title: string; message: (email?: string | undefined) => ReactNode };
|
||||
} = {
|
||||
// global
|
||||
[EAuthErrorCodes.INSTANCE_NOT_CONFIGURED]: {
|
||||
title: `Instance not configured`,
|
||||
message: () => `Instance not configured. Please contact your administrator.`,
|
||||
},
|
||||
[EAuthErrorCodes.SIGNUP_DISABLED]: {
|
||||
title: `Sign up disabled`,
|
||||
message: () => `Sign up disabled. Please contact your administrator.`,
|
||||
},
|
||||
[EAuthErrorCodes.INVALID_PASSWORD]: {
|
||||
title: `Invalid password`,
|
||||
message: () => `Invalid password. Please try again.`,
|
||||
},
|
||||
[EAuthErrorCodes.SMTP_NOT_CONFIGURED]: {
|
||||
title: `SMTP not configured`,
|
||||
message: () => `SMTP not configured. Please contact your administrator.`,
|
||||
},
|
||||
// email check in both sign up and sign in
|
||||
[EAuthErrorCodes.INVALID_EMAIL]: {
|
||||
title: `Invalid email`,
|
||||
message: () => `Invalid email. Please try again.`,
|
||||
},
|
||||
[EAuthErrorCodes.EMAIL_REQUIRED]: {
|
||||
title: `Email required`,
|
||||
message: () => `Email required. Please try again.`,
|
||||
},
|
||||
// sign up
|
||||
[EAuthErrorCodes.USER_ALREADY_EXIST]: {
|
||||
title: `User already exists`,
|
||||
message: () => `Your account is already registered. Sign in now.`,
|
||||
},
|
||||
[EAuthErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_UP]: {
|
||||
title: `Email and password required`,
|
||||
message: () => `Email and password required. Please try again.`,
|
||||
},
|
||||
[EAuthErrorCodes.AUTHENTICATION_FAILED_SIGN_UP]: {
|
||||
title: `Authentication failed`,
|
||||
message: () => `Authentication failed. Please try again.`,
|
||||
},
|
||||
[EAuthErrorCodes.INVALID_EMAIL_SIGN_UP]: {
|
||||
title: `Invalid email`,
|
||||
message: () => `Invalid email. Please try again.`,
|
||||
},
|
||||
[EAuthErrorCodes.MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED]: {
|
||||
title: `Email and code required`,
|
||||
message: () => `Email and code required. Please try again.`,
|
||||
},
|
||||
[EAuthErrorCodes.INVALID_EMAIL_MAGIC_SIGN_UP]: {
|
||||
title: `Invalid email`,
|
||||
message: () => `Invalid email. Please try again.`,
|
||||
},
|
||||
// sign in
|
||||
[EAuthErrorCodes.USER_ACCOUNT_DEACTIVATED]: {
|
||||
title: `User account deactivated`,
|
||||
message: () => `User account deactivated. Please contact administrator.`,
|
||||
},
|
||||
[EAuthErrorCodes.USER_DOES_NOT_EXIST]: {
|
||||
title: `User does not exist`,
|
||||
message: () => `No account found. Create one to get started.`,
|
||||
},
|
||||
[EAuthErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_IN]: {
|
||||
title: `Email and password required`,
|
||||
message: () => `Email and password required. Please try again.`,
|
||||
},
|
||||
[EAuthErrorCodes.AUTHENTICATION_FAILED_SIGN_IN]: {
|
||||
title: `Authentication failed`,
|
||||
message: () => `Authentication failed. Please try again.`,
|
||||
},
|
||||
[EAuthErrorCodes.INVALID_EMAIL_SIGN_IN]: {
|
||||
title: `Invalid email`,
|
||||
message: () => `Invalid email. Please try again.`,
|
||||
},
|
||||
[EAuthErrorCodes.MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED]: {
|
||||
title: `Email and code required`,
|
||||
message: () => `Email and code required. Please try again.`,
|
||||
},
|
||||
[EAuthErrorCodes.INVALID_EMAIL_MAGIC_SIGN_IN]: {
|
||||
title: `Invalid email`,
|
||||
message: () => `Invalid email. Please try again.`,
|
||||
},
|
||||
// Both Sign in and Sign up
|
||||
[EAuthErrorCodes.INVALID_MAGIC_CODE_SIGN_IN]: {
|
||||
title: `Authentication failed`,
|
||||
message: () => `Invalid magic code. Please try again.`,
|
||||
},
|
||||
[EAuthErrorCodes.INVALID_MAGIC_CODE_SIGN_UP]: {
|
||||
title: `Authentication failed`,
|
||||
message: () => `Invalid magic code. Please try again.`,
|
||||
},
|
||||
[EAuthErrorCodes.EXPIRED_MAGIC_CODE_SIGN_IN]: {
|
||||
title: `Expired magic code`,
|
||||
message: () => `Expired magic code. Please try again.`,
|
||||
},
|
||||
[EAuthErrorCodes.EXPIRED_MAGIC_CODE_SIGN_UP]: {
|
||||
title: `Expired magic code`,
|
||||
message: () => `Expired magic code. Please try again.`,
|
||||
},
|
||||
[EAuthErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN]: {
|
||||
title: `Expired magic code`,
|
||||
message: () => `Expired magic code. Please try again.`,
|
||||
},
|
||||
[EAuthErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP]: {
|
||||
title: `Expired magic code`,
|
||||
message: () => `Expired magic code. Please try again.`,
|
||||
},
|
||||
// Oauth
|
||||
[EAuthErrorCodes.OAUTH_NOT_CONFIGURED]: {
|
||||
title: `OAuth not configured`,
|
||||
message: () => `OAuth not configured. Please contact your administrator.`,
|
||||
},
|
||||
[EAuthErrorCodes.GOOGLE_NOT_CONFIGURED]: {
|
||||
title: `Google not configured`,
|
||||
message: () => `Google not configured. Please contact your administrator.`,
|
||||
},
|
||||
[EAuthErrorCodes.GITHUB_NOT_CONFIGURED]: {
|
||||
title: `GitHub not configured`,
|
||||
message: () => `GitHub not configured. Please contact your administrator.`,
|
||||
},
|
||||
[EAuthErrorCodes.GITLAB_NOT_CONFIGURED]: {
|
||||
title: `GitLab not configured`,
|
||||
message: () => `GitLab not configured. Please contact your administrator.`,
|
||||
},
|
||||
[EAuthErrorCodes.GOOGLE_OAUTH_PROVIDER_ERROR]: {
|
||||
title: `Google OAuth provider error`,
|
||||
message: () => `Google OAuth provider error. Please try again.`,
|
||||
},
|
||||
[EAuthErrorCodes.GITHUB_OAUTH_PROVIDER_ERROR]: {
|
||||
title: `GitHub OAuth provider error`,
|
||||
message: () => `GitHub OAuth provider error. Please try again.`,
|
||||
},
|
||||
[EAuthErrorCodes.GITLAB_OAUTH_PROVIDER_ERROR]: {
|
||||
title: `GitLab OAuth provider error`,
|
||||
message: () => `GitLab OAuth provider error. Please try again.`,
|
||||
},
|
||||
// Reset Password
|
||||
[EAuthErrorCodes.INVALID_PASSWORD_TOKEN]: {
|
||||
title: `Invalid password token`,
|
||||
message: () => `Invalid password token. Please try again.`,
|
||||
},
|
||||
[EAuthErrorCodes.EXPIRED_PASSWORD_TOKEN]: {
|
||||
title: `Expired password token`,
|
||||
message: () => `Expired password token. Please try again.`,
|
||||
},
|
||||
// Change password
|
||||
[EAuthErrorCodes.MISSING_PASSWORD]: {
|
||||
title: `Password required`,
|
||||
message: () => `Password required. Please try again.`,
|
||||
},
|
||||
[EAuthErrorCodes.INCORRECT_OLD_PASSWORD]: {
|
||||
title: `Incorrect old password`,
|
||||
message: () => `Incorrect old password. Please try again.`,
|
||||
},
|
||||
[EAuthErrorCodes.INVALID_NEW_PASSWORD]: {
|
||||
title: `Invalid new password`,
|
||||
message: () => `Invalid new password. Please try again.`,
|
||||
},
|
||||
// set password
|
||||
[EAuthErrorCodes.PASSWORD_ALREADY_SET]: {
|
||||
title: `Password already set`,
|
||||
message: () => `Password already set. Please try again.`,
|
||||
},
|
||||
// admin
|
||||
[EAuthErrorCodes.ADMIN_ALREADY_EXIST]: {
|
||||
title: `Admin already exists`,
|
||||
message: () => `Admin already exists. Please try again.`,
|
||||
},
|
||||
[EAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME]: {
|
||||
title: `Email, password and first name required`,
|
||||
message: () => `Email, password and first name required. Please try again.`,
|
||||
},
|
||||
[EAuthErrorCodes.INVALID_ADMIN_EMAIL]: {
|
||||
title: `Invalid admin email`,
|
||||
message: () => `Invalid admin email. Please try again.`,
|
||||
},
|
||||
[EAuthErrorCodes.INVALID_ADMIN_PASSWORD]: {
|
||||
title: `Invalid admin password`,
|
||||
message: () => `Invalid admin password. Please try again.`,
|
||||
},
|
||||
[EAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD]: {
|
||||
title: `Email and password required`,
|
||||
message: () => `Email and password required. Please try again.`,
|
||||
},
|
||||
[EAuthErrorCodes.ADMIN_AUTHENTICATION_FAILED]: {
|
||||
title: `Authentication failed`,
|
||||
message: () => `Authentication failed. Please try again.`,
|
||||
},
|
||||
[EAuthErrorCodes.ADMIN_USER_ALREADY_EXIST]: {
|
||||
title: `Admin user already exists`,
|
||||
message: () => `Admin user already exists. Sign in now.`,
|
||||
},
|
||||
[EAuthErrorCodes.ADMIN_USER_DOES_NOT_EXIST]: {
|
||||
title: `Admin user does not exist`,
|
||||
message: () => `Admin user does not exist. Sign in now.`,
|
||||
},
|
||||
[EAuthErrorCodes.MAGIC_LINK_LOGIN_DISABLED]: {
|
||||
title: `Magic link login disabled`,
|
||||
message: () => `Magic link login is disabled. Please use password to login.`,
|
||||
},
|
||||
[EAuthErrorCodes.PASSWORD_LOGIN_DISABLED]: {
|
||||
title: `Password login disabled`,
|
||||
message: () => `Password login is disabled. Please use magic link to login.`,
|
||||
},
|
||||
[EAuthErrorCodes.ADMIN_USER_DEACTIVATED]: {
|
||||
title: `Admin user deactivated`,
|
||||
message: () => `Admin user account has been deactivated. Please contact administrator.`,
|
||||
},
|
||||
[EAuthErrorCodes.RATE_LIMIT_EXCEEDED]: {
|
||||
title: `Rate limit exceeded`,
|
||||
message: () => `Too many requests. Please try again later.`,
|
||||
},
|
||||
};
|
||||
|
||||
// Error handler
|
||||
export const authErrorHandler = (
|
||||
errorCode: EAuthErrorCodes,
|
||||
email?: string | undefined
|
||||
): TAuthErrorInfo | undefined => {
|
||||
const bannerAlertErrorCodes = [
|
||||
EAuthErrorCodes.INSTANCE_NOT_CONFIGURED,
|
||||
EAuthErrorCodes.INVALID_EMAIL,
|
||||
EAuthErrorCodes.EMAIL_REQUIRED,
|
||||
EAuthErrorCodes.SIGNUP_DISABLED,
|
||||
EAuthErrorCodes.INVALID_PASSWORD,
|
||||
EAuthErrorCodes.SMTP_NOT_CONFIGURED,
|
||||
EAuthErrorCodes.USER_ALREADY_EXIST,
|
||||
EAuthErrorCodes.AUTHENTICATION_FAILED_SIGN_UP,
|
||||
EAuthErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_UP,
|
||||
EAuthErrorCodes.INVALID_EMAIL_SIGN_UP,
|
||||
EAuthErrorCodes.INVALID_EMAIL_MAGIC_SIGN_UP,
|
||||
EAuthErrorCodes.MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED,
|
||||
EAuthErrorCodes.USER_DOES_NOT_EXIST,
|
||||
EAuthErrorCodes.AUTHENTICATION_FAILED_SIGN_IN,
|
||||
EAuthErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_IN,
|
||||
EAuthErrorCodes.INVALID_EMAIL_SIGN_IN,
|
||||
EAuthErrorCodes.INVALID_EMAIL_MAGIC_SIGN_IN,
|
||||
EAuthErrorCodes.MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED,
|
||||
EAuthErrorCodes.INVALID_MAGIC_CODE_SIGN_IN,
|
||||
EAuthErrorCodes.INVALID_MAGIC_CODE_SIGN_UP,
|
||||
EAuthErrorCodes.EXPIRED_MAGIC_CODE_SIGN_IN,
|
||||
EAuthErrorCodes.EXPIRED_MAGIC_CODE_SIGN_UP,
|
||||
EAuthErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN,
|
||||
EAuthErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP,
|
||||
EAuthErrorCodes.OAUTH_NOT_CONFIGURED,
|
||||
EAuthErrorCodes.GOOGLE_NOT_CONFIGURED,
|
||||
EAuthErrorCodes.GITHUB_NOT_CONFIGURED,
|
||||
EAuthErrorCodes.GITLAB_NOT_CONFIGURED,
|
||||
EAuthErrorCodes.GOOGLE_OAUTH_PROVIDER_ERROR,
|
||||
EAuthErrorCodes.GITHUB_OAUTH_PROVIDER_ERROR,
|
||||
EAuthErrorCodes.GITLAB_OAUTH_PROVIDER_ERROR,
|
||||
EAuthErrorCodes.INVALID_PASSWORD_TOKEN,
|
||||
EAuthErrorCodes.EXPIRED_PASSWORD_TOKEN,
|
||||
EAuthErrorCodes.INCORRECT_OLD_PASSWORD,
|
||||
EAuthErrorCodes.INVALID_NEW_PASSWORD,
|
||||
EAuthErrorCodes.PASSWORD_ALREADY_SET,
|
||||
EAuthErrorCodes.ADMIN_ALREADY_EXIST,
|
||||
EAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME,
|
||||
EAuthErrorCodes.INVALID_ADMIN_EMAIL,
|
||||
EAuthErrorCodes.INVALID_ADMIN_PASSWORD,
|
||||
EAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD,
|
||||
EAuthErrorCodes.ADMIN_AUTHENTICATION_FAILED,
|
||||
EAuthErrorCodes.ADMIN_USER_ALREADY_EXIST,
|
||||
EAuthErrorCodes.ADMIN_USER_DOES_NOT_EXIST,
|
||||
EAuthErrorCodes.USER_ACCOUNT_DEACTIVATED,
|
||||
];
|
||||
|
||||
if (bannerAlertErrorCodes.includes(errorCode))
|
||||
return {
|
||||
type: EErrorAlertType.BANNER_ALERT,
|
||||
code: errorCode,
|
||||
title: errorCodeMessages[errorCode]?.title || "Error",
|
||||
message: errorCodeMessages[errorCode]?.message(email) || "Something went wrong. Please try again.",
|
||||
};
|
||||
|
||||
return undefined;
|
||||
};
|
||||
86
packages/utils/src/calendar.ts
Normal file
86
packages/utils/src/calendar.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
// plane imports
|
||||
import { EStartOfTheWeek, ICalendarDate, ICalendarPayload } from "@plane/types";
|
||||
// local imports
|
||||
import { getWeekNumberOfDate, renderFormattedPayloadDate } from "./datetime";
|
||||
|
||||
/**
|
||||
* @returns {ICalendarPayload} calendar payload to render the calendar
|
||||
* @param {ICalendarPayload | null} currentStructure current calendar payload
|
||||
* @param {Date} startDate date of the month to render
|
||||
* @param {EStartOfTheWeek} startOfWeek the day to start the week on
|
||||
* @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,
|
||||
startOfWeek: EStartOfTheWeek = EStartOfTheWeek.SUNDAY
|
||||
): 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 firstDayOfMonthRaw = new Date(year, month, 1).getDay(); // Sunday is 0, Monday is 1, ..., Saturday is 6
|
||||
|
||||
// Adjust firstDayOfMonth based on startOfWeek preference
|
||||
// This calculates how many empty cells we need at the start of the calendar
|
||||
const firstDayOfMonth = (firstDayOfMonthRaw - startOfWeek + 7) % 7;
|
||||
|
||||
calendarData[`y-${year}`] ||= {};
|
||||
// Always reset the month data to ensure clean regeneration with correct startOfWeek
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
// Use sequential week index instead of calculated week number for the key
|
||||
// This ensures weeks are grouped correctly regardless of startOfWeek preference
|
||||
calendarData[`y-${year}`][`m-${month}`][`w-${week}`] = currentWeekObject;
|
||||
}
|
||||
|
||||
return calendarData;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a new array sorted by the startOfWeek.
|
||||
* @param items Array of items to sort.
|
||||
* @param getDayIndex Function to get the day index (0-6) from an item.
|
||||
* @param startOfWeek The day to start the week on.
|
||||
*/
|
||||
export const getOrderedDays = <T>(
|
||||
items: T[],
|
||||
getDayIndex: (item: T) => number,
|
||||
startOfWeek: EStartOfTheWeek = EStartOfTheWeek.SUNDAY
|
||||
): T[] =>
|
||||
[...items].sort((a, b) => {
|
||||
const dayA = (7 + getDayIndex(a) - startOfWeek) % 7;
|
||||
const dayB = (7 + getDayIndex(b) - startOfWeek) % 7;
|
||||
return dayA - dayB;
|
||||
});
|
||||
299
packages/utils/src/color.ts
Normal file
299
packages/utils/src/color.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Represents an RGB color with numeric values for red, green, and blue components
|
||||
* @typedef {Object} TRgb
|
||||
* @property {number} r - Red component (0-255)
|
||||
* @property {number} g - Green component (0-255)
|
||||
* @property {number} b - Blue component (0-255)
|
||||
*/
|
||||
export type TRgb = { r: number; g: number; b: number };
|
||||
|
||||
export type THsl = { h: number; s: number; l: number };
|
||||
|
||||
/**
|
||||
* @description Validates and clamps color values to RGB range (0-255)
|
||||
* @param {number} value - The color value to validate
|
||||
* @returns {number} Clamped and floored value between 0-255
|
||||
* @example
|
||||
* validateColor(-10) // returns 0
|
||||
* validateColor(300) // returns 255
|
||||
* validateColor(128) // returns 128
|
||||
*/
|
||||
export const validateColor = (value: number) => {
|
||||
if (value < 0) return 0;
|
||||
if (value > 255) return 255;
|
||||
return Math.floor(value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a decimal color value to two-character hex
|
||||
* @param {number} value - Decimal color value (0-255)
|
||||
* @returns {string} Two-character hex value with leading zero if needed
|
||||
*/
|
||||
export const toHex = (value: number) => validateColor(value).toString(16).padStart(2, "0");
|
||||
|
||||
/**
|
||||
* Converts a hexadecimal color code to RGB values
|
||||
* @param {string} hex - The hexadecimal color code (e.g., "#ff0000" for red)
|
||||
* @returns {RGB} An object containing the RGB values
|
||||
* @example
|
||||
* hexToRgb("#ff0000") // returns { r: 255, g: 0, b: 0 }
|
||||
* hexToRgb("#00ff00") // returns { r: 0, g: 255, b: 0 }
|
||||
* hexToRgb("#0000ff") // returns { r: 0, g: 0, b: 255 }
|
||||
*/
|
||||
export const hexToRgb = (hex: string): TRgb => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex.trim());
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
}
|
||||
: { r: 0, g: 0, b: 0 };
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts RGB values to a hexadecimal color code
|
||||
* @param {RGB} rgb - An object containing RGB values
|
||||
* @param {number} rgb.r - Red component (0-255)
|
||||
* @param {number} rgb.g - Green component (0-255)
|
||||
* @param {number} rgb.b - Blue component (0-255)
|
||||
* @returns {string} The hexadecimal color code (e.g., "#ff0000" for red)
|
||||
* @example
|
||||
* rgbToHex({ r: 255, g: 0, b: 0 }) // returns "#ff0000"
|
||||
* rgbToHex({ r: 0, g: 255, b: 0 }) // returns "#00ff00"
|
||||
* rgbToHex({ r: 0, g: 0, b: 255 }) // returns "#0000ff"
|
||||
*/
|
||||
export const rgbToHex = ({ r, g, b }: TRgb): string => `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
|
||||
/**
|
||||
* Converts Hex values to HSL values
|
||||
* @param {string} hex - The hexadecimal color code (e.g., "#ff0000" for red)
|
||||
* @returns {HSL} An object containing the HSL values
|
||||
* @example
|
||||
* hexToHsl("#ff0000") // returns { h: 0, s: 100, l: 50 }
|
||||
* hexToHsl("#00ff00") // returns { h: 120, s: 100, l: 50 }
|
||||
* hexToHsl("#0000ff") // returns { h: 240, s: 100, l: 50 }
|
||||
*/
|
||||
export const hexToHsl = (hex: string): THsl => {
|
||||
// return default value for invalid hex
|
||||
if (!/^#[0-9A-Fa-f]{6}$/.test(hex)) return { h: 0, s: 0, l: 0 };
|
||||
|
||||
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
const l = (max + min) / 2;
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r:
|
||||
h = (g - b) / d + (g < b ? 6 : 0);
|
||||
break;
|
||||
case g:
|
||||
h = (b - r) / d + 2;
|
||||
break;
|
||||
case b:
|
||||
h = (r - g) / d + 4;
|
||||
break;
|
||||
}
|
||||
h /= 6;
|
||||
}
|
||||
|
||||
return {
|
||||
h: h * 360,
|
||||
s: s * 100,
|
||||
l: l * 100,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts HSL values to a hexadecimal color code
|
||||
* @param {HSL} hsl - An object containing HSL values
|
||||
* @param {number} hsl.h - Hue component (0-360)
|
||||
* @param {number} hsl.s - Saturation component (0-100)
|
||||
* @param {number} hsl.l - Lightness component (0-100)
|
||||
* @returns {string} The hexadecimal color code (e.g., "#ff0000" for red)
|
||||
* @example
|
||||
* hslToHex({ h: 0, s: 100, l: 50 }) // returns "#ff0000"
|
||||
* hslToHex({ h: 120, s: 100, l: 50 }) // returns "#00ff00"
|
||||
* hslToHex({ h: 240, s: 100, l: 50 }) // returns "#0000ff"
|
||||
*/
|
||||
export const hslToHex = ({ h, s, l }: THsl): string => {
|
||||
if (h < 0 || h > 360) return "#000000";
|
||||
if (s < 0 || s > 100) return "#000000";
|
||||
if (l < 0 || l > 100) return "#000000";
|
||||
|
||||
l /= 100;
|
||||
const a = (s * Math.min(l, 1 - l)) / 100;
|
||||
|
||||
const f = (n: number) => {
|
||||
const k = (n + h / 30) % 12;
|
||||
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
||||
return Math.round(255 * color)
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
};
|
||||
|
||||
return `#${f(0)}${f(8)}${f(4)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate relative luminance of a color according to WCAG
|
||||
* @param {Object} rgb - RGB color object with r, g, b properties
|
||||
* @returns {number} Relative luminance value
|
||||
*/
|
||||
export const getLuminance = ({ r, g, b }: TRgb) => {
|
||||
// Convert RGB to sRGB
|
||||
const sR = r / 255;
|
||||
const sG = g / 255;
|
||||
const sB = b / 255;
|
||||
|
||||
// Convert sRGB to linear RGB with gamma correction
|
||||
const R = sR <= 0.03928 ? sR / 12.92 : Math.pow((sR + 0.055) / 1.055, 2.4);
|
||||
const G = sG <= 0.03928 ? sG / 12.92 : Math.pow((sG + 0.055) / 1.055, 2.4);
|
||||
const B = sB <= 0.03928 ? sB / 12.92 : Math.pow((sB + 0.055) / 1.055, 2.4);
|
||||
|
||||
// Calculate luminance
|
||||
return 0.2126 * R + 0.7152 * G + 0.0722 * B;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate contrast ratio between two colors
|
||||
* @param {Object} rgb1 - First RGB color object
|
||||
* @param {Object} rgb2 - Second RGB color object
|
||||
* @returns {number} Contrast ratio between the colors
|
||||
*/
|
||||
export function getContrastRatio(rgb1: { r: number; g: number; b: number }, rgb2: { r: number; g: number; b: number }) {
|
||||
const luminance1 = getLuminance(rgb1);
|
||||
const luminance2 = getLuminance(rgb2);
|
||||
|
||||
const lighter = Math.max(luminance1, luminance2);
|
||||
const darker = Math.min(luminance1, luminance2);
|
||||
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lighten a color by a specified amount
|
||||
* @param {Object} rgb - RGB color object
|
||||
* @param {number} amount - Amount to lighten (0-1)
|
||||
* @returns {Object} Lightened RGB color
|
||||
*/
|
||||
export function lightenColor(rgb: { r: number; g: number; b: number }, amount: number) {
|
||||
return {
|
||||
r: rgb.r + (255 - rgb.r) * amount,
|
||||
g: rgb.g + (255 - rgb.g) * amount,
|
||||
b: rgb.b + (255 - rgb.b) * amount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Darken a color by a specified amount
|
||||
* @param {Object} rgb - RGB color object
|
||||
* @param {number} amount - Amount to darken (0-1)
|
||||
* @returns {Object} Darkened RGB color
|
||||
*/
|
||||
export function darkenColor(rgb: { r: number; g: number; b: number }, amount: number) {
|
||||
return {
|
||||
r: rgb.r * (1 - amount),
|
||||
g: rgb.g * (1 - amount),
|
||||
b: rgb.b * (1 - amount),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate appropriate foreground and background colors based on input color
|
||||
* @param {string} color - Input color in hex format
|
||||
* @returns {Object} Object containing foreground and background colors in hex format
|
||||
*/
|
||||
export function generateIconColors(color: string) {
|
||||
// Parse input color
|
||||
const rgbColor = hexToRgb(color);
|
||||
const luminance = getLuminance(rgbColor);
|
||||
|
||||
// Initialize output colors
|
||||
let foregroundColor = rgbColor;
|
||||
|
||||
// Constants for color adjustment
|
||||
const MIN_CONTRAST_RATIO = 3.0; // Minimum acceptable contrast ratio
|
||||
|
||||
// For light colors, use as foreground and darken for background
|
||||
if (luminance > 0.5) {
|
||||
// Make sure the foreground color is dark enough for visibility
|
||||
let adjustedForeground = foregroundColor;
|
||||
const whiteContrast = getContrastRatio(foregroundColor, { r: 255, g: 255, b: 255 });
|
||||
|
||||
if (whiteContrast < MIN_CONTRAST_RATIO) {
|
||||
// Darken the foreground color until it has enough contrast
|
||||
let darkenAmount = 0.1;
|
||||
while (darkenAmount <= 0.9) {
|
||||
adjustedForeground = darkenColor(foregroundColor, darkenAmount);
|
||||
if (getContrastRatio(adjustedForeground, { r: 255, g: 255, b: 255 }) >= MIN_CONTRAST_RATIO) {
|
||||
break;
|
||||
}
|
||||
darkenAmount += 0.1;
|
||||
}
|
||||
foregroundColor = adjustedForeground;
|
||||
}
|
||||
}
|
||||
// For dark colors, use as foreground and lighten for background
|
||||
else {
|
||||
// Make sure the foreground color is light enough for visibility
|
||||
let adjustedForeground = foregroundColor;
|
||||
const blackContrast = getContrastRatio(foregroundColor, { r: 0, g: 0, b: 0 });
|
||||
|
||||
if (blackContrast < MIN_CONTRAST_RATIO) {
|
||||
// Lighten the foreground color until it has enough contrast
|
||||
let lightenAmount = 0.1;
|
||||
while (lightenAmount <= 0.9) {
|
||||
adjustedForeground = lightenColor(foregroundColor, lightenAmount);
|
||||
if (getContrastRatio(adjustedForeground, { r: 0, g: 0, b: 0 }) >= MIN_CONTRAST_RATIO) {
|
||||
break;
|
||||
}
|
||||
lightenAmount += 0.1;
|
||||
}
|
||||
foregroundColor = adjustedForeground;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
foreground: rgbToHex({ r: foregroundColor.r, g: foregroundColor.g, b: foregroundColor.b }),
|
||||
background: `rgba(${foregroundColor.r}, ${foregroundColor.g}, ${foregroundColor.b}, 0.25)`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Generates a deterministic HSL color based on input string
|
||||
* @param {string} input - Input string to generate color from
|
||||
* @returns {THsl} An object containing the HSL values
|
||||
* @example
|
||||
* generateRandomColor("hello") // returns consistent HSL color for "hello"
|
||||
* generateRandomColor("") // returns { h: 0, s: 0, l: 0 }
|
||||
*/
|
||||
export const generateRandomColor = (input: string): THsl => {
|
||||
// If input is falsy, generate a random seed string.
|
||||
// The random seed is created by converting a random number to base-36 and taking a substring.
|
||||
const seed = input || Math.random().toString(36).substring(2, 8);
|
||||
|
||||
const uniqueId = seed.length.toString() + seed; // Unique identifier based on string length
|
||||
const combinedString = uniqueId + seed;
|
||||
|
||||
// Create a hash value from the combined string.
|
||||
const hash = Array.from(combinedString).reduce((acc, char) => {
|
||||
const charCode = char.charCodeAt(0);
|
||||
return (acc << 5) - acc + charCode;
|
||||
}, 0);
|
||||
|
||||
// Derive the HSL values from the hash.
|
||||
const hue = Math.abs(hash % 360);
|
||||
const saturation = 70; // Maintains a good amount of color
|
||||
const lightness = 70; // Increased lightness for a pastel look
|
||||
|
||||
return { h: hue, s: saturation, l: lightness };
|
||||
};
|
||||
62
packages/utils/src/common.ts
Normal file
62
packages/utils/src/common.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { CompleteOrEmpty } from "@plane/types";
|
||||
|
||||
// Support email can be configured by the application
|
||||
export const getSupportEmail = (defaultEmail: string = ""): string => defaultEmail;
|
||||
|
||||
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
|
||||
|
||||
/**
|
||||
* Extracts IDs from an array of objects with ID property
|
||||
*/
|
||||
export const extractIds = <T extends { id: string }>(items: T[]): string[] => items.map((item) => item.id);
|
||||
|
||||
/**
|
||||
* Checks if an ID exists and is valid within the provided list
|
||||
*/
|
||||
export const isValidId = (id: string | null | undefined, validIds: string[]): boolean => !!id && validIds.includes(id);
|
||||
|
||||
/**
|
||||
* Filters an array to only include valid IDs
|
||||
*/
|
||||
export const filterValidIds = (ids: string[], validIds: string[]): string[] =>
|
||||
ids.filter((id) => validIds.includes(id));
|
||||
|
||||
/**
|
||||
* Filters an array to include only valid IDs, returning both valid and invalid IDs
|
||||
*/
|
||||
export const partitionValidIds = (ids: string[], validIds: string[]): { valid: string[]; invalid: string[] } => {
|
||||
const valid: string[] = [];
|
||||
const invalid: string[] = [];
|
||||
|
||||
ids.forEach((id) => {
|
||||
if (validIds.includes(id)) {
|
||||
valid.push(id);
|
||||
} else {
|
||||
invalid.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
return { valid, invalid };
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if an object is complete (has properties) rather than empty.
|
||||
* This helps TypeScript narrow the type from CompleteOrEmpty<T> to T.
|
||||
*
|
||||
* @param obj The object to check, typed as CompleteOrEmpty<T>
|
||||
* @returns A boolean indicating if the object is complete (true) or empty (false)
|
||||
*/
|
||||
export const isComplete = <T>(obj: CompleteOrEmpty<T>): obj is T => {
|
||||
// Check if object is not null or undefined
|
||||
if (obj == null) return false;
|
||||
|
||||
// Check if it's an object
|
||||
if (typeof obj !== "object") return false;
|
||||
|
||||
// Check if it has any own properties
|
||||
return Object.keys(obj).length > 0;
|
||||
};
|
||||
|
||||
export const convertRemToPixel = (rem: number): number => rem * 0.9 * 16;
|
||||
244
packages/utils/src/cycle.ts
Normal file
244
packages/utils/src/cycle.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { startOfToday, format } from "date-fns";
|
||||
import { isEmpty, orderBy, sortBy, uniqBy } from "lodash-es";
|
||||
// plane imports
|
||||
import { ICycle, TCycleFilters, TProgressSnapshot } from "@plane/types";
|
||||
// local imports
|
||||
import { findTotalDaysInRange, generateDateArray, getDate } from "./datetime";
|
||||
import { satisfiesDateFilter } from "./filter";
|
||||
|
||||
/**
|
||||
* Orders cycles based on their status
|
||||
* @param {ICycle[]} cycles - Array of cycles to be ordered
|
||||
* @param {boolean} sortByManual - Whether to sort by manual order
|
||||
* @returns {ICycle[]} Ordered array of cycles
|
||||
*/
|
||||
export const orderCycles = (cycles: ICycle[], sortByManual: boolean): ICycle[] => {
|
||||
if (cycles.length === 0) return [];
|
||||
|
||||
const acceptedStatuses = ["current", "upcoming", "draft"];
|
||||
const STATUS_ORDER: {
|
||||
[key: string]: number;
|
||||
} = {
|
||||
current: 1,
|
||||
upcoming: 2,
|
||||
draft: 3,
|
||||
};
|
||||
|
||||
let filteredCycles = cycles.filter((c) => acceptedStatuses.includes(c.status?.toLowerCase() ?? ""));
|
||||
if (sortByManual) filteredCycles = sortBy(filteredCycles, [(c) => c.sort_order]);
|
||||
else
|
||||
filteredCycles = sortBy(filteredCycles, [
|
||||
(c) => STATUS_ORDER[c.status?.toLowerCase() ?? ""],
|
||||
(c) => (c.status?.toLowerCase() === "upcoming" ? c.start_date : c.name.toLowerCase()),
|
||||
]);
|
||||
|
||||
return filteredCycles;
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters cycles based on provided filter criteria
|
||||
* @param {ICycle} cycle - The cycle to be filtered
|
||||
* @param {TCycleFilters} filter - Filter criteria to apply
|
||||
* @returns {boolean} Whether the cycle passes the filter
|
||||
*/
|
||||
export const shouldFilterCycle = (cycle: ICycle, filter: TCycleFilters): boolean => {
|
||||
let fallsInFilters = true;
|
||||
Object.keys(filter).forEach((key) => {
|
||||
const filterKey = key as keyof TCycleFilters;
|
||||
if (filterKey === "status" && filter.status && filter.status.length > 0)
|
||||
fallsInFilters = fallsInFilters && filter.status.includes(cycle.status?.toLowerCase() ?? "");
|
||||
if (filterKey === "start_date" && filter.start_date && filter.start_date.length > 0) {
|
||||
const startDate = getDate(cycle.start_date);
|
||||
filter.start_date.forEach((dateFilter) => {
|
||||
fallsInFilters = fallsInFilters && !!startDate && satisfiesDateFilter(startDate, dateFilter);
|
||||
});
|
||||
}
|
||||
if (filterKey === "end_date" && filter.end_date && filter.end_date.length > 0) {
|
||||
const endDate = getDate(cycle.end_date);
|
||||
filter.end_date.forEach((dateFilter) => {
|
||||
fallsInFilters = fallsInFilters && !!endDate && satisfiesDateFilter(endDate, dateFilter);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return fallsInFilters;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the scope based on whether it's an issue or estimate points
|
||||
* @param {any} p - Progress data
|
||||
* @param {boolean} isTypeIssue - Whether the type is an issue
|
||||
* @returns {number} Calculated scope
|
||||
*/
|
||||
const scope = (p: any, isTypeIssue: boolean) => (isTypeIssue ? p.total_issues : p.total_estimate_points);
|
||||
|
||||
/**
|
||||
* Calculates the ideal progress value
|
||||
* @param {string} date - Current date
|
||||
* @param {number} scope - Total scope
|
||||
* @param {ICycle} cycle - Cycle data
|
||||
* @returns {number} Ideal progress value
|
||||
*/
|
||||
const ideal = (date: string, scope: number, cycle: ICycle) =>
|
||||
Math.floor(
|
||||
((findTotalDaysInRange(date, cycle.end_date) || 0) /
|
||||
(findTotalDaysInRange(cycle.start_date, cycle.end_date) || 0)) *
|
||||
scope
|
||||
);
|
||||
|
||||
/**
|
||||
* Formats cycle data for version 1
|
||||
* @param {boolean} isTypeIssue - Whether the type is an issue
|
||||
* @param {ICycle} cycle - Cycle data
|
||||
* @param {boolean} isBurnDown - Whether it's a burn down chart
|
||||
* @param {Date|string} endDate - End date
|
||||
* @returns {TProgressChartData} Formatted progress data
|
||||
*/
|
||||
const formatV1Data = (isTypeIssue: boolean, cycle: ICycle, isBurnDown: boolean, endDate: Date | string) => {
|
||||
const today = format(startOfToday(), "yyyy-MM-dd");
|
||||
const data = isTypeIssue ? cycle.distribution : cycle.estimate_distribution;
|
||||
const extendedArray = generateDateArray(endDate, endDate).map((d) => d.date);
|
||||
|
||||
if (isEmpty(data)) return [];
|
||||
const progress = [...Object.keys(data.completion_chart), ...extendedArray].map((p) => {
|
||||
const pending = data.completion_chart[p] || 0;
|
||||
const total = isTypeIssue ? cycle.total_issues : cycle.total_estimate_points;
|
||||
const completed = scope(cycle, isTypeIssue) - pending;
|
||||
|
||||
return {
|
||||
date: p,
|
||||
scope: p! < today ? scope(cycle, isTypeIssue) : null,
|
||||
completed,
|
||||
backlog: isTypeIssue ? cycle.backlog_issues : cycle.backlog_estimate_points,
|
||||
started: p === today ? cycle[isTypeIssue ? "started_issues" : "started_estimate_points"] : undefined,
|
||||
unstarted: p === today ? cycle[isTypeIssue ? "unstarted_issues" : "unstarted_estimate_points"] : undefined,
|
||||
cancelled: p === today ? cycle[isTypeIssue ? "cancelled_issues" : "cancelled_estimate_points"] : undefined,
|
||||
pending: Math.abs(pending || 0),
|
||||
ideal:
|
||||
p < today
|
||||
? ideal(p, total || 0, cycle)
|
||||
: p <= cycle.end_date!
|
||||
? ideal(today as string, total || 0, cycle)
|
||||
: null,
|
||||
actual: p <= today ? (isBurnDown ? Math.abs(pending) : completed) : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
return progress;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats cycle data for version 2
|
||||
* @param {boolean} isTypeIssue - Whether the type is an issue
|
||||
* @param {ICycle} cycle - Cycle data
|
||||
* @param {boolean} isBurnDown - Whether it's a burn down chart
|
||||
* @param {Date|string} endDate - End date
|
||||
* @returns {TProgressChartData} Formatted progress data
|
||||
*/
|
||||
const formatV2Data = (isTypeIssue: boolean, cycle: ICycle, isBurnDown: boolean, endDate: Date | string) => {
|
||||
if (!cycle.progress) return [];
|
||||
let today: Date | string = startOfToday();
|
||||
|
||||
const extendedArray = endDate > today ? generateDateArray(today as Date, endDate) : [];
|
||||
if (isEmpty(cycle.progress)) return extendedArray;
|
||||
today = format(startOfToday(), "yyyy-MM-dd");
|
||||
const todaysData = cycle?.progress[cycle?.progress.length - 1];
|
||||
const scopeToday = scope(todaysData, isTypeIssue);
|
||||
const idealToday = ideal(todaysData.date, scopeToday, cycle);
|
||||
|
||||
let progress = [...orderBy(cycle?.progress, "date"), ...extendedArray].map((p) => {
|
||||
const pending = isTypeIssue
|
||||
? p.total_issues - p.completed_issues - p.cancelled_issues
|
||||
: p.total_estimate_points - p.completed_estimate_points - p.cancelled_estimate_points;
|
||||
const completed = isTypeIssue ? p.completed_issues : p.completed_estimate_points;
|
||||
const dataDate = p.progress_date ? format(new Date(p.progress_date), "yyyy-MM-dd") : p.date;
|
||||
|
||||
return {
|
||||
date: dataDate,
|
||||
scope: dataDate! < today ? scope(p, isTypeIssue) : dataDate! <= cycle.end_date! ? scopeToday : null,
|
||||
completed,
|
||||
backlog: isTypeIssue ? p.backlog_issues : p.backlog_estimate_points,
|
||||
started: isTypeIssue ? p.started_issues : p.started_estimate_points,
|
||||
unstarted: isTypeIssue ? p.unstarted_issues : p.unstarted_estimate_points,
|
||||
cancelled: isTypeIssue ? p.cancelled_issues : p.cancelled_estimate_points,
|
||||
pending: Math.abs(pending),
|
||||
ideal:
|
||||
dataDate! < today
|
||||
? ideal(dataDate, scope(p, isTypeIssue), cycle)
|
||||
: dataDate! < cycle.end_date!
|
||||
? idealToday
|
||||
: null,
|
||||
actual: dataDate! <= today ? (isBurnDown ? Math.abs(pending) : completed) : undefined,
|
||||
};
|
||||
});
|
||||
progress = uniqBy(progress, "date");
|
||||
|
||||
return progress;
|
||||
};
|
||||
|
||||
export const formatActiveCycle = (args: {
|
||||
cycle: ICycle;
|
||||
isBurnDown?: boolean | undefined;
|
||||
isTypeIssue?: boolean | undefined;
|
||||
}) => {
|
||||
const { cycle, isBurnDown, isTypeIssue } = args;
|
||||
const endDate: Date | string = new Date(cycle.end_date!);
|
||||
|
||||
return cycle.version === 1
|
||||
? formatV1Data(isTypeIssue!, cycle, isBurnDown!, endDate)
|
||||
: formatV2Data(isTypeIssue!, cycle, isBurnDown!, endDate);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates cycle progress percentage excluding cancelled issues from total count
|
||||
* Formula: completed / (total - cancelled) * 100
|
||||
* This gives accurate progress based on: pendingIssues = totalIssues - completedIssues - cancelledIssues
|
||||
* @param cycle - Cycle data object
|
||||
* @param estimateType - Whether to calculate based on "issues" or "points"
|
||||
* @param includeInProgress - Whether to include started/in-progress items in completion calculation
|
||||
* @returns Progress percentage (0-100)
|
||||
*/
|
||||
export const calculateCycleProgress = (
|
||||
cycle: ICycle | undefined,
|
||||
estimateType: "issues" | "points" = "issues",
|
||||
includeInProgress: boolean = false
|
||||
): number => {
|
||||
if (!cycle) return 0;
|
||||
|
||||
const progressSnapshot: TProgressSnapshot | undefined = cycle.progress_snapshot;
|
||||
const cycleDetails = progressSnapshot && !isEmpty(progressSnapshot) ? progressSnapshot : cycle;
|
||||
|
||||
let completed: number;
|
||||
let cancelled: number;
|
||||
let total: number;
|
||||
|
||||
if (estimateType === "points") {
|
||||
completed = cycleDetails.completed_estimate_points || 0;
|
||||
cancelled = cycleDetails.cancelled_estimate_points || 0;
|
||||
total = cycleDetails.total_estimate_points || 0;
|
||||
|
||||
if (includeInProgress) {
|
||||
completed += cycleDetails.started_estimate_points || 0;
|
||||
}
|
||||
} else {
|
||||
completed = cycleDetails.completed_issues || 0;
|
||||
cancelled = cycleDetails.cancelled_issues || 0;
|
||||
total = cycleDetails.total_issues || 0;
|
||||
|
||||
if (includeInProgress) {
|
||||
completed += cycleDetails.started_issues || 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Exclude cancelled issues from total (pendingIssues = total - completed - cancelled)
|
||||
const adjustedTotal = total - cancelled;
|
||||
|
||||
// Handle edge cases
|
||||
if (adjustedTotal === 0) return 0;
|
||||
if (completed < 0 || adjustedTotal < 0) return 0;
|
||||
if (completed > adjustedTotal) return 100;
|
||||
|
||||
// Calculate percentage and round
|
||||
const percentage = (completed / adjustedTotal) * 100;
|
||||
return Math.round(percentage);
|
||||
};
|
||||
589
packages/utils/src/datetime.ts
Normal file
589
packages/utils/src/datetime.ts
Normal file
@@ -0,0 +1,589 @@
|
||||
import { differenceInDays, format, formatDistanceToNow, isAfter, isEqual, isValid, parseISO } from "date-fns";
|
||||
import { isNumber } from "lodash-es";
|
||||
|
||||
// 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() + 2);
|
||||
|
||||
// Create an empty array to store the dates
|
||||
const dateArray = [];
|
||||
|
||||
// Use a while loop to generate dates between the range
|
||||
while (start <= end) {
|
||||
// Push the current date (converted to ISO string for consistency)
|
||||
dateArray.push({
|
||||
date: new Date(start).toISOString().split("T")[0],
|
||||
});
|
||||
// Increment the date by 1 day (86400000 milliseconds)
|
||||
start.setDate(start.getDate() + 1);
|
||||
}
|
||||
|
||||
return dateArray;
|
||||
};
|
||||
|
||||
/**
|
||||
* Processes relative date strings like "1_weeks", "2_months" etc and returns a Date
|
||||
* @param value The relative date string (e.g., "1_weeks", "2_months")
|
||||
* @returns Date object representing the calculated date
|
||||
*/
|
||||
export const processRelativeDate = (value: string): Date => {
|
||||
const [amountStr, unit] = value.split("_");
|
||||
const amount = parseInt(amountStr, 10);
|
||||
if (isNaN(amount)) {
|
||||
throw new Error(`Invalid relative amount: ${amountStr}`);
|
||||
}
|
||||
const date = new Date();
|
||||
|
||||
switch (unit) {
|
||||
case "days":
|
||||
date.setDate(date.getDate() + amount);
|
||||
break;
|
||||
case "weeks":
|
||||
date.setDate(date.getDate() + amount * 7);
|
||||
break;
|
||||
case "months":
|
||||
date.setMonth(date.getMonth() + amount);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported time unit: ${unit}`);
|
||||
}
|
||||
|
||||
return date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses a date filter string and returns the comparison type and date
|
||||
* @param filterValue The date filter string (e.g., "1_weeks;after;fromnow" or "2024-12-01;after")
|
||||
* @returns Object containing the comparison type and target date
|
||||
*/
|
||||
export const parseDateFilter = (filterValue: string): { type: "after" | "before"; date: Date } => {
|
||||
const parts = filterValue.split(";");
|
||||
const dateStr = parts[0];
|
||||
const type = parts[1] as "after" | "before";
|
||||
|
||||
let date: Date;
|
||||
if (dateStr.includes("_")) {
|
||||
// Handle relative dates (e.g., "1_weeks;after;fromnow")
|
||||
date = processRelativeDate(dateStr);
|
||||
} else {
|
||||
// Handle absolute dates (e.g., "2024-12-01;after")
|
||||
date = new Date(dateStr);
|
||||
}
|
||||
|
||||
return { type, date };
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a date meets the filter criteria
|
||||
* @param dateToCheck The date to check
|
||||
* @param filterDate The filter date to compare against
|
||||
* @param type The type of comparison ('after' or 'before')
|
||||
* @returns boolean indicating if the date meets the criteria
|
||||
*/
|
||||
export const checkDateCriteria = (dateToCheck: Date | null, filterDate: Date, type: "after" | "before"): boolean => {
|
||||
if (!dateToCheck) return false;
|
||||
|
||||
const checkDate = new Date(dateToCheck);
|
||||
const normalizedCheck = new Date(checkDate.setHours(0, 0, 0, 0));
|
||||
const normalizedFilter = new Date(filterDate.getTime());
|
||||
normalizedFilter.setHours(0, 0, 0, 0);
|
||||
|
||||
return type === "after" ? normalizedCheck >= normalizedFilter : normalizedCheck <= normalizedFilter;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats merged date range display with smart formatting
|
||||
* - Single date: "Jan 24, 2025"
|
||||
* - Same year, same month: "Jan 24 - 28, 2025"
|
||||
* - Same year, different month: "Jan 24 - Feb 6, 2025"
|
||||
* - Different year: "Dec 28, 2024 - Jan 4, 2025"
|
||||
*/
|
||||
export const formatDateRange = (
|
||||
parsedStartDate: Date | null | undefined,
|
||||
parsedEndDate: Date | null | undefined
|
||||
): string => {
|
||||
// If no dates are provided
|
||||
if (!parsedStartDate && !parsedEndDate) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// If only start date is provided
|
||||
if (parsedStartDate && !parsedEndDate) {
|
||||
return format(parsedStartDate, "MMM dd, yyyy");
|
||||
}
|
||||
|
||||
// If only end date is provided
|
||||
if (!parsedStartDate && parsedEndDate) {
|
||||
return format(parsedEndDate, "MMM dd, yyyy");
|
||||
}
|
||||
|
||||
// If both dates are provided
|
||||
if (parsedStartDate && parsedEndDate) {
|
||||
const startYear = parsedStartDate.getFullYear();
|
||||
const startMonth = parsedStartDate.getMonth();
|
||||
const endYear = parsedEndDate.getFullYear();
|
||||
const endMonth = parsedEndDate.getMonth();
|
||||
|
||||
// Same year, same month
|
||||
if (startYear === endYear && startMonth === endMonth) {
|
||||
const startDay = format(parsedStartDate, "dd");
|
||||
const endDay = format(parsedEndDate, "dd");
|
||||
return `${format(parsedStartDate, "MMM")} ${startDay} - ${endDay}, ${startYear}`;
|
||||
}
|
||||
|
||||
// Same year, different month
|
||||
if (startYear === endYear) {
|
||||
const startFormatted = format(parsedStartDate, "MMM dd");
|
||||
const endFormatted = format(parsedEndDate, "MMM dd");
|
||||
return `${startFormatted} - ${endFormatted}, ${startYear}`;
|
||||
}
|
||||
|
||||
// Different year
|
||||
const startFormatted = format(parsedStartDate, "MMM dd, yyyy");
|
||||
const endFormatted = format(parsedEndDate, "MMM dd, yyyy");
|
||||
return `${startFormatted} - ${endFormatted}`;
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
// Duration Helpers
|
||||
/**
|
||||
* @returns {string} formatted duration in human readable format
|
||||
* @description Converts seconds to human readable duration format (e.g., "1 hr 20 min 5 sec" or "122.30 ms")
|
||||
* @param {number} seconds - The duration in seconds
|
||||
* @example formatDuration(3665) // "1 hr 1 min 5 sec"
|
||||
* @example formatDuration(125) // "2 min 5 sec"
|
||||
* @example formatDuration(45) // "45 sec"
|
||||
* @example formatDuration(0.1223094) // "122.31 ms"
|
||||
*/
|
||||
export const formatDuration = (seconds: number | undefined | null): string => {
|
||||
// Return "N/A" if seconds is not a valid number
|
||||
if (seconds == null || typeof seconds !== "number" || !Number.isFinite(seconds) || seconds < 0) {
|
||||
return "N/A";
|
||||
}
|
||||
|
||||
// If less than 1 second, show in ms (2 decimal places)
|
||||
if (seconds > 0 && seconds < 1) {
|
||||
const ms = seconds * 1000;
|
||||
return `${ms.toFixed(2)} ms`;
|
||||
}
|
||||
|
||||
// Round to nearest second
|
||||
const totalSeconds = Math.round(seconds);
|
||||
|
||||
// Calculate hours, minutes, and seconds
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const remainingSeconds = totalSeconds % 60;
|
||||
|
||||
// Build the formatted string
|
||||
const parts: string[] = [];
|
||||
|
||||
if (hours > 0) {
|
||||
parts.push(`${hours} hr`);
|
||||
}
|
||||
|
||||
if (minutes > 0) {
|
||||
parts.push(`${minutes} min`);
|
||||
}
|
||||
|
||||
if (remainingSeconds > 0 || parts.length === 0) {
|
||||
parts.push(`${remainingSeconds} sec`);
|
||||
}
|
||||
|
||||
return parts.join(" ");
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a date is valid
|
||||
* @param date The date to check
|
||||
* @returns Whether the date is valid or not
|
||||
*/
|
||||
export const isValidDate = (date: unknown): date is string | Date =>
|
||||
(typeof date === "string" || typeof date === "object") && date !== null && !isNaN(Date.parse(date as string));
|
||||
264
packages/utils/src/distribution-update.ts
Normal file
264
packages/utils/src/distribution-update.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
"use client";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { get, set } from "lodash-es";
|
||||
// plane imports
|
||||
import { COMPLETED_STATE_GROUPS, STATE_DISTRIBUTION } from "@plane/constants";
|
||||
import { ICycle, IEstimatePoint, IModule, IState, TIssue } from "@plane/types";
|
||||
// helper
|
||||
import { getDate } from "./datetime";
|
||||
|
||||
export type DistributionObjectUpdate = {
|
||||
id: string;
|
||||
completed_issues?: number;
|
||||
pending_issues?: number;
|
||||
total_issues: number;
|
||||
completed_estimates?: number;
|
||||
pending_estimates?: number;
|
||||
total_estimates: number;
|
||||
};
|
||||
|
||||
type ChartUpdates = {
|
||||
updates: {
|
||||
path: string[];
|
||||
value: number;
|
||||
}[];
|
||||
isCompleted?: boolean;
|
||||
};
|
||||
|
||||
export type DistributionUpdates = {
|
||||
pathUpdates: { path: string[]; value: number }[];
|
||||
assigneeUpdates: DistributionObjectUpdate[];
|
||||
labelUpdates: DistributionObjectUpdate[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
100
packages/utils/src/editor.ts
Normal file
100
packages/utils/src/editor.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
// local imports
|
||||
import { getFileURL } from "./file";
|
||||
|
||||
type TEditorSrcArgs = {
|
||||
assetId: string;
|
||||
projectId?: string;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description generate the file source using assetId
|
||||
* @param {TEditorSrcArgs} args
|
||||
*/
|
||||
export const getEditorAssetSrc = (args: TEditorSrcArgs): string | undefined => {
|
||||
const { assetId, projectId, workspaceSlug } = args;
|
||||
let url: string | undefined = "";
|
||||
if (projectId) {
|
||||
url = getFileURL(`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${assetId}/`);
|
||||
} else {
|
||||
url = getFileURL(`/api/assets/v2/workspaces/${workspaceSlug}/${assetId}/`);
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description generate the file source using assetId
|
||||
* @param {TEditorSrcArgs} args
|
||||
*/
|
||||
export const getEditorAssetDownloadSrc = (args: TEditorSrcArgs): string | undefined => {
|
||||
const { assetId, projectId, workspaceSlug } = args;
|
||||
let url: string | undefined = "";
|
||||
if (projectId) {
|
||||
url = getFileURL(`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/download/${assetId}/`);
|
||||
} else {
|
||||
url = getFileURL(`/api/assets/v2/workspaces/${workspaceSlug}/download/${assetId}/`);
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
export const getTextContent = (jsx: React.ReactNode | 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() === "";
|
||||
|
||||
export enum CORE_EXTENSIONS {
|
||||
BLOCKQUOTE = "blockquote",
|
||||
BOLD = "bold",
|
||||
BULLET_LIST = "bulletList",
|
||||
CALLOUT = "calloutComponent",
|
||||
CHARACTER_COUNT = "characterCount",
|
||||
CODE_BLOCK = "codeBlock",
|
||||
CODE_INLINE = "code",
|
||||
CUSTOM_COLOR = "customColor",
|
||||
CUSTOM_IMAGE = "imageComponent",
|
||||
CUSTOM_LINK = "link",
|
||||
DOCUMENT = "doc",
|
||||
DROP_CURSOR = "dropCursor",
|
||||
ENTER_KEY = "enterKey",
|
||||
GAP_CURSOR = "gapCursor",
|
||||
HARD_BREAK = "hardBreak",
|
||||
HEADING = "heading",
|
||||
HEADINGS_LIST = "headingsList",
|
||||
HISTORY = "history",
|
||||
HORIZONTAL_RULE = "horizontalRule",
|
||||
IMAGE = "image",
|
||||
ITALIC = "italic",
|
||||
LIST_ITEM = "listItem",
|
||||
MARKDOWN_CLIPBOARD = "markdownClipboard",
|
||||
MENTION = "mention",
|
||||
ORDERED_LIST = "orderedList",
|
||||
PARAGRAPH = "paragraph",
|
||||
PLACEHOLDER = "placeholder",
|
||||
SIDE_MENU = "editorSideMenu",
|
||||
SLASH_COMMANDS = "slash-command",
|
||||
STRIKETHROUGH = "strike",
|
||||
TABLE = "table",
|
||||
TABLE_CELL = "tableCell",
|
||||
TABLE_HEADER = "tableHeader",
|
||||
TABLE_ROW = "tableRow",
|
||||
TASK_ITEM = "taskItem",
|
||||
TASK_LIST = "taskList",
|
||||
TEXT_ALIGN = "textAlign",
|
||||
TEXT_STYLE = "textStyle",
|
||||
TYPOGRAPHY = "typography",
|
||||
UNDERLINE = "underline",
|
||||
UTILITY = "utility",
|
||||
WORK_ITEM_EMBED = "issue-embed-component",
|
||||
EMOJI = "emoji",
|
||||
}
|
||||
|
||||
export enum ADDITIONAL_EXTENSIONS {}
|
||||
85
packages/utils/src/emoji.ts
Normal file
85
packages/utils/src/emoji.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
// plane imports
|
||||
import { RANDOM_EMOJI_CODES } from "@plane/constants";
|
||||
|
||||
/**
|
||||
* Converts a hyphen-separated hexadecimal emoji code to its decimal representation
|
||||
* @param {string} emojiUnified - The unified emoji code in hexadecimal format (e.g., "1f600" or "1f1e6-1f1e8")
|
||||
* @returns {string} The decimal representation of the emoji code (e.g., "128512" or "127462-127464")
|
||||
* @example
|
||||
* convertHexEmojiToDecimal("1f600") // returns "128512"
|
||||
* convertHexEmojiToDecimal("1f1e6-1f1e8") // returns "127462-127464"
|
||||
* convertHexEmojiToDecimal("") // returns ""
|
||||
*/
|
||||
export const convertHexEmojiToDecimal = (emojiUnified: string): string => {
|
||||
if (!emojiUnified) return "";
|
||||
|
||||
return emojiUnified
|
||||
.toString()
|
||||
.split("-")
|
||||
.map((e) => parseInt(e, 16))
|
||||
.join("-");
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a hyphen-separated decimal emoji code back to its hexadecimal representation
|
||||
* @param {string} emoji - The emoji code in decimal format (e.g., "128512" or "127462-127464")
|
||||
* @returns {string} The hexadecimal representation of the emoji code (e.g., "1f600" or "1f1e6-1f1e8")
|
||||
* @example
|
||||
* emojiCodeToUnicode("128512") // returns "1f600"
|
||||
* emojiCodeToUnicode("127462-127464") // returns "1f1e6-1f1e8"
|
||||
* emojiCodeToUnicode("") // returns ""
|
||||
*/
|
||||
export const emojiCodeToUnicode = (emoji: string): string => {
|
||||
if (!emoji) return "";
|
||||
|
||||
// convert emoji code to unicode
|
||||
const uniCodeEmoji = emoji
|
||||
.toString()
|
||||
.split("-")
|
||||
.map((emoji) => parseInt(emoji, 10).toString(16))
|
||||
.join("-");
|
||||
|
||||
return uniCodeEmoji;
|
||||
};
|
||||
|
||||
/**
|
||||
* Groups reactions by a specified key
|
||||
* @param {any[]} reactions - Array of reaction objects
|
||||
* @param {string} key - Key to group reactions by
|
||||
* @returns {Object} Object with reactions grouped by the specified key
|
||||
*/
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a random emoji code from the RANDOM_EMOJI_CODES array
|
||||
* @returns {string} A random emoji code
|
||||
*/
|
||||
export const getRandomEmoji = (): string => RANDOM_EMOJI_CODES[Math.floor(Math.random() * RANDOM_EMOJI_CODES.length)];
|
||||
34
packages/utils/src/estimates.ts
Normal file
34
packages/utils/src/estimates.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// plane web constants
|
||||
import { EEstimateSystem } from "@plane/constants";
|
||||
|
||||
export const isEstimatePointValuesRepeated = (
|
||||
estimatePoints: string[],
|
||||
estimateType: EEstimateSystem,
|
||||
newEstimatePoint?: string | undefined
|
||||
) => {
|
||||
const currentEstimatePoints = estimatePoints.map((estimatePoint) => estimatePoint.trim());
|
||||
let isRepeated = false;
|
||||
|
||||
if (newEstimatePoint === undefined) {
|
||||
if (estimateType === EEstimateSystem.CATEGORIES) {
|
||||
const points = new Set(currentEstimatePoints);
|
||||
if (points.size != currentEstimatePoints.length) isRepeated = true;
|
||||
} else if ([EEstimateSystem.POINTS, EEstimateSystem.TIME].includes(estimateType)) {
|
||||
currentEstimatePoints.map((point) => {
|
||||
if (Number(point) === Number(newEstimatePoint)) isRepeated = true;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (estimateType === EEstimateSystem.CATEGORIES) {
|
||||
currentEstimatePoints.map((point) => {
|
||||
if (point === newEstimatePoint.trim()) isRepeated = true;
|
||||
});
|
||||
} else if ([EEstimateSystem.POINTS, EEstimateSystem.TIME].includes(estimateType)) {
|
||||
currentEstimatePoints.map((point) => {
|
||||
if (Number(point) === Number(newEstimatePoint.trim())) isRepeated = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return isRepeated;
|
||||
};
|
||||
90
packages/utils/src/file.ts
Normal file
90
packages/utils/src/file.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
// plane imports
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
|
||||
/**
|
||||
* @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 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 {
|
||||
throw new Error("Invalid URL format");
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
// check if the response is OK
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch image: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onloadend = () => {
|
||||
if (reader.result) {
|
||||
resolve(reader.result as string);
|
||||
} else {
|
||||
reject(new Error("Failed to convert image to base64."));
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
reject(new Error("Failed to read the image file."));
|
||||
};
|
||||
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description downloads a CSV file
|
||||
* @param {Array<Array<string>> | { [key: string]: string }} data - The data to be exported to CSV
|
||||
* @param {string} name - The name of the file to be downloaded
|
||||
*/
|
||||
export const csvDownload = (data: Array<Array<string>> | { [key: string]: string }, name: string) => {
|
||||
const rows = Array.isArray(data) ? [...data] : [Object.keys(data), Object.values(data)];
|
||||
|
||||
const csvContent = "data:text/csv;charset=utf-8," + rows.map((e) => e.join(",")).join("\n");
|
||||
const encodedUri = encodeURI(csvContent);
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = encodedUri;
|
||||
link.download = `${name}.csv`;
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
63
packages/utils/src/filter.ts
Normal file
63
packages/utils/src/filter.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { differenceInCalendarDays } from "date-fns/differenceInCalendarDays";
|
||||
// local imports
|
||||
import { getDate } from "./datetime";
|
||||
|
||||
/**
|
||||
* @description calculates the total number of filters applied
|
||||
* @param {T} filters
|
||||
* @returns {number}
|
||||
*/
|
||||
export const calculateTotalFilters = <T>(filters: T): number =>
|
||||
filters && Object.keys(filters).length > 0
|
||||
? Object.keys(filters)
|
||||
.map((key) => {
|
||||
const value = filters[key as keyof T];
|
||||
if (value === null) return 0;
|
||||
if (Array.isArray(value)) return value.length;
|
||||
if (typeof value === "boolean") return value ? 1 : 0;
|
||||
return 0;
|
||||
})
|
||||
.reduce((curr, prev) => curr + prev, 0)
|
||||
: 0;
|
||||
|
||||
/**
|
||||
* @description checks if the date satisfies the filter
|
||||
* @param {Date} date
|
||||
* @param {string} filter
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const satisfiesDateFilter = (date: Date, filter: string): boolean => {
|
||||
const [value, operator, from] = filter.split(";");
|
||||
|
||||
const dateValue = getDate(value);
|
||||
const differenceInDays = differenceInCalendarDays(date, new Date());
|
||||
|
||||
if (operator === "custom" && from === "custom") {
|
||||
if (value === "today") return differenceInDays === 0;
|
||||
if (value === "yesterday") return differenceInDays === -1;
|
||||
if (value === "last_7_days") return differenceInDays >= -7;
|
||||
if (value === "last_30_days") return differenceInDays >= -30;
|
||||
}
|
||||
|
||||
if (!from && dateValue) {
|
||||
if (operator === "after") return date >= dateValue;
|
||||
if (operator === "before") return date <= dateValue;
|
||||
}
|
||||
|
||||
if (from === "fromnow") {
|
||||
if (operator === "before") {
|
||||
if (value === "1_weeks") return differenceInDays <= -7;
|
||||
if (value === "2_weeks") return differenceInDays <= -14;
|
||||
if (value === "1_months") return differenceInDays <= -30;
|
||||
}
|
||||
|
||||
if (operator === "after") {
|
||||
if (value === "1_weeks") return differenceInDays >= 7;
|
||||
if (value === "2_weeks") return differenceInDays >= 14;
|
||||
if (value === "1_months") return differenceInDays >= 30;
|
||||
if (value === "2_months") return differenceInDays >= 60;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
64
packages/utils/src/get-icon-for-link.ts
Normal file
64
packages/utils/src/get-icon-for-link.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
Github,
|
||||
Linkedin,
|
||||
Twitter,
|
||||
Facebook,
|
||||
Instagram,
|
||||
Youtube,
|
||||
Dribbble,
|
||||
Figma,
|
||||
FileText,
|
||||
FileImage,
|
||||
FileVideo,
|
||||
FileAudio,
|
||||
FileArchive,
|
||||
FileSpreadsheet,
|
||||
FileCode,
|
||||
Mail,
|
||||
Chrome,
|
||||
Link2,
|
||||
} from "lucide-react";
|
||||
|
||||
type IconMatcher = {
|
||||
pattern: RegExp;
|
||||
icon: typeof Github;
|
||||
};
|
||||
|
||||
const SOCIAL_MEDIA_MATCHERS: IconMatcher[] = [
|
||||
{ pattern: /github\.com/, icon: Github },
|
||||
{ pattern: /linkedin\.com/, icon: Linkedin },
|
||||
{ pattern: /(twitter\.com|x\.com)/, icon: Twitter },
|
||||
{ pattern: /facebook\.com/, icon: Facebook },
|
||||
{ pattern: /instagram\.com/, icon: Instagram },
|
||||
{ pattern: /youtube\.com/, icon: Youtube },
|
||||
{ pattern: /dribbble\.com/, icon: Dribbble },
|
||||
];
|
||||
|
||||
const PRODUCTIVITY_MATCHERS: IconMatcher[] = [
|
||||
{ pattern: /figma\.com/, icon: Figma },
|
||||
{ pattern: /(google\.com|docs\.|doc\.)/, icon: FileText },
|
||||
];
|
||||
|
||||
const FILE_TYPE_MATCHERS: IconMatcher[] = [
|
||||
{ pattern: /\.(jpg|jpeg|png|gif|bmp|svg|webp)$/, icon: FileImage },
|
||||
{ pattern: /\.(mp4|mov|avi|wmv|flv|mkv)$/, icon: FileVideo },
|
||||
{ pattern: /\.(mp3|wav|ogg)$/, icon: FileAudio },
|
||||
{ pattern: /\.(zip|rar|7z|tar|gz)$/, icon: FileArchive },
|
||||
{ pattern: /\.(xls|xlsx|csv)$/, icon: FileSpreadsheet },
|
||||
{ pattern: /\.(pdf|doc|docx|txt)$/, icon: FileText },
|
||||
{ pattern: /\.(html|js|ts|jsx|tsx|css|scss)$/, icon: FileCode },
|
||||
];
|
||||
|
||||
const OTHER_MATCHERS: IconMatcher[] = [
|
||||
{ pattern: /^mailto:/, icon: Mail },
|
||||
{ pattern: /^http/, icon: Chrome },
|
||||
];
|
||||
|
||||
export const getIconForLink = (url: string) => {
|
||||
const lowerUrl = url.toLowerCase();
|
||||
|
||||
const allMatchers = [...SOCIAL_MEDIA_MATCHERS, ...PRODUCTIVITY_MATCHERS, ...FILE_TYPE_MATCHERS, ...OTHER_MATCHERS];
|
||||
|
||||
const matchedIcon = allMatchers.find(({ pattern }) => pattern.test(lowerUrl));
|
||||
return matchedIcon?.icon ?? Link2;
|
||||
};
|
||||
34
packages/utils/src/index.ts
Normal file
34
packages/utils/src/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export * from "./array";
|
||||
export * from "./attachment";
|
||||
export * from "./auth";
|
||||
export * from "./calendar";
|
||||
export * from "./color";
|
||||
export * from "./common";
|
||||
export * from "./cycle";
|
||||
export * from "./datetime";
|
||||
export * from "./distribution-update";
|
||||
export * from "./editor";
|
||||
export * from "./emoji";
|
||||
export * from "./estimates";
|
||||
export * from "./file";
|
||||
export * from "./filter";
|
||||
export * from "./get-icon-for-link";
|
||||
export * from "./intake";
|
||||
export * from "./loader";
|
||||
export * from "./math";
|
||||
export * from "./module";
|
||||
export * from "./notification";
|
||||
export * from "./page";
|
||||
export * from "./permission";
|
||||
export * from "./project-views";
|
||||
export * from "./project";
|
||||
export * from "./rich-filters";
|
||||
export * from "./router";
|
||||
export * from "./string";
|
||||
export * from "./subscription";
|
||||
export * from "./tab-indices";
|
||||
export * from "./theme";
|
||||
export * from "./url";
|
||||
export * from "./work-item-filters";
|
||||
export * from "./work-item";
|
||||
export * from "./workspace";
|
||||
34
packages/utils/src/intake.ts
Normal file
34
packages/utils/src/intake.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { subDays } from "date-fns";
|
||||
// plane imports
|
||||
import { EPastDurationFilters } from "@plane/constants";
|
||||
// local imports
|
||||
import { renderFormattedPayloadDate } from "./datetime";
|
||||
|
||||
export const getCustomDates = (duration: EPastDurationFilters): string => {
|
||||
const today = new Date();
|
||||
let firstDay, lastDay;
|
||||
|
||||
switch (duration) {
|
||||
case EPastDurationFilters.TODAY: {
|
||||
firstDay = renderFormattedPayloadDate(today);
|
||||
lastDay = renderFormattedPayloadDate(today);
|
||||
return `${firstDay};after,${lastDay};before`;
|
||||
}
|
||||
case EPastDurationFilters.YESTERDAY: {
|
||||
const yesterday = subDays(today, 1);
|
||||
firstDay = renderFormattedPayloadDate(yesterday);
|
||||
lastDay = renderFormattedPayloadDate(yesterday);
|
||||
return `${firstDay};after,${lastDay};before`;
|
||||
}
|
||||
case EPastDurationFilters.LAST_7_DAYS: {
|
||||
firstDay = renderFormattedPayloadDate(subDays(today, 7));
|
||||
lastDay = renderFormattedPayloadDate(today);
|
||||
return `${firstDay};after,${lastDay};before`;
|
||||
}
|
||||
case EPastDurationFilters.LAST_30_DAYS: {
|
||||
firstDay = renderFormattedPayloadDate(subDays(today, 30));
|
||||
lastDay = renderFormattedPayloadDate(today);
|
||||
return `${firstDay};after,${lastDay};before`;
|
||||
}
|
||||
}
|
||||
};
|
||||
4
packages/utils/src/loader.ts
Normal file
4
packages/utils/src/loader.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { TLoader } from "@plane/types";
|
||||
|
||||
// checks if a loader has finished initialization
|
||||
export const isLoaderReady = (loader: TLoader | undefined) => loader !== "init-loader";
|
||||
2
packages/utils/src/math.ts
Normal file
2
packages/utils/src/math.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const getProgress = (completed: number | undefined, total: number | undefined) =>
|
||||
total && total > 0 ? Math.round(((completed ?? 0) / total) * 100) : 0;
|
||||
87
packages/utils/src/module.ts
Normal file
87
packages/utils/src/module.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { sortBy } from "lodash-es";
|
||||
// plane imports
|
||||
import { IModule, TModuleDisplayFilters, TModuleFilters, TModuleOrderByOptions } from "@plane/types";
|
||||
// local imports
|
||||
import { getDate } from "./datetime";
|
||||
import { satisfiesDateFilter } from "./filter";
|
||||
|
||||
const collator = new Intl.Collator("en-US", { numeric: true, sensitivity: "base" });
|
||||
|
||||
/**
|
||||
* @description performs natural sorting of strings (handles numbers within strings correctly)
|
||||
* @param {string} a - first string to compare
|
||||
* @param {string} b - second string to compare
|
||||
* @returns {number} - comparison result (-1, 0, or 1)
|
||||
*/
|
||||
const naturalSort = (a: string, b: string): number => collator.compare(a, b);
|
||||
/**
|
||||
* @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 = [...modules].sort((a, b) => naturalSort(a.name, b.name));
|
||||
if (orderByKey === "-name") orderedModules = [...modules].sort((a, b) => naturalSort(b.name, a.name));
|
||||
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;
|
||||
},
|
||||
]);
|
||||
if (["issues_length", "-issues_length"].includes(orderByKey))
|
||||
orderedModules = sortBy(modules, [(m) => (orderByKey === "issues_length" ? m.total_issues : !m.total_issues)]);
|
||||
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;
|
||||
};
|
||||
8
packages/utils/src/notification.ts
Normal file
8
packages/utils/src/notification.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { stripAndTruncateHTML } from "./string";
|
||||
|
||||
export const sanitizeCommentForNotification = (mentionContent: string | undefined) =>
|
||||
mentionContent
|
||||
? stripAndTruncateHTML(
|
||||
mentionContent.replace(/<mention-component\b[^>]*\blabel="([^"]*)"[^>]*><\/mention-component>/g, "$1")
|
||||
)
|
||||
: mentionContent;
|
||||
86
packages/utils/src/page.ts
Normal file
86
packages/utils/src/page.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { sortBy } from "lodash-es";
|
||||
// plane imports
|
||||
import { TPage, TPageFilterProps, TPageFiltersSortBy, TPageFiltersSortKey, TPageNavigationTabs } from "@plane/types";
|
||||
// local imports
|
||||
import { getDate } from "./datetime";
|
||||
import { satisfiesDateFilter } from "./filter";
|
||||
|
||||
/**
|
||||
* @description filters pages based on the page type
|
||||
* @param {TPageNavigationTabs} pageType
|
||||
* @param {TPage[]} pages
|
||||
* @returns {TPage[]}
|
||||
*/
|
||||
export const filterPagesByPageType = (pageType: TPageNavigationTabs, pages: TPage[]): TPage[] =>
|
||||
pages.filter((page) => {
|
||||
if (pageType === "public") return page.access === 0 && !page.archived_at;
|
||||
if (pageType === "private") return page.access === 1 && !page.archived_at;
|
||||
if (pageType === "archived") return page.archived_at;
|
||||
return true;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description orders pages based on their status
|
||||
* @param {TPage[]} pages
|
||||
* @param {TPageFiltersSortKey | undefined} sortByKey
|
||||
* @param {TPageFiltersSortBy} sortByOrder
|
||||
* @returns {TPage[]}
|
||||
*/
|
||||
export const orderPages = (
|
||||
pages: TPage[],
|
||||
sortByKey: TPageFiltersSortKey | undefined,
|
||||
sortByOrder: TPageFiltersSortBy
|
||||
): TPage[] => {
|
||||
let orderedPages: TPage[] = [];
|
||||
if (pages.length === 0 || !sortByKey) return [];
|
||||
|
||||
if (sortByKey === "name") {
|
||||
orderedPages = sortBy(pages, [(m) => m.name?.toLowerCase()]);
|
||||
if (sortByOrder === "desc") orderedPages = orderedPages.reverse();
|
||||
}
|
||||
if (sortByKey === "created_at") {
|
||||
orderedPages = sortBy(pages, [(m) => m.created_at]);
|
||||
if (sortByOrder === "desc") orderedPages = orderedPages.reverse();
|
||||
}
|
||||
if (sortByKey === "updated_at") {
|
||||
orderedPages = sortBy(pages, [(m) => m.updated_at]);
|
||||
if (sortByOrder === "desc") orderedPages = orderedPages.reverse();
|
||||
}
|
||||
|
||||
return orderedPages;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description filters pages based on the filters
|
||||
* @param {TPage} page
|
||||
* @param {TPageFilterProps | undefined} filters
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const shouldFilterPage = (page: TPage, filters: TPageFilterProps | undefined): boolean => {
|
||||
let fallsInFilters = true;
|
||||
Object.keys(filters ?? {}).forEach((key) => {
|
||||
const filterKey = key as keyof TPageFilterProps;
|
||||
if (filterKey === "created_by" && filters?.created_by && filters.created_by.length > 0)
|
||||
fallsInFilters = fallsInFilters && filters.created_by.includes(`${page.owned_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;
|
||||
};
|
||||
1
packages/utils/src/permission/index.ts
Normal file
1
packages/utils/src/permission/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./role";
|
||||
26
packages/utils/src/permission/role.ts
Normal file
26
packages/utils/src/permission/role.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// plane imports
|
||||
import { EUserPermissions } from "@plane/constants";
|
||||
import { EUserProjectRoles, EUserWorkspaceRoles } from "@plane/types";
|
||||
|
||||
export const getUserRole = (role: EUserPermissions | EUserWorkspaceRoles | EUserProjectRoles) => {
|
||||
switch (role) {
|
||||
case EUserPermissions.GUEST:
|
||||
return "GUEST";
|
||||
case EUserPermissions.MEMBER:
|
||||
return "MEMBER";
|
||||
case EUserPermissions.ADMIN:
|
||||
return "ADMIN";
|
||||
}
|
||||
};
|
||||
|
||||
type TSupportedRole = EUserPermissions | EUserProjectRoles | EUserWorkspaceRoles;
|
||||
|
||||
/**
|
||||
* @description Returns the highest role from an array of supported roles
|
||||
* @param { TSupportedRole[] } roles
|
||||
* @returns { TSupportedRole | undefined }
|
||||
*/
|
||||
export const getHighestRole = <T extends TSupportedRole>(roles: T[]): T | undefined => {
|
||||
if (!roles || roles.length === 0) return undefined;
|
||||
return roles.reduce((highest, current) => (current > highest ? current : highest));
|
||||
};
|
||||
104
packages/utils/src/project-views.ts
Normal file
104
packages/utils/src/project-views.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { isNil, orderBy } from "lodash-es";
|
||||
// plane imports
|
||||
import { SPACE_BASE_PATH, SPACE_BASE_URL } from "@plane/constants";
|
||||
import { IProjectView, TViewFilterProps, TViewFiltersSortBy, TViewFiltersSortKey } from "@plane/types";
|
||||
// local imports
|
||||
import { getDate } from "./datetime";
|
||||
import { satisfiesDateFilter } from "./filter";
|
||||
|
||||
/**
|
||||
* order views base on TViewFiltersSortKey
|
||||
* @param views
|
||||
* @param sortByKey
|
||||
* @param sortByOrder
|
||||
* @returns
|
||||
*/
|
||||
export const orderViews = (
|
||||
views: IProjectView[],
|
||||
sortByKey: TViewFiltersSortKey | undefined,
|
||||
sortByOrder: TViewFiltersSortBy
|
||||
): IProjectView[] => {
|
||||
if (views.length === 0 || !sortByKey) return [];
|
||||
|
||||
let iterableFunction;
|
||||
if (sortByKey === "name") {
|
||||
iterableFunction = (view: IProjectView) => view.name?.toLowerCase();
|
||||
}
|
||||
if (sortByKey === "created_at") {
|
||||
iterableFunction = (view: IProjectView) => view.created_at;
|
||||
}
|
||||
if (sortByKey === "updated_at") {
|
||||
iterableFunction = (view: IProjectView) => view.updated_at;
|
||||
}
|
||||
|
||||
if (!iterableFunction) return [];
|
||||
|
||||
return orderBy(views, [iterableFunction], [sortByOrder]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the passed down view should be filtered or not
|
||||
* @param view
|
||||
* @param filters
|
||||
* @returns
|
||||
*/
|
||||
export const shouldFilterView = (view: IProjectView, filters: TViewFilterProps | undefined): boolean => {
|
||||
let fallsInFilters = true;
|
||||
Object.keys(filters ?? {}).forEach((key) => {
|
||||
const filterKey = key as keyof TViewFilterProps;
|
||||
if (filterKey === "owned_by" && filters?.owned_by && filters.owned_by.length > 0) {
|
||||
fallsInFilters = fallsInFilters && filters.owned_by.includes(`${view.created_by}`);
|
||||
}
|
||||
|
||||
if (filterKey === "created_at" && filters?.created_at && filters.created_at.length > 0) {
|
||||
const createdDate = getDate(view.created_at);
|
||||
filters?.created_at.forEach((dateFilter) => {
|
||||
fallsInFilters = fallsInFilters && !!createdDate && satisfiesDateFilter(createdDate, dateFilter);
|
||||
});
|
||||
}
|
||||
|
||||
if (filterKey === "view_type" && filters?.view_type && filters?.view_type?.length > 0) {
|
||||
fallsInFilters = filters.view_type.includes(view.access);
|
||||
}
|
||||
});
|
||||
|
||||
if (filters?.favorites && !view.is_favorite) fallsInFilters = false;
|
||||
|
||||
return fallsInFilters;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description returns the name of the project after checking for untitled view
|
||||
* @param {string | undefined} name
|
||||
* @returns {string}
|
||||
*/
|
||||
export const getViewName = (name: string | undefined) => {
|
||||
if (name === undefined) return "";
|
||||
if (!name || name.trim() === "") return "Untitled";
|
||||
return name;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds validation for the view creation filters
|
||||
* @param data
|
||||
* @returns
|
||||
*/
|
||||
export const getValidatedViewFilters = (data: Partial<IProjectView>) => {
|
||||
if (data?.display_filters && data?.display_filters?.layout === "kanban" && isNil(data.display_filters.group_by)) {
|
||||
data.display_filters.group_by = "state";
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* returns published view link
|
||||
* @param anchor
|
||||
* @returns
|
||||
*/
|
||||
export const getPublishViewLink = (anchor: string | undefined) => {
|
||||
if (!anchor) return;
|
||||
|
||||
const SPACE_APP_URL = (SPACE_BASE_URL.trim() === "" ? window.location.origin : SPACE_BASE_URL) + SPACE_BASE_PATH;
|
||||
return `${SPACE_APP_URL}/views/${anchor}`;
|
||||
};
|
||||
104
packages/utils/src/project.ts
Normal file
104
packages/utils/src/project.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { sortBy } from "lodash-es";
|
||||
// plane imports
|
||||
import { TProject, TProjectDisplayFilters, TProjectFilters, TProjectOrderByOptions } from "@plane/types";
|
||||
// local imports
|
||||
import { getDate } from "./datetime";
|
||||
import { satisfiesDateFilter } from "./filter";
|
||||
|
||||
/**
|
||||
* Updates the sort order of the project.
|
||||
* @param sortIndex
|
||||
* @param destinationIndex
|
||||
* @param projectId
|
||||
* @returns number | undefined
|
||||
*/
|
||||
export const orderJoinedProjects = (
|
||||
sourceIndex: number,
|
||||
destinationIndex: number,
|
||||
currentProjectId: string,
|
||||
joinedProjects: TProject[]
|
||||
): number | undefined => {
|
||||
if (!currentProjectId || sourceIndex < 0 || destinationIndex < 0 || joinedProjects.length <= 0) return undefined;
|
||||
|
||||
let updatedSortOrder: number | undefined = undefined;
|
||||
const sortOrderDefaultValue = 10000;
|
||||
|
||||
if (destinationIndex === 0) {
|
||||
// updating project at the top of the project
|
||||
const currentSortOrder = joinedProjects[destinationIndex].sort_order || 0;
|
||||
updatedSortOrder = currentSortOrder - sortOrderDefaultValue;
|
||||
} else if (destinationIndex === joinedProjects.length) {
|
||||
// updating project at the bottom of the project
|
||||
const currentSortOrder = joinedProjects[destinationIndex - 1].sort_order || 0;
|
||||
updatedSortOrder = currentSortOrder + sortOrderDefaultValue;
|
||||
} else {
|
||||
// updating project in the middle of the project
|
||||
const destinationTopProjectSortOrder = joinedProjects[destinationIndex - 1].sort_order || 0;
|
||||
const destinationBottomProjectSortOrder = joinedProjects[destinationIndex].sort_order || 0;
|
||||
const updatedValue = (destinationTopProjectSortOrder + destinationBottomProjectSortOrder) / 2;
|
||||
updatedSortOrder = updatedValue;
|
||||
}
|
||||
|
||||
return updatedSortOrder;
|
||||
};
|
||||
|
||||
export const projectIdentifierSanitizer = (identifier: string): string =>
|
||||
identifier.replace(/[^ÇŞĞIİÖÜA-Za-z0-9]/g, "");
|
||||
|
||||
/**
|
||||
* @description filters projects based on the filter
|
||||
* @param {TProject} project
|
||||
* @param {TProjectFilters} filters
|
||||
* @param {TProjectDisplayFilters} displayFilters
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const shouldFilterProject = (
|
||||
project: TProject,
|
||||
displayFilters: TProjectDisplayFilters,
|
||||
filters: TProjectFilters
|
||||
): boolean => {
|
||||
let fallsInFilters = true;
|
||||
Object.keys(filters).forEach((key) => {
|
||||
const filterKey = key as keyof TProjectFilters;
|
||||
if (filterKey === "access" && filters.access && filters.access.length > 0)
|
||||
fallsInFilters = fallsInFilters && filters.access.includes(`${project.network}`);
|
||||
if (filterKey === "lead" && filters.lead && filters.lead.length > 0)
|
||||
fallsInFilters = fallsInFilters && filters.lead.includes(`${project.project_lead}`);
|
||||
if (filterKey === "members" && filters.members && filters.members.length > 0) {
|
||||
const memberIds = project.members;
|
||||
fallsInFilters = fallsInFilters && filters.members.some((memberId) => memberIds?.includes(memberId));
|
||||
}
|
||||
if (filterKey === "created_at" && filters.created_at && filters.created_at.length > 0) {
|
||||
const createdDate = getDate(project.created_at);
|
||||
filters.created_at.forEach((dateFilter) => {
|
||||
fallsInFilters = fallsInFilters && !!createdDate && satisfiesDateFilter(createdDate, dateFilter);
|
||||
});
|
||||
}
|
||||
});
|
||||
if (displayFilters.my_projects && !project.member_role) fallsInFilters = false;
|
||||
if (displayFilters.archived_projects && !project.archived_at) fallsInFilters = false;
|
||||
if (project.archived_at) fallsInFilters = displayFilters.archived_projects ? fallsInFilters : false;
|
||||
|
||||
return fallsInFilters;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description orders projects based on the orderByKey
|
||||
* @param {TProject[]} projects
|
||||
* @param {TProjectOrderByOptions | undefined} orderByKey
|
||||
* @returns {TProject[]}
|
||||
*/
|
||||
export const orderProjects = (projects: TProject[], orderByKey: TProjectOrderByOptions | undefined): TProject[] => {
|
||||
let orderedProjects: TProject[] = [];
|
||||
if (projects.length === 0) return orderedProjects;
|
||||
|
||||
if (orderByKey === "sort_order") orderedProjects = sortBy(projects, [(p) => p.sort_order]);
|
||||
if (orderByKey === "name") orderedProjects = sortBy(projects, [(p) => p.name.toLowerCase()]);
|
||||
if (orderByKey === "-name") orderedProjects = sortBy(projects, [(p) => p.name.toLowerCase()]).reverse();
|
||||
if (orderByKey === "created_at") orderedProjects = sortBy(projects, [(p) => p.created_at]);
|
||||
if (orderByKey === "-created_at") orderedProjects = sortBy(projects, [(p) => !p.created_at]);
|
||||
if (orderByKey === "members_length") orderedProjects = sortBy(projects, [(p) => p.members?.length]);
|
||||
if (orderByKey === "-members_length") orderedProjects = sortBy(projects, [(p) => p.members?.length]).reverse();
|
||||
|
||||
return orderedProjects;
|
||||
};
|
||||
129
packages/utils/src/rich-filters/factories/configs/core.ts
Normal file
129
packages/utils/src/rich-filters/factories/configs/core.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
// plane imports
|
||||
import { FILTER_FIELD_TYPE, TFilterValue, TSupportedOperators, TBaseFilterFieldConfig } from "@plane/types";
|
||||
// local imports
|
||||
import { createFilterFieldConfig, IFilterIconConfig } from "./shared";
|
||||
|
||||
// ------------ Selection filters ------------
|
||||
|
||||
/**
|
||||
* Options transformation interface for selection filters
|
||||
*/
|
||||
export interface TOptionTransforms<TItem, TValue extends TFilterValue = string, TIconData = undefined> {
|
||||
items: TItem[];
|
||||
getId: (item: TItem) => string;
|
||||
getLabel: (item: TItem) => string;
|
||||
getValue: (item: TItem) => TValue;
|
||||
getIconData?: (item: TItem) => TIconData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-select filter configuration
|
||||
*/
|
||||
export type TSingleSelectConfig<TValue extends TFilterValue = string> = TBaseFilterFieldConfig & {
|
||||
defaultValue?: TValue;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to get the single select config
|
||||
* @param transforms - How to transform items into options
|
||||
* @param config - Single-select specific configuration
|
||||
* @param iconConfig - Icon configuration for options
|
||||
* @returns The single select config
|
||||
*/
|
||||
export const getSingleSelectConfig = <
|
||||
TItem,
|
||||
TValue extends TFilterValue = string,
|
||||
TIconData extends string | number | boolean | object | undefined = undefined,
|
||||
>(
|
||||
transforms: TOptionTransforms<TItem, TValue, TIconData>,
|
||||
config: TSingleSelectConfig<TValue>,
|
||||
iconConfig?: IFilterIconConfig<TIconData>
|
||||
) =>
|
||||
createFilterFieldConfig<typeof FILTER_FIELD_TYPE.SINGLE_SELECT, TValue>({
|
||||
type: FILTER_FIELD_TYPE.SINGLE_SELECT,
|
||||
...config,
|
||||
getOptions: () =>
|
||||
transforms.items.map((item) => ({
|
||||
id: transforms.getId(item),
|
||||
label: transforms.getLabel(item),
|
||||
value: transforms.getValue(item),
|
||||
icon: iconConfig?.getOptionIcon?.(transforms.getIconData?.(item) as TIconData),
|
||||
})),
|
||||
});
|
||||
|
||||
/**
|
||||
* Multi-select filter configuration
|
||||
*/
|
||||
export type TMultiSelectConfig<TValue extends TFilterValue = string> = TBaseFilterFieldConfig & {
|
||||
defaultValue?: TValue[];
|
||||
singleValueOperator: TSupportedOperators;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to get the multi select config
|
||||
* @param transforms - How to transform items into options
|
||||
* @param config - Multi-select specific configuration
|
||||
* @param iconConfig - Icon configuration for options
|
||||
* @returns The multi select config
|
||||
*/
|
||||
export const getMultiSelectConfig = <
|
||||
TItem,
|
||||
TValue extends TFilterValue = string,
|
||||
TIconData extends string | number | boolean | object | undefined = undefined,
|
||||
>(
|
||||
transforms: TOptionTransforms<TItem, TValue, TIconData>,
|
||||
config: TMultiSelectConfig<TValue>,
|
||||
iconConfig?: IFilterIconConfig<TIconData>
|
||||
) =>
|
||||
createFilterFieldConfig<typeof FILTER_FIELD_TYPE.MULTI_SELECT, TValue>({
|
||||
type: FILTER_FIELD_TYPE.MULTI_SELECT,
|
||||
...config,
|
||||
operatorLabel: config?.operatorLabel,
|
||||
getOptions: () =>
|
||||
transforms.items.map((item) => ({
|
||||
id: transforms.getId(item),
|
||||
label: transforms.getLabel(item),
|
||||
value: transforms.getValue(item),
|
||||
icon: iconConfig?.getOptionIcon?.(transforms.getIconData?.(item) as TIconData),
|
||||
})),
|
||||
});
|
||||
|
||||
// ------------ Date filters ------------
|
||||
|
||||
/**
|
||||
* Date filter configuration
|
||||
*/
|
||||
export type TDateConfig = TBaseFilterFieldConfig & {
|
||||
min?: Date;
|
||||
max?: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Date range filter configuration
|
||||
*/
|
||||
export type TDateRangeConfig = TBaseFilterFieldConfig & {
|
||||
min?: Date;
|
||||
max?: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to get the date picker config
|
||||
* @param config - Date-specific configuration
|
||||
* @returns The date picker config
|
||||
*/
|
||||
export const getDatePickerConfig = (config: TDateConfig) =>
|
||||
createFilterFieldConfig<typeof FILTER_FIELD_TYPE.DATE, Date>({
|
||||
type: FILTER_FIELD_TYPE.DATE,
|
||||
...config,
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to get the date range picker config
|
||||
* @param config - Date range-specific configuration
|
||||
* @returns The date range picker config
|
||||
*/
|
||||
export const getDateRangePickerConfig = (config: TDateRangeConfig) =>
|
||||
createFilterFieldConfig<typeof FILTER_FIELD_TYPE.DATE_RANGE, Date>({
|
||||
type: FILTER_FIELD_TYPE.DATE_RANGE,
|
||||
...config,
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./core";
|
||||
export * from "./shared";
|
||||
export * from "./properties";
|
||||
@@ -0,0 +1,27 @@
|
||||
// plane imports
|
||||
import { TFilterProperty } from "@plane/types";
|
||||
// local imports
|
||||
import { createFilterConfig, TCreateDateFilterParams, TCreateFilterConfig } from "../shared";
|
||||
import { getSupportedDateOperators, TCustomPropertyFilterParams } from "./shared";
|
||||
|
||||
/**
|
||||
* Date property filter specific params
|
||||
*/
|
||||
export type TCreateDatePropertyFilterParams = TCustomPropertyFilterParams<Date> & TCreateDateFilterParams;
|
||||
|
||||
/**
|
||||
* Get the date property filter config
|
||||
* @param params - The filter params
|
||||
* @returns The date property filter config
|
||||
*/
|
||||
export const getDatePropertyFilterConfig =
|
||||
<P extends TFilterProperty>(key: P): TCreateFilterConfig<P, TCreateDatePropertyFilterParams> =>
|
||||
(params: TCreateDatePropertyFilterParams) =>
|
||||
createFilterConfig({
|
||||
id: key,
|
||||
...params,
|
||||
label: params.propertyDisplayName,
|
||||
icon: params.filterIcon,
|
||||
allowMultipleFilters: true,
|
||||
supportedOperatorConfigsMap: getSupportedDateOperators(params),
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./date";
|
||||
export * from "./member-picker";
|
||||
export * from "./shared";
|
||||
@@ -0,0 +1,30 @@
|
||||
// plane imports
|
||||
import { EQUALITY_OPERATOR, IUserLite, TFilterProperty } from "@plane/types";
|
||||
// local imports
|
||||
import { createFilterConfig, createOperatorConfigEntry, TCreateFilterConfig } from "../shared";
|
||||
import { getMemberMultiSelectConfig, TCreateUserFilterParams, TCustomPropertyFilterParams } from "./shared";
|
||||
|
||||
/**
|
||||
* Member picker property filter specific params
|
||||
*/
|
||||
type TCreateMemberPickerPropertyFilterParams = TCustomPropertyFilterParams<IUserLite> & TCreateUserFilterParams;
|
||||
|
||||
/**
|
||||
* Get the member picker property filter config
|
||||
* @param params - The filter params
|
||||
* @returns The member picker property filter config
|
||||
*/
|
||||
export const getMemberPickerPropertyFilterConfig =
|
||||
<P extends TFilterProperty>(key: P): TCreateFilterConfig<P, TCreateMemberPickerPropertyFilterParams> =>
|
||||
(params: TCreateMemberPickerPropertyFilterParams) =>
|
||||
createFilterConfig({
|
||||
id: key,
|
||||
...params,
|
||||
label: params.propertyDisplayName,
|
||||
icon: params.filterIcon,
|
||||
supportedOperatorConfigsMap: new Map([
|
||||
createOperatorConfigEntry(EQUALITY_OPERATOR.EXACT, params, (updatedParams) =>
|
||||
getMemberMultiSelectConfig(updatedParams, EQUALITY_OPERATOR.EXACT)
|
||||
),
|
||||
]),
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
// plane imports
|
||||
import {
|
||||
COMPARISON_OPERATOR,
|
||||
EQUALITY_OPERATOR,
|
||||
IProject,
|
||||
IUserLite,
|
||||
TOperatorConfigMap,
|
||||
TSupportedOperators,
|
||||
} from "@plane/types";
|
||||
// local imports
|
||||
import { getDatePickerConfig, getDateRangePickerConfig, getMultiSelectConfig } from "../core";
|
||||
import {
|
||||
createOperatorConfigEntry,
|
||||
IFilterIconConfig,
|
||||
TCreateDateFilterParams,
|
||||
TCreateFilterConfigParams,
|
||||
TFilterIconType,
|
||||
} from "../shared";
|
||||
|
||||
// ------------ Base User Filter Types ------------
|
||||
|
||||
/**
|
||||
* User filter specific params
|
||||
*/
|
||||
export type TCreateUserFilterParams = TCreateFilterConfigParams &
|
||||
IFilterIconConfig<IUserLite> & {
|
||||
members: IUserLite[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to get the member multi select config
|
||||
* @param params - The filter params
|
||||
* @returns The member multi select config
|
||||
*/
|
||||
export const getMemberMultiSelectConfig = (params: TCreateUserFilterParams, singleValueOperator: TSupportedOperators) =>
|
||||
getMultiSelectConfig<IUserLite, string, IUserLite>(
|
||||
{
|
||||
items: params.members,
|
||||
getId: (member) => member.id,
|
||||
getLabel: (member) => member.display_name,
|
||||
getValue: (member) => member.id,
|
||||
getIconData: (member) => member,
|
||||
},
|
||||
{
|
||||
singleValueOperator,
|
||||
...params,
|
||||
},
|
||||
{
|
||||
...params,
|
||||
}
|
||||
);
|
||||
|
||||
// ------------ Date Operators ------------
|
||||
|
||||
export const getSupportedDateOperators = (params: TCreateDateFilterParams): TOperatorConfigMap<Date> =>
|
||||
new Map([
|
||||
createOperatorConfigEntry(EQUALITY_OPERATOR.EXACT, params, (updatedParams) => getDatePickerConfig(updatedParams)),
|
||||
createOperatorConfigEntry(COMPARISON_OPERATOR.RANGE, params, (updatedParams) =>
|
||||
getDateRangePickerConfig(updatedParams)
|
||||
),
|
||||
]);
|
||||
|
||||
// ------------ Project filter ------------
|
||||
|
||||
/**
|
||||
* Project filter specific params
|
||||
*/
|
||||
export type TCreateProjectFilterParams = TCreateFilterConfigParams &
|
||||
IFilterIconConfig<IProject> & {
|
||||
projects: IProject[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to get the project multi select config
|
||||
* @param params - The filter params
|
||||
* @returns The member multi select config
|
||||
*/
|
||||
export const getProjectMultiSelectConfig = (
|
||||
params: TCreateProjectFilterParams,
|
||||
singleValueOperator: TSupportedOperators
|
||||
) =>
|
||||
getMultiSelectConfig<IProject, string, IProject>(
|
||||
{
|
||||
items: params.projects,
|
||||
getId: (project) => project.id,
|
||||
getLabel: (project) => project.name,
|
||||
getValue: (project) => project.id,
|
||||
getIconData: (project) => project,
|
||||
},
|
||||
{
|
||||
singleValueOperator,
|
||||
...params,
|
||||
},
|
||||
{
|
||||
...params,
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Custom property filter specific params
|
||||
*/
|
||||
export type TCustomPropertyFilterParams<T extends TFilterIconType> = TCreateFilterConfigParams &
|
||||
IFilterIconConfig<T> & {
|
||||
propertyDisplayName: string;
|
||||
};
|
||||
93
packages/utils/src/rich-filters/factories/configs/shared.ts
Normal file
93
packages/utils/src/rich-filters/factories/configs/shared.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
FILTER_FIELD_TYPE,
|
||||
TBaseFilterFieldConfig,
|
||||
TDateFilterFieldConfig,
|
||||
TDateRangeFilterFieldConfig,
|
||||
TFilterConfig,
|
||||
TFilterProperty,
|
||||
TFilterFieldType,
|
||||
TFilterValue,
|
||||
TMultiSelectFilterFieldConfig,
|
||||
TSingleSelectFilterFieldConfig,
|
||||
TSupportedFilterFieldConfigs,
|
||||
TSupportedOperators,
|
||||
} from "@plane/types";
|
||||
|
||||
/**
|
||||
* Helper to create a type-safe filter config
|
||||
* @param config - The filter config to create
|
||||
* @returns The created filter config
|
||||
*/
|
||||
export const createFilterConfig = <P extends TFilterProperty, V extends TFilterValue>(
|
||||
config: TFilterConfig<P, V>
|
||||
): TFilterConfig<P, V> => config;
|
||||
|
||||
/**
|
||||
* Base parameters for filter type config factory functions.
|
||||
* - operator: The operator to use for the filter.
|
||||
*/
|
||||
export type TCreateFilterConfigParams = Omit<TBaseFilterFieldConfig, "isOperatorEnabled"> & {
|
||||
isEnabled: boolean;
|
||||
allowedOperators: Set<TSupportedOperators>;
|
||||
rightContent?: React.ReactNode; // content to display on the right side of the filter option in the dropdown
|
||||
tooltipContent?: React.ReactNode; // content to display when hovering over the applied filter item in the filter list
|
||||
};
|
||||
|
||||
/**
|
||||
* Type for filter icon type
|
||||
*/
|
||||
export type TFilterIconType = string | number | boolean | object | undefined;
|
||||
|
||||
/**
|
||||
* Icon configuration for filters and their options.
|
||||
* - filterIcon: Optional icon for the filter
|
||||
* - getOptionIcon: Function to get icon for specific option values
|
||||
*/
|
||||
export interface IFilterIconConfig<T extends TFilterIconType = undefined> {
|
||||
filterIcon?: React.FC<React.SVGAttributes<SVGElement>>;
|
||||
getOptionIcon?: (value: T) => React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Date filter config params
|
||||
*/
|
||||
export type TCreateDateFilterParams = TCreateFilterConfigParams & IFilterIconConfig<Date>;
|
||||
|
||||
/**
|
||||
* Helper to create an operator entry for the supported operators map.
|
||||
* This ensures consistency between the operator key and the operator passed to the config function.
|
||||
* @param operator - The operator to use as both key and parameter
|
||||
* @param createParams - The base filter configuration parameters
|
||||
* @param configFn - Function that creates the operator config using base configuration
|
||||
* @returns A tuple of operator and its config
|
||||
*/
|
||||
export const createOperatorConfigEntry = <T, P extends TCreateFilterConfigParams>(
|
||||
operator: TSupportedOperators,
|
||||
createParams: P,
|
||||
configFn: (updatedParams: P) => T
|
||||
): [TSupportedOperators, T] => [
|
||||
operator,
|
||||
configFn({ isOperatorEnabled: createParams.allowedOperators.has(operator), ...createParams }),
|
||||
];
|
||||
|
||||
/**
|
||||
* Factory function signature for creating filter configurations.
|
||||
*/
|
||||
export type TCreateFilterConfig<P extends TFilterProperty, T> = (params: T) => TFilterConfig<P>;
|
||||
|
||||
/**
|
||||
* Helper to create a type-safe filter field config
|
||||
* @param config - The filter field config to create
|
||||
* @returns The created filter field config
|
||||
*/
|
||||
export const createFilterFieldConfig = <T extends TFilterFieldType, V extends TFilterValue>(
|
||||
config: T extends typeof FILTER_FIELD_TYPE.SINGLE_SELECT
|
||||
? TSingleSelectFilterFieldConfig<V>
|
||||
: T extends typeof FILTER_FIELD_TYPE.MULTI_SELECT
|
||||
? TMultiSelectFilterFieldConfig<V>
|
||||
: T extends typeof FILTER_FIELD_TYPE.DATE
|
||||
? TDateFilterFieldConfig<V>
|
||||
: T extends typeof FILTER_FIELD_TYPE.DATE_RANGE
|
||||
? TDateRangeFilterFieldConfig<V>
|
||||
: never
|
||||
): TSupportedFilterFieldConfigs<V> => config as TSupportedFilterFieldConfigs<V>;
|
||||
4
packages/utils/src/rich-filters/factories/index.ts
Normal file
4
packages/utils/src/rich-filters/factories/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./configs/core";
|
||||
export * from "./configs/shared";
|
||||
export * from "./configs/properties";
|
||||
export * from "./nodes/core";
|
||||
39
packages/utils/src/rich-filters/factories/nodes/core.ts
Normal file
39
packages/utils/src/rich-filters/factories/nodes/core.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
// plane imports
|
||||
import {
|
||||
FILTER_NODE_TYPE,
|
||||
LOGICAL_OPERATOR,
|
||||
TFilterAndGroupNode,
|
||||
TFilterConditionNode,
|
||||
TFilterConditionPayload,
|
||||
TFilterExpression,
|
||||
TFilterProperty,
|
||||
TFilterValue,
|
||||
} from "@plane/types";
|
||||
|
||||
/**
|
||||
* Creates a condition node with a unique ID.
|
||||
* @param condition - The condition to create
|
||||
* @returns The created condition node
|
||||
*/
|
||||
export const createConditionNode = <P extends TFilterProperty, V extends TFilterValue>(
|
||||
condition: TFilterConditionPayload<P, V>
|
||||
): TFilterConditionNode<P, V> => ({
|
||||
id: uuidv4(),
|
||||
type: FILTER_NODE_TYPE.CONDITION,
|
||||
...condition,
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates an AND group node with a unique ID.
|
||||
* @param nodes - The nodes to add to the group
|
||||
* @returns The created AND group node
|
||||
*/
|
||||
export const createAndGroupNode = <P extends TFilterProperty>(
|
||||
nodes: TFilterExpression<P>[]
|
||||
): TFilterAndGroupNode<P> => ({
|
||||
id: uuidv4(),
|
||||
type: FILTER_NODE_TYPE.GROUP,
|
||||
logicalOperator: LOGICAL_OPERATOR.AND,
|
||||
children: nodes,
|
||||
});
|
||||
6
packages/utils/src/rich-filters/index.ts
Normal file
6
packages/utils/src/rich-filters/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from "./factories";
|
||||
export * from "./operations";
|
||||
export * from "./operators";
|
||||
export * from "./types";
|
||||
export * from "./validators";
|
||||
export * from "./values";
|
||||
168
packages/utils/src/rich-filters/operations/comparison.ts
Normal file
168
packages/utils/src/rich-filters/operations/comparison.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { compact, isEqual, sortBy } from "lodash-es";
|
||||
// plane imports
|
||||
import {
|
||||
FILTER_NODE_TYPE,
|
||||
TFilterConditionNode,
|
||||
TFilterExpression,
|
||||
TFilterGroupNode,
|
||||
TFilterProperty,
|
||||
TFilterValue,
|
||||
} from "@plane/types";
|
||||
// local imports
|
||||
import { isConditionNode, isGroupNode } from "../types/core";
|
||||
import { processGroupNode } from "../types/shared";
|
||||
import { hasValidValue } from "../validators/core";
|
||||
import { transformExpressionTree } from "./transformation/core";
|
||||
|
||||
/**
|
||||
* Creates a comparable representation of a condition for deep comparison.
|
||||
* This uses property, operator, and value instead of ID for comparison.
|
||||
* IDs are completely excluded to avoid UUID comparison issues.
|
||||
* @param condition - The condition to create a comparable representation for
|
||||
* @returns A comparable object without ID
|
||||
*/
|
||||
const createConditionComparable = <P extends TFilterProperty>(condition: TFilterConditionNode<P, TFilterValue>) => ({
|
||||
// Explicitly exclude: id (random UUID should not be compared)
|
||||
type: condition.type,
|
||||
property: condition.property,
|
||||
operator: condition.operator,
|
||||
value: Array.isArray(condition.value) ? condition.value : [condition.value],
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper function to create comparable children for AND/OR groups.
|
||||
* This eliminates code duplication between AND and OR group processing.
|
||||
*/
|
||||
const createComparableChildren = <P extends TFilterProperty>(
|
||||
children: TFilterExpression<P>[],
|
||||
baseComparable: Record<string, unknown>
|
||||
): Record<string, unknown> => {
|
||||
const childrenComparable = compact(children.map((child) => createExpressionComparable(child)));
|
||||
|
||||
// Sort children by a consistent key for comparison to ensure order doesn't affect equality
|
||||
const sortedChildren = sortBy(childrenComparable, (child) => {
|
||||
if (child?.type === FILTER_NODE_TYPE.CONDITION) {
|
||||
return `condition_${child.property}_${child.operator}_${JSON.stringify(child.value)}`;
|
||||
}
|
||||
// For nested groups, sort by logical operator and recursive structure
|
||||
if (child?.type === FILTER_NODE_TYPE.GROUP) {
|
||||
const childrenCount = child.child ? 1 : Array.isArray(child.children) ? child.children.length : 0;
|
||||
return `group_${child.logicalOperator}_${childrenCount}_${JSON.stringify(child)}`;
|
||||
}
|
||||
return "unknown";
|
||||
});
|
||||
|
||||
return {
|
||||
...baseComparable,
|
||||
children: sortedChildren,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a comparable representation of a group for deep comparison.
|
||||
* This recursively creates comparable representations for all children.
|
||||
* IDs are completely excluded to avoid UUID comparison issues.
|
||||
* Uses processGroupNode for consistent group type handling.
|
||||
* @param group - The group to create a comparable representation for
|
||||
* @returns A comparable object without ID
|
||||
*/
|
||||
export const createGroupComparable = <P extends TFilterProperty>(
|
||||
group: TFilterGroupNode<P>
|
||||
): Record<string, unknown> => {
|
||||
const baseComparable = {
|
||||
// Explicitly exclude: id (random UUID should not be compared)
|
||||
type: group.type,
|
||||
logicalOperator: group.logicalOperator,
|
||||
};
|
||||
|
||||
return processGroupNode(group, {
|
||||
onAndGroup: (andGroup) => createComparableChildren(andGroup.children, baseComparable),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a comparable representation of any filter expression.
|
||||
* Recursively handles deep nesting of groups within groups.
|
||||
* Completely excludes IDs from comparison to avoid UUID issues.
|
||||
* @param expression - The expression to create a comparable representation for
|
||||
* @returns A comparable object without IDs or null if the expression is empty
|
||||
*/
|
||||
export const createExpressionComparable = <P extends TFilterProperty>(
|
||||
expression: TFilterExpression<P> | null
|
||||
): Record<string, unknown> | null => {
|
||||
if (!expression) return null;
|
||||
|
||||
// Handle condition nodes - exclude ID completely
|
||||
if (isConditionNode(expression)) {
|
||||
return createConditionComparable(expression);
|
||||
}
|
||||
|
||||
// Handle group nodes - exclude ID completely and support deep nesting
|
||||
if (isGroupNode(expression)) {
|
||||
return createGroupComparable(expression);
|
||||
}
|
||||
|
||||
// Should never reach here with proper typing, but return null for safety
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes a filter expression by removing empty conditions and groups.
|
||||
* This helps compare expressions by focusing only on meaningful content.
|
||||
* Uses the transformExpressionTree utility for consistent tree processing.
|
||||
* @param expression - The filter expression to normalize
|
||||
* @returns The normalized expression or null if the entire expression is empty
|
||||
*/
|
||||
export const normalizeFilterExpression = <P extends TFilterProperty>(
|
||||
expression: TFilterExpression<P> | null
|
||||
): TFilterExpression<P> | null => {
|
||||
const result = transformExpressionTree<P>(expression, (node: TFilterExpression<P>) => {
|
||||
// Only transform condition nodes - check if they have valid values
|
||||
if (isConditionNode(node)) {
|
||||
return {
|
||||
expression: hasValidValue(node.value) ? node : null,
|
||||
shouldNotify: false,
|
||||
};
|
||||
}
|
||||
// For group nodes, let the generic transformer handle the recursion
|
||||
return { expression: node, shouldNotify: false };
|
||||
});
|
||||
|
||||
return result.expression;
|
||||
};
|
||||
|
||||
/**
|
||||
* Performs a deep comparison of two filter expressions based on their meaningful content.
|
||||
* This comparison completely ignores IDs (UUIDs) and focuses on property, operator, value, and tree structure.
|
||||
* Empty conditions and groups are normalized before comparison.
|
||||
* Supports deep nesting of groups within groups recursively.
|
||||
* @param expression1 - The first expression to compare
|
||||
* @param expression2 - The second expression to compare
|
||||
* @returns True if the expressions are meaningfully equal, false otherwise
|
||||
*/
|
||||
export const deepCompareFilterExpressions = <P extends TFilterProperty>(
|
||||
expression1: TFilterExpression<P> | null,
|
||||
expression2: TFilterExpression<P> | null
|
||||
): boolean => {
|
||||
// Normalize both expressions to remove empty conditions and groups
|
||||
const normalized1 = normalizeFilterExpression(expression1);
|
||||
const normalized2 = normalizeFilterExpression(expression2);
|
||||
|
||||
// If both are null after normalization, they're equal
|
||||
if (!normalized1 && !normalized2) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If one is null and the other isn't, they're different
|
||||
if (!normalized1 || !normalized2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create comparable representations (IDs completely excluded)
|
||||
const comparable1 = createExpressionComparable(normalized1);
|
||||
const comparable2 = createExpressionComparable(normalized2);
|
||||
|
||||
// Deep compare using lodash isEqual for reliable object comparison
|
||||
// This handles deep nesting recursively and ignores UUID differences
|
||||
return isEqual(comparable1, comparable2);
|
||||
};
|
||||
4
packages/utils/src/rich-filters/operations/index.ts
Normal file
4
packages/utils/src/rich-filters/operations/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./comparison";
|
||||
export * from "./manipulation/core";
|
||||
export * from "./transformation/core";
|
||||
export * from "./traversal/core";
|
||||
124
packages/utils/src/rich-filters/operations/manipulation/core.ts
Normal file
124
packages/utils/src/rich-filters/operations/manipulation/core.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
// plane imports
|
||||
import {
|
||||
TFilterConditionPayload,
|
||||
TFilterExpression,
|
||||
TFilterGroupNode,
|
||||
TFilterProperty,
|
||||
TFilterValue,
|
||||
} from "@plane/types";
|
||||
// local imports
|
||||
import { createAndGroupNode } from "../../factories/nodes/core";
|
||||
import { getGroupChildren } from "../../types";
|
||||
import { isAndGroupNode, isConditionNode, isGroupNode } from "../../types/core";
|
||||
import { shouldUnwrapGroup } from "../../validators/shared";
|
||||
import { transformExpressionTree } from "../transformation/core";
|
||||
|
||||
/**
|
||||
* Adds an AND condition to the filter expression.
|
||||
* @param expression - The current filter expression
|
||||
* @param condition - The condition to add
|
||||
* @returns The updated filter expression
|
||||
*/
|
||||
export const addAndCondition = <P extends TFilterProperty>(
|
||||
expression: TFilterExpression<P> | null,
|
||||
condition: TFilterExpression<P>
|
||||
): TFilterExpression<P> => {
|
||||
// if no expression, set the new condition
|
||||
if (!expression) {
|
||||
return condition;
|
||||
}
|
||||
// if the expression is a condition, convert it to an AND group
|
||||
if (isConditionNode(expression)) {
|
||||
return createAndGroupNode([expression, condition]);
|
||||
}
|
||||
// if the expression is a group, and the group is an AND group, add the new condition to the group
|
||||
if (isGroupNode(expression) && isAndGroupNode(expression)) {
|
||||
expression.children.push(condition);
|
||||
return expression;
|
||||
}
|
||||
// if the expression is a group, but not an AND group, create a new AND group and add the new condition to it
|
||||
if (isGroupNode(expression) && !isAndGroupNode(expression)) {
|
||||
return createAndGroupNode([expression, condition]);
|
||||
}
|
||||
// Throw error for unexpected expression type
|
||||
console.error("Invalid expression type", expression);
|
||||
return expression;
|
||||
};
|
||||
|
||||
/**
|
||||
* Replaces a node in the expression tree with another node.
|
||||
* Uses transformExpressionTree for consistent tree processing and better maintainability.
|
||||
* @param expression - The expression tree to search in
|
||||
* @param targetId - The ID of the node to replace
|
||||
* @param replacement - The node to replace with
|
||||
* @returns The updated expression tree
|
||||
*/
|
||||
export const replaceNodeInExpression = <P extends TFilterProperty>(
|
||||
expression: TFilterExpression<P>,
|
||||
targetId: string,
|
||||
replacement: TFilterExpression<P>
|
||||
): TFilterExpression<P> => {
|
||||
const result = transformExpressionTree(expression, (node: TFilterExpression<P>) => {
|
||||
// If this is the node we want to replace, return the replacement
|
||||
if (node.id === targetId) {
|
||||
return {
|
||||
expression: replacement,
|
||||
shouldNotify: false,
|
||||
};
|
||||
}
|
||||
// For all other nodes, let the generic transformer handle the recursion
|
||||
return { expression: node, shouldNotify: false };
|
||||
});
|
||||
|
||||
// Since we're doing a replacement, the result should never be null
|
||||
return result.expression || expression;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates a node in the filter expression.
|
||||
* Uses recursive tree traversal with proper type handling.
|
||||
* @param expression - The filter expression to update
|
||||
* @param targetId - The id of the node to update
|
||||
* @param updates - The updates to apply to the node
|
||||
*/
|
||||
export const updateNodeInExpression = <P extends TFilterProperty>(
|
||||
expression: TFilterExpression<P>,
|
||||
targetId: string,
|
||||
updates: Partial<TFilterConditionPayload<P, TFilterValue>>
|
||||
) => {
|
||||
// Helper function to recursively update nodes
|
||||
const updateNode = (node: TFilterExpression<P>): void => {
|
||||
if (node.id === targetId) {
|
||||
if (!isConditionNode<P, TFilterValue>(node)) {
|
||||
console.warn("updateNodeInExpression: targetId matched a group; ignoring updates");
|
||||
return;
|
||||
}
|
||||
Object.assign(node, updates);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isGroupNode(node)) {
|
||||
const children = getGroupChildren(node);
|
||||
children.forEach((child) => updateNode(child));
|
||||
}
|
||||
};
|
||||
|
||||
updateNode(expression);
|
||||
};
|
||||
|
||||
/**
|
||||
* Unwraps a group if it meets the unwrapping criteria, otherwise returns the group.
|
||||
* @param group - The group node to potentially unwrap
|
||||
* @param preserveNotGroups - Whether to preserve NOT groups even with single children
|
||||
* @returns The unwrapped child or the original group
|
||||
*/
|
||||
export const unwrapGroupIfNeeded = <P extends TFilterProperty>(
|
||||
group: TFilterGroupNode<P>,
|
||||
preserveNotGroups = true
|
||||
) => {
|
||||
if (shouldUnwrapGroup(group, preserveNotGroups)) {
|
||||
const children = getGroupChildren(group);
|
||||
return children[0];
|
||||
}
|
||||
return group;
|
||||
};
|
||||
@@ -0,0 +1,178 @@
|
||||
// plane imports
|
||||
import { TFilterExpression, TFilterGroupNode, TFilterProperty } from "@plane/types";
|
||||
// local imports
|
||||
import { isConditionNode, isGroupNode } from "../../types/core";
|
||||
import { getGroupChildren } from "../../types/shared";
|
||||
import { hasValidValue } from "../../validators/core";
|
||||
import { unwrapGroupIfNeeded } from "../manipulation/core";
|
||||
import { transformGroup } from "./shared";
|
||||
|
||||
/**
|
||||
* Generic tree transformation result type
|
||||
*/
|
||||
export type TTreeTransformResult<P extends TFilterProperty> = {
|
||||
expression: TFilterExpression<P> | null;
|
||||
shouldNotify?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Transform function type for tree processing
|
||||
*/
|
||||
export type TTreeTransformFn<P extends TFilterProperty> = (expression: TFilterExpression<P>) => TTreeTransformResult<P>;
|
||||
|
||||
/**
|
||||
* Generic recursive tree transformer that handles common tree manipulation logic.
|
||||
* This function provides a reusable way to transform expression trees while maintaining
|
||||
* tree integrity, handling group restructuring, and applying stabilization.
|
||||
*
|
||||
* @param expression - The expression to transform
|
||||
* @param transformFn - Function that defines the transformation logic for each node
|
||||
* @returns The transformation result with expression and metadata
|
||||
*/
|
||||
/**
|
||||
* Helper function to create a consistent transformation result for group nodes.
|
||||
* Centralizes the logic for wrapping group expressions and tracking notifications.
|
||||
*/
|
||||
const createGroupTransformResult = <P extends TFilterProperty>(
|
||||
groupExpression: TFilterGroupNode<P> | null,
|
||||
shouldNotify: boolean
|
||||
): TTreeTransformResult<P> => ({
|
||||
expression: groupExpression ? unwrapGroupIfNeeded(groupExpression, true) : null,
|
||||
shouldNotify,
|
||||
});
|
||||
|
||||
/**
|
||||
* Transforms groups with children by processing all children.
|
||||
* Handles child collection, null filtering, and empty group removal.
|
||||
*/
|
||||
export const transformGroupWithChildren = <P extends TFilterProperty>(
|
||||
group: TFilterGroupNode<P>,
|
||||
transformFn: TTreeTransformFn<P>
|
||||
): TTreeTransformResult<P> => {
|
||||
const children = getGroupChildren(group);
|
||||
const transformedChildren: TFilterExpression<P>[] = [];
|
||||
let shouldNotify = false;
|
||||
|
||||
// Transform all children and collect non-null results
|
||||
for (const child of children) {
|
||||
const childResult = transformExpressionTree(child, transformFn);
|
||||
|
||||
if (childResult.shouldNotify) {
|
||||
shouldNotify = true;
|
||||
}
|
||||
|
||||
if (childResult.expression !== null) {
|
||||
transformedChildren.push(childResult.expression);
|
||||
}
|
||||
}
|
||||
|
||||
// If no children remain, remove the entire group
|
||||
if (transformedChildren.length === 0) {
|
||||
return { expression: null, shouldNotify };
|
||||
}
|
||||
|
||||
// Create updated group with transformed children - type-safe without casting
|
||||
const updatedGroup: TFilterGroupNode<P> = {
|
||||
...group,
|
||||
children: transformedChildren,
|
||||
} as TFilterGroupNode<P>;
|
||||
|
||||
return createGroupTransformResult(updatedGroup, shouldNotify);
|
||||
};
|
||||
|
||||
/**
|
||||
* Generic recursive tree transformer that handles common tree manipulation logic.
|
||||
* This function provides a reusable way to transform expression trees while maintaining
|
||||
* tree integrity, handling group restructuring, and applying stabilization.
|
||||
*
|
||||
* @param expression - The expression to transform
|
||||
* @param transformFn - Function that defines the transformation logic for each node
|
||||
* @returns The transformation result with expression and metadata
|
||||
*/
|
||||
export const transformExpressionTree = <P extends TFilterProperty>(
|
||||
expression: TFilterExpression<P> | null,
|
||||
transformFn: TTreeTransformFn<P>
|
||||
): TTreeTransformResult<P> => {
|
||||
// Handle null expressions early
|
||||
if (!expression) {
|
||||
return { expression: null, shouldNotify: false };
|
||||
}
|
||||
|
||||
// Apply the transformation function to the current node
|
||||
const transformResult = transformFn(expression);
|
||||
|
||||
// If the transform function handled this node completely, return its result
|
||||
if (transformResult.expression === null || transformResult.expression !== expression) {
|
||||
return transformResult;
|
||||
}
|
||||
|
||||
// Handle condition nodes (no children to transform)
|
||||
if (isConditionNode(expression)) {
|
||||
return { expression, shouldNotify: false };
|
||||
}
|
||||
|
||||
// Handle group nodes by delegating to the extended transformGroup function
|
||||
if (isGroupNode(expression)) {
|
||||
return transformGroup(expression, transformFn);
|
||||
}
|
||||
|
||||
throw new Error("Unknown expression type in transformExpressionTree");
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes a node from the filter expression.
|
||||
* @param expression - The filter expression to remove the node from
|
||||
* @param targetId - The id of the node to remove
|
||||
* @returns An object containing the updated filter expression and whether to notify about the change
|
||||
*/
|
||||
export const removeNodeFromExpression = <P extends TFilterProperty>(
|
||||
expression: TFilterExpression<P>,
|
||||
targetId: string
|
||||
): { expression: TFilterExpression<P> | null; shouldNotify: boolean } => {
|
||||
const result = transformExpressionTree(expression, (node) => {
|
||||
// If this node matches the target ID, remove it
|
||||
if (node.id === targetId) {
|
||||
const shouldNotify = isConditionNode(node) ? hasValidValue(node.value) : true;
|
||||
return {
|
||||
expression: null,
|
||||
shouldNotify,
|
||||
};
|
||||
}
|
||||
// For all other nodes, let the generic transformer handle the recursion
|
||||
return { expression: node, shouldNotify: false };
|
||||
});
|
||||
|
||||
return {
|
||||
expression: result.expression,
|
||||
shouldNotify: result.shouldNotify || false,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitizes and stabilizes a filter expression by removing invalid conditions and unnecessary groups.
|
||||
* This function performs deep sanitization of the entire expression tree:
|
||||
* 1. Removes condition nodes that don't have valid values
|
||||
* 2. Removes empty groups (groups with no children after sanitization)
|
||||
* 3. Unwraps single-child groups that don't need to be wrapped
|
||||
* 4. Preserves tree integrity and logical operators
|
||||
*
|
||||
* @param expression - The filter expression to sanitize
|
||||
* @returns The sanitized expression or null if no valid conditions remain
|
||||
*/
|
||||
export const sanitizeAndStabilizeExpression = <P extends TFilterProperty>(
|
||||
expression: TFilterExpression<P> | null
|
||||
): TFilterExpression<P> | null => {
|
||||
const result = transformExpressionTree(expression, (node) => {
|
||||
// Only transform condition nodes - check if they have valid values
|
||||
if (isConditionNode(node)) {
|
||||
return {
|
||||
expression: hasValidValue(node.value) ? node : null,
|
||||
shouldNotify: false,
|
||||
};
|
||||
}
|
||||
// For group nodes, let the generic transformer handle the recursion
|
||||
return { expression: node, shouldNotify: false };
|
||||
});
|
||||
|
||||
return result.expression;
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { TFilterGroupNode, TFilterProperty } from "@plane/types";
|
||||
import { processGroupNode } from "../../types/shared";
|
||||
import { transformGroupWithChildren, TTreeTransformFn, TTreeTransformResult } from "./core";
|
||||
|
||||
/**
|
||||
* Transforms groups by processing children.
|
||||
* Handles AND/OR groups with children and NOT groups with single child.
|
||||
* @param group - The group to transform
|
||||
* @param transformFn - The transformation function
|
||||
* @returns The transformation result
|
||||
*/
|
||||
export const transformGroup = <P extends TFilterProperty>(
|
||||
group: TFilterGroupNode<P>,
|
||||
transformFn: TTreeTransformFn<P>
|
||||
): TTreeTransformResult<P> =>
|
||||
processGroupNode(group, {
|
||||
onAndGroup: (andGroup) => transformGroupWithChildren(andGroup, transformFn),
|
||||
});
|
||||
210
packages/utils/src/rich-filters/operations/traversal/core.ts
Normal file
210
packages/utils/src/rich-filters/operations/traversal/core.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
// plane imports
|
||||
import {
|
||||
TAllAvailableOperatorsForDisplay,
|
||||
TFilterConditionNode,
|
||||
TFilterConditionNodeForDisplay,
|
||||
TFilterExpression,
|
||||
TFilterGroupNode,
|
||||
TFilterProperty,
|
||||
TFilterValue,
|
||||
} from "@plane/types";
|
||||
// local imports
|
||||
import { isConditionNode, isGroupNode } from "../../types/core";
|
||||
import { getGroupChildren } from "../../types/shared";
|
||||
import { getDisplayOperator } from "./shared";
|
||||
|
||||
/**
|
||||
* Generic tree visitor function type
|
||||
*/
|
||||
export type TreeVisitorFn<P extends TFilterProperty, T> = (
|
||||
expression: TFilterExpression<P>,
|
||||
parent?: TFilterGroupNode<P>,
|
||||
depth?: number
|
||||
) => T | null;
|
||||
|
||||
/**
|
||||
* Tree traversal modes
|
||||
*/
|
||||
export enum TreeTraversalMode {
|
||||
/** Visit all nodes depth-first */
|
||||
ALL = "ALL",
|
||||
/** Visit only condition nodes */
|
||||
CONDITIONS = "CONDITIONS",
|
||||
/** Visit only group nodes */
|
||||
GROUPS = "GROUPS",
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic tree traversal utility that visits nodes based on the specified mode.
|
||||
* This eliminates code duplication in tree walking functions.
|
||||
*
|
||||
* @param expression - The expression to traverse
|
||||
* @param visitor - Function to call for each visited node
|
||||
* @param mode - Traversal mode to determine which nodes to visit
|
||||
* @param parent - Parent node (used internally for recursion)
|
||||
* @param depth - Current depth (used internally for recursion)
|
||||
* @returns Array of results from the visitor function (nulls are filtered out)
|
||||
*/
|
||||
export const traverseExpressionTree = <P extends TFilterProperty, T>(
|
||||
expression: TFilterExpression<P> | null,
|
||||
visitor: TreeVisitorFn<P, T>,
|
||||
mode: TreeTraversalMode = TreeTraversalMode.ALL,
|
||||
parent?: TFilterGroupNode<P>,
|
||||
depth: number = 0
|
||||
): T[] => {
|
||||
if (!expression) return [];
|
||||
|
||||
const results: T[] = [];
|
||||
|
||||
// Determine if we should visit this node based on the mode
|
||||
const shouldVisit =
|
||||
mode === TreeTraversalMode.ALL ||
|
||||
(mode === TreeTraversalMode.CONDITIONS && isConditionNode(expression)) ||
|
||||
(mode === TreeTraversalMode.GROUPS && isGroupNode(expression));
|
||||
|
||||
if (shouldVisit) {
|
||||
const result = visitor(expression, parent, depth);
|
||||
if (result !== null) {
|
||||
results.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively traverse children for group nodes
|
||||
if (isGroupNode(expression)) {
|
||||
const children = getGroupChildren(expression);
|
||||
for (const child of children) {
|
||||
const childResults = traverseExpressionTree(child, visitor, mode, expression, depth + 1);
|
||||
results.push(...childResults);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds a node by its ID in the filter expression tree.
|
||||
* Uses the generic tree traversal utility for better maintainability.
|
||||
* @param expression - The filter expression to search in
|
||||
* @param targetId - The ID of the node to find
|
||||
* @returns The found node or null if not found
|
||||
*/
|
||||
export const findNodeById = <P extends TFilterProperty>(
|
||||
expression: TFilterExpression<P>,
|
||||
targetId: string
|
||||
): TFilterExpression<P> | null => {
|
||||
const results = traverseExpressionTree(
|
||||
expression,
|
||||
(node) => (node.id === targetId ? node : null),
|
||||
TreeTraversalMode.ALL
|
||||
);
|
||||
|
||||
// Return the first match (there should only be one with unique IDs)
|
||||
return results.length > 0 ? results[0] : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds the parent chain of a given node ID in the filter expression tree.
|
||||
* @param expression - The filter expression to search in
|
||||
* @param targetId - The ID of the node whose parent chain to find
|
||||
* @param currentPath - Current path of parent nodes (used internally for recursion)
|
||||
* @returns Array of parent nodes from immediate parent to root, or null if not found
|
||||
*/
|
||||
export const findParentChain = <P extends TFilterProperty>(
|
||||
expression: TFilterExpression<P>,
|
||||
targetId: string,
|
||||
currentPath: TFilterGroupNode<P>[] = []
|
||||
): TFilterGroupNode<P>[] | null => {
|
||||
// if the expression is a group, search in the children
|
||||
if (isGroupNode(expression)) {
|
||||
const children = getGroupChildren(expression);
|
||||
|
||||
// check if any direct child has the target ID
|
||||
for (const child of children) {
|
||||
if (child.id === targetId) {
|
||||
return [expression, ...currentPath];
|
||||
}
|
||||
}
|
||||
|
||||
// recursively search in child groups
|
||||
for (const child of children) {
|
||||
if (isGroupNode(child)) {
|
||||
const chain = findParentChain(child, targetId, [expression, ...currentPath]);
|
||||
if (chain) return chain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds the immediate parent node of a given node ID.
|
||||
* @param expression - The filter expression to find parent in
|
||||
* @param targetId - The ID of the node whose parent to find
|
||||
* @returns The immediate parent node or null if not found or if the target is the root
|
||||
*/
|
||||
export const findImmediateParent = <P extends TFilterProperty>(
|
||||
expression: TFilterExpression<P>,
|
||||
targetId: string
|
||||
): TFilterGroupNode<P> | null => {
|
||||
// if the expression is null, return null
|
||||
if (!expression) return null;
|
||||
|
||||
// find the parent chain
|
||||
const parentChain = findParentChain(expression, targetId);
|
||||
|
||||
// return the immediate parent if it exists
|
||||
return parentChain && parentChain.length > 0 ? parentChain[0] : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts all conditions from a filter expression.
|
||||
* Uses the generic tree traversal utility for better maintainability and consistency.
|
||||
* @param expression - The filter expression to extract conditions from
|
||||
* @returns An array of filter conditions
|
||||
*/
|
||||
export const extractConditions = <P extends TFilterProperty>(
|
||||
expression: TFilterExpression<P>
|
||||
): TFilterConditionNode<P, TFilterValue>[] =>
|
||||
traverseExpressionTree(
|
||||
expression,
|
||||
(node) => (isConditionNode(node) ? node : null),
|
||||
TreeTraversalMode.CONDITIONS
|
||||
) as TFilterConditionNode<P, TFilterValue>[];
|
||||
|
||||
/**
|
||||
* Extracts all conditions from a filter expression, including their display operators.
|
||||
* @param expression - The filter expression to extract conditions from
|
||||
* @returns An array of filter conditions with their display operators
|
||||
*/
|
||||
export const extractConditionsWithDisplayOperators = <P extends TFilterProperty>(
|
||||
expression: TFilterExpression<P>
|
||||
): TFilterConditionNodeForDisplay<P, TFilterValue>[] => {
|
||||
// First extract all raw conditions
|
||||
const rawConditions = extractConditions(expression);
|
||||
|
||||
// Transform operators using the extended helper
|
||||
return rawConditions.map((condition) => {
|
||||
const displayOperator = getDisplayOperator(condition.operator, expression, condition.id);
|
||||
return {
|
||||
...condition,
|
||||
operator: displayOperator,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds all conditions by property and operator.
|
||||
* @param expression - The filter expression to search in
|
||||
* @param property - The property to find the conditions by
|
||||
* @param operator - The operator to find the conditions by
|
||||
* @returns An array of conditions that match the property and operator
|
||||
*/
|
||||
export const findConditionsByPropertyAndOperator = <P extends TFilterProperty>(
|
||||
expression: TFilterExpression<P>,
|
||||
property: P,
|
||||
operator: TAllAvailableOperatorsForDisplay
|
||||
): TFilterConditionNodeForDisplay<P, TFilterValue>[] => {
|
||||
const conditions = extractConditionsWithDisplayOperators(expression);
|
||||
return conditions.filter((condition) => condition.property === property && condition.operator === operator);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
// plane imports
|
||||
import {
|
||||
TAllAvailableOperatorsForDisplay,
|
||||
TFilterExpression,
|
||||
TFilterProperty,
|
||||
TSupportedOperators,
|
||||
} from "@plane/types";
|
||||
|
||||
/**
|
||||
* Helper function to get the display operator for a condition.
|
||||
* This checks for NOT group context and applies negation if needed.
|
||||
* @param operator - The original operator
|
||||
* @param expression - The filter expression
|
||||
* @param conditionId - The ID of the condition
|
||||
* @returns The display operator (possibly negated)
|
||||
*/
|
||||
export const getDisplayOperator = <P extends TFilterProperty>(
|
||||
operator: TSupportedOperators,
|
||||
_expression: TFilterExpression<P>,
|
||||
_conditionId: string
|
||||
): TAllAvailableOperatorsForDisplay =>
|
||||
// Otherwise, return the operator as-is
|
||||
operator;
|
||||
42
packages/utils/src/rich-filters/operators/core.ts
Normal file
42
packages/utils/src/rich-filters/operators/core.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { get } from "lodash-es";
|
||||
// plane imports
|
||||
import { DATE_OPERATOR_LABELS_MAP, EMPTY_OPERATOR_LABEL, OPERATOR_LABELS_MAP } from "@plane/constants";
|
||||
import {
|
||||
TAllAvailableOperatorsForDisplay,
|
||||
TFilterValue,
|
||||
TAllAvailableDateFilterOperatorsForDisplay,
|
||||
} from "@plane/types";
|
||||
|
||||
// -------- OPERATOR LABEL UTILITIES --------
|
||||
|
||||
/**
|
||||
* Get the label for a filter operator
|
||||
* @param operator - The operator to get the label for
|
||||
* @returns The label for the operator
|
||||
*/
|
||||
export const getOperatorLabel = (operator: TAllAvailableOperatorsForDisplay | undefined): string => {
|
||||
if (!operator) return EMPTY_OPERATOR_LABEL;
|
||||
return get(OPERATOR_LABELS_MAP, operator, EMPTY_OPERATOR_LABEL);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the label for a date filter operator
|
||||
* @param operator - The operator to get the label for
|
||||
* @returns The label for the operator
|
||||
*/
|
||||
export const getDateOperatorLabel = (operator: TAllAvailableDateFilterOperatorsForDisplay | undefined): string => {
|
||||
if (!operator) return EMPTY_OPERATOR_LABEL;
|
||||
return get(DATE_OPERATOR_LABELS_MAP, operator, EMPTY_OPERATOR_LABEL);
|
||||
};
|
||||
|
||||
// -------- OPERATOR TYPE GUARDS --------
|
||||
|
||||
/**
|
||||
* Type guard to check if an operator supports date filter types.
|
||||
* @param operator - The operator to check
|
||||
* @returns True if the operator supports date filters
|
||||
*/
|
||||
export const isDateFilterOperator = <V extends TFilterValue = TFilterValue>(
|
||||
operator: TAllAvailableOperatorsForDisplay
|
||||
): operator is TAllAvailableDateFilterOperatorsForDisplay<V> =>
|
||||
Object.keys(DATE_OPERATOR_LABELS_MAP).includes(operator);
|
||||
2
packages/utils/src/rich-filters/operators/index.ts
Normal file
2
packages/utils/src/rich-filters/operators/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./core";
|
||||
export * from "./shared";
|
||||
24
packages/utils/src/rich-filters/operators/shared.ts
Normal file
24
packages/utils/src/rich-filters/operators/shared.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { TAllAvailableOperatorsForDisplay, TSupportedOperators } from "@plane/types";
|
||||
|
||||
/**
|
||||
* Result type for operator conversion
|
||||
*/
|
||||
export type TOperatorForPayload = {
|
||||
operator: TSupportedOperators;
|
||||
isNegation: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a display operator to the format needed for supported by filter expression condition.
|
||||
* @param displayOperator - The operator from the UI
|
||||
* @returns Object with supported operator and negation flag
|
||||
*/
|
||||
export const getOperatorForPayload = (displayOperator: TAllAvailableOperatorsForDisplay): TOperatorForPayload => {
|
||||
const isNegation = false;
|
||||
const operator = displayOperator;
|
||||
|
||||
return {
|
||||
operator,
|
||||
isNegation,
|
||||
};
|
||||
};
|
||||
68
packages/utils/src/rich-filters/types/core.ts
Normal file
68
packages/utils/src/rich-filters/types/core.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
FILTER_FIELD_TYPE,
|
||||
FILTER_NODE_TYPE,
|
||||
LOGICAL_OPERATOR,
|
||||
TFilterAndGroupNode,
|
||||
TFilterConditionNode,
|
||||
TFilterExpression,
|
||||
TFilterFieldType,
|
||||
TFilterGroupNode,
|
||||
TFilterProperty,
|
||||
TFilterValue,
|
||||
} from "@plane/types";
|
||||
|
||||
/**
|
||||
* Type guard to check if a node is a condition node.
|
||||
* @param node - The node to check
|
||||
* @returns True if the node is a condition node
|
||||
*/
|
||||
export const isConditionNode = <P extends TFilterProperty, V extends TFilterValue>(
|
||||
node: TFilterExpression<P>
|
||||
): node is TFilterConditionNode<P, V> => node.type === FILTER_NODE_TYPE.CONDITION;
|
||||
|
||||
/**
|
||||
* Type guard to check if a node is a group node.
|
||||
* @param node - The node to check
|
||||
* @returns True if the node is a group node
|
||||
*/
|
||||
export const isGroupNode = <P extends TFilterProperty>(node: TFilterExpression<P>): node is TFilterGroupNode<P> =>
|
||||
node.type === FILTER_NODE_TYPE.GROUP;
|
||||
|
||||
/**
|
||||
* Type guard to check if a group node is an AND group.
|
||||
* @param group - The group node to check
|
||||
* @returns True if the group is an AND group
|
||||
*/
|
||||
export const isAndGroupNode = <P extends TFilterProperty>(
|
||||
group: TFilterGroupNode<P>
|
||||
): group is TFilterAndGroupNode<P> => group.logicalOperator === LOGICAL_OPERATOR.AND;
|
||||
|
||||
/**
|
||||
* Type guard to check if a group node has children property
|
||||
* @param group - The group node to check
|
||||
* @returns True if the group has children property
|
||||
*/
|
||||
export const hasChildrenProperty = <P extends TFilterProperty>(
|
||||
group: TFilterGroupNode<P>
|
||||
): group is TFilterAndGroupNode<P> => {
|
||||
const groupWithChildren = group as { children?: unknown };
|
||||
return "children" in group && Array.isArray(groupWithChildren.children);
|
||||
};
|
||||
|
||||
/**
|
||||
* Safely gets the children array from an AND group node.
|
||||
* @param group - The AND group node
|
||||
* @returns The children array
|
||||
*/
|
||||
export const getAndGroupChildren = <P extends TFilterProperty>(group: TFilterAndGroupNode<P>): TFilterExpression<P>[] =>
|
||||
group.children;
|
||||
|
||||
/**
|
||||
* Type guard to check if a filter type is a date filter type.
|
||||
* @param type - The filter type to check
|
||||
* @returns True if the filter type is a date filter type
|
||||
*/
|
||||
export const isDateFilterType = (
|
||||
type: TFilterFieldType
|
||||
): type is typeof FILTER_FIELD_TYPE.DATE | typeof FILTER_FIELD_TYPE.DATE_RANGE =>
|
||||
type === FILTER_FIELD_TYPE.DATE || type === FILTER_FIELD_TYPE.DATE_RANGE;
|
||||
2
packages/utils/src/rich-filters/types/index.ts
Normal file
2
packages/utils/src/rich-filters/types/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./core";
|
||||
export * from "./shared";
|
||||
35
packages/utils/src/rich-filters/types/shared.ts
Normal file
35
packages/utils/src/rich-filters/types/shared.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// plane imports
|
||||
import { TFilterAndGroupNode, TFilterExpression, TFilterGroupNode, TFilterProperty } from "@plane/types";
|
||||
// local imports
|
||||
import { getAndGroupChildren, isAndGroupNode } from "./core";
|
||||
|
||||
type TProcessGroupNodeHandlers<P extends TFilterProperty, T> = {
|
||||
onAndGroup: (group: TFilterAndGroupNode<P>) => T;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generic helper to process group nodes with type-safe handlers.
|
||||
* @param group - The group node to process
|
||||
* @param handlers - Object with handlers for each group type
|
||||
* @returns Result of the appropriate handler
|
||||
*/
|
||||
export const processGroupNode = <P extends TFilterProperty, T>(
|
||||
group: TFilterGroupNode<P>,
|
||||
handlers: TProcessGroupNodeHandlers<P, T>
|
||||
): T => {
|
||||
if (isAndGroupNode(group)) {
|
||||
return handlers.onAndGroup(group);
|
||||
}
|
||||
throw new Error(`Invalid group node: unknown logical operator ${group}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the children of a group node, handling AND/OR groups (children array) and NOT groups (single child).
|
||||
* Uses processGroupNode for consistent group type handling.
|
||||
* @param group - The group node to get children from
|
||||
* @returns Array of child expressions
|
||||
*/
|
||||
export const getGroupChildren = <P extends TFilterProperty>(group: TFilterGroupNode<P>): TFilterExpression<P>[] =>
|
||||
processGroupNode(group, {
|
||||
onAndGroup: (andGroup) => getAndGroupChildren(andGroup),
|
||||
});
|
||||
52
packages/utils/src/rich-filters/validators/core.ts
Normal file
52
packages/utils/src/rich-filters/validators/core.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// plane imports
|
||||
import { SingleOrArray, TFilterExpression, TFilterProperty, TFilterValue } from "@plane/types";
|
||||
// local imports
|
||||
import { getGroupChildren } from "../types";
|
||||
import { isConditionNode, isGroupNode } from "../types/core";
|
||||
|
||||
/**
|
||||
* Determines whether to notify about a change based on the filter value.
|
||||
* @param value - The filter value to check
|
||||
* @returns True if we should notify, false otherwise
|
||||
*/
|
||||
export const hasValidValue = (value: SingleOrArray<TFilterValue>): boolean => {
|
||||
if (value === null || value === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If it's an array, check if it's empty or contains only null/undefined values
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return value.some((v) => v !== null && v !== undefined);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines whether to notify about a change based on the entire filter expression.
|
||||
* @param expression - The filter expression to check
|
||||
* @returns True if we should notify, false otherwise
|
||||
*/
|
||||
export const shouldNotifyChangeForExpression = <P extends TFilterProperty>(
|
||||
expression: TFilterExpression<P> | null
|
||||
): boolean => {
|
||||
if (!expression) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If it's a condition, check its value
|
||||
if (isConditionNode(expression)) {
|
||||
return hasValidValue(expression.value);
|
||||
}
|
||||
|
||||
// If it's a group, check if any of its children have meaningful values
|
||||
if (isGroupNode(expression)) {
|
||||
const children = getGroupChildren(expression);
|
||||
return children.some((child) => shouldNotifyChangeForExpression(child));
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
2
packages/utils/src/rich-filters/validators/index.ts
Normal file
2
packages/utils/src/rich-filters/validators/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./core";
|
||||
export * from "./shared";
|
||||
22
packages/utils/src/rich-filters/validators/shared.ts
Normal file
22
packages/utils/src/rich-filters/validators/shared.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// plane imports
|
||||
import { TFilterGroupNode, TFilterProperty } from "@plane/types";
|
||||
// local imports
|
||||
import { getGroupChildren } from "../types/shared";
|
||||
|
||||
/**
|
||||
* Determines if a group should be unwrapped based on the number of children and group type.
|
||||
* @param group - The group node to check
|
||||
* @param preserveNotGroups - Whether to preserve NOT groups even with single children
|
||||
* @returns True if the group should be unwrapped, false otherwise
|
||||
*/
|
||||
export const shouldUnwrapGroup = <P extends TFilterProperty>(group: TFilterGroupNode<P>, _preserveNotGroups = true) => {
|
||||
const children = getGroupChildren(group);
|
||||
|
||||
// Never unwrap groups with multiple children
|
||||
if (children.length !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Unwrap AND/OR groups with single children, and NOT groups if preserveNotGroups is false
|
||||
return true;
|
||||
};
|
||||
24
packages/utils/src/rich-filters/values/core.ts
Normal file
24
packages/utils/src/rich-filters/values/core.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { SingleOrArray, TFilterValue } from "@plane/types";
|
||||
|
||||
/**
|
||||
* Converts any value to a non-null array for UI components that expect arrays
|
||||
* Returns empty array for null/undefined values
|
||||
*/
|
||||
export const toFilterArray = <V extends TFilterValue>(value: SingleOrArray<V>): NonNullable<V>[] => {
|
||||
if (value === null || value === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.isArray(value) ? (value as NonNullable<V>[]) : ([value] as NonNullable<V>[]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the length of a filter value
|
||||
*/
|
||||
export const getFilterValueLength = <V extends TFilterValue>(value: SingleOrArray<V>): number => {
|
||||
if (value === null || value === undefined) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Array.isArray(value) ? value.length : 1;
|
||||
};
|
||||
1
packages/utils/src/rich-filters/values/index.ts
Normal file
1
packages/utils/src/rich-filters/values/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./core";
|
||||
8
packages/utils/src/router.ts
Normal file
8
packages/utils/src/router.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const generateQueryParams = (searchParams: URLSearchParams, excludedParamKeys?: string[]): string => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
excludedParamKeys &&
|
||||
excludedParamKeys.forEach((key) => {
|
||||
params.delete(key);
|
||||
});
|
||||
return params.toString();
|
||||
};
|
||||
427
packages/utils/src/string.ts
Normal file
427
packages/utils/src/string.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
import DOMPurify from "dompurify";
|
||||
import type { Content, JSONContent } from "@plane/types";
|
||||
|
||||
/**
|
||||
* @description Adds space between camelCase words
|
||||
* @param {string} str - String to add spaces to
|
||||
* @returns {string} String with spaces between camelCase words
|
||||
* @example
|
||||
* addSpaceIfCamelCase("camelCase") // returns "camel Case"
|
||||
* addSpaceIfCamelCase("thisIsATest") // returns "this Is A Test"
|
||||
*/
|
||||
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");
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Replaces underscores with spaces in snake_case strings
|
||||
* @param {string} str - String to replace underscores in
|
||||
* @returns {string} String with underscores replaced by spaces
|
||||
* @example
|
||||
* replaceUnderscoreIfSnakeCase("snake_case") // returns "snake case"
|
||||
*/
|
||||
export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " ");
|
||||
|
||||
/**
|
||||
* @description Truncates text to specified length and adds ellipsis
|
||||
* @param {string} str - String to truncate
|
||||
* @param {number} length - Maximum length before truncation
|
||||
* @returns {string} Truncated string with ellipsis if needed
|
||||
* @example
|
||||
* truncateText("This is a long text", 7) // returns "This is..."
|
||||
*/
|
||||
export const truncateText = (str: string, length: number) => {
|
||||
if (!str || str === "") return "";
|
||||
|
||||
return str.length > length ? `${str.substring(0, length)}...` : str;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Creates a similar string by randomly shuffling characters
|
||||
* @param {string} str - String to shuffle
|
||||
* @returns {string} Shuffled string with same characters
|
||||
* @example
|
||||
* createSimilarString("hello") // might return "olleh" or "lehol"
|
||||
*/
|
||||
export const createSimilarString = (str: string) => {
|
||||
const shuffled = str
|
||||
.split("")
|
||||
.sort(() => Math.random() - 0.5)
|
||||
.join("");
|
||||
|
||||
return shuffled;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Copies full URL (origin + path) to clipboard
|
||||
* @param {string} path - URL path to copy
|
||||
* @returns {Promise<void>} Promise that resolves when copying is complete
|
||||
* @example
|
||||
* await copyUrlToClipboard("issues/123") // copies "https://example.com/issues/123"
|
||||
*/
|
||||
export const copyUrlToClipboard = async (path: string) => {
|
||||
// get origin or default to empty string if not in browser
|
||||
const originUrl = typeof window !== "undefined" ? window.location.origin : "";
|
||||
// create URL object and ensure proper path formatting
|
||||
const url = new URL(path, originUrl);
|
||||
await copyTextToClipboard(url.toString());
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Gets first character of first word or first characters of first two words
|
||||
* @param {string} str - Input string
|
||||
* @returns {string} First character(s)
|
||||
* @example
|
||||
* getFirstCharacters("John") // returns "J"
|
||||
* getFirstCharacters("John Doe") // returns "JD"
|
||||
*/
|
||||
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 Formats number count, showing "99+" for numbers over 99
|
||||
* @param {number} number - Number to format
|
||||
* @returns {string} Formatted number string
|
||||
* @example
|
||||
* getNumberCount(50) // returns "50"
|
||||
* getNumberCount(100) // returns "99+"
|
||||
*/
|
||||
export const getNumberCount = (number: number): string => {
|
||||
if (number > 99) {
|
||||
return "99+";
|
||||
}
|
||||
return number.toString();
|
||||
};
|
||||
|
||||
/**
|
||||
* @description: This function will capitalize the first letter of a string
|
||||
* @param str String
|
||||
* @returns String
|
||||
*/
|
||||
export const capitalizeFirstLetter = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
|
||||
|
||||
/**
|
||||
* @description : This function will remove all the HTML tags from the string
|
||||
* @param {string} htmlString
|
||||
* @return {string}
|
||||
* @example :
|
||||
* const html = "<p>Some text</p>";
|
||||
const text = stripHTML(html);
|
||||
console.log(text); // Some text
|
||||
*/
|
||||
export const sanitizeHTML = (htmlString: string) => {
|
||||
const sanitizedText = DOMPurify.sanitize(htmlString, { ALLOWED_TAGS: [] }); // sanitize the string to remove all HTML tags
|
||||
return sanitizedText.trim(); // trim the string to remove leading and trailing whitespaces
|
||||
};
|
||||
|
||||
/**
|
||||
* @description: This function will remove all the HTML tags from the string and truncate the string to the specified length
|
||||
* @param {string} html
|
||||
* @param {number} length
|
||||
* @return {string}
|
||||
* @example:
|
||||
* const html = "<p>Some text</p>";
|
||||
* const text = stripAndTruncateHTML(html);
|
||||
* console.log(text); // Some text
|
||||
*/
|
||||
export const stripAndTruncateHTML = (html: string, length: number = 55) => truncateText(sanitizeHTML(html), length);
|
||||
|
||||
/**
|
||||
* @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 checkEmailValidity("hello world") => false
|
||||
* @example checkEmailValidity("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 DOMPurify
|
||||
const cleanText = DOMPurify.sanitize(htmlString, { ALLOWED_TAGS: allowedHTMLTags });
|
||||
// Trim the string and check if it's empty
|
||||
return cleanText.trim() === "";
|
||||
};
|
||||
|
||||
/**
|
||||
* @description
|
||||
* Check if a JSONContent object is empty
|
||||
* @param {JSONContent} content
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isJSONContentEmpty = (content: JSONContent | undefined): boolean => {
|
||||
// If it has text, check if text is meaningful
|
||||
if (!content) {
|
||||
return true;
|
||||
}
|
||||
if (content.text !== undefined) {
|
||||
return !content.text || content.text.trim() === "";
|
||||
}
|
||||
|
||||
// If it has no content array, consider it empty
|
||||
if (!content.content || content.content.length === 0) {
|
||||
// Special case: empty paragraph nodes should be considered empty
|
||||
if (content.type === "paragraph" || content.type === "doc") {
|
||||
return true;
|
||||
}
|
||||
// For other node types without content (like hard breaks), check if they're meaningful
|
||||
return (
|
||||
content.type !== "hardBreak" &&
|
||||
content.type !== "image" &&
|
||||
content.type !== "mention-component" &&
|
||||
content.type !== "image-component"
|
||||
);
|
||||
}
|
||||
|
||||
// Check if all nested content is empty
|
||||
return content.content.every(isJSONContentEmpty);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description
|
||||
* This function will check if the comment is empty or not.
|
||||
* It returns true if comment is empty.
|
||||
* Now supports TipTap Content types (HTMLContent, JSONContent, JSONContent[], null)
|
||||
*
|
||||
* For HTML content:
|
||||
* 1. If comment is undefined/null
|
||||
* 2. If comment is an empty string
|
||||
* 3. If comment is "<p></p>"
|
||||
* 4. If comment contains only empty HTML tags
|
||||
*
|
||||
* For JSON content:
|
||||
* 1. If content is null/undefined
|
||||
* 2. If content has no meaningful text or nested content
|
||||
* 3. If all nested content is empty
|
||||
*
|
||||
* @param {Content} comment - TipTap Content type
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isCommentEmpty = (comment: Content | undefined): boolean => {
|
||||
// Handle null/undefined
|
||||
if (!comment) return true;
|
||||
|
||||
// Handle HTMLContent (string)
|
||||
if (typeof comment === "string") {
|
||||
return (
|
||||
comment.trim() === "" ||
|
||||
comment === "<p></p>" ||
|
||||
isEmptyHtmlString(comment, ["img", "mention-component", "image-component"])
|
||||
);
|
||||
}
|
||||
|
||||
// Handle JSONContent[] (array)
|
||||
if (Array.isArray(comment)) {
|
||||
return comment.length === 0 || comment.every(isJSONContentEmpty);
|
||||
}
|
||||
|
||||
// Handle JSONContent (object)
|
||||
return isJSONContentEmpty(comment);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description
|
||||
* Legacy function for backward compatibility with string comments
|
||||
* @param {string | undefined} comment
|
||||
* @returns {boolean}
|
||||
* @deprecated Use isCommentEmpty with Content type instead
|
||||
*/
|
||||
export const isStringCommentEmpty = (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", "embed-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);
|
||||
};
|
||||
|
||||
/**
|
||||
* Combines array elements with a separator and adds a conjunction before the last element
|
||||
* @param array Array of strings to combine
|
||||
* @param separator Separator to use between elements (default: ", ")
|
||||
* @param conjunction Conjunction to use before last element (default: "and")
|
||||
* @returns Combined string with conjunction before the last element
|
||||
*/
|
||||
export const joinWithConjunction = (array: string[], separator: string = ", ", conjunction: string = "and"): string => {
|
||||
if (!array || array.length === 0) return "";
|
||||
if (array.length === 1) return array[0];
|
||||
if (array.length === 2) return `${array[0]} ${conjunction} ${array[1]}`;
|
||||
|
||||
const lastElement = array[array.length - 1];
|
||||
const elementsExceptLast = array.slice(0, -1);
|
||||
|
||||
return `${elementsExceptLast.join(separator)}${separator}${conjunction} ${lastElement}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Ensures a URL has a protocol
|
||||
* @param {string} url
|
||||
* @returns {string}
|
||||
* @example
|
||||
* ensureUrlHasProtocol("example.com") => "http://example.com"
|
||||
*/
|
||||
export const ensureUrlHasProtocol = (url: string): string => (url.startsWith("http") ? url : `http://${url}`);
|
||||
|
||||
/**
|
||||
* @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 (_err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Copies text to clipboard
|
||||
* @param {string} text - Text to copy
|
||||
* @returns {Promise<void>} Promise that resolves when copying is complete
|
||||
* @example
|
||||
* await copyTextToClipboard("Hello, World!") // copies "Hello, World!" to clipboard
|
||||
*/
|
||||
const fallbackCopyTextToClipboard = (text: string) => {
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = text;
|
||||
|
||||
// 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);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Copies text to clipboard
|
||||
* @param {string} text - Text to copy
|
||||
* @returns {Promise<void>} Promise that resolves when copying is complete
|
||||
* @example
|
||||
* await copyTextToClipboard("Hello, World!") // copies "Hello, World!" to clipboard
|
||||
*/
|
||||
export const copyTextToClipboard = async (text: string): Promise<void> => {
|
||||
if (!navigator.clipboard) {
|
||||
fallbackCopyTextToClipboard(text);
|
||||
return;
|
||||
}
|
||||
await navigator.clipboard.writeText(text);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Joins URL path segments properly, removing duplicate slashes using URL encoding
|
||||
* @param {...string} segments - URL path segments to join
|
||||
* @returns {string} Properly joined URL path
|
||||
* @example
|
||||
* joinUrlPath("/workspace", "/projects") => "/workspace/projects"
|
||||
* joinUrlPath("/workspace", "projects") => "/workspace/projects"
|
||||
* joinUrlPath("workspace", "projects") => "/workspace/projects"
|
||||
* joinUrlPath("/workspace/", "/projects/") => "/workspace/projects/"
|
||||
*/
|
||||
export const joinUrlPath = (...segments: string[]): string => {
|
||||
if (segments.length === 0) return "";
|
||||
|
||||
// Filter out empty segments
|
||||
const validSegments = segments.filter((segment) => segment !== "");
|
||||
if (validSegments.length === 0) return "";
|
||||
|
||||
// Process segments to normalize slashes
|
||||
const processedSegments = validSegments.map((segment, index) => {
|
||||
let processed = segment;
|
||||
|
||||
// Remove leading slashes from all segments except the first
|
||||
while (processed.startsWith("/")) {
|
||||
processed = processed.substring(1);
|
||||
}
|
||||
|
||||
// Remove trailing slashes from all segments except the last
|
||||
if (index < validSegments.length - 1) {
|
||||
while (processed.endsWith("/")) {
|
||||
processed = processed.substring(0, processed.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
return processed;
|
||||
});
|
||||
|
||||
// Join segments with single slash
|
||||
const joined = processedSegments.join("/");
|
||||
|
||||
// Use URL constructor to normalize the path and handle double slashes
|
||||
try {
|
||||
// Create a dummy URL to leverage browser's URL normalization
|
||||
const dummyUrl = new URL(`http://example.com/${joined}`);
|
||||
return dummyUrl.pathname;
|
||||
} catch {
|
||||
// Fallback: manually handle double slashes by splitting and filtering
|
||||
const pathParts = joined.split("/").filter((part) => part !== "");
|
||||
return pathParts.length > 0 ? `/${pathParts.join("/")}` : "";
|
||||
}
|
||||
};
|
||||
98
packages/utils/src/subscription.ts
Normal file
98
packages/utils/src/subscription.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { orderBy } from "lodash-es";
|
||||
// plane imports
|
||||
import { EProductSubscriptionEnum, IPaymentProduct, TProductSubscriptionType, TSubscriptionPrice } from "@plane/types";
|
||||
|
||||
/**
|
||||
* Calculates the yearly discount percentage when switching from monthly to yearly billing
|
||||
* @param monthlyPrice - The monthly subscription price
|
||||
* @param yearlyPricePerMonth - The monthly equivalent price when billed yearly
|
||||
* @returns The discount percentage as a whole number (floored)
|
||||
*/
|
||||
export const calculateYearlyDiscount = (monthlyPrice: number, yearlyPricePerMonth: number): number => {
|
||||
const monthlyCost = monthlyPrice * 12;
|
||||
const yearlyCost = yearlyPricePerMonth * 12;
|
||||
const amountSaved = monthlyCost - yearlyCost;
|
||||
const discountPercentage = (amountSaved / monthlyCost) * 100;
|
||||
return Math.floor(discountPercentage);
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the display name for a subscription plan variant
|
||||
* @param planVariant - The subscription plan variant enum
|
||||
* @returns The human-readable name of the plan
|
||||
*/
|
||||
export const getSubscriptionName = (planVariant: EProductSubscriptionEnum): string => {
|
||||
switch (planVariant) {
|
||||
case EProductSubscriptionEnum.FREE:
|
||||
return "Free";
|
||||
case EProductSubscriptionEnum.ONE:
|
||||
return "One";
|
||||
case EProductSubscriptionEnum.PRO:
|
||||
return "Pro";
|
||||
case EProductSubscriptionEnum.BUSINESS:
|
||||
return "Business";
|
||||
case EProductSubscriptionEnum.ENTERPRISE:
|
||||
return "Enterprise";
|
||||
default:
|
||||
return "--";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the base subscription name for upgrade/downgrade paths
|
||||
* @param planVariant - The current subscription plan variant
|
||||
* @returns The name of the base subscription plan
|
||||
*/
|
||||
export const getBaseSubscriptionName = (planVariant: TProductSubscriptionType): string => {
|
||||
switch (planVariant) {
|
||||
case EProductSubscriptionEnum.ONE:
|
||||
return getSubscriptionName(EProductSubscriptionEnum.FREE);
|
||||
case EProductSubscriptionEnum.PRO:
|
||||
return getSubscriptionName(EProductSubscriptionEnum.FREE);
|
||||
case EProductSubscriptionEnum.BUSINESS:
|
||||
return getSubscriptionName(EProductSubscriptionEnum.PRO);
|
||||
case EProductSubscriptionEnum.ENTERPRISE:
|
||||
return getSubscriptionName(EProductSubscriptionEnum.BUSINESS);
|
||||
default:
|
||||
return "--";
|
||||
}
|
||||
};
|
||||
|
||||
export type TSubscriptionPriceDetail = {
|
||||
monthlyPriceDetails: TSubscriptionPrice;
|
||||
yearlyPriceDetails: TSubscriptionPrice;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the price details for a subscription product
|
||||
* @param product - The payment product to get price details for
|
||||
* @returns Array of price details for monthly and yearly plans
|
||||
*/
|
||||
export const getSubscriptionPriceDetails = (product: IPaymentProduct | undefined): TSubscriptionPriceDetail => {
|
||||
const productPrices = product?.prices || [];
|
||||
const monthlyPriceDetails = orderBy(productPrices, ["recurring"], ["desc"])?.find(
|
||||
(price) => price.recurring === "month"
|
||||
);
|
||||
const monthlyPriceAmount = Number(((monthlyPriceDetails?.unit_amount || 0) / 100).toFixed(2));
|
||||
const yearlyPriceDetails = orderBy(productPrices, ["recurring"], ["desc"])?.find(
|
||||
(price) => price.recurring === "year"
|
||||
);
|
||||
const yearlyPriceAmount = Number(((yearlyPriceDetails?.unit_amount || 0) / 1200).toFixed(2));
|
||||
|
||||
return {
|
||||
monthlyPriceDetails: {
|
||||
key: "monthly",
|
||||
id: monthlyPriceDetails?.id,
|
||||
currency: "$",
|
||||
price: monthlyPriceAmount,
|
||||
recurring: "month",
|
||||
},
|
||||
yearlyPriceDetails: {
|
||||
key: "yearly",
|
||||
id: yearlyPriceDetails?.id,
|
||||
currency: "$",
|
||||
price: yearlyPriceAmount,
|
||||
recurring: "year",
|
||||
},
|
||||
};
|
||||
};
|
||||
11
packages/utils/src/tab-indices.ts
Normal file
11
packages/utils/src/tab-indices.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// plane imports
|
||||
import { ETabIndices, TAB_INDEX_MAP } from "@plane/constants";
|
||||
|
||||
export const getTabIndex = (type?: ETabIndices, isMobile: boolean = false) => {
|
||||
const getIndex = (key: string) =>
|
||||
isMobile ? undefined : type && TAB_INDEX_MAP[type].findIndex((tabIndex) => tabIndex === key) + 1;
|
||||
|
||||
const baseTabIndex = isMobile ? -1 : 1;
|
||||
|
||||
return { getIndex, baseTabIndex };
|
||||
};
|
||||
124
packages/utils/src/theme.ts
Normal file
124
packages/utils/src/theme.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
// local imports
|
||||
import { TRgb, hexToRgb } from "./color";
|
||||
|
||||
type TShades = {
|
||||
10: TRgb;
|
||||
20: TRgb;
|
||||
30: TRgb;
|
||||
40: TRgb;
|
||||
50: TRgb;
|
||||
60: TRgb;
|
||||
70: TRgb;
|
||||
80: TRgb;
|
||||
90: TRgb;
|
||||
100: TRgb;
|
||||
200: TRgb;
|
||||
300: TRgb;
|
||||
400: TRgb;
|
||||
500: TRgb;
|
||||
600: TRgb;
|
||||
700: TRgb;
|
||||
800: TRgb;
|
||||
900: TRgb;
|
||||
};
|
||||
|
||||
const calculateShades = (hexValue: string): TShades => {
|
||||
const shades: Partial<TShades> = {};
|
||||
const { r, g, b } = hexToRgb(hexValue);
|
||||
|
||||
const convertHexToSpecificShade = (shade: number): TRgb => {
|
||||
if (shade <= 100) {
|
||||
const decimalValue = (100 - shade) / 100;
|
||||
|
||||
const newR = Math.floor(r + (255 - r) * decimalValue);
|
||||
const newG = Math.floor(g + (255 - g) * decimalValue);
|
||||
const newB = Math.floor(b + (255 - b) * decimalValue);
|
||||
|
||||
return {
|
||||
r: newR,
|
||||
g: newG,
|
||||
b: newB,
|
||||
};
|
||||
} else {
|
||||
const decimalValue = 1 - Math.ceil((shade - 100) / 100) / 10;
|
||||
|
||||
const newR = Math.ceil(r * decimalValue);
|
||||
const newG = Math.ceil(g * decimalValue);
|
||||
const newB = Math.ceil(b * decimalValue);
|
||||
|
||||
return {
|
||||
r: newR,
|
||||
g: newG,
|
||||
b: newB,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 10; i <= 900; i >= 100 ? (i += 100) : (i += 10))
|
||||
shades[i as keyof TShades] = convertHexToSpecificShade(i);
|
||||
|
||||
return shades as TShades;
|
||||
};
|
||||
|
||||
export const applyTheme = (palette: string, isDarkPalette: boolean) => {
|
||||
if (!palette) return;
|
||||
const themeElement = document?.querySelector("html");
|
||||
// palette: [bg, text, primary, sidebarBg, sidebarText]
|
||||
const values: string[] = palette.split(",");
|
||||
values.push(isDarkPalette ? "dark" : "light");
|
||||
|
||||
const bgShades = calculateShades(values[0]);
|
||||
const textShades = calculateShades(values[1]);
|
||||
const primaryShades = calculateShades(values[2]);
|
||||
const sidebarBackgroundShades = calculateShades(values[3]);
|
||||
const sidebarTextShades = calculateShades(values[4]);
|
||||
|
||||
for (let i = 10; i <= 900; i >= 100 ? (i += 100) : (i += 10)) {
|
||||
const shade = i as keyof TShades;
|
||||
|
||||
const bgRgbValues = `${bgShades[shade].r}, ${bgShades[shade].g}, ${bgShades[shade].b}`;
|
||||
const textRgbValues = `${textShades[shade].r}, ${textShades[shade].g}, ${textShades[shade].b}`;
|
||||
const primaryRgbValues = `${primaryShades[shade].r}, ${primaryShades[shade].g}, ${primaryShades[shade].b}`;
|
||||
const sidebarBackgroundRgbValues = `${sidebarBackgroundShades[shade].r}, ${sidebarBackgroundShades[shade].g}, ${sidebarBackgroundShades[shade].b}`;
|
||||
const sidebarTextRgbValues = `${sidebarTextShades[shade].r}, ${sidebarTextShades[shade].g}, ${sidebarTextShades[shade].b}`;
|
||||
|
||||
themeElement?.style.setProperty(`--color-background-${shade}`, bgRgbValues);
|
||||
themeElement?.style.setProperty(`--color-text-${shade}`, textRgbValues);
|
||||
themeElement?.style.setProperty(`--color-primary-${shade}`, primaryRgbValues);
|
||||
themeElement?.style.setProperty(`--color-sidebar-background-${shade}`, sidebarBackgroundRgbValues);
|
||||
themeElement?.style.setProperty(`--color-sidebar-text-${shade}`, sidebarTextRgbValues);
|
||||
|
||||
if (i >= 100 && i <= 400) {
|
||||
const borderShade = i === 100 ? 70 : i === 200 ? 80 : i === 300 ? 90 : 100;
|
||||
|
||||
themeElement?.style.setProperty(
|
||||
`--color-border-${shade}`,
|
||||
`${bgShades[borderShade].r}, ${bgShades[borderShade].g}, ${bgShades[borderShade].b}`
|
||||
);
|
||||
themeElement?.style.setProperty(
|
||||
`--color-sidebar-border-${shade}`,
|
||||
`${sidebarBackgroundShades[borderShade].r}, ${sidebarBackgroundShades[borderShade].g}, ${sidebarBackgroundShades[borderShade].b}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
themeElement?.style.setProperty("--color-scheme", values[5]);
|
||||
};
|
||||
|
||||
export const unsetCustomCssVariables = () => {
|
||||
for (let i = 10; i <= 900; i >= 100 ? (i += 100) : (i += 10)) {
|
||||
const dom = document.querySelector<HTMLElement>("[data-theme='custom']");
|
||||
|
||||
dom?.style.removeProperty(`--color-background-${i}`);
|
||||
dom?.style.removeProperty(`--color-text-${i}`);
|
||||
dom?.style.removeProperty(`--color-border-${i}`);
|
||||
dom?.style.removeProperty(`--color-primary-${i}`);
|
||||
dom?.style.removeProperty(`--color-sidebar-background-${i}`);
|
||||
dom?.style.removeProperty(`--color-sidebar-text-${i}`);
|
||||
dom?.style.removeProperty(`--color-sidebar-border-${i}`);
|
||||
dom?.style.removeProperty("--color-scheme");
|
||||
}
|
||||
};
|
||||
|
||||
export const resolveGeneralTheme = (resolvedTheme: string | undefined) =>
|
||||
resolvedTheme?.includes("light") ? "light" : resolvedTheme?.includes("dark") ? "dark" : "system";
|
||||
1441
packages/utils/src/tlds.ts
Normal file
1441
packages/utils/src/tlds.ts
Normal file
File diff suppressed because it is too large
Load Diff
324
packages/utils/src/url.ts
Normal file
324
packages/utils/src/url.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import tlds from "./tlds";
|
||||
|
||||
const PROTOCOL_REGEX = /^[a-zA-Z]+:\/\//;
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const LOCALHOST_ADDRESSES = ["localhost", "127.0.0.1", "0.0.0.0"];
|
||||
const HTTP_PROTOCOL = "http://";
|
||||
const MAILTO_PROTOCOL = "mailto:";
|
||||
const DEFAULT_PROTOCOL = HTTP_PROTOCOL;
|
||||
// IPv4 regex - matches 0.0.0.0 to 255.255.255.255
|
||||
const IPV4_REGEX = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
// IPv6 regex - comprehensive pattern for all IPv6 formats
|
||||
const IPV6_REGEX =
|
||||
/^(?:(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,7}:|(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:(?::[0-9a-fA-F]{1,4}){1,6}|:(?::[0-9a-fA-F]{1,4}){1,7}|::|fe80:(?::[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(?:ffff(?::0{1,4}){0,1}:){0,1}(?:(?:25[0-5]|(?:2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(?:25[0-5]|(?:2[0-4]|1{0,1}[0-9]){0,1}[0-9])|(?:[0-9a-fA-F]{1,4}:){1,4}:(?:(?:25[0-5]|(?:2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(?:25[0-5]|(?:2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/;
|
||||
|
||||
/**
|
||||
* Checks if a string is a valid IPv4 address
|
||||
* @param ip - String to validate as IPv4
|
||||
* @returns True if valid IPv4 address
|
||||
*/
|
||||
export function isValidIPv4(ip: string): boolean {
|
||||
if (!ip || typeof ip !== "string") return false;
|
||||
return IPV4_REGEX.test(ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a string is a valid IPv6 address
|
||||
* @param ip - String to validate as IPv6
|
||||
* @returns True if valid IPv6 address
|
||||
*/
|
||||
export function isValidIPv6(ip: string): boolean {
|
||||
if (!ip || typeof ip !== "string") return false;
|
||||
|
||||
// Remove brackets if present (for URL format like [::1])
|
||||
const cleanIP = ip.replace(/^\[|\]$/g, "");
|
||||
|
||||
return IPV6_REGEX.test(cleanIP);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a string is a valid IP address (IPv4 or IPv6)
|
||||
* @param ip - String to validate as IP address
|
||||
* @returns Object with validation results
|
||||
*/
|
||||
export function validateIPAddress(ip: string): {
|
||||
isValid: boolean;
|
||||
type: "ipv4" | "ipv6" | "invalid";
|
||||
formatted?: string;
|
||||
} {
|
||||
if (!ip || typeof ip !== "string") {
|
||||
return { isValid: false, type: "invalid" };
|
||||
}
|
||||
|
||||
if (isValidIPv4(ip)) {
|
||||
return { isValid: true, type: "ipv4", formatted: ip };
|
||||
}
|
||||
|
||||
if (isValidIPv6(ip)) {
|
||||
const formatted = ip.replace(/^\[|\]$/g, ""); // Remove brackets
|
||||
return { isValid: true, type: "ipv6", formatted };
|
||||
}
|
||||
|
||||
return { isValid: false, type: "invalid" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a URL string points to a localhost address.
|
||||
* @param url - The URL string to check
|
||||
* @returns True if the URL points to localhost, false otherwise
|
||||
*/
|
||||
export function isLocalhost(url: string): boolean {
|
||||
const hostname = extractHostname(url);
|
||||
return LOCALHOST_ADDRESSES.includes(hostname);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts hostname from a URL string by removing protocol, path, query, hash, and port.
|
||||
* @param url - The URL string to extract hostname from
|
||||
* @returns The cleaned hostname
|
||||
*/
|
||||
export function extractHostname(url: string): string {
|
||||
let hostname = url;
|
||||
|
||||
// Remove protocol if present
|
||||
if (hostname.includes("://")) {
|
||||
hostname = hostname.split("://")[1];
|
||||
}
|
||||
|
||||
// Remove auth credentials if present
|
||||
const atIndex = hostname.indexOf("@");
|
||||
if (atIndex !== -1) {
|
||||
hostname = hostname.substring(atIndex + 1);
|
||||
}
|
||||
|
||||
// Remove path, query, hash, and port in one pass
|
||||
hostname = hostname.split("/")[0].split("?")[0].split("#")[0].split(":")[0];
|
||||
|
||||
return hostname;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a readable representation of a URL by stripping the protocol
|
||||
* and any trailing slash. For valid URLs, only the host is returned.
|
||||
* Invalid URLs are sanitized by removing the protocol and trailing slash.
|
||||
*
|
||||
* @param url - The URL string to format
|
||||
* @returns The formatted domain for display
|
||||
*/
|
||||
export function formatURLForDisplay(url: string): string {
|
||||
if (!url) return "";
|
||||
|
||||
try {
|
||||
return new URL(url).host;
|
||||
} catch (_error) {
|
||||
return extractHostname(url);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts and validates the TLD (Top Level Domain) from a URL string.
|
||||
*
|
||||
* @param {string} urlString - The string to extract TLD from
|
||||
* @returns {string} The valid TLD if found, empty string otherwise
|
||||
*
|
||||
* @description
|
||||
* The function performs the following steps:
|
||||
* 1. Basic validation (rejects empty strings, strings starting/ending with dots)
|
||||
* 2. URL component cleaning:
|
||||
* - Removes protocol (if present)
|
||||
* - Removes auth credentials (if present)
|
||||
* - Removes path component (everything after '/')
|
||||
* - Removes query parameters (everything after '?')
|
||||
* - Removes hash fragments (everything after '#')
|
||||
* - Removes port numbers (everything after ':')
|
||||
* 3. Validates the TLD against a list of known TLDs
|
||||
*/
|
||||
|
||||
export function extractTLD(urlString: string): string {
|
||||
if (!urlString || urlString.startsWith(".") || urlString.endsWith(".")) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const hostname = extractHostname(urlString);
|
||||
const hostnameParts = hostname.split(".");
|
||||
|
||||
if (hostnameParts.length >= 2) {
|
||||
const potentialTLD = hostnameParts[hostnameParts.length - 1].toLowerCase();
|
||||
return tlds.includes(potentialTLD) ? potentialTLD : "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface representing the cleaned components of a URL.
|
||||
* @interface IURLComponents
|
||||
* @property {string} protocol - The URL protocol (e.g., 'http', 'https'), if protocol is not present, Always contains the actual protocol used.
|
||||
* @property {string} subdomain - The subdomain part of the URL (e.g., 'blog' in 'blog.example.com')
|
||||
* @property {string} rootDomain - The root domain name (e.g., 'example' in 'blog.example.com')
|
||||
* @property {string} tld - The top-level domain (e.g., 'com', 'org')
|
||||
* @property {string} pathname - The URL path excluding search params and hash, empty if pathname is '/'
|
||||
* @property {URL} full - The original URL object with all native URL properties
|
||||
*/
|
||||
export interface IURLComponents {
|
||||
protocol: string;
|
||||
subdomain: string;
|
||||
rootDomain: string;
|
||||
tld: string;
|
||||
pathname: string;
|
||||
full: URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a URL object to extract its components
|
||||
*/
|
||||
export function processURL(url: URL): IURLComponents {
|
||||
const protocol = url.protocol.slice(0, -1);
|
||||
const hostnameParts = url.hostname.split(".");
|
||||
|
||||
let subdomain = "";
|
||||
let rootDomain = "";
|
||||
let tld = "";
|
||||
|
||||
if (hostnameParts.length === 1) {
|
||||
rootDomain = hostnameParts[0]; // For cases like 'localhost'
|
||||
} else if (hostnameParts.length >= 2) {
|
||||
tld = hostnameParts[hostnameParts.length - 1];
|
||||
rootDomain = hostnameParts[hostnameParts.length - 2];
|
||||
|
||||
if (hostnameParts.length > 2) {
|
||||
subdomain = hostnameParts.slice(0, -2).join(".");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
protocol,
|
||||
subdomain,
|
||||
rootDomain,
|
||||
tld,
|
||||
pathname: url.pathname === "/" ? "" : url.pathname,
|
||||
full: url,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts components from a URL object or string.
|
||||
*
|
||||
* @param {URL | string} url - The URL object or string to extract components from
|
||||
* @returns {IURLComponents | undefined} URL components or undefined if invalid
|
||||
*
|
||||
* @example
|
||||
* // With URL object
|
||||
* const url = new URL('https://blog.example.com/posts');
|
||||
* extractURLComponents(url);
|
||||
*
|
||||
* // With string
|
||||
* extractURLComponents('blog.example.com/posts');
|
||||
*
|
||||
* // Example output:
|
||||
* // {
|
||||
* // protocol: 'https', // empty string if protocol is not present
|
||||
* // subdomain: 'blog',
|
||||
* // rootDomain: 'example',
|
||||
* // tld: 'com',
|
||||
* // pathname: 'posts',
|
||||
* // full: URL {} // The parsed URL object
|
||||
* // }
|
||||
*/
|
||||
export function extractURLComponents(url: URL | string): IURLComponents | undefined {
|
||||
// If URL object is passed directly
|
||||
if (typeof url !== "string") {
|
||||
return processURL(url);
|
||||
}
|
||||
|
||||
// Handle empty strings
|
||||
if (!url || url.trim() === "") return undefined;
|
||||
|
||||
// Input length validation for security
|
||||
if (url.length > 2048) return undefined;
|
||||
|
||||
const urlLower = url.toLowerCase();
|
||||
|
||||
try {
|
||||
// 1. Handle web URLs with protocols (including mailto, http, https, ftp, etc.)
|
||||
if (PROTOCOL_REGEX.test(urlLower) || urlLower.startsWith(MAILTO_PROTOCOL)) {
|
||||
return processURL(new URL(url));
|
||||
}
|
||||
|
||||
// 2. Check if it's an email address
|
||||
if (EMAIL_REGEX.test(urlLower)) {
|
||||
return processURL(new URL(`${MAILTO_PROTOCOL}${url}`));
|
||||
}
|
||||
|
||||
// 3. URL without protocol but valid domain or IP address or TLD
|
||||
if (isLocalhost(urlLower) || isValidIPv4(urlLower) || isValidIPv6(urlLower) || extractTLD(urlLower)) {
|
||||
return processURL(new URL(`${DEFAULT_PROTOCOL}${urlLower}`));
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a next_path parameter is safe for redirection.
|
||||
* Only allows relative paths starting with "/" to prevent open redirect vulnerabilities.
|
||||
*
|
||||
* @param url - The next_path URL to validate
|
||||
* @returns True if the URL is a safe relative path, false otherwise
|
||||
*
|
||||
* @example
|
||||
* isValidNextPath("/dashboard") // true
|
||||
* isValidNextPath("/workspace/123") // true
|
||||
* isValidNextPath("https://malicious.com") // false
|
||||
* isValidNextPath("//malicious.com") // false (protocol-relative)
|
||||
* isValidNextPath("javascript:alert(1)") // false
|
||||
* isValidNextPath("") // false
|
||||
* isValidNextPath("dashboard") // false (must start with /)
|
||||
* isValidNextPath("\\malicious") // false (backslash)
|
||||
* isValidNextPath(" /dashboard ") // true (trimmed)
|
||||
*/
|
||||
export function isValidNextPath(url: string): boolean {
|
||||
if (!url || typeof url !== "string") return false;
|
||||
|
||||
// Trim leading/trailing whitespace
|
||||
const trimmedUrl = url.trim();
|
||||
|
||||
if (!trimmedUrl) return false;
|
||||
|
||||
// Only allow relative paths starting with /
|
||||
if (!trimmedUrl.startsWith("/")) return false;
|
||||
|
||||
// Block protocol-relative URLs (//example.com) - open redirect vulnerability
|
||||
if (trimmedUrl.startsWith("//")) return false;
|
||||
|
||||
// Block backslashes which can be used for path traversal or Windows-style paths
|
||||
if (trimmedUrl.includes("\\")) return false;
|
||||
|
||||
try {
|
||||
// Use URL constructor with a dummy base to normalize and validate the path
|
||||
const normalizedUrl = new URL(trimmedUrl, "http://localhost");
|
||||
|
||||
// Ensure the path is still relative (no host change from our dummy base)
|
||||
if (normalizedUrl.hostname !== "localhost" || normalizedUrl.protocol !== "http:") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use the normalized pathname for additional security checks
|
||||
const pathname = normalizedUrl.pathname;
|
||||
|
||||
// Additional security checks for malicious patterns in the normalized path
|
||||
const maliciousPatterns = [
|
||||
/javascript:/i,
|
||||
/data:/i,
|
||||
/vbscript:/i,
|
||||
/<script/i,
|
||||
/on\w+=/i, // Event handlers like onclick=, onload=
|
||||
];
|
||||
|
||||
return !maliciousPatterns.some((pattern) => pattern.test(pathname));
|
||||
} catch (error) {
|
||||
// If URL constructor fails, it's an invalid path
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// plane imports
|
||||
import {
|
||||
EQUALITY_OPERATOR,
|
||||
ICycle,
|
||||
TCycleGroups,
|
||||
TFilterProperty,
|
||||
COLLECTION_OPERATOR,
|
||||
TSupportedOperators,
|
||||
} from "@plane/types";
|
||||
// local imports
|
||||
import {
|
||||
createFilterConfig,
|
||||
TCreateFilterConfigParams,
|
||||
IFilterIconConfig,
|
||||
TCreateFilterConfig,
|
||||
getMultiSelectConfig,
|
||||
createOperatorConfigEntry,
|
||||
} from "../../../rich-filters";
|
||||
|
||||
/**
|
||||
* Cycle filter specific params
|
||||
*/
|
||||
export type TCreateCycleFilterParams = TCreateFilterConfigParams &
|
||||
IFilterIconConfig<TCycleGroups> & {
|
||||
cycles: ICycle[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to get the cycle multi select config
|
||||
* @param params - The filter params
|
||||
* @returns The cycle multi select config
|
||||
*/
|
||||
export const getCycleMultiSelectConfig = (params: TCreateCycleFilterParams, singleValueOperator: TSupportedOperators) =>
|
||||
getMultiSelectConfig<ICycle, string, TCycleGroups>(
|
||||
{
|
||||
items: params.cycles,
|
||||
getId: (cycle) => cycle.id,
|
||||
getLabel: (cycle) => cycle.name,
|
||||
getValue: (cycle) => cycle.id,
|
||||
getIconData: (cycle) => cycle.status || "draft",
|
||||
},
|
||||
{
|
||||
singleValueOperator,
|
||||
...params,
|
||||
},
|
||||
{
|
||||
...params,
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get the cycle filter config
|
||||
* @template K - The filter key
|
||||
* @param key - The filter key to use
|
||||
* @returns A function that takes parameters and returns the cycle filter config
|
||||
*/
|
||||
export const getCycleFilterConfig =
|
||||
<P extends TFilterProperty>(key: P): TCreateFilterConfig<P, TCreateCycleFilterParams> =>
|
||||
(params: TCreateCycleFilterParams) =>
|
||||
createFilterConfig<P, string>({
|
||||
id: key,
|
||||
label: "Cycle",
|
||||
...params,
|
||||
icon: params.filterIcon,
|
||||
supportedOperatorConfigsMap: new Map([
|
||||
createOperatorConfigEntry(COLLECTION_OPERATOR.IN, params, (updatedParams) =>
|
||||
getCycleMultiSelectConfig(updatedParams, EQUALITY_OPERATOR.EXACT)
|
||||
),
|
||||
]),
|
||||
});
|
||||
83
packages/utils/src/work-item-filters/configs/filters/date.ts
Normal file
83
packages/utils/src/work-item-filters/configs/filters/date.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
// plane imports
|
||||
import { TFilterProperty } from "@plane/types";
|
||||
// local imports
|
||||
import {
|
||||
createFilterConfig,
|
||||
TCreateFilterConfig,
|
||||
TCreateDateFilterParams,
|
||||
getSupportedDateOperators,
|
||||
} from "../../../rich-filters";
|
||||
|
||||
// ------------ Date filters ------------
|
||||
|
||||
/**
|
||||
* Get the start date filter config
|
||||
* @template K - The filter key
|
||||
* @param key - The filter key to use
|
||||
* @returns A function that takes parameters and returns the start date filter config
|
||||
*/
|
||||
export const getStartDateFilterConfig =
|
||||
<P extends TFilterProperty>(key: P): TCreateFilterConfig<P, TCreateDateFilterParams> =>
|
||||
(params: TCreateDateFilterParams) =>
|
||||
createFilterConfig<P, Date>({
|
||||
id: key,
|
||||
label: "Start date",
|
||||
...params,
|
||||
icon: params.filterIcon,
|
||||
allowMultipleFilters: true,
|
||||
supportedOperatorConfigsMap: getSupportedDateOperators(params),
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the target date filter config
|
||||
* @template K - The filter key
|
||||
* @param key - The filter key to use
|
||||
* @returns A function that takes parameters and returns the target date filter config
|
||||
*/
|
||||
export const getTargetDateFilterConfig =
|
||||
<P extends TFilterProperty>(key: P): TCreateFilterConfig<P, TCreateDateFilterParams> =>
|
||||
(params: TCreateDateFilterParams) =>
|
||||
createFilterConfig<P, Date>({
|
||||
id: key,
|
||||
label: "Target date",
|
||||
...params,
|
||||
icon: params.filterIcon,
|
||||
allowMultipleFilters: true,
|
||||
supportedOperatorConfigsMap: getSupportedDateOperators(params),
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the created at filter config
|
||||
* @template K - The filter key
|
||||
* @param key - The filter key to use
|
||||
* @returns A function that takes parameters and returns the created at filter config
|
||||
*/
|
||||
export const getCreatedAtFilterConfig =
|
||||
<P extends TFilterProperty>(key: P): TCreateFilterConfig<P, TCreateDateFilterParams> =>
|
||||
(params: TCreateDateFilterParams) =>
|
||||
createFilterConfig<P, Date>({
|
||||
id: key,
|
||||
label: "Created at",
|
||||
...params,
|
||||
icon: params.filterIcon,
|
||||
allowMultipleFilters: true,
|
||||
supportedOperatorConfigsMap: getSupportedDateOperators(params),
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the updated at filter config
|
||||
* @template K - The filter key
|
||||
* @param key - The filter key to use
|
||||
* @returns A function that takes parameters and returns the updated at filter config
|
||||
*/
|
||||
export const getUpdatedAtFilterConfig =
|
||||
<P extends TFilterProperty>(key: P): TCreateFilterConfig<P, TCreateDateFilterParams> =>
|
||||
(params: TCreateDateFilterParams) =>
|
||||
createFilterConfig<P, Date>({
|
||||
id: key,
|
||||
label: "Updated at",
|
||||
...params,
|
||||
icon: params.filterIcon,
|
||||
allowMultipleFilters: true,
|
||||
supportedOperatorConfigsMap: getSupportedDateOperators(params),
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from "./cycle";
|
||||
export * from "./date";
|
||||
export * from "./label";
|
||||
export * from "./module";
|
||||
export * from "./priority";
|
||||
export * from "./project";
|
||||
export * from "./state";
|
||||
export * from "./user";
|
||||
@@ -0,0 +1,69 @@
|
||||
// plane imports
|
||||
import {
|
||||
EQUALITY_OPERATOR,
|
||||
IIssueLabel,
|
||||
TFilterProperty,
|
||||
COLLECTION_OPERATOR,
|
||||
TSupportedOperators,
|
||||
} from "@plane/types";
|
||||
// local imports
|
||||
import {
|
||||
createFilterConfig,
|
||||
TCreateFilterConfigParams,
|
||||
IFilterIconConfig,
|
||||
TCreateFilterConfig,
|
||||
getMultiSelectConfig,
|
||||
createOperatorConfigEntry,
|
||||
} from "../../../rich-filters";
|
||||
|
||||
/**
|
||||
* Label filter specific params
|
||||
*/
|
||||
export type TCreateLabelFilterParams = TCreateFilterConfigParams &
|
||||
IFilterIconConfig<string> & {
|
||||
labels: IIssueLabel[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to get the label multi select config
|
||||
* @param params - The filter params
|
||||
* @returns The label multi select config
|
||||
*/
|
||||
export const getLabelMultiSelectConfig = (params: TCreateLabelFilterParams, singleValueOperator: TSupportedOperators) =>
|
||||
getMultiSelectConfig<IIssueLabel, string, string>(
|
||||
{
|
||||
items: params.labels,
|
||||
getId: (label) => label.id,
|
||||
getLabel: (label) => label.name,
|
||||
getValue: (label) => label.id,
|
||||
getIconData: (label) => label.color,
|
||||
},
|
||||
{
|
||||
singleValueOperator,
|
||||
...params,
|
||||
},
|
||||
{
|
||||
getOptionIcon: params.getOptionIcon,
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get the label filter config
|
||||
* @template K - The filter key
|
||||
* @param key - The filter key to use
|
||||
* @returns A function that takes parameters and returns the label filter config
|
||||
*/
|
||||
export const getLabelFilterConfig =
|
||||
<P extends TFilterProperty>(key: P): TCreateFilterConfig<P, TCreateLabelFilterParams> =>
|
||||
(params: TCreateLabelFilterParams) =>
|
||||
createFilterConfig<P, string>({
|
||||
id: key,
|
||||
label: "Label",
|
||||
...params,
|
||||
icon: params.filterIcon,
|
||||
supportedOperatorConfigsMap: new Map([
|
||||
createOperatorConfigEntry(COLLECTION_OPERATOR.IN, params, (updatedParams) =>
|
||||
getLabelMultiSelectConfig(updatedParams, EQUALITY_OPERATOR.EXACT)
|
||||
),
|
||||
]),
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// plane imports
|
||||
import { EQUALITY_OPERATOR, IModule, TFilterProperty, COLLECTION_OPERATOR } from "@plane/types";
|
||||
// local imports
|
||||
import {
|
||||
createFilterConfig,
|
||||
TCreateFilterConfigParams,
|
||||
IFilterIconConfig,
|
||||
TCreateFilterConfig,
|
||||
getMultiSelectConfig,
|
||||
createOperatorConfigEntry,
|
||||
} from "../../../rich-filters";
|
||||
|
||||
/**
|
||||
* Module filter specific params
|
||||
*/
|
||||
export type TCreateModuleFilterParams = TCreateFilterConfigParams &
|
||||
IFilterIconConfig<undefined> & {
|
||||
modules: IModule[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to get the module multi select config
|
||||
* @param params - The filter params
|
||||
* @returns The module multi select config
|
||||
*/
|
||||
export const getModuleMultiSelectConfig = (params: TCreateModuleFilterParams) =>
|
||||
getMultiSelectConfig<IModule, string, undefined>(
|
||||
{
|
||||
items: params.modules,
|
||||
getId: (module) => module.id,
|
||||
getLabel: (module) => module.name,
|
||||
getValue: (module) => module.id,
|
||||
getIconData: () => undefined,
|
||||
},
|
||||
{
|
||||
singleValueOperator: EQUALITY_OPERATOR.EXACT,
|
||||
...params,
|
||||
},
|
||||
{
|
||||
...params,
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get the module filter config
|
||||
* @template K - The filter key
|
||||
* @param key - The filter key to use
|
||||
* @returns A function that takes parameters and returns the module filter config
|
||||
*/
|
||||
export const getModuleFilterConfig =
|
||||
<P extends TFilterProperty>(key: P): TCreateFilterConfig<P, TCreateModuleFilterParams> =>
|
||||
(params: TCreateModuleFilterParams) =>
|
||||
createFilterConfig<P, string>({
|
||||
id: key,
|
||||
label: "Module",
|
||||
...params,
|
||||
icon: params.filterIcon,
|
||||
supportedOperatorConfigsMap: new Map([
|
||||
createOperatorConfigEntry(COLLECTION_OPERATOR.IN, params, (updatedParams) =>
|
||||
getModuleMultiSelectConfig(updatedParams)
|
||||
),
|
||||
]),
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// plane imports
|
||||
import { ISSUE_PRIORITIES, TIssuePriorities } from "@plane/constants";
|
||||
import { EQUALITY_OPERATOR, TFilterProperty, COLLECTION_OPERATOR, TSupportedOperators } from "@plane/types";
|
||||
// local imports
|
||||
import {
|
||||
createFilterConfig,
|
||||
TCreateFilterConfigParams,
|
||||
IFilterIconConfig,
|
||||
TCreateFilterConfig,
|
||||
getMultiSelectConfig,
|
||||
createOperatorConfigEntry,
|
||||
} from "../../../rich-filters";
|
||||
|
||||
// ------------ Priority filter ------------
|
||||
|
||||
/**
|
||||
* Priority filter specific params
|
||||
*/
|
||||
export type TCreatePriorityFilterParams = TCreateFilterConfigParams & IFilterIconConfig<TIssuePriorities>;
|
||||
|
||||
/**
|
||||
* Helper to get the priority multi select config
|
||||
* @param params - The filter params
|
||||
* @returns The priority multi select config
|
||||
*/
|
||||
export const getPriorityMultiSelectConfig = (
|
||||
params: TCreatePriorityFilterParams,
|
||||
singleValueOperator: TSupportedOperators
|
||||
) =>
|
||||
getMultiSelectConfig<{ key: TIssuePriorities; title: string }, TIssuePriorities, TIssuePriorities>(
|
||||
{
|
||||
items: ISSUE_PRIORITIES,
|
||||
getId: (priority) => priority.key,
|
||||
getLabel: (priority) => priority.title,
|
||||
getValue: (priority) => priority.key,
|
||||
getIconData: (priority) => priority.key,
|
||||
},
|
||||
{
|
||||
singleValueOperator,
|
||||
...params,
|
||||
},
|
||||
{
|
||||
getOptionIcon: params.getOptionIcon,
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get the priority filter config
|
||||
* @template K - The filter key
|
||||
* @param key - The filter key to use
|
||||
* @returns A function that takes parameters and returns the priority filter config
|
||||
*/
|
||||
export const getPriorityFilterConfig =
|
||||
<P extends TFilterProperty>(key: P): TCreateFilterConfig<P, TCreatePriorityFilterParams> =>
|
||||
(params: TCreatePriorityFilterParams) =>
|
||||
createFilterConfig<P, TIssuePriorities>({
|
||||
id: key,
|
||||
label: "Priority",
|
||||
...params,
|
||||
icon: params.filterIcon,
|
||||
supportedOperatorConfigsMap: new Map([
|
||||
createOperatorConfigEntry(COLLECTION_OPERATOR.IN, params, (updatedParams) =>
|
||||
getPriorityMultiSelectConfig(updatedParams, EQUALITY_OPERATOR.EXACT)
|
||||
),
|
||||
]),
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
// plane imports
|
||||
import { EQUALITY_OPERATOR, TFilterProperty, COLLECTION_OPERATOR } from "@plane/types";
|
||||
// local imports
|
||||
import {
|
||||
createFilterConfig,
|
||||
createOperatorConfigEntry,
|
||||
getProjectMultiSelectConfig,
|
||||
TCreateFilterConfig,
|
||||
TCreateProjectFilterParams,
|
||||
} from "../../../rich-filters";
|
||||
|
||||
// ------------ Project filter ------------
|
||||
|
||||
/**
|
||||
* Get the project filter config
|
||||
* @template K - The filter key
|
||||
* @param key - The filter key to use
|
||||
* @returns A function that takes parameters and returns the project filter config
|
||||
*/
|
||||
export const getProjectFilterConfig =
|
||||
<P extends TFilterProperty>(key: P): TCreateFilterConfig<P, TCreateProjectFilterParams> =>
|
||||
(params: TCreateProjectFilterParams) =>
|
||||
createFilterConfig<P, string>({
|
||||
id: key,
|
||||
label: "Projects",
|
||||
...params,
|
||||
icon: params.filterIcon,
|
||||
supportedOperatorConfigsMap: new Map([
|
||||
createOperatorConfigEntry(COLLECTION_OPERATOR.IN, params, (updatedParams) =>
|
||||
getProjectMultiSelectConfig(updatedParams, EQUALITY_OPERATOR.EXACT)
|
||||
),
|
||||
]),
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
// plane imports
|
||||
import {
|
||||
COMPARISON_OPERATOR,
|
||||
EQUALITY_OPERATOR,
|
||||
IProject,
|
||||
TOperatorConfigMap,
|
||||
TSupportedOperators,
|
||||
} from "@plane/types";
|
||||
// local imports
|
||||
import {
|
||||
createOperatorConfigEntry,
|
||||
getDatePickerConfig,
|
||||
getDateRangePickerConfig,
|
||||
getMultiSelectConfig,
|
||||
IFilterIconConfig,
|
||||
TCreateDateFilterParams,
|
||||
TCreateFilterConfigParams,
|
||||
} from "../../../rich-filters";
|
||||
|
||||
// ------------ Date filter ------------
|
||||
|
||||
export const getSupportedDateOperators = (params: TCreateDateFilterParams): TOperatorConfigMap<Date> =>
|
||||
new Map([
|
||||
createOperatorConfigEntry(EQUALITY_OPERATOR.EXACT, params, (updatedParams) => getDatePickerConfig(updatedParams)),
|
||||
createOperatorConfigEntry(COMPARISON_OPERATOR.RANGE, params, (updatedParams) =>
|
||||
getDateRangePickerConfig(updatedParams)
|
||||
),
|
||||
]);
|
||||
|
||||
// ------------ Project filter ------------
|
||||
|
||||
/**
|
||||
* Project filter specific params
|
||||
*/
|
||||
export type TCreateProjectFilterParams = TCreateFilterConfigParams &
|
||||
IFilterIconConfig<IProject> & {
|
||||
projects: IProject[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to get the project multi select config
|
||||
* @param params - The filter params
|
||||
* @returns The member multi select config
|
||||
*/
|
||||
export const getProjectMultiSelectConfig = (
|
||||
params: TCreateProjectFilterParams,
|
||||
singleValueOperator: TSupportedOperators
|
||||
) =>
|
||||
getMultiSelectConfig<IProject, string, IProject>(
|
||||
{
|
||||
items: params.projects,
|
||||
getId: (project) => project.id,
|
||||
getLabel: (project) => project.name,
|
||||
getValue: (project) => project.id,
|
||||
getIconData: (project) => project,
|
||||
},
|
||||
{
|
||||
singleValueOperator,
|
||||
...params,
|
||||
},
|
||||
{
|
||||
...params,
|
||||
}
|
||||
);
|
||||
127
packages/utils/src/work-item-filters/configs/filters/state.ts
Normal file
127
packages/utils/src/work-item-filters/configs/filters/state.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
// plane imports
|
||||
import { STATE_GROUPS } from "@plane/constants";
|
||||
import {
|
||||
COLLECTION_OPERATOR,
|
||||
EQUALITY_OPERATOR,
|
||||
IState,
|
||||
TFilterProperty,
|
||||
TStateGroups,
|
||||
TSupportedOperators,
|
||||
} from "@plane/types";
|
||||
// local imports
|
||||
import {
|
||||
createFilterConfig,
|
||||
getMultiSelectConfig,
|
||||
IFilterIconConfig,
|
||||
TCreateFilterConfig,
|
||||
TCreateFilterConfigParams,
|
||||
createOperatorConfigEntry,
|
||||
} from "../../../rich-filters";
|
||||
|
||||
// ------------ State group filter ------------
|
||||
|
||||
/**
|
||||
* State group filter specific params
|
||||
*/
|
||||
export type TCreateStateGroupFilterParams = TCreateFilterConfigParams & IFilterIconConfig<TStateGroups>;
|
||||
|
||||
/**
|
||||
* Helper to get the state group multi select config
|
||||
* @param params - The filter params
|
||||
* @returns The state group multi select config
|
||||
*/
|
||||
export const getStateGroupMultiSelectConfig = (
|
||||
params: TCreateStateGroupFilterParams,
|
||||
singleValueOperator: TSupportedOperators
|
||||
) =>
|
||||
getMultiSelectConfig<{ key: TStateGroups; label: string }, TStateGroups, TStateGroups>(
|
||||
{
|
||||
items: Object.values(STATE_GROUPS),
|
||||
getId: (state) => state.key,
|
||||
getLabel: (state) => state.label,
|
||||
getValue: (state) => state.key,
|
||||
getIconData: (state) => state.key,
|
||||
},
|
||||
{
|
||||
singleValueOperator,
|
||||
...params,
|
||||
},
|
||||
{
|
||||
...params,
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get the state group filter config
|
||||
* @template K - The filter key
|
||||
* @param key - The filter key to use
|
||||
* @returns A function that takes parameters and returns the state group filter config
|
||||
*/
|
||||
export const getStateGroupFilterConfig =
|
||||
<P extends TFilterProperty>(key: P): TCreateFilterConfig<P, TCreateStateGroupFilterParams> =>
|
||||
(params: TCreateStateGroupFilterParams) =>
|
||||
createFilterConfig<P, TStateGroups>({
|
||||
id: key,
|
||||
label: "State Group",
|
||||
...params,
|
||||
icon: params.filterIcon,
|
||||
supportedOperatorConfigsMap: new Map([
|
||||
createOperatorConfigEntry(COLLECTION_OPERATOR.IN, params, (updatedParams) =>
|
||||
getStateGroupMultiSelectConfig(updatedParams, EQUALITY_OPERATOR.EXACT)
|
||||
),
|
||||
]),
|
||||
});
|
||||
|
||||
// ------------ State filter ------------
|
||||
|
||||
/**
|
||||
* State filter specific params
|
||||
*/
|
||||
export type TCreateStateFilterParams = TCreateFilterConfigParams &
|
||||
IFilterIconConfig<IState> & {
|
||||
states: IState[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to get the state multi select config
|
||||
* @param params - The filter params
|
||||
* @returns The state multi select config
|
||||
*/
|
||||
export const getStateMultiSelectConfig = (params: TCreateStateFilterParams, singleValueOperator: TSupportedOperators) =>
|
||||
getMultiSelectConfig<IState, string, IState>(
|
||||
{
|
||||
items: params.states,
|
||||
getId: (state) => state.id,
|
||||
getLabel: (state) => state.name,
|
||||
getValue: (state) => state.id,
|
||||
getIconData: (state) => state,
|
||||
},
|
||||
{
|
||||
singleValueOperator,
|
||||
...params,
|
||||
},
|
||||
{
|
||||
...params,
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get the state filter config
|
||||
* @template K - The filter key
|
||||
* @param key - The filter key to use
|
||||
* @returns A function that takes parameters and returns the state filter config
|
||||
*/
|
||||
export const getStateFilterConfig =
|
||||
<P extends TFilterProperty>(key: P): TCreateFilterConfig<P, TCreateStateFilterParams> =>
|
||||
(params: TCreateStateFilterParams) =>
|
||||
createFilterConfig<P, string>({
|
||||
id: key,
|
||||
label: "State",
|
||||
...params,
|
||||
icon: params.filterIcon,
|
||||
supportedOperatorConfigsMap: new Map([
|
||||
createOperatorConfigEntry(COLLECTION_OPERATOR.IN, params, (updatedParams) =>
|
||||
getStateMultiSelectConfig(updatedParams, EQUALITY_OPERATOR.EXACT)
|
||||
),
|
||||
]),
|
||||
});
|
||||
122
packages/utils/src/work-item-filters/configs/filters/user.ts
Normal file
122
packages/utils/src/work-item-filters/configs/filters/user.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
// plane imports
|
||||
import { EQUALITY_OPERATOR, TFilterProperty, COLLECTION_OPERATOR } from "@plane/types";
|
||||
// local imports
|
||||
import {
|
||||
createFilterConfig,
|
||||
TCreateFilterConfig,
|
||||
createOperatorConfigEntry,
|
||||
getMemberMultiSelectConfig,
|
||||
TCreateUserFilterParams,
|
||||
} from "../../../rich-filters";
|
||||
|
||||
// ------------ Assignee filter ------------
|
||||
|
||||
/**
|
||||
* Assignee filter specific params
|
||||
*/
|
||||
export type TCreateAssigneeFilterParams = TCreateUserFilterParams;
|
||||
|
||||
/**
|
||||
* Get the assignee filter config
|
||||
* @template K - The filter key
|
||||
* @param key - The filter key to use
|
||||
* @returns A function that takes parameters and returns the assignee filter config
|
||||
*/
|
||||
export const getAssigneeFilterConfig =
|
||||
<P extends TFilterProperty>(key: P): TCreateFilterConfig<P, TCreateAssigneeFilterParams> =>
|
||||
(params: TCreateAssigneeFilterParams) =>
|
||||
createFilterConfig<P, string>({
|
||||
id: key,
|
||||
label: "Assignees",
|
||||
...params,
|
||||
icon: params.filterIcon,
|
||||
supportedOperatorConfigsMap: new Map([
|
||||
createOperatorConfigEntry(COLLECTION_OPERATOR.IN, params, (updatedParams) =>
|
||||
getMemberMultiSelectConfig(updatedParams, EQUALITY_OPERATOR.EXACT)
|
||||
),
|
||||
]),
|
||||
});
|
||||
|
||||
// ------------ Mention filter ------------
|
||||
|
||||
/**
|
||||
* Mention filter specific params
|
||||
*/
|
||||
export type TCreateMentionFilterParams = TCreateUserFilterParams;
|
||||
|
||||
/**
|
||||
* Get the mention filter config
|
||||
* @template K - The filter key
|
||||
* @param key - The filter key to use
|
||||
* @returns A function that takes parameters and returns the mention filter config
|
||||
*/
|
||||
export const getMentionFilterConfig =
|
||||
<P extends TFilterProperty>(key: P): TCreateFilterConfig<P, TCreateMentionFilterParams> =>
|
||||
(params: TCreateMentionFilterParams) =>
|
||||
createFilterConfig<P, string>({
|
||||
id: key,
|
||||
label: "Mentions",
|
||||
...params,
|
||||
icon: params.filterIcon,
|
||||
supportedOperatorConfigsMap: new Map([
|
||||
createOperatorConfigEntry(COLLECTION_OPERATOR.IN, params, (updatedParams) =>
|
||||
getMemberMultiSelectConfig(updatedParams, EQUALITY_OPERATOR.EXACT)
|
||||
),
|
||||
]),
|
||||
});
|
||||
|
||||
// ------------ Created by filter ------------
|
||||
|
||||
/**
|
||||
* Created by filter specific params
|
||||
*/
|
||||
export type TCreateCreatedByFilterParams = TCreateUserFilterParams;
|
||||
|
||||
/**
|
||||
* Get the created by filter config
|
||||
* @template K - The filter key
|
||||
* @param key - The filter key to use
|
||||
* @returns A function that takes parameters and returns the created by filter config
|
||||
*/
|
||||
export const getCreatedByFilterConfig =
|
||||
<P extends TFilterProperty>(key: P): TCreateFilterConfig<P, TCreateCreatedByFilterParams> =>
|
||||
(params: TCreateCreatedByFilterParams) =>
|
||||
createFilterConfig<P, string>({
|
||||
id: key,
|
||||
label: "Created by",
|
||||
...params,
|
||||
icon: params.filterIcon,
|
||||
supportedOperatorConfigsMap: new Map([
|
||||
createOperatorConfigEntry(COLLECTION_OPERATOR.IN, params, (updatedParams) =>
|
||||
getMemberMultiSelectConfig(updatedParams, EQUALITY_OPERATOR.EXACT)
|
||||
),
|
||||
]),
|
||||
});
|
||||
|
||||
// ------------ Subscriber filter ------------
|
||||
|
||||
/**
|
||||
* Subscriber filter specific params
|
||||
*/
|
||||
export type TCreateSubscriberFilterParams = TCreateUserFilterParams;
|
||||
|
||||
/**
|
||||
* Get the subscriber filter config
|
||||
* @template K - The filter key
|
||||
* @param key - The filter key to use
|
||||
* @returns A function that takes parameters and returns the subscriber filter config
|
||||
*/
|
||||
export const getSubscriberFilterConfig =
|
||||
<P extends TFilterProperty>(key: P): TCreateFilterConfig<P, TCreateSubscriberFilterParams> =>
|
||||
(params: TCreateSubscriberFilterParams) =>
|
||||
createFilterConfig<P, string>({
|
||||
id: key,
|
||||
label: "Subscriber",
|
||||
...params,
|
||||
icon: params.filterIcon,
|
||||
supportedOperatorConfigsMap: new Map([
|
||||
createOperatorConfigEntry(COLLECTION_OPERATOR.IN, params, (updatedParams) =>
|
||||
getMemberMultiSelectConfig(updatedParams, EQUALITY_OPERATOR.EXACT)
|
||||
),
|
||||
]),
|
||||
});
|
||||
1
packages/utils/src/work-item-filters/configs/index.ts
Normal file
1
packages/utils/src/work-item-filters/configs/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./filters";
|
||||
1
packages/utils/src/work-item-filters/index.ts
Normal file
1
packages/utils/src/work-item-filters/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./configs";
|
||||
361
packages/utils/src/work-item/base.ts
Normal file
361
packages/utils/src/work-item/base.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
import { differenceInCalendarDays } from "date-fns/differenceInCalendarDays";
|
||||
import { isEmpty } from "lodash-es";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
// plane imports
|
||||
import {
|
||||
ISSUE_DISPLAY_FILTERS_BY_PAGE,
|
||||
ISSUE_PRIORITY_FILTERS,
|
||||
STATE_GROUPS,
|
||||
TIssueFilterPriorityObject,
|
||||
TIssuePriorities,
|
||||
} from "@plane/constants";
|
||||
import {
|
||||
EIssueLayoutTypes,
|
||||
IGanttBlock,
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
TGroupedIssues,
|
||||
TIssue,
|
||||
TIssueGroupByOptions,
|
||||
TIssueOrderByOptions,
|
||||
TIssueParams,
|
||||
TStateGroups,
|
||||
TSubGroupedIssues,
|
||||
TUnGroupedIssues,
|
||||
} from "@plane/types";
|
||||
// local imports
|
||||
import { orderArrayBy } from "../array";
|
||||
import { getDate } from "../datetime";
|
||||
import { isEditorEmpty } from "../editor";
|
||||
|
||||
type THandleIssuesMutation = (
|
||||
formData: Partial<TIssue>,
|
||||
oldGroupTitle: string,
|
||||
selectedGroupBy: TIssueGroupByOptions,
|
||||
issueIndex: number,
|
||||
orderBy: TIssueOrderByOptions,
|
||||
prevData?:
|
||||
| {
|
||||
[key: string]: TIssue[];
|
||||
}
|
||||
| TIssue[]
|
||||
) =>
|
||||
| {
|
||||
[key: string]: TIssue[];
|
||||
}
|
||||
| TIssue[]
|
||||
| undefined;
|
||||
|
||||
export const handleIssuesMutation: THandleIssuesMutation = (
|
||||
formData,
|
||||
oldGroupTitle,
|
||||
selectedGroupBy,
|
||||
issueIndex,
|
||||
orderBy,
|
||||
prevData
|
||||
) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
if (Array.isArray(prevData)) {
|
||||
const updatedIssue = {
|
||||
...prevData[issueIndex],
|
||||
...formData,
|
||||
};
|
||||
|
||||
prevData.splice(issueIndex, 1, updatedIssue);
|
||||
|
||||
return [...prevData];
|
||||
} else {
|
||||
const oldGroup = prevData[oldGroupTitle ?? ""] ?? [];
|
||||
|
||||
let newGroup: TIssue[] = [];
|
||||
|
||||
if (selectedGroupBy === "priority") newGroup = prevData[formData.priority ?? ""] ?? [];
|
||||
else if (selectedGroupBy === "state") newGroup = prevData[formData.state_id ?? ""] ?? [];
|
||||
|
||||
const updatedIssue = {
|
||||
...oldGroup[issueIndex],
|
||||
...formData,
|
||||
};
|
||||
|
||||
if (selectedGroupBy !== Object.keys(formData)[0])
|
||||
return {
|
||||
...prevData,
|
||||
[oldGroupTitle ?? ""]: orderArrayBy(
|
||||
oldGroup.map((i) => (i.id === updatedIssue.id ? updatedIssue : i)),
|
||||
orderBy
|
||||
),
|
||||
};
|
||||
|
||||
const groupThatIsUpdated = selectedGroupBy === "priority" ? formData.priority : formData.state_id;
|
||||
|
||||
return {
|
||||
...prevData,
|
||||
[oldGroupTitle ?? ""]: orderArrayBy(
|
||||
oldGroup.filter((i) => i.id !== updatedIssue.id),
|
||||
orderBy
|
||||
),
|
||||
[groupThatIsUpdated ?? ""]: orderArrayBy([...newGroup, updatedIssue], orderBy),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const handleIssueQueryParamsByLayout = (
|
||||
layout: EIssueLayoutTypes | undefined,
|
||||
viewType:
|
||||
| "my_issues"
|
||||
| "issues"
|
||||
| "profile_issues"
|
||||
| "archived_issues"
|
||||
| "draft_issues"
|
||||
| "team_issues"
|
||||
| "team_project_work_items"
|
||||
): TIssueParams[] | null => {
|
||||
const queryParams: TIssueParams[] = ["filters"];
|
||||
|
||||
if (!layout) return null;
|
||||
|
||||
const currentViewLayoutOptions = ISSUE_DISPLAY_FILTERS_BY_PAGE[viewType].layoutOptions[layout];
|
||||
|
||||
// add display filters query params
|
||||
Object.keys(currentViewLayoutOptions.display_filters).forEach((option) => {
|
||||
queryParams.push(option as TIssueParams);
|
||||
});
|
||||
|
||||
// add extra options query params
|
||||
if (currentViewLayoutOptions.extra_options.access) {
|
||||
currentViewLayoutOptions.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",
|
||||
label_ids: [],
|
||||
assignee_ids: [],
|
||||
sub_issues_count: 0,
|
||||
attachment_count: 0,
|
||||
link_count: 0,
|
||||
// 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,
|
||||
meta: {
|
||||
project_id: block?.project_id ?? 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 = !isEmpty(displayFilters) ? 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,
|
||||
sub_issue: filters?.sub_issue || false,
|
||||
show_empty_groups: filters?.show_empty_groups || false,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* @description This method is used to apply the display properties on the issues
|
||||
* @param {IIssueDisplayProperties} displayProperties
|
||||
* @returns {IIssueDisplayProperties}
|
||||
*/
|
||||
export const getComputedDisplayProperties = (
|
||||
displayProperties: IIssueDisplayProperties = {}
|
||||
): IIssueDisplayProperties => ({
|
||||
assignee: displayProperties?.assignee ?? true,
|
||||
start_date: displayProperties?.start_date ?? true,
|
||||
due_date: displayProperties?.due_date ?? true,
|
||||
labels: displayProperties?.labels ?? true,
|
||||
priority: displayProperties?.priority ?? true,
|
||||
state: displayProperties?.state ?? true,
|
||||
sub_issue_count: displayProperties?.sub_issue_count ?? true,
|
||||
attachment_count: displayProperties?.attachment_count ?? true,
|
||||
link: displayProperties?.link ?? true,
|
||||
estimate: displayProperties?.estimate ?? true,
|
||||
key: displayProperties?.key ?? true,
|
||||
created_on: displayProperties?.created_on ?? true,
|
||||
updated_on: displayProperties?.updated_on ?? true,
|
||||
modules: displayProperties?.modules ?? true,
|
||||
cycle: displayProperties?.cycle ?? true,
|
||||
issue_type: displayProperties?.issue_type ?? true,
|
||||
});
|
||||
|
||||
/**
|
||||
* This is to check if the issues list api should fall back to server or use local db
|
||||
* @param queries
|
||||
* @returns
|
||||
*/
|
||||
export const getIssuesShouldFallbackToServer = (queries: any) => {
|
||||
// If there is expand query and is not grouped then fallback to server
|
||||
if (!isEmpty(queries.expand as string) && !queries.group_by) return true;
|
||||
// If query has mentions then fallback to server
|
||||
if (!isEmpty(queries.mentions)) return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const generateWorkItemLink = ({
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
projectIdentifier,
|
||||
sequenceId,
|
||||
isArchived = false,
|
||||
isEpic = false,
|
||||
}: {
|
||||
workspaceSlug: string | undefined | null;
|
||||
projectId: string | undefined | null;
|
||||
issueId: string | undefined | null;
|
||||
projectIdentifier: string | undefined | null;
|
||||
sequenceId: string | number | undefined | null;
|
||||
isArchived?: boolean;
|
||||
isEpic?: boolean;
|
||||
}): string => {
|
||||
const archiveIssueLink = `/${workspaceSlug}/projects/${projectId}/archives/issues/${issueId}`;
|
||||
const epicLink = `/${workspaceSlug}/projects/${projectId}/epics/${issueId}`;
|
||||
const workItemLink = `/${workspaceSlug}/browse/${projectIdentifier}-${sequenceId}/`;
|
||||
|
||||
return isArchived ? archiveIssueLink : isEpic ? epicLink : workItemLink;
|
||||
};
|
||||
|
||||
export const getIssuePriorityFilters = (priorityKey: TIssuePriorities): TIssueFilterPriorityObject | undefined => {
|
||||
const currentIssuePriority: TIssueFilterPriorityObject | undefined =
|
||||
ISSUE_PRIORITY_FILTERS && ISSUE_PRIORITY_FILTERS.length > 0
|
||||
? ISSUE_PRIORITY_FILTERS.find((_priority) => _priority.key === priorityKey)
|
||||
: undefined;
|
||||
|
||||
if (currentIssuePriority) return currentIssuePriority;
|
||||
return undefined;
|
||||
};
|
||||
3
packages/utils/src/work-item/index.ts
Normal file
3
packages/utils/src/work-item/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./base";
|
||||
export * from "./modal";
|
||||
export * from "./state";
|
||||
47
packages/utils/src/work-item/modal.ts
Normal file
47
packages/utils/src/work-item/modal.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { set } from "lodash-es";
|
||||
// plane imports
|
||||
import { DEFAULT_WORK_ITEM_FORM_VALUES } from "@plane/constants";
|
||||
import { IPartialProject, ISearchIssueResponse, IState, TIssue } from "@plane/types";
|
||||
|
||||
export const getUpdateFormDataForReset = (projectId: string | null | undefined, formData: Partial<TIssue>) => ({
|
||||
...DEFAULT_WORK_ITEM_FORM_VALUES,
|
||||
project_id: projectId,
|
||||
name: formData.name,
|
||||
description_html: formData.description_html,
|
||||
priority: formData.priority,
|
||||
start_date: formData.start_date,
|
||||
target_date: formData.target_date,
|
||||
});
|
||||
|
||||
export const convertWorkItemDataToSearchResponse = (
|
||||
workspaceSlug: string,
|
||||
workItem: TIssue,
|
||||
project: IPartialProject | undefined,
|
||||
state: IState | undefined
|
||||
): ISearchIssueResponse => ({
|
||||
id: workItem.id,
|
||||
name: workItem.name,
|
||||
project_id: workItem.project_id ?? "",
|
||||
project__identifier: project?.identifier ?? "",
|
||||
project__name: project?.name ?? "",
|
||||
sequence_id: workItem.sequence_id,
|
||||
type_id: workItem.type_id ?? "",
|
||||
state__color: state?.color ?? "",
|
||||
start_date: workItem.start_date,
|
||||
state__group: state?.group ?? "backlog",
|
||||
state__name: state?.name ?? "",
|
||||
workspace__slug: workspaceSlug,
|
||||
});
|
||||
|
||||
export function getChangedIssuefields(formData: Partial<TIssue>, dirtyFields: { [key: string]: boolean | undefined }) {
|
||||
const changedFields = {};
|
||||
|
||||
const dirtyFieldKeys = Object.keys(dirtyFields) as (keyof TIssue)[];
|
||||
for (const dirtyField of dirtyFieldKeys) {
|
||||
if (!!dirtyFields[dirtyField]) {
|
||||
set(changedFields, [dirtyField], formData[dirtyField]);
|
||||
}
|
||||
}
|
||||
|
||||
return changedFields as Partial<TIssue>;
|
||||
}
|
||||
49
packages/utils/src/work-item/state.ts
Normal file
49
packages/utils/src/work-item/state.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// plane imports
|
||||
import { STATE_GROUPS, TDraggableData } from "@plane/constants";
|
||||
import { IState, IStateResponse } from "@plane/types";
|
||||
|
||||
export const orderStateGroups = (unorderedStateGroups: IStateResponse | undefined): IStateResponse | undefined => {
|
||||
if (!unorderedStateGroups) return undefined;
|
||||
return Object.assign({ backlog: [], unstarted: [], started: [], completed: [], cancelled: [] }, unorderedStateGroups);
|
||||
};
|
||||
|
||||
export const sortStates = (states: IState[]) => {
|
||||
if (!states || states.length === 0) return;
|
||||
|
||||
return states.sort((stateA, stateB) => {
|
||||
if (stateA.group === stateB.group) {
|
||||
return stateA.sequence - stateB.sequence;
|
||||
}
|
||||
return Object.keys(STATE_GROUPS).indexOf(stateA.group) - Object.keys(STATE_GROUPS).indexOf(stateB.group);
|
||||
});
|
||||
};
|
||||
|
||||
export const getCurrentStateSequence = (
|
||||
groupSates: IState[],
|
||||
destinationData: TDraggableData,
|
||||
edge: string | undefined
|
||||
) => {
|
||||
const defaultSequence = 65535;
|
||||
if (!edge) return defaultSequence;
|
||||
|
||||
const currentStateIndex = groupSates.findIndex((state) => state.id === destinationData.id);
|
||||
const currentStateSequence = groupSates[currentStateIndex]?.sequence || undefined;
|
||||
|
||||
if (!currentStateSequence) return defaultSequence;
|
||||
|
||||
if (edge === "top") {
|
||||
const prevStateSequence = groupSates[currentStateIndex - 1]?.sequence || undefined;
|
||||
|
||||
if (prevStateSequence === undefined) {
|
||||
return currentStateSequence - defaultSequence;
|
||||
}
|
||||
return (currentStateSequence + prevStateSequence) / 2;
|
||||
} else if (edge === "bottom") {
|
||||
const nextStateSequence = groupSates[currentStateIndex + 1]?.sequence || undefined;
|
||||
|
||||
if (nextStateSequence === undefined) {
|
||||
return currentStateSequence + defaultSequence;
|
||||
}
|
||||
return (currentStateSequence + nextStateSequence) / 2;
|
||||
}
|
||||
};
|
||||
5
packages/utils/src/workspace.ts
Normal file
5
packages/utils/src/workspace.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// plane imports
|
||||
import { IWorkspace } from "@plane/types";
|
||||
|
||||
export const orderWorkspacesList = (workspaces: IWorkspace[]): IWorkspace[] =>
|
||||
workspaces.sort((a, b) => a.name.localeCompare(b.name));
|
||||
5
packages/utils/tsconfig.json
Normal file
5
packages/utils/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "@plane/typescript-config/react-library.json",
|
||||
"include": ["./src"],
|
||||
"exclude": ["dist", "build", "node_modules"]
|
||||
}
|
||||
12
packages/utils/tsdown.config.ts
Normal file
12
packages/utils/tsdown.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "tsdown";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
outDir: "dist",
|
||||
format: ["esm", "cjs"],
|
||||
exports: true,
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
target: "esnext",
|
||||
});
|
||||
Reference in New Issue
Block a user