From 9f5def3a6a3ea816c9de166b674eed6d0b8231c5 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 23 Dec 2024 14:30:13 +0530 Subject: [PATCH] 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 * refactor: move enums from utils/auth.ts to @plane/constants/auth.ts Co-Authored-By: sriram@plane.so --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: sriram@plane.so --- packages/constants/src/auth.ts | 12 +- packages/utils/src/auth.ts | 291 +++++++++++++++++++++++++++++++++ packages/utils/src/common.ts | 3 + packages/utils/src/datetime.ts | 46 ++++++ packages/utils/src/editor.ts | 103 ++++++++++++ packages/utils/src/emoji.ts | 24 +++ packages/utils/src/file.ts | 36 ++++ packages/utils/src/index.ts | 3 + packages/utils/src/issue.ts | 33 +++- packages/utils/src/state.ts | 13 ++ packages/utils/src/string.ts | 90 ++++++++++ 11 files changed, 651 insertions(+), 3 deletions(-) create mode 100644 packages/utils/src/datetime.ts create mode 100644 packages/utils/src/editor.ts create mode 100644 packages/utils/src/state.ts diff --git a/packages/constants/src/auth.ts b/packages/constants/src/auth.ts index 8d34766d467..884a8dd1c89 100644 --- a/packages/constants/src/auth.ts +++ b/packages/constants/src/auth.ts @@ -39,6 +39,14 @@ export enum EAuthPageTypes { AUTHENTICATED = "AUTHENTICATED", } +export enum EPageTypes { + INIT = "INIT", + PUBLIC = "PUBLIC", + NON_AUTHENTICATED = "NON_AUTHENTICATED", + ONBOARDING = "ONBOARDING", + AUTHENTICATED = "AUTHENTICATED", +} + export enum EAuthModes { SIGN_IN = "SIGN_IN", SIGN_UP = "SIGN_UP", @@ -50,9 +58,9 @@ export enum EAuthSteps { UNIQUE_CODE = "UNIQUE_CODE", } -// TODO: remove this export enum EErrorAlertType { BANNER_ALERT = "BANNER_ALERT", + TOAST_ALERT = "TOAST_ALERT", INLINE_FIRST_NAME = "INLINE_FIRST_NAME", INLINE_EMAIL = "INLINE_EMAIL", INLINE_PASSWORD = "INLINE_PASSWORD", @@ -127,7 +135,7 @@ export enum EAuthErrorCodes { INCORRECT_OLD_PASSWORD = "5135", MISSING_PASSWORD = "5138", INVALID_NEW_PASSWORD = "5140", - // set passowrd + // set password PASSWORD_ALREADY_SET = "5145", // Admin ADMIN_ALREADY_EXIST = "5150", diff --git a/packages/utils/src/auth.ts b/packages/utils/src/auth.ts index 2fe7ba732bc..bea3eb275f5 100644 --- a/packages/utils/src/auth.ts +++ b/packages/utils/src/auth.ts @@ -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; +}; diff --git a/packages/utils/src/common.ts b/packages/utils/src/common.ts index a500a738583..fb47656d3b2 100644 --- a/packages/utils/src/common.ts +++ b/packages/utils/src/common.ts @@ -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)); diff --git a/packages/utils/src/datetime.ts b/packages/utils/src/datetime.ts new file mode 100644 index 00000000000..d558d1661b9 --- /dev/null +++ b/packages/utils/src/datetime.ts @@ -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 diff --git a/packages/utils/src/editor.ts b/packages/utils/src/editor.ts new file mode 100644 index 00000000000..809c1dd3d2a --- /dev/null +++ b/packages/utils/src/editor.ts @@ -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; +type RestoreImage = (assetUrlWithWorkspaceId: string) => Promise; +type UploadImage = (file: File) => Promise; + +// Define the FileService interface based on usage +interface IFileService { + deleteOldEditorAsset: (workspaceId: string, src: string) => Promise; + deleteNewAsset: (url: string) => Promise; + restoreOldEditorAsset: (workspaceId: string, src: string) => Promise; + restoreNewAsset: (anchor: string, src: string) => Promise; + cancelUpload: () => void; +} + +// Define TFileHandler locally since we can't import from @plane/editor +interface TFileHandler { + getAssetSrc: (path: string) => Promise; + 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; + 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 +): { 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) ?? ""; + } + }, + }; +}; diff --git a/packages/utils/src/emoji.ts b/packages/utils/src/emoji.ts index 306d4afef86..9b796575a8b 100644 --- a/packages/utils/src/emoji.ts +++ b/packages/utils/src/emoji.ts @@ -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 = (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; +}; diff --git a/packages/utils/src/file.ts b/packages/utils/src/file.ts index 6e3394abe97..42b52bf48fa 100644 --- a/packages/utils/src/file.ts +++ b/packages/utils/src/file.ts @@ -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; +}; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 98399070704..597fb5db950 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -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"; diff --git a/packages/utils/src/issue.ts b/packages/utils/src/issue.ts index 8bc5165b94f..1097e190b5f 100644 --- a/packages/utils/src/issue.ts +++ b/packages/utils/src/issue.ts @@ -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; +}; diff --git a/packages/utils/src/state.ts b/packages/utils/src/state.ts new file mode 100644 index 00000000000..8d97c39f617 --- /dev/null +++ b/packages/utils/src/state.ts @@ -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); + }); +}; diff --git a/packages/utils/src/string.ts b/packages/utils/src/string.ts index 753231b923c..7b2ffa858af 100644 --- a/packages/utils/src/string.ts +++ b/packages/utils/src/string.ts @@ -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 "

" + * @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 === "

" || + 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; + +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 };