mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-15 19:52:52 -06:00
Fixes #7035: Refactor APISelect query_param logic
This commit is contained in:
parent
0d61dcb1bc
commit
89b7f3f19d
@ -131,10 +131,10 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
|||||||
site_id = DynamicModelMultipleChoiceField(
|
site_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=Site.objects.all(),
|
queryset=Site.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
query_params={
|
filter_fields=[
|
||||||
'region_id': '$region_id',
|
{'accessor': 'region_id', 'field_name': 'region_id'},
|
||||||
'site_group_id': '$site_group_id',
|
{'accessor': 'site_group_id', 'field_name': 'site_group_id'}
|
||||||
},
|
],
|
||||||
label=_('Site'),
|
label=_('Site'),
|
||||||
fetch_trigger='open'
|
fetch_trigger='open'
|
||||||
)
|
)
|
||||||
@ -405,9 +405,9 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilte
|
|||||||
provider_network_id = DynamicModelMultipleChoiceField(
|
provider_network_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=ProviderNetwork.objects.all(),
|
queryset=ProviderNetwork.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
query_params={
|
filter_fields=[
|
||||||
'provider_id': '$provider_id'
|
{'accessor': 'provider_id', 'field_name': 'provider_id'}
|
||||||
},
|
],
|
||||||
label=_('Provider network'),
|
label=_('Provider network'),
|
||||||
fetch_trigger='open'
|
fetch_trigger='open'
|
||||||
)
|
)
|
||||||
@ -431,10 +431,10 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilte
|
|||||||
site_id = DynamicModelMultipleChoiceField(
|
site_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=Site.objects.all(),
|
queryset=Site.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
query_params={
|
filter_fields=[
|
||||||
'region_id': '$region_id',
|
{'accessor': 'region_id', 'field_name': 'region_id'},
|
||||||
'site_group_id': '$site_group_id',
|
{'accessor': 'site_group_id', 'field_name': 'site_group_id'},
|
||||||
},
|
],
|
||||||
label=_('Site'),
|
label=_('Site'),
|
||||||
fetch_trigger='open'
|
fetch_trigger='open'
|
||||||
)
|
)
|
||||||
@ -467,10 +467,10 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
|
|||||||
)
|
)
|
||||||
site = DynamicModelChoiceField(
|
site = DynamicModelChoiceField(
|
||||||
queryset=Site.objects.all(),
|
queryset=Site.objects.all(),
|
||||||
query_params={
|
filter_fields=[
|
||||||
'region_id': '$region',
|
{'accessor': 'region_id', 'field_name': 'region'},
|
||||||
'group_id': '$site_group',
|
{'accessor': 'site_group_id', 'field_name': 'site_group'},
|
||||||
},
|
],
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
provider_network = DynamicModelChoiceField(
|
provider_network = DynamicModelChoiceField(
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -437,19 +437,19 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
|||||||
queryset=Site.objects.all(),
|
queryset=Site.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
null_option='None',
|
null_option='None',
|
||||||
query_params={
|
filter_fields=[
|
||||||
'region_id': '$region',
|
{'accessor': 'region_id', 'field_name': 'region'},
|
||||||
'group_id': '$site_group',
|
{'accessor': 'group_id', 'field_name': 'site_group'},
|
||||||
}
|
]
|
||||||
)
|
)
|
||||||
vlan_group = DynamicModelChoiceField(
|
vlan_group = DynamicModelChoiceField(
|
||||||
queryset=VLANGroup.objects.all(),
|
queryset=VLANGroup.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
label='VLAN group',
|
label='VLAN group',
|
||||||
null_option='None',
|
null_option='None',
|
||||||
query_params={
|
filter_fields=[
|
||||||
'site_id': '$site'
|
{'accessor': 'site_id', 'field_name': 'site'},
|
||||||
},
|
],
|
||||||
initial_params={
|
initial_params={
|
||||||
'vlans': '$vlan'
|
'vlans': '$vlan'
|
||||||
}
|
}
|
||||||
@ -458,10 +458,10 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
|||||||
queryset=VLAN.objects.all(),
|
queryset=VLAN.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
label='VLAN',
|
label='VLAN',
|
||||||
query_params={
|
filter_fields=[
|
||||||
'site_id': '$site',
|
{'accessor': 'site_id', 'field_name': 'site'},
|
||||||
'group_id': '$vlan_group',
|
{'accessor': 'group_id', 'field_name': 'vlan_group'},
|
||||||
}
|
]
|
||||||
)
|
)
|
||||||
role = DynamicModelChoiceField(
|
role = DynamicModelChoiceField(
|
||||||
queryset=Role.objects.all(),
|
queryset=Role.objects.all(),
|
||||||
@ -573,10 +573,10 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulk
|
|||||||
site = DynamicModelChoiceField(
|
site = DynamicModelChoiceField(
|
||||||
queryset=Site.objects.all(),
|
queryset=Site.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
query_params={
|
filter_fields=[
|
||||||
'region_id': '$region',
|
{'accessor': 'region_id', 'field_name': 'region'},
|
||||||
'group_id': '$site_group',
|
{'accessor': 'group_id', 'field_name': 'site_group'},
|
||||||
}
|
],
|
||||||
)
|
)
|
||||||
vrf = DynamicModelChoiceField(
|
vrf = DynamicModelChoiceField(
|
||||||
queryset=VRF.objects.all(),
|
queryset=VRF.objects.all(),
|
||||||
@ -695,9 +695,9 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilter
|
|||||||
queryset=Site.objects.all(),
|
queryset=Site.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
null_option='None',
|
null_option='None',
|
||||||
query_params={
|
filter_fields=[
|
||||||
'region_id': '$region_id'
|
{'accessor': 'region_id', 'field_name': 'region_id'},
|
||||||
},
|
],
|
||||||
label=_('Site'),
|
label=_('Site'),
|
||||||
fetch_trigger='open'
|
fetch_trigger='open'
|
||||||
)
|
)
|
||||||
@ -883,9 +883,9 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
|||||||
interface = DynamicModelChoiceField(
|
interface = DynamicModelChoiceField(
|
||||||
queryset=Interface.objects.all(),
|
queryset=Interface.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
query_params={
|
filter_fields=[
|
||||||
'device_id': '$device'
|
{'accessor': 'device_id', 'field_name': 'device'},
|
||||||
}
|
],
|
||||||
)
|
)
|
||||||
virtual_machine = DynamicModelChoiceField(
|
virtual_machine = DynamicModelChoiceField(
|
||||||
queryset=VirtualMachine.objects.all(),
|
queryset=VirtualMachine.objects.all(),
|
||||||
@ -898,9 +898,9 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
|||||||
queryset=VMInterface.objects.all(),
|
queryset=VMInterface.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
label='Interface',
|
label='Interface',
|
||||||
query_params={
|
filter_fields=[
|
||||||
'virtual_machine_id': '$virtual_machine'
|
{'accessor': 'virtual_machine_id', 'field_name': 'virtual_machine'},
|
||||||
}
|
]
|
||||||
)
|
)
|
||||||
vrf = DynamicModelChoiceField(
|
vrf = DynamicModelChoiceField(
|
||||||
queryset=VRF.objects.all(),
|
queryset=VRF.objects.all(),
|
||||||
@ -927,28 +927,28 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
|||||||
queryset=Site.objects.all(),
|
queryset=Site.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
label='Site',
|
label='Site',
|
||||||
query_params={
|
filter_fields=[
|
||||||
'region_id': '$nat_region',
|
{'accessor': 'region_id', 'field_name': 'nat_region'},
|
||||||
'group_id': '$nat_site_group',
|
{'accessor': 'group_id', 'field_name': 'nat_site_group'},
|
||||||
}
|
],
|
||||||
)
|
)
|
||||||
nat_rack = DynamicModelChoiceField(
|
nat_rack = DynamicModelChoiceField(
|
||||||
queryset=Rack.objects.all(),
|
queryset=Rack.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
label='Rack',
|
label='Rack',
|
||||||
null_option='None',
|
null_option='None',
|
||||||
query_params={
|
filter_fields=[
|
||||||
'site_id': '$site'
|
{'accessor': 'site_id', 'field_name': 'site'},
|
||||||
}
|
],
|
||||||
)
|
)
|
||||||
nat_device = DynamicModelChoiceField(
|
nat_device = DynamicModelChoiceField(
|
||||||
queryset=Device.objects.all(),
|
queryset=Device.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
label='Device',
|
label='Device',
|
||||||
query_params={
|
filter_fields=[
|
||||||
'site_id': '$site',
|
{'accessor': 'site_id', 'field_name': 'site'},
|
||||||
'rack_id': '$nat_rack',
|
{'accessor': 'rack_id', 'field_name': 'nat_rack'},
|
||||||
}
|
]
|
||||||
)
|
)
|
||||||
nat_cluster = DynamicModelChoiceField(
|
nat_cluster = DynamicModelChoiceField(
|
||||||
queryset=Cluster.objects.all(),
|
queryset=Cluster.objects.all(),
|
||||||
@ -959,9 +959,9 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
|||||||
queryset=VirtualMachine.objects.all(),
|
queryset=VirtualMachine.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
label='Virtual Machine',
|
label='Virtual Machine',
|
||||||
query_params={
|
filter_fields=[
|
||||||
'cluster_id': '$nat_cluster',
|
{'accessor': 'cluster_id', 'field_name': 'nat_cluster'},
|
||||||
}
|
]
|
||||||
)
|
)
|
||||||
nat_vrf = DynamicModelChoiceField(
|
nat_vrf = DynamicModelChoiceField(
|
||||||
queryset=VRF.objects.all(),
|
queryset=VRF.objects.all(),
|
||||||
@ -972,11 +972,11 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
|||||||
queryset=IPAddress.objects.all(),
|
queryset=IPAddress.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
label='IP Address',
|
label='IP Address',
|
||||||
query_params={
|
filter_fields=[
|
||||||
'device_id': '$nat_device',
|
{'accessor': 'device_id', 'field_name': 'nat_device'},
|
||||||
'virtual_machine_id': '$nat_virtual_machine',
|
{'accessor': 'virtual_machine_id', 'field_name': 'nat_virtual_machine'},
|
||||||
'vrf_id': '$nat_vrf',
|
{'accessor': 'vrf_id', 'field_name': 'nat_vrf'},
|
||||||
}
|
],
|
||||||
)
|
)
|
||||||
primary_for_parent = forms.BooleanField(
|
primary_for_parent = forms.BooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
@ -1365,10 +1365,10 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm):
|
|||||||
initial_params={
|
initial_params={
|
||||||
'locations': '$location'
|
'locations': '$location'
|
||||||
},
|
},
|
||||||
query_params={
|
filter_fields=[
|
||||||
'region_id': '$region',
|
{'accessor': 'region_id', 'field_name': 'region'},
|
||||||
'group_id': '$sitegroup',
|
{'accessor': 'group_id', 'field_name': 'sitegroup'},
|
||||||
}
|
],
|
||||||
)
|
)
|
||||||
location = DynamicModelChoiceField(
|
location = DynamicModelChoiceField(
|
||||||
queryset=Location.objects.all(),
|
queryset=Location.objects.all(),
|
||||||
@ -1376,17 +1376,17 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm):
|
|||||||
initial_params={
|
initial_params={
|
||||||
'racks': '$rack'
|
'racks': '$rack'
|
||||||
},
|
},
|
||||||
query_params={
|
filter_fields=[
|
||||||
'site_id': '$site',
|
{'accessor': 'site_id', 'field_name': 'site'},
|
||||||
}
|
]
|
||||||
)
|
)
|
||||||
rack = DynamicModelChoiceField(
|
rack = DynamicModelChoiceField(
|
||||||
queryset=Rack.objects.all(),
|
queryset=Rack.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
query_params={
|
filter_fields=[
|
||||||
'site_id': '$site',
|
{'accessor': 'site_id', 'field_name': 'site'},
|
||||||
'location_id': '$location',
|
{'accessor': 'location_id', 'field_name': 'location'},
|
||||||
}
|
],
|
||||||
)
|
)
|
||||||
clustergroup = DynamicModelChoiceField(
|
clustergroup = DynamicModelChoiceField(
|
||||||
queryset=ClusterGroup.objects.all(),
|
queryset=ClusterGroup.objects.all(),
|
||||||
@ -1399,9 +1399,9 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm):
|
|||||||
cluster = DynamicModelChoiceField(
|
cluster = DynamicModelChoiceField(
|
||||||
queryset=Cluster.objects.all(),
|
queryset=Cluster.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
query_params={
|
filter_fields=[
|
||||||
'group_id': '$clustergroup',
|
{'accessor': 'group_id', 'field_name': 'clustergroup'},
|
||||||
}
|
],
|
||||||
)
|
)
|
||||||
slug = SlugField()
|
slug = SlugField()
|
||||||
|
|
||||||
@ -1541,9 +1541,9 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
|||||||
group = DynamicModelChoiceField(
|
group = DynamicModelChoiceField(
|
||||||
queryset=VLANGroup.objects.all(),
|
queryset=VLANGroup.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
query_params={
|
filter_fields=[
|
||||||
'scope_type': '$scope_type',
|
{'accessor': 'scope_type', 'field_name': 'scope_type'},
|
||||||
},
|
],
|
||||||
label='VLAN Group'
|
label='VLAN Group'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1568,10 +1568,10 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
|||||||
queryset=Site.objects.all(),
|
queryset=Site.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
null_option='None',
|
null_option='None',
|
||||||
query_params={
|
filter_fields=[
|
||||||
'region_id': '$region',
|
{'accessor': 'region_id', 'field_name': 'region'},
|
||||||
'group_id': '$sitegroup',
|
{'accessor': 'group_id', 'field_name': 'sitegroup'},
|
||||||
}
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Other fields
|
# Other fields
|
||||||
@ -1657,17 +1657,17 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd
|
|||||||
site = DynamicModelChoiceField(
|
site = DynamicModelChoiceField(
|
||||||
queryset=Site.objects.all(),
|
queryset=Site.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
query_params={
|
filter_fields=[
|
||||||
'region_id': '$region',
|
{'accessor': 'region_id', 'field_name': 'region'},
|
||||||
'group_id': '$site_group',
|
{'accessor': 'group_id', 'field_name': 'site_group'},
|
||||||
}
|
]
|
||||||
)
|
)
|
||||||
group = DynamicModelChoiceField(
|
group = DynamicModelChoiceField(
|
||||||
queryset=VLANGroup.objects.all(),
|
queryset=VLANGroup.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
query_params={
|
filter_fields=[
|
||||||
'site_id': '$site'
|
{'accessor': 'site_id', 'field_name': 'site'},
|
||||||
}
|
]
|
||||||
)
|
)
|
||||||
tenant = DynamicModelChoiceField(
|
tenant = DynamicModelChoiceField(
|
||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
@ -1722,9 +1722,9 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFo
|
|||||||
queryset=Site.objects.all(),
|
queryset=Site.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
null_option='None',
|
null_option='None',
|
||||||
query_params={
|
filter_fields=[
|
||||||
'region': '$region'
|
{'accessor': 'region_id', 'field_name': 'region'},
|
||||||
},
|
],
|
||||||
label=_('Site'),
|
label=_('Site'),
|
||||||
fetch_trigger='open'
|
fetch_trigger='open'
|
||||||
)
|
)
|
||||||
@ -1732,9 +1732,9 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFo
|
|||||||
queryset=VLANGroup.objects.all(),
|
queryset=VLANGroup.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
null_option='None',
|
null_option='None',
|
||||||
query_params={
|
filter_fields=[
|
||||||
'region': '$region'
|
{'accessor': 'region_id', 'field_name': 'region'},
|
||||||
},
|
],
|
||||||
label=_('VLAN group'),
|
label=_('VLAN group'),
|
||||||
fetch_trigger='open'
|
fetch_trigger='open'
|
||||||
)
|
)
|
||||||
|
BIN
netbox/project-static/dist/config.js
vendored
BIN
netbox/project-static/dist/config.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/config.js.map
vendored
BIN
netbox/project-static/dist/config.js.map
vendored
Binary file not shown.
BIN
netbox/project-static/dist/jobs.js
vendored
BIN
netbox/project-static/dist/jobs.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/jobs.js.map
vendored
BIN
netbox/project-static/dist/jobs.js.map
vendored
Binary file not shown.
BIN
netbox/project-static/dist/lldp.js
vendored
BIN
netbox/project-static/dist/lldp.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/lldp.js.map
vendored
BIN
netbox/project-static/dist/lldp.js.map
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js
vendored
BIN
netbox/project-static/dist/netbox.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js.map
vendored
BIN
netbox/project-static/dist/netbox.js.map
vendored
Binary file not shown.
BIN
netbox/project-static/dist/status.js
vendored
BIN
netbox/project-static/dist/status.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/status.js.map
vendored
BIN
netbox/project-static/dist/status.js.map
vendored
Binary file not shown.
@ -2,8 +2,9 @@ import queryString from 'query-string';
|
|||||||
import debounce from 'just-debounce-it';
|
import debounce from 'just-debounce-it';
|
||||||
import { readableColor } from 'color2k';
|
import { readableColor } from 'color2k';
|
||||||
import SlimSelect from 'slim-select';
|
import SlimSelect from 'slim-select';
|
||||||
import { createToast } from '../bs';
|
import { createToast } from '../../bs';
|
||||||
import { hasUrl, hasExclusions, isTrigger } from './util';
|
import { hasUrl, hasExclusions, isTrigger } from '../util';
|
||||||
|
import { FilterFieldMap } from './filterFields';
|
||||||
import {
|
import {
|
||||||
isTruthy,
|
isTruthy,
|
||||||
hasMore,
|
hasMore,
|
||||||
@ -11,61 +12,14 @@ import {
|
|||||||
getElement,
|
getElement,
|
||||||
getApiData,
|
getApiData,
|
||||||
isApiError,
|
isApiError,
|
||||||
getElements,
|
|
||||||
createElement,
|
createElement,
|
||||||
uniqueByProperty,
|
uniqueByProperty,
|
||||||
findFirstAdjacent,
|
findFirstAdjacent,
|
||||||
} from '../util';
|
} from '../../util';
|
||||||
|
|
||||||
import type { Stringifiable } from 'query-string';
|
import type { Stringifiable } from 'query-string';
|
||||||
import type { Option } from 'slim-select/dist/data';
|
import type { Option } from 'slim-select/dist/data';
|
||||||
|
import type { Trigger, PathFilter, ApplyMethod, QueryFilter } from './types';
|
||||||
/**
|
|
||||||
* Map of string keys to primitive array values accepted by `query-string`. Keys are used as
|
|
||||||
* URL query parameter keys. Values correspond to query param values, enforced as an array
|
|
||||||
* for easier handling. For example, a mapping of `{ site_id: [1, 2] }` is serialized by
|
|
||||||
* `query-string` as `?site_id=1&site_id=2`. Likewise, `{ site_id: [1] }` is serialized as
|
|
||||||
* `?site_id=1`.
|
|
||||||
*/
|
|
||||||
type QueryFilter = Map<string, Stringifiable[]>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map of string keys to primitive values. Used to track variables within URLs from the server. For
|
|
||||||
* example, `/api/$key/thing`. `PathFilter` tracks `$key` as `{ key: '' }` in the map, and when the
|
|
||||||
* value is later known, the value is set — `{ key: 'value' }`, and the URL is transformed to
|
|
||||||
* `/api/value/thing`.
|
|
||||||
*/
|
|
||||||
type PathFilter = Map<string, Stringifiable>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merge or replace incoming options with current options.
|
|
||||||
*/
|
|
||||||
type ApplyMethod = 'merge' | 'replace';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trigger for which the select instance should fetch its data from the NetBox API.
|
|
||||||
*/
|
|
||||||
export type Trigger =
|
|
||||||
/**
|
|
||||||
* Load data when the select element is opened.
|
|
||||||
*/
|
|
||||||
| 'open'
|
|
||||||
/**
|
|
||||||
* Load data when the element is loaded.
|
|
||||||
*/
|
|
||||||
| 'load'
|
|
||||||
/**
|
|
||||||
* Load data when a parent element is uncollapsed.
|
|
||||||
*/
|
|
||||||
| 'collapse';
|
|
||||||
|
|
||||||
// Various one-off patterns to replace in query param keys.
|
|
||||||
const REPLACE_PATTERNS = [
|
|
||||||
// Don't query `termination_a_device=1`, but rather `device=1`.
|
|
||||||
[new RegExp(/termination_(a|b)_(.+)/g), '$2'],
|
|
||||||
// A tenant's group relationship field is `group`, but the field name is `tenant_group`.
|
|
||||||
[new RegExp(/tenant_(group)/g), '$1'],
|
|
||||||
] as [RegExp, string][];
|
|
||||||
|
|
||||||
// Empty placeholder option.
|
// Empty placeholder option.
|
||||||
const PLACEHOLDER = {
|
const PLACEHOLDER = {
|
||||||
@ -81,7 +35,7 @@ const DISABLED_ATTRIBUTES = ['occupied'] as string[];
|
|||||||
* Manage a single API-backed select element's state. Each API select element is likely controlled
|
* Manage a single API-backed select element's state. Each API select element is likely controlled
|
||||||
* or dynamically updated by one or more other API select (or static select) elements' values.
|
* or dynamically updated by one or more other API select (or static select) elements' values.
|
||||||
*/
|
*/
|
||||||
class APISelect {
|
export class APISelect {
|
||||||
/**
|
/**
|
||||||
* Base `<select/>` DOM element.
|
* Base `<select/>` DOM element.
|
||||||
*/
|
*/
|
||||||
@ -124,24 +78,29 @@ class APISelect {
|
|||||||
*/
|
*/
|
||||||
private readonly slim: InstanceType<typeof SlimSelect>;
|
private readonly slim: InstanceType<typeof SlimSelect>;
|
||||||
|
|
||||||
/**
|
|
||||||
* API query parameters that should be applied to API queries for this field. This will be
|
|
||||||
* updated as other dependent fields' values change. This is a mapping of:
|
|
||||||
*
|
|
||||||
* Form Field Names → Form Field Values
|
|
||||||
*
|
|
||||||
* This is/might be different than the query parameters themselves, as the form field names may
|
|
||||||
* be different than the object model key names. For example, `tenant_group` would be the field
|
|
||||||
* name, but `group` would be the query parameter. Query parameters themselves are tracked in
|
|
||||||
* `queryParams`.
|
|
||||||
*/
|
|
||||||
private readonly filterParams: QueryFilter = new Map();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Post-parsed URL query parameters for API queries.
|
* Post-parsed URL query parameters for API queries.
|
||||||
*/
|
*/
|
||||||
private readonly queryParams: QueryFilter = new Map();
|
private readonly queryParams: QueryFilter = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API query parameters that should be applied to API queries for this field. This will be
|
||||||
|
* updated as other dependent fields' values change. This is a mapping of:
|
||||||
|
*
|
||||||
|
* Form Field Names → Object containing:
|
||||||
|
* - Query parameter key name
|
||||||
|
* - Query value
|
||||||
|
* - Other options such as a default value, and the option to include
|
||||||
|
* null values.
|
||||||
|
*
|
||||||
|
* This is different from `queryParams` in that it tracks all _possible_ related fields and their
|
||||||
|
* values, even if they are empty. Further, the keys in `queryParams` correspond to the actual
|
||||||
|
* query parameter keys, which are not necessarily the same as the form field names, depending on
|
||||||
|
* the model. For example, `tenant_group` would be the field name, but `group_id` would be the
|
||||||
|
* query parameter.
|
||||||
|
*/
|
||||||
|
private readonly filterFields: FilterFieldMap = new FilterFieldMap();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mapping of URL template key/value pairs. If this element's URL contains Django template tags
|
* Mapping of URL template key/value pairs. If this element's URL contains Django template tags
|
||||||
* (e.g., `{{key}}`), `key` will be added to `pathValue` and the `id_key` form element will be
|
* (e.g., `{{key}}`), `key` will be added to `pathValue` and the `id_key` form element will be
|
||||||
@ -228,17 +187,27 @@ class APISelect {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Initialize API query properties.
|
// Initialize API query properties.
|
||||||
this.getFilteredBy();
|
// this.getFilteredBy();
|
||||||
|
this.getFilterFields();
|
||||||
this.getPathKeys();
|
this.getPathKeys();
|
||||||
|
|
||||||
for (const filter of this.filterParams.keys()) {
|
// for (const filter of this.filterParams.keys()) {
|
||||||
|
// this.updateQueryParams(filter);
|
||||||
|
// }
|
||||||
|
for (const filter of this.filterFields.keys()) {
|
||||||
this.updateQueryParams(filter);
|
this.updateQueryParams(filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add any already-resolved key/value pairs to the API query parameters.
|
// Add any already-resolved key/value pairs to the API query parameters.
|
||||||
for (const [key, value] of this.filterParams.entries()) {
|
// for (const [key, value] of this.filterParams.entries()) {
|
||||||
if (isTruthy(value)) {
|
// if (isTruthy(value)) {
|
||||||
this.queryParams.set(key, value);
|
// this.queryParams.set(key, value);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
for (const value of this.filterFields.values()) {
|
||||||
|
const { queryParam, queryValue } = value;
|
||||||
|
if (isTruthy(queryValue)) {
|
||||||
|
this.queryParams.set(queryParam, queryValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -395,7 +364,8 @@ class APISelect {
|
|||||||
|
|
||||||
// Create a unique iterator of all possible form fields which, when changed, should cause this
|
// Create a unique iterator of all possible form fields which, when changed, should cause this
|
||||||
// element to update its API query.
|
// element to update its API query.
|
||||||
const dependencies = new Set([...this.filterParams.keys(), ...this.pathValues.keys()]);
|
// const dependencies = new Set([...this.filterParams.keys(), ...this.pathValues.keys()]);
|
||||||
|
const dependencies = new Set([...this.filterFields.keys(), ...this.pathValues.keys()]);
|
||||||
|
|
||||||
for (const dep of dependencies) {
|
for (const dep of dependencies) {
|
||||||
const filterElement = document.querySelector(`[name="${dep}"]`);
|
const filterElement = document.querySelector(`[name="${dep}"]`);
|
||||||
@ -588,6 +558,12 @@ class APISelect {
|
|||||||
this.updateQueryParams(target.name);
|
this.updateQueryParams(target.name);
|
||||||
this.updatePathValues(target.name);
|
this.updatePathValues(target.name);
|
||||||
this.updateQueryUrl();
|
this.updateQueryUrl();
|
||||||
|
|
||||||
|
console.group(this.name, this.queryUrl);
|
||||||
|
console.log(this.filterFields);
|
||||||
|
console.log(this.queryParams);
|
||||||
|
console.groupEnd();
|
||||||
|
|
||||||
// Load new data.
|
// Load new data.
|
||||||
Promise.all([this.loadData()]);
|
Promise.all([this.loadData()]);
|
||||||
}
|
}
|
||||||
@ -655,27 +631,12 @@ class APISelect {
|
|||||||
* Update an element's API URL based on the value of another element on which this element
|
* Update an element's API URL based on the value of another element on which this element
|
||||||
* relies.
|
* relies.
|
||||||
*
|
*
|
||||||
* @param id DOM ID of the other element.
|
* @param fieldName DOM ID of the other element.
|
||||||
*/
|
*/
|
||||||
private updateQueryParams(id: string): void {
|
private updateQueryParams(fieldName: string): void {
|
||||||
let key = id.replaceAll(/^id_/gi, '');
|
|
||||||
// Find the element dependency.
|
// Find the element dependency.
|
||||||
const element = getElement<HTMLSelectElement>(`id_${key}`);
|
const element = document.querySelector<HTMLSelectElement>(`[name="${fieldName}"]`);
|
||||||
if (element !== null) {
|
if (element !== null) {
|
||||||
// If the dependency has a value, parse the dependency's name (form key) for any
|
|
||||||
// required replacements.
|
|
||||||
for (const [pattern, replacement] of REPLACE_PATTERNS) {
|
|
||||||
if (key.match(pattern)) {
|
|
||||||
key = key.replaceAll(pattern, replacement);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Force related keys to end in `_id`, if they don't already.
|
|
||||||
if (key.substring(key.length - 3) !== '_id') {
|
|
||||||
key = `${key}_id`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize the element value as an array, in case there are multiple values.
|
// Initialize the element value as an array, in case there are multiple values.
|
||||||
let elementValue = [] as Stringifiable[];
|
let elementValue = [] as Stringifiable[];
|
||||||
|
|
||||||
@ -694,13 +655,29 @@ class APISelect {
|
|||||||
|
|
||||||
if (elementValue.length > 0) {
|
if (elementValue.length > 0) {
|
||||||
// If the field has a value, add it to the map.
|
// If the field has a value, add it to the map.
|
||||||
if (this.filterParams.has(id)) {
|
this.filterFields.updateValue(fieldName, elementValue);
|
||||||
// If this instance is filtered by the neighbor element, add its value to the map.
|
|
||||||
this.queryParams.set(key, elementValue);
|
const current = this.filterFields.get(fieldName);
|
||||||
|
|
||||||
|
if (typeof current !== 'undefined') {
|
||||||
|
const { queryParam, queryValue, includeNull } = current;
|
||||||
|
let value = [] as Stringifiable[];
|
||||||
|
if (includeNull) {
|
||||||
|
value = [...value, null];
|
||||||
|
}
|
||||||
|
if (queryValue.length > 0) {
|
||||||
|
value = [...value, ...queryValue];
|
||||||
|
this.queryParams.set(queryParam, value);
|
||||||
|
} else {
|
||||||
|
this.queryParams.delete(queryParam);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Otherwise, delete it (we don't want to send an empty query like `?site_id=`)
|
// Otherwise, delete it (we don't want to send an empty query like `?site_id=`)
|
||||||
this.queryParams.delete(key);
|
const queryParam = this.filterFields.queryParam(fieldName);
|
||||||
|
if (queryParam !== null) {
|
||||||
|
this.queryParams.delete(queryParam);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -798,86 +775,17 @@ class APISelect {
|
|||||||
/**
|
/**
|
||||||
* Determine if a select element should be filtered by the value of another select element.
|
* Determine if a select element should be filtered by the value of another select element.
|
||||||
*
|
*
|
||||||
* Looks for the DOM attribute `data-query-param-<name of other field>`, which would look like:
|
* Looks for the DOM attribute `data-filter-fields`, the value of which is a JSON array of
|
||||||
* `["$<name>"]`
|
* objects containing information about how to handle the related field.
|
||||||
*
|
|
||||||
* If the attribute exists, parse out the raw value. In the above example, this would be `name`.
|
|
||||||
*/
|
*/
|
||||||
private getFilteredBy(): void {
|
private getFilterFields(): void {
|
||||||
const pattern = new RegExp(/\[|\]|"|\$/g);
|
const serialized = this.base.getAttribute('data-filter-fields');
|
||||||
const keyPattern = new RegExp(/data-query-param-/g);
|
try {
|
||||||
|
this.filterFields.addFromJson(serialized);
|
||||||
// Extract data attributes.
|
} catch (err) {
|
||||||
const keys = Object.values(this.base.attributes)
|
console.group(`Unable to determine filter fields for select field '${this.name}'`);
|
||||||
.map(v => v.name)
|
console.warn(err);
|
||||||
.filter(v => v.includes('data'));
|
console.groupEnd();
|
||||||
|
|
||||||
/**
|
|
||||||
* Properly handle preexistence of keys, value types, and deduplication when adding a filter to
|
|
||||||
* `filterParams`.
|
|
||||||
*
|
|
||||||
* _Note: This is an unnamed function so that it can access `this`._
|
|
||||||
*/
|
|
||||||
const addFilter = (key: string, value: Stringifiable): void => {
|
|
||||||
const current = this.filterParams.get(key);
|
|
||||||
|
|
||||||
if (typeof current !== 'undefined') {
|
|
||||||
// This instance is already filtered by `key`, so we should add the new `value`.
|
|
||||||
// Merge and deduplicate the current filter parameter values with the incoming value.
|
|
||||||
const next = Array.from(
|
|
||||||
new Set<Stringifiable>([...(current as Stringifiable[]), value]),
|
|
||||||
);
|
|
||||||
this.filterParams.set(key, next);
|
|
||||||
} else {
|
|
||||||
// This instance is not already filtered by `key`, so we should add a new mapping.
|
|
||||||
if (value === '') {
|
|
||||||
// Don't add placeholder values.
|
|
||||||
this.filterParams.set(key, []);
|
|
||||||
} else {
|
|
||||||
// If the value is not a placeholder, add it.
|
|
||||||
this.filterParams.set(key, [value]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const key of keys) {
|
|
||||||
if (key.match(keyPattern) && key !== 'data-query-param-exclude') {
|
|
||||||
const value = this.base.getAttribute(key);
|
|
||||||
if (value !== null) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(value) as string | string[];
|
|
||||||
if (Array.isArray(parsed)) {
|
|
||||||
// Query param contains multiple values.
|
|
||||||
for (const item of parsed) {
|
|
||||||
if (item.match(/^\$.+$/g)) {
|
|
||||||
// Value is an unfulfilled variable.
|
|
||||||
addFilter(item.replaceAll(pattern, ''), '');
|
|
||||||
} else {
|
|
||||||
// Value has been fulfilled and is a real value to query.
|
|
||||||
addFilter(key.replaceAll(keyPattern, ''), item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (parsed.match(/^\$.+$/g)) {
|
|
||||||
// Value is an unfulfilled variable.
|
|
||||||
addFilter(parsed.replaceAll(pattern, ''), '');
|
|
||||||
} else {
|
|
||||||
// Value has been fulfilled and is a real value to query.
|
|
||||||
addFilter(key.replaceAll(keyPattern, ''), parsed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(err);
|
|
||||||
if (value.match(/^\$.+$/g)) {
|
|
||||||
// Value is an unfulfilled variable.
|
|
||||||
addFilter(value.replaceAll(pattern, ''), '');
|
|
||||||
} else {
|
|
||||||
// Value has been fulfilled and is a real value to query.
|
|
||||||
addFilter(key.replaceAll(keyPattern, ''), value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -990,9 +898,3 @@ class APISelect {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initApiSelect(): void {
|
|
||||||
for (const select of getElements<HTMLSelectElement>('.netbox-api-select')) {
|
|
||||||
new APISelect(select);
|
|
||||||
}
|
|
||||||
}
|
|
87
netbox/project-static/src/select/api/filterFields.ts
Normal file
87
netbox/project-static/src/select/api/filterFields.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { isTruthy } from '../../util';
|
||||||
|
import { isDataFilterFields } from './types';
|
||||||
|
|
||||||
|
import type { Stringifiable } from 'query-string';
|
||||||
|
import type { FilterFieldValue } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension of built-in `Map` to add convenience functions.
|
||||||
|
*/
|
||||||
|
export class FilterFieldMap extends Map<string, FilterFieldValue> {
|
||||||
|
/**
|
||||||
|
* Get the query parameter key based on field name.
|
||||||
|
*
|
||||||
|
* @param fieldName Related field name.
|
||||||
|
* @returns `queryParam` key.
|
||||||
|
*/
|
||||||
|
public queryParam(fieldName: string): Nullable<FilterFieldValue['queryParam']> {
|
||||||
|
const value = this.get(fieldName);
|
||||||
|
if (typeof value !== 'undefined') {
|
||||||
|
return value.queryParam;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the query parameter value based on field name.
|
||||||
|
*
|
||||||
|
* @param fieldName Related field name.
|
||||||
|
* @returns `queryValue` value, or an empty array if there is no corresponding Map entry.
|
||||||
|
*/
|
||||||
|
public queryValue(fieldName: string): FilterFieldValue['queryValue'] {
|
||||||
|
const value = this.get(fieldName);
|
||||||
|
if (typeof value !== 'undefined') {
|
||||||
|
return value.queryValue;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the value of a field when the value changes.
|
||||||
|
*
|
||||||
|
* @param fieldName Related field name.
|
||||||
|
* @param queryValue New value.
|
||||||
|
* @returns `true` if the update was successful, `false` if there was no corresponding Map entry.
|
||||||
|
*/
|
||||||
|
public updateValue(fieldName: string, queryValue: FilterFieldValue['queryValue']): boolean {
|
||||||
|
const current = this.get(fieldName);
|
||||||
|
if (isTruthy(current)) {
|
||||||
|
const { queryParam, includeNull } = current;
|
||||||
|
this.set(fieldName, { queryParam, queryValue, includeNull });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Populate the underlying map based on the JSON passed in the `data-filter-fields` attribute.
|
||||||
|
*
|
||||||
|
* @param json Raw JSON string from `data-filter-fields` attribute.
|
||||||
|
*/
|
||||||
|
public addFromJson(json: string | null | undefined): void {
|
||||||
|
if (isTruthy(json)) {
|
||||||
|
const deserialized = JSON.parse(json);
|
||||||
|
// Ensure the value is the data structure we expect.
|
||||||
|
if (isDataFilterFields(deserialized)) {
|
||||||
|
for (const { queryParam, fieldName, defaultValue, includeNull } of deserialized) {
|
||||||
|
let queryValue = [] as Stringifiable[];
|
||||||
|
if (isTruthy(defaultValue)) {
|
||||||
|
// Add the default value, if it exists.
|
||||||
|
if (Array.isArray(defaultValue)) {
|
||||||
|
// If the default value is an array, add all elements to the value.
|
||||||
|
queryValue = [...queryValue, ...defaultValue];
|
||||||
|
} else {
|
||||||
|
queryValue = [defaultValue];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Populate the underlying map with the initial data.
|
||||||
|
this.set(fieldName, { queryParam, queryValue, includeNull });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
`Data from 'data-filter-fields' attribute is improperly formatted: '${json}'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
10
netbox/project-static/src/select/api/index.ts
Normal file
10
netbox/project-static/src/select/api/index.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { getElements } from '../../util';
|
||||||
|
import { APISelect } from './apiSelect';
|
||||||
|
|
||||||
|
export function initApiSelect(): void {
|
||||||
|
for (const select of getElements<HTMLSelectElement>('.netbox-api-select')) {
|
||||||
|
new APISelect(select);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { Trigger } from './types';
|
111
netbox/project-static/src/select/api/types.ts
Normal file
111
netbox/project-static/src/select/api/types.ts
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import type { Stringifiable } from 'query-string';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map of string keys to primitive array values accepted by `query-string`. Keys are used as
|
||||||
|
* URL query parameter keys. Values correspond to query param values, enforced as an array
|
||||||
|
* for easier handling. For example, a mapping of `{ site_id: [1, 2] }` is serialized by
|
||||||
|
* `query-string` as `?site_id=1&site_id=2`. Likewise, `{ site_id: [1] }` is serialized as
|
||||||
|
* `?site_id=1`.
|
||||||
|
*/
|
||||||
|
export type QueryFilter = Map<string, Stringifiable[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracked data for a related field. This is the value of `APISelect.filterFields`.
|
||||||
|
*/
|
||||||
|
export type FilterFieldValue = {
|
||||||
|
/**
|
||||||
|
* Key to use in the query parameter itself.
|
||||||
|
*/
|
||||||
|
queryParam: string;
|
||||||
|
/**
|
||||||
|
* Value to use in the query parameter for the related field.
|
||||||
|
*/
|
||||||
|
queryValue: Stringifiable[];
|
||||||
|
/**
|
||||||
|
* @see `DataFilterFields.includeNull`
|
||||||
|
*/
|
||||||
|
includeNull: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON data passed from Django on the `data-filter-fields` attribute.
|
||||||
|
*/
|
||||||
|
export type DataFilterFields = {
|
||||||
|
/**
|
||||||
|
* Related field form name (`[name="<fieldName>"]`)
|
||||||
|
*
|
||||||
|
* @example tenant_group
|
||||||
|
*/
|
||||||
|
fieldName: string;
|
||||||
|
/**
|
||||||
|
* Key to use in the query parameter itself.
|
||||||
|
*
|
||||||
|
* @example group_id
|
||||||
|
*/
|
||||||
|
queryParam: string;
|
||||||
|
/**
|
||||||
|
* Optional default value. If set, value will be added to the query parameters prior to the
|
||||||
|
* initial API call and will be maintained until the field `fieldName` references (if one exists)
|
||||||
|
* is updated with a new value.
|
||||||
|
*
|
||||||
|
* @example 1
|
||||||
|
*/
|
||||||
|
defaultValue: Nullable<Stringifiable | Stringifiable[]>;
|
||||||
|
/**
|
||||||
|
* Include `null` on queries for the related field. For example, if `true`, `?<fieldName>=null`
|
||||||
|
* will be added to all API queries for this field.
|
||||||
|
*/
|
||||||
|
includeNull: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map of string keys to primitive values. Used to track variables within URLs from the server. For
|
||||||
|
* example, `/api/$key/thing`. `PathFilter` tracks `$key` as `{ key: '' }` in the map, and when the
|
||||||
|
* value is later known, the value is set — `{ key: 'value' }`, and the URL is transformed to
|
||||||
|
* `/api/value/thing`.
|
||||||
|
*/
|
||||||
|
export type PathFilter = Map<string, Stringifiable>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge or replace incoming options with current options.
|
||||||
|
*/
|
||||||
|
export type ApplyMethod = 'merge' | 'replace';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger for which the select instance should fetch its data from the NetBox API.
|
||||||
|
*/
|
||||||
|
export type Trigger =
|
||||||
|
/**
|
||||||
|
* Load data when the select element is opened.
|
||||||
|
*/
|
||||||
|
| 'open'
|
||||||
|
/**
|
||||||
|
* Load data when the element is loaded.
|
||||||
|
*/
|
||||||
|
| 'load'
|
||||||
|
/**
|
||||||
|
* Load data when a parent element is uncollapsed.
|
||||||
|
*/
|
||||||
|
| 'collapse';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strict Type Guard to determine if a deserialized value from the `data-filter-fields` attribute
|
||||||
|
* is of type `DataFilterFields`.
|
||||||
|
*
|
||||||
|
* @param value Deserialized value from `data-filter-fields` attribute.
|
||||||
|
*/
|
||||||
|
export function isDataFilterFields(value: unknown): value is DataFilterFields[] {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) {
|
||||||
|
if (typeof item === 'object' && item !== null) {
|
||||||
|
if ('fieldName' in item && 'queryParam' in item) {
|
||||||
|
return (
|
||||||
|
typeof (item as DataFilterFields).fieldName === 'string' &&
|
||||||
|
typeof (item as DataFilterFields).queryParam === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
@ -39,6 +39,8 @@ export function isTruthy<V extends unknown>(value: V): value is NonNullable<V> {
|
|||||||
return true;
|
return true;
|
||||||
} else if (typeof value === 'boolean') {
|
} else if (typeof value === 'boolean') {
|
||||||
return true;
|
return true;
|
||||||
|
} else if (typeof value === 'object' && value !== null) {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -170,9 +170,9 @@ class TenancyForm(forms.Form):
|
|||||||
tenant = DynamicModelChoiceField(
|
tenant = DynamicModelChoiceField(
|
||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
query_params={
|
filter_fields=[
|
||||||
'group_id': '$tenant_group'
|
{'accessor': 'group_id', 'field_name': 'tenant_group'}
|
||||||
}
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -188,9 +188,9 @@ class TenancyFilterForm(forms.Form):
|
|||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
null_option='None',
|
null_option='None',
|
||||||
query_params={
|
filter_fields=[
|
||||||
'group_id': '$tenant_group_id'
|
{'accessor': 'group_id', 'field_name': 'tenant_group'}
|
||||||
},
|
],
|
||||||
label=_('Tenant'),
|
label=_('Tenant'),
|
||||||
fetch_trigger='open'
|
fetch_trigger='open'
|
||||||
)
|
)
|
||||||
|
@ -371,17 +371,20 @@ class DynamicModelChoiceMixin:
|
|||||||
choice (optional)
|
choice (optional)
|
||||||
:param str fetch_trigger: The event type which will cause the select element to
|
:param str fetch_trigger: The event type which will cause the select element to
|
||||||
fetch data from the API. Must be 'load', 'open', or 'collapse'. (optional)
|
fetch data from the API. Must be 'load', 'open', or 'collapse'. (optional)
|
||||||
|
:param filter_fields: A dictionary or list of dictionaries that define a related
|
||||||
|
field. Example: `{'accessor': 'group_id', 'field_name': 'tenant_group'}` (optional)
|
||||||
"""
|
"""
|
||||||
filter = django_filters.ModelChoiceFilter
|
filter = django_filters.ModelChoiceFilter
|
||||||
widget = widgets.APISelect
|
widget = widgets.APISelect
|
||||||
|
|
||||||
def __init__(self, query_params=None, initial_params=None, null_option=None, disabled_indicator=None, fetch_trigger=None, *args,
|
def __init__(self, query_params=None, initial_params=None, null_option=None, disabled_indicator=None, fetch_trigger=None,
|
||||||
**kwargs):
|
filter_fields=[], *args, **kwargs):
|
||||||
self.query_params = query_params or {}
|
self.query_params = query_params or {}
|
||||||
self.initial_params = initial_params or {}
|
self.initial_params = initial_params or {}
|
||||||
self.null_option = null_option
|
self.null_option = null_option
|
||||||
self.disabled_indicator = disabled_indicator
|
self.disabled_indicator = disabled_indicator
|
||||||
self.fetch_trigger = fetch_trigger
|
self.fetch_trigger = fetch_trigger
|
||||||
|
self.filter_fields = filter_fields
|
||||||
|
|
||||||
# to_field_name is set by ModelChoiceField.__init__(), but we need to set it early for reference
|
# to_field_name is set by ModelChoiceField.__init__(), but we need to set it early for reference
|
||||||
# by widget_attrs()
|
# by widget_attrs()
|
||||||
@ -412,6 +415,10 @@ class DynamicModelChoiceMixin:
|
|||||||
for key, value in self.query_params.items():
|
for key, value in self.query_params.items():
|
||||||
widget.add_query_param(key, value)
|
widget.add_query_param(key, value)
|
||||||
|
|
||||||
|
# Attach any dynamic query parameters
|
||||||
|
if len(self.filter_fields) > 0:
|
||||||
|
widget.add_filter_fields(self.filter_fields)
|
||||||
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
def get_bound_field(self, form, field_name):
|
def get_bound_field(self, form, field_name):
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
from typing import Dict, Sequence, Union
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@ -26,6 +27,8 @@ __all__ = (
|
|||||||
'TimePicker',
|
'TimePicker',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
JSONPrimitive = Union[str, bool, int, float, None]
|
||||||
|
|
||||||
|
|
||||||
class SmallTextarea(forms.Textarea):
|
class SmallTextarea(forms.Textarea):
|
||||||
"""
|
"""
|
||||||
@ -142,22 +145,81 @@ class APISelect(SelectWithDisabled):
|
|||||||
if api_url:
|
if api_url:
|
||||||
self.attrs['data-url'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/')) # Inject BASE_PATH
|
self.attrs['data-url'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/')) # Inject BASE_PATH
|
||||||
|
|
||||||
def add_query_param(self, name, value):
|
def add_query_param(self, key: str, value: JSONPrimitive) -> None:
|
||||||
"""
|
"""
|
||||||
Add details for an additional query param in the form of a data-* JSON-encoded list attribute.
|
Add a query parameter with a static value to the API request.
|
||||||
|
|
||||||
:param name: The name of the query param
|
|
||||||
:param value: The value of the query param
|
|
||||||
"""
|
"""
|
||||||
key = f'data-query-param-{name}'
|
self.add_filter_fields({'accessor': key, 'field_name': key, 'default_value': value})
|
||||||
|
|
||||||
values = json.loads(self.attrs.get(key, '[]'))
|
def add_filter_fields(self, filter_fields: Union[Dict[str, JSONPrimitive], Sequence[Dict[str, JSONPrimitive]]]) -> None:
|
||||||
if type(value) in (list, tuple):
|
"""
|
||||||
values.extend([str(v) for v in value])
|
Add details about another form field, the value for which should
|
||||||
else:
|
be added to this APISelect's URL query parameters.
|
||||||
values.append(str(value))
|
|
||||||
|
|
||||||
self.attrs[key] = json.dumps(values)
|
:Example:
|
||||||
|
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
'field_name': 'tenant_group',
|
||||||
|
'accessor': 'tenant',
|
||||||
|
'default_value': 1,
|
||||||
|
'include_null': False,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
:param filter_fields: Dict or list of dicts with the following properties:
|
||||||
|
|
||||||
|
- accessor: The related field's property name. For example, on the
|
||||||
|
`Tenant`model, a related model might be `TenantGroup`. In
|
||||||
|
this case, `accessor` would be `group_id`.
|
||||||
|
|
||||||
|
- field_name: The related field's form name. In the above `Tenant`
|
||||||
|
example, `field_name` would be `tenant_group`.
|
||||||
|
|
||||||
|
- default_value: (Optional) Set a default initial value, which can be
|
||||||
|
overridden if the field changes.
|
||||||
|
|
||||||
|
- include_null: (Optional) Include `null` on queries for the related
|
||||||
|
field. For example, if `True`, `?<fieldName>=null` will
|
||||||
|
be added to all API queries for this field.
|
||||||
|
|
||||||
|
"""
|
||||||
|
key = 'data-filter-fields'
|
||||||
|
# Deserialize the current serialized value from the widget, using an empty JSON
|
||||||
|
# array as a fallback in the event one is not defined.
|
||||||
|
current = json.loads(self.attrs.get(key, '[]'))
|
||||||
|
|
||||||
|
# Create a new list of filter fields using camelCse to align with front-end code standards
|
||||||
|
# (this value will be read and used heavily at the JavaScript layer).
|
||||||
|
update: Sequence[Dict[str, str]] = []
|
||||||
|
try:
|
||||||
|
if isinstance(filter_fields, Sequence):
|
||||||
|
update = [
|
||||||
|
{
|
||||||
|
'fieldName': field['field_name'],
|
||||||
|
'queryParam': field['accessor'],
|
||||||
|
'defaultValue': field.get('default_value'),
|
||||||
|
'includeNull': field.get('include_null', False),
|
||||||
|
} for field in filter_fields
|
||||||
|
]
|
||||||
|
elif isinstance(filter_fields, Dict):
|
||||||
|
update = [
|
||||||
|
{
|
||||||
|
'fieldName': filter_fields['field_name'],
|
||||||
|
'queryParam': filter_fields['accessor'],
|
||||||
|
'defaultValue': filter_fields.get('default_value'),
|
||||||
|
'includeNull': filter_fields.get('include_null', False),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
except KeyError as error:
|
||||||
|
raise KeyError(f"Missing required property '{error.args[0]}' on APISelect.filter_fields") from error
|
||||||
|
|
||||||
|
# Combine the current values with the updated values and serialize the result as
|
||||||
|
# JSON. Note: the `separators` kwarg effectively removes extra whitespace from
|
||||||
|
# the serialized JSON string, which is ideal since these will be passed as
|
||||||
|
# attributes to HTML elements and parsed on the client.
|
||||||
|
self.attrs[key] = json.dumps([*current, *update], separators=(',', ':'))
|
||||||
|
|
||||||
|
|
||||||
class APISelectMultiple(APISelect, forms.SelectMultiple):
|
class APISelectMultiple(APISelect, forms.SelectMultiple):
|
||||||
|
@ -126,10 +126,10 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
|||||||
site = DynamicModelChoiceField(
|
site = DynamicModelChoiceField(
|
||||||
queryset=Site.objects.all(),
|
queryset=Site.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
query_params={
|
filter_fields=[
|
||||||
'region_id': '$region',
|
{'accessor': 'region_id', 'field_name': 'region'},
|
||||||
'group_id': '$site_group',
|
{'accessor': 'group_id', 'field_name': 'site_group'},
|
||||||
}
|
],
|
||||||
)
|
)
|
||||||
comments = CommentField()
|
comments = CommentField()
|
||||||
tags = DynamicModelMultipleChoiceField(
|
tags = DynamicModelMultipleChoiceField(
|
||||||
@ -206,10 +206,10 @@ class ClusterBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBul
|
|||||||
site = DynamicModelChoiceField(
|
site = DynamicModelChoiceField(
|
||||||
queryset=Site.objects.all(),
|
queryset=Site.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
query_params={
|
filter_fields=[
|
||||||
'region_id': '$region',
|
{'accessor': 'region_id', 'field_name': 'region'},
|
||||||
'group_id': '$site_group',
|
{'accessor': 'group_id', 'field_name': 'site_group'},
|
||||||
}
|
]
|
||||||
)
|
)
|
||||||
comments = CommentField(
|
comments = CommentField(
|
||||||
widget=SmallTextarea,
|
widget=SmallTextarea,
|
||||||
@ -260,10 +260,10 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilte
|
|||||||
queryset=Site.objects.all(),
|
queryset=Site.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
null_option='None',
|
null_option='None',
|
||||||
query_params={
|
filter_fields=[
|
||||||
'region_id': '$region_id',
|
{'accessor': 'region_id', 'field_name': 'region_id'},
|
||||||
'site_group_id': '$site_group_id',
|
{'accessor': 'site_group_id', 'field_name': 'site_group'},
|
||||||
},
|
],
|
||||||
label=_('Site'),
|
label=_('Site'),
|
||||||
fetch_trigger='open'
|
fetch_trigger='open'
|
||||||
)
|
)
|
||||||
@ -291,26 +291,26 @@ class ClusterAddDevicesForm(BootstrapMixin, forms.Form):
|
|||||||
site = DynamicModelChoiceField(
|
site = DynamicModelChoiceField(
|
||||||
queryset=Site.objects.all(),
|
queryset=Site.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
query_params={
|
filter_fields=[
|
||||||
'region_id': '$region',
|
{'accessor': 'region_id', 'field_name': 'region'},
|
||||||
'group_id': '$site_group',
|
{'accessor': 'group_id', 'field_name': 'site_group'},
|
||||||
}
|
]
|
||||||
)
|
)
|
||||||
rack = DynamicModelChoiceField(
|
rack = DynamicModelChoiceField(
|
||||||
queryset=Rack.objects.all(),
|
queryset=Rack.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
null_option='None',
|
null_option='None',
|
||||||
query_params={
|
filter_fields=[
|
||||||
'site_id': '$site'
|
{'accessor': 'site_id', 'field_name': 'site'},
|
||||||
}
|
]
|
||||||
)
|
)
|
||||||
devices = DynamicModelMultipleChoiceField(
|
devices = DynamicModelMultipleChoiceField(
|
||||||
queryset=Device.objects.all(),
|
queryset=Device.objects.all(),
|
||||||
query_params={
|
filter_fields=[
|
||||||
'site_id': '$site',
|
{'accessor': 'site_id', 'field_name': 'site'},
|
||||||
'rack_id': '$rack',
|
{'accessor': 'rack_id', 'field_name': 'rack'},
|
||||||
'cluster_id': 'null',
|
{'accessor': 'cluster_id', 'field_name': 'cluster', 'default_value': None},
|
||||||
}
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -362,16 +362,16 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
|||||||
)
|
)
|
||||||
cluster = DynamicModelChoiceField(
|
cluster = DynamicModelChoiceField(
|
||||||
queryset=Cluster.objects.all(),
|
queryset=Cluster.objects.all(),
|
||||||
query_params={
|
filter_fields=[
|
||||||
'group_id': '$cluster_group'
|
{'accessor': 'group_id', 'field_name': 'cluster_group'},
|
||||||
}
|
]
|
||||||
)
|
)
|
||||||
role = DynamicModelChoiceField(
|
role = DynamicModelChoiceField(
|
||||||
queryset=DeviceRole.objects.all(),
|
queryset=DeviceRole.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
query_params={
|
filter_fields=[
|
||||||
"vm_role": "True"
|
{'accessor': 'vm_role', 'field_name': 'vm_role', 'default_value': True},
|
||||||
}
|
],
|
||||||
)
|
)
|
||||||
platform = DynamicModelChoiceField(
|
platform = DynamicModelChoiceField(
|
||||||
queryset=Platform.objects.all(),
|
queryset=Platform.objects.all(),
|
||||||
@ -510,9 +510,9 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldM
|
|||||||
vm_role=True
|
vm_role=True
|
||||||
),
|
),
|
||||||
required=False,
|
required=False,
|
||||||
query_params={
|
filter_fields=[
|
||||||
"vm_role": "True"
|
{'accessor': 'vm_role', 'field_name': 'vm_role', 'default_value': True},
|
||||||
}
|
]
|
||||||
)
|
)
|
||||||
tenant = DynamicModelChoiceField(
|
tenant = DynamicModelChoiceField(
|
||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
@ -595,10 +595,10 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldMod
|
|||||||
queryset=Site.objects.all(),
|
queryset=Site.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
null_option='None',
|
null_option='None',
|
||||||
query_params={
|
filter_fields=[
|
||||||
'region_id': '$region_id',
|
{'accessor': 'region_id', 'field_name': 'region_id'},
|
||||||
'group_id': '$site_group_id',
|
{'accessor': 'group_id', 'field_name': 'site_group_id'},
|
||||||
},
|
],
|
||||||
label=_('Site'),
|
label=_('Site'),
|
||||||
fetch_trigger='open'
|
fetch_trigger='open'
|
||||||
)
|
)
|
||||||
@ -606,9 +606,9 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldMod
|
|||||||
queryset=DeviceRole.objects.all(),
|
queryset=DeviceRole.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
null_option='None',
|
null_option='None',
|
||||||
query_params={
|
filter_fields=[
|
||||||
'vm_role': "True"
|
{'accessor': 'vm_role', 'field_name': 'vm_role', 'default_value': True},
|
||||||
},
|
],
|
||||||
label=_('Role'),
|
label=_('Role'),
|
||||||
fetch_trigger='open'
|
fetch_trigger='open'
|
||||||
)
|
)
|
||||||
@ -657,17 +657,17 @@ class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm)
|
|||||||
queryset=VLAN.objects.all(),
|
queryset=VLAN.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
label='Untagged VLAN',
|
label='Untagged VLAN',
|
||||||
query_params={
|
filter_fields=[
|
||||||
'group_id': '$vlan_group',
|
{'accessor': 'group_id', 'field_name': 'vlan_group'},
|
||||||
}
|
]
|
||||||
)
|
)
|
||||||
tagged_vlans = DynamicModelMultipleChoiceField(
|
tagged_vlans = DynamicModelMultipleChoiceField(
|
||||||
queryset=VLAN.objects.all(),
|
queryset=VLAN.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
label='Tagged VLANs',
|
label='Tagged VLANs',
|
||||||
query_params={
|
filter_fields=[
|
||||||
'group_id': '$vlan_group',
|
{'accessor': 'group_id', 'field_name': 'vlan_group'},
|
||||||
}
|
]
|
||||||
)
|
)
|
||||||
tags = DynamicModelMultipleChoiceField(
|
tags = DynamicModelMultipleChoiceField(
|
||||||
queryset=Tag.objects.all(),
|
queryset=Tag.objects.all(),
|
||||||
@ -718,9 +718,9 @@ class VMInterfaceCreateForm(BootstrapMixin, CustomFieldsMixin, InterfaceCommonFo
|
|||||||
parent = DynamicModelChoiceField(
|
parent = DynamicModelChoiceField(
|
||||||
queryset=VMInterface.objects.all(),
|
queryset=VMInterface.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
query_params={
|
filter_fields=[
|
||||||
'virtual_machine_id': '$virtual_machine',
|
{'accessor': 'virtual_machine_id', 'field_name': 'virtual_machine'},
|
||||||
}
|
]
|
||||||
)
|
)
|
||||||
mac_address = forms.CharField(
|
mac_address = forms.CharField(
|
||||||
required=False,
|
required=False,
|
||||||
@ -896,9 +896,9 @@ class VMInterfaceFilterForm(BootstrapMixin, forms.Form):
|
|||||||
virtual_machine_id = DynamicModelMultipleChoiceField(
|
virtual_machine_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=VirtualMachine.objects.all(),
|
queryset=VirtualMachine.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
query_params={
|
filter_fields=[
|
||||||
'cluster_id': '$cluster_id'
|
{'accessor': 'cluster_id', 'field_name': 'cluster_id'},
|
||||||
},
|
],
|
||||||
label=_('Virtual machine'),
|
label=_('Virtual machine'),
|
||||||
fetch_trigger='open'
|
fetch_trigger='open'
|
||||||
)
|
)
|
||||||
|
Loading…
Reference in New Issue
Block a user