From ec10281af961fe847f7ca86d20729c52e6301bd0 Mon Sep 17 00:00:00 2001 From: Yashwin Date: Thu, 14 May 2026 12:47:52 +0530 Subject: [PATCH] feat: add search functionality to FormSelect and MultiselectPaginatedSelect, and update task assignment display logic --- src/components/layout/Layout.tsx | 4 +- src/components/shared/FormSelect.tsx | 56 ++++- .../shared/MultiselectPaginatedSelect.tsx | 55 ++++- .../shared/WorkflowDefinitionModal.tsx | 223 ++++++++++++------ src/pages/tenant/Dashboard.tsx | 6 +- src/pages/tenant/Tasks.tsx | 7 +- src/pages/tenant/ViewDocument.tsx | 3 +- src/services/user-service.ts | 9 + src/types/workflow.ts | 11 +- 9 files changed, 264 insertions(+), 110 deletions(-) diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index e0f3868..04f2b76 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -33,9 +33,9 @@ export const Layout = ({ }; return ( -
+
{/* Background */} -
+
{/* Content Wrapper */}
diff --git a/src/components/shared/FormSelect.tsx b/src/components/shared/FormSelect.tsx index 250e95f..0f64791 100644 --- a/src/components/shared/FormSelect.tsx +++ b/src/components/shared/FormSelect.tsx @@ -17,6 +17,7 @@ interface FormSelectProps extends Omit, options: SelectOption[]; placeholder?: string; onValueChange?: (value: string) => void; + isSearchable?: boolean; } export const FormSelect = ({ @@ -30,9 +31,11 @@ export const FormSelect = ({ id, value, onValueChange, + isSearchable = false, ...props }: FormSelectProps): ReactElement => { const [isOpen, setIsOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); const [selectedValue, setSelectedValue] = useState(value as string || ''); const [dropdownStyle, setDropdownStyle] = useState<{ top?: string; bottom?: string; left: string; width: string }>({ left: '0', width: '0' }); const dropdownRef = useRef(null); @@ -60,6 +63,10 @@ export const FormSelect = ({ setIsOpen(false); }; + if (!isOpen) { + setSearchTerm(''); + } + if (isOpen && buttonRef.current) { document.addEventListener('mousedown', handleClickOutside); window.addEventListener('scroll', handleScroll, true); @@ -109,6 +116,11 @@ export const FormSelect = ({ const fieldId = id || `select-${label.toLowerCase().replace(/\s+/g, '-')}`; const hasError = Boolean(error); + + const filteredOptions = options.filter(opt => + opt.label.toLowerCase().includes(searchTerm.toLowerCase()) + ); + const selectedOption = options.find((opt) => opt.value === selectedValue); const handleSelect = (optionValue: string) => { @@ -161,21 +173,39 @@ export const FormSelect = ({ className="fixed bg-white border border-[rgba(0,0,0,0.2)] rounded-md shadow-lg z-[250] max-h-60 overflow-y-auto" style={dropdownStyle} > + {isSearchable && ( +
+ setSearchTerm(e.target.value)} + onClick={(e) => e.stopPropagation()} + /> +
+ )}
    - {options.map((option) => ( -
  • - + {filteredOptions.length > 0 ? ( + filteredOptions.map((option) => ( +
  • + +
  • + )) + ) : ( +
  • + No results found
  • - ))} + )}
, document.body diff --git a/src/components/shared/MultiselectPaginatedSelect.tsx b/src/components/shared/MultiselectPaginatedSelect.tsx index 12d1658..4313b09 100644 --- a/src/components/shared/MultiselectPaginatedSelect.tsx +++ b/src/components/shared/MultiselectPaginatedSelect.tsx @@ -1,7 +1,7 @@ import { useState, useRef, useEffect, useCallback } from "react"; import { createPortal } from "react-dom"; import type { ReactElement } from "react"; -import { ChevronDown, Loader2, X } from "lucide-react"; +import { ChevronDown, Loader2, X, Search } from "lucide-react"; import { cn } from "@/lib/utils"; interface MultiselectPaginatedSelectOption { @@ -20,6 +20,7 @@ interface MultiselectPaginatedSelectProps { onLoadOptions: ( page: number, limit: number, + search?: string, ) => Promise<{ options: MultiselectPaginatedSelectOption[]; pagination: { @@ -35,6 +36,7 @@ interface MultiselectPaginatedSelectProps { id?: string; multiple?: boolean; disabled?: boolean; + isSearchable?: boolean; } export const MultiselectPaginatedSelect = ({ @@ -51,6 +53,7 @@ export const MultiselectPaginatedSelect = ({ id, multiple = true, disabled = false, + isSearchable = false, }: MultiselectPaginatedSelectProps): ReactElement => { const [isOpen, setIsOpen] = useState(false); const [options, setOptions] = useState( @@ -58,6 +61,7 @@ export const MultiselectPaginatedSelect = ({ ); const [isLoading, setIsLoading] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); const [pagination, setPagination] = useState<{ page: number; limit: number; @@ -76,6 +80,7 @@ export const MultiselectPaginatedSelect = ({ const buttonRef = useRef(null); const dropdownMenuRef = useRef(null); const scrollContainerRef = useRef(null); + const searchInputRef = useRef(null); const [dropdownStyle, setDropdownStyle] = useState<{ top?: string; bottom?: string; @@ -85,7 +90,7 @@ export const MultiselectPaginatedSelect = ({ // Load initial options const loadOptions = useCallback( - async (page: number = 1, append: boolean = false) => { + async (page: number = 1, append: boolean = false, search: string = "") => { try { if (page === 1) { setIsLoading(true); @@ -93,7 +98,7 @@ export const MultiselectPaginatedSelect = ({ setIsLoadingMore(true); } - const result = await onLoadOptions(page, pagination.limit); + const result = await onLoadOptions(page, pagination.limit, search); if (append) { setOptions((prev) => [...prev, ...result.options]); @@ -116,10 +121,25 @@ export const MultiselectPaginatedSelect = ({ useEffect(() => { if (isOpen) { if (options.length === 0) { - loadOptions(1, false); + loadOptions(1, false, searchTerm); + } + // Focus search input if searchable + if (isSearchable) { + setTimeout(() => searchInputRef.current?.focus(), 0); } } - }, [isOpen, options.length, loadOptions]); + }, [isOpen, options.length, loadOptions, isSearchable]); + + // Debounced search + useEffect(() => { + if (!isOpen || !isSearchable) return; + + const timer = setTimeout(() => { + loadOptions(1, false, searchTerm); + }, 300); + + return () => clearTimeout(timer); + }, [searchTerm, isOpen, isSearchable, loadOptions]); // Handle scroll for infinite loading useEffect(() => { @@ -131,7 +151,7 @@ export const MultiselectPaginatedSelect = ({ const isNearBottom = scrollTop + clientHeight >= scrollHeight - 50; if (isNearBottom && pagination.hasMore && !isLoadingMore && !isLoading) { - loadOptions(pagination.page + 1, true); + loadOptions(pagination.page + 1, true, searchTerm); } }; @@ -139,7 +159,7 @@ export const MultiselectPaginatedSelect = ({ return () => { scrollContainer.removeEventListener("scroll", handleScroll); }; - }, [isOpen, pagination, isLoadingMore, isLoading, loadOptions]); + }, [isOpen, pagination, isLoadingMore, isLoading, loadOptions, searchTerm]); // Handle click outside useEffect(() => { @@ -183,7 +203,7 @@ export const MultiselectPaginatedSelect = ({ const rect = buttonRef.current.getBoundingClientRect(); const spaceBelow = window.innerHeight - rect.bottom; const spaceAbove = rect.top; - const dropdownHeight = Math.min(240, 240); + const dropdownHeight = isSearchable ? 300 : 240; const shouldOpenUp = spaceBelow < dropdownHeight && spaceAbove > spaceBelow; @@ -207,7 +227,7 @@ export const MultiselectPaginatedSelect = ({ document.removeEventListener("mousedown", handleClickOutside); window.removeEventListener("scroll", handleScroll, true); }; - }, [isOpen]); + }, [isOpen, isSearchable]); const fieldId = id || `multiselect-${label.toLowerCase().replace(/\s+/g, "-")}`; @@ -314,10 +334,25 @@ export const MultiselectPaginatedSelect = ({ createPortal(
+ {isSearchable && ( +
+
+ + setSearchTerm(e.target.value)} + /> +
+
+ )} {isLoading && allOptions.length === 0 ? (
diff --git a/src/components/shared/WorkflowDefinitionModal.tsx b/src/components/shared/WorkflowDefinitionModal.tsx index 44fd47c..6c7a310 100644 --- a/src/components/shared/WorkflowDefinitionModal.tsx +++ b/src/components/shared/WorkflowDefinitionModal.tsx @@ -30,8 +30,8 @@ const stepSchema = z sequence: z.number().int().min(1), step_type: z.enum(["initial", "task", "approval", "terminal"]), assignee_type: z.enum(["role", "user", "originator"]).optional(), - assignee_role: z.union([z.string(), z.array(z.string())]).optional(), - assignee_id: z.string().optional(), + assignee_role_ids: z.array(z.string().uuid()).optional(), + assignee_user_ids: z.array(z.string().uuid()).optional(), available_actions: z.array(z.string()).optional(), requires_signature: z.boolean().default(false), requires_comment: z.boolean().default(false), @@ -74,22 +74,19 @@ const stepSchema = z // Conditional validation based on assignee type if (data.assignee_type === "role") { - if ( - !data.assignee_role || - (Array.isArray(data.assignee_role) && data.assignee_role.length === 0) - ) { + if (!data.assignee_role_ids || data.assignee_role_ids.length === 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "At least one role is required", - path: ["assignee_role"], + path: ["assignee_role_ids"], }); } } else if (data.assignee_type === "user") { - if (!data.assignee_id) { + if (!data.assignee_user_ids || data.assignee_user_ids.length === 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "User selection is required", - path: ["assignee_id"], + message: "At least one user is required", + path: ["assignee_user_ids"], }); } } @@ -136,6 +133,18 @@ const workflowSchema = z const hasTerminalStep = data.steps.some( (step: any) => step.step_type === "terminal", ); + const hasInitialStep = data.steps.some( + (step: any) => step.step_type === "initial", + ); + + if (!hasInitialStep) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Workflow must have exactly one initial step", + path: ["steps"], + }); + } + if (!hasTerminalStep) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -143,6 +152,35 @@ const workflowSchema = z path: ["steps"], }); } + + // Connectivity Validation + if (hasInitialStep && hasTerminalStep && data.transitions) { + const transitionFromCodes = new Set(data.transitions.map(t => t.from_step_code)); + const transitionToCodes = new Set(data.transitions.map(t => t.to_step_code)); + + data.steps.forEach((step: any, index: number) => { + const isInitial = step.step_type === 'initial'; + const isTerminal = step.step_type === 'terminal'; + + // 1. Every non-terminal step must have an outgoing transition + if (!isTerminal && !transitionFromCodes.has(step.step_code)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Step '${step.name}' must have at least one outgoing transition`, + path: ["steps", index, "step_code"], + }); + } + + // 2. Every non-initial step must have an incoming transition (be reachable) + if (!isInitial && !transitionToCodes.has(step.step_code)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Step '${step.name}' is unreachable (no transitions lead to it)`, + path: ["steps", index, "step_code"], + }); + } + }); + } } } }); @@ -163,7 +201,7 @@ const StepAssigneeFields = ({ errors, isEdit, loadRoles, - userOptions, + loadUsers, }: any) => { const assigneeType = useWatch({ control, @@ -173,22 +211,18 @@ const StepAssigneeFields = ({ if (assigneeType === "role") { return ( ( )} @@ -199,16 +233,18 @@ const StepAssigneeFields = ({ if (assigneeType === "user") { return ( ( - )} @@ -233,9 +269,6 @@ export const WorkflowDefinitionModal = ({ const [isSubmitting, setIsSubmitting] = useState(false); const isEdit = !!definition; - const [userOptions, setUserOptions] = useState< - { value: string; label: string }[] - >([]); const [availableModules, setAvailableModules] = useState([]); const { @@ -243,6 +276,7 @@ export const WorkflowDefinitionModal = ({ control, handleSubmit, setValue, + setError, formState: { errors }, reset, } = useForm({ @@ -296,24 +330,6 @@ export const WorkflowDefinitionModal = ({ useEffect(() => { if (isOpen) { - const fetchInitialData = async () => { - try { - const response = tenantId - ? await userService.getByTenant(tenantId, 1, 1000) - : await userService.getAll(1, 1000); - if (response.success) { - setUserOptions( - response.data.map((u: any) => ({ - value: u.id, - label: `${u.first_name} ${u.last_name} (${u.email})`, - })), - ); - } - } catch (err) { - console.error("Failed to fetch users:", err); - } - }; - const fetchModules = async () => { try { const response = await moduleService.getMyModules(tenantId); @@ -325,7 +341,6 @@ export const WorkflowDefinitionModal = ({ } }; - fetchInitialData(); fetchModules(); if (definition) { @@ -343,8 +358,8 @@ export const WorkflowDefinitionModal = ({ sequence: s.sequence, step_type: s.step_type, assignee_type: s.assignee.type, - assignee_role: s.assignee.role ?? undefined, - assignee_id: s.assignee.id ?? undefined, + assignee_role_ids: s.assignee.role_ids ?? undefined, + assignee_user_ids: s.assignee.user_ids ?? s.assignee.ids ?? undefined, available_actions: s.available_actions || [], requires_signature: s.requires_signature || false, requires_comment: s.requires_comment || false, @@ -411,8 +426,6 @@ export const WorkflowDefinitionModal = ({ if (step.step_type === "terminal") { const { assignee_type, - assignee_role, - assignee_id, available_actions, ...rest } = step; @@ -423,15 +436,15 @@ export const WorkflowDefinitionModal = ({ // Clean up assignee fields based on assignee_type if (step.assignee_type === "originator") { - delete cleanedStep.assignee_role; - delete cleanedStep.assignee_id; + delete cleanedStep.assignee_role_ids; + delete cleanedStep.assignee_user_ids; } else if (step.assignee_type === "role") { - delete cleanedStep.assignee_id; + delete cleanedStep.assignee_user_ids; } else if (step.assignee_type === "user") { - delete cleanedStep.assignee_role; - // Ensure ID is not an empty string which fails UUID validation - if (cleanedStep.assignee_id === "") { - delete cleanedStep.assignee_id; + delete cleanedStep.assignee_role_ids; + // Ensure IDs are not empty + if (!cleanedStep.assignee_user_ids || cleanedStep.assignee_user_ids.length === 0) { + delete cleanedStep.assignee_user_ids; } } @@ -479,10 +492,42 @@ export const WorkflowDefinitionModal = ({ } } } catch (err: any) { - showToast.error( - err?.response?.data?.error?.message || - `Failed to ${isEdit ? "update" : "create"} workflow`, - ); + const backendErrors = err?.response?.data?.details; + if (Array.isArray(backendErrors)) { + backendErrors.forEach((detail: any) => { + const path = detail.path; + // Map backend path (e.g., "steps.2") to frontend field (e.g., "steps.2.assignee_user_ids") + // if it's a generic step error, or just use the path if it's already specific. + + if (path.startsWith('steps.') && !path.includes('.', 6)) { + // It's a generic step error like "steps.2" + // We'll attach it to the assignee fields so it's visible + const index = path.split('.')[1]; + const assigneeType = watchedSteps[parseInt(index)]?.assignee_type; + const fieldName = assigneeType === 'role' ? 'assignee_role_ids' : 'assignee_user_ids'; + setError(`${path}.${fieldName}`, { + type: 'manual', + message: detail.message + }); + setActiveTab('steps'); + } else { + setError(path, { + type: 'manual', + message: detail.message + }); + + // Switch to relevant tab + if (path.startsWith('steps')) setActiveTab('steps'); + if (path.startsWith('transitions')) setActiveTab('transitions'); + } + }); + showToast.error("Please fix the validation errors"); + } else { + showToast.error( + err?.response?.data?.error?.message || + `Failed to ${isEdit ? "update" : "create"} workflow`, + ); + } } finally { setIsSubmitting(false); } @@ -517,16 +562,16 @@ export const WorkflowDefinitionModal = ({ }; }; - const loadRoles = async (page: number, limit: number) => { + const loadRoles = async (page: number, limit: number, search?: string) => { const response = tenantId - ? await roleService.getByTenant(tenantId, page, limit) - : await roleService.getAll(page, limit); + ? await roleService.getByTenant(tenantId, page, limit, null, search) + : await roleService.getAll(page, limit, null, search); if (response.success) { return { options: response.data.map((r: any) => ({ - value: r.code, - label: r.name, + value: r.id, + label: `${r.name} (${r.code})`, })), pagination: response.pagination, }; @@ -537,6 +582,38 @@ export const WorkflowDefinitionModal = ({ }; }; + const loadUsers = async (_page: number, _limit: number, search?: string) => { + try { + const response = await userService.getDropdown({ + search + }); + + if (response.success) { + const options = response.data.map((u: any) => ({ + value: u.id, + label: `${u.name} (${u.email})` + })); + + return { + options, + pagination: { + page: 1, + limit: options.length, + total: options.length, + totalPages: 1, + hasMore: false + } + }; + } + } catch (err) { + console.error("Failed to fetch users for dropdown:", err); + } + return { + options: [], + pagination: { page: 1, limit, total: 0, totalPages: 0, hasMore: false } + }; + }; + const tabClasses = (tab: typeof activeTab) => cn( "px-6 py-3 text-sm font-medium border-b-2 transition-colors focus:outline-none", @@ -812,8 +889,8 @@ export const WorkflowDefinitionModal = ({ // When switching TO terminal, clear all hidden fields if (val === "terminal") { setValue(`steps.${index}.assignee_type`, undefined); - setValue(`steps.${index}.assignee_role`, []); - setValue(`steps.${index}.assignee_id`, ""); + setValue(`steps.${index}.assignee_role_ids`, []); + setValue(`steps.${index}.assignee_user_ids`, []); setValue(`steps.${index}.available_actions`, []); } }} @@ -840,8 +917,8 @@ export const WorkflowDefinitionModal = ({ value={field.value} onValueChange={(val) => { field.onChange(val); - setValue(`steps.${index}.assignee_role`, []); - setValue(`steps.${index}.assignee_id`, ""); + setValue(`steps.${index}.assignee_role_ids`, []); + setValue(`steps.${index}.assignee_user_ids`, []); }} error={ (errors.steps as any)?.[index]?.assignee_type @@ -860,7 +937,7 @@ export const WorkflowDefinitionModal = ({ errors={errors} isEdit={isEdit} loadRoles={loadRoles} - userOptions={userOptions} + loadUsers={loadUsers} /> )} diff --git a/src/pages/tenant/Dashboard.tsx b/src/pages/tenant/Dashboard.tsx index 6e5295e..042d8c0 100644 --- a/src/pages/tenant/Dashboard.tsx +++ b/src/pages/tenant/Dashboard.tsx @@ -76,11 +76,7 @@ const TaskCard = ({ task }: { task: WorkflowTask }) => { /> {task.step.name} •{" "} - {(() => { - const role = task.assignment?.assigned_role; - if (role) return Array.isArray(role) ? role.join(", ") : role; - return task.assignment?.assigned_to_name || "Unassigned"; - })()} + {task.assignment?.assigned_to_name || (task.assignment?.assigned_role_ids?.length ? `${task.assignment.assigned_role_ids.length} roles` : "Unassigned")}
diff --git a/src/pages/tenant/Tasks.tsx b/src/pages/tenant/Tasks.tsx index 5eb1345..26e64da 100644 --- a/src/pages/tenant/Tasks.tsx +++ b/src/pages/tenant/Tasks.tsx @@ -136,13 +136,14 @@ const Tasks = (): ReactElement => { ), }, { - key: "assigned_role", + key: "assignment", label: "Assigned To", render: (task) => { - const role = task.assignment.assigned_role; + const user = task.assignment.assigned_to_name; + const roleIds = task.assignment.assigned_role_ids; return ( - {Array.isArray(role) ? role.join(", ") : (role || "-")} + {user || (roleIds && roleIds.length > 0 ? `${roleIds.length} roles` : "-")} ); }, diff --git a/src/pages/tenant/ViewDocument.tsx b/src/pages/tenant/ViewDocument.tsx index 986c732..4635ca7 100644 --- a/src/pages/tenant/ViewDocument.tsx +++ b/src/pages/tenant/ViewDocument.tsx @@ -1235,6 +1235,7 @@ const ViewDocument = (): ReactElement => { value={workflowDefinitionId} onValueChange={setWorkflowDefinitionId} placeholder="Select active workflow definition" + isSearchable /> {/*
@@ -1558,7 +1559,7 @@ const ViewDocument = (): ReactElement => {
- {task.assigned_to} + {task.assigned_to_name || "-"}
diff --git a/src/services/user-service.ts b/src/services/user-service.ts index 14e9461..9ade88c 100644 --- a/src/services/user-service.ts +++ b/src/services/user-service.ts @@ -75,6 +75,15 @@ export const userService = { const response = await apiClient.put(`/users/${id}`, data); return response.data; }, + getDropdown: async ( + params: { search?: string } + ) => { + const query = new URLSearchParams(); + if (params.search) query.append('search', params.search); + + const response = await apiClient.get(`/tenants/current/users/dropdown?${query.toString()}`); + return response.data; + }, // delete: async (id: string): Promise => { // const response = await apiClient.delete(`/users/${id}`); // return response.data; diff --git a/src/types/workflow.ts b/src/types/workflow.ts index 205d5b9..47d7f98 100644 --- a/src/types/workflow.ts +++ b/src/types/workflow.ts @@ -8,7 +8,10 @@ export interface WorkflowStep { assignee: { type: 'role' | 'user' | 'originator'; id?: string | null; + ids?: string[] | null; + user_ids?: string[] | null; role?: string[] | string | null; + role_ids?: string[] | null; }; available_actions: string[]; requires_signature?: boolean; @@ -120,7 +123,9 @@ export interface WorkflowInstance { tasks: Array<{ id: string; step: string; - assigned_to: string; + assigned_user_ids?: string[] | null; + assigned_to_name?: string | null; + assigned_role_ids?: string[] | null; status: string; action_taken: string | null; completed_at: string | null; @@ -172,9 +177,9 @@ export interface WorkflowTask { name: string; }; assignment: { - assigned_to: string | null; + assigned_user_ids?: string[] | null; assigned_to_name: string | null; - assigned_role: string | string[]; + assigned_role_ids?: string[] | null; assigned_at: string; }; status: string;