diff --git a/CHANGELOG.md b/CHANGELOG.md index d245e6c..1bdbdf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,89 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +### [0.26.12](https://github.com/geolaborapp/react-bootstrap-utils/compare/v0.26.11...v0.26.12) (2021-10-20) + + +### Bug Fixes + +* define default value in align helper function ([2f216a4](https://github.com/geolaborapp/react-bootstrap-utils/commit/2f216a4a308fc95332285692c75b84d7fedaecbd)) + +### [0.26.11](https://github.com/geolaborapp/react-bootstrap-utils/compare/v0.26.10...v0.26.11) (2021-10-15) + + +### Bug Fixes + +* load of an unlisted value in the form autocomplete ([85b5a80](https://github.com/geolaborapp/react-bootstrap-utils/commit/85b5a80efa7865d32dd25f6740b45328f5c7af0f)) +* load of an unlisted value in the form autocomplete ([c0c84d1](https://github.com/geolaborapp/react-bootstrap-utils/commit/c0c84d175c2ed8143d87b2087fa76da2136faa50)) + +### [0.26.10](https://github.com/geolaborapp/react-bootstrap-utils/compare/v0.26.9...v0.26.10) (2021-09-03) + + +### Features + +* makes the use of "template" more flexible for Dropdown and FormDropdown ([#9](https://github.com/geolaborapp/react-bootstrap-utils/issues/9)) ([24a2d86](https://github.com/geolaborapp/react-bootstrap-utils/commit/24a2d865811edadd94e82c3835da46f6bded4edc)) + +### [0.26.9](https://github.com/geolaborapp/react-bootstrap-utils/compare/v0.26.8...v0.26.9) (2021-08-25) + + +### Features + +* new dropdown selection component ([#6](https://github.com/geolaborapp/react-bootstrap-utils/issues/6)) ([60fae42](https://github.com/geolaborapp/react-bootstrap-utils/commit/60fae42059ece24817b61f293aeaa12842e85742)) + +### [0.26.8](https://github.com/geolaborapp/react-bootstrap-utils/compare/v0.26.7...v0.26.8) (2021-08-13) + + +### Features + +* include prop to hide closed modals content ([4099fdc](https://github.com/geolaborapp/react-bootstrap-utils/commit/4099fdcd86e9e1caafa539a7659d7edc82200dd7)) +* include prop to hide closed modals content ([82a00d6](https://github.com/geolaborapp/react-bootstrap-utils/commit/82a00d638671990edff1bcca602950364943cb00)) + +### [0.26.7](https://github.com/geolaborapp/react-bootstrap-utils/compare/v0.26.6...v0.26.7) (2021-07-28) + + +### Bug Fixes + +* set the selected value as initial search in autocomplete ([07c4369](https://github.com/geolaborapp/react-bootstrap-utils/commit/07c4369ec0585b426500824e65bb0599f9befee1)) +* set the selected value as initial search in autocomplete ([bd3be74](https://github.com/geolaborapp/react-bootstrap-utils/commit/bd3be741cba33fc0ad3583beaff63a57bed388ee)) + +### [0.26.6](https://github.com/geolaborapp/react-bootstrap-utils/compare/v0.26.5...v0.26.6) (2021-07-21) + + +### Features + +* adiciona componente SortableTable ([320feab](https://github.com/geolaborapp/react-bootstrap-utils/commit/320feab1af4e9a9339a6ac36e48551de17026ebe)) + + +### Bug Fixes + +* flexibiliza papel das linhas de uma tabela ([dd39fa7](https://github.com/geolaborapp/react-bootstrap-utils/commit/dd39fa7988f62fd43870ea78c34c4e3cd504aae2)) + +### [0.26.5](https://github.com/geolaborapp/react-bootstrap-utils/compare/v0.26.3...v0.26.5) (2021-07-09) + + +### Features + +* allow input masks functions on forminput2 ([afaa1ce](https://github.com/geolaborapp/react-bootstrap-utils/commit/afaa1cea773ea9abedd8ffaa4e827b0e14b22ea0)) +* allow input masks functions on forminput2 ([049b214](https://github.com/geolaborapp/react-bootstrap-utils/commit/049b21484b7b00dec2f91f9c775b6a18ef9ea264)) + + +### Bug Fixes + +* check if an element is descendant before remove ([a1eb85d](https://github.com/geolaborapp/react-bootstrap-utils/commit/a1eb85d56cbd0142673e2a35394821a5a4a19874)) + +### [0.26.4](https://github.com/geolaborapp/react-bootstrap-utils/compare/v0.26.3...v0.26.4) (2021-07-09) + + +### Features + +* allow input masks functions on forminput2 ([afaa1ce](https://github.com/geolaborapp/react-bootstrap-utils/commit/afaa1cea773ea9abedd8ffaa4e827b0e14b22ea0)) +* allow input masks functions on forminput2 ([049b214](https://github.com/geolaborapp/react-bootstrap-utils/commit/049b21484b7b00dec2f91f9c775b6a18ef9ea264)) + + +### Bug Fixes + +* check if an element is descendant before remove ([a1eb85d](https://github.com/geolaborapp/react-bootstrap-utils/commit/a1eb85d56cbd0142673e2a35394821a5a4a19874)) + ### [0.26.3](https://github.com/assisrafael/react-bootstrap-utils/compare/v0.26.2...v0.26.3) (2021-04-21) diff --git a/README.md b/README.md index 5adf488..8f796de 100644 --- a/README.md +++ b/README.md @@ -46,3 +46,26 @@ Starting demo app ```bash npm run start ``` + +## How to Publish a New Version + +Make sure the master branch is updated (git pull) and make sure all new commits (or expected commits) are included. +Suggested log visualization: + +```bash +git log --graph --pretty=format:'%C(yellow)%h%C(cyan)%d%Creset %s - %C(blue)%an%Creset, %C(white)%ar%Creset' +``` + +On the terminal, run the command: + +```bash +npm run release +``` + +followed by: + +```bash +git push --follow-tags origin master && npm publish +``` + +Check the repository on Github, verifying the newly created tag \ No newline at end of file diff --git a/demo/Form2Examples.jsx b/demo/Form2Examples.jsx index 5dc2b07..1e7f8af 100644 --- a/demo/Form2Examples.jsx +++ b/demo/Form2Examples.jsx @@ -7,7 +7,13 @@ export function Form2Examples() {
Alternative Form implementation console.log('onSubmit', data)} onChange={(data) => console.log('onChange', data)} transform={(formData) => { @@ -76,6 +82,8 @@ export function Form2Examples() {
+ +
@@ -122,3 +130,95 @@ function FormObserver() { return
{state}
; } + +function FormMasked() { + const percentageFormControl = useFormControl2('masks.percentageValue'); + + const decimalMask = function (v) { + let maskedValue = String(v); + + maskedValue = maskedValue.replace(/\D/g, ''); + maskedValue = maskedValue.replace(/(\d)(\d{3})$/, '$1.$2'); + + return maskedValue; + }; + + const dateMask = function (v) { + let maskedValue = v; + + maskedValue = maskedValue.replace(/\D/g, ''); + + maskedValue = maskedValue.replace(/(\d{2})(\d)/, '$1/$2'); + maskedValue = maskedValue.replace(/(\d{2})(\d)/, '$1/$2'); + + return maskedValue; + }; + + const hourMask = function (v) { + let maskedValue = v; + + maskedValue = maskedValue.replace(/\D/g, ''); + maskedValue = maskedValue.replace(/(\d{2})(\d)/, '$1:$2'); + + return maskedValue; + }; + + const currency = function (v) { + let maskedValue = v; + + maskedValue = maskedValue.replace(/\D/g, ''); + maskedValue = maskedValue.replace(/(\d)(\d{2})$/, '$1,$2'); + maskedValue = maskedValue.replace(/(?=(\d{3})+(\D))\B/g, '.'); + + return maskedValue; + }; + + const percentageMask = function (v) { + let maskedValue = v; + maskedValue = maskedValue.replace(/[^0-9\.]/g, ''); + + if (!maskedValue) { + return ''; + } + + return `${maskedValue}%`; + }; + + return ( +
+ Masked Inputs +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + { + const rawValue = value.replace(/\%/, ''); + percentageFormControl.setValue(Number(rawValue) / 100); + }} + /> + +
+ +
+ + +
+
+ ); +} diff --git a/demo/FormExamples.jsx b/demo/FormExamples.jsx index 800411f..0b1f282 100644 --- a/demo/FormExamples.jsx +++ b/demo/FormExamples.jsx @@ -9,6 +9,7 @@ import { FormGroupRadio, FormGroupTextarea, FormGroupAutocomplete, + FormGroupDropdown, // eslint-disable-next-line import/no-unresolved } from '../dist/main'; @@ -18,12 +19,17 @@ export function FormExamples() { initialValues={{ textField: 'abc', autocompleteField1: '2345', + autocompleteField4: 'unlisted item', selectField4: { e: 2, c: 'b' }, switchField2: true, checkboxField2: true, radioField2: 'b', numberField: null, dateField: new Date().toISOString(), + dropdownField1: { + title: 'Title two', + }, + dropdownField2: '03', }} onChange={console.info} onSubmit={(formData) => { @@ -131,13 +137,22 @@ export function FormExamples() {
+
+ +
@@ -348,6 +363,81 @@ export function FormExamples() {
))} + + console.log('afterChange dropdown')} + template={(label, value) => { + return value ? ( +
+ {label.title ?? '-'} +

{label.subtitle ?? '-'}

+
+ ) : ( + label + ); + }} + itemClassName="border-bottom" + childClassName="text-muted" + trackBy="secondValue" + /> + + Value one

