Skip to content

Commit b639dc5

Browse files
committed
Fixes #7604: Add filter modifier dropdowns for advanced lookup operators
Implements dynamic filter modifier UI that allows users to select lookup operators (exact, contains, starts with, regex, negation, empty/not empty) directly in filter forms without manual URL parameter editing. Supports filters for all scalar types and strings, as well as some related object filters. Explicitly does not support filters on fields that use APIWidget. That has been broken out in to follow up work. **Backend:** - FilterModifierWidget: Wraps form widgets with lookup modifier dropdown - FilterModifierMixin: Auto-enhances filterset fields with appropriate lookups - Extended lookup support: Adds negation (n), regex, iregex, empty_true/false lookups - Field-type-aware: CharField gets text lookups, IntegerField gets comparison operators, etc. **Frontend:** - TypeScript handler syncs modifier dropdown with URL parameters - Dynamically updates form field names (serial → serial__ic) on modifier change - Flexible-width modifier dropdowns with semantic CSS classes
1 parent bf83299 commit b639dc5

File tree

15 files changed

+893
-18
lines changed

15 files changed

+893
-18
lines changed

netbox/circuits/forms/filtersets.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@
1313
from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
1414
from utilities.forms import add_blank_choice
1515
from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
16+
from utilities.forms.filterset_mappings import FILTERSET_MAPPINGS
17+
from utilities.forms.mixins import FilterModifierMixin
1618
from utilities.forms.rendering import FieldSet
1719
from utilities.forms.widgets import DatePicker, NumberWithOptions
20+
from circuits.filtersets import CircuitFilterSet
1821

1922
__all__ = (
2023
'CircuitFilterForm',
@@ -118,7 +121,7 @@ class CircuitTypeFilterForm(OrganizationalModelFilterSetForm):
118121
)
119122

120123

