@onehat/ui 0.2.48 → 0.2.50

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onehat/ui",
3
- "version": "0.2.48",
3
+ "version": "0.2.50",
4
4
  "description": "Base UI for OneHat apps",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -28,6 +28,9 @@ const IconButton = React.forwardRef((props, ref) => {
28
28
  justifyContent="center"
29
29
  alignItems="center"
30
30
  p={2}
31
+ _disabled={{
32
+ bg: 'trueGray.300',
33
+ }}
31
34
  {...props}
32
35
  >
33
36
  {icon}
@@ -0,0 +1,40 @@
1
+ import React from 'react';
2
+ import {
3
+ Row,
4
+ Text,
5
+ } from 'native-base';
6
+ import IconButton from './IconButton.js';
7
+ import Plus from '../Icons/Plus.js';
8
+ import Minus from '../Icons/Minus.js';
9
+
10
+
11
+ const PlusMinusButton = React.forwardRef((props, ref) => {
12
+
13
+ const {
14
+ isPlusDisabled = false,
15
+ isMinusDisabled = false,
16
+ plusHandler = () => {},
17
+ minusHandler = () => {},
18
+ } = props;
19
+
20
+ return <Row {...props}>
21
+ <Row alignItems="center">
22
+ <IconButton
23
+ icon={<Minus color="#fff" />}
24
+ onPress={minusHandler}
25
+ bg="primary.200"
26
+ isDisabled={isMinusDisabled}
27
+ />
28
+ <IconButton
29
+ icon={<Plus color="#fff" />}
30
+ onPress={plusHandler}
31
+ bg="primary.200"
32
+ isDisabled={isPlusDisabled}
33
+ ml={1}
34
+ />
35
+ </Row>
36
+ </Row>;
37
+
38
+ });
39
+
40
+ export default PlusMinusButton;
@@ -74,6 +74,7 @@ import _ from 'lodash';
74
74
  minValue={minValue}
75
75
  maxValue={maxValue}
76
76
  tooltip={(tooltip ? tooltip + ' ' : '') + 'Low'}
77
+ maxWidth={120}
77
78
  />
78
79
  <Text px={2} userSelect="none">to</Text>
79
80
  <Number
@@ -83,6 +84,7 @@ import _ from 'lodash';
83
84
  minValue={minValue}
84
85
  maxValue={maxValue}
85
86
  tooltip={(tooltip ? tooltip + ' ' : '') + 'High'}
87
+ maxWidth={120}
86
88
  />
87
89
  </Row>;
88
90
  },
@@ -178,17 +178,32 @@ export function Combo(props) {
178
178
  const {
179
179
  relatedTarget
180
180
  } = e;
181
+
182
+ // If user focused on the trigger and text is blank, clear the selection and close the menu
183
+ if ((triggerRef.current === relatedTarget || triggerRef.current.contains(relatedTarget)) && (_.isEmpty(textValue) || _.isNil(textValue))) {
184
+ setSelection([]); // delete current selection
185
+ hideMenu();
186
+ return;
187
+ }
181
188
 
182
- // If user clicked on the menu or trigger, ignore this blur
183
- if (menuRef.current?.contains(relatedTarget) || triggerRef.current.contains(relatedTarget)) {
189
+ // If user focused on the menu or trigger, ignore this blur
190
+ if (triggerRef.current === relatedTarget ||
191
+ triggerRef.current.contains(relatedTarget) ||
192
+ menuRef.current=== relatedTarget ||
193
+ menuRef.current?.contains(relatedTarget)) {
184
194
  return;
185
195
  }
186
196
 
187
197
  if (!relatedTarget ||
188
- (!inputRef.current.contains(relatedTarget) && triggerRef.current !== relatedTarget && (!menuRef.current || !menuRef.current.contains(relatedTarget)))) {
198
+ (
199
+ !inputRef.current.contains(relatedTarget) &&
200
+ triggerRef.current !== relatedTarget &&
201
+ (!menuRef.current || !menuRef.current.contains(relatedTarget))
202
+ )
203
+ ) {
189
204
  hideMenu();
190
205
  }
191
- if (textValue === '') {
206
+ if (_.isEmpty(textValue) || _.isNil(textValue)) {
192
207
  setSelection([]); // delete current selection
193
208
 
194
209
  } else if (isManuallyEnteringText) {
@@ -221,12 +236,17 @@ export function Combo(props) {
221
236
  inputRef.current.focus();
222
237
  },
223
238
  onTriggerBlur = (e) => {
224
- if (!isMenuShown) {
225
- return;
226
- }
227
239
  const {
228
240
  relatedTarget
229
241
  } = e;
242
+
243
+ if (_.isEmpty(textValue) || _.isNil(textValue)) {
244
+ setSelection([]); // delete current selection
245
+ }
246
+
247
+ if (!isMenuShown) {
248
+ return;
249
+ }
230
250
  if (!relatedTarget ||
231
251
  (!inputRef.current.contains(relatedTarget) && triggerRef.current !== relatedTarget && !menuRef.current.contains(relatedTarget))) {
232
252
  hideMenu();
@@ -54,6 +54,11 @@ function NumberElement(props) {
54
54
  }
55
55
  },
56
56
  onChangeText = (value) => {
57
+ if (value.match(/\.$/)) { // if value ends with a decimal point
58
+ setLocalValue(value);
59
+ return;
60
+ }
61
+
57
62
  if (value === '') {
58
63
  value = null; // empty string makes value null
59
64
  } else {
@@ -114,7 +119,7 @@ function NumberElement(props) {
114
119
  isIncrementDisabled = typeof maxValue !== 'undefined' && value === maxValue,
115
120
  isDecrementDisabled = typeof minValue !== 'undefined' && (value === minValue || (!value && minValue === 0));
116
121
 
117
- return <Row flex={1} h="100%" p={0} borderWidth={1} borderColor="trueGray.400" borderRadius={6}>
122
+ return <Row flex={1} h="100%" p={0} borderWidth={1} borderColor="trueGray.400" borderRadius={6} {...props}>
118
123
  <IconButton
119
124
  icon={<Icon as={Minus} color={isDecrementDisabled ? 'disabled' : 'trueGray.500'} />}
120
125
  onPress={onDecrement}
@@ -67,7 +67,9 @@ const
67
67
  {...propsToPass}
68
68
  />
69
69
  </Pressable>
70
- <Text ml={2} fontSize={styles.FORM_TOGGLE_FONTSIZE}>{_.isNil(value) ? 'N/A' : (!!value ? 'Yes' : 'No')}</Text>
70
+ <Pressable onPress={onToggle}>
71
+ <Text ml={2} fontSize={styles.FORM_TOGGLE_FONTSIZE}>{_.isNil(value) ? 'N/A' : (!!value ? 'Yes' : 'No')}</Text>
72
+ </Pressable>
71
73
  </Row>;
72
74
  },
73
75
  ToggleField = withValue(ToggleElement);
@@ -28,6 +28,7 @@ import inArray from '../../Functions/inArray.js';
28
28
  import getComponentFromType from '../../Functions/getComponentFromType.js';
29
29
  import IconButton from '../Buttons/IconButton.js';
30
30
  import AngleLeft from '../Icons/AngleLeft.js';
31
+ import Eye from '../Icons/Eye.js';
31
32
  import Rotate from '../Icons/Rotate.js';
32
33
  import Pencil from '../Icons/Pencil.js';
33
34
  import Footer from '../Panel/Footer.js';
@@ -62,6 +63,9 @@ function Form(props) {
62
63
  footerProps = {},
63
64
  buttonGroupProps = {}, // buttons in footer
64
65
  onBack,
66
+ onReset,
67
+ onViewMode,
68
+ additionalButtons = [],
65
69
  ancillaryComponents = [],
66
70
 
67
71
  // sizing of outer container
@@ -357,7 +361,12 @@ function Form(props) {
357
361
  let element = <Element
358
362
  name={name}
359
363
  value={value}
360
- onChangeValue={onChange}
364
+ onChangeValue={(value) => {
365
+ onChange(value); // form onChange handler
366
+ if (propsToPass.onChange) {
367
+ propsToPass.onChange(value); // item onChange handler
368
+ }
369
+ }}
361
370
  onBlur={onBlur}
362
371
  selectorId={selectorId}
363
372
  selectorSelected={selectorSelected}
@@ -476,7 +485,8 @@ function Form(props) {
476
485
 
477
486
  return <Column {...sizeProps} onLayout={onLayout}>
478
487
 
479
- <Row p={2} alignItems="center">
488
+ <Row p={2} alignItems="center" justifyContent="flex-end">
489
+ {/* <Text mr={2} fontSize={18}>{editorModeF} Mode</Text> */}
480
490
  {isSingle && editorMode === EDITOR_MODE__EDIT && onBack &&
481
491
  <Button
482
492
  key="backBtn"
@@ -484,8 +494,18 @@ function Form(props) {
484
494
  leftIcon={<Icon as={AngleLeft} color="#fff" size="sm" />}
485
495
  color="#fff"
486
496
  >Back</Button>}
487
- <Text ml={2} fontSize={18}>{editorModeF} Mode</Text>
497
+ {isSingle && editorMode === EDITOR_MODE__EDIT && onViewMode &&
498
+ <Button
499
+ key="viewBtn"
500
+ onPress={onViewMode}
501
+ leftIcon={<Icon as={Eye} color="#fff" size="sm" />}
502
+ color="#fff"
503
+ >View</Button>}
488
504
  </Row>
505
+ {!_.isEmpty(additionalButtons) &&
506
+ <Row p={2} alignItems="center" justifyContent="flex-end">
507
+ {additionalButtons}
508
+ </Row>}
489
509
 
490
510
  {editor}
491
511
 
@@ -493,7 +513,12 @@ function Form(props) {
493
513
  <Button.Group space={2} {...buttonGroupProps}>
494
514
  {!isViewOnly && <IconButton
495
515
  key="resetBtn"
496
- onPress={() => reset()}
516
+ onPress={() => {
517
+ if (onReset) {
518
+ onReset();
519
+ }
520
+ reset();
521
+ }}
497
522
  icon={<Rotate color="#fff" />}
498
523
  />}
499
524
  {!isViewOnly && onCancel && <Button
@@ -17,344 +17,385 @@ import _ from 'lodash';
17
17
 
18
18
  // Filters only work with Repository; not data array
19
19
 
20
+ // Yet to do:
21
+ // - Save user choice in cookie for next time this component loads
22
+ //
23
+ // Model defaultFilters should adjust to this new arrangement
24
+
20
25
  export default function withFilters(WrappedComponent) {
21
26
  return (props) => {
22
27
  const {
28
+ // config
23
29
  useFilters = false,
24
30
  searchAllText = true,
25
31
  showLabels = true,
26
- filter1StartingField = '',
27
- filter2StartingField = '',
28
- filter3StartingField = '',
29
- filter4StartingField = '',
30
- filter5StartingField = '',
31
- filter1StartingValue = null,
32
- filter2StartingValue = null,
33
- filter3StartingValue = null,
34
- filter4StartingValue = null,
35
- filter5StartingValue = null,
32
+ showFilterSelector = true,
33
+ defaultFilters = [], // likely a list of field names, possibly could be of shape below
34
+ customFilters = [], // of shape: { title, type, field, value, getRepoFilters(value) }
35
+ minFilters = 3,
36
+ maxFilters = 6,
36
37
 
37
38
  // withData
38
39
  Repository,
39
- } = props,
40
- styles = UiGlobals.styles;
40
+ } = props;
41
41
 
42
- let modal,
42
+ let modal = null,
43
43
  topToolbar = null;
44
44
 
45
45
  if (useFilters && Repository) {
46
+
46
47
  const
48
+ // aliases
49
+ {
50
+ defaultFilters: modelDefaultFilters,
51
+ filterTypes: modelFilterTypes,
52
+ titles: modelTitles,
53
+ virtualFields: modelVirtualFields,
54
+ excludeFields: modelExcludeFields,
55
+ filteringDisabled: modelFilteringDisabled,
56
+ } = Repository.getSchema().model,
57
+
58
+ // determine the starting filters
59
+ startingFilters = !_.isEmpty(customFilters) ? customFilters : // custom filters override component filters
60
+ !_.isEmpty(defaultFilters) ? defaultFilters : // component filters override model filters
61
+ !_.isEmpty(modelDefaultFilters) ? modelDefaultFilters : [],
62
+ isUsingCustomFilters = startingFilters === customFilters,
47
63
  [isReady, setIsReady] = useState(false),
48
64
  [isFilterSelectorShown, setIsFilterSelectorShown] = useState(false),
49
- [filter1Field, setFilter1Field] = useState(filter1StartingField || Repository?.getSchema().model.defaultFilters?.[0] || null),
50
- [filter2Field, setFilter2Field] = useState(filter2StartingField || Repository?.getSchema().model.defaultFilters?.[1] || null),
51
- [filter3Field, setFilter3Field] = useState(filter3StartingField || Repository?.getSchema().model.defaultFilters?.[2] || null),
52
- [filter4Field, setFilter4Field] = useState(filter4StartingField || Repository?.getSchema().model.defaultFilters?.[3] || null),
53
- [filter5Field, setFilter5Field] = useState(filter5StartingField || Repository?.getSchema().model.defaultFilters?.[4] || null),
54
- [filter1FieldForModal, setFilter1FieldForModal] = useState(filter1Field),
55
- [filter2FieldForModal, setFilter2FieldForModal] = useState(filter2Field),
56
- [filter3FieldForModal, setFilter3FieldForModal] = useState(filter3Field),
57
- [filter4FieldForModal, setFilter4FieldForModal] = useState(filter4Field),
58
- [filter5FieldForModal, setFilter5FieldForModal] = useState(filter5Field),
59
- [filterQValue, setFilterQValue] = useState(null),
60
- [filter1Value, setFilter1Value] = useState(filter1StartingValue),
61
- [filter2Value, setFilter2Value] = useState(filter2StartingValue),
62
- [filter3Value, setFilter3Value] = useState(filter3StartingValue),
63
- [filter4Value, setFilter4Value] = useState(filter4StartingValue),
64
- [filter5Value, setFilter5Value] = useState(filter5StartingValue),
65
- [filterFields, setFilterFields] = useState([]),
66
- onFilterChange = (ix, value) => {
67
- switch(ix) {
68
- case 'q':
69
- setFilterQValue(value);
70
- break;
71
- case 0:
72
- setFilter1Value(value);
73
- break;
74
- case 1:
75
- setFilter2Value(value);
76
- break;
77
- case 2:
78
- setFilter3Value(value);
79
- break;
80
- case 3:
81
- setFilter4Value(value);
82
- break;
83
- case 4:
84
- setFilter5Value(value);
85
- break;
86
- default:
65
+ getFormattedFilter = (filter) => {
66
+ let formatted = null;
67
+ if (_.isString(filter)) {
68
+ const field = filter;
69
+ formatted = {
70
+ field,
71
+ title: modelTitles[field],
72
+ type: modelFilterTypes[field],
73
+ value: null, // value starts as null
74
+ };
75
+ } else if (_.isPlainObject(filter)) {
76
+ // already formatted
77
+ formatted = filter;
87
78
  }
88
- },
89
- getFilterType = (ix) => {
79
+ return formatted;
80
+ };
81
+
82
+ let formattedStartingFilters = [],
83
+ startingSlots = [];
84
+ if (!isReady) {
85
+ // Generate initial starting state
86
+ if (searchAllText) {
87
+ formattedStartingFilters.push({ field: 'q', title: 'Search all text fields', type: 'Input', value: null, });
88
+ }
89
+ _.each(startingFilters, (filter) => {
90
90
  const
91
- filterField = getFilterField(ix),
92
- filterTypeDefinition = Repository.getSchema().model.filterTypes[filterField];
93
- let filterType;
94
- if (_.isString(filterTypeDefinition)) {
95
- filterType = filterTypeDefinition;
96
- } else {
97
- filterType = filterTypeDefinition.type;
91
+ formattedFilter = getFormattedFilter(filter),
92
+ field = formattedFilter.field;
93
+ formattedStartingFilters.push(formattedFilter);
94
+ if (!isUsingCustomFilters) {
95
+ startingSlots.push(field);
98
96
  }
99
- return filterType;
100
- },
101
- getFilterField = (ix) => {
102
- let field;
103
- switch(ix) {
104
- case 0:
105
- field = filter1Field;
106
- break;
107
- case 1:
108
- field = filter2Field;
109
- break;
110
- case 2:
111
- field = filter3Field;
112
- break;
113
- case 3:
114
- field = filter4Field;
115
- break;
116
- case 4:
117
- field = filter5Field;
118
- break;
119
- default:
97
+ });
98
+ if (startingSlots.length < minFilters) {
99
+ for (let i = startingSlots.length; i < minFilters; i++) {
100
+ startingSlots.push(null);
120
101
  }
121
- return field;
102
+ }
103
+ }
104
+
105
+ const
106
+ [filters, setFilters] = useState(formattedStartingFilters), // array of formatted filters
107
+ [slots, setSlots] = useState(startingSlots), // array of field names user is currently filtering on; blank slots have a null entry in array
108
+ [modalFilters, setModalFilters] = useState([]),
109
+ [modalSlots, setModalSlots] = useState([]),
110
+ [previousFilterNames, setPreviousFilterNames] = useState([]), // names of filters the repository used last query
111
+ canAddSlot = (() => {
112
+ let canAdd = true;
113
+ if (!!maxFilters && modalSlots.length >= maxFilters) {
114
+ canAdd = false; // maxFilters has been reached
115
+ }
116
+ if (canAdd) {
117
+ _.each(modalSlots, (field) => {
118
+ if (_.isNil(field)) {
119
+ canAdd = false; // at least one slot has no selected field to filter
120
+ return false;
121
+ }
122
+ });
123
+ }
124
+ return canAdd;
125
+ })(),
126
+ canDeleteSlot = modalSlots.length > minFilters,
127
+ onAddSlot = () => {
128
+ if (!canAddSlot) {
129
+ return;
130
+ }
131
+ const newSlots = _.clone(modalSlots);
132
+ newSlots.push(null);
133
+ setModalSlots(newSlots);
122
134
  },
123
- getFilterValue = (ix) => {
124
- let value;
125
- switch(ix) {
126
- case 'q':
127
- value = filterQValue;
128
- break;
129
- case 0:
130
- value = filter1Value;
131
- break;
132
- case 1:
133
- value = filter2Value;
134
- break;
135
- case 2:
136
- value = filter3Value;
137
- break;
138
- case 3:
139
- value = filter4Value;
140
- break;
141
- case 4:
142
- value = filter5Value;
143
- break;
144
- default:
135
+ onDeleteSlot = () => {
136
+ if (!canDeleteSlot) {
137
+ return;
145
138
  }
146
- return value;
139
+ const
140
+ newFilters = _.clone(modalFilters),
141
+ newSlots = _.clone(modalSlots);
142
+ newFilters.pop();
143
+ newSlots.pop();
144
+ setModalFilters(newFilters);
145
+ setModalSlots(newSlots);
146
+ },
147
+ onFilterChangeValue = (field, value) => {
148
+ // handler for when a filter value changes
149
+ const newFilters = [];
150
+ _.each(filters, (filter) => {
151
+ if (filter.field === field) {
152
+ filter.value = value;
153
+ }
154
+ newFilters.push(filter);
155
+ });
156
+ setFilters(newFilters);
157
+ },
158
+ onClearFilters = () => {
159
+ // Clears values for all active filters
160
+ const newFilters = [];
161
+ _.each(filters, (filter) => {
162
+ filter.value = null;
163
+ newFilters.push(filter);
164
+ });
165
+ setFilters(newFilters);
147
166
  },
148
- getIsFilterRange = (ix) => {
149
- const filterType = getFilterType(0);
167
+ getFilterByField = (field) => {
168
+ return _.find(filters, (filter) => {
169
+ return filter.field === field;
170
+ });
171
+ },
172
+ getFilterValue = (field) => {
173
+ const filter = getFilterByField(field);
174
+ return filter?.value;
175
+ },
176
+ getFilterType = (field) => {
177
+ // Finds filter type for the field name, from active filters
178
+ const filter = getFilterByField(field);
179
+ return filter?.type;
180
+ },
181
+ getIsFilterRange = (field) => {
182
+ // determines if filter is a "range" filter
183
+ const filterType = getFilterType(field);
150
184
  return inArray(filterType, ['NumberRange', 'DateRange']);
151
185
  },
152
186
  renderFilters = () => {
153
- if (!Repository) {
154
- return null;
155
- }
156
-
157
187
  const
158
- {
159
- titles = [],
160
- filterTypes = [],
161
- virtualFields = [],
162
- excludeFields = [],
163
- } = Repository.getSchema().model,
164
188
  filterProps = {
165
189
  mx: 1,
166
190
  },
167
- filterElements = [],
168
- addFilter = (fieldName, ix) => {
169
- if (ix === 'q') {
170
- // special case
171
- const Element = getComponentFromType('Input');
172
- filterElements.push(<Element
173
- key={ix}
174
- tooltip="Search all text fields"
175
- placeholder="All text fields"
176
- value={getFilterValue(ix)}
177
- autoSubmit={true}
178
- onChangeValue={(value) => onFilterChange(ix, value)}
179
- {...filterProps}
180
- />);
181
- return;
182
- }
183
- if (inArray(fieldName, virtualFields) || inArray(fieldName, excludeFields)) {
184
- return; // skip
185
- }
186
- const filterType = filterTypes[fieldName];
187
- let Element,
188
- modelProps = {};
189
- if (_.isString(filterType)) {
190
- Element = getComponentFromType(filterType);
191
- } else if (_.isPlainObject(filterType)) {
192
- const {
193
- type,
194
- ...p
195
- } = filterType;
196
- modelProps = p;
197
- Element = getComponentFromType(type);
198
- }
199
- if (!Element) {
200
- debugger;
191
+ filterElements = [];
192
+ _.each(filters, (filter, ix) => {
193
+ let Element,
194
+ elementProps = {};
195
+ const {
196
+ field,
197
+ type: filterType,
198
+ } = filter;
199
+
200
+ if (_.isString(filterType)) {
201
+ Element = getComponentFromType(filterType);
202
+ if (filterType === 'Input') {
203
+ elementProps.autoSubmit = true;
201
204
  }
202
- let filterElement = <Element
203
- key={'element-' + ix}
204
- tooltip={titles[fieldName]}
205
- placeholder={titles[fieldName]}
206
- value={getFilterValue(ix)}
207
- onChangeValue={(value) => onFilterChange(ix, value)}
208
- {...filterProps}
209
- {...modelProps}
210
- />;
211
- if (showLabels) {
212
- filterElement = <Row key={'label-' + ix} alignItems="center">
213
- <Text ml={2} mr={1} fontSize={styles.FILTER_LABEL_FONTSIZE}>{titles[fieldName]}</Text>
214
- {filterElement}
215
- </Row>;
205
+ } else if (_.isPlainObject(filterType)) {
206
+ const {
207
+ type,
208
+ ...p
209
+ } = filterType;
210
+ elementProps = p;
211
+ Element = getComponentFromType(type);
212
+ if (type === 'Input') {
213
+ elementProps.autoSubmit = true;
216
214
  }
217
- filterElements.push(filterElement);
218
- };
219
- if (searchAllText) {
220
- addFilter(null, 'q');
221
- }
222
- if (filter1Field) {
223
- addFilter(filter1Field, 0);
224
- }
225
- if (filter2Field) {
226
- addFilter(filter2Field, 1);
227
- }
228
- if (filter3Field) {
229
- addFilter(filter3Field, 2);
230
- }
231
- if (filter4Field) {
232
- addFilter(filter4Field, 3);
233
- }
234
- if (filter5Field) {
235
- addFilter(filter5Field, 4);
236
- }
237
-
238
- filterElements.push(<IconButton
239
- key="clear"
240
- _icon={{
241
- as: Ban,
242
- }}
243
- ml={1}
244
- onPress={onClearFilters}
245
- tooltip="Clear all filters"
246
- />);
247
- filterElements.push(<IconButton
248
- key="gear"
249
- _icon={{
250
- as: Gear,
251
- }}
252
- ml={1}
253
- onPress={() => setIsFilterSelectorShown(true)}
254
- tooltip="Swap filters"
255
- />);
256
-
215
+ }
216
+ if (field === 'q') {
217
+ elementProps.flex = 1;
218
+ elementProps.minWidth = 100;
219
+ }
220
+
221
+ const tooltip = filter.tooltip || filter.title || modelTitles[filter.field];
222
+ let filterElement = <Element
223
+ key={'filter-' + field}
224
+ tooltip={tooltip}
225
+ placeholder={tooltip}
226
+ value={getFilterValue(field)}
227
+ onChangeValue={(value) => onFilterChangeValue(field, value)}
228
+ {...filterProps}
229
+ {...elementProps}
230
+ />;
231
+ if (showLabels && field !== 'q') {
232
+ filterElement = <Row key={'label-' + ix} alignItems="center">
233
+ <Text ml={2} mr={1} fontSize={UiGlobals.styles.FILTER_LABEL_FONTSIZE}>{modelTitles[field]}</Text>
234
+ {filterElement}
235
+ </Row>;
236
+ }
237
+ filterElements.push(filterElement);
238
+ });
257
239
  return filterElements;
258
- },
259
- setFiltersOn = (ix, filters, newFilterFields) => {
260
- const
261
- filterIxField = getFilterField(ix),
262
- filterIxValue = getFilterValue(ix),
263
- isFilterRange = getIsFilterRange(ix);
264
- let highValue,
265
- lowValue,
266
- highField,
267
- lowField;
268
- if (isFilterRange && !!filterIxValue) {
269
- highValue = filterIxValue.high;
270
- lowValue = filterIxValue.low;
271
- highField = filterIxField + ' <=';
272
- lowField = filterIxField + ' >=';
273
-
274
- newFilterFields.push(highField);
275
- newFilterFields.push(lowField);
276
- filters.push({ name: highField, value: highValue, });
277
- filters.push({ name: lowField, value: lowValue, });
278
- } else {
279
- newFilterFields.push(filterIxField);
280
- filters.push({ name: filterIxField, value: filterIxValue, });
281
- }
282
- },
283
- onClearFilters = () => {
284
- setFilterQValue(null);
285
- setFilter1Value(null);
286
- setFilter2Value(null);
287
- setFilter3Value(null);
288
- setFilter4Value(null);
289
- setFilter5Value(null);
290
240
  };
291
241
 
292
242
  useEffect(() => {
293
- const
294
- filters = [],
295
- newFilterFields = [];
243
+ // Whenever the filters change in some way, make repository conform to these new filters
244
+ const newRepoFilters = [];
296
245
 
297
- // For each filter field that is set, add a real filter for it
298
- if (filter1Field) {
299
- setFiltersOn(0, filters, newFilterFields);
300
- }
301
- if (filter2Field) {
302
- setFiltersOn(1, filters, newFilterFields);
303
- }
304
- if (filter3Field) {
305
- setFiltersOn(2, filters, newFilterFields);
306
- }
307
- if (filter4Field) {
308
- setFiltersOn(3, filters, newFilterFields);
309
- }
310
- if (filter5Field) {
311
- setFiltersOn(4, filters, newFilterFields);
312
- }
313
- if (searchAllText && !_.isEmpty(filterQValue)) {
314
- const q = 'q';
315
- newFilterFields.push(q);
316
- filters.push({ name: q, value: filterQValue, });
317
- if (Repository.searchAncillary && !Repository.hasBaseParam('searchAncillary')) {
318
- Repository.setBaseParam('searchAncillary', true);
319
- }
246
+ if (isUsingCustomFilters) {
247
+ _.each(filters, (filter) => {
248
+ const repoFiltersFromFilter = filter.getRepoFilters(value);
249
+ _.each(repoFiltersFromFilter, (repoFilter) => { // one custom filter might generate multiple filters for the repository
250
+ newRepoFilters.push(repoFilter);
251
+ });
252
+ });
253
+ } else {
254
+ const newFilterNames = [];
255
+ _.each(filters, (filter) => {
256
+ const {
257
+ field,
258
+ value,
259
+ } = filter,
260
+ isFilterRange = getIsFilterRange(field);
261
+ if (isFilterRange) {
262
+ if (!!value) {
263
+ const
264
+ highField = field + ' <=',
265
+ lowField = field + ' >=',
266
+ highValue = value.high,
267
+ lowValue = value.low;
268
+ newFilterNames.push(highField);
269
+ newFilterNames.push(lowField);
270
+ newRepoFilters.push({ name: highField, value: highValue, });
271
+ newRepoFilters.push({ name: lowField, value: lowValue, });
272
+ }
273
+ } else {
274
+ newFilterNames.push(field);
275
+ newRepoFilters.push({ name: field, value, });
276
+ }
277
+ });
278
+
279
+ // Go through previousFilterNames and see if any are no longer used.
280
+ _.each(previousFilterNames, (name) => {
281
+ if (!inArray(name, newFilterNames)) {
282
+ newRepoFilters.push({ name, value: null, }); // no longer used, so set it to null so it'll be deleted
283
+ }
284
+ });
285
+ setPreviousFilterNames(newFilterNames);
320
286
  }
321
- setFilterFields(newFilterFields);
322
287
 
323
- // Go through previous filterFields see if any are no longer used. If no longer used, set it to null so it'll be deleted
324
- _.each(filterFields, (filterField) => {
325
- if (!inArray(filterField, newFilterFields)) {
326
- filters.push({ name: filterField, value: null, });
327
- }
328
- });
329
-
330
- Repository.filter(filters, null, false); // false so other filters remain
288
+ Repository.filter(newRepoFilters, null, false); // false so other filters remain
289
+
290
+ if (searchAllText && Repository.searchAncillary && !Repository.hasBaseParam('searchAncillary')) {
291
+ Repository.setBaseParam('searchAncillary', true);
292
+ }
331
293
 
332
294
  if (!isReady) {
333
295
  setIsReady(true);
334
296
  }
335
297
 
336
- }, [filter1Field, filter2Field, filter3Field, filter4Field, filter5Field,
337
- filter1Value, filter2Value, filter3Value, filter4Value, filter5Value,
338
- filterQValue,]);
298
+ }, [filters]);
339
299
 
340
300
  if (!isReady) {
341
301
  return null;
342
302
  }
343
303
 
304
+ const
305
+ renderedFilters = renderFilters(),
306
+ hasFilters = !!renderedFilters.length;
307
+ topToolbar = <Toolbar justifyContent="space-between" alignItems="center">
308
+ <Text pr={2} userSelect="none">Filters:{hasFilters ? '' : ' None'}</Text>
309
+ {renderedFilters}
310
+ <Row flex={hasFilters ? null : 1} justifyContent="flex-end">
311
+ <IconButton
312
+ key="clear"
313
+ _icon={{
314
+ as: Ban,
315
+ }}
316
+ ml={1}
317
+ onPress={onClearFilters}
318
+ tooltip="Clear all filters"
319
+ />
320
+ {showFilterSelector && !isUsingCustomFilters && <IconButton
321
+ key="gear"
322
+ _icon={{
323
+ as: Gear,
324
+ }}
325
+ ml={1}
326
+ onPress={() => {
327
+ setModalFilters(filters);
328
+ setModalSlots(slots);
329
+ setIsFilterSelectorShown(true);
330
+ }}
331
+ tooltip="Swap filters"
332
+ />}
333
+ </Row>
334
+ </Toolbar>;
344
335
 
345
- let filterComboProps = {};
346
- if (Repository?.getSchema().model.titles) {
347
- filterComboProps.data = [];
348
- const schemaModel = Repository.getSchema().model;
349
- _.each(schemaModel.titles, (title, fieldName) => {
350
- if (!inArray(fieldName, schemaModel.virtualFields) && !inArray(fieldName, schemaModel.excludeFields) && !inArray(fieldName, schemaModel.filteringDisabled)) {
351
- filterComboProps.data.push([fieldName, title]);
352
- }
336
+ if (isFilterSelectorShown) { // this is always false when isUsingCustomFilters
337
+ // Build the modal to select the filters
338
+ const
339
+ modalFilterElements = [],
340
+ usedFields = _.filter(_.map(modalFilters, (filter) => {
341
+ return filter?.field;
342
+ }), el => !_.isNil(el)),
343
+ formStartingValues = {};
344
+
345
+ _.each(modalSlots, (field, ix) => {
346
+
347
+ // Create the data for the combobox.
348
+ const data = [];
349
+ _.each(modelFilterTypes, (filterType, filterField) => {
350
+ if (inArray(filterField, usedFields) && field !== filterField) { // Show all filters not yet applied, but include the current filter
351
+ return; // skip, since it's already been used
352
+ }
353
+ data.push([ filterField, modelTitles[filterField] ]);
354
+ });
355
+
356
+ const
357
+ ixPlusOne = (ix +1),
358
+ filterName = 'filter' + ixPlusOne;
359
+
360
+ modalFilterElements.push({
361
+ key: filterName,
362
+ name: filterName,
363
+ type: 'Combo',
364
+ label: 'Filter ' + ixPlusOne,
365
+ data,
366
+ onChange: (value) => {
367
+ const
368
+ newFilters = _.clone(modalFilters),
369
+ newSlots = _.clone(modalSlots),
370
+ i = searchAllText ? ixPlusOne : ix; // compensate for 'q' filter's possible presence
371
+
372
+ if (newFilters[i]?.value) {
373
+ newFilters[i].value = value;
374
+ } else {
375
+ newFilters[i] = getFormattedFilter(value);
376
+ }
377
+ newSlots[ix] = value;
378
+
379
+ setModalFilters(newFilters);
380
+ setModalSlots(newSlots);
381
+ },
382
+ });
383
+
384
+ formStartingValues[filterName] = field;
353
385
  });
354
- topToolbar = <Toolbar justifyContent="space-between"><Text pt={2} pr={2} userSelect="none">Filters:</Text>{renderFilters()}</Toolbar>;
355
- }
356
386
 
357
- if (isFilterSelectorShown) {
387
+ if (canAddSlot || canDeleteSlot) {
388
+ modalFilterElements.push({
389
+ type: 'PlusMinusButton',
390
+ name: 'plusMinusButton',
391
+ plusHandler: onAddSlot,
392
+ minusHandler: onDeleteSlot,
393
+ isPlusDisabled: !canAddSlot,
394
+ isMinusDisabled: !canDeleteSlot,
395
+ justifyContent: 'flex-end',
396
+ });
397
+ }
398
+
358
399
  modal = <Modal
359
400
  isOpen={true}
360
401
  onClose={() => setIsFilterSelectorShown(false)}
@@ -362,99 +403,60 @@ export default function withFilters(WrappedComponent) {
362
403
  <Column bg="#fff" w={500}>
363
404
  <FormPanel
364
405
  title="Filter Selector"
365
- instructions="Please select which fields to filter by. You may select up to five filters. Leave blank for no filter."
406
+ instructions="Please select which fields to filter by."
366
407
  flex={1}
367
- startingValues={{
368
- filter1: filter1Field,
369
- filter2: filter2Field,
370
- filter3: filter3Field,
371
- filter4: filter4Field,
372
- filter5: filter5Field,
373
- }}
408
+ startingValues={formStartingValues}
374
409
  items={[
375
410
  {
376
411
  type: 'Column',
377
412
  flex: 1,
378
- items: [
379
- {
380
- type: 'Combo',
381
- label: 'Filter 1',
382
- name: 'filter1',
383
- onChangeValue: (value) => {
384
- setFilter1FieldForModal(value);
385
- },
386
- ...filterComboProps,
387
- },
388
- {
389
- type: 'Combo',
390
- label: 'Filter 2',
391
- name: 'filter2',
392
- onChangeValue: (value) => {
393
- setFilter2FieldForModal(value);
394
- },
395
- ...filterComboProps,
396
- },
397
- {
398
- type: 'Combo',
399
- label: 'Filter 3',
400
- name: 'filter3',
401
- onChangeValue: (value) => {
402
- setFilter3FieldForModal(value);
403
- },
404
- ...filterComboProps,
405
- },
406
- {
407
- type: 'Combo',
408
- label: 'Filter 4',
409
- name: 'filter4',
410
- onChangeValue: (value) => {
411
- setFilter4FieldForModal(value);
412
- },
413
- ...filterComboProps,
414
- },
415
- {
416
- type: 'Combo',
417
- label: 'Filter 5',
418
- name: 'filter5',
419
- onChangeValue: (value) => {
420
- setFilter5FieldForModal(value);
421
- },
422
- ...filterComboProps,
423
- },
424
- ],
425
- }, // END Column
413
+ items: modalFilterElements,
414
+ },
426
415
  ]}
427
416
  onCancel={(e) => {
428
- setFilter1FieldForModal(filter1Field);
429
- setFilter2FieldForModal(filter2Field);
430
- setFilter3FieldForModal(filter3Field);
431
- setFilter4FieldForModal(filter4Field);
432
- setFilter5FieldForModal(filter5Field);
417
+ // Just close the modal
433
418
  setIsFilterSelectorShown(false);
434
419
  }}
435
420
  onSave={(data, e) => {
436
- if (filter1FieldForModal !== filter1Field) {
437
- setFilter1Field(filter1FieldForModal);
438
- setFilter1Value(null);
439
- }
440
- if (filter2FieldForModal !== filter2Field) {
441
- setFilter2Field(filter2FieldForModal);
442
- setFilter2Value(null);
443
- }
444
- if (filter3FieldForModal !== filter3Field) {
445
- setFilter3Field(filter3FieldForModal);
446
- setFilter3Value(null);
447
- }
448
- if (filter4FieldForModal !== filter4Field) {
449
- setFilter4Field(filter4FieldForModal);
450
- setFilter4Value(null);
421
+ // Conform filters to this new choice of filters
422
+
423
+ const
424
+ newFilters = [],
425
+ newSlots = [];
426
+
427
+ if (searchAllText) {
428
+ newFilters.push(filters[0]);
451
429
  }
452
- if (filter5FieldForModal !== filter5Field) {
453
- setFilter5Field(filter5FieldForModal);
454
- setFilter5Value(null);
430
+
431
+ // Conform the filters to the modal selection
432
+ _.each(data, (field, ix) => {
433
+ if (_.isEmpty(field) || !ix.match(/^filter/)) {
434
+ return;
435
+ }
436
+
437
+ const newFilter = getFormattedFilter(field);
438
+
439
+ newFilters.push(newFilter);
440
+ newSlots.push(field);
441
+ });
442
+
443
+ if (newSlots.length < minFilters) {
444
+ // Add more slots until we get to minFilters
445
+ for(let i = newSlots.length; i < minFilters; i++) {
446
+ newSlots.push(null);
447
+ }
455
448
  }
449
+
450
+ setFilters(newFilters);
451
+ setSlots(newSlots);
452
+
453
+ // Close the modal
456
454
  setIsFilterSelectorShown(false);
457
455
  }}
456
+ onReset={() => {
457
+ setModalFilters(filters);
458
+ setModalSlots(slots);
459
+ }}
458
460
  />
459
461
  </Column>
460
462
  </Modal>;
@@ -16,6 +16,8 @@ import PageSizeCombo from '../Form/Field/Combo/PageSizeCombo.js';
16
16
 
17
17
  export default function Pagination(props) {
18
18
  const {
19
+ minimize = false,
20
+
19
21
  // withData
20
22
  Repository,
21
23
  } = props,
@@ -70,23 +72,25 @@ export default function Pagination(props) {
70
72
  onPress={() => Repository.prevPage()}
71
73
  tooltip="Previous Page"
72
74
  />);
73
- items.push(<Row
74
- key="pageSelector"
75
- mx={3}
76
- justifyContent="center"
77
- alignItems="center"
78
- >
79
- <Text mr={2}>Page</Text>
80
- <Input
81
- value={page}
82
- onChangeValue={(value) => Repository.setPage(value)}
83
- maxValue={totalPages}
84
- isDisabled={totalPages === 1}
85
- w={10}
86
- tooltip="Set Page"
87
- />
88
- <Text ml={2}>of {totalPages}</Text>
89
- </Row>);
75
+ if (!minimize) {
76
+ items.push(<Row
77
+ key="pageSelector"
78
+ mx={3}
79
+ justifyContent="center"
80
+ alignItems="center"
81
+ >
82
+ <Text mr={2}>Page</Text>
83
+ <Input
84
+ value={page}
85
+ onChangeValue={(value) => Repository.setPage(value)}
86
+ maxValue={totalPages}
87
+ isDisabled={totalPages === 1}
88
+ w={10}
89
+ tooltip="Set Page"
90
+ />
91
+ <Text ml={2}>of {totalPages}</Text>
92
+ </Row>);
93
+ }
90
94
 
91
95
  isDisabled = page === totalPages || totalPages <= 1;
92
96
  items.push(<IconButton
@@ -115,7 +119,9 @@ export default function Pagination(props) {
115
119
  />);
116
120
  }
117
121
 
118
- items.push(<PageSizeCombo key="pageSize" pageSize={pageSize} Repository={Repository} />);
122
+ if (!minimize) {
123
+ items.push(<PageSizeCombo key="pageSize" pageSize={pageSize} Repository={Repository} />);
124
+ }
119
125
 
120
126
  let pageSpan = `${pageStart} – ${pageEnd}`;
121
127
  if (pageStart === pageEnd) {
@@ -130,7 +136,7 @@ export default function Pagination(props) {
130
136
  {...props}
131
137
  >
132
138
  {items}
133
- <Text ml={3}>Displaying {pageSpan} of {total}</Text>
139
+ {!minimize && <Text ml={3}>Displaying {pageSpan} of {total}</Text>}
134
140
  </Row>;
135
141
  }, [
136
142
  // Repository,
@@ -140,6 +146,7 @@ export default function Pagination(props) {
140
146
  totalPages,
141
147
  pageStart,
142
148
  pageEnd,
149
+ minimize,
143
150
  ])
144
151
 
145
152
 
@@ -1,3 +1,4 @@
1
+ import { useState } from 'react';
1
2
  import {
2
3
  Row,
3
4
  } from 'native-base';
@@ -9,14 +10,34 @@ export default function PaginationToolbar(props) {
9
10
  const {
10
11
  toolbarItems = [],
11
12
  } = props,
12
- propsToPass = _.omit(props, 'toolbarItems');
13
+ [minimize, setMinimize] = useState(false),
14
+ propsToPass = _.omit(props, 'toolbarItems'),
15
+ onLayout = (e) => {
16
+ // Note to future self: this is using hard-coded values.
17
+ // Eventually might want to make it responsive to actual sizes
18
+
19
+ // Also, eventually might useMediaQuery from NativeBase, but ReactNative is not yet supported,
20
+ // so have to do things the long way.
21
+ const
22
+ width = e.nativeEvent.layout.width,
23
+ pagingToolbarMinwidth = 576,
24
+ toolbarItemsMinwidth = 45 * toolbarItems.length,
25
+ threshold = pagingToolbarMinwidth + toolbarItemsMinwidth,
26
+ shouldMinimize = width < threshold;
27
+
28
+ if (shouldMinimize !== minimize) {
29
+ setMinimize(shouldMinimize);
30
+ }
31
+ };
32
+
13
33
  return <Toolbar
14
34
  bg="trueGray.200"
15
35
  borderTopWidth={1}
16
36
  borderTopColor="trueGray.400"
17
37
  w="100%"
38
+ onLayout={(e) => onLayout(e)}
18
39
  >
19
- <Pagination {...propsToPass} w={toolbarItems.length ? null : '100%'} />
40
+ <Pagination {...propsToPass} w={toolbarItems.length ? null : '100%'} minimize={minimize} />
20
41
  {toolbarItems.length ? <Row flex={1} borderLeftWidth={1} borderLeftColor="trueGray.400" pl={3} ml={3}>{toolbarItems}</Row> : null}
21
42
  </Toolbar>;
22
43
  };
@@ -35,6 +35,7 @@ import Number from './Form/Field/Number.js';
35
35
  import NumberRange from './Filter/NumberRange.js';
36
36
  import Panel from './Panel/Panel.js';
37
37
  // import Picker from '../Components/Panel/Picker.js';
38
+ import PlusMinusButton from './Buttons/PlusMinusButton.js';
38
39
  import RadioGroup from './Form/Field/RadioGroup/RadioGroup.js';
39
40
  import TabPanel from './Panel/TabPanel.js';
40
41
  import Tag from './Form/Field/Combo/Tag.js';
@@ -82,6 +83,7 @@ const components = {
82
83
  NumberRange,
83
84
  Panel,
84
85
  // Picker,
86
+ PlusMinusButton,
85
87
  RadioGroup,
86
88
  TabPanel,
87
89
  Tag,