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 (
+ 0 && (
@@ -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) => (
|
{getColumnValue(doc, column, docIndex)}
|
@@ -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) => (
|
- {columnHeaderFormat(column.label, column.attribute)}
+ {columnHeaderFormat(column.label, column.attribute, column)}
|
))}
{hasActions && {actionLabel} | }
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))
+ );
+}