chore: copy helper functions from admin and space into @plane/utils (#6256)
* chore: copy helper functions from space to @plane/utils Co-Authored-By: sriram@plane.so <sriram@plane.so> * refactor: move enums from utils/auth.ts to @plane/constants/auth.ts Co-Authored-By: sriram@plane.so <sriram@plane.so> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: sriram@plane.so <sriram@plane.so>
This commit is contained in:
parent
043f4eaa5e
commit
9f5def3a6a
11 changed files with 651 additions and 3 deletions
|
|
@ -1,6 +1,17 @@
|
|||
import { ReactNode } from "react";
|
||||
import zxcvbn from "zxcvbn";
|
||||
import { E_PASSWORD_STRENGTH, PASSWORD_CRITERIA, PASSWORD_MIN_LENGTH } from "@plane/constants";
|
||||
|
||||
import { EPageTypes, EErrorAlertType, EAuthErrorCodes } from "@plane/constants";
|
||||
|
||||
export type TAuthErrorInfo = {
|
||||
type: EErrorAlertType;
|
||||
code: EAuthErrorCodes;
|
||||
title: string;
|
||||
message: ReactNode;
|
||||
};
|
||||
|
||||
// Password strength check
|
||||
export const getPasswordStrength = (password: string): E_PASSWORD_STRENGTH => {
|
||||
let passwordStrength: E_PASSWORD_STRENGTH = E_PASSWORD_STRENGTH.EMPTY;
|
||||
|
||||
|
|
@ -31,3 +42,283 @@ export const getPasswordStrength = (password: string): E_PASSWORD_STRENGTH => {
|
|||
|
||||
return passwordStrength;
|
||||
};
|
||||
|
||||
// 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: (email = undefined) => `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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
// Support email can be configured by the application
|
||||
export const getSupportEmail = (defaultEmail: string = ""): string => defaultEmail;
|
||||
|
||||
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
|
||||
|
|
|
|||
46
packages/utils/src/datetime.ts
Normal file
46
packages/utils/src/datetime.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { format, isValid } from "date-fns";
|
||||
|
||||
/**
|
||||
* This method returns a date from string of type yyyy-mm-dd
|
||||
* This method is recommended to use instead of new Date() as this does not introduce any timezone offsets
|
||||
* @param date
|
||||
* @returns date or undefined
|
||||
*/
|
||||
export const getDate = (date: string | Date | undefined | null): Date | undefined => {
|
||||
try {
|
||||
if (!date || date === "") return;
|
||||
|
||||
if (typeof date !== "string" && !(date instanceof String)) return date;
|
||||
|
||||
const [yearString, monthString, dayString] = date.substring(0, 10).split("-");
|
||||
const year = parseInt(yearString);
|
||||
const month = parseInt(monthString);
|
||||
const day = parseInt(dayString);
|
||||
// Using Number.isInteger instead of lodash's isNumber for better specificity and no external dependency
|
||||
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return;
|
||||
|
||||
return new Date(year, month - 1, day);
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @returns {string | null} formatted date in the format of MMM dd, yyyy
|
||||
* @description Returns date in the formatted format
|
||||
* @param {Date | string} date
|
||||
* @example renderFormattedDate("2024-01-01") // Jan 01, 2024
|
||||
*/
|
||||
export const renderFormattedDate = (date: string | Date | undefined | null): string | null => {
|
||||
// Parse the date to check if it is valid
|
||||
const parsedDate = getDate(date);
|
||||
// return if undefined
|
||||
if (!parsedDate) return null;
|
||||
// Check if the parsed date is valid before formatting
|
||||
if (!isValid(parsedDate)) return null; // Return null for invalid dates
|
||||
// Format the date in format (MMM dd, yyyy)
|
||||
const formattedDate = format(parsedDate, "MMM dd, yyyy");
|
||||
return formattedDate;
|
||||
};
|
||||
|
||||
// Note: timeAgo function was incomplete in the original file, so it has been omitted
|
||||
103
packages/utils/src/editor.ts
Normal file
103
packages/utils/src/editor.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { MAX_FILE_SIZE } from "@plane/constants";
|
||||
import { getFileURL } from "./file";
|
||||
|
||||
// Define image-related types locally
|
||||
type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<void>;
|
||||
type RestoreImage = (assetUrlWithWorkspaceId: string) => Promise<void>;
|
||||
type UploadImage = (file: File) => Promise<string>;
|
||||
|
||||
// Define the FileService interface based on usage
|
||||
interface IFileService {
|
||||
deleteOldEditorAsset: (workspaceId: string, src: string) => Promise<void>;
|
||||
deleteNewAsset: (url: string) => Promise<void>;
|
||||
restoreOldEditorAsset: (workspaceId: string, src: string) => Promise<void>;
|
||||
restoreNewAsset: (anchor: string, src: string) => Promise<void>;
|
||||
cancelUpload: () => void;
|
||||
}
|
||||
|
||||
// Define TFileHandler locally since we can't import from @plane/editor
|
||||
interface TFileHandler {
|
||||
getAssetSrc: (path: string) => Promise<string>;
|
||||
cancel: () => void;
|
||||
delete: DeleteImage;
|
||||
upload: UploadImage;
|
||||
restore: RestoreImage;
|
||||
validation: {
|
||||
maxFileSize: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @description generate the file source using assetId
|
||||
* @param {string} anchor
|
||||
* @param {string} assetId
|
||||
*/
|
||||
export const getEditorAssetSrc = (anchor: string, assetId: string): string | undefined => {
|
||||
const url = getFileURL(`/api/public/assets/v2/anchor/${anchor}/${assetId}/`);
|
||||
return url;
|
||||
};
|
||||
|
||||
type TArgs = {
|
||||
anchor: string;
|
||||
uploadFile: (file: File) => Promise<string>;
|
||||
workspaceId: string;
|
||||
fileService: IFileService;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description this function returns the file handler required by the editors
|
||||
* @param {TArgs} args
|
||||
*/
|
||||
export const getEditorFileHandlers = (args: TArgs): TFileHandler => {
|
||||
const { anchor, uploadFile, workspaceId, fileService } = args;
|
||||
|
||||
return {
|
||||
getAssetSrc: async (path: string) => {
|
||||
if (!path) return "";
|
||||
if (path?.startsWith("http")) {
|
||||
return path;
|
||||
} else {
|
||||
return getEditorAssetSrc(anchor, path) ?? "";
|
||||
}
|
||||
},
|
||||
upload: uploadFile,
|
||||
delete: async (src: string) => {
|
||||
if (src?.startsWith("http")) {
|
||||
await fileService.deleteOldEditorAsset(workspaceId, src);
|
||||
} else {
|
||||
await fileService.deleteNewAsset(getEditorAssetSrc(anchor, src) ?? "");
|
||||
}
|
||||
},
|
||||
restore: async (src: string) => {
|
||||
if (src?.startsWith("http")) {
|
||||
await fileService.restoreOldEditorAsset(workspaceId, src);
|
||||
} else {
|
||||
await fileService.restoreNewAsset(anchor, src);
|
||||
}
|
||||
},
|
||||
cancel: fileService.cancelUpload,
|
||||
validation: {
|
||||
maxFileSize: MAX_FILE_SIZE,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* @description this function returns the file handler required by the read-only editors
|
||||
*/
|
||||
export const getReadOnlyEditorFileHandlers = (
|
||||
args: Pick<TArgs, "anchor">
|
||||
): { getAssetSrc: TFileHandler["getAssetSrc"] } => {
|
||||
const { anchor } = args;
|
||||
|
||||
return {
|
||||
getAssetSrc: async (path: string) => {
|
||||
if (!path) return "";
|
||||
if (path?.startsWith("http")) {
|
||||
return path;
|
||||
} else {
|
||||
return getEditorAssetSrc(anchor, path) ?? "";
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -38,3 +38,27 @@ export const emojiCodeToUnicode = (emoji: string): string => {
|
|||
|
||||
return uniCodeEmoji;
|
||||
};
|
||||
|
||||
/**
|
||||
* Groups reactions by a specified key
|
||||
* @param {T[]} reactions - Array of reaction objects
|
||||
* @param {string} key - Key to group reactions by
|
||||
* @returns {Object} Object with reactions grouped by the specified key
|
||||
* @example
|
||||
* const reactions = [{ reaction: "👍", id: 1 }, { reaction: "👍", id: 2 }, { reaction: "❤️", id: 3 }];
|
||||
* groupReactions(reactions, "reaction") // returns { "👍": [{ reaction: "👍", id: 1 }, { reaction: "👍", id: 2 }], "❤️": [{ reaction: "❤️", id: 3 }] }
|
||||
*/
|
||||
export const groupReactions = <T extends { reaction: string }>(reactions: T[], key: string): { [key: string]: T[] } => {
|
||||
const groupedReactions = reactions.reduce(
|
||||
(acc: { [key: string]: T[] }, reaction: T) => {
|
||||
if (!acc[reaction[key as keyof T] as string]) {
|
||||
acc[reaction[key as keyof T] as string] = [];
|
||||
}
|
||||
acc[reaction[key as keyof T] as string].push(reaction);
|
||||
return acc;
|
||||
},
|
||||
{} as { [key: string]: T[] }
|
||||
);
|
||||
|
||||
return groupedReactions;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { API_BASE_URL } from "@plane/constants";
|
||||
import { TFileMetaDataLite, TFileSignedURLResponse } from "@plane/types";
|
||||
|
||||
/**
|
||||
* @description combine the file path with the base URL
|
||||
|
|
@ -11,3 +12,38 @@ export const getFileURL = (path: string): string | undefined => {
|
|||
if (isValidURL) return path;
|
||||
return `${API_BASE_URL}${path}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description from the provided signed URL response, generate a payload to be used to upload the file
|
||||
* @param {TFileSignedURLResponse} signedURLResponse
|
||||
* @param {File} file
|
||||
* @returns {FormData} file upload request payload
|
||||
*/
|
||||
export const generateFileUploadPayload = (signedURLResponse: TFileSignedURLResponse, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
Object.entries(signedURLResponse.upload_data.fields).forEach(([key, value]) => formData.append(key, value));
|
||||
formData.append("file", file);
|
||||
return formData;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description returns the necessary file meta data to upload a file
|
||||
* @param {File} file
|
||||
* @returns {TFileMetaDataLite} payload with file info
|
||||
*/
|
||||
export const getFileMetaDataForUpload = (file: File): TFileMetaDataLite => ({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
});
|
||||
|
||||
/**
|
||||
* @description this function returns the assetId from the asset source
|
||||
* @param {string} src
|
||||
* @returns {string} assetId
|
||||
*/
|
||||
export const getAssetIdFromUrl = (src: string): string => {
|
||||
const sourcePaths = src.split("/");
|
||||
const assetUrl = sourcePaths[sourcePaths.length - 1];
|
||||
return assetUrl;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
export * from "./auth";
|
||||
export * from "./color";
|
||||
export * from "./common";
|
||||
export * from "./datetime";
|
||||
export * from "./editor";
|
||||
export * from "./emoji";
|
||||
export * from "./file";
|
||||
export * from "./issue";
|
||||
export * from "./state";
|
||||
export * from "./string";
|
||||
export * from "./theme";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,12 @@
|
|||
import { ISSUE_PRIORITY_FILTERS, TIssuePriorities, TIssueFilterPriorityObject } from "@plane/constants";
|
||||
import { differenceInCalendarDays } from "date-fns";
|
||||
import {
|
||||
ISSUE_PRIORITY_FILTERS,
|
||||
STATE_GROUPS,
|
||||
TIssuePriorities,
|
||||
TIssueFilterPriorityObject
|
||||
} from "@plane/constants";
|
||||
import { TStateGroups } from "@plane/types";
|
||||
import { getDate } from "./datetime";
|
||||
|
||||
export const getIssuePriorityFilters = (priorityKey: TIssuePriorities): TIssueFilterPriorityObject | undefined => {
|
||||
const currentIssuePriority: TIssueFilterPriorityObject | undefined =
|
||||
|
|
@ -9,3 +17,26 @@ export const getIssuePriorityFilters = (priorityKey: TIssuePriorities): TIssueFi
|
|||
if (currentIssuePriority) return currentIssuePriority;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description check if the issue due date should be highlighted
|
||||
* @param date
|
||||
* @param stateGroup
|
||||
* @returns boolean
|
||||
*/
|
||||
export const shouldHighlightIssueDueDate = (
|
||||
date: string | Date | null,
|
||||
stateGroup: TStateGroups | undefined
|
||||
): boolean => {
|
||||
if (!date || !stateGroup) return false;
|
||||
// if the issue is completed or cancelled, don't highlight the due date
|
||||
if ([STATE_GROUPS.completed.key, STATE_GROUPS.cancelled.key].includes(stateGroup)) return false;
|
||||
|
||||
const parsedDate = getDate(date);
|
||||
if (!parsedDate) return false;
|
||||
|
||||
const targetDateDistance = differenceInCalendarDays(parsedDate, new Date());
|
||||
|
||||
// if the issue is overdue, highlight the due date
|
||||
return targetDateDistance <= 0;
|
||||
};
|
||||
|
|
|
|||
13
packages/utils/src/state.ts
Normal file
13
packages/utils/src/state.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { STATE_GROUPS } from "@plane/constants";
|
||||
import { IState } from "@plane/types";
|
||||
|
||||
export const sortStates = (states: IState[]) => {
|
||||
if (!states || states.length === 0) return;
|
||||
|
||||
return states.sort((stateA, stateB) => {
|
||||
if (stateA.group === stateB.group) {
|
||||
return stateA.sequence - stateB.sequence;
|
||||
}
|
||||
return Object.keys(STATE_GROUPS).indexOf(stateA.group) - Object.keys(STATE_GROUPS).indexOf(stateB.group);
|
||||
});
|
||||
};
|
||||
|
|
@ -1,5 +1,11 @@
|
|||
import DOMPurify from "isomorphic-dompurify";
|
||||
|
||||
export const addSpaceIfCamelCase = (str: string) => str.replace(/([a-z])([A-Z])/g, "$1 $2");
|
||||
|
||||
export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " ");
|
||||
|
||||
export const capitalizeFirstLetter = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
|
||||
|
||||
/**
|
||||
* @description: This function will remove all the HTML tags from the string
|
||||
* @param {string} html
|
||||
|
|
@ -14,6 +20,49 @@ export const sanitizeHTML = (htmlString: string) => {
|
|||
return sanitizedText.trim(); // trim the string to remove leading and trailing whitespaces
|
||||
};
|
||||
|
||||
/**
|
||||
* @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 this function returns whether a comment is empty or not by checking for the following conditions-
|
||||
* 1. If comment is undefined
|
||||
* 2. If comment is an empty string
|
||||
* 3. If comment is "<p></p>"
|
||||
* @param {string | undefined} comment
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isCommentEmpty = (comment: string | undefined): boolean => {
|
||||
// return true if comment is undefined
|
||||
if (!comment) return true;
|
||||
return (
|
||||
comment?.trim() === "" ||
|
||||
comment === "<p></p>" ||
|
||||
isEmptyHtmlString(comment ?? "", ["img", "mention-component", "image-component"])
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description
|
||||
* This function test whether a URL is valid or not.
|
||||
|
|
@ -35,3 +84,44 @@ export const checkURLValidity = (url: string): boolean => {
|
|||
|
||||
return urlPattern.test(url);
|
||||
};
|
||||
|
||||
// Browser-only clipboard functions
|
||||
let copyTextToClipboard: (text: string) => Promise<void>;
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
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) {}
|
||||
|
||||
document.body.removeChild(textArea);
|
||||
};
|
||||
|
||||
copyTextToClipboard = async (text: string) => {
|
||||
if (!navigator.clipboard) {
|
||||
fallbackCopyTextToClipboard(text);
|
||||
return;
|
||||
}
|
||||
await navigator.clipboard.writeText(text);
|
||||
};
|
||||
} else {
|
||||
copyTextToClipboard = async () => {
|
||||
throw new Error("copyTextToClipboard is only available in browser environments");
|
||||
};
|
||||
}
|
||||
|
||||
export { copyTextToClipboard };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue