Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,36 @@

All notable changes to netbox-super-cli are tracked here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loosely. From v1.0.0 onward, releases follow [Semantic Versioning](https://semver.org/) and the version in `pyproject.toml` matches the git tag. Pre-1.0 milestones (Phase 1-5) were pinned by tag while `pyproject.toml` stayed at `0.0.1`.

## v1.6.1 — 2026-06-29

Patch release. Fixes the TUI bulk-edit form so custom-field edits are visible and
actually save.

### Fixed

- **Bulk-edit include toggles were invisible, so edits silently didn't save**
([#137]). The per-field include switch in the bulk-edit form was clipped to
zero width by its stylesheet and rendered blank — users couldn't see or flip
it, so no field was opted in and the apply produced no change with no error.
The toggle now sizes to fit its slider.
- **Edit forms showed the raw `custom_fields.<name>` key** ([#137]). The
single-edit and bulk-edit forms, and the change-preview diff, now show a custom
field's human label (e.g. "Site Contact") instead of the raw dotted key,
matching the list-column labels added in v1.6.0.
- **Opting in a custom-field select with an unresolved choice set could silently
null the field** ([#137]). A select whose choices couldn't be fetched rendered
empty; opting it in seeded a null and cleared a value the user never edited.
Opting in a blank select now contributes no change; only the explicit ∅ button
nulls.
- **Bulk-editing a shared custom field could overwrite it on opt-in** ([#137]).
Custom-field widgets didn't seed the value the selected records share, so a
shared boolean rendered as `False` and opting it in unchanged flipped every
record to `False` (and a shared multiselect cleared to empty). Custom-field
widgets now seed the shared value, so opting a field in without editing it is a
no-op.

[#137]: https://github.com/thomaschristory/netbox-super-cli/issues/137

## v1.6.0 — 2026-06-29

Minor release. The interactive TUI gets human-readable custom-field columns,
Expand Down
8 changes: 5 additions & 3 deletions docs/guides/interactive-tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,11 @@ endpoint can filter by this one. Switch tabs with <kbd>Tab</kbd> and press
## Bulk editing

Select rows on a list with <kbd>v</kbd>/<kbd>Space</kbd>, then press
<kbd>B</kbd>. Choose *which* fields to set (each has an include toggle) and one
value each. Fields are **prepopulated** with the value the selected records
share, so a small tweak doesn't mean retyping.
<kbd>B</kbd>. Each field is one row: flip its **include toggle** (the switch on
the left) to opt it into the change, then set its value — a field with the toggle
off is left untouched. Custom fields appear as individual rows under their human
label. Fields are **prepopulated** with the value the selected records share, so
a small tweak doesn't mean retyping.

Press <kbd>p</kbd> to **preview** — a per-record diff of exactly what will
change (records already matching are left untouched). Confirm to apply; a
Expand Down
2 changes: 1 addition & 1 deletion nsc/_version.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Single source of truth for the package version."""

__version__ = "1.6.0"
__version__ = "1.6.1"
6 changes: 4 additions & 2 deletions nsc/tui/bulk.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,19 +29,21 @@ def bulk_diff(
bulk_set: dict[str, object],
sensitive_paths: tuple[str, ...],
new_displays: dict[str, str] | None = None,
field_labels: dict[str, str] | None = None,
) -> list[RecordChange]:
"""Compute the per-record patch and diff rows for a uniform bulk ``set``.

Order of ``selected`` is preserved. A field whose new value equals a
record's current value contributes no patch entry and no row for that
record, so heterogeneous current values yield different changed subsets.
``new_displays`` maps a field to the human label of its chosen FK value so
the diff renders the name rather than the id.
the diff renders the name rather than the id. ``field_labels`` maps a field
key to its human label so ``custom_fields.<name>`` rows aren't shown raw.
"""
changes: list[RecordChange] = []
for record in selected:
patch = compute_patch(record, bulk_set)
rows = diff_rows(record, patch, sensitive_paths, new_displays)
rows = diff_rows(record, patch, sensitive_paths, new_displays, field_labels)
changes.append(RecordChange(record_id=record.get("id"), patch=patch, rows=rows))
return changes

Expand Down
21 changes: 17 additions & 4 deletions nsc/tui/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ class WidgetSpec(BaseModel):

kind: WidgetKind
name: str
# Human-friendly row label; falls back to ``name`` when blank (regular fields
# keep their key as the label; custom fields carry their NetBox label here).
label: str = ""
choices: tuple[str, ...] = ()
nullable: bool = False
sensitive: bool = False
Expand Down Expand Up @@ -83,15 +86,20 @@ def custom_field_widget(cf: CustomFieldDef) -> WidgetSpec:
kind = _CF_KINDS.get(cf.type, "text")
nullable = not cf.required
if kind == "select":
return WidgetSpec(kind="select", name=name, choices=cf.choices, nullable=nullable)
return WidgetSpec(
kind="select", name=name, label=cf.label, choices=cf.choices, nullable=nullable
)
if kind == "multi_select":
return WidgetSpec(
kind="multi_select",
name=name,
label=cf.label,
options=tuple((c, c) for c in cf.choices),
nullable=nullable,
)
return WidgetSpec(kind=kind, name=name, nullable=nullable, is_float=cf.type == "decimal")
return WidgetSpec(
kind=kind, name=name, label=cf.label, nullable=nullable, is_float=cf.type == "decimal"
)


def expand_custom_fields(defs: dict[str, CustomFieldDef]) -> list[WidgetSpec]:
Expand Down Expand Up @@ -242,20 +250,25 @@ def diff_rows(
patch: dict[str, object],
sensitive_paths: tuple[str, ...],
new_displays: dict[str, str] | None = None,
field_labels: dict[str, str] | None = None,
) -> list[DiffRow]:
"""Render ``patch`` as human-readable old -> new rows for the confirm modal.

``new_displays`` overrides a field's rendered *new* value — used for FK
fields whose staged value is a bare id but whose chosen label is known
(e.g. picked from the record chooser). The patch still carries the id.
``field_labels`` overrides a field's *name* — used so a ``custom_fields.<name>``
key renders as its human label rather than the raw dotted key.
"""
overrides = new_displays or {}
labels = field_labels or {}
rows: list[DiffRow] = []
for name, new_value in patch.items():
field = labels.get(name, name)
if name in sensitive_paths:
rows.append(DiffRow(field=name, old_display="****", new_display="****"))
rows.append(DiffRow(field=field, old_display="****", new_display="****"))
continue
old_display = fk_display(original[name]) if name in original else ""
new_display = overrides.get(name, str(new_value))
rows.append(DiffRow(field=name, old_display=old_display, new_display=new_display))
rows.append(DiffRow(field=field, old_display=old_display, new_display=new_display))
return rows
37 changes: 32 additions & 5 deletions nsc/tui/screens/bulk_edit_form.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,15 @@ def __init__(
self._fk_labels: dict[str, str] = {}
body = update_op.request_body
field_names = list(body.fields) if body is not None else []
# Custom fields are staged under flattened ``custom_fields.<name>`` keys,
# so seed their shared value from flattened records too — otherwise a
# widget defaults (e.g. a boolean to False) and opting it in unchanged
# would silently overwrite the records' shared value.
cf_names = [f"custom_fields.{cf.name}" for cf in (custom_field_defs or {}).values()]
flattened_records = [flatten_custom_fields(record) for record in selected_records]
# Shared current value per field, to seed the widgets (does NOT opt the
# field in — the include toggle still gates what gets set).
self._shared = shared_values(selected_records, field_names)
self._shared = shared_values(flattened_records, field_names + cf_names)
self.progress_total = 0
self.progress_done = 0
self.title = f"Bulk edit {len(selected_records)} {resource_name}"
Expand All @@ -111,6 +117,10 @@ def __init__(
def bulk_set(self) -> dict[str, Any]:
return {name: self._values[name] for name in self._included if name in self._values}

def _field_labels(self) -> dict[str, str]:
"""Human labels for fields that carry one (custom fields), for the diff."""
return {name: spec.label for name, spec in self._specs.items() if spec.label}

def compose(self) -> ComposeResult:
yield Header()
with VerticalScroll(id="bulk-form-body"):
Expand Down Expand Up @@ -142,7 +152,7 @@ def _specs_for(self, name: str, field: Any, sensitive: tuple[str, ...]) -> list[
def _compose_field(self, name: str, spec: WidgetSpec) -> ComposeResult:
with Horizontal(classes="bulk-field"):
yield Switch(value=False, id=f"include-{encode_field_id(name)}", classes="bulk-include")
yield Label(name, classes="bulk-label")
yield Label(spec.label or name, classes="bulk-label")
yield from self._compose_widget(name, spec)
if spec.nullable and spec.kind != "multi_select":
yield Button("∅", id=f"setnull-{encode_field_id(name)}", classes="bulk-setnull")
Expand Down Expand Up @@ -191,8 +201,16 @@ def _compose_fk(self, name: str) -> ComposeResult:
def _compose_widget(self, name: str, spec: WidgetSpec) -> ComposeResult:
wid = f"field-{encode_field_id(name)}"
if spec.kind == "multi_select":
# Tags seed via spec.selected; a custom-field multiselect seeds its
# shared current list so opting it in unchanged isn't a destructive
# clear (tags are intentionally left blank — they have their own flow).
selected = set(spec.selected)
if not selected and name != "tags":
shared = self._shared.get(name)
if isinstance(shared, list):
selected = {str(v) for v in shared}
yield SelectionList[str](
*(Selection(label, val, val in spec.selected) for label, val in spec.options),
*(Selection(label, val, val in selected) for label, val in spec.options),
id=wid,
classes="bulk-multiselect",
)
Expand Down Expand Up @@ -264,7 +282,10 @@ def _read_widget_value(self, name: str) -> Any:
return self._multiselect_value(name, self.query_one(wid, SelectionList).selected)
if spec is not None and spec.kind == "select":
value = self.query_one(wid, Select).value
return None if value is Select.NULL else value
# A blank select (no/unresolved choices, or a current value not in the
# options) must not seed a null on opt-in — that would silently clear a
# field the user never edited. Only the explicit ∅ button nulls.
return _NO_SEED if value is Select.NULL else value
if spec is not None and spec.kind == "switch":
return self.query_one(wid, Switch).value
return self._coerce_input(name, self.query_one(wid, Input).value)
Expand All @@ -279,6 +300,10 @@ def on_select_changed(self, event: Select.Changed) -> None:
name = self._strip(event.select.id, "field-")
if name is None:
return
# A blank select on a field the user hasn't opted in must not stage a null
# (which would clear the field on apply). Only the explicit ∅ button nulls.
if event.value is Select.NULL and name not in self._included:
return
self._values[name] = None if event.value is Select.NULL else event.value

def on_selection_list_selected_changed(self, event: SelectionList.SelectedChanged[str]) -> None:
Expand Down Expand Up @@ -329,7 +354,9 @@ def action_preview(self) -> None:
sensitive = body.sensitive_paths if body is not None else ()
# Flatten custom_fields so each chosen custom_fields.<name> diffs per record.
flattened = [flatten_custom_fields(record) for record in self._selected]
changes = bulk_diff(flattened, self.bulk_set, sensitive, self._fk_labels)
changes = bulk_diff(
flattened, self.bulk_set, sensitive, self._fk_labels, self._field_labels()
)

def _on_confirm(confirmed: bool | None) -> None:
if confirmed:
Expand Down
6 changes: 4 additions & 2 deletions nsc/tui/screens/edit_form.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ def _specs_for(self, name: str, field: Any, sensitive: tuple[str, ...]) -> list[
def _compose_field(self, name: str, spec: WidgetSpec) -> ComposeResult:
value = _record_value(self._record, name)
with Horizontal(classes="edit-field"):
yield Label(name, classes="edit-label")
yield Label(spec.label or name, classes="edit-label")
yield from self._compose_widget(name, spec, value)
if spec.nullable and spec.kind != "multi_select":
yield Button("∅", id=f"setnull-{encode_field_id(name)}", classes="edit-setnull")
Expand Down Expand Up @@ -292,8 +292,10 @@ def _on_confirm(confirmed: bool | None) -> None:
if confirmed:
self._apply_patch(patch, sensitive)

labels = {name: spec.label for name, spec in self._specs.items() if spec.label}
self.app.push_screen(
DiffModal(diff_rows(self._record, patch, sensitive, self._fk_labels)), _on_confirm
DiffModal(diff_rows(self._record, patch, sensitive, self._fk_labels, labels)),
_on_confirm,
)

def _apply_patch(self, patch: dict[str, Any], sensitive_paths: tuple[str, ...]) -> None:
Expand Down
5 changes: 4 additions & 1 deletion nsc/tui/styles.tcss
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,10 @@ BulkEditForm #bulk-form-body {
}

.bulk-include {
width: 6;
/* A Switch needs room for its border + padding + slider; a too-narrow
width clips the slider to zero so the toggle becomes invisible. */
width: auto;
padding: 0 1;
}

.bulk-label {
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "netbox-super-cli"
version = "1.6.0"
version = "1.6.1"
description = "Dynamic NetBox CLI generated from the live OpenAPI schema."
readme = "README.md"
requires-python = ">=3.12"
Expand Down
Loading