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:
devin-ai-integration[bot] 2024-12-23 14:30:13 +05:30 committed by GitHub
parent 043f4eaa5e
commit 9f5def3a6a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 651 additions and 3 deletions

View file

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

View file

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

View 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

View 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) ?? "";
}
},
};
};

View file

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

View file

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

View file

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

View file

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

View 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);
});
};

View file

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