@xeplr/ui-utils 1.0.0 → 1.0.2
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/package.json +25 -6
- package/src/confirm/confirm.js +175 -0
- package/src/dataImport/dataImport.js +32 -0
- package/src/dataImport/designs/DataImportSample.jsx +71 -0
- package/src/dataImport/designs/ImportHistorySample.jsx +130 -0
- package/src/dataImport/designs/dataImport.css +98 -0
- package/src/dataImport/designs/index.js +2 -0
- package/src/dataImport/pages.jsx +17 -0
- package/src/dataImport/useDataImportController.js +94 -0
- package/src/dataImport/useImportHistoryController.js +67 -0
- package/src/dataImport/validateDesign.js +68 -0
- package/src/dateField/dateField.js +71 -0
- package/src/dateField/designs/DateFieldSample.jsx +30 -0
- package/src/dateField/designs/dateField.css +60 -0
- package/src/dateField/designs/index.js +1 -0
- package/src/dateField/pages.jsx +10 -0
- package/src/dateField/useDateFieldController.js +77 -0
- package/src/dateField/validateDesign.js +3 -0
- package/src/dateRange/dateRange.js +273 -0
- package/src/dateRange/designs/DateRangeSample.jsx +138 -0
- package/src/dateRange/designs/dateRange.css +69 -0
- package/src/dateRange/designs/index.js +1 -0
- package/src/dateRange/pages.jsx +10 -0
- package/src/dateRange/useDateRangeController.js +118 -0
- package/src/dateRange/validateDesign.js +20 -0
- package/src/dropdown/designs/DropdownSample.jsx +151 -0
- package/src/dropdown/designs/dropdown.css +226 -0
- package/src/dropdown/designs/index.js +1 -0
- package/src/dropdown/dropdown.js +75 -0
- package/src/dropdown/pages.jsx +10 -0
- package/src/dropdown/useDropdownController.js +254 -0
- package/src/dropdown/validateDesign.js +4 -0
- package/src/fileUpload/validateDesign.js +27 -8
- package/src/gridDisplayer/designs/GridDisplayerSample.jsx +58 -0
- package/src/gridDisplayer/designs/gridDisplayer.css +89 -0
- package/src/gridDisplayer/designs/index.js +1 -0
- package/src/gridDisplayer/gridDisplayer.js +44 -0
- package/src/gridDisplayer/pages.jsx +10 -0
- package/src/gridDisplayer/useGridDisplayerController.js +59 -0
- package/src/gridDisplayer/validateDesign.js +4 -0
- package/src/index.js +177 -1
- package/src/numberField/designs/NumberFieldSample.jsx +52 -0
- package/src/numberField/designs/index.js +1 -0
- package/src/numberField/designs/numberField.css +66 -0
- package/src/numberField/numberField.js +105 -0
- package/src/numberField/pages.jsx +10 -0
- package/src/numberField/useNumberFieldController.js +116 -0
- package/src/numberField/validateDesign.js +3 -0
- package/src/progress/ProgressNotifier.jsx +118 -0
- package/src/progress/designs/progressNotifier.css +73 -0
- package/src/progress/progressSource.js +168 -0
- package/src/rangeField/designs/RangeFieldSample.jsx +25 -0
- package/src/rangeField/designs/index.js +1 -0
- package/src/rangeField/designs/rangeField.css +26 -0
- package/src/rangeField/pages.jsx +10 -0
- package/src/rangeField/rangeField.js +29 -0
- package/src/rangeField/useRangeFieldController.js +52 -0
- package/src/rangeField/validateDesign.js +3 -0
- package/src/snackbar/snackbar.js +6 -3
- package/src/textField/designs/TextFieldSample.jsx +75 -0
- package/src/textField/designs/index.js +1 -0
- package/src/textField/designs/textField.css +92 -0
- package/src/textField/pages.jsx +10 -0
- package/src/textField/textField.js +65 -0
- package/src/textField/useTextFieldController.js +101 -0
- package/src/textField/validateDesign.js +3 -0
- package/src/theme/default.theme.json +934 -0
- package/src/theme/index.js +155 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {object} options
|
|
5
|
+
* @param {() => Promise<{jobs: object[]}|object[]>} options.onList
|
|
6
|
+
* @param {(job: object) => Promise<any>} options.onRollback
|
|
7
|
+
* @param {(job: object) => Promise<any>} [options.onDelete]
|
|
8
|
+
*
|
|
9
|
+
* Job shape (generic — same part/target vocabulary as useDataImportController):
|
|
10
|
+
* { id, originalFileName, part, target, rows, status, startedAt, error,
|
|
11
|
+
* destination?, rowsLoaded?, rowsDropped?, createdByName? }
|
|
12
|
+
* status: 'completed' | 'failed' | 'rolled-back' — only 'completed' rows are
|
|
13
|
+
* rollback-able; the design decides how to render the others.
|
|
14
|
+
* The `?` fields are optional context (where it went beyond the target table,
|
|
15
|
+
* a loaded/dropped breakdown, who ran it) — apps that don't track them can
|
|
16
|
+
* omit them and the design falls back to '—'.
|
|
17
|
+
*/
|
|
18
|
+
export function useImportHistoryController(options = {}) {
|
|
19
|
+
var [jobs, setJobs] = useState([]);
|
|
20
|
+
var [loading, setLoading] = useState(false);
|
|
21
|
+
var [error, setError] = useState('');
|
|
22
|
+
var [rollingBackId, setRollingBackId] = useState(null);
|
|
23
|
+
var [deletingId, setDeletingId] = useState(null);
|
|
24
|
+
|
|
25
|
+
var load = useCallback(async function() {
|
|
26
|
+
setLoading(true);
|
|
27
|
+
setError('');
|
|
28
|
+
try {
|
|
29
|
+
var res = await options.onList();
|
|
30
|
+
setJobs((res && res.jobs) || res || []);
|
|
31
|
+
} catch (err) {
|
|
32
|
+
setError(err.message || 'Failed to load import history');
|
|
33
|
+
} finally {
|
|
34
|
+
setLoading(false);
|
|
35
|
+
}
|
|
36
|
+
}, [options.onList]);
|
|
37
|
+
|
|
38
|
+
useEffect(function() { load(); }, [load]);
|
|
39
|
+
|
|
40
|
+
async function handleRollback(job) {
|
|
41
|
+
setRollingBackId(job.id);
|
|
42
|
+
setError('');
|
|
43
|
+
try {
|
|
44
|
+
await options.onRollback(job);
|
|
45
|
+
await load();
|
|
46
|
+
} catch (err) {
|
|
47
|
+
setError(err.message || 'Rollback failed');
|
|
48
|
+
} finally {
|
|
49
|
+
setRollingBackId(null);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function handleDelete(job) {
|
|
54
|
+
setDeletingId(job.id);
|
|
55
|
+
setError('');
|
|
56
|
+
try {
|
|
57
|
+
await options.onDelete(job);
|
|
58
|
+
await load();
|
|
59
|
+
} catch (err) {
|
|
60
|
+
setError(err.message || 'Delete failed');
|
|
61
|
+
} finally {
|
|
62
|
+
setDeletingId(null);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { jobs, loading, error, rollingBackId, deletingId, reload: load, handleRollback, handleDelete };
|
|
67
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Validates that required elements exist in the rendered design, and reports
|
|
5
|
+
* loudly when they don't — console.error plus a data-xeplr-design-invalid
|
|
6
|
+
* marker on the container.
|
|
7
|
+
*
|
|
8
|
+
* A rule matches by id, role or selector. `anyOf` takes a list of selectors
|
|
9
|
+
* and passes when ANY one is present — which is what a design with tabs or
|
|
10
|
+
* steps needs, since only the current one is mounted and demanding all of
|
|
11
|
+
* them at once can never pass.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} componentName - Name of the page (for error messages)
|
|
14
|
+
* @param {Array<{id?: string, role?: string, selector?: string, anyOf?: string[], label: string}>} requiredElements
|
|
15
|
+
*/
|
|
16
|
+
export function useDesignValidator(componentName, requiredElements) {
|
|
17
|
+
var containerRef = useRef(null);
|
|
18
|
+
|
|
19
|
+
useEffect(function() {
|
|
20
|
+
if (!containerRef.current) return;
|
|
21
|
+
|
|
22
|
+
var missing = [];
|
|
23
|
+
for (var i = 0; i < requiredElements.length; i++) {
|
|
24
|
+
var rule = requiredElements[i];
|
|
25
|
+
var found = false;
|
|
26
|
+
|
|
27
|
+
if (rule.id) {
|
|
28
|
+
found = !!containerRef.current.querySelector('#' + rule.id);
|
|
29
|
+
} else if (rule.role) {
|
|
30
|
+
found = !!containerRef.current.querySelector('[role="' + rule.role + '"]');
|
|
31
|
+
} else if (rule.anyOf) {
|
|
32
|
+
for (var j = 0; j < rule.anyOf.length && !found; j++) {
|
|
33
|
+
found = !!containerRef.current.querySelector(rule.anyOf[j]);
|
|
34
|
+
}
|
|
35
|
+
} else if (rule.selector) {
|
|
36
|
+
found = !!containerRef.current.querySelector(rule.selector);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!found) {
|
|
40
|
+
var where = rule.id ? ' (id="' + rule.id + '")'
|
|
41
|
+
: rule.anyOf ? ' (one of: ' + rule.anyOf.join(', ') + ')'
|
|
42
|
+
: rule.selector ? ' (' + rule.selector + ')' : '';
|
|
43
|
+
missing.push(rule.label + where);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (missing.length > 0) {
|
|
48
|
+
// Reported, not thrown. Throwing from an effect gives React nothing to
|
|
49
|
+
// catch, so a design rule — a development-time check — took the whole
|
|
50
|
+
// application down with it. The container is marked so the failure is
|
|
51
|
+
// visible on the page as well as in the console, which is what "fail
|
|
52
|
+
// loudly" was ever meant to buy.
|
|
53
|
+
var message = '[xeplr-ui-utils] ' + componentName + ' design is missing required elements:\n' +
|
|
54
|
+
missing.map(function(m) { return ' - ' + m; }).join('\n');
|
|
55
|
+
console.error(message);
|
|
56
|
+
containerRef.current.setAttribute('data-xeplr-design-invalid', componentName);
|
|
57
|
+
containerRef.current.setAttribute('title', message);
|
|
58
|
+
}
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
return containerRef;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export var DATA_IMPORT_RULES = [
|
|
65
|
+
{ selector: 'input[type="file"]', label: 'File input' }
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
export var IMPORT_HISTORY_RULES = [];
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DateField — pure model. No React.
|
|
3
|
+
*
|
|
4
|
+
* Three flavours:
|
|
5
|
+
* - 'date' → ISO date string (YYYY-MM-DD)
|
|
6
|
+
* - 'datetime' → ISO datetime string (YYYY-MM-DDTHH:mm)
|
|
7
|
+
* - 'age' → input is a DOB (date), output value is the DOB but the
|
|
8
|
+
* age in years is computed and returned alongside.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export var DATE_TYPES = ['date', 'datetime', 'age'];
|
|
12
|
+
|
|
13
|
+
export function isValidISODate(s) {
|
|
14
|
+
if (typeof s !== 'string' || s.length === 0) return false;
|
|
15
|
+
var d = new Date(s);
|
|
16
|
+
return !isNaN(d.getTime());
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Calculate completed years between dob and `now`. Returns null if dob is invalid.
|
|
21
|
+
*/
|
|
22
|
+
export function calculateAge(dob, now) {
|
|
23
|
+
if (!dob) return null;
|
|
24
|
+
var birth = dob instanceof Date ? dob : new Date(dob);
|
|
25
|
+
if (isNaN(birth.getTime())) return null;
|
|
26
|
+
var ref = now || new Date();
|
|
27
|
+
var years = ref.getFullYear() - birth.getFullYear();
|
|
28
|
+
var monthDiff = ref.getMonth() - birth.getMonth();
|
|
29
|
+
if (monthDiff < 0 || (monthDiff === 0 && ref.getDate() < birth.getDate())) {
|
|
30
|
+
years--;
|
|
31
|
+
}
|
|
32
|
+
return years < 0 ? null : years;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Map our public type to the underlying <input> type attribute.
|
|
37
|
+
*/
|
|
38
|
+
export function inputTypeFor(type) {
|
|
39
|
+
if (type === 'datetime') return 'datetime-local';
|
|
40
|
+
return 'date';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Normalise a Date or string into the format the <input> element expects.
|
|
45
|
+
*/
|
|
46
|
+
export function toInputValue(value, type) {
|
|
47
|
+
if (!value) return '';
|
|
48
|
+
var d = value instanceof Date ? value : new Date(value);
|
|
49
|
+
if (isNaN(d.getTime())) return '';
|
|
50
|
+
var pad = function(n) { return n < 10 ? '0' + n : '' + n; };
|
|
51
|
+
var ymd = d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
|
|
52
|
+
if (type === 'datetime') {
|
|
53
|
+
return ymd + 'T' + pad(d.getHours()) + ':' + pad(d.getMinutes());
|
|
54
|
+
}
|
|
55
|
+
return ymd;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function validateBounds(value, min, max) {
|
|
59
|
+
if (!value) return { valid: true };
|
|
60
|
+
var d = value instanceof Date ? value : new Date(value);
|
|
61
|
+
if (isNaN(d.getTime())) return { valid: false, error: 'Invalid date' };
|
|
62
|
+
if (min) {
|
|
63
|
+
var dMin = min instanceof Date ? min : new Date(min);
|
|
64
|
+
if (d < dMin) return { valid: false, error: 'Date is before minimum' };
|
|
65
|
+
}
|
|
66
|
+
if (max) {
|
|
67
|
+
var dMax = max instanceof Date ? max : new Date(max);
|
|
68
|
+
if (d > dMax) return { valid: false, error: 'Date is after maximum' };
|
|
69
|
+
}
|
|
70
|
+
return { valid: true };
|
|
71
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import './dateField.css';
|
|
2
|
+
|
|
3
|
+
export default function DateFieldSample({
|
|
4
|
+
type, inputType, raw, age, error, placeholder, disabled, min, max,
|
|
5
|
+
handleChange
|
|
6
|
+
}) {
|
|
7
|
+
return (
|
|
8
|
+
<div className={'xeplr-datefield xeplr-datefield-' + type + (error ? ' xeplr-datefield-error' : '')}>
|
|
9
|
+
<div className="xeplr-datefield-row">
|
|
10
|
+
<input
|
|
11
|
+
type={inputType}
|
|
12
|
+
className="xeplr-datefield-input"
|
|
13
|
+
value={raw}
|
|
14
|
+
placeholder={placeholder}
|
|
15
|
+
disabled={disabled}
|
|
16
|
+
min={min}
|
|
17
|
+
max={max}
|
|
18
|
+
onChange={handleChange}
|
|
19
|
+
/>
|
|
20
|
+
{type === 'age' && (
|
|
21
|
+
<div className="xeplr-datefield-age">
|
|
22
|
+
<span className="xeplr-datefield-age-label">Age</span>
|
|
23
|
+
<span className="xeplr-datefield-age-value">{age === null ? '—' : age + ' yrs'}</span>
|
|
24
|
+
</div>
|
|
25
|
+
)}
|
|
26
|
+
</div>
|
|
27
|
+
{error && <div className="xeplr-datefield-error-text">{error}</div>}
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
.xeplr-datefield {
|
|
2
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
3
|
+
color: #e0e0e0;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
.xeplr-datefield-row {
|
|
7
|
+
display: flex;
|
|
8
|
+
align-items: stretch;
|
|
9
|
+
gap: 10px;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.xeplr-datefield-input {
|
|
13
|
+
flex: 1;
|
|
14
|
+
background: #1a1a1a;
|
|
15
|
+
border: 1px solid #444;
|
|
16
|
+
border-radius: 6px;
|
|
17
|
+
padding: 9px 12px;
|
|
18
|
+
color: inherit;
|
|
19
|
+
font-size: 14px;
|
|
20
|
+
outline: none;
|
|
21
|
+
color-scheme: dark;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.xeplr-datefield-input:focus {
|
|
25
|
+
border-color: #646cff;
|
|
26
|
+
box-shadow: 0 0 0 2px rgba(100, 108, 255, 0.18);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.xeplr-datefield-error .xeplr-datefield-input {
|
|
30
|
+
border-color: #e25555;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.xeplr-datefield-age {
|
|
34
|
+
display: flex;
|
|
35
|
+
flex-direction: column;
|
|
36
|
+
justify-content: center;
|
|
37
|
+
background: #232540;
|
|
38
|
+
border-radius: 6px;
|
|
39
|
+
padding: 4px 14px;
|
|
40
|
+
min-width: 90px;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.xeplr-datefield-age-label {
|
|
44
|
+
font-size: 10px;
|
|
45
|
+
text-transform: uppercase;
|
|
46
|
+
letter-spacing: 0.6px;
|
|
47
|
+
color: #888bbf;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.xeplr-datefield-age-value {
|
|
51
|
+
font-size: 14px;
|
|
52
|
+
font-weight: 600;
|
|
53
|
+
color: #e0e0e0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.xeplr-datefield-error-text {
|
|
57
|
+
margin-top: 4px;
|
|
58
|
+
font-size: 12px;
|
|
59
|
+
color: #e25555;
|
|
60
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as DateFieldSample } from './DateFieldSample.jsx';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { useDateFieldController } from './useDateFieldController.js';
|
|
2
|
+
import DateFieldSample from './designs/DateFieldSample.jsx';
|
|
3
|
+
import { useDesignValidator } from '../fileUpload/validateDesign.js';
|
|
4
|
+
import { DATE_FIELD_RULES } from './validateDesign.js';
|
|
5
|
+
|
|
6
|
+
export function DateFieldPage(props) {
|
|
7
|
+
var controller = useDateFieldController(props);
|
|
8
|
+
var ref = useDesignValidator('DateFieldPage', DATE_FIELD_RULES);
|
|
9
|
+
return <div ref={ref}><DateFieldSample {...controller} /></div>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useState, useEffect, useMemo } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
DATE_TYPES,
|
|
4
|
+
calculateAge,
|
|
5
|
+
inputTypeFor,
|
|
6
|
+
toInputValue,
|
|
7
|
+
validateBounds
|
|
8
|
+
} from './dateField.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {object} props
|
|
12
|
+
* @param {'date'|'datetime'|'age'} [props.type='date']
|
|
13
|
+
* @param {Date|string|null} [props.value] — controlled value
|
|
14
|
+
* @param {Date|string|null} [props.defaultValue] — uncontrolled initial value
|
|
15
|
+
* @param {(value, meta) => void} [props.onChange] — meta.age included for age type
|
|
16
|
+
* @param {Date|string} [props.min]
|
|
17
|
+
* @param {Date|string} [props.max]
|
|
18
|
+
* @param {string} [props.placeholder]
|
|
19
|
+
* @param {boolean} [props.disabled]
|
|
20
|
+
*/
|
|
21
|
+
export function useDateFieldController(props) {
|
|
22
|
+
props = props || {};
|
|
23
|
+
var type = props.type || 'date';
|
|
24
|
+
if (DATE_TYPES.indexOf(type) === -1) {
|
|
25
|
+
throw new Error('[xeplr-ui-utils:DateField] Unknown type: ' + type + '. Expected one of ' + DATE_TYPES.join(', '));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
var isControlled = props.value !== undefined;
|
|
29
|
+
var initial = isControlled ? props.value : (props.defaultValue !== undefined ? props.defaultValue : null);
|
|
30
|
+
|
|
31
|
+
var [raw, setRaw] = useState(toInputValue(initial, type === 'age' ? 'date' : type));
|
|
32
|
+
var [error, setError] = useState('');
|
|
33
|
+
|
|
34
|
+
useEffect(function() {
|
|
35
|
+
if (isControlled) {
|
|
36
|
+
setRaw(toInputValue(props.value, type === 'age' ? 'date' : type));
|
|
37
|
+
}
|
|
38
|
+
}, [props.value, isControlled, type]);
|
|
39
|
+
|
|
40
|
+
var inputType = useMemo(function() {
|
|
41
|
+
return inputTypeFor(type === 'age' ? 'date' : type);
|
|
42
|
+
}, [type]);
|
|
43
|
+
|
|
44
|
+
var age = useMemo(function() {
|
|
45
|
+
if (type !== 'age') return null;
|
|
46
|
+
return calculateAge(raw);
|
|
47
|
+
}, [type, raw]);
|
|
48
|
+
|
|
49
|
+
function emit(nextRaw) {
|
|
50
|
+
var bounds = validateBounds(nextRaw, props.min, props.max);
|
|
51
|
+
setError(bounds.valid ? '' : bounds.error);
|
|
52
|
+
if (props.onChange) {
|
|
53
|
+
var meta = {};
|
|
54
|
+
if (type === 'age') meta.age = calculateAge(nextRaw);
|
|
55
|
+
props.onChange(nextRaw || null, meta);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function handleChange(e) {
|
|
60
|
+
var v = e.target.value;
|
|
61
|
+
if (!isControlled) setRaw(v);
|
|
62
|
+
emit(v);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
type,
|
|
67
|
+
inputType,
|
|
68
|
+
raw,
|
|
69
|
+
age,
|
|
70
|
+
error,
|
|
71
|
+
placeholder: props.placeholder,
|
|
72
|
+
disabled: !!props.disabled,
|
|
73
|
+
min: props.min ? toInputValue(props.min, type === 'age' ? 'date' : type) : undefined,
|
|
74
|
+
max: props.max ? toInputValue(props.max, type === 'age' ? 'date' : type) : undefined,
|
|
75
|
+
handleChange
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// DateRange — model. A date SELECTION, and how it becomes a real from/to
|
|
2
|
+
// range. Pure: no React, no DOM.
|
|
3
|
+
//
|
|
4
|
+
// One shape covers every way a person names a span of time, which is the
|
|
5
|
+
// point. A default set once, a preset that keeps moving, and a range typed by
|
|
6
|
+
// hand are all the same value, so an app can fall back between them without
|
|
7
|
+
// three different mechanisms.
|
|
8
|
+
//
|
|
9
|
+
// { kind: 'quick', preset: 'current_month' }
|
|
10
|
+
// { kind: 'range', from: '2022-01-01', to: '2022-03-31' }
|
|
11
|
+
// { kind: 'single', value: '2022-05' } // a day, a month, or a year
|
|
12
|
+
// null // not set — inherit
|
|
13
|
+
//
|
|
14
|
+
// EVERYTHING RESOLVES TO A RANGE, including a single date. "May 2022" is the
|
|
15
|
+
// range 1–31 May; "2022" is the whole year; "1/1/2022" is that one day. A
|
|
16
|
+
// consumer therefore always gets {from, to} and never has to ask which
|
|
17
|
+
// comparison was meant — the selection already says.
|
|
18
|
+
|
|
19
|
+
// April. Overridable per call — a financial year starts in a different month
|
|
20
|
+
// in most of the world, and hardcoding one is how a report quietly reports
|
|
21
|
+
// the wrong year.
|
|
22
|
+
var DEFAULT_FISCAL_START_MONTH = 4
|
|
23
|
+
|
|
24
|
+
function pad(n) { return String(n).padStart(2, '0') }
|
|
25
|
+
function iso(y, m, d) { return `${y}-${pad(m + 1)}-${pad(d)}` }
|
|
26
|
+
function lastDay(y, m) { return new Date(y, m + 1, 0).getDate() }
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The presets people actually ask for. Each resolves against `now`, so a
|
|
30
|
+
* report saved with one stays current instead of freezing on the day it was
|
|
31
|
+
* built — the whole reason to prefer a preset over a literal date.
|
|
32
|
+
*/
|
|
33
|
+
export const QUICK_PRESETS = [
|
|
34
|
+
{ key: 'today', label: 'Today' },
|
|
35
|
+
{ key: 'yesterday', label: 'Yesterday' },
|
|
36
|
+
{ key: 'dby', label: 'Day before yesterday' },
|
|
37
|
+
{ key: 'last_7_days', label: 'Last 7 days' },
|
|
38
|
+
{ key: 'last_30_days', label: 'Last 30 days' },
|
|
39
|
+
{ key: 'current_month', label: 'Current month' },
|
|
40
|
+
{ key: 'current_mtd', label: 'Current month-to-date' },
|
|
41
|
+
{ key: 'last_month', label: 'Last month' },
|
|
42
|
+
{ key: 'current_quarter', label: 'Current quarter' },
|
|
43
|
+
{ key: 'current_qtd', label: 'Current quarter-to-date' },
|
|
44
|
+
{ key: 'last_quarter', label: 'Last quarter' },
|
|
45
|
+
{ key: 'current_calendar_year', label: 'Current calendar year' },
|
|
46
|
+
{ key: 'current_ytd', label: 'Current year-to-date' },
|
|
47
|
+
{ key: 'last_calendar_year', label: 'Last calendar year' },
|
|
48
|
+
{ key: 'current_financial_year', label: 'Current financial year' },
|
|
49
|
+
{ key: 'current_fytd', label: 'Current financial year-to-date' },
|
|
50
|
+
{ key: 'last_financial_year', label: 'Last financial year' }
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
const PRESET_LABEL = new Map(QUICK_PRESETS.map((p) => [p.key, p.label]))
|
|
54
|
+
|
|
55
|
+
const MONTHS = ['january', 'february', 'march', 'april', 'may', 'june',
|
|
56
|
+
'july', 'august', 'september', 'october', 'november', 'december']
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parses the free text a Single selection accepts: a full date, a month, or a
|
|
60
|
+
* year. Returns a {from, to} range, since a month or a year IS a range.
|
|
61
|
+
*
|
|
62
|
+
* Day-first for the ambiguous slash form (1/2/2022 is 1 February), matching
|
|
63
|
+
* where this is used rather than the American reading.
|
|
64
|
+
*/
|
|
65
|
+
export function parseSingleDate(text) {
|
|
66
|
+
const raw = String(text || '').trim()
|
|
67
|
+
if (!raw) return null
|
|
68
|
+
|
|
69
|
+
// 2022
|
|
70
|
+
let m = /^(\d{4})$/.exec(raw)
|
|
71
|
+
if (m) {
|
|
72
|
+
const y = Number(m[1])
|
|
73
|
+
return { from: iso(y, 0, 1), to: iso(y, 11, 31) }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 2022-05 or 2022/05
|
|
77
|
+
m = /^(\d{4})[-/](\d{1,2})$/.exec(raw)
|
|
78
|
+
if (m) {
|
|
79
|
+
const y = Number(m[1]); const mo = Number(m[2]) - 1
|
|
80
|
+
if (mo < 0 || mo > 11) return null
|
|
81
|
+
return { from: iso(y, mo, 1), to: iso(y, mo, lastDay(y, mo)) }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 2022-05-17
|
|
85
|
+
m = /^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$/.exec(raw)
|
|
86
|
+
if (m) {
|
|
87
|
+
const y = Number(m[1]); const mo = Number(m[2]) - 1; const d = Number(m[3])
|
|
88
|
+
if (mo < 0 || mo > 11) return null
|
|
89
|
+
return { from: iso(y, mo, d), to: iso(y, mo, d) }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// May 2022 / may 2022
|
|
93
|
+
m = /^([A-Za-z]+)\s+(\d{4})$/.exec(raw)
|
|
94
|
+
if (m) {
|
|
95
|
+
const idx = MONTHS.findIndex((name) => name.startsWith(m[1].toLowerCase()))
|
|
96
|
+
if (idx === -1) return null
|
|
97
|
+
const y = Number(m[2])
|
|
98
|
+
return { from: iso(y, idx, 1), to: iso(y, idx, lastDay(y, idx)) }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 17/5/2022 — day first.
|
|
102
|
+
m = /^(\d{1,2})[-/](\d{1,2})[-/](\d{4})$/.exec(raw)
|
|
103
|
+
if (m) {
|
|
104
|
+
const d = Number(m[1]); const mo = Number(m[2]) - 1; const y = Number(m[3])
|
|
105
|
+
if (mo < 0 || mo > 11) return null
|
|
106
|
+
return { from: iso(y, mo, d), to: iso(y, mo, d) }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function shift(now, days) {
|
|
113
|
+
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - days)
|
|
114
|
+
return iso(d.getFullYear(), d.getMonth(), d.getDate())
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function monthRange(y, m) {
|
|
118
|
+
return { from: iso(y, m, 1), to: iso(y, m, lastDay(y, m)) }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* What somebody might mean, as they type — months and years, never days.
|
|
123
|
+
*
|
|
124
|
+
* A day is faster to finish typing than to pick out of a list, and there are
|
|
125
|
+
* thirty of them. A MONTH is the opposite: "j" is three different months, and
|
|
126
|
+
* each of those is a different year depending on what you were just looking
|
|
127
|
+
* at. That ambiguity is the whole reason to suggest anything.
|
|
128
|
+
*
|
|
129
|
+
* ANCHORED to the year already in play — if the filter currently says
|
|
130
|
+
* Feb 2025, typing "j" offers Jan/Jun/Jul 2025 first, because somebody
|
|
131
|
+
* comparing months is almost always comparing them within a year. Other years
|
|
132
|
+
* follow, so the less common case is one click rather than more typing.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} text what has been typed so far
|
|
135
|
+
* @param {number} anchor the year to offer first
|
|
136
|
+
* @param {number} years how many additional years to offer per month
|
|
137
|
+
*/
|
|
138
|
+
export function singleDateSuggestions(text, anchor, years) {
|
|
139
|
+
const q = String(text || '').trim().toLowerCase()
|
|
140
|
+
const year = Number(anchor) || new Date().getFullYear()
|
|
141
|
+
const depth = years == null ? 2 : years
|
|
142
|
+
if (!q) return []
|
|
143
|
+
|
|
144
|
+
// A year on its own — "202" is a prefix of several, and a whole year is a
|
|
145
|
+
// legitimate answer in its own right.
|
|
146
|
+
if (/^\d{1,4}$/.test(q)) {
|
|
147
|
+
const out = []
|
|
148
|
+
for (let y = year + 1; y >= year - 6; y--) {
|
|
149
|
+
if (String(y).startsWith(q)) out.push({ value: String(y), label: String(y), note: 'the whole year' })
|
|
150
|
+
}
|
|
151
|
+
return out.slice(0, 8)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// "jan 20", "june 2024" — the month is settled, so only years are open.
|
|
155
|
+
const withYear = /^([a-z]+)\s+(\d{1,4})$/.exec(q)
|
|
156
|
+
const monthQuery = withYear ? withYear[1] : q
|
|
157
|
+
const yearQuery = withYear ? withYear[2] : null
|
|
158
|
+
if (!/^[a-z]+$/.test(monthQuery)) return []
|
|
159
|
+
|
|
160
|
+
const hits = []
|
|
161
|
+
MONTHS.forEach((name, i) => {
|
|
162
|
+
if (!name.startsWith(monthQuery)) return
|
|
163
|
+
hits.push({ name: name.charAt(0).toUpperCase() + name.slice(1), index: i })
|
|
164
|
+
})
|
|
165
|
+
if (!hits.length) return []
|
|
166
|
+
|
|
167
|
+
// The anchor year first for every match, THEN the other years — so a list
|
|
168
|
+
// of three months reads Jan/Jun/Jul 2025 before it reads Jan 2024, rather
|
|
169
|
+
// than burying two of this year's months under last year's January.
|
|
170
|
+
const out = []
|
|
171
|
+
const push = (m, y) => {
|
|
172
|
+
if (yearQuery && !String(y).startsWith(yearQuery)) return
|
|
173
|
+
out.push({ value: m.name + ' ' + y, label: m.name + ' ' + y, note: null })
|
|
174
|
+
}
|
|
175
|
+
hits.forEach((m) => push(m, year))
|
|
176
|
+
for (let d = 1; d <= depth; d++) {
|
|
177
|
+
hits.forEach((m) => push(m, year - d))
|
|
178
|
+
}
|
|
179
|
+
hits.forEach((m) => push(m, year + 1))
|
|
180
|
+
return out.slice(0, 10)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function resolveQuickPreset(preset, now, options) {
|
|
184
|
+
const fiscalStart = ((options && options.fiscalStartMonth) || DEFAULT_FISCAL_START_MONTH) - 1
|
|
185
|
+
const y = now.getFullYear()
|
|
186
|
+
const m = now.getMonth()
|
|
187
|
+
const today = iso(y, m, now.getDate())
|
|
188
|
+
const q = Math.floor(m / 3)
|
|
189
|
+
// The fiscal year containing `now` began this calendar year only if we're
|
|
190
|
+
// already past its start month — same rule as shared/params.js.
|
|
191
|
+
const fy = m >= fiscalStart ? y : y - 1
|
|
192
|
+
|
|
193
|
+
switch (preset) {
|
|
194
|
+
case 'today': return { from: today, to: today }
|
|
195
|
+
case 'yesterday': return { from: shift(now, 1), to: shift(now, 1) }
|
|
196
|
+
case 'dby': return { from: shift(now, 2), to: shift(now, 2) }
|
|
197
|
+
case 'last_7_days': return { from: shift(now, 6), to: today }
|
|
198
|
+
case 'last_30_days': return { from: shift(now, 29), to: today }
|
|
199
|
+
|
|
200
|
+
case 'current_month': return monthRange(y, m)
|
|
201
|
+
case 'current_mtd': return { from: iso(y, m, 1), to: today }
|
|
202
|
+
case 'last_month': return m === 0 ? monthRange(y - 1, 11) : monthRange(y, m - 1)
|
|
203
|
+
|
|
204
|
+
case 'current_quarter': return { from: iso(y, q * 3, 1), to: iso(y, q * 3 + 2, lastDay(y, q * 3 + 2)) }
|
|
205
|
+
case 'current_qtd': return { from: iso(y, q * 3, 1), to: today }
|
|
206
|
+
case 'last_quarter': {
|
|
207
|
+
const ly = q === 0 ? y - 1 : y
|
|
208
|
+
const lq = q === 0 ? 3 : q - 1
|
|
209
|
+
return { from: iso(ly, lq * 3, 1), to: iso(ly, lq * 3 + 2, lastDay(ly, lq * 3 + 2)) }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
case 'current_calendar_year': return { from: iso(y, 0, 1), to: iso(y, 11, 31) }
|
|
213
|
+
case 'current_ytd': return { from: iso(y, 0, 1), to: today }
|
|
214
|
+
case 'last_calendar_year': return { from: iso(y - 1, 0, 1), to: iso(y - 1, 11, 31) }
|
|
215
|
+
|
|
216
|
+
case 'current_financial_year': {
|
|
217
|
+
const end = new Date(fy + 1, fiscalStart, 0)
|
|
218
|
+
return { from: iso(fy, fiscalStart, 1), to: iso(end.getFullYear(), end.getMonth(), end.getDate()) }
|
|
219
|
+
}
|
|
220
|
+
case 'current_fytd': return { from: iso(fy, fiscalStart, 1), to: today }
|
|
221
|
+
case 'last_financial_year': {
|
|
222
|
+
const end = new Date(fy, fiscalStart, 0)
|
|
223
|
+
return { from: iso(fy - 1, fiscalStart, 1), to: iso(end.getFullYear(), end.getMonth(), end.getDate()) }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
default: return null
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* A selection -> { from, to }, or null when it resolves to nothing.
|
|
232
|
+
*
|
|
233
|
+
* A null result is "no constraint", never "match nothing" — an unresolvable
|
|
234
|
+
* selection must widen rather than silently empty the report.
|
|
235
|
+
*/
|
|
236
|
+
export function resolveDateValue(value, now, options) {
|
|
237
|
+
if (!value) return null
|
|
238
|
+
if (value.kind === 'quick') return resolveQuickPreset(value.preset, now || new Date(), options)
|
|
239
|
+
if (value.kind === 'single') return parseSingleDate(value.value)
|
|
240
|
+
if (value.kind === 'range') {
|
|
241
|
+
const from = value.from || null
|
|
242
|
+
const to = value.to || null
|
|
243
|
+
if (!from && !to) return null
|
|
244
|
+
return { from, to }
|
|
245
|
+
}
|
|
246
|
+
return null
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** What the chip and the panel read. */
|
|
250
|
+
export function dateValueLabel(value) {
|
|
251
|
+
if (!value) return null
|
|
252
|
+
if (value.kind === 'quick') return PRESET_LABEL.get(value.preset) || value.preset
|
|
253
|
+
if (value.kind === 'single') return value.value || null
|
|
254
|
+
if (value.kind === 'range') {
|
|
255
|
+
if (value.from && value.to) return `${value.from} → ${value.to}`
|
|
256
|
+
if (value.from) return `From ${value.from}`
|
|
257
|
+
if (value.to) return `Until ${value.to}`
|
|
258
|
+
return null
|
|
259
|
+
}
|
|
260
|
+
return null
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* True when a selection would actually constrain anything.
|
|
265
|
+
*
|
|
266
|
+
* Defined as "resolves to a range", NOT "has something typed in it". A Single
|
|
267
|
+
* holding unparseable text has a label but no range — treating that as set
|
|
268
|
+
* would let it pass a "do we have a default?" check and then silently apply no
|
|
269
|
+
* filter at all, which is the worst of both.
|
|
270
|
+
*/
|
|
271
|
+
export function dateValueIsSet(value, now) {
|
|
272
|
+
return Boolean(resolveDateValue(value, now || new Date()))
|
|
273
|
+
}
|