diff --git a/package-lock.json b/package-lock.json index 77ea639..ae970c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,13 @@ { - "name": "rcb-plugin", + "name": "@rcb-plugins/input-validator", "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "rcb-plugin", + "name": "@rcb-plugins/input-validator", "version": "0.1.1", + "license": "MIT", "devDependencies": { "@eslint/js": "^9.11.1", "@types/react": "^18.3.10", diff --git a/src/App.tsx b/src/App.tsx index 760f700..202451d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,36 +2,61 @@ import ChatBot, { Flow } from "react-chatbotify"; import RcbPlugin from "./factory/RcbPluginFactory"; import { InputValidatorBlock } from "./types/InputValidatorBlock"; +import { validateFile } from "./utils/validateFile"; const App = () => { - // initialize example plugin - const plugins = [RcbPlugin()]; + // Initialize the plugin + const plugins = [RcbPlugin()]; - // example flow for testing - const flow: Flow = { - start: { - message: "Hey there, please enter your age!", - path: "try_again", - validateInput: (userInput: string) => { - if (typeof userInput === "string" && !Number.isNaN(Number(userInput))) { - return {success: true}; - } - return {success: false, promptContent: "Age must be a number!", promptDuration: 3000, promptType: "error", highlightTextArea: true}; - } - } as InputValidatorBlock, - try_again : { - message: "Nice, you passed the input validation!", - path: "start", - } - } + // Example flow for testing + const flow: Flow = { + start: { + message: "Hey there! Please enter your age.", + path: "age_validation", + validateTextInput: (userInput?: string) => { + if (userInput && !Number.isNaN(Number(userInput))) { + return { success: true }; + } + return { + success: false, + promptContent: "Age must be a number!", + promptDuration: 3000, + promptType: "error", + highlightTextArea: true, + }; + }, + } as InputValidatorBlock, - return ( - - ); -} + age_validation: { + message: + "Great! Now please upload a profile picture (JPEG or PNG) or provide a URL.", + path: "file_upload_validation", + chatDisabled: true, // Text input is disabled + validateFileInput: (file?: File) => { + return validateFile(file); // Validation is handled here + }, + file: async ({ files }) => { + console.log("Files received:", files); + + if (files && files[0]) { + console.log("File uploaded successfully:", files[0]); + } else { + console.error("No file provided."); + } + }, + } as InputValidatorBlock, + -export default App; \ No newline at end of file + file_upload_validation: { + message: + "Thank you! Your input has been received. You passed the validation!", + path: "start", + }, + }; + + return ( + + ); +}; + +export default App; diff --git a/src/core/useRcbPlugin.ts b/src/core/useRcbPlugin.ts index b8798ac..16a3daf 100644 --- a/src/core/useRcbPlugin.ts +++ b/src/core/useRcbPlugin.ts @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { useBotId, RcbUserSubmitTextEvent, + RcbUserUploadFileEvent, useToasts, useFlow, useStyles, @@ -31,67 +32,116 @@ const useRcbPlugin = (pluginConfig?: PluginConfig) => { useEffect(() => { /** - * Handles the user submitting input event. + * Handles the user submitting text input event. * - * @param event event emitted when user submits input + * @param event Event emitted when user submits text input. */ - const handleUserSubmitText = (event: RcbUserSubmitTextEvent): void => { - // gets validator and if no validator, return - const validator = getValidator(event, getBotId(), getFlow()); + const handleUserSubmitText = (event: Event): void => { + const rcbEvent = event as RcbUserSubmitTextEvent; + + // Get validator and if no validator, return + const validator = getValidator( + rcbEvent, + getBotId(), + getFlow(), + "validateTextInput" + ); if (!validator) { return; } - // gets and checks validation result + // Get and check validation result const validationResult = validator( - event.data.inputText + rcbEvent.data.inputText ) as ValidationResult; if (!validationResult?.success) { event.preventDefault(); } - // if nothing to prompt, return + // If nothing to prompt, return if (!validationResult.promptContent) { return; } - // if this is the first plugin toast, preserve original styles for restoration later + // Preserve original styles if this is the first plugin toast if (numPluginToasts === 0) { - originalStyles.current = structuredClone(styles) + originalStyles.current = structuredClone(styles); } const promptStyles = getPromptStyles( validationResult, mergedPluginConfig ); - // update toast with prompt styles + // Update styles with prompt styles updateStyles(promptStyles); - // shows prompt toast to user + // Show prompt toast to user showToast( validationResult.promptContent, validationResult.promptDuration ?? 3000 ); - // increases number of plugin toasts by 1 + // Increase number of plugin toasts by 1 setNumPluginToasts((prev) => prev + 1); }; + const handleUserUploadFile = (event: Event): void => { + const rcbEvent = event as RcbUserUploadFileEvent; + const file: File | undefined = rcbEvent.data?.files?.[0]; + + if (!file) { + console.error("No file uploaded."); + event.preventDefault(); + return; + } + + const validator = getValidator( + rcbEvent, + getBotId(), + getFlow(), + "validateFileInput" + ); + + if (!validator) { + console.error("Validator not found for file input."); + return; + } + + const validationResult = validator(file); + + if (!validationResult.success) { + console.error("Validation failed:", validationResult); + if (validationResult.promptContent) { + showToast( + validationResult.promptContent, + validationResult.promptDuration ?? 3000 + ); + } + event.preventDefault(); + return; + } + + console.log("Validation successful:", validationResult); + }; + /** * Handles the dismiss toast event. * - * @param event event emitted when toast is dismissed + * @param event Event emitted when toast is dismissed. */ const handleDismissToast = (): void => { setNumPluginToasts((prev) => prev - 1); }; - // adds required events + // Add required event listeners window.addEventListener("rcb-user-submit-text", handleUserSubmitText); + window.addEventListener("rcb-user-upload-file", handleUserUploadFile); window.addEventListener("rcb-dismiss-toast", handleDismissToast); return () => { + // Remove event listeners window.removeEventListener("rcb-user-submit-text", handleUserSubmitText); + window.removeEventListener("rcb-user-upload-file", handleUserUploadFile); window.removeEventListener("rcb-dismiss-toast", handleDismissToast); }; }, [ @@ -101,28 +151,29 @@ const useRcbPlugin = (pluginConfig?: PluginConfig) => { updateStyles, styles, mergedPluginConfig, - numPluginToasts + numPluginToasts, ]); - // restores original styles when plugin toasts are all dismissed + // Restore original styles when all plugin toasts are dismissed useEffect(() => { if (numPluginToasts === 0) { setTimeout(() => { replaceStyles(originalStyles.current); }); } - }, [numPluginToasts, replaceStyles, originalStyles]); + }, [numPluginToasts, replaceStyles]); - // initializes plugin metadata with plugin name + // Initialize plugin metadata with plugin name const pluginMetaData: ReturnType = { - name: "@rcb-plugins/input-validator" + name: "@rcb-plugins/input-validator", }; - // adds required events in settings if auto config is true + // Add required events in settings if autoConfig is true if (mergedPluginConfig.autoConfig) { pluginMetaData.settings = { event: { rcbUserSubmitText: true, + rcbUserUploadFile: true, rcbDismissToast: true, }, }; diff --git a/src/types/InputValidatorBlock.ts b/src/types/InputValidatorBlock.ts index ffb923d..f93fbc7 100644 --- a/src/types/InputValidatorBlock.ts +++ b/src/types/InputValidatorBlock.ts @@ -2,8 +2,9 @@ import { Block } from "react-chatbotify"; import { ValidationResult } from "./ValidationResult"; /** - * Extends the Block from React ChatBotify to support inputValidator attribute. + * Extends the Block from React ChatBotify to support inputValidator attributes. */ export type InputValidatorBlock = Block & { - validateInput: (userInput?: string) => ValidationResult; -}; \ No newline at end of file + validateTextInput?: (userInput?: string) => ValidationResult; + validateFileInput?: (files?: FileList) => ValidationResult; // Accepts multiple files +}; diff --git a/src/utils/getValidator.ts b/src/utils/getValidator.ts index e61f27d..b217158 100644 --- a/src/utils/getValidator.ts +++ b/src/utils/getValidator.ts @@ -1,15 +1,27 @@ -import { Flow, RcbUserSubmitTextEvent } from "react-chatbotify"; +import { Flow, RcbUserSubmitTextEvent, RcbUserUploadFileEvent } from "react-chatbotify"; import { InputValidatorBlock } from "../types/InputValidatorBlock"; +import { ValidationResult } from "../types/ValidationResult"; /** - * Retrieves the validator function and returns null if not applicable. + * Union type for user events that can be validated. */ -export const getValidator = (event: RcbUserSubmitTextEvent, currBotId: string | null, currFlow: Flow) => { - if (currBotId !== event.detail.botId) { - return; - } - - if (!event.detail.currPath) { +type RcbUserEvent = RcbUserSubmitTextEvent | RcbUserUploadFileEvent; + +/** + * Retrieves the validator function from the current flow block. + * + * @param event The event emitted by the user action (text submission or file upload). + * @param currBotId The current bot ID. + * @param currFlow The current flow object. + * @returns The validator function if it exists, otherwise undefined. + */ +export const getValidator = ( + event: RcbUserEvent, + currBotId: string | null, + currFlow: Flow, + validatorType: "validateTextInput" | "validateFileInput" = "validateTextInput" +): ((input: T) => ValidationResult) | undefined => { + if (!event.detail?.currPath || currBotId !== event.detail.botId) { return; } @@ -18,12 +30,6 @@ export const getValidator = (event: RcbUserSubmitTextEvent, currBotId: string | return; } - const validator = currBlock.validateInput; - const isValidatorFunction = - validator && typeof validator === "function"; - if (!isValidatorFunction) { - return; - } - - return validator; -} \ No newline at end of file + const validator = currBlock[validatorType] as ((input: T) => ValidationResult) | undefined; + return typeof validator === "function" ? validator : undefined; +}; diff --git a/src/utils/validateFile.ts b/src/utils/validateFile.ts new file mode 100644 index 0000000..001d801 --- /dev/null +++ b/src/utils/validateFile.ts @@ -0,0 +1,54 @@ +import { ValidationResult } from "../types/ValidationResult"; + +/** + * Validates uploaded files. + * Ensures each file is of an allowed type and size, and rejects invalid inputs. + */ +export const validateFile = (input?: File | FileList): ValidationResult => { + const allowedTypes = ["image/jpeg", "image/png"]; + const maxSizeInBytes = 5 * 1024 * 1024; // 5MB + const files: File[] = input instanceof FileList ? Array.from(input) : input ? [input] : []; + + // Check if no files are provided + if (files.length === 0) { + return { + success: false, + promptContent: "No files uploaded.", + promptDuration: 3000, + promptType: "error", + }; + } + + // Validate each file + for (const file of files) { + // Check if the file is empty + if (file.size === 0) { + return { + success: false, + promptContent: `The file "${file.name}" is empty. Please upload a valid file.`, + promptDuration: 3000, + promptType: "error", + }; + } + + // Validate file type + if (!allowedTypes.includes(file.type)) { + return { + success: false, + promptContent: `The file "${file.name}" is not a valid type. Only JPEG or PNG files are allowed.`, + promptType: "error", + }; + } + + // Validate file size + if (file.size > maxSizeInBytes) { + return { + success: false, + promptContent: `The file "${file.name}" exceeds the 5MB size limit.`, + promptType: "error", + }; + } + } + + return { success: true }; +};