nitro-web 0.0.144 → 0.0.146
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/index.ts +5 -5
- package/components/partials/element/calendar.tsx +63 -40
- package/components/partials/element/filters.tsx +98 -69
- package/components/partials/element/table.tsx +40 -53
- package/components/partials/element/timepicker.tsx +119 -0
- package/components/partials/form/field-color.tsx +27 -19
- package/components/partials/form/field-currency.tsx +108 -102
- package/components/partials/form/field-date.tsx +167 -93
- package/components/partials/form/field.tsx +16 -29
- package/components/partials/styleguide.tsx +94 -40
- package/package.json +3 -4
- package/types/util.d.ts +3 -8
- package/types/util.d.ts.map +1 -1
- package/util.js +9 -24
- package/components/partials/form/field-time.tsx +0 -214
package/client/index.ts
CHANGED
|
@@ -26,12 +26,13 @@ export { Avatar } from '../components/partials/element/avatar'
|
|
|
26
26
|
export { Button } from '../components/partials/element/button'
|
|
27
27
|
export { Calendar, type CalendarProps } from '../components/partials/element/calendar'
|
|
28
28
|
export { Dropdown, type DropdownProps, type DropdownOption } from '../components/partials/element/dropdown'
|
|
29
|
-
export { Filters, type FiltersHandleType, type FilterType } from '../components/partials/element/filters'
|
|
29
|
+
export { Filters, type FiltersHandleType, type FilterType, usePushChangesToPath } from '../components/partials/element/filters'
|
|
30
30
|
export { GithubLink } from '../components/partials/element/github-link'
|
|
31
31
|
export { Initials } from '../components/partials/element/initials'
|
|
32
32
|
export { Message } from '../components/partials/element/message'
|
|
33
33
|
export { Modal } from '../components/partials/element/modal'
|
|
34
34
|
export { Sidebar, type SidebarProps } from '../components/partials/element/sidebar'
|
|
35
|
+
export { TimePicker, type TimePickerProps } from '../components/partials/element/timepicker'
|
|
35
36
|
export { Tooltip } from '../components/partials/element/tooltip'
|
|
36
37
|
export { Topbar } from '../components/partials/element/topbar'
|
|
37
38
|
export { Table, type TableColumn, type TableProps, type TableRow, type TableRowType } from '../components/partials/element/table'
|
|
@@ -42,10 +43,9 @@ export { Drop } from '../components/partials/form/drop'
|
|
|
42
43
|
export { DropHandler } from '../components/partials/form/drop-handler'
|
|
43
44
|
export { FormError } from '../components/partials/form/form-error'
|
|
44
45
|
export { Field, isFieldCached, type FieldProps } from '../components/partials/form/field'
|
|
45
|
-
export {
|
|
46
|
-
export {
|
|
47
|
-
export {
|
|
48
|
-
export { FieldTime, type FieldTimeProps } from '../components/partials/form/field-time'
|
|
46
|
+
export { type FieldColorProps } from '../components/partials/form/field-color'
|
|
47
|
+
export { type FieldCurrencyProps } from '../components/partials/form/field-currency'
|
|
48
|
+
export { type FieldDateProps } from '../components/partials/form/field-date'
|
|
49
49
|
export { Location } from '../components/partials/form/location'
|
|
50
50
|
export { Select, getSelectStyle, type SelectProps, type SelectOption } from '../components/partials/form/select'
|
|
51
51
|
|
|
@@ -1,79 +1,100 @@
|
|
|
1
|
-
import { DayPicker, getDefaultClassNames, DayPickerProps as DayPickerPropsBase } from 'react-day-picker'
|
|
1
|
+
import { DayPicker, getDefaultClassNames, DayPickerProps as DayPickerPropsBase, TZDate } from 'react-day-picker'
|
|
2
2
|
import { isValid } from 'date-fns'
|
|
3
3
|
import 'react-day-picker/style.css'
|
|
4
4
|
import { IsFirstRender } from 'nitro-web'
|
|
5
5
|
|
|
6
6
|
export const dayButtonClassName = 'size-[33px] text-sm'
|
|
7
7
|
|
|
8
|
+
type Timestamp = null | number
|
|
9
|
+
type TimestampArray = null | Timestamp[]
|
|
8
10
|
type Mode = 'single'|'multiple'|'range'
|
|
9
|
-
type
|
|
11
|
+
type DayPickerSelection<T extends Mode> = (
|
|
10
12
|
T extends 'single' ? Date | undefined
|
|
11
13
|
: T extends 'multiple' ? Date[]
|
|
12
14
|
: { from?: Date; to?: Date }
|
|
13
15
|
)
|
|
16
|
+
|
|
14
17
|
export type DayPickerProps = Omit<DayPickerPropsBase,
|
|
15
18
|
'mode' | 'selected' | 'onSelect' | 'modifiersClassNames' | 'classNames' | 'numberOfMonths' | 'month' | 'onMonthChange'>
|
|
16
19
|
|
|
17
|
-
|
|
18
|
-
mode?: Mode
|
|
19
|
-
onChange?: (value: null|number|(null|number)[]) => void
|
|
20
|
-
value?: null|number|string|(null|number|string)[]
|
|
20
|
+
type PreCalendarProps = DayPickerProps & {
|
|
21
21
|
numberOfMonths?: number
|
|
22
|
-
month
|
|
22
|
+
/** month to display in the calendar (the value may be updated from an outside source, thus the month may have changed) */
|
|
23
|
+
month?: number
|
|
23
24
|
className?: string
|
|
24
|
-
|
|
25
|
+
/** single mode only: preserve the time of the original date if needed */
|
|
26
|
+
preserveTime?: boolean
|
|
27
|
+
/** timezone to display and set the dates in for the calendar (output always a timestamp) */
|
|
28
|
+
tz?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Discriminated union types based on mode
|
|
32
|
+
type CalendarPropsSingle = PreCalendarProps & {
|
|
33
|
+
mode: 'single'
|
|
34
|
+
onChange?: (value: Timestamp) => void
|
|
35
|
+
value?: Timestamp // gracefully handles falsey values
|
|
25
36
|
}
|
|
26
37
|
|
|
27
|
-
|
|
28
|
-
|
|
38
|
+
type CalendarPropsMultiple = PreCalendarProps & {
|
|
39
|
+
mode: 'multiple' | 'range'
|
|
40
|
+
onChange?: (value: TimestampArray) => void
|
|
41
|
+
value?: TimestampArray // gracefully handles falsey values
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type CalendarProps = CalendarPropsSingle | CalendarPropsMultiple
|
|
45
|
+
|
|
46
|
+
export function Calendar({ value, numberOfMonths, month: monthProp, className, preserveTime, tz, ...props }: CalendarProps) {
|
|
29
47
|
const isFirstRender = IsFirstRender()
|
|
30
|
-
const isRange = mode == 'range'
|
|
48
|
+
const isRange = props.mode == 'range'
|
|
31
49
|
|
|
32
|
-
// Convert the value to an array of valid
|
|
33
|
-
const
|
|
34
|
-
const _dates = Array.isArray(value) ? value : [value]
|
|
35
|
-
return _dates.map(date => isValid(date) ? new
|
|
50
|
+
// Convert the value to an array of valid dates
|
|
51
|
+
const internalValue = useMemo(() => {
|
|
52
|
+
const _dates = value ? (Array.isArray(value) ? value : [value]) : []
|
|
53
|
+
return _dates.map(date => date && isValid(date) ? new TZDate(date, tz) : undefined)// DayPicker uses undefined (allgood, we output null)
|
|
36
54
|
}, [value])
|
|
37
55
|
|
|
38
56
|
// Hold the month in state to control the calendar when the input changes
|
|
39
|
-
const [month, setMonth] = useState(
|
|
57
|
+
const [month, setMonth] = useState(internalValue[0] as Date)
|
|
40
58
|
|
|
41
59
|
// Update the month if its changed from an outside source
|
|
42
60
|
useEffect(() => {
|
|
43
|
-
if (!isFirstRender && monthProp) setMonth(new
|
|
61
|
+
if (!isFirstRender && monthProp) setMonth(new TZDate(monthProp, tz))
|
|
44
62
|
}, [monthProp])
|
|
45
63
|
|
|
46
|
-
function handleDayPickerSelect
|
|
47
|
-
switch (mode
|
|
64
|
+
function handleDayPickerSelect(value: DayPickerSelection<CalendarProps['mode']>) {
|
|
65
|
+
switch (props.mode) {
|
|
48
66
|
case 'single': {
|
|
49
|
-
const date =
|
|
50
|
-
|
|
51
|
-
onChange?.(date?.getTime() ?? null)
|
|
67
|
+
const date = preserveTimeFn(value as DayPickerSelection<'single'>)
|
|
68
|
+
props.onChange?.(date ? date.getTime() : null)
|
|
52
69
|
break
|
|
53
70
|
}
|
|
54
71
|
case 'range': {
|
|
55
|
-
const { from, to } = (
|
|
56
|
-
onChange?.(from ? [from.getTime() || null, to?.getTime() || null] : null)
|
|
72
|
+
const { from, to } = (value ?? {}) as DayPickerSelection<'range'>
|
|
73
|
+
props.onChange?.(from ? [from.getTime() || null, to?.getTime() || null] : null)
|
|
57
74
|
break
|
|
58
75
|
}
|
|
59
76
|
case 'multiple': {
|
|
60
|
-
const dates = (
|
|
61
|
-
onChange?.(dates.map((d) => d.getTime()))
|
|
77
|
+
const dates = (value as DayPickerSelection<'multiple'>)?.filter(Boolean)
|
|
78
|
+
props.onChange?.(dates.length ? dates.map((d) => d.getTime()) : null)
|
|
62
79
|
break
|
|
63
80
|
}
|
|
64
81
|
}
|
|
65
82
|
}
|
|
66
83
|
|
|
67
|
-
function preserveTimeFn(
|
|
68
|
-
// Preserve time from the original date if needed
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
84
|
+
function preserveTimeFn(newDate?: Date) {
|
|
85
|
+
// Preserve time from the original date if needed. Since internalValue[0] is a TZDate object, we need to make sure the
|
|
86
|
+
// new date is also a TZDate object in the same timezone.
|
|
87
|
+
if (newDate && preserveTime && internalValue[0]) {
|
|
88
|
+
const tzDate = new TZDate(newDate, tz)
|
|
89
|
+
tzDate.setHours(
|
|
90
|
+
internalValue[0].getHours(),
|
|
91
|
+
internalValue[0].getMinutes(),
|
|
92
|
+
internalValue[0].getSeconds(),
|
|
93
|
+
internalValue[0].getMilliseconds()
|
|
76
94
|
)
|
|
95
|
+
return tzDate
|
|
96
|
+
} else {
|
|
97
|
+
return newDate
|
|
77
98
|
}
|
|
78
99
|
}
|
|
79
100
|
|
|
@@ -83,6 +104,7 @@ export function Calendar({ mode='single', onChange, value, numberOfMonths, month
|
|
|
83
104
|
onMonthChange: setMonth,
|
|
84
105
|
onSelect: handleDayPickerSelect,
|
|
85
106
|
numberOfMonths: numberOfMonths || (isRange ? 2 : 1),
|
|
107
|
+
timeZone: tz,
|
|
86
108
|
modifiersClassNames: {
|
|
87
109
|
// Add a class without _, TW seems to replace this with a space in the css definition, e.g. &:not(.range middle)
|
|
88
110
|
range_middle: `${d.range_middle} rangemiddle`,
|
|
@@ -115,12 +137,13 @@ export function Calendar({ mode='single', onChange, value, numberOfMonths, month
|
|
|
115
137
|
return (
|
|
116
138
|
<div>
|
|
117
139
|
{
|
|
118
|
-
mode === 'single' ? (
|
|
119
|
-
<DayPicker {...props} {...common} mode="single" selected={
|
|
120
|
-
) : mode === 'range' ? (
|
|
121
|
-
<DayPicker {...props} {...common} mode="range" selected={{ from:
|
|
140
|
+
props.mode === 'single' ? (
|
|
141
|
+
<DayPicker {...props} {...common} mode="single" selected={internalValue[0]} className={className} />
|
|
142
|
+
) : props.mode === 'range' ? (
|
|
143
|
+
<DayPicker {...props} {...common} mode="range" selected={{ from: internalValue[0], to: internalValue[1] }}
|
|
144
|
+
className={className} />
|
|
122
145
|
) : (
|
|
123
|
-
<DayPicker {...props} {...common} mode="multiple" selected={
|
|
146
|
+
<DayPicker {...props} {...common} mode="multiple" selected={internalValue.filter((d) => !!d)} className={className} />
|
|
124
147
|
)
|
|
125
148
|
}
|
|
126
149
|
</div>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { Dispatch, SetStateAction, useRef, useEffect, useLayoutEffect } from 'react'
|
|
2
3
|
import { Button, Dropdown, Field, Select, type FieldProps, type SelectProps } from 'nitro-web'
|
|
3
4
|
import { camelCaseToTitle, debounce, omit, queryString, queryObject, twMerge } from 'nitro-web/util'
|
|
4
5
|
import { ListFilterIcon } from 'lucide-react'
|
|
@@ -14,10 +15,11 @@ export type FilterType = (
|
|
|
14
15
|
)
|
|
15
16
|
|
|
16
17
|
type FilterState = {
|
|
17
|
-
[key: string]:
|
|
18
|
+
[key: string]: unknown
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
type FiltersProps = {
|
|
22
|
+
/** State passed to the component, values must be processed, i.e. real numbers for dates, etc. */
|
|
21
23
|
state?: FilterState
|
|
22
24
|
setState?: Dispatch<SetStateAction<FilterState>>
|
|
23
25
|
filters?: FilterType[]
|
|
@@ -41,26 +43,23 @@ export type FiltersHandleType = {
|
|
|
41
43
|
|
|
42
44
|
const debounceTime = 250
|
|
43
45
|
|
|
44
|
-
export
|
|
46
|
+
export function Filters({
|
|
45
47
|
filters,
|
|
46
48
|
setState: setStateProp,
|
|
47
|
-
state: stateProp,
|
|
49
|
+
state: stateProp, // state passed
|
|
48
50
|
buttonProps,
|
|
49
51
|
buttonCounterClassName,
|
|
50
52
|
buttonText,
|
|
51
53
|
dropdownProps,
|
|
52
54
|
dropdownFiltersClassName,
|
|
53
55
|
elements,
|
|
54
|
-
}
|
|
56
|
+
}: FiltersProps) {
|
|
55
57
|
const location = useLocation()
|
|
56
|
-
const navigate = useNavigate()
|
|
57
58
|
const [lastUpdated, setLastUpdated] = useState(0)
|
|
58
|
-
const [
|
|
59
|
-
const [stateDefault, setStateDefault] = useState(() => ({ ...queryObject(location.search) }))
|
|
59
|
+
const [stateDefault, setStateDefault] = useState<FilterState>(() => processState({ ...queryObject(location.search) }, filters))
|
|
60
60
|
const [state, setState] = [stateProp || stateDefault, setStateProp || setStateDefault]
|
|
61
|
-
const stateRef = useRef(state)
|
|
62
|
-
const locationRef = useRef(location)
|
|
63
61
|
const count = useMemo(() => Object.keys(state).filter((k) => state[k] && filters?.some((f) => f.name === k)).length, [state, filters])
|
|
62
|
+
const pushChangesToPath = usePushChangesToPath(state)
|
|
64
63
|
|
|
65
64
|
const Elements = {
|
|
66
65
|
Button: elements?.Button || Button,
|
|
@@ -69,29 +68,11 @@ export const Filters = forwardRef<FiltersHandleType, FiltersProps>(({
|
|
|
69
68
|
Select: elements?.Select || Select,
|
|
70
69
|
FilterIcon: elements?.FilterIcon || ListFilterIcon,
|
|
71
70
|
}
|
|
72
|
-
|
|
73
|
-
useImperativeHandle(ref, () => ({
|
|
74
|
-
submit: debouncedSubmit,
|
|
75
|
-
}))
|
|
76
|
-
|
|
77
|
-
useEffect(() => {
|
|
78
|
-
return () => debouncedSubmit.cancel()
|
|
79
|
-
}, [])
|
|
80
71
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}, [state])
|
|
84
|
-
|
|
85
|
-
useEffect(() => {
|
|
86
|
-
locationRef.current = location
|
|
87
|
-
}, [location])
|
|
88
|
-
|
|
89
|
-
useEffect(() => {
|
|
90
|
-
// Only update the state if the filters haven't been input changed in the last 500ms
|
|
72
|
+
useLayoutEffect(() => {
|
|
73
|
+
// Only update the state if the filters haven't been input changed in the last 500ms (calls initially since lastUpdated is 0)
|
|
91
74
|
if (Date.now() - lastUpdated > (debounceTime + 250)) {
|
|
92
|
-
setState(() => ({
|
|
93
|
-
...queryObject(location.search),
|
|
94
|
-
}))
|
|
75
|
+
setState(() => processState({ ...queryObject(location.search) }, filters))
|
|
95
76
|
}
|
|
96
77
|
}, [location.search])
|
|
97
78
|
|
|
@@ -110,21 +91,15 @@ export const Filters = forwardRef<FiltersHandleType, FiltersProps>(({
|
|
|
110
91
|
async function onInputChange(e: {target: {name: string, value: unknown}}) {
|
|
111
92
|
// console.log('onInputChange', e.target.name, e.target.value)
|
|
112
93
|
// the state is flattened for the query string, so here we se full paths as the key names e.g. 'job.location': '10')
|
|
113
|
-
setState((s) => ({ ...s, [e.target.name]: e.target.value as
|
|
94
|
+
setState((s) => ({ ...s, [e.target.name]: e.target.value as unknown }))
|
|
114
95
|
onAfterChange()
|
|
115
96
|
}
|
|
116
97
|
|
|
117
98
|
function onAfterChange() {
|
|
118
99
|
setLastUpdated(Date.now())
|
|
119
|
-
|
|
100
|
+
pushChangesToPath()
|
|
120
101
|
}
|
|
121
102
|
|
|
122
|
-
// Update the URL by replacing the current entry in the history stack
|
|
123
|
-
function submit(includePagination?: boolean) {
|
|
124
|
-
const queryStr = queryString(omit(stateRef.current, includePagination ? [] : ['page']))
|
|
125
|
-
navigate(locationRef.current.pathname + queryStr, { replace: true })
|
|
126
|
-
}
|
|
127
|
-
|
|
128
103
|
function getBasisWidth(width: 'full' | 'half' | 'third' | 'quarter' | 'fifth') {
|
|
129
104
|
// Need to splay out the classnames for tailwind to work
|
|
130
105
|
if (width == 'full') return 'w-full'
|
|
@@ -134,6 +109,40 @@ export const Filters = forwardRef<FiltersHandleType, FiltersProps>(({
|
|
|
134
109
|
else if (width == 'fifth') return 'shrink basis-[calc(20%-8px)]'
|
|
135
110
|
}
|
|
136
111
|
|
|
112
|
+
function processState(state: FilterState, filters: FilterType[]|undefined) {
|
|
113
|
+
// Since queryObject returns a string|true|(string|true)[], we need to parse the values to the correct type for the Fields/Selects
|
|
114
|
+
const output: FilterState = {...state}
|
|
115
|
+
for (const filter of filters || []) {
|
|
116
|
+
const name = filter.name
|
|
117
|
+
// Undefined values
|
|
118
|
+
if (typeof state[name] === 'undefined') {
|
|
119
|
+
output[name] = undefined
|
|
120
|
+
|
|
121
|
+
// Date single needs to be null|string
|
|
122
|
+
} else if (filter.type === 'date' && (filter.mode === 'single' || filter.mode === 'time')) {
|
|
123
|
+
output[name] = parseDateValue(state[name], name)
|
|
124
|
+
|
|
125
|
+
} else if (filter.type === 'date' && (filter.mode === 'range' || filter.mode === 'multiple')) {
|
|
126
|
+
if (!state[name]) state[name] = undefined
|
|
127
|
+
else if (!Array.isArray(state[name])) console.error(`The "${name}" filter expected an array, received:`, state[name])
|
|
128
|
+
else output[name] = state[name].map((v, i) => parseDateValue(v, name + '.' + i))
|
|
129
|
+
|
|
130
|
+
// Remaining filters should accept text values
|
|
131
|
+
} else {
|
|
132
|
+
output[filter.name] = state[filter.name] + ''
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return output
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function parseDateValue(input: unknown, name: string) {
|
|
139
|
+
const number = parseFloat(input + '')
|
|
140
|
+
if (typeof input === 'undefined') return undefined
|
|
141
|
+
else if (input === null) return null
|
|
142
|
+
if (isNaN(number)) console.error(`The "${name}" filter expected a number, received:`, input)
|
|
143
|
+
else return number
|
|
144
|
+
}
|
|
145
|
+
|
|
137
146
|
return (
|
|
138
147
|
<Elements.Dropdown
|
|
139
148
|
// menuIsOpen={true}
|
|
@@ -153,35 +162,26 @@ export const Filters = forwardRef<FiltersHandleType, FiltersProps>(({
|
|
|
153
162
|
</div> */}
|
|
154
163
|
<div class={twMerge(`flex flex-wrap gap-[16px] p-[16px] pb-6 ${dropdownFiltersClassName || ''}`)}>
|
|
155
164
|
{
|
|
156
|
-
filters?.map(({label, width='full', rowClassName, ...filter}, i) =>
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
165
|
+
filters?.map(({label, width='full', rowClassName, ...filter}, i) => {
|
|
166
|
+
// `filter.name` is a full path e.g. 'job.location', not just the key `location`
|
|
167
|
+
const common = { className: '!mb-0', onChange: onInputChange }
|
|
168
|
+
return (
|
|
169
|
+
<div key={i} class={twMerge(getBasisWidth(width), rowClassName || '')}>
|
|
170
|
+
<div class="flex justify-between">
|
|
171
|
+
<label for={filter.id || filter.name}>{label || camelCaseToTitle(filter.name)}</label>
|
|
172
|
+
<a href="#" class="label font-normal text-secondary underline" onClick={(e) => reset(e, filter)}>Reset</a>
|
|
173
|
+
</div>
|
|
174
|
+
{
|
|
175
|
+
// Note: ignore typings for field, it has been sanitised in processState()
|
|
176
|
+
filter.type === 'select'
|
|
177
|
+
? <Elements.Select {...filter} {...common} value={state[filter.name] ?? ''} type={undefined} />
|
|
178
|
+
: filter.type === 'date'
|
|
179
|
+
? <Elements.Field {...filter} {...common} value={state[filter.name] as null ?? null} />
|
|
180
|
+
: <Elements.Field {...filter} {...common} value={state[filter.name] as string ?? ''} />
|
|
181
|
+
}
|
|
161
182
|
</div>
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
<Elements.Select
|
|
165
|
-
{...filter}
|
|
166
|
-
class="!mb-0"
|
|
167
|
-
// `filter.name` is a full path e.g. 'job.location', not just the key `location`
|
|
168
|
-
value={typeof state[filter.name] === 'undefined' ? '' : state[filter.name]}
|
|
169
|
-
onChange={onInputChange}
|
|
170
|
-
type={undefined}
|
|
171
|
-
/>
|
|
172
|
-
}
|
|
173
|
-
{
|
|
174
|
-
filter.type !== 'select' &&
|
|
175
|
-
<Elements.Field
|
|
176
|
-
{...filter}
|
|
177
|
-
class="!mb-0"
|
|
178
|
-
// `filter.name` is a full path e.g. 'job.location', not just the key `location`
|
|
179
|
-
value={typeof state[filter.name] === 'undefined' ? '' : state[filter.name] as string}
|
|
180
|
-
onChange={onInputChange}
|
|
181
|
-
/>
|
|
182
|
-
}
|
|
183
|
-
</div>
|
|
184
|
-
))
|
|
183
|
+
)
|
|
184
|
+
})
|
|
185
185
|
}
|
|
186
186
|
</div>
|
|
187
187
|
</div>
|
|
@@ -207,6 +207,35 @@ export const Filters = forwardRef<FiltersHandleType, FiltersProps>(({
|
|
|
207
207
|
</Elements.Button>
|
|
208
208
|
</Elements.Dropdown>
|
|
209
209
|
)
|
|
210
|
-
}
|
|
210
|
+
}
|
|
211
211
|
|
|
212
|
-
Filters.displayName = 'Filters'
|
|
212
|
+
Filters.displayName = 'Filters'
|
|
213
|
+
|
|
214
|
+
export function usePushChangesToPath(state: { [key: string]: unknown }) {
|
|
215
|
+
// Return a debounced function which updates the query path using the state
|
|
216
|
+
const navigate = useNavigate()
|
|
217
|
+
const location = useLocation()
|
|
218
|
+
const [debouncedPush] = useState(() => debounce(push, debounceTime))
|
|
219
|
+
const stateRef = useRef(state)
|
|
220
|
+
const locationRef = useRef(location)
|
|
221
|
+
|
|
222
|
+
useEffect(() => {
|
|
223
|
+
locationRef.current = location
|
|
224
|
+
}, [location])
|
|
225
|
+
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
return () => debouncedPush.cancel()
|
|
228
|
+
}, [])
|
|
229
|
+
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
stateRef.current = state
|
|
232
|
+
}, [state])
|
|
233
|
+
|
|
234
|
+
// Update the URL by replacing the current entry in the history stack
|
|
235
|
+
function push(includePagination?: boolean) {
|
|
236
|
+
const queryStr = queryString(omit(stateRef.current, includePagination ? [] : ['page']))
|
|
237
|
+
navigate(locationRef.current.pathname + queryStr, { replace: true })
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return debouncedPush
|
|
241
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { JSX, useState, useCallback, Fragment } from 'react'
|
|
1
|
+
import { JSX, useState, useCallback, Fragment, useMemo, useEffect } from 'react'
|
|
2
2
|
import { ChevronDownIcon, ChevronUpIcon } from 'lucide-react'
|
|
3
3
|
import { Checkbox, queryObject, queryString, twMerge } from 'nitro-web'
|
|
4
|
+
import { useLocation, useNavigate } from 'react-router-dom'
|
|
4
5
|
|
|
5
6
|
export type TableRowType = 'row' | 'loading' | 'empty' | 'thead'
|
|
6
7
|
|
|
@@ -78,6 +79,11 @@ export function Table<T extends TableRow>({
|
|
|
78
79
|
'first:border-l last:border-r border-t-0 box-border'
|
|
79
80
|
const [rand] = useState(() => new Date().getTime() + Math.random())
|
|
80
81
|
|
|
82
|
+
const rowsToRender = useMemo(() => {
|
|
83
|
+
// 1) Only show the first row when loading (content hidden), 2) an empty row when there are no records, or all rows
|
|
84
|
+
return rows.length > 0 ? (isLoading ? rows.slice(0, 1) : rows) : [{ _id: '' }] as unknown as T[]
|
|
85
|
+
}, [rows, isLoading])
|
|
86
|
+
|
|
81
87
|
const columns = useMemo(() => {
|
|
82
88
|
const checkboxCol: TableColumn = { value: 'checkbox', label: '', disableSort: true }
|
|
83
89
|
const cols = (generateCheckboxActions ? [checkboxCol, ...columnsProp] : columnsProp).map((col, _i) => ({
|
|
@@ -92,7 +98,7 @@ export function Table<T extends TableRow>({
|
|
|
92
98
|
|
|
93
99
|
const onSelect = useCallback((idOrAll: string, checked: boolean) => {
|
|
94
100
|
setSelectedRowIds((o) => {
|
|
95
|
-
if (idOrAll == 'all' && checked) return (rows ?? []).map(row => row
|
|
101
|
+
if (idOrAll == 'all' && checked) return (rows ?? []).map(row => row._id || '')
|
|
96
102
|
else if (idOrAll == 'all' && !checked) return []
|
|
97
103
|
else if (o.includes(idOrAll) && !checked) return o.filter(id => id != idOrAll)
|
|
98
104
|
else if (!o.includes(idOrAll) && checked) return [...o, idOrAll]
|
|
@@ -106,7 +112,7 @@ export function Table<T extends TableRow>({
|
|
|
106
112
|
}, [])
|
|
107
113
|
|
|
108
114
|
// Reset selected rows when the location changes, or the number of rows changed (e.g. when a row is removed)
|
|
109
|
-
useEffect(() => setSelectedRowIds([]), [location.key, (rows ?? []).map(row => row
|
|
115
|
+
useEffect(() => setSelectedRowIds([]), [location.key, (rows ?? []).map(row => row._id || '').join(',')])
|
|
110
116
|
|
|
111
117
|
// --- Sorting ---
|
|
112
118
|
|
|
@@ -124,8 +130,8 @@ export function Table<T extends TableRow>({
|
|
|
124
130
|
navigate(location.pathname + queryStr, { replace: true })
|
|
125
131
|
}, [location.pathname, query, sort, sortBy])
|
|
126
132
|
|
|
127
|
-
const getColumnPadding = useCallback((j: number, row: T|undefined,
|
|
128
|
-
const sideColor = j == 0 && rowSideColor ? rowSideColor(row,
|
|
133
|
+
const getColumnPadding = useCallback((j: number, row: T|undefined, rowType: TableRowType) => {
|
|
134
|
+
const sideColor = j == 0 && rowSideColor ? rowSideColor(row, rowType) : undefined
|
|
129
135
|
const sideColorPadding = sideColor /*&& rows.length > 0*/ ? sideColor.width + 5 : 0
|
|
130
136
|
const pl = sideColorPadding + (j == 0 ? columnPaddingX : columnGap)
|
|
131
137
|
const pr = j == columns.length - 1 ? columnPaddingX : columnGap
|
|
@@ -214,8 +220,8 @@ export function Table<T extends TableRow>({
|
|
|
214
220
|
</div>
|
|
215
221
|
{/* Tbody rows */}
|
|
216
222
|
{
|
|
217
|
-
|
|
218
|
-
const isSelected = selectedRowIds.includes(row
|
|
223
|
+
rowsToRender.map((row: T, i: number) => {
|
|
224
|
+
const isSelected = selectedRowIds.includes(row._id || '')
|
|
219
225
|
return (
|
|
220
226
|
<div
|
|
221
227
|
key={`${row._id}-${i}`}
|
|
@@ -227,7 +233,8 @@ export function Table<T extends TableRow>({
|
|
|
227
233
|
>
|
|
228
234
|
{
|
|
229
235
|
columns.map((col, j) => {
|
|
230
|
-
const
|
|
236
|
+
const rowType = row._id ? 'row' : isLoading ? 'loading' : 'empty'
|
|
237
|
+
const { pl, pr, sideColor } = getColumnPadding(j, isLoading ? undefined : row, rowType)
|
|
231
238
|
if (col.isHidden) return <Fragment key={j} />
|
|
232
239
|
return (
|
|
233
240
|
<div
|
|
@@ -260,18 +267,31 @@ export function Table<T extends TableRow>({
|
|
|
260
267
|
/>
|
|
261
268
|
}
|
|
262
269
|
{
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
270
|
+
// Rows (content hidden when loading)
|
|
271
|
+
row._id &&
|
|
272
|
+
<div className={isLoading ? 'opacity-0 pointer-events-none' : ''}>
|
|
273
|
+
{
|
|
274
|
+
col.value == 'checkbox'
|
|
275
|
+
? <Checkbox
|
|
276
|
+
size={checkboxSize}
|
|
277
|
+
name={`checkbox-${row._id}`}
|
|
278
|
+
onChange={(e) => onSelect(row?._id || '', e.target.checked)}
|
|
279
|
+
checked={selectedRowIds.includes(row?._id || '')}
|
|
280
|
+
onClick={(e) => e.stopPropagation()}
|
|
281
|
+
hitboxPadding={5}
|
|
282
|
+
className='!m-0 py-[5px]' // py-5 is required for hitbox (restricted to tabel cell height)
|
|
283
|
+
checkboxClassName={twMerge('border-foreground shadow-[0_1px_2px_0px_#0000001c]', checkboxClassName)}
|
|
284
|
+
/>
|
|
285
|
+
: generateTd(col, row, i, i == rows.length - 1)
|
|
286
|
+
}
|
|
287
|
+
</div>
|
|
288
|
+
}
|
|
289
|
+
{
|
|
290
|
+
// Show "loading" or "no records" text in the first column
|
|
291
|
+
j == 0 && (isLoading || !row._id) &&
|
|
292
|
+
<div className={'absolute top-0 h-full flex items-center justify-center text-sm text-gray-500'}>
|
|
293
|
+
{ isLoading ? <>Loading<span className="relative ml-[2px] loading-dots" /></> : 'No records found.' }
|
|
294
|
+
</div>
|
|
275
295
|
}
|
|
276
296
|
</div>
|
|
277
297
|
</div>
|
|
@@ -282,39 +302,6 @@ export function Table<T extends TableRow>({
|
|
|
282
302
|
)
|
|
283
303
|
})
|
|
284
304
|
}
|
|
285
|
-
{
|
|
286
|
-
(isLoading || rows.length == 0) &&
|
|
287
|
-
<div className='table-row relative'>
|
|
288
|
-
{
|
|
289
|
-
columns.map((col, j) => {
|
|
290
|
-
const { pl, pr, sideColor } = getColumnPadding(j, undefined, isLoading ? 'loading' : 'empty')
|
|
291
|
-
return (
|
|
292
|
-
<div
|
|
293
|
-
key={j}
|
|
294
|
-
style={{ height: rowHeightMin, paddingLeft: pl, paddingRight: pr }}
|
|
295
|
-
className={twMerge(_columnClassName, columnClassName, col.className)}
|
|
296
|
-
>
|
|
297
|
-
{
|
|
298
|
-
sideColor &&
|
|
299
|
-
<div
|
|
300
|
-
className={`absolute top-0 left-0 h-full ${sideColor?.className||''}`}
|
|
301
|
-
style={{ width: sideColor.width }}
|
|
302
|
-
/>
|
|
303
|
-
}
|
|
304
|
-
<div
|
|
305
|
-
className={twMerge(
|
|
306
|
-
'absolute top-0 h-full flex items-center justify-center text-sm text-gray-500',
|
|
307
|
-
col.innerClassName
|
|
308
|
-
)}
|
|
309
|
-
>
|
|
310
|
-
{ j == 0 && (isLoading ? <>Loading<span className="relative ml-[2px] loading-dots" /></> : 'No records found.') }
|
|
311
|
-
</div>
|
|
312
|
-
</div>
|
|
313
|
-
)
|
|
314
|
-
})
|
|
315
|
-
}
|
|
316
|
-
</div>
|
|
317
|
-
}
|
|
318
305
|
</div>
|
|
319
306
|
</div>
|
|
320
307
|
)
|