1
- import queryString from 'query-string' ;
2
- import debounce from 'just-debounce-it' ;
3
1
import { readableColor } from 'color2k' ;
2
+ import debounce from 'just-debounce-it' ;
3
+ import queryString from 'query-string' ;
4
4
import SlimSelect from 'slim-select' ;
5
- import { createToast } from '../bs' ;
6
- import { hasUrl , hasExclusions , isTrigger } from './util' ;
5
+ import { createToast } from '../../bs' ;
6
+ import { hasUrl , hasExclusions , isTrigger } from '../util' ;
7
+ import { DynamicParamsMap } from './dynamicParams' ;
8
+ import { isStaticParams } from './types' ;
7
9
import {
8
- isTruthy ,
9
10
hasMore ,
11
+ isTruthy ,
10
12
hasError ,
11
13
getElement ,
12
14
getApiData ,
13
15
isApiError ,
14
- getElements ,
15
16
createElement ,
16
17
uniqueByProperty ,
17
18
findFirstAdjacent ,
18
- } from '../util' ;
19
+ } from '../../ util' ;
19
20
20
21
import type { Stringifiable } from 'query-string' ;
21
22
import type { Option } from 'slim-select/dist/data' ;
22
-
23
- /**
24
- * Map of string keys to primitive array values accepted by `query-string`. Keys are used as
25
- * URL query parameter keys. Values correspond to query param values, enforced as an array
26
- * for easier handling. For example, a mapping of `{ site_id: [1, 2] }` is serialized by
27
- * `query-string` as `?site_id=1&site_id=2`. Likewise, `{ site_id: [1] }` is serialized as
28
- * `?site_id=1`.
29
- */
30
- type QueryFilter = Map < string , Stringifiable [ ] > ;
31
-
32
- /**
33
- * Map of string keys to primitive values. Used to track variables within URLs from the server. For
34
- * example, `/api/$key/thing`. `PathFilter` tracks `$key` as `{ key: '' }` in the map, and when the
35
- * value is later known, the value is set — `{ key: 'value' }`, and the URL is transformed to
36
- * `/api/value/thing`.
37
- */
38
- type PathFilter = Map < string , Stringifiable > ;
39
-
40
- /**
41
- * Merge or replace incoming options with current options.
42
- */
43
- type ApplyMethod = 'merge' | 'replace' ;
44
-
45
- /**
46
- * Trigger for which the select instance should fetch its data from the NetBox API.
47
- */
48
- export type Trigger =
49
- /**
50
- * Load data when the select element is opened.
51
- */
52
- | 'open'
53
- /**
54
- * Load data when the element is loaded.
55
- */
56
- | 'load'
57
- /**
58
- * Load data when a parent element is uncollapsed.
59
- */
60
- | 'collapse' ;
61
-
62
- // Various one-off patterns to replace in query param keys.
63
- const REPLACE_PATTERNS = [
64
- // Don't query `termination_a_device=1`, but rather `device=1`.
65
- [ new RegExp ( / t e r m i n a t i o n _ ( a | b ) _ ( .+ ) / g) , '$2' ] ,
66
- // A tenant's group relationship field is `group`, but the field name is `tenant_group`.
67
- [ new RegExp ( / t e n a n t _ ( g r o u p ) / g) , '$1' ] ,
68
- ] as [ RegExp , string ] [ ] ;
23
+ import type { Trigger , PathFilter , ApplyMethod , QueryFilter } from './types' ;
69
24
70
25
// Empty placeholder option.
71
26
const PLACEHOLDER = {
@@ -81,7 +36,7 @@ const DISABLED_ATTRIBUTES = ['occupied'] as string[];
81
36
* Manage a single API-backed select element's state. Each API select element is likely controlled
82
37
* or dynamically updated by one or more other API select (or static select) elements' values.
83
38
*/
84
- class APISelect {
39
+ export class APISelect {
85
40
/**
86
41
* Base `<select/>` DOM element.
87
42
*/
@@ -124,23 +79,31 @@ class APISelect {
124
79
*/
125
80
private readonly slim : InstanceType < typeof SlimSelect > ;
126
81
82
+ /**
83
+ * Post-parsed URL query parameters for API queries.
84
+ */
85
+ private readonly queryParams : QueryFilter = new Map ( ) ;
86
+
127
87
/**
128
88
* API query parameters that should be applied to API queries for this field. This will be
129
89
* updated as other dependent fields' values change. This is a mapping of:
130
90
*
131
- * Form Field Names → Form Field Values
91
+ * Form Field Names → Object containing:
92
+ * - Query parameter key name
93
+ * - Query value
132
94
*
133
- * This is/might be different than the query parameters themselves, as the form field names may
134
- * be different than the object model key names. For example, `tenant_group` would be the field
135
- * name, but `group` would be the query parameter. Query parameters themselves are tracked in
136
- * `queryParams`.
95
+ * This is different from `queryParams` in that it tracks all _possible_ related fields and their
96
+ * values, even if they are empty. Further, the keys in `queryParams` correspond to the actual
97
+ * query parameter keys, which are not necessarily the same as the form field names, depending on
98
+ * the model. For example, `tenant_group` would be the field name, but `group_id` would be the
99
+ * query parameter.
137
100
*/
138
- private readonly filterParams : QueryFilter = new Map ( ) ;
101
+ private readonly dynamicParams : DynamicParamsMap = new DynamicParamsMap ( ) ;
139
102
140
103
/**
141
- * Post-parsed URL query parameters for API queries .
104
+ * API query parameters that are already known by the server and should not change .
142
105
*/
143
- private readonly queryParams : QueryFilter = new Map ( ) ;
106
+ private readonly staticParams : QueryFilter = new Map ( ) ;
144
107
145
108
/**
146
109
* Mapping of URL template key/value pairs. If this element's URL contains Django template tags
@@ -228,20 +191,21 @@ class APISelect {
228
191
} ) ;
229
192
230
193
// Initialize API query properties.
231
- this . getFilteredBy ( ) ;
194
+ this . getStaticParams ( ) ;
195
+ this . getDynamicParams ( ) ;
232
196
this . getPathKeys ( ) ;
233
197
234
- for ( const filter of this . filterParams . keys ( ) ) {
235
- this . updateQueryParams ( filter ) ;
198
+ // Populate static query parameters.
199
+ for ( const [ key , value ] of this . staticParams . entries ( ) ) {
200
+ this . queryParams . set ( key , value ) ;
236
201
}
237
202
238
- // Add any already-resolved key/value pairs to the API query parameters.
239
- for ( const [ key , value ] of this . filterParams . entries ( ) ) {
240
- if ( isTruthy ( value ) ) {
241
- this . queryParams . set ( key , value ) ;
242
- }
203
+ // Populate dynamic query parameters with any form values that are already known.
204
+ for ( const filter of this . dynamicParams . keys ( ) ) {
205
+ this . updateQueryParams ( filter ) ;
243
206
}
244
207
208
+ // Populate dynamic path values with any form values that are already known.
245
209
for ( const filter of this . pathValues . keys ( ) ) {
246
210
this . updatePathValues ( filter ) ;
247
211
}
@@ -395,7 +359,8 @@ class APISelect {
395
359
396
360
// Create a unique iterator of all possible form fields which, when changed, should cause this
397
361
// element to update its API query.
398
- const dependencies = new Set ( [ ...this . filterParams . keys ( ) , ...this . pathValues . keys ( ) ] ) ;
362
+ // const dependencies = new Set([...this.filterParams.keys(), ...this.pathValues.keys()]);
363
+ const dependencies = new Set ( [ ...this . dynamicParams . keys ( ) , ...this . pathValues . keys ( ) ] ) ;
399
364
400
365
for ( const dep of dependencies ) {
401
366
const filterElement = document . querySelector ( `[name="${ dep } "]` ) ;
@@ -588,6 +553,7 @@ class APISelect {
588
553
this . updateQueryParams ( target . name ) ;
589
554
this . updatePathValues ( target . name ) ;
590
555
this . updateQueryUrl ( ) ;
556
+
591
557
// Load new data.
592
558
Promise . all ( [ this . loadData ( ) ] ) ;
593
559
}
@@ -655,27 +621,12 @@ class APISelect {
655
621
* Update an element's API URL based on the value of another element on which this element
656
622
* relies.
657
623
*
658
- * @param id DOM ID of the other element.
624
+ * @param fieldName DOM ID of the other element.
659
625
*/
660
- private updateQueryParams ( id : string ) : void {
661
- let key = id . replaceAll ( / ^ i d _ / gi, '' ) ;
626
+ private updateQueryParams ( fieldName : string ) : void {
662
627
// Find the element dependency.
663
- const element = getElement < HTMLSelectElement > ( `id_ ${ key } ` ) ;
628
+ const element = document . querySelector < HTMLSelectElement > ( `[name=" ${ fieldName } "] ` ) ;
664
629
if ( element !== null ) {
665
- // If the dependency has a value, parse the dependency's name (form key) for any
666
- // required replacements.
667
- for ( const [ pattern , replacement ] of REPLACE_PATTERNS ) {
668
- if ( key . match ( pattern ) ) {
669
- key = key . replaceAll ( pattern , replacement ) ;
670
- break ;
671
- }
672
- }
673
-
674
- // Force related keys to end in `_id`, if they don't already.
675
- if ( key . substring ( key . length - 3 ) !== '_id' ) {
676
- key = `${ key } _id` ;
677
- }
678
-
679
630
// Initialize the element value as an array, in case there are multiple values.
680
631
let elementValue = [ ] as Stringifiable [ ] ;
681
632
@@ -694,13 +645,38 @@ class APISelect {
694
645
695
646
if ( elementValue . length > 0 ) {
696
647
// If the field has a value, add it to the map.
697
- if ( this . filterParams . has ( id ) ) {
698
- // If this instance is filtered by the neighbor element, add its value to the map.
699
- this . queryParams . set ( key , elementValue ) ;
648
+ this . dynamicParams . updateValue ( fieldName , elementValue ) ;
649
+ // Get the updated value.
650
+ const current = this . dynamicParams . get ( fieldName ) ;
651
+
652
+ if ( typeof current !== 'undefined' ) {
653
+ const { queryParam, queryValue } = current ;
654
+ let value = [ ] as Stringifiable [ ] ;
655
+
656
+ if ( this . staticParams . has ( queryParam ) ) {
657
+ // If the field is defined in `staticParams`, we should merge the dynamic value with
658
+ // the static value.
659
+ const staticValue = this . staticParams . get ( queryParam ) ;
660
+ if ( typeof staticValue !== 'undefined' ) {
661
+ value = [ ...staticValue , ...queryValue ] ;
662
+ }
663
+ } else {
664
+ // If the field is _not_ defined in `staticParams`, we should replace the current value
665
+ // with the new dynamic value.
666
+ value = queryValue ;
667
+ }
668
+ if ( value . length > 0 ) {
669
+ this . queryParams . set ( queryParam , value ) ;
670
+ } else {
671
+ this . queryParams . delete ( queryParam ) ;
672
+ }
700
673
}
701
674
} else {
702
675
// Otherwise, delete it (we don't want to send an empty query like `?site_id=`)
703
- this . queryParams . delete ( key ) ;
676
+ const queryParam = this . dynamicParams . queryParam ( fieldName ) ;
677
+ if ( queryParam !== null ) {
678
+ this . queryParams . delete ( queryParam ) ;
679
+ }
704
680
}
705
681
}
706
682
}
@@ -796,88 +772,50 @@ class APISelect {
796
772
}
797
773
798
774
/**
799
- * Determine if a select element should be filtered by the value of another select element.
775
+ * Determine if a this instances' options should be filtered by the value of another select
776
+ * element.
800
777
*
801
- * Looks for the DOM attribute `data-query-param-<name of other field>`, which would look like:
802
- * `["$<name>"]`
778
+ * Looks for the DOM attribute `data-dynamic-params`, the value of which is a JSON array of
779
+ * objects containing information about how to handle the related field.
780
+ */
781
+ private getDynamicParams ( ) : void {
782
+ const serialized = this . base . getAttribute ( 'data-dynamic-params' ) ;
783
+ try {
784
+ this . dynamicParams . addFromJson ( serialized ) ;
785
+ } catch ( err ) {
786
+ console . group ( `Unable to determine dynamic query parameters for select field '${ this . name } '` ) ;
787
+ console . warn ( err ) ;
788
+ console . groupEnd ( ) ;
789
+ }
790
+ }
791
+
792
+ /**
793
+ * Determine if this instance's options should be filtered by static values passed from the
794
+ * server.
803
795
*
804
- * If the attribute exists, parse out the raw value. In the above example, this would be `name`.
805
- */
806
- private getFilteredBy ( ) : void {
807
- const pattern = new RegExp ( / \[ | \] | " | \$ / g) ;
808
- const keyPattern = new RegExp ( / d a t a - q u e r y - p a r a m - / g) ;
809
-
810
- // Extract data attributes.
811
- const keys = Object . values ( this . base . attributes )
812
- . map ( v => v . name )
813
- . filter ( v => v . includes ( 'data' ) ) ;
814
-
815
- /**
816
- * Properly handle preexistence of keys, value types, and deduplication when adding a filter to
817
- * `filterParams`.
818
- *
819
- * _Note: This is an unnamed function so that it can access `this`._
820
- */
821
- const addFilter = ( key : string , value : Stringifiable ) : void => {
822
- const current = this . filterParams . get ( key ) ;
823
-
824
- if ( typeof current !== 'undefined' ) {
825
- // This instance is already filtered by `key`, so we should add the new `value`.
826
- // Merge and deduplicate the current filter parameter values with the incoming value.
827
- const next = Array . from (
828
- new Set < Stringifiable > ( [ ...( current as Stringifiable [ ] ) , value ] ) ,
829
- ) ;
830
- this . filterParams . set ( key , next ) ;
831
- } else {
832
- // This instance is not already filtered by `key`, so we should add a new mapping.
833
- if ( value === '' ) {
834
- // Don't add placeholder values.
835
- this . filterParams . set ( key , [ ] ) ;
836
- } else {
837
- // If the value is not a placeholder, add it.
838
- this . filterParams . set ( key , [ value ] ) ;
839
- }
840
- }
841
- } ;
842
-
843
- for ( const key of keys ) {
844
- if ( key . match ( keyPattern ) && key !== 'data-query-param-exclude' ) {
845
- const value = this . base . getAttribute ( key ) ;
846
- if ( value !== null ) {
847
- try {
848
- const parsed = JSON . parse ( value ) as string | string [ ] ;
849
- if ( Array . isArray ( parsed ) ) {
850
- // Query param contains multiple values.
851
- for ( const item of parsed ) {
852
- if ( item . match ( / ^ \$ .+ $ / g) ) {
853
- // Value is an unfulfilled variable.
854
- addFilter ( item . replaceAll ( pattern , '' ) , '' ) ;
855
- } else {
856
- // Value has been fulfilled and is a real value to query.
857
- addFilter ( key . replaceAll ( keyPattern , '' ) , item ) ;
858
- }
859
- }
860
- } else {
861
- if ( parsed . match ( / ^ \$ .+ $ / g) ) {
862
- // Value is an unfulfilled variable.
863
- addFilter ( parsed . replaceAll ( pattern , '' ) , '' ) ;
864
- } else {
865
- // Value has been fulfilled and is a real value to query.
866
- addFilter ( key . replaceAll ( keyPattern , '' ) , parsed ) ;
867
- }
868
- }
869
- } catch ( err ) {
870
- console . warn ( err ) ;
871
- if ( value . match ( / ^ \$ .+ $ / g) ) {
872
- // Value is an unfulfilled variable.
873
- addFilter ( value . replaceAll ( pattern , '' ) , '' ) ;
796
+ * Looks for the DOM attribute `data-static-params`, the value of which is a JSON array of
797
+ * objects containing key/value pairs to add to `this.staticParams`.
798
+ */
799
+ private getStaticParams ( ) : void {
800
+ const serialized = this . base . getAttribute ( 'data-static-params' ) ;
801
+
802
+ try {
803
+ if ( isTruthy ( serialized ) ) {
804
+ const deserialized = JSON . parse ( serialized ) ;
805
+ if ( isStaticParams ( deserialized ) ) {
806
+ for ( const { queryParam, queryValue } of deserialized ) {
807
+ if ( Array . isArray ( queryValue ) ) {
808
+ this . staticParams . set ( queryParam , queryValue ) ;
874
809
} else {
875
- // Value has been fulfilled and is a real value to query.
876
- addFilter ( key . replaceAll ( keyPattern , '' ) , value ) ;
810
+ this . staticParams . set ( queryParam , [ queryValue ] ) ;
877
811
}
878
812
}
879
813
}
880
814
}
815
+ } catch ( err ) {
816
+ console . group ( `Unable to determine static query parameters for select field '${ this . name } '` ) ;
817
+ console . warn ( err ) ;
818
+ console . groupEnd ( ) ;
881
819
}
882
820
}
883
821
@@ -990,9 +928,3 @@ class APISelect {
990
928
}
991
929
}
992
930
}
993
-
994
- export function initApiSelect ( ) : void {
995
- for ( const select of getElements < HTMLSelectElement > ( '.netbox-api-select' ) ) {
996
- new APISelect ( select ) ;
997
- }
998
- }
0 commit comments