121-
class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm):
124+
class CircuitFilterForm(FilterModifierMixin, TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
122125
model = Circuit
123126
fieldsets = (
124127
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
@@ -397,3 +400,7 @@ class VirtualCircuitTerminationFilterForm(NetBoxModelFilterSetForm):
397400
label=_('Provider')
398401
)
399402
tag = TagFilterField(model)
403+
404+
405+
# Register FilterSet mappings for FilterModifierMixin lookup verification
406+
FILTERSET_MAPPINGS[CircuitFilterForm] = CircuitFilterSet

netbox/dcim/forms/filtersets.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from dcim.choices import *
55
from dcim.constants import *
6+
from dcim.filtersets import DeviceFilterSet, RackFilterSet, PowerOutletFilterSet
67
from dcim.models import *
78
from extras.forms import LocalConfigContextFilterForm
89
from extras.models import ConfigTemplate
@@ -16,6 +17,8 @@
1617
from users.models import Owner, User
1718
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
1819
from utilities.forms.fields import ColorField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField
20+
from utilities.forms.filterset_mappings import FILTERSET_MAPPINGS
21+
from utilities.forms.mixins import FilterModifierMixin
1922
from utilities.forms.rendering import FieldSet
2023
from utilities.forms.widgets import NumberWithOptions
2124
from virtualization.models import Cluster, ClusterGroup, VirtualMachine
@@ -330,7 +333,7 @@ class RackTypeFilterForm(RackBaseFilterForm):
330333
tag = TagFilterField(model)
331334

332335

333-
class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterForm):
336+
class RackFilterForm(FilterModifierMixin, TenancyFilterForm, ContactModelFilterForm, RackBaseFilterForm):
334337
model = Rack
335338
fieldsets = (
336339
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
@@ -759,6 +762,7 @@ class PlatformFilterForm(NestedGroupModelFilterSetForm):
759762

760763

761764
class DeviceFilterForm(
765+
FilterModifierMixin,
762766
LocalConfigContextFilterForm,
763767
TenancyFilterForm,
764768
ContactModelFilterForm,
@@ -1396,7 +1400,7 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
13961400
tag = TagFilterField(model)
13971401

13981402

1399-
class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
1403+
class PowerOutletFilterForm(FilterModifierMixin, PathEndpointFilterForm, DeviceComponentFilterForm):
14001404
model = PowerOutlet
14011405
fieldsets = (
14021406
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
@@ -1791,3 +1795,9 @@ class InterfaceConnectionFilterForm(FilterForm):
17911795
},
17921796
label=_('Device')
17931797
)
1798+
1799+
1800+
# Register FilterSet mappings for FilterModifierMixin lookup verification
1801+
FILTERSET_MAPPINGS[DeviceFilterForm] = DeviceFilterSet
1802+
FILTERSET_MAPPINGS[RackFilterForm] = RackFilterSet
1803+
FILTERSET_MAPPINGS[PowerOutletFilterForm] = PowerOutletFilterSet

netbox/project-static/dist/netbox.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

netbox/project-static/dist/netbox.js

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

netbox/project-static/dist/netbox.js.map

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import { getElements } from '../util';
2+
3+
// Modifier codes for empty/null checking
4+
// These map to Django's 'empty' lookup: field__empty=true/false
5+
const MODIFIER_EMPTY_TRUE = 'empty_true';
6+
const MODIFIER_EMPTY_FALSE = 'empty_false';
7+
8+
/**
9+
* Initialize filter modifier functionality.
10+
*
11+
* Handles transformation of field names based on modifier selection
12+
* at form submission time using the FormData API.
13+
*/
14+
export function initFilterModifiers(): void {
15+
for (const form of getElements<HTMLFormElement>('form')) {
16+
// Only process forms with modifier selects
17+
const modifierSelects = form.querySelectorAll<HTMLSelectElement>('.modifier-select');
18+
if (modifierSelects.length === 0) continue;
19+
20+
// Initialize form state from URL parameters
21+
initializeFromURL(form);
22+
23+
// Add change listeners to modifier dropdowns to handle isnull
24+
modifierSelects.forEach(select => {
25+
select.addEventListener('change', () => handleModifierChange(select));
26+
// Trigger initial state
27+
handleModifierChange(select);
28+
});
29+
30+
// Handle form submission - must use submit event for GET forms
31+
form.addEventListener('submit', e => {
32+
e.preventDefault();
33+
34+
// Build FormData to get all form values
35+
const formData = new FormData(form);
36+
37+
// Transform field names
38+
handleFormDataTransform(form, formData);
39+
40+
// Build URL with transformed parameters
41+
const params = new URLSearchParams();
42+
for (const [key, value] of formData.entries()) {
43+
if (value && String(value).trim()) {
44+
params.append(key, String(value));
45+
}
46+
}
47+
48+
// Navigate to new URL
49+
window.location.href = `${form.action}?${params.toString()}`;
50+
});
51+
}
52+
}
53+
54+
/**
55+
* Handle modifier dropdown changes - disable/enable value input for empty lookups.
56+
*/
57+
function handleModifierChange(modifierSelect: HTMLSelectElement): void {
58+
const group = modifierSelect.closest('.filter-modifier-group');
59+
if (!group) return;
60+
61+
const wrapper = group.querySelector('.filter-value-container');
62+
if (!wrapper) return;
63+
64+
const valueInput = wrapper.querySelector<
65+
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
66+
>('input, select, textarea');
67+
68+
if (!valueInput) return;
69+
70+
const modifier = modifierSelect.value;
71+
72+
// Disable and add placeholder for empty modifiers
73+
if (modifier === MODIFIER_EMPTY_TRUE || modifier === MODIFIER_EMPTY_FALSE) {
74+
valueInput.disabled = true;
75+
valueInput.value = '';
76+
// Get translatable placeholder from modifier dropdown's data attribute
77+
const placeholder = modifierSelect.dataset.emptyPlaceholder || '(automatically set)';
78+
valueInput.setAttribute('placeholder', placeholder);
79+
} else {
80+
valueInput.disabled = false;
81+
valueInput.removeAttribute('placeholder');
82+
}
83+
}
84+
85+
/**
86+
* Transform field names in FormData based on modifier selection.
87+
*/
88+
function handleFormDataTransform(form: HTMLFormElement, formData: FormData): void {
89+
const modifierGroups = form.querySelectorAll('.filter-modifier-group');
90+
91+
for (const group of modifierGroups) {
92+
const modifierSelect = group.querySelector<HTMLSelectElement>('.modifier-select');
93+
// Find input in the wrapper div (more specific selector)
94+
const wrapper = group.querySelector('.filter-value-container');
95+
if (!wrapper) continue;
96+
97+
const valueInput = wrapper.querySelector<
98+
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
99+
>('input, select, textarea');
100+
101+
if (!modifierSelect || !valueInput) continue;
102+
103+
const currentName = valueInput.name;
104+
const modifier = modifierSelect.value;
105+
106+
// Handle empty special case
107+
if (modifier === MODIFIER_EMPTY_TRUE || modifier === MODIFIER_EMPTY_FALSE) {
108+
formData.delete(currentName);
109+
const boolValue = modifier === MODIFIER_EMPTY_TRUE ? 'true' : 'false';
110+
formData.set(`${currentName}__empty`, boolValue);
111+
} else {
112+
// Get all values (handles multi-select)
113+
const values = formData.getAll(currentName);
114+
115+
if (values.length > 0 && values.some(v => String(v).trim())) {
116+
formData.delete(currentName);
117+
const newName = modifier === 'exact' ? currentName : `${currentName}__${modifier}`;
118+
119+
// Set all values with the new name
120+
for (const value of values) {
121+
if (String(value).trim()) {
122+
formData.append(newName, value);
123+
}
124+
}
125+
} else {
126+
formData.delete(currentName);
127+
}
128+
}
129+
}
130+
}
131+
132+
/**
133+
* Initialize form state from URL parameters.
134+
* Restores modifier selection and values from query string.
135+
*
136+
* Process:
137+
* 1. Parse URL parameters
138+
* 2. For each modifier group, check which lookup variant exists in URL
139+
* 3. Set modifier dropdown to match
140+
* 4. Populate value field with parameter value
141+
*/
142+
function initializeFromURL(form: HTMLFormElement): void {
143+
const urlParams = new URLSearchParams(window.location.search);
144+
145+
// Find all modifier groups
146+
const modifierGroups = form.querySelectorAll('.filter-modifier-group');
147+
148+
for (const group of modifierGroups) {
149+
const modifierSelect = group.querySelector<HTMLSelectElement>('.modifier-select');
150+
// Find input in the wrapper div (more specific selector)
151+
const wrapper = group.querySelector('.filter-value-container');
152+
if (!wrapper) continue;
153+
154+
const valueInput = wrapper.querySelector<
155+
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
156+
>('input, select, textarea');
157+
158+
if (!modifierSelect || !valueInput) continue;
159+
160+
const baseFieldName = valueInput.name; // e.g., "serial"
161+
162+
// Special handling for empty - check if field__empty exists in URL
163+
const emptyParam = `${baseFieldName}__empty`;
164+
if (urlParams.has(emptyParam)) {
165+
const emptyValue = urlParams.get(emptyParam);
166+
const modifier = emptyValue === 'true' ? MODIFIER_EMPTY_TRUE : MODIFIER_EMPTY_FALSE;
167+
modifierSelect.value = modifier;
168+
continue; // Don't set value input for empty
169+
}
170+
171+
// Check each possible lookup in URL
172+
for (const option of modifierSelect.options) {
173+
const lookup = option.value;
174+
175+
// Skip empty_true/false as they're handled above
176+
if (lookup === MODIFIER_EMPTY_TRUE || lookup === MODIFIER_EMPTY_FALSE) continue;
177+
178+
const paramName = lookup === 'exact' ? baseFieldName : `${baseFieldName}__${lookup}`;
179+
180+
if (urlParams.has(paramName)) {
181+
modifierSelect.value = lookup;
182+
183+
// Handle multi-select vs single-value inputs
184+
if (valueInput instanceof HTMLSelectElement && valueInput.multiple) {
185+
// For multi-select, set selected on matching options
186+
const values = urlParams.getAll(paramName);
187+
for (const option of valueInput.options) {
188+
option.selected = values.includes(option.value);
189+
}
190+
} else {
191+
// For single-value inputs, set value directly
192+
valueInput.value = urlParams.get(paramName) || '';
193+
}
194+
break;
195+
}
196+
}
197+
}
198+
}
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { initFormElements } from './elements';
2+
import { initFilterModifiers } from './filterModifiers';
23
import { initSpeedSelector } from './speedSelector';
34

45
export function initForms(): void {
5-
for (const func of [initFormElements, initSpeedSelector]) {
6+
for (const func of [initFormElements, initSpeedSelector, initFilterModifiers]) {
67
func();
78
}
89
}

netbox/project-static/styles/transitional/_forms.scss

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,11 @@ form.object-edit {
3232
border: 1px solid $red;
3333
}
3434
}
35+
36+
// Filter modifier dropdown sizing
37+
.modifier-select {
38+
min-width: 10rem;
39+
max-width: 15rem;
40+
width: auto;
41+
white-space: nowrap;
42+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Mapping of filter form classes to their corresponding FilterSet classes
2+
# This enables the FilterModifierMixin to verify which lookups are actually supported
3+
# by checking the FilterSet's auto-generated lookup filters.
4+
#
5+
# Usage:
6+
# from utilities.forms.filterset_mappings import FILTERSET_MAPPINGS
7+
# from .forms.filtersets import XFilterForm
8+
# from .filtersets import XFilterSet
9+
# FILTERSET_MAPPINGS[XFilterForm] = XFilterSet
10+
11+
FILTERSET_MAPPINGS = {}

0 commit comments

Comments
 (0)