, + }, + { + value: '02', + label:

Value two

, + }, + { + value: '03', + label:

Value three

, + }, + ]} + placeholder="Select one value" + includeEmptyItem={false} + menuClassName="p-4 w-100" + /> ); } diff --git a/demo/TableExamples.jsx b/demo/TableExamples.jsx index 6ea33c2..7bae878 100644 --- a/demo/TableExamples.jsx +++ b/demo/TableExamples.jsx @@ -1,7 +1,7 @@ /* eslint-disable no-console */ import React, { useState } from 'react'; // eslint-disable-next-line import/no-unresolved -import { Table, Form, FormCheckbox } from '../dist/main'; +import { Table, SortableTable, Form, FormCheckbox } from '../dist/main'; import { stopPropagation } from '../src/utils/event-handlers'; export function TableExamples() { @@ -221,6 +221,133 @@ export function TableExamples() { ]} /> +
+

Sortable Table

+ + +
+ +
+
+

Sortable Table with Custom Header

+ + + column?.isSortable !== false ? ( + sortBy === attribute ? ( + sortOrder === 'ASC' ? ( + + ) : ( + + ) + ) : ( + + ) + ) : ( + label + ) + } + columns={[ + { + attribute: 'a', + label: 'A', + }, + { + attribute: 'b', + label: 'B', + isSortable: false, + }, + { + attribute: 'c', + label: 'C', + }, + ]} + docs={[ + { a: 'B', b: 'G', c: 'Y' }, + { a: 'C', b: 'I', c: 'Z' }, + { a: 'A', b: 'H', c: 'X' }, + { a: 'D', b: 'L', c: 'T' }, + ]} + /> +
+
+

Sortable Table with no default sorting

+ + +
+
+

Table with column hideIf

+ + true, + }, + { + attribute: 'b', + label: 'B', + }, + { + attribute: 'c', + label: 'C', + }, + ]} + docs={[ + { a: 'B', b: 'G', c: 'Y' }, + { a: 'C', b: 'I', c: 'Z' }, + { a: 'A', b: 'H', c: 'X' }, + { a: 'D', b: 'L', c: 'T' }, + ]} + /> + ); diff --git a/demo/index.html b/demo/index.html index 458f8af..72d0643 100644 --- a/demo/index.html +++ b/demo/index.html @@ -8,7 +8,9 @@ - + + +
diff --git a/package.json b/package.json index 1c57bed..a142171 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "react-bootstrap-utils", - "version": "0.26.3", + "name": "@geolaborapp/react-bootstrap-utils", + "version": "0.26.12", "description": "React bootstrap library", "main": "dist/main.js", "scripts": { @@ -18,15 +18,14 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/assisrafael/react-bootstrap-utils.git" + "url": "git+https://github.com/geolaborapp/react-bootstrap-utils.git" + }, + "publishConfig": { + "registry": "https://npm.pkg.github.com/geolaborapp" }, "keywords": [], - "author": "Igor Rafael ", + "author": "João Vitor ", "license": "MIT", - "bugs": { - "url": "https://github.com/assisrafael/react-bootstrap-utils/issues" - }, - "homepage": "https://github.com/assisrafael/react-bootstrap-utils#readme", "devDependencies": { "@babel/core": "^7.12.10", "@babel/plugin-transform-runtime": "^7.12.10", diff --git a/src/dialog/Dialog.jsx b/src/dialog/Dialog.jsx index 50616ad..59dad41 100644 --- a/src/dialog/Dialog.jsx +++ b/src/dialog/Dialog.jsx @@ -1,3 +1,4 @@ +/* eslint-disable react/prop-types */ import React, { useState } from 'react'; import PropTypes from 'prop-types'; @@ -8,7 +9,7 @@ import { safeClick } from '../utils/event-handlers'; import { ModalPortal } from './ModalPortal'; import { Modal } from './Modal'; -export function useDialog(props) { +export function useDialog({ onlyRenderContentIfIsOpen = true, ...props }) { const { isOpen, open, close } = useOpenState(); const [dialogBodyProps, setDialogBodyProps] = useState({}); @@ -18,7 +19,9 @@ export function useDialog(props) { open(); }, DialogPortal() { - return ( + return onlyRenderContentIfIsOpen && !isOpen() ? ( + <> + ) : ( @@ -27,20 +30,28 @@ export function useDialog(props) { }; } -export function Dialog({ children, ...props }) { +export function Dialog({ children, onlyRenderContentIfIsOpen, ...props }) { const { isOpen, open, close } = useOpenState(); return ( {children} - - - + {onlyRenderContentIfIsOpen && !isOpen() ? ( + <> + ) : ( + + + + )} ); } +Dialog.defaultProps = { + onlyRenderContentIfIsOpen: true, +}; + Dialog.propTypes = { afterOpen: PropTypes.func, children: PropTypes.node, @@ -48,6 +59,7 @@ Dialog.propTypes = { centered: PropTypes.bool, footer: PropTypes.oneOfType([PropTypes.node, PropTypes.func]), keyboard: PropTypes.bool, + onlyRenderContentIfIsOpen: PropTypes.bool, scrollable: PropTypes.bool, size: PropTypes.oneOf(['sm', 'lg', 'xl', '']), staticBackdrop: PropTypes.bool, diff --git a/src/dialog/ModalPortal.jsx b/src/dialog/ModalPortal.jsx index 4ecbf26..0409a18 100644 --- a/src/dialog/ModalPortal.jsx +++ b/src/dialog/ModalPortal.jsx @@ -22,7 +22,11 @@ export function ModalPortal({ children, title, isOpen }) { return; } - modalPortalsElem.removeChild(container); + const isDescendant = modalPortalsElem.contains(container); + + if (isDescendant) { + modalPortalsElem.removeChild(container); + } }; }, [container, title]); diff --git a/src/forms/FormAutocomplete.jsx b/src/forms/FormAutocomplete.jsx index c36cf0b..56b62d3 100644 --- a/src/forms/FormAutocomplete.jsx +++ b/src/forms/FormAutocomplete.jsx @@ -10,6 +10,14 @@ import { handleInputChange, normalizeOptions, booleanOrFunction } from './helper import { useFormControl } from './helpers/useFormControl'; import { FormGroup } from './FormGroup'; +function getSelectedItem(selectedItem, value, allowUnlistedValue) { + if (isEmptyLike(selectedItem) && !isEmptyLike(value) && allowUnlistedValue) { + return { value: value, label: value }; + } + + return selectedItem; +} + export function FormAutocomplete({ onSearch, options, @@ -24,9 +32,14 @@ export function FormAutocomplete({ afterChange, allowUnlistedValue, }) { - const { getValue, setValue: _setValue, register, isValid, getFormSubmitedAttempted, getFormData } = useFormControl( - name - ); + const { + getValue, + setValue: _setValue, + register, + isValid, + getFormSubmitedAttempted, + getFormData, + } = useFormControl(name); const value = getValue(); const setValue = useCallback( @@ -43,7 +56,7 @@ export function FormAutocomplete({ const items = normalizeOptions(options, getFormData(), searchValue); const _selectedItem = items.find((item) => item.value === value); - const [selectedItem, setSelectedItem] = useState(_selectedItem); + const [selectedItem, setSelectedItem] = useState(getSelectedItem(_selectedItem, value, allowUnlistedValue)); const { isOpen, open, close } = useOpenState(); const [ignoreBlur, setIgnoreBlur] = useState(false); const [isFocused, setFocus] = useState(false); @@ -100,7 +113,7 @@ export function FormAutocomplete({ setValue(''); setSelectedItem(null); updateSearchInputValidation(); - } else if (isEmptyLike(selectedItem) && !isEmptyLike(searchValue) && allowUnlistedValue) { + } else if (selectedItem?.value !== searchValue && !isEmptyLike(searchValue) && allowUnlistedValue) { onSelectItem({ value: searchValue, label: searchValue }); } @@ -149,6 +162,12 @@ export function FormAutocomplete({ useEffect(updateSearchInputValidation, [updateSearchInputValidation]); useEffect(clearSearchValue, [clearSearchValue]); + useEffect(() => { + if (selectedItem?.label) { + setSearchValue(selectedItem?.label); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); return ( <> diff --git a/src/forms/FormDropdown.jsx b/src/forms/FormDropdown.jsx new file mode 100644 index 0000000..e3959af --- /dev/null +++ b/src/forms/FormDropdown.jsx @@ -0,0 +1,159 @@ +import React, { useRef, useCallback, useEffect, useMemo } from 'react'; +import PropTypes from 'prop-types'; +import { isFunction } from 'js-var-type'; + +import { Dropdown } from '../mixed/Dropdown'; +import { useOpenState } from '../utils/useOpenState'; +import { formatClasses } from '../utils/attributes'; + +import { getSelectedOption, normalizeOptions } from './helpers/form-helpers'; +import { useFormControl } from './helpers/useFormControl'; +import { FormGroup } from './FormGroup'; + +export const FormDropdown = ({ + afterChange, + childClassName, + dropdownClassName, + includeEmptyItem, + itemClassName, + menuClassName, + name, + options, + placeholder, + template, + toggleIcon, + trackBy, +}) => { + const dropdownRef = useRef(null); + + const { getFormData, getValue, setValue: _setValue } = useFormControl(name); + + const value = getValue(); + const items = normalizeOptions(options, getFormData()); + const selectedItem = useMemo(() => getSelectedOption(value, items, trackBy), [items, trackBy, value]); + + const { isOpen: _isOpen, open, close } = useOpenState(); + const isOpen = _isOpen(); + + const setValue = useCallback( + (v) => { + _setValue(v); + + if (isFunction(afterChange)) { + afterChange(v); + } + }, + [_setValue, afterChange] + ); + + const onSelectItem = useCallback( + ({ value }) => { + setValue(value); + close(); + }, + [close, setValue] + ); + + const toggleDropdown = useCallback(() => (isOpen ? close() : open()), [close, isOpen, open]); + + useEffect(() => { + /* The logic in this effect allows the user to close the dropdown menu when they click outside of the component. */ + const pageClickEvent = (e) => { + if (dropdownRef.current !== null && !dropdownRef.current.contains(e.target)) { + if (isOpen) { + close(); + } else { + open(); + } + } + }; + + if (isOpen) { + window.addEventListener('click', pageClickEvent); + } + + return () => { + window.removeEventListener('click', pageClickEvent); + }; + }, [close, isOpen, open]); + + return ( +
+
}, ...items] : items} + onSelect={onSelectItem} + template={template} + itemClassName={itemClassName} + className={dropdownClassName} + menuClassName={menuClassName} + > +
+ {selectedItem ? template(selectedItem.label, selectedItem.value) :
{placeholder}
} + {toggleIcon(isOpen)} +
+ +
+ ); +}; + +FormDropdown.defaultProps = { + includeEmptyItem: true, + menuClassName: 'p-0 w-100', + template: (x) => x, + toggleIcon: function toggleIcon(isOpen) { + return ( +
+ +
+ ); + }, +}; + +FormDropdown.propTypes = { + afterChange: PropTypes.func, + childClassName: PropTypes.string, + dropdownClassName: PropTypes.string, + includeEmptyItem: PropTypes.bool, + itemClassName: PropTypes.string, + menuClassName: PropTypes.string, + name: PropTypes.string.isRequired, + options: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.object])), + ]), + placeholder: PropTypes.string, + template: PropTypes.func, + toggleIcon: PropTypes.func, + trackBy: PropTypes.string, +}; + +export function FormGroupDropdown(props) { + return ( + + + + ); +} + +FormGroupDropdown.propTypes = { + afterChange: PropTypes.func, + childClassName: PropTypes.string, + dropdownClassName: PropTypes.string, + help: PropTypes.node, + includeEmptyItem: PropTypes.bool, + itemClassName: PropTypes.string, + menuClassName: PropTypes.string, + name: PropTypes.string.isRequired, + options: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.object])), + ]), + placeholder: PropTypes.string, + template: PropTypes.func, + toggleIcon: PropTypes.func, + trackBy: PropTypes.string, +}; diff --git a/src/forms/FormSelect.jsx b/src/forms/FormSelect.jsx index 757c720..073f6c6 100644 --- a/src/forms/FormSelect.jsx +++ b/src/forms/FormSelect.jsx @@ -5,7 +5,7 @@ import { normalizeOptions, booleanOrFunction, serializeValue, - getSelectedOption, + getSelectedValue, getOptionsType, } from './helpers/form-helpers'; import { useFormControl } from './helpers/useFormControl'; @@ -40,7 +40,7 @@ export function FormSelect({ {...attrs} className="custom-select" onChange={handleOnChangeFactory(afterChange, getOptionsType(normalizedOptions))} - value={getSelectedOption(value, normalizedOptions, trackBy)} + value={getSelectedValue(value, normalizedOptions, trackBy)} ref={registerRef} > diff --git a/src/forms/helpers/form-helpers.js b/src/forms/helpers/form-helpers.js index a84500b..c9a2fcd 100644 --- a/src/forms/helpers/form-helpers.js +++ b/src/forms/helpers/form-helpers.js @@ -97,12 +97,18 @@ export function serializeValue(value) { } export function getSelectedOption(value, options, trackBy) { + if (!trackBy) { + return options.find((option) => option.value === value); + } + + return options.find((option) => getValueByPath(option.value, trackBy) === getValueByPath(value, trackBy)); +} + +export function getSelectedValue(value, options, trackBy) { let selectedValue = value; if (trackBy) { - const selectedOption = options.find( - (option) => getValueByPath(option.value, trackBy) === getValueByPath(value, trackBy) - ); + const selectedOption = getSelectedOption(value, options, trackBy); if (selectedOption) { selectedValue = selectedOption.value; diff --git a/src/forms/index.js b/src/forms/index.js index 94b662a..beb78f6 100644 --- a/src/forms/index.js +++ b/src/forms/index.js @@ -1,6 +1,8 @@ +/* eslint-disable import/max-dependencies */ export * from './Form'; export * from './FormAutocomplete'; export * from './FormCheckbox'; +export * from './FormDropdown'; export * from './FormGroup'; export * from './FormInput'; export * from './FormRadio'; diff --git a/src/forms2/FormInput.jsx b/src/forms2/FormInput.jsx index 5570426..5147053 100644 --- a/src/forms2/FormInput.jsx +++ b/src/forms2/FormInput.jsx @@ -5,7 +5,15 @@ import { booleanOrFunction } from '../forms/helpers/form-helpers'; import { useFormControl2 } from './helpers/useFormControl'; -export function FormInput2({ type, name, required: _required, disabled: _disabled, afterChange, ..._attrs }) { +export function FormInput2({ + type, + name, + required: _required, + disabled: _disabled, + afterChange, + maskFunction, + ..._attrs +}) { const { getValue, handleOnChangeFactory, getFormData } = useFormControl2(name); const disabled = booleanOrFunction(_disabled, getFormData()); @@ -25,7 +33,9 @@ export function FormInput2({ type, name, required: _required, disabled: _disable attrs.value = getValue(); } - return ; + return ( + + ); } FormInput2.defaultProps = { @@ -36,6 +46,7 @@ FormInput2.propTypes = { afterChange: PropTypes.func, disabled: PropTypes.oneOfType([PropTypes.bool, PropTypes.func]), id: PropTypes.string, + maskFunction: PropTypes.func, max: PropTypes.string, maxLength: PropTypes.string, min: PropTypes.string, diff --git a/src/forms2/FormSelect.jsx b/src/forms2/FormSelect.jsx index be41ece..763b43f 100644 --- a/src/forms2/FormSelect.jsx +++ b/src/forms2/FormSelect.jsx @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import { booleanOrFunction, getOptionsType, - getSelectedOption, + getSelectedValue, normalizeOptions, serializeValue, } from '../forms/helpers/form-helpers'; @@ -40,7 +40,7 @@ export function FormSelect2({ {...attrs} className="custom-select" onChange={handleOnChangeFactory(afterChange, getOptionsType(normalizedOptions))} - value={getSelectedOption(value, normalizedOptions, trackBy)} + value={getSelectedValue(value, normalizedOptions, trackBy)} > {renderOptions(normalizedOptions, trackBy)} diff --git a/src/forms2/helpers/useFormControl.jsx b/src/forms2/helpers/useFormControl.jsx index ce358d1..89b45d1 100644 --- a/src/forms2/helpers/useFormControl.jsx +++ b/src/forms2/helpers/useFormControl.jsx @@ -26,14 +26,20 @@ export function useFormControl2(name, type) { ); const handleOnChange = useCallback( - ({ target }, _type) => { + ({ target }, _type, maskFunction) => { const value = getTargetValue(target); - const decodedValue = decode(value, type || _type); + let maskedOrDecodedValue; - setValue(decodedValue); + if (isFunction(maskFunction)) { + maskedOrDecodedValue = maskFunction(value); + } else { + maskedOrDecodedValue = decode(value, type || _type); + } + + setValue(maskedOrDecodedValue); - return decodedValue; + return maskedOrDecodedValue; }, [setValue, type] ); @@ -54,8 +60,8 @@ export function useFormControl2(name, type) { isRegistered() { return isRegistered; }, - handleOnChangeFactory: (afterChange, type) => (e) => { - const newValue = handleOnChange(e, type); + handleOnChangeFactory: (afterChange, type, maskFunction) => (e) => { + const newValue = handleOnChange(e, type, maskFunction); if (isFunction(afterChange)) { afterChange(newValue); diff --git a/src/mixed/Dropdown.jsx b/src/mixed/Dropdown.jsx index d0568bb..320f1ea 100644 --- a/src/mixed/Dropdown.jsx +++ b/src/mixed/Dropdown.jsx @@ -14,6 +14,8 @@ export function Dropdown({ onMouseLeave, template, className, + itemClassName, + menuClassName, }) { return (
0 && (
@@ -32,10 +34,10 @@ export function Dropdown({ - {template(label)} + {template(label, value, index)} ))}
@@ -59,4 +61,6 @@ Dropdown.propTypes = { onTouchStart: PropTypes.func, template: PropTypes.func, className: PropTypes.string, + itemClassName: PropTypes.string, + menuClassName: PropTypes.string, }; diff --git a/src/table/SortableTable.jsx b/src/table/SortableTable.jsx new file mode 100644 index 0000000..5fcc664 --- /dev/null +++ b/src/table/SortableTable.jsx @@ -0,0 +1,106 @@ +import React, { useState, useCallback, useMemo } from 'react'; +import PropTypes from 'prop-types'; +import { isFunction } from 'js-var-type'; + +import { sortData } from '../utils/sort'; + +import { Table } from './Table'; + +export function SortableTable({ columnHeaderFormat, docs, defaultSortOption, ..._props }) { + const [sortOption, setSortOption] = useState(defaultSortOption); + + const sortedDocs = useMemo(() => { + if (sortOption?.sortBy) { + return sortData(docs, { + sortBy: sortOption.sortBy, + sortOrder: sortOption.sortOrder ?? 'ASC', // default case ASC + }); + } + + return docs; + }, [docs, sortOption]); + + const changeSort = useCallback((attribute) => { + setSortOption((prevState) => { + const attributeChanged = prevState?.sortBy !== attribute; + const order = !attributeChanged && prevState?.sortOrder === 'DESC' ? 'ASC' : 'DESC'; + + return { + sortBy: attribute, + sortOrder: order, + }; + }); + }, []); + + const defaultHeaderContent = useCallback( + (label, attribute, column) => { + /* By default, if isSortable is not set to false, + * it is assumed the column can be sorted */ + if (column?.isSortable === false) { + return label; + } + + const icon = + sortOption?.sortBy === attribute ? ( + sortOption.sortOrder === 'DESC' ? ( + + ) : ( + // default case ASC + ) + ) : ( + + ); + + return icon; + }, + [sortOption?.sortBy, sortOption?.sortOrder] + ); + + const renderHeaderContent = useCallback( + (label, attribute, column) => + isFunction(columnHeaderFormat) + ? columnHeaderFormat(label, attribute, column, sortOption) + : defaultHeaderContent(label, attribute, column), + [columnHeaderFormat, defaultHeaderContent, sortOption] + ); + + const buildSortingHeader = useCallback( + (label, attribute, column) => + /* By default, if isSortable is not set to false, + * it is assumed the column can be sorted */ + column?.isSortable === false ? ( + renderHeaderContent(label, attribute, column) + ) : ( + <> + { + e.preventDefault(); + e.stopPropagation(); + changeSort(attribute); + }} + > + {renderHeaderContent(label, attribute, column)} + + {label} + + ), + [changeSort, renderHeaderContent] + ); + + return
; +} + +SortableTable.defaultSortOption = { + defaultSortOption: {}, +}; + +SortableTable.propTypes = { + docs: PropTypes.arrayOf(PropTypes.object), + columnHeaderFormat: PropTypes.func, + defaultSortOption: PropTypes.shape({ + sortBy: PropTypes.string, + sortOrder: PropTypes.oneOf(['ASC', 'DESC']), + }), +}; diff --git a/src/table/Table.jsx b/src/table/Table.jsx index 82eaad2..9063934 100644 --- a/src/table/Table.jsx +++ b/src/table/Table.jsx @@ -55,7 +55,6 @@ Table.defaultProps = { dark: false, actionLabel: 'Actions', rowClass: () => '', - onRowClick: () => {}, columnHeaderFormat: (label) => label, }; diff --git a/src/table/TableBody.jsx b/src/table/TableBody.jsx index 11ddd47..c038b47 100644 --- a/src/table/TableBody.jsx +++ b/src/table/TableBody.jsx @@ -1,5 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; +import { isFunction } from 'js-var-type'; import { safeClick } from '../utils/event-handlers'; import { getValueByPath } from '../utils/getters-setters'; @@ -7,12 +8,16 @@ import { getValueByPath } from '../utils/getters-setters'; import { getColumnClass } from './table-helpers'; import { TableActions } from './TableActions'; -export function TableBody({ columns, docs, rowClass, actions, onRowClick }) { +export function TableBody({ columns, docs, rowRole, rowClass, actions, onRowClick }) { + const trRole = rowRole ?? isFunction(onRowClick) ? 'button' : 'row'; + const trOnClick = isFunction(onRowClick) ? onRowClick : () => {}; + const filteredColumns = columns.filter((column) => !column.hideIf?.()); + return ( - {docs.map((doc, docIndex) => ( - - {columns.map((column, columnIndex) => ( + {docs?.map((doc, docIndex) => ( + + {filteredColumns?.map((column, columnIndex) => ( @@ -29,6 +34,7 @@ TableBody.propTypes = { actions: PropTypes.oneOfType([PropTypes.func, PropTypes.arrayOf(PropTypes.object)]), columns: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.object])), docs: PropTypes.arrayOf(PropTypes.object), + rowRole: PropTypes.string, rowClass: PropTypes.func, onRowClick: PropTypes.func, }; diff --git a/src/table/TableHead.jsx b/src/table/TableHead.jsx index 064aeec..d26a1d2 100644 --- a/src/table/TableHead.jsx +++ b/src/table/TableHead.jsx @@ -4,12 +4,14 @@ import PropTypes from 'prop-types'; import { getColumnClass } from './table-helpers'; export function TableHead({ columns, hasActions, actionLabel, columnHeaderFormat }) { + const filteredColumns = columns.filter((column) => !column.hideIf?.()); + return ( - {columns.map((column, columnIndex) => ( + {filteredColumns?.map((column, columnIndex) => ( ))} {hasActions && } diff --git a/src/table/index.js b/src/table/index.js index 75193ad..d0dfcca 100644 --- a/src/table/index.js +++ b/src/table/index.js @@ -1 +1,2 @@ +export * from './SortableTable'; export * from './Table'; diff --git a/src/table/table-helpers.js b/src/table/table-helpers.js index 2591eb1..9dce8a3 100644 --- a/src/table/table-helpers.js +++ b/src/table/table-helpers.js @@ -15,6 +15,6 @@ export function normalizeColumns(columns) { }); } -export function getColumnClass({ align }) { +export function getColumnClass({ align } = {}) { return formatClasses([align === 'center' ? 'text-center' : align === 'right' ? 'text-right' : '']); } diff --git a/src/utils/sort.js b/src/utils/sort.js new file mode 100644 index 0000000..29b8ea6 --- /dev/null +++ b/src/utils/sort.js @@ -0,0 +1,57 @@ +import { getValueByPath } from './getters-setters'; + +function compareASC(a, b) { + if (!a || !b) { + if (a) { + return 1; + } + + if (b) { + return -1; + } + + return 0; + } + + if (a > b) { + return 1; + } + + if (a < b) { + return -1; + } + + return 0; +} + +function compareDESC(a, b) { + if (!a || !b) { + if (a) { + return -1; + } + + if (b) { + return 1; + } + + return 0; + } + + if (a > b) { + return -1; + } + + if (a < b) { + return 1; + } + + return 0; +} + +export function sortData(docs, { sortBy, sortOrder }) { + return docs?.sort((a, b) => + sortOrder === 'ASC' + ? compareASC(getValueByPath(a, sortBy), getValueByPath(b, sortBy)) + : compareDESC(getValueByPath(a, sortBy), getValueByPath(b, sortBy)) + ); +}
{getColumnValue(doc, column, docIndex)}
- {columnHeaderFormat(column.label, column.attribute)} + {columnHeaderFormat(column.label, column.attribute, column)} {actionLabel}