From 0aaa301ac6017ab17007ecbadd0d6ab0bf48eb93 Mon Sep 17 00:00:00 2001 From: AppElent Date: Mon, 20 Jan 2025 16:24:55 +0100 Subject: [PATCH] bringing up to date with satisfactory app --- package.json | 4 + src/components/default/ColorModeSelect.jsx | 2 +- src/components/default/custom-breadcrumbs.tsx | 155 +++++-- .../default/filters/sort-options.tsx | 42 ++ src/components/default/floating-button.tsx | 49 +++ src/components/default/json-editor.tsx | 5 +- src/components/default/ui/search-bar.tsx | 18 +- src/config/paths.tsx | 32 +- src/hooks/use-keyboard-shortcut.ts | 43 ++ src/hooks/use-navigation-guard.tsx | 42 ++ src/hooks/use-path-router.ts | 50 +++ src/layouts/paperbase/Header.jsx | 3 - src/layouts/paperbase/Navigator.jsx | 8 + src/libs/data-sources/DataProvider.tsx | 16 + .../data-sources/BaseDataSource.tsx | 189 +++++---- .../data-sources/FirestoreDataSource.tsx | 25 +- .../data-sources/LocalStorageDataSource.tsx | 20 +- src/libs/data-sources/index.tsx | 71 ++-- src/libs/data-sources/useData.tsx | 41 +- .../filters/components/boolean-filter.tsx | 72 ++++ .../filters/components/mult-select-filter.tsx | 70 ++++ src/libs/filters/components/pagination.tsx | 25 ++ src/libs/filters/components/range-filter.tsx | 111 +++++ src/libs/filters/components/search-bar.tsx | 51 +++ src/libs/filters/components/select-filter.tsx | 54 +++ src/libs/filters/components/sort-options.tsx | 59 +++ src/libs/filters/index.tsx | 35 ++ src/{hooks => libs/filters}/use-filter.ts | 172 ++++---- src/libs/forms/components/Autocomplete.tsx | 143 +++++++ src/libs/forms/components/CheckboxList.tsx | 222 ++++++++++ src/libs/forms/components/Notepad.tsx | 137 ++++++ src/libs/forms/components/Select.tsx | 62 +++ src/libs/forms/components/SubmitButton.tsx | 74 ++-- src/libs/forms/components/Table.tsx | 225 ++++++++++ src/libs/forms/components/TextField.tsx | 3 +- src/libs/forms/index.tsx | 37 +- src/libs/mermaid/index.tsx | 34 ++ src/libs/tabs/index.tsx | 2 +- src/libs/tabs/tabs-header.tsx | 38 ++ src/libs/tabs/tabs.tsx | 42 +- src/libs/tabs/{use-tabs.ts => use-tabs.tsx} | 16 + src/main.tsx | 4 + src/pages/default/DefaultPage.tsx | 18 +- src/pages/default/Settings.tsx | 6 +- src/schemas/dummy.ts | 16 +- src/schemas/index.ts | 392 +++++++++++++++--- src/schemas/recipe.ts | 97 ----- src/utils/random-name-generator.ts | 2 + 48 files changed, 2543 insertions(+), 491 deletions(-) create mode 100644 src/components/default/filters/sort-options.tsx create mode 100644 src/components/default/floating-button.tsx create mode 100644 src/hooks/use-keyboard-shortcut.ts create mode 100644 src/hooks/use-navigation-guard.tsx create mode 100644 src/hooks/use-path-router.ts create mode 100644 src/libs/filters/components/boolean-filter.tsx create mode 100644 src/libs/filters/components/mult-select-filter.tsx create mode 100644 src/libs/filters/components/pagination.tsx create mode 100644 src/libs/filters/components/range-filter.tsx create mode 100644 src/libs/filters/components/search-bar.tsx create mode 100644 src/libs/filters/components/select-filter.tsx create mode 100644 src/libs/filters/components/sort-options.tsx create mode 100644 src/libs/filters/index.tsx rename src/{hooks => libs/filters}/use-filter.ts (59%) create mode 100644 src/libs/forms/components/Autocomplete.tsx create mode 100644 src/libs/forms/components/CheckboxList.tsx create mode 100644 src/libs/forms/components/Notepad.tsx create mode 100644 src/libs/forms/components/Select.tsx create mode 100644 src/libs/forms/components/Table.tsx create mode 100644 src/libs/mermaid/index.tsx create mode 100644 src/libs/tabs/tabs-header.tsx rename src/libs/tabs/{use-tabs.ts => use-tabs.tsx} (87%) delete mode 100644 src/schemas/recipe.ts diff --git a/package.json b/package.json index 4c57221..5ae9e78 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,11 @@ "format:write": "prettier --write \"**/*.{js,jsx,ts,tsx,mdx}\" --cache", "format:check": "prettier --check \"**/*.{js,jsx,ts,tsx,mdx}\" --cache", "deploy:stg": "npm run lint-fix && npm run build && firebase deploy --only hosting:stg", + "deploy:stg:skipchecks": "npm run build && firebase deploy --only hosting:stg", + "deploy:stg:skipbuild": "firebase deploy --only hosting:stg", "deploy:prd": "npm run lint-fix && npm run build && firebase deploy --only hosting:prd", + "deploy:prd:skipchecks": "npm run build && firebase deploy --only hosting:prd", + "deploy:prd:skipbuild": "firebase deploy --only hosting:prd", "check-updates": "npx npm-check-updates -i", "docs": "jsdoc -c jsdoc.conf.json", "npm-check": "npm-check" diff --git a/src/components/default/ColorModeSelect.jsx b/src/components/default/ColorModeSelect.jsx index aeecbb8..3c8b8e9 100644 --- a/src/components/default/ColorModeSelect.jsx +++ b/src/components/default/ColorModeSelect.jsx @@ -1,6 +1,6 @@ -import { useColorScheme } from '@mui/material/styles'; import MenuItem from '@mui/material/MenuItem'; import Select from '@mui/material/Select'; +import { useColorScheme } from '@mui/material/styles'; export default function ColorModeSelect(props) { const { mode, setMode } = useColorScheme(); diff --git a/src/components/default/custom-breadcrumbs.tsx b/src/components/default/custom-breadcrumbs.tsx index 02fe8ab..d73490e 100644 --- a/src/components/default/custom-breadcrumbs.tsx +++ b/src/components/default/custom-breadcrumbs.tsx @@ -1,10 +1,13 @@ -import { Box, Breadcrumbs, Link, Stack, Typography } from '@mui/material'; +import { Box, Breadcrumbs, Link, Menu, MenuItem, Stack } from '@mui/material'; import paths, { PathItem } from '@/config/paths'; import useIsMobile from '@/hooks/use-is-mobile'; +import useRouter from '@/hooks/use-router'; +import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Link as RouterLink, matchPath } from 'react-router-dom'; +import { Link as RouterLink, matchPath, useParams } from 'react-router-dom'; const generateBreadcrumbs = (breadcrumbsConfig: PathItem[], pathname: string) => { const breadcrumbs: PathItem[] = []; @@ -34,15 +37,66 @@ const generateBreadcrumbs = (breadcrumbsConfig: PathItem[], pathname: string) => return breadcrumbs; }; -const CustomBreadcrumbs = ({ currentPage }: { currentPage?: string }) => { +interface CustomBreadcrumbsProps { + // currentPage?: string; + // switchOptions?: { + // label: string; + // key: string; + // }[]; + options?: { + [key: string]: { + getLabel?: (params: { [key: string]: string }) => string; + options: { + label: string; + key: string; + }[]; + }; + }; +} + +const CustomBreadcrumbs = ({ options }: CustomBreadcrumbsProps) => { + const params = useParams(); const { t } = useTranslation(); const isMobile = useIsMobile(); const items = generateBreadcrumbs(paths, window.location.pathname); - // If currentPage is set, replace the last item with it - if (currentPage) { - items[items.length - 1].label = currentPage; - } + const [anchorEl, setAnchorEl] = useState(null); + const router = useRouter(); + + const handleClick = (event: any) => { + setAnchorEl(event.currentTarget); + }; + + const handleItemClick = (item: { label: string; key: string }) => { + // Route to the selectem item by replacing the last segment of the url with the className + const urlSegments = window.location.pathname.split('/'); + urlSegments[urlSegments.length - 1] = item.key; + const newUrl = urlSegments.join('/'); + router.push(newUrl); + setAnchorEl(null); + }; + + const getLink = (link: string) => { + const urlSegments = link.split('/'); + urlSegments.forEach((segment) => { + if (segment.startsWith(':')) { + const key = segment.slice(1); + if (params[key]) { + link = link.replace(segment, params[key]); + } + } + }); + return link; + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + // // If currentPage is set, replace the last item with it + // if (currentPage) { + // items[items.length - 1].label = currentPage; + // } return ( <> @@ -53,8 +107,16 @@ const CustomBreadcrumbs = ({ currentPage }: { currentPage?: string }) => { maxItems={isMobile ? 2 : 8} > {items.map((item, index) => { - const value = item.translationKey ? t(item.translationKey) : item.label; - return item.to ? ( + const originalValue = item.translationKey ? t(item.translationKey) : item.label; + const optionFound = options?.[item.id]; + let value = originalValue; + if (optionFound) { + if (optionFound.getLabel) { + value = optionFound.getLabel?.(params as any) || originalValue; + } + } + const to = getLink(item.to || ''); + return ( { > {item.Icon && item.Icon} - - {value} - + {item.to ? ( + + {value} + + ) : ( + <> + {optionFound?.options ? ( + <> + + + {value} + + + + + {optionFound.options.map((dropdownItem, dropdownIndex) => ( + handleItemClick(dropdownItem)} + > + {dropdownItem.label} + + ))} + + + ) : ( + <>{value} + )} + + )} - ) : ( - - {item.Icon && item.Icon} - - {/* {item.Icon && item.Icon} */} - {value} - - ); })} diff --git a/src/components/default/filters/sort-options.tsx b/src/components/default/filters/sort-options.tsx new file mode 100644 index 0000000..a692d5d --- /dev/null +++ b/src/components/default/filters/sort-options.tsx @@ -0,0 +1,42 @@ +import { FormControl, FormControlProps, MenuItem, TextField, TextFieldProps } from '@mui/material'; + +interface SortOptionsProps { + value: string; + options: { value: string; label: string }[]; + handleSortChange: (e: React.ChangeEvent) => void; + muiFormControlProps?: FormControlProps; + muiTextFieldProps?: TextFieldProps; +} + +const SortOptions = ({ value, options, handleSortChange, ...rest }: SortOptionsProps) => { + const { muiFormControlProps, muiTextFieldProps } = rest; + + return ( + + + {options.map((option) => ( + + {option.label} + + ))} + + + ); +}; + +export default SortOptions; diff --git a/src/components/default/floating-button.tsx b/src/components/default/floating-button.tsx new file mode 100644 index 0000000..4447288 --- /dev/null +++ b/src/components/default/floating-button.tsx @@ -0,0 +1,49 @@ +import AddIcon from '@mui/icons-material/Add'; // Import AddIcon +import { Fab, FabProps, Tooltip } from '@mui/material'; + +interface FloatingButtonProps { + handleAdd: () => void; + muiFabProps?: FabProps; + children?: React.ReactNode; +} + +const FloatingButton = ({ handleAdd, muiFabProps, children }: FloatingButtonProps) => { + return ( + + {children ? ( + children + ) : ( + + + + )} + + ); +}; + +export default FloatingButton; diff --git a/src/components/default/json-editor.tsx b/src/components/default/json-editor.tsx index 7a0465d..bc5a8b3 100644 --- a/src/components/default/json-editor.tsx +++ b/src/components/default/json-editor.tsx @@ -1,5 +1,4 @@ import ReactJson, { InteractionProps, ReactJsonViewProps } from '@microlink/react-json-view'; -import { JsonEditor as JsonEditorExternal } from 'json-edit-react'; interface JsonEditorProps extends ReactJsonViewProps { validationSchema?: any; @@ -41,14 +40,14 @@ const JsonEditor = (props: { data: any; options?: Partial }) => return (
- console.log(alldata)} // optional theme={['githubLight']} restrictDrag={false} restrictDelete={false} collapse={true} - /> + /> */} void; value: any; onChange: (e: React.ChangeEvent) => void; - placeholder: string; - props?: Partial>; + placeholder?: string; + muiOutlinedInputProps?: OutlinedInputProps; } -const SearchBar = ({ onClear, value, onChange, placeholder, props }: SearchBarProps) => { +const SearchBar = ({ + onClear, + value, + onChange, + placeholder, + muiOutlinedInputProps, +}: SearchBarProps) => { return ( // {/* */} @@ -35,7 +40,8 @@ const SearchBar = ({ onClear, value, onChange, placeholder, props }: SearchBarPr value={value || ''} sx={{ flexGrow: 1 }} onChange={onChange} - {...props} + {...muiOutlinedInputProps} + placeholder={placeholder || 'Search'} /> ); }; diff --git a/src/config/paths.tsx b/src/config/paths.tsx index eae691b..fef355b 100644 --- a/src/config/paths.tsx +++ b/src/config/paths.tsx @@ -1,3 +1,4 @@ +import { useParams } from 'react-router-dom'; import config from '.'; import { paths } from './routing'; @@ -7,6 +8,7 @@ export interface PathItem { translationKey?: string; to?: string; Icon: React.ReactNode; + loginRequired?: boolean; category?: string; } @@ -35,7 +37,32 @@ const menuCategories: MenuCategory[] = [ }, ]; -export const getPath = (id: string) => paths.find((path) => path.id === id); +export const getPath = (id: string, params?: { [key: string]: string | undefined }) => { + const path = paths.find((path) => path.id === id); + if (!path) { + return null; + } + const pathCopy = { ...path }; + if (params) { + let newPath = path.to; + Object.keys(params).forEach((key) => { + newPath = newPath.replace(`:${key}`, params[key]); + }); + pathCopy.to = newPath; + } + + return pathCopy; +}; + +export const useCurrentPath = () => { + const params = useParams(); + let windowPath = window.location.pathname; + Object.keys(params).forEach((key) => { + windowPath = windowPath.replace(params[key] as string, `:${key}`); + }); + const path = paths.find((path) => path.to === windowPath); + return path || null; +}; const generateMenu = () => { const menu = menuCategories @@ -57,6 +84,7 @@ const generateMenu = () => { label: item.label, translationKey: item.translationKey, Icon: item.Icon, + loginRequired: item.loginRequired, to: item.to, })), }; @@ -67,4 +95,6 @@ const generateMenu = () => { export const menu = generateMenu(); +console.log('PATHS', paths); + export default paths; diff --git a/src/hooks/use-keyboard-shortcut.ts b/src/hooks/use-keyboard-shortcut.ts new file mode 100644 index 0000000..08a988f --- /dev/null +++ b/src/hooks/use-keyboard-shortcut.ts @@ -0,0 +1,43 @@ +import { useEffect } from 'react'; + +/** + * useKeyboardShortcut Hook + * + * A React hook to listen for specific keyboard combinations (e.g., Ctrl+S or Ctrl+P) + * and execute a custom action when the combination is pressed. + * + * @param {string} key - The key to listen for (e.g., 'S', 'P'). + * @param {Function} action - The function to execute when the combination is pressed. + * @param {boolean} [ctrl=true] - Whether to include the Ctrl key in the combination. + * @param {boolean} [alt=false] - Whether to include the Alt key in the combination. + * @param {boolean} [shift=false] - Whether to include the Shift key in the combination. + */ +function useKeyboardShortcut( + key: string, + action: () => any, + ctrl = true, + alt = false, + shift = false +) { + useEffect(() => { + const handleKeyDown = (event: any) => { + if ( + event.key.toLowerCase() === key.toLowerCase() && + event.ctrlKey === ctrl && + event.altKey === alt && + event.shiftKey === shift + ) { + event.preventDefault(); // Prevent default browser action (e.g., saving for Ctrl+S) + action(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [key, action, ctrl, alt, shift]); +} + +export default useKeyboardShortcut; diff --git a/src/hooks/use-navigation-guard.tsx b/src/hooks/use-navigation-guard.tsx new file mode 100644 index 0000000..e7e06e5 --- /dev/null +++ b/src/hooks/use-navigation-guard.tsx @@ -0,0 +1,42 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +const useNavigationGuard = (when: boolean, message: string) => { + const navigate = useNavigate(); + const [showDialog, setShowDialog] = useState(false); + const [nextLocation, setNextLocation] = useState(null); + + const handleNavigation = useCallback(() => { + if (nextLocation) { + setShowDialog(false); + navigate(nextLocation); + } + }, [navigate, nextLocation]); + + const handleBlocker = useCallback( + (event: BeforeUnloadEvent) => { + if (when) { + event.preventDefault(); + event.returnValue = message; + } + }, + [when, message] + ); + + useEffect(() => { + window.addEventListener('beforeunload', handleBlocker); + + return () => { + window.removeEventListener('beforeunload', handleBlocker); + }; + }, [handleBlocker]); + + const confirmNavigation = (path: string) => { + setNextLocation(path); + setShowDialog(true); + }; + + return { showDialog, setShowDialog, confirmNavigation, handleNavigation }; +}; + +export default useNavigationGuard; diff --git a/src/hooks/use-path-router.ts b/src/hooks/use-path-router.ts new file mode 100644 index 0000000..652662f --- /dev/null +++ b/src/hooks/use-path-router.ts @@ -0,0 +1,50 @@ +import { getPath } from '@/config/paths'; +import { useCallback, useMemo } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; + +interface PathRouter { + back: () => void; + forward: () => void; + refresh: () => void; + push: (id: string, params?: { [key: string]: string | undefined }) => void; + replace: (id: string, params?: { [key: string]: string | undefined }) => void; + prefetch: () => void; +} + +/** + * This is a wrapper over `react-router/useNavigate` hook. + * We use this to help us maintain consistency between CRA and Next.js + */ +const usePathRouter = (): PathRouter => { + const navigate = useNavigate(); + const params = useParams(); + const push = useCallback( + (type: 'push' | 'replace') => + (id: string, customParams?: { [key: string]: string | undefined }) => { + const newParams = { ...(params || {}), ...(customParams || {}) }; + const path = getPath(id, newParams); + if (!path) { + throw new Error(`Path with id ${id} not found`); + } + if (type === 'push') { + navigate(path.to); + } else { + navigate(path.to, { replace: true }); + } + }, + [navigate, params] + ); + + return useMemo(() => { + return { + back: () => navigate(-1), + forward: () => navigate(1), + refresh: () => navigate(0), + push: push('push'), + replace: push('replace'), + prefetch: () => {}, + }; + }, [navigate, push]); +}; + +export default usePathRouter; diff --git a/src/layouts/paperbase/Header.jsx b/src/layouts/paperbase/Header.jsx index d86fa41..4869b6a 100644 --- a/src/layouts/paperbase/Header.jsx +++ b/src/layouts/paperbase/Header.jsx @@ -63,9 +63,6 @@ function Header(props) { item xs /> - {/* - - */} diff --git a/src/layouts/paperbase/Navigator.jsx b/src/layouts/paperbase/Navigator.jsx index a9d4370..a4c756b 100644 --- a/src/layouts/paperbase/Navigator.jsx +++ b/src/layouts/paperbase/Navigator.jsx @@ -1,6 +1,7 @@ import { ExpandLess, ExpandMore } from '@mui/icons-material'; import DnsRoundedIcon from '@mui/icons-material/DnsRounded'; import HomeIcon from '@mui/icons-material/Home'; +import LockIcon from '@mui/icons-material/Lock'; import PeopleIcon from '@mui/icons-material/People'; import PhonelinkSetupIcon from '@mui/icons-material/PhonelinkSetup'; import PermMediaOutlinedIcon from '@mui/icons-material/PhotoSizeSelectActual'; @@ -22,6 +23,7 @@ import ListItemText from '@mui/material/ListItemText'; import config from '@/config'; import { getPath, menu } from '@/config/paths'; import useRouter from '@/hooks/use-router'; +import { useAuth } from '@/libs/auth'; import { Collapse } from '@mui/material'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -71,6 +73,8 @@ const itemCategory = { }; export default function Navigator(props) { + const auth = useAuth(); + console.log(auth); const handleClick = (id) => { setOpen((prevOpen) => ({ ...prevOpen, [id]: !prevOpen[id] })); }; @@ -139,6 +143,7 @@ export default function Navigator(props) { label: childLabel, translationKey: childTranslationKey, to, + loginRequired, Icon, }) => ( + {loginRequired && !auth.isAuthenticated && ( + + )} ) diff --git a/src/libs/data-sources/DataProvider.tsx b/src/libs/data-sources/DataProvider.tsx index bc2409d..a325425 100644 --- a/src/libs/data-sources/DataProvider.tsx +++ b/src/libs/data-sources/DataProvider.tsx @@ -1,6 +1,22 @@ import React, { createContext, ReactNode, useCallback, useEffect, useState } from 'react'; import { DataSource, DataSourceObject } from '.'; +// interface DataProviderContextProps { +// dataSources: DataSourceObject; +// setDataSource: (key: string, dataSource: DataSource) => void; +// addDataSource: (key: string, dataSource: DataSource) => void; +// data: Record; +// loading: Record; +// error: Record; +// fetchData: (key: string, filter?: object) => Promise; +// subscribeToData: (key: string) => void; +// subscriptions: Record void>; +// add: (key: string, item: any) => Promise; +// update: (key: string, data: any, id: string) => Promise; +// set: (key: string, data: any, id: string) => Promise; +// remove: (key: string, id: string) => Promise; +// } // TODO: implement? + // Create a context for the data export const DataContext = createContext(undefined); diff --git a/src/libs/data-sources/data-sources/BaseDataSource.tsx b/src/libs/data-sources/data-sources/BaseDataSource.tsx index bd7cb79..5a2b9fc 100644 --- a/src/libs/data-sources/data-sources/BaseDataSource.tsx +++ b/src/libs/data-sources/data-sources/BaseDataSource.tsx @@ -2,8 +2,14 @@ import { faker } from '@faker-js/faker'; import * as yup from 'yup'; import { DataSourceInitOptions, FilterObject } from '..'; -interface validateOptions { - full: boolean; +interface validateOptions extends yup.ValidateOptions { + full?: boolean; +} + +interface ValidationResult { + valid: boolean; + errors?: Record; + values: any; } /** @@ -36,72 +42,72 @@ class BaseDataSource { }; } - /** - * Helper function to validate email format. - * @param {string} email - The email to validate. - * @returns {boolean} - True if the email is valid, false otherwise. - */ - #isValidEmail = (email: string): boolean => { - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - return emailRegex.test(email); - }; + // /** + // * Helper function to validate email format. + // * @param {string} email - The email to validate. + // * @returns {boolean} - True if the email is valid, false otherwise. + // */ + // #isValidEmail = (email: string): boolean => { + // const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + // return emailRegex.test(email); + // }; + + // /** + // * Helper function to validate date format. + // * @param {string} date - The date to validate. + // * @returns {boolean} - True if the date is valid, false otherwise. + // */ + // #isValidDate = (date: string): boolean => { + // return !isNaN(new Date(date).getTime()); + // }; /** - * Helper function to validate date format. - * @param {string} date - The date to validate. - * @returns {boolean} - True if the date is valid, false otherwise. - */ - #isValidDate = (date: string): boolean => { - return !isNaN(new Date(date).getTime()); - }; - - /** - * Validates the data against the schema. - * @param {Partial} data - The data to validate. - * @param {validateOptions} [_options] - Additional validation options. - * @throws Will throw an error if validation fails. - */ - #validateSchema = async (data: Partial, _options?: validateOptions): Promise => { - if (this.options.schema) { - const errors = []; - for (const [key, rules] of Object.entries(this.options.schema)) { - const value = (data as { [key: string]: any })[key]; - - // Check required fields - if (rules.required && (value === undefined || value === null)) { - errors.push(`${key} is required.`); - continue; - } - - // Check data type - if (rules.type) { - switch (rules.type) { - case 'string': - if (typeof value !== 'string') errors.push(`${key} must be a string.`); - break; - case 'number': - if (typeof value !== 'number') errors.push(`${key} must be a number.`); - break; - case 'boolean': - if (typeof value !== 'boolean') errors.push(`${key} must be a boolean.`); - break; - case 'date': - if (!this.#isValidDate(value)) errors.push(`${key} must be a valid date.`); - break; - case 'email': - if (!this.#isValidEmail(value)) errors.push(`${key} must be a valid email.`); - break; - default: - break; - } - } - } - - if (errors.length > 0) { - throw new Error(errors.join(' ')); - } - } - }; + // * Validates the data against the schema. + // * @param {Partial} data - The data to validate. + // * @param {validateOptions} [_options] - Additional validation options. + // * @throws Will throw an error if validation fails. + // */ + // #validateSchema = async (data: Partial, _options?: validateOptions): Promise => { + // if (this.options.schema) { + // const errors = []; + // for (const [key, rules] of Object.entries(this.options.schema)) { + // const value = (data as { [key: string]: any })[key]; + + // // Check required fields + // if (rules.required && (value === undefined || value === null)) { + // errors.push(`${key} is required.`); + // continue; + // } + + // // Check data type + // if (rules.type) { + // switch (rules.type) { + // case 'string': + // if (typeof value !== 'string') errors.push(`${key} must be a string.`); + // break; + // case 'number': + // if (typeof value !== 'number') errors.push(`${key} must be a number.`); + // break; + // case 'boolean': + // if (typeof value !== 'boolean') errors.push(`${key} must be a boolean.`); + // break; + // case 'date': + // if (!this.#isValidDate(value)) errors.push(`${key} must be a valid date.`); + // break; + // case 'email': + // if (!this.#isValidEmail(value)) errors.push(`${key} must be a valid email.`); + // break; + // default: + // break; + // } + // } + // } + + // if (errors.length > 0) { + // throw new Error(errors.join(' ')); + // } + // } + // }; /** * Validates the data against the Yup schema. @@ -109,15 +115,50 @@ class BaseDataSource { * @param {validateOptions} [_options] - Additional validation options. * @throws Will throw an error if validation fails. */ - #validateYupSchema = async (data: Partial, _options?: validateOptions): Promise => { + #validateYupSchema = async ( + data: Partial, + options?: validateOptions + ): Promise => { + const combinedOptions = { full: false, abortEarly: false, ...options }; + const returnObject: ValidationResult = { + valid: true, + errors: undefined, + values: data, + }; if (this.options.YupValidationSchema) { try { - await this.options.YupValidationSchema.validate(data, { abortEarly: false }); - } catch (err: any) { - const validationErrors = err.errors?.join(' '); - throw new Error(`Validation failed: ${validationErrors}`); + const validationSchema = options?.full + ? this.options.YupValidationSchema + : (this.options.YupValidationSchema as yup.ObjectSchema).pick( + Object.keys(data) as (keyof typeof this.options.YupValidationSchema)[] + ); + //await this.options.YupValidationSchema.validate(data, { abortEarly: false }); + await validationSchema.validate(data, combinedOptions); + } catch (error: any) { + if (error instanceof yup.ValidationError) { + // Collect multiple errors for each path + const errorMessages = error.inner.reduce((acc: Record, err) => { + if (err.path && !acc[err.path]) { + acc[err.path] = []; + } + if (err.path) { + acc[err.path].push(err.message); + } + return acc; + }, {}); + console.error('Validation error', errorMessages); + returnObject.valid = false; + returnObject.errors = errorMessages; + console.error('Validation error:', returnObject); + } else { + console.error('Unexpected error:', error); + throw error; + } + } finally { + console.log('VALIDATION', returnObject); } } + return returnObject; }; /** @@ -126,9 +167,9 @@ class BaseDataSource { * @param {validateOptions} [options] - Additional validation options. * @throws Will throw an error if validation fails. */ - validate = async (data: Partial, options?: validateOptions): Promise => { - await this.#validateSchema(data, options); - await this.#validateYupSchema(data, options); + validate = async (data: Partial, options?: validateOptions): Promise => { + //await this.#validateSchema(data, options); + return await this.#validateYupSchema(data, options); }; /** diff --git a/src/libs/data-sources/data-sources/FirestoreDataSource.tsx b/src/libs/data-sources/data-sources/FirestoreDataSource.tsx index efb5fb4..bcd99ea 100644 --- a/src/libs/data-sources/data-sources/FirestoreDataSource.tsx +++ b/src/libs/data-sources/data-sources/FirestoreDataSource.tsx @@ -121,7 +121,6 @@ export class FirestoreDataSource extends BaseDataSource { // Parses filter and returns an object for provider specific filterand and the generic js filtering #parseFilters = (filterObject: FilterObject): FilterReturn => { let q = this.ref; - console.log(this.ref); // Apply filters if (filterObject.filters) { @@ -166,7 +165,6 @@ export class FirestoreDataSource extends BaseDataSource { const docSnap = await getDoc(docRef); if (docSnap.exists()) { const data = docSnap.data(); - console.log(data); return { id: docSnap.id, ...(data ? data : {}) } as T; } else { return this._getDefaultValue(); @@ -187,15 +185,13 @@ export class FirestoreDataSource extends BaseDataSource { const filterObject = filter || this.options.targetFilter || {}; //console.log(filterObject, filter, this.options.targetFilter); const { provider: query, postFilter } = this.#parseFilters(filterObject); - console.log(query, postFilter); const querySnapshot = await getDocs(query); - // let documents: any[] = []; - // querySnapshot.forEach((doc) => { - // documents.push({ id: doc.id, ...(doc.data() as object) }); - // }); - let documents = querySnapshot.docs.map((doc) => doc.data() as T); - documents = this._applyPostFilters(documents, postFilter) as T[]; + let documents: any[] = []; + querySnapshot.forEach((doc) => { + documents.push({ id: doc.id, ...(doc.data() as object) }); + }); + documents = this._applyPostFilters(documents, postFilter); return documents as T[]; } catch (error) { console.error('Error getting documents:', error); @@ -210,7 +206,7 @@ export class FirestoreDataSource extends BaseDataSource { throw new Error('add() can only be used with collections'); // Validate new data item = this.#clearUndefinedValues(item); - this.validate(item, { full: false }); + this.validate(item); const docRef = await addDoc(this.ref, item); const newDoc = await getDoc(docRef); return { id: docRef.id, ...newDoc.data() } as T; @@ -228,7 +224,11 @@ export class FirestoreDataSource extends BaseDataSource { } const docRef = this.#getRef(id); data = this.#clearUndefinedValues(data); - this.validate(data); + const validateResult = await this.validate(data, { strict: false }); + if (!validateResult.valid) { + throw new Error('Validation failed'); + } + console.log(docRef, data, id); await updateDoc(docRef, data as UpdateData>); } catch (error) { console.error('Error updating document:', error); @@ -244,7 +244,7 @@ export class FirestoreDataSource extends BaseDataSource { throw new Error('set() requires an ID when using collections'); } data = this.#clearUndefinedValues(data); - this.validate(data); + this.validate(data); // TODO: fix validation everywhere const docRef = this.#getRef(id); await setDoc(docRef, data); } catch (error) { @@ -287,7 +287,6 @@ export class FirestoreDataSource extends BaseDataSource { // documents.push({ ...doc.data() } as T); // }); const documents: T[] = snapshot.docs.map((doc) => doc.data() as T); - console.log('documents', documents); callback(this._applyPostFilters(documents, postFilter)); }); diff --git a/src/libs/data-sources/data-sources/LocalStorageDataSource.tsx b/src/libs/data-sources/data-sources/LocalStorageDataSource.tsx index c58d359..954a143 100644 --- a/src/libs/data-sources/data-sources/LocalStorageDataSource.tsx +++ b/src/libs/data-sources/data-sources/LocalStorageDataSource.tsx @@ -102,16 +102,21 @@ export class LocalStorageDataSource extends BaseDataSource { async set(data?: T, id?: any): Promise { this.validate(data); if (this.options?.targetMode === 'document') { - this.saveData(id || data); + this.saveData(data); return; } const existingData = this.getData(); const itemIndex = existingData.findIndex((d: any) => d.id === id); if (itemIndex === -1) { - throw new Error(`Item with ID ${id} does not exist.`); + existingData.push({ + id, + ...data, + }); + } else { + existingData[itemIndex] = data; } - existingData[itemIndex] = data; + // existingData[itemIndex] = data; this.saveData(existingData); } @@ -123,13 +128,8 @@ export class LocalStorageDataSource extends BaseDataSource { return; } const existingData = this.getData(); - const itemIndex = existingData.findIndex((d: any) => d.id === id); - if (id && itemIndex > -1) { - delete existingData[itemIndex]; - this.saveData(existingData); - } else { - throw new Error('Document not found'); - } + const newData = existingData.filter((d: any) => d.id !== id); + this.saveData(newData); } // Subscribe to changes in the storage data diff --git a/src/libs/data-sources/index.tsx b/src/libs/data-sources/index.tsx index 789fdb3..5792b47 100644 --- a/src/libs/data-sources/index.tsx +++ b/src/libs/data-sources/index.tsx @@ -3,6 +3,8 @@ import * as Yup from 'yup'; import DataProvider, { DataContext } from './DataProvider'; import useData from './useData'; +export type WithOptionalId = Omit & { id?: string }; + export interface DataSourceInitOptions { target: string; targetMode?: 'collection' | 'document' | 'number' | 'string' | 'boolean'; //TODO: implement @@ -32,57 +34,58 @@ export interface DataSourceObject { // dataSource: DataSourceSource; // } -export interface DataSourceActions { +export interface DataSourceActions { fetchData: (filter?: FilterObject) => Promise; - getAll: (filter?: FilterObject) => Promise; + getAll: (filter?: FilterObject) => Promise; get: (id?: string) => Promise; - add: (item: T) => Promise; - update: (data: Partial, id?: string) => Promise; - set: (data: T, id?: string) => Promise; + add: (item: WithOptionalId) => Promise; + update: (data: Partial>, id?: string) => Promise; + set: (data: WithOptionalId, id?: string) => Promise; delete: (id?: string) => Promise; validate: (data: T) => Promise; getDummyData: () => T; } -export interface UseDataReturn { - // Options supplied to class constructor - options?: DataSourceInitOptions; - providerConfig?: any; - // Data state - data: T; - loading: boolean; - error: any; - // Provider name - provider: string; - // Actions - actions: DataSourceActions; - // Predefined methods - // getAll: (filter?: object) => Promise; - // get: (id?: string) => Promise; - // add: (item: T) => Promise; - // update: (data: Partial, id?: string) => Promise; - // set: (data: T, id?: string) => Promise; - // delete: (id?: string) => Promise; - // Raw datasource info - dataSource: any; - // Custom props - custom?: { - [key: string]: any; - }; -} +// export interface UseDataReturn { +// // Options supplied to class constructor +// options?: DataSourceInitOptions; +// providerConfig?: any; +// // Data state +// data: T; +// loading: boolean; +// error: any; +// // Provider name +// provider: string; +// // Actions +// actions: DataSourceActions; +// // Predefined methods +// // getAll: (filter?: object) => Promise; +// // get: (id?: string) => Promise; +// // add: (item: T) => Promise; +// // update: (data: Partial, id?: string) => Promise; +// // set: (data: T, id?: string) => Promise; +// // delete: (id?: string) => Promise; +// // Raw datasource info +// dataSource: any; +// // Custom props +// custom?: { +// [key: string]: any; +// }; +// } -export interface DataSource { +export interface DataSource { + //TODO: fix second generic type // Options supplied to class constructor options?: DataSourceInitOptions; providerConfig?: any; // Data state - data: T; + data: Z; loading: boolean; error: any; // Provider name provider: string; // Actions - actions: DataSourceActions; + actions: DataSourceActions; // Predefined methods // getAll: (filter?: object) => Promise; // get: (id?: string) => Promise; diff --git a/src/libs/data-sources/useData.tsx b/src/libs/data-sources/useData.tsx index 9f15dca..4e7b93e 100644 --- a/src/libs/data-sources/useData.tsx +++ b/src/libs/data-sources/useData.tsx @@ -1,10 +1,12 @@ -import { useContext, useEffect, useMemo } from 'react'; -import { DataSource } from '.'; +import { useCallback, useContext, useEffect, useMemo } from 'react'; +import { DataSource, FilterObject, WithOptionalId } from '.'; import { DataContext } from './DataProvider'; -interface UseDataPropsOptions { +interface UseDataPropsOptions { datasource?: any; addDatasourceWhenNotAvailable?: boolean; + filter?: FilterObject; + find?: { field: keyof T; operator: '=='; value: any }; [key: string]: any; } @@ -13,11 +15,14 @@ interface UseDataPropsOptions { // [key: string]: any; // } -const defaultOptions: UseDataPropsOptions = { +const defaultOptions = { addDatasourceWhenNotAvailable: true, }; -const useData = (key: string, options: UseDataPropsOptions = defaultOptions): DataSource => { +const useData = ( + key: string, + options: UseDataPropsOptions = defaultOptions +): DataSource => { const context = useContext(DataContext); if (!context) { throw new Error('useData must be used within a DataProvider'); @@ -110,7 +115,25 @@ const useData = (key: string, options: UseDataPropsOptions = defaultOptions) return getClassMethods(dataSource); }, [dataSource]); - const returnObject: DataSource = useMemo( + const find = useCallback( + (id: string) => { + data[key].find((item: any) => item.id === id); + }, + [data, key] + ); + + // const filteredData = useMemo(() => { + // if (options?.find) { + // return data[key].filter( + // (item: T) => + // options.find?.field !== undefined && item[options.find.field] === options.find.value + // ); + // } else { + // return data[key]; + // } + // }, [data, key, options]); // TODO: implement + + const returnObject: DataSource = useMemo( () => ({ // Custom methods custom: { ...methods }, @@ -120,10 +143,11 @@ const useData = (key: string, options: UseDataPropsOptions = defaultOptions) error: error[key], // Public methods actions: { - fetchData: (filter?: any) => fetchData(key, filter), + fetchData: (filter?: FilterObject) => fetchData(key, filter), get: get || (() => {}), getAll: getAll || (() => {}), - add: (item: T) => add(key, item), + find, + add: (item: WithOptionalId) => add(key, item), update: (data, id) => update(key, data, id), set: (data, id) => set(key, data, id), delete: (id) => remove(key, id), @@ -151,6 +175,7 @@ const useData = (key: string, options: UseDataPropsOptions = defaultOptions) error, get, getAll, + find, key, loading, methods, diff --git a/src/libs/filters/components/boolean-filter.tsx b/src/libs/filters/components/boolean-filter.tsx new file mode 100644 index 0000000..b0d372b --- /dev/null +++ b/src/libs/filters/components/boolean-filter.tsx @@ -0,0 +1,72 @@ +import { + FormControl, + FormControlLabel, + FormLabel, + Radio, + RadioGroup, + RadioGroupProps, +} from '@mui/material'; +import { useMemo } from 'react'; +import { BooleanFilter as BooleanFilterType, Filter } from '..'; + +interface BooleanFilterProps { + id: string; + filterOptions: { + filters: Filter[]; + setFilter: (id: string, filter: Filter) => void; + }; + muiRadioGroupProps?: RadioGroupProps; +} + +const BooleanFilter = ({ id, filterOptions, muiRadioGroupProps }: BooleanFilterProps) => { + const { filters, setFilter } = filterOptions; + const filter = useMemo( + () => + filters.find( + (filter): filter is BooleanFilterType => filter.id === id && filter.type === 'boolean' + ) || null, + [filters, id] + ); + + if (!filter) { + throw new Error(`Filter with id ${id} not found`); + } + + //const [filter, setFilter] = useState(initialFilter); + const handleChange = (e: any) => { + const value = e.target.value === 'unset' ? null : e.target.value === 'true'; + const text = value === null ? undefined : value ? 'Alternate: Yes' : 'Alternate: No'; + setFilter(id, { ...filter, value, text }); + }; + + return ( + + {filter.label} + + } + label="All" + /> + } + label="Yes" + /> + } + label="No" + /> + + + ); +}; + +export default BooleanFilter; diff --git a/src/libs/filters/components/mult-select-filter.tsx b/src/libs/filters/components/mult-select-filter.tsx new file mode 100644 index 0000000..54c1137 --- /dev/null +++ b/src/libs/filters/components/mult-select-filter.tsx @@ -0,0 +1,70 @@ +import { Checkbox, FormControl, InputLabel, ListItemText, MenuItem, Select } from '@mui/material'; +import { useMemo } from 'react'; +import { Filter, MultiSelectFilter as MultiSelectFilterType } from '..'; + +interface MultiSelectFilterProps { + id: string; + filterOptions: { + filters: Filter[]; + setFilter: (id: string, filter: Filter) => void; + }; +} + +const MultiSelectFilter = ({ id, filterOptions }: MultiSelectFilterProps) => { + const { filters, setFilter } = filterOptions; + const filter = useMemo( + () => + filters.find( + (filter): filter is MultiSelectFilterType => + filter.id === id && filter.type === 'multi-select' + ) || null, + [filters, id] + ); + + if (!filter) { + throw new Error(`Filter with id ${id} not found`); + } + + //const [filter, setFilter] = useState(initialFilter); + const handleChange = (e: any) => { + const value = e.target.value as string[]; + //const value = e.target.value === 'unset' ? null : e.target.value === 'true'; + // If value is not an empty array, set text as value + const text = + Array.isArray(value) && value.length > 0 + ? `${filter.label}: ${filter.value.join(', ')}` + : undefined; + setFilter(id, { ...filter, value, text }); + }; + + return ( + + {filter.label} + + + ); +}; + +export default MultiSelectFilter; diff --git a/src/libs/filters/components/pagination.tsx b/src/libs/filters/components/pagination.tsx new file mode 100644 index 0000000..967a513 --- /dev/null +++ b/src/libs/filters/components/pagination.tsx @@ -0,0 +1,25 @@ +import { Pagination as DPagination } from '@mui/material'; + +interface PaginationProps { + filter: { + pages: number; + page: number; + setPage: (page: number) => void; + }; + muiPaginationProps?: PaginationProps; +} + +const Pagination = ({ filter }: PaginationProps) => { + //const pageOrZero = filter.page - 1 < 0 ? 0 : filter.page - 1; + return ( + { + filter.setPage(newPage); + }} + /> + ); +}; + +export default Pagination; diff --git a/src/libs/filters/components/range-filter.tsx b/src/libs/filters/components/range-filter.tsx new file mode 100644 index 0000000..fec155d --- /dev/null +++ b/src/libs/filters/components/range-filter.tsx @@ -0,0 +1,111 @@ +import { + Box, + BoxProps, + Slider, + SliderProps, + TextField, + TextFieldProps, + Typography, +} from '@mui/material'; +import { useMemo } from 'react'; +import { Filter, RangeFilter as RangeFilterType } from '..'; + +interface RangeFilterProps { + id: string; + filterOptions: { + filters: Filter[]; + setFilter: (id: string, filter: Filter) => void; + }; + showTextFields?: boolean; + muiBoxProps?: BoxProps; + muiTextFieldProps?: TextFieldProps; + muiSliderProps?: SliderProps; +} + +const RangeFilter = ({ + id, + filterOptions, + showTextFields, + muiTextFieldProps, + muiSliderProps, + muiBoxProps, +}: RangeFilterProps) => { + const { filters, setFilter } = filterOptions; + const filter = useMemo( + () => + filters.find( + (filter): filter is RangeFilterType => filter.id === id && filter.type === 'range' + ) || null, + [filters, id] + ); + + if (!filter) { + throw new Error(`Filter with id ${id} not found`); + } + + const setMinMax = (value: number[]) => { + setFilter(id, { ...filter, value }); + }; + + // const setMinMax = useMemo( + // () => + // debounce((value: number[]) => { + // setFilter(id, { ...filter, value }); + // }, 100), + // [id, filter, setFilter] + // ); + + return ( + + {showTextFields && ( + <> + { + const value = isNaN(Number(e.target.value)) ? undefined : Number(e.target.value); + setMinMax([Number(value || filter.value[0]), filter.value[1]]); + }} + {...muiTextFieldProps} + /> + { + const value = isNaN(Number(e.target.value)) ? undefined : Number(e.target.value); + setMinMax([filter.value[0], Number(value || filter.value[1])]); + }} + {...muiTextFieldProps} + /> + + )} + + {filter.label} + + { + // setFilter(id, { ...filter, value: newValue as number[] }); + setMinMax(newValue as number[]); + }} + // valueLabelDisplay="auto" + valueLabelDisplay={showTextFields ? 'auto' : 'on'} + min={0} + max={100} + {...muiSliderProps} + /> + + ); +}; + +export default RangeFilter; diff --git a/src/libs/filters/components/search-bar.tsx b/src/libs/filters/components/search-bar.tsx new file mode 100644 index 0000000..63f4d65 --- /dev/null +++ b/src/libs/filters/components/search-bar.tsx @@ -0,0 +1,51 @@ +import { IconButton, InputAdornment, OutlinedInput, OutlinedInputProps } from '@mui/material'; +// import SearchBar from '../../../components/default/ui/search-bar'; +import ClearIcon from '@mui/icons-material/Clear'; + +interface SearchBarProps { + filter: { + inputQuery: string; + setSearchQuery: (query: string) => void; + }; + muiOutlinedInputProps?: OutlinedInputProps; +} + +const SearchBar = ({ filter, muiOutlinedInputProps }: SearchBarProps) => { + return ( + <> + + // {/* */} + // + // } + endAdornment={ + + filter.setSearchQuery('')} + onMouseDown={(e) => e.preventDefault()} + edge="end" + > + + + + } + autoFocus + value={filter.inputQuery || ''} + sx={{ flexGrow: 1 }} + onChange={(e) => filter.setSearchQuery(e.target.value)} + {...muiOutlinedInputProps} + placeholder={muiOutlinedInputProps?.placeholder || 'Search'} + /> + + // filter.setSearchQuery('')} + // value={filter.inputQuery} + // onChange={(e) => filter.setSearchQuery(e.target.value)} + // muiOutlinedInputProps={muiOutlinedInputProps} + // /> + ); +}; + +export default SearchBar; diff --git a/src/libs/filters/components/select-filter.tsx b/src/libs/filters/components/select-filter.tsx new file mode 100644 index 0000000..334e067 --- /dev/null +++ b/src/libs/filters/components/select-filter.tsx @@ -0,0 +1,54 @@ +import { FormControl, InputLabel, MenuItem, Select } from '@mui/material'; +import { useMemo } from 'react'; +import { Filter, SelectFilter as SelectFilterType } from '..'; + +interface SelectFilterProps { + id: string; + filterOptions: { + filters: Filter[]; + setFilter: (id: string, filter: Filter) => void; + }; +} + +const SelectFilter = ({ id, filterOptions }: SelectFilterProps) => { + const { filters, setFilter } = filterOptions; + const filter = useMemo( + () => + filters.find( + (filter): filter is SelectFilterType => filter.id === id && filter.type === 'select' + ) || null, + [filters, id] + ); + + if (!filter) { + throw new Error(`Filter with id ${id} not found`); + } + + const handleChange = (e: any) => { + const value = e.target.value; + const text = filter.options.find((option) => option.value === value)?.label; + setFilter(id, { ...filter, value, text }); + }; + + return ( + + {filter.label} + + + ); +}; + +export default SelectFilter; diff --git a/src/libs/filters/components/sort-options.tsx b/src/libs/filters/components/sort-options.tsx new file mode 100644 index 0000000..785c782 --- /dev/null +++ b/src/libs/filters/components/sort-options.tsx @@ -0,0 +1,59 @@ +import { FormControl, FormControlProps, MenuItem, TextField, TextFieldProps } from '@mui/material'; + +interface SortOptionsProps { + filter: { + sortField: string | null; + sortDirection: 'asc' | 'desc'; + setSortField: (sortField: string | null) => void; + setSortDirection: (sortDirection: 'asc' | 'desc') => void; + }; + options: { value: string; label: string }[]; + muiFormControlProps?: FormControlProps; + muiTextFieldProps?: TextFieldProps; +} + +const SortOptions = ({ filter, ...rest }: SortOptionsProps) => { + const { options, muiFormControlProps, muiTextFieldProps } = rest; + const value = `${filter.sortField}-${filter.sortDirection}`; + + const handleSortChange = (e: React.ChangeEvent) => { + const [sortField, sortDirection] = e.target.value.split('-'); + filter.setSortField(sortField); + filter.setSortDirection(sortDirection as 'asc' | 'desc'); + }; + + return ( + // + + + {options.map((option) => ( + + {option.label} + + ))} + + + ); +}; + +export default SortOptions; diff --git a/src/libs/filters/index.tsx b/src/libs/filters/index.tsx new file mode 100644 index 0000000..d01660d --- /dev/null +++ b/src/libs/filters/index.tsx @@ -0,0 +1,35 @@ +type FilterType = 'range' | 'select' | 'boolean' | 'multi-select'; + +interface BaseFilter { + id: string; // ID or Field name + label: string; // Display label + type: FilterType; + value: any; + // onChange: (value: any) => void; + filterFunction?: (item: any) => boolean; // Custom filter logic + text?: string; +} + +export interface RangeFilter extends BaseFilter { + type: 'range'; + value: number[]; // [min, max] +} + +export interface SelectFilter extends BaseFilter { + type: 'select'; + value: string; + options: { label: string; value: string }[]; +} + +export interface BooleanFilter extends BaseFilter { + type: 'boolean'; + value: boolean | null; +} + +export interface MultiSelectFilter extends BaseFilter { + type: 'multi-select'; + value: string[]; // Array of selected values + options: { label: string; value: string }[]; // List of selectable options +} + +export type Filter = RangeFilter | SelectFilter | BooleanFilter | MultiSelectFilter; diff --git a/src/hooks/use-filter.ts b/src/libs/filters/use-filter.ts similarity index 59% rename from src/hooks/use-filter.ts rename to src/libs/filters/use-filter.ts index 242d7a2..cc88423 100644 --- a/src/hooks/use-filter.ts +++ b/src/libs/filters/use-filter.ts @@ -1,5 +1,6 @@ import _, { debounce } from 'lodash'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Filter } from '.'; interface Options { initialPage?: number; @@ -7,54 +8,57 @@ interface Options { limit?: number; initialSortField?: string | null; initialSortDirection?: 'asc' | 'desc'; - initialFilters?: Record; + initialFilters?: Filter[]; searchableFields?: string[] | null; updateInitialData?: boolean; debounceTime?: number; // Add debounceTime option } -interface UseFilterReturn { - data: any[]; +interface UseFilterReturn { + data: T[]; totalFilteredItems: number; page: number; pages: number; rowsPerPage: number; sortField: string | null; sortDirection: 'asc' | 'desc'; - filters: Record; + filters: Filter[]; searchQuery: string; inputQuery: string; - addFilter: (key: string, filterFunctionOrValue: string | (() => void)) => void; - removeFilter: (key: string) => void; + // addFilter: (key: string, filterFunctionOrValue: string | (() => void)) => void; + // removeFilter: (key: string) => void; + setFilter: (id: string, filter: Filter) => void; setPage: (page: number) => void; setRowsPerPage: (rowsPerPage: number) => void; setSortField: (sortField: string | null) => void; setSortDirection: (sortDirection: 'asc' | 'desc') => void; - setFilters: (filters: Record) => void; + setFilters: (filters: Filter[]) => void; setSearchQuery: (searchQuery: string) => void; setInputQuery: (inputQuery: string) => void; - setData: (data: any[]) => void; + setData: (data: T[]) => void; } -const useFilter = (initialData: any[] = [], options: Options = {}): UseFilterReturn => { +//TODO: use type in usefilter call + +const useFilter = (initialData: T[] = [], options: Options = {}): UseFilterReturn => { const { - initialPage = 0, + initialPage = 1, initialRowsPerPage = 10, limit = Infinity, initialSortField = null, initialSortDirection = 'asc', - initialFilters = {}, + initialFilters = [], searchableFields = null, updateInitialData = false, debounceTime = 300, // Default debounce time to 300ms } = options; - const [data, setData] = useState(initialData); + const [data, setData] = useState(initialData); const [page, setPage] = useState(initialPage); const [rowsPerPage, setRowsPerPage] = useState(initialRowsPerPage); const [sortField, setSortField] = useState(initialSortField); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>(initialSortDirection); - const [filters, setFilters] = useState(initialFilters); + const [filters, setFilters] = useState(initialFilters); const [searchQuery, setSearchQuery] = useState(''); const [inputQuery, setInputQuery] = useState(''); // State for the input value @@ -77,35 +81,31 @@ const useFilter = (initialData: any[] = [], options: Options = {}): UseFilterRet debouncedSetSearchQuery(query); }; - /** - * Adds or updates a filter. - * @param {string} key - The key identifying the filter. - * @param {Function|any} filterFunctionOrValue - The filter function or value to be applied. - */ - const addFilter = (key: string, filterFunctionOrValue: string | (() => void)) => { - setFilters((prevFilters) => ({ - ...prevFilters, - [key]: filterFunctionOrValue, - })); - }; + // const addFilter = (key: string, filterFunctionOrValue: string | (() => void)) => { + // setFilters((prevFilters) => ({ + // ...prevFilters, + // [key]: filterFunctionOrValue, + // })); + // }; // TODO: replace + + // const removeFilter = (key: string) => { + // setFilters((prevFilters) => { + // const newFilters = { ...prevFilters }; + // delete newFilters[key]; + // return newFilters; + // }); + // }; //TODO: replace - /** - * Removes a filter. - * @param {string} key - The key identifying the filter to be removed. - */ - const removeFilter = (key: string) => { + const setFilter = useCallback((id: string, filter: Filter) => { setFilters((prevFilters) => { - const newFilters = { ...prevFilters }; - delete newFilters[key]; - return newFilters; + const index = prevFilters.findIndex((f) => f.id === id); + if (index === -1) { + return [...prevFilters, filter]; + } + return [...prevFilters.slice(0, index), filter, ...prevFilters.slice(index + 1)]; }); - }; + }, []); - /** - * Applies sorting to the given data array based on the specified sort field and direction. - * @param {any[]} data - The array of data to be sorted. - * @returns {any[]} The sorted array. - */ const applySort = (data: any[]): any[] => { if (!sortField) return data; @@ -122,28 +122,58 @@ const useFilter = (initialData: any[] = [], options: Options = {}): UseFilterRet ); }; - /** - * Applies custom filters to the dataset. - * @param {Array} data - The dataset to be filtered. - * @returns {Array} The filtered dataset. - */ - const applyFilters = (data: any[]) => { - return data.filter((item: any) => { - return Object.keys(filters).every((key) => { - const filter = filters[key]; - if (typeof filter === 'function') { - return filter(item); + // const applyFilters = (data: T[]) => { + // return data.filter((item: T) => { + // return Object.keys(filters).every((key) => { + // const filter = filters[key]; + // if (typeof filter === 'function') { + // return filter(item); + // } + // return (item as Record)[key] === filter; + // }); + // }); + // }; //TODO: replace + + const applyFilters = (data: T[]): T[] => { + return data.filter((item) => + filters.every((filter) => { + if ( + filter.value === null || + filter.value === undefined || + (Array.isArray(filter.value) && filter.value.length === 0) + ) { + return true; // Skip unset filters } - return item[key] === filter; - }); - }); + + switch (filter.type) { + case 'range': { + const rangeValue = filter.value; + return ( + (rangeValue[0] === undefined || + (item as Record)[filter.id] >= rangeValue[0]) && + (rangeValue[1] === undefined || + (item as Record)[filter.id] <= rangeValue[1]) + ); + // return ( + // (rangeValue.min === undefined || + // (item as Record)[filter.id] >= rangeValue.min) && + // (rangeValue.max === undefined || + // (item as Record)[filter.id] <= rangeValue.max) + // ); + } + case 'multi-select': + return filter.value.includes((item as Record)[filter.id]); + case 'boolean': + return ( + filter.value === null || (item as Record)[filter.id] === filter.value + ); + default: + return true; + } + }) + ); }; - /** - * Applies text search to the dataset based on searchable fields or all fields. - * @param {Array} data - The dataset to be searched. - * @returns {Array} The filtered dataset that matches the search query. - */ const applyTextSearch = (data: any[]) => { if (!searchQuery) return data; // If no search query, return data unchanged @@ -163,21 +193,12 @@ const useFilter = (initialData: any[] = [], options: Options = {}): UseFilterRet }); }; - /** - * Applies pagination to the dataset based on the current page (0-based) and rowsPerPage. - * Then enforces the hard limit if necessary. - * @param {Array} data - The dataset to be paginated. - * @returns {Array} The paginated dataset. - */ const applyPagination = (data: any[]) => { - const startIndex = page * rowsPerPage; // Start index for the current page (0-based) + const startIndex = (page - 1) * rowsPerPage; // Start index for the current page (1-based) const endIndex = Math.min(startIndex + rowsPerPage, limit); // Ensure no more than `limit` items are returned return data.slice(startIndex, endIndex); }; - /** - * Total items after applying filters and search, but before pagination is applied. - */ const totalFilteredItems = useMemo(() => { let processedData = applyFilters(data); processedData = applyTextSearch(processedData); @@ -193,20 +214,12 @@ const useFilter = (initialData: any[] = [], options: Options = {}): UseFilterRet return applyPagination(processedData); }, [data, filters, searchQuery, sortField, sortDirection, page, rowsPerPage, limit]); - /** - * Reset the page to 0 whenever filters, searchQuery, sortField, or sortDirection change, - * but only if the current page is not already 0 to avoid unnecessary resets. - */ useEffect(() => { - if (page !== 0) { - setPage(0); + if (page !== 1) { + setPage(1); } }, [filters, searchQuery, sortField, sortDirection]); - /** - * Sets the number of rows per page and resets the page to 0. - * @param {number} newRowsPerPage - The new number of rows per page. - */ const updateRowsPerPage = (newRowsPerPage: number) => { setRowsPerPage(newRowsPerPage); setPage(0); // Reset to page 0 when changing rows per page @@ -230,8 +243,9 @@ const useFilter = (initialData: any[] = [], options: Options = {}): UseFilterRet setRowsPerPage: updateRowsPerPage, // This allows setting rows per page setSortField, setSortDirection, - addFilter, - removeFilter, + // addFilter, + // removeFilter, + setFilter, setFilters, setSearchQuery: handleInputQueryChange, // Use the handler for input changes setInputQuery, diff --git a/src/libs/forms/components/Autocomplete.tsx b/src/libs/forms/components/Autocomplete.tsx new file mode 100644 index 0000000..ff85742 --- /dev/null +++ b/src/libs/forms/components/Autocomplete.tsx @@ -0,0 +1,143 @@ +import { FieldConfig } from '@/libs/forms'; +import useFormField from '@/libs/forms/use-form-field'; +import { + AutocompleteProps, + Box, + ChipProps, + Autocomplete as MUIAutocomplete, + TextField, + TextFieldProps, + Typography, +} from '@mui/material'; +import _ from 'lodash'; + +interface AutocompleteChipListProps { + name?: string; + field?: FieldConfig; + suggestions?: string[]; + muiAutocompleteProps?: Partial>; + muiChipProps?: ChipProps; + muiTextFieldProps?: TextFieldProps; +} + +const Autocomplete = ({ name, field: fieldConfig, ...props }: AutocompleteChipListProps) => { + if (!name && !fieldConfig) { + throw new Error('Either name or field must be provided'); + } + const fieldName = name || fieldConfig?.name; + const data = useFormField(fieldName as string); + const { options, field, helpers, meta } = data; + + // Merge custom props with default props + const newProps = _.merge({}, options, props); + // Merge suggestions with default suggestions and remove the current values + // const mergedSuggestions = useMemo( + // () => + // Array.from(new Set([...(fieldConfig?.custom?.suggestions || []), ...(suggestions || [])])) + // .filter((suggestion) => !field.value.includes(suggestion)) + // .sort(Intl.Collator().compare), + // [field.value, fieldConfig?.custom?.suggestions, suggestions] + // ); + + // TODO: should be unique? getOptionDisabled={(option) => field.value.includes(option)} + + console.log(field.value); + const selectedOption = fieldConfig?.options?.find((option: any) => option.key === field.value); + + return ( + helpers.setValue(newValue.key)} + getOptionLabel={(option) => option.label || ''} + //getOptionDisabled={(option) => field.value.includes(option)} + // renderTags={(value, getTagProps) => + // value.map((option, index) => ( + // { + // const newKeywords = field.value.filter((keyword: string) => keyword !== option); + // helpers.setValue(newKeywords); + // }} + // {...newProps?.muiChipProps} + // /> + // )) + // } + renderInput={(params) => ( + + {typeof selectedOption.img === 'string' ? ( + {selectedOption.label} + ) : ( + <>{selectedOption.img} + )} + + ) : null, + }} + /> + )} + renderOption={(props, option) => ( + + {option.img && ( + <> + {typeof option.img === 'string' ? ( + {option.label} + ) : ( + <>{option.img} + )} + + )} + + {option.label} + + )} + /> + ); +}; + +export default Autocomplete; diff --git a/src/libs/forms/components/CheckboxList.tsx b/src/libs/forms/components/CheckboxList.tsx new file mode 100644 index 0000000..8127c53 --- /dev/null +++ b/src/libs/forms/components/CheckboxList.tsx @@ -0,0 +1,222 @@ +import { FieldConfig } from '@/libs/forms'; +import useFormField from '@/libs/forms/use-form-field'; +import ClearIcon from '@mui/icons-material/Clear'; +import { + Box, + Button, + Checkbox, + IconButton, + InputAdornment, + List, + ListItem, + ListItemButton, + ListItemIcon, + ListItemText, + ListProps, + TextField, +} from '@mui/material'; +import _ from 'lodash'; +import { useMemo, useState } from 'react'; + +interface CustomCheckboxListProps { + name?: string; + field?: FieldConfig; + muiListProps?: ListProps; + options?: CustomCheckboxListOptions; +} + +interface CustomCheckboxListOptions { + search?: boolean; + selectAll?: boolean; + selectNone?: boolean; + inverted?: boolean; +} + +const defaultOptions: CustomCheckboxListOptions = { + search: false, + selectAll: false, + selectNone: false, + inverted: false, +}; + +const IconComponent: React.FC<{ imageUrl: string; alt: string }> = ({ imageUrl, alt }) => ( + + {alt} + +); + +const CheckboxList = ({ name, field: fieldConfig, options }: CustomCheckboxListProps) => { + if (!name && !fieldConfig) { + throw new Error('Either name or field must be provided'); + } + const [searchQuery, setSearchQuery] = useState(''); + const fieldName = name || fieldConfig?.name; + const data = useFormField(fieldName as string, fieldConfig); + const { field, helpers } = data; + + // spread options + const { search, selectAll, selectNone, inverted } = _.merge({}, defaultOptions, options); + + console.log(fieldName, name, fieldConfig, options); + + const optionList = fieldConfig?.options || []; + const checkedItems = useMemo(() => { + return new Set(field.value || []); + }, [field.value]); + console.log(checkedItems, field.value, field.checked); + //const [checkedItems, setCheckedItems] = useState(new Set(field.value || [])); + + // useEffect(() => { + // const newCheckedItems = new Set(field.value || []); + // if (!_.isEqual(Array.from(newCheckedItems), Array.from(checkedItems))) { + // setCheckedItems(newCheckedItems); + // } + // }, [field.value, checkedItems]); + + // const label = fieldConfig?.translationKey + // ? t(fieldConfig?.translationKey, { defaultValue: fieldConfig?.label || fieldName }) + // : fieldConfig?.label || name;) + + const handleToggle = (key: string) => { + const newCheckedItems = new Set(field.value || []); + + if (newCheckedItems.has(key)) { + newCheckedItems.delete(key); + } else { + newCheckedItems.add(key); + } + + helpers.setValue(Array.from(newCheckedItems)); + }; + + const filteredOptions = optionList.filter( + (item) => + item.key.toLowerCase().includes(searchQuery.toLowerCase()) || + item.label.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return ( + <> + {(search || selectAll || selectNone) && ( + + {search && ( + setSearchQuery(e.target.value)} + sx={{ mr: 1 }} + size="small" + InputProps={{ + endAdornment: ( + + setSearchQuery('')} + edge="end" + > + + + + ), + }} + /> + )} + {selectAll && ( + + )} + {selectNone && ( + + )} + + )} + + {filteredOptions.map((item) => { + const labelId = `checkbox-list-label-${item.key}`; + const itemIsChecked = () => { + let isChecked = false; + if (checkedItems.has(item.key)) { + isChecked = true; + } + if (inverted) { + isChecked = !isChecked; + } + // console.log(item.key, isChecked, inverted); + return isChecked; + }; + itemIsChecked(); + + return ( + handleToggle(item.key)} + secondaryAction={item.secondaryAction} + sx={{ + '&:hover': { + backgroundColor: 'rgba(0, 0, 0, 0.08)', + }, + }} + // secondaryAction={} + > + + + + + {item.img && ( + + + + )} + + + + ); + })} + + + ); +}; + +export default CheckboxList; diff --git a/src/libs/forms/components/Notepad.tsx b/src/libs/forms/components/Notepad.tsx new file mode 100644 index 0000000..161f506 --- /dev/null +++ b/src/libs/forms/components/Notepad.tsx @@ -0,0 +1,137 @@ +import { styled } from '@mui/material/styles'; +import Quill from 'react-quill'; +import 'react-quill/dist/quill.snow.css'; +import { FieldConfig, useFormField } from '..'; + +// For some unknown reason, styling Quill directly throws an error. + +const Editor = (props: any) => ; + +const StyledNotepad = styled(Editor)(({ theme }) => ({ + border: 1, + borderColor: theme.palette.divider, + borderRadius: theme.shape.borderRadius, + borderStyle: 'solid', + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', + '& .quill': { + display: 'flex', + flex: 1, + flexDirection: 'column', + overflow: 'hidden', + }, + '& .ql-snow.ql-toolbar': { + borderColor: theme.palette.divider, + borderLeft: 'none', + borderRight: 'none', + borderTop: 'none', + '& .ql-picker-label:hover': { + color: theme.palette.primary.main, + }, + '& .ql-picker-label.ql-active': { + color: theme.palette.primary.main, + }, + '& .ql-picker-item:hover': { + color: theme.palette.primary.main, + }, + '& .ql-picker-item.ql-selected': { + color: theme.palette.primary.main, + }, + '& button:hover': { + color: theme.palette.primary.main, + '& .ql-stroke': { + stroke: theme.palette.primary.main, + }, + }, + '& button:focus': { + color: theme.palette.primary.main, + '& .ql-stroke': { + stroke: theme.palette.primary.main, + }, + }, + '& button.ql-active': { + '& .ql-stroke': { + stroke: theme.palette.primary.main, + }, + }, + '& .ql-stroke': { + stroke: theme.palette.text.primary, + }, + '& .ql-picker': { + color: theme.palette.text.primary, + }, + '& .ql-picker-options': { + backgroundColor: theme.palette.background.paper, + border: 'none', + borderRadius: theme.shape.borderRadius, + boxShadow: theme.shadows[10], + padding: theme.spacing(2), + }, + }, + '& .ql-snow.ql-container': { + borderBottom: 'none', + borderColor: theme.palette.divider, + borderLeft: 'none', + borderRight: 'none', + display: 'flex', + flex: 1, + flexDirection: 'column', + height: 'auto', + overflow: 'hidden', + '& .ql-editor': { + color: theme.palette.text.primary, + flex: 1, + fontFamily: theme.typography.body1.fontFamily, + fontSize: theme.typography.body1.fontSize, + height: 'auto', + overflowY: 'auto', + padding: theme.spacing(2), + '&.ql-blank::before': { + color: theme.palette.text.secondary, + fontStyle: 'normal', + left: theme.spacing(2), + }, + }, + }, +})); + +interface NotepadProps { + name?: string; + field?: FieldConfig; + placeholder?: string; + sx?: any; +} + +const Notepad = ({ name, field: fieldConfig, placeholder, ...props }: NotepadProps) => { + if (!name && !fieldConfig) { + throw new Error('Either name or field must be provided'); + } + const fieldName = name || fieldConfig?.name; + const data = useFormField(fieldName as string, fieldConfig); + const { field, helpers } = data; + + //const newProps = _.merge({}, options, props); + + const value = field.value !== undefined ? field.value : ''; + + return ( + { + if (v !== value) { + helpers.setValue(v); + } + }} + {...props} + /> + ); +}; + +export default Notepad; diff --git a/src/libs/forms/components/Select.tsx b/src/libs/forms/components/Select.tsx new file mode 100644 index 0000000..2da083d --- /dev/null +++ b/src/libs/forms/components/Select.tsx @@ -0,0 +1,62 @@ +import { FieldConfig } from '@/libs/forms'; +import useFormField from '@/libs/forms/use-form-field'; +import { FormControl, InputLabel, MenuItem, Select as MUISelect, SelectProps } from '@mui/material'; + +interface CustomSelectProps { + name?: string; + field?: FieldConfig; + options?: { key: string; value: string }[]; + muiSelectProps?: SelectProps; +} + +const Select = ({ name, field: fieldConfig, options: menuOptions }: CustomSelectProps) => { + if (!name && !fieldConfig) { + throw new Error('Either name or field must be provided'); + } + const fieldName = name || fieldConfig?.name; + const data = useFormField(fieldName as string, fieldConfig); + const { options, field, meta } = data; + + const selectOptions = menuOptions || fieldConfig?.options || []; + + return ( + <> + + {fieldConfig?.label || fieldName} + e.stopPropagation()} + sx={{ width: '100%' }} + size={'small'} + > + {selectOptions.map((option: any) => ( + + {option.value} + + ))} + + + {/* */} + + ); +}; + +export default Select; diff --git a/src/libs/forms/components/SubmitButton.tsx b/src/libs/forms/components/SubmitButton.tsx index 920a8e8..5e9b16e 100644 --- a/src/libs/forms/components/SubmitButton.tsx +++ b/src/libs/forms/components/SubmitButton.tsx @@ -1,5 +1,5 @@ import { useFormButton } from '@/libs/forms'; -import { ButtonProps, CircularProgress, Button as DefaultButton } from '@mui/material'; +import { ButtonProps, CircularProgress, Button as DefaultButton, Tooltip } from '@mui/material'; import _ from 'lodash'; const SubmitButton = (props: ButtonProps) => { @@ -10,35 +10,57 @@ const SubmitButton = (props: ButtonProps) => { const disabled = formik.isSubmitting || !formik.isValid || !formik.dirty; + let disabledText: string = `Values are good to save`; + + if (disabled) { + if (!formik.dirty) { + disabledText = 'No changes to save'; + } else if (!formik.isValid) { + disabledText = 'Form is invalid.\n\n'; + console.log(formik.errors); + Object.keys(formik.errors).forEach((key) => { + disabledText += `${(formik.errors as Record)[key]}\n`; + }); + //disabledText += formik.errors ? JSON.stringify(formik.errors, null, 2) : ''; + } else if (formik.isSubmitting) { + disabledText = 'Submitting...'; + } + } + const isLoading = formik.isSubmitting; return ( <> - ) => { - await formik.handleSubmit(); - if (onClick) await onClick(e); - }} - // onSubmit={(e: React.MouseEvent) => { - // formik.handleSubmit(); - // if (onClick) onClick(e); - // }} - {...newProps.muiButtonProps} - startIcon={ - isLoading ? ( - - ) : null - } - > - {isLoading ? 'Submitting...' : props.children || 'Submit'} - + {disabledText}}> + + ) => { + await formik.handleSubmit(); + if (onClick) await onClick(e); + }} + // onSubmit={(e: React.MouseEvent) => { + // formik.handleSubmit(); + // if (onClick) onClick(e); + // }} + {...newProps.muiButtonProps} + {...props} + startIcon={ + isLoading ? ( + + ) : null + } + > + {isLoading ? 'Submitting...' : props.children || 'Submit'} + + + ); }; diff --git a/src/libs/forms/components/Table.tsx b/src/libs/forms/components/Table.tsx new file mode 100644 index 0000000..2421e2a --- /dev/null +++ b/src/libs/forms/components/Table.tsx @@ -0,0 +1,225 @@ +import { FieldConfig } from '@/libs/forms'; +import useFormField from '@/libs/forms/use-form-field'; +import AddIcon from '@mui/icons-material/Add'; +import DeleteIcon from '@mui/icons-material/Delete'; +import { + AutocompleteProps, + ChipProps, + Table as DTable, + IconButton, + TableRow as MUITableRow, + Stack, + TableBody, + TableCell, + TableContainer, + TableHead, + TextFieldProps, + Toolbar, + Tooltip, + Typography, +} from '@mui/material'; +import { FieldArray } from 'formik'; +import _ from 'lodash'; +import React, { useState } from 'react'; +import Autocomplete from './Autocomplete'; +import Select from './Select'; +import TextField from './TextField'; + +export interface TableProps { + name?: string; + field?: FieldConfig; + tableOptions?: { + template?: { [key: string]: string | number }; + getTemplate?: () => { [key: string]: string | number }; + editable?: boolean; + selectable?: boolean; + columns?: { [key: string]: FieldConfig }; + title?: string; + }; + muiAutocompleteProps?: Partial>; + muiChipProps?: ChipProps; + muiTextFieldProps?: TextFieldProps; +} + +const defaultTableOptions = { + // template: {}, + getTemplate: () => ({}), + editable: true, + selectable: false, + columns: {}, + //title: 'Table title', +}; + +const Table = ({ name, field: fieldConfig, tableOptions }: TableProps) => { + const [selected, setSelected] = useState([]); + if (!name && !fieldConfig) { + throw new Error('Either name or field must be provided'); + } + const fieldName = name || fieldConfig?.name; + const data = useFormField(fieldName as string); + const { field, helpers } = data; + // const mergedTableOptions = _.merge({}, defaultTableOptions, tableOptions); + // const { template, getTemplate } = mergedTableOptions; + // console.log(template, getTemplate); + + // const { editable, selectable, columns = {}, title } = fieldConfig?.custom?.table || {}; + // Merge tableOptions with fieldConfig.custom.table + const mergedTableOptions = _.merge( + {}, + defaultTableOptions, + fieldConfig?.custom?.table, + tableOptions + ); + const { template, getTemplate, editable, selectable, columns = {}, title } = mergedTableOptions; + console.log(mergedTableOptions); + + // useEffect(() => { + // if (field.value.length === 0) { + // helpers.push({}); + // } + // }, []) + + const handleDeleteRows = () => { + const newValues = field.value.filter((_: any, index: number) => !selected.includes(index)); + //selected.forEach((index) => deleteFn(index)); + helpers.setValue(newValues); + setSelected([]); + }; + + const renderCell = (_row: any, index: number, fieldDefinition: FieldConfig) => { + // if (fieldDefinition.render) { + // return fieldDefinition.render(row[fieldDefinition?.id]); + // } else // TODO: Implement + + if (fieldDefinition.definition === 'select') { + return ( +