diff --git a/README.md b/README.md index 5e2296c3..9061ebfa 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Using Force UI as a dependency in package.json - ```json "dependencies": { - "@bsf/force-ui": "git+https://github.com/brainstormforce/force-ui#1.4.0" + "@bsf/force-ui": "git+https://github.com/brainstormforce/force-ui#1.4.1" } ``` @@ -28,7 +28,7 @@ npm install Or you can directly run the following command to install the package - ```bash -npm i -S @bsf/force-ui@git+https://github.com/brainstormforce/force-ui.git#1.4.0 +npm i -S @bsf/force-ui@git+https://github.com/brainstormforce/force-ui.git#1.4.1 ```
diff --git a/changelog.txt b/changelog.txt index 205bc869..e29fb57d 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,7 @@ +Version 1.4.1 - 10th February, 2025 +- Improvement: Show active preset with background color in the DatePicker component. +- Improvement: Introduced a new property in the Select component for personalized search beyond the component. + Version 1.4.0 - 27th January, 2025 - New: Added a Hamburger Menu component. - Improvement: Added Y-axis tick formatter to the BarChart component. diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 00000000..091a2cfb --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,103 @@ +import gulp from 'gulp'; +import replace from 'gulp-replace'; +import inquirer from 'inquirer'; +import { exec } from 'child_process'; + +/* eslint-disable no-console */ + +function updateVersion( newVersion ) { + // Update README.md - handle both dependency formats + gulp.src( './README.md' ) + .pipe( + replace( + /@bsf\/force-ui@git\+https:\/\/github\.com\/brainstormforce\/force-ui\.git#[0-9]+\.[0-9]+\.[0-9]+/g, + `@bsf/force-ui@git+https://github.com/brainstormforce/force-ui.git#${ newVersion }` + ) + ) + .pipe( + replace( + /@bsf\/force-ui": "git\+https:\/\/github\.com\/brainstormforce\/force-ui#[0-9]+\.[0-9]+\.[0-9]+/g, + `@bsf/force-ui": "git+https://github.com/brainstormforce/force-ui#${ newVersion }` + ) + ) + .pipe( gulp.dest( './' ) ); + + // Update package.json + gulp.src( './package.json' ) + .pipe( + replace( + /"version": "[0-9]+\.[0-9]+\.[0-9]+"/, + `"version": "${ newVersion }"` + ) + ) + .pipe( gulp.dest( './' ) ); + + // Update version.json + gulp.src( './version.json' ) + .pipe( + replace( + /"force-ui": "[0-9]+\.[0-9]+\.[0-9]+"/, + `"force-ui": "${ newVersion }"` + ) + ) + .pipe( gulp.dest( './' ) ); +} + +// Bump version in all files +gulp.task( 'bump-and-update', function( done ) { + // Get the new version from command line arguments + let newVersion = process.argv[ 3 ]; + + // If the version is not provided as an argument, prompt for it + if ( ! newVersion ) { + inquirer + .prompt( [ + { + type: 'input', + name: 'version', + message: 'Enter the new version:', + }, + ] ) + .then( ( answers ) => { + newVersion = answers.version; + updateVersion( newVersion ); + console.log( '✅ Bumped version successfully' ); + done(); + } ) + .catch( ( error ) => { + console.error( `Error: ${ error.message }` ); + console.error( '❌ Failed to bump version' ); + done( error ); + } ); + } else { + try { + updateVersion( newVersion ); + console.log( '✅ Bumped version successfully' ); + done(); + } catch ( error ) { + console.error( `Error: ${ error.message }` ); + console.error( '❌ Failed to bump version' ); + done( error ); + } + } +} ); + +// Update package-lock.json +gulp.task( 'update-package-lock', function( done ) { + exec( 'npm i', ( error, stdout, stderr ) => { + if ( error ) { + console.error( `Error: ${ error.message }` ); + done( error ); + return; + } + if ( stderr ) { + console.error( `stderr: ${ stderr }` ); + } + console.log( `stdout: ${ stdout }` ); + console.log( '✅ Updated package-lock.json successfully' ); + done(); + } ); +} ); + +// Bump version and update package-lock.json +gulp.task( 'bump', gulp.series( 'bump-and-update', 'update-package-lock' ) ); diff --git a/package-lock.json b/package-lock.json index 849a79cf..5cd8c786 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bsf/force-ui", - "version": "1.2.1", + "version": "1.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bsf/force-ui", - "version": "1.2.1", + "version": "1.4.1", "license": "ISC", "dependencies": { "@emotion/is-prop-valid": "^1.3.0", diff --git a/package.json b/package.json index fecd8f64..4ef0e913 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bsf/force-ui", - "version": "1.4.0", + "version": "1.4.1", "description": "Library of components for the BSF project", "main": "./dist/force-ui.js", "module": "./dist/force-ui.js", @@ -107,6 +107,10 @@ "eslint-plugin-react-refresh": "^0.4.12", "eslint-plugin-storybook": "^0.9.0", "globals": "^15.9.0", + "gulp": "^5.0.0", + "gulp-cli": "^3.0.0", + "gulp-replace": "^1.1.4", + "inquirer": "^12.4.1", "postcss": "^8.4.39", "prettier": "^3.2.5", "rollup-preserve-directives": "^1.1.2", @@ -116,7 +120,7 @@ "tailwindcss": "^3.4.10", "typescript": "5.4.2", "typescript-eslint": "^8.7.0", - "vite": "^5.4.8", + "vite": "^5.4.14", "vite-plugin-dts": "^4.2.3" }, "browserslist": [ diff --git a/src/components/datepicker/datepicker.stories.tsx b/src/components/datepicker/datepicker.stories.tsx index 7e07acae..25d2c37d 100644 --- a/src/components/datepicker/datepicker.stories.tsx +++ b/src/components/datepicker/datepicker.stories.tsx @@ -162,6 +162,11 @@ WithPresets.args = { }, }, ], + // set last_week selected for testing. + selected: { + from: startOfMonth( new Date() ), + to: endOfMonth( new Date() ), + }, onApply: () => { //code }, diff --git a/src/components/datepicker/datepicker.tsx b/src/components/datepicker/datepicker.tsx index e2b65426..6ce51699 100644 --- a/src/components/datepicker/datepicker.tsx +++ b/src/components/datepicker/datepicker.tsx @@ -9,9 +9,11 @@ import { startOfMonth, endOfMonth, subDays, + startOfDay, } from 'date-fns'; import { getDefaultSelectedValue } from './utils'; import { type PropsBase } from 'react-day-picker'; +import { cn } from '@/utilities/functions'; export interface DatePickerProps { /** Defines the selection selectionType of the date picker: single, range, or multiple dates. */ @@ -114,8 +116,8 @@ const DatePicker = ( { { label: 'Last 7 Days', range: { - from: subDays( new Date(), 6 ), - to: new Date(), + from: startOfDay( subDays( new Date(), 6 ) ), + to: startOfDay( new Date() ), }, }, { @@ -128,8 +130,8 @@ const DatePicker = ( { { label: 'Last 30 Days', range: { - from: subDays( new Date(), 29 ), - to: new Date(), + from: startOfDay( subDays( new Date(), 29 ) ), + to: startOfDay( new Date() ), }, }, ]; @@ -226,16 +228,30 @@ const DatePicker = ( { return (
- { presets.map( ( preset, index ) => ( - - ) ) } + { presets.map( ( preset, index ) => { + const isSelected = + selectedDates && + 'from' in selectedDates && + 'to' in selectedDates && + selectedDates.from?.getTime() === + preset.range.from.getTime() && + selectedDates.to?.getTime() === + preset.range.to.getTime(); + + return ( + + ); + } ) }
Promise; + /** Delay in milliseconds for debounced search. If the searchFn is provided, the debounceDelay will be used to debounce the search. */ + debounceDelay?: number; }; export interface SelectPortalProps { @@ -167,4 +171,6 @@ export type SelectContextValue = { onChange: SelectOnChange; value?: SelectOptionValue | SelectOptionValue[]; searchPlaceholder?: string; + searchFn?: ( keyword: string ) => Promise; + debounceDelay?: number; }; diff --git a/src/components/select/select.tsx b/src/components/select/select.tsx index 87dec5d9..df913bd8 100644 --- a/src/components/select/select.tsx +++ b/src/components/select/select.tsx @@ -12,6 +12,7 @@ import { useLayoutEffect, Fragment, type ReactNode, + memo, } from 'react'; import { cn } from '@/utilities/functions'; import { CheckIcon, ChevronDown, ChevronsUpDown, Search } from 'lucide-react'; @@ -30,7 +31,7 @@ import { autoUpdate, FloatingPortal, } from '@floating-ui/react'; -import Badge from '../badge'; +import { Badge, Loader } from '@/components'; import { nanoid } from 'nanoid'; import { disabledClassNames, @@ -53,6 +54,7 @@ import type { SelectOptionGroupProps, } from './select-types'; import { getTextContent } from './utils'; +import { useDebouncedCallback } from '@/utilities/hooks'; // Context to manage the state of the select component. const SelectContext = createContext( @@ -360,6 +362,8 @@ export function SelectOptions( { by, searchPlaceholder, activeIndex, + searchFn, + debounceDelay, } = useSelectContext(); const initialSelectedValueIndex = useMemo( () => { @@ -446,7 +450,9 @@ export function SelectOptions( { return false; } - if ( searchKeyword ) { + // Handle option groups when searchFn is not provided + // Search functionality will be handled outside of the select component + if ( searchKeyword && ! searchFn ) { const textContent = getTextContent( groupChild.props.children )?.toLowerCase(); @@ -498,8 +504,8 @@ export function SelectOptions( { return cloneElement( child, groupProps ); } - // Handle regular options - if ( searchKeyword ) { + // Handle regular options when searchFn is not provided + if ( searchKeyword && ! searchFn ) { const textContent = getTextContent( child.props?.children )?.toLowerCase(); @@ -519,7 +525,7 @@ export function SelectOptions( { }; return Children.map( children, processChild ); - }, [ searchKeyword, value, selected, children ] ); + }, [ searchKeyword, value, selected, children, searchFn ] ); const childrenCount = Children.count( renderChildren ); // Update the content list reference. @@ -548,7 +554,8 @@ export function SelectOptions( { const textContent = getTextContent( child.props?.children )?.toLowerCase(); - if ( searchKeyword ) { + // Handle regular options when searchFn is not provided + if ( searchKeyword && ! searchFn ) { const searchTerm = searchKeyword.toLowerCase(); const textMatch = textContent?.includes( searchTerm ); @@ -559,8 +566,38 @@ export function SelectOptions( { listContentRef.current.push( textContent ); } ); + }, [ searchKeyword, searchFn ] ); + + const [ searching, setSearching ] = useState( false ); + + // Create a function to handle the search function. + const handleSearchFn = useCallback( async () => { + if ( ! searchFn || typeof searchFn !== 'function' || searching ) { + return; + } + + setSearching( true ); + try { + await searchFn( searchKeyword ); + } catch ( error ) { + // eslint-disable-next-line no-console + console.error( error ); + } finally { + setSearching( false ); + } }, [ searchKeyword ] ); + // Debounce the search function. + const initiateSearch = useDebouncedCallback( handleSearchFn, debounceDelay! ); + + // Initiate search when searchFn is a function. + useEffect( () => { + if ( typeof searchFn !== 'function' ) { + return; + } + initiateSearch(); + }, [ initiateSearch ] ); + return ( <> { /* Dropdown */ } @@ -595,14 +632,24 @@ export function SelectOptions( { .searchbarWrapper ) } > - + { searching ? ( + + ) : ( + + ) } { const selectId = useMemo( () => id || `select-${ nanoid() }`, [ id ] ); const isControlled = useMemo( () => typeof value !== 'undefined', [ value ] ); @@ -961,6 +1010,8 @@ const Select = ( { disabled, isControlled, searchPlaceholder, + searchFn, + debounceDelay, } } > { children } @@ -968,16 +1019,20 @@ const Select = ( { ); }; +SelectComponent.displayName = 'Select'; + +const Select = Object.assign( memo( SelectComponent ), { + Portal: memo( SelectPortal ), + Button: memo( SelectButton ), + Options: memo( SelectOptions ), + Option: memo( SelectItem ), + OptionGroup: memo( SelectOptionGroup ), +} ); + SelectPortal.displayName = 'Select.Portal'; SelectButton.displayName = 'Select.Button'; SelectOptions.displayName = 'Select.Options'; SelectItem.displayName = 'Select.Option'; SelectOptionGroup.displayName = 'Select.OptionGroup'; -Select.Portal = SelectPortal; -Select.Button = SelectButton; -Select.Options = SelectOptions; -Select.Option = SelectItem; -Select.OptionGroup = SelectOptionGroup; - export default Select; diff --git a/src/utilities/hooks.ts b/src/utilities/hooks.ts new file mode 100644 index 00000000..ca34c65b --- /dev/null +++ b/src/utilities/hooks.ts @@ -0,0 +1,48 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; + +type AsyncFunction = ( ...args: T ) => Promise; + +/** + * Debounce a value + * @param {any} value - The value to debounce + * @param {number} delay - The delay in milliseconds + * @return {any} The debounced value + */ +export const useDebounce = ( value: unknown, delay: number = 500 ) => { + const [ debouncedValue, setDebouncedValue ] = useState( value ); + + useEffect( () => { + const timeout = setTimeout( () => setDebouncedValue( value ), delay ); + + return () => clearTimeout( timeout ); + }, [ value, delay ] ); + + return debouncedValue; +}; + +/** + * Debounce a callback function. + * @param {Function} func - The function to debounce + * @param {number} delay - The delay in milliseconds + * @return {Function} The debounced function + */ +export const useDebouncedCallback = ( + func: AsyncFunction, + delay: number = 500 +) => { + const timeoutRef = useRef( null ); + + return useCallback( + ( ...args: unknown[] ) => { + if ( timeoutRef.current ) { + clearTimeout( timeoutRef.current ); + } + + timeoutRef.current = setTimeout( + () => func( ...( args as [unknown] ) ), + delay + ); + }, + [ func, delay ] + ); +}; diff --git a/version.json b/version.json index c06da17c..47b1ee9d 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "force-ui": "1.4.0" + "force-ui": "1.4.1" }