Skip to content

Forms

ModelForm renders the fields, EditFormWrapper puts a title row with buttons around it. The keyword options both accept are listed on Keyword options; the narrative version of this page is Components.

ModelForm

Renders a Pydantic model as an editable form (fields only — no chrome). Use EditFormWrapper to add a title, description, and action buttons.

Create via factory methods: ModelForm.from_item(instance) — in-memory item ModelForm.from_json(Type, path) — JSON file, auto-saves ModelForm.from_adapter(Type, adapter, key) — any CollectionAdapter

Configuration options are accepted as keyword arguments or via the model's Meta class (kwargs take priority).

Source code in niceview/modelform.py
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
class ModelForm():
    """
    Renders a Pydantic model as an editable form (fields only — no chrome).
    Use EditFormWrapper to add a title, description, and action buttons.

    Create via factory methods:
      ModelForm.from_item(instance)              — in-memory item
      ModelForm.from_json(Type, path)            — JSON file, auto-saves
      ModelForm.from_adapter(Type, adapter, key) — any CollectionAdapter

    Configuration options are accepted as keyword arguments or via the model's
    Meta class (kwargs take priority).
    """
    _item_type: type[BaseModel]
    _item_adapter: ItemAdapter | None
    _model_repositories: dict[type[BaseModel] | str, CollectionAdapter]
    _change_handlers: list[Handler[FieldChangeEventArguments]]

    _fields: Fields
    _current_item: BaseModel | None
    _validated_item: BaseModel | None
    _validation_error_messages: dict[str, str]
    _nonfield_validation_errors: list[str]
    _nonfield_error_element: ui.label | None
    _warned_nonfield: bool
    _actions: dict[str, 'FormAction']
    _validity_gated: list[ui.button]
    widgets: dict[str, Any]
    action_buttons: dict[str, ui.button]

    autosave: bool
    local_tz: str | None
    required_marker: str | None
    required_message: str
    description_as: DescriptionTarget
    base_props: str | None
    default_classes: str | None
    _chrome_style: 'ChromeStyle | None'
    _chrome_text: 'ChromeText | None'

    def __init__(self, item_type: type[BaseModel], **kwargs: Unpack[_ModelFormOptionInputs]) -> None:
        """
        Create a ModelForm for the given Pydantic model type.
        Prefer the factory methods (from_item, from_json, from_adapter) over
        calling the constructor directly.
        """

        if not isinstance(item_type, type) or not issubclass(item_type, BaseModel):
            raise TypeError(f"item_type must be a subclass of BaseModel, got {item_type}")

        self._item_type = item_type
        self._item_adapter = None
        self._model_repositories: dict[type[BaseModel] | str, CollectionAdapter] = {}
        self._change_handlers: list[Handler[FieldChangeEventArguments]] = []

        include = meta_option(item_type, kwargs, 'include', '__all__')
        exclude = meta_option(item_type, kwargs, 'exclude', '')
        field_infos = meta_option(item_type, kwargs, 'field_infos', {})
        # profile stays kwargs-only -- Fields() itself resolves Meta.default_profile as its fallback.
        profile = kwargs.pop('profile', None)  # type: ignore[misc]
        layout = meta_option(item_type, kwargs, 'layout', None)
        # Actions are kwargs-only, deliberately: a Meta entry is data, and an action carries a
        # callback — behaviour does not belong on the model class.
        self._actions = self._checked_actions(kwargs.pop('actions', {}))  # type: ignore[misc]
        self._fields = Fields(item_type, include, exclude, field_infos, profile=profile, layout=layout,
                              actions=self._actions)
        self._current_item = None
        self._validated_item = None
        self._validation_error_messages = {}
        self._nonfield_validation_errors = []
        self._nonfield_error_element = None
        self._warned_nonfield = False
        self._validity_gated = []
        self.widgets = {}
        self.action_buttons = {}

        self._chrome_style = kwargs.pop('chrome_style', None)  # type: ignore[misc]
        self._chrome_text = kwargs.pop('chrome_text', None)  # type: ignore[misc]
        text = self._chrome_text or get_chrome_text()

        self.autosave = meta_option(item_type, kwargs, 'autosave', False)
        self.local_tz = meta_option(item_type, kwargs, 'local_tz', None)
        self.required_marker = meta_option(item_type, kwargs, 'required_marker', text_of(text.required_marker))
        self.required_message = meta_option(item_type, kwargs, 'required_message', text_of(text.required_message))
        self.description_as = meta_option(item_type, kwargs, 'description_as', DESCRIPTION_AS)
        self.base_props = meta_option(item_type, kwargs, 'base_props', None)
        self.default_classes = meta_option(item_type, kwargs, 'default_classes', None)

        if on_change_callback := kwargs.pop('on_change', None):
            self.on_change(on_change_callback)

        if len(kwargs) > 0:
            raise TypeError(f"Unexpected keyword arguments: {', '.join(kwargs.keys())}")

    @staticmethod
    def _checked_actions(actions: Any, *, no_form: str = '') -> 'dict[str, FormAction]':
        """
        Validate the action table. The keys are the names the layout's '@name' refers to.

        `no_form` is the reason there is no form behind these actions, given by the wrappers
        whose title row has none: `requires_valid` cannot be answered there, and saying so beats
        a button that stays enabled without a word.
        """
        if not isinstance(actions, dict):
            raise TypeError(f"actions must be a dict of name -> FormAction, got {type(actions).__name__}")
        for name, action in actions.items():
            if not isinstance(name, str) or not name or name.startswith('@'):
                raise ValueError(f"Invalid action name {name!r}: a plain name, referred to as "
                                 f"'@{str(name).lstrip('@')}' in the layout")
            if not isinstance(action, FormAction):
                raise TypeError(f"Action '{name}' must be a FormAction, got {type(action).__name__}")
            if no_form and action.requires_valid:
                raise ValueError(f"Action '{name}': requires_valid needs a form to ask, and {no_form}")
        return dict(actions)

    @property
    def _style(self) -> ChromeStyle:
        return self._chrome_style or get_chrome_style()

    @property
    def _text(self) -> ChromeText:
        return self._chrome_text or get_chrome_text()

    def _notify(self, template: Any, kind: NotifyKind, **params: Any) -> None:
        """One of niceview's notifications: text from ChromeText, delivery from ChromeStyle."""
        chrome_notify(text_of(template, **params), kind, self._style)

    # --- factory methods ---------------------------------------------------

    @typing.overload
    @classmethod
    def from_item(cls, item: BaseModel, /, **kwargs: Unpack[_ModelFormOptionInputs]) -> Self: ...
    @typing.overload
    @classmethod
    def from_item(cls, item_type: type[BaseModel], item: BaseModel, /, **kwargs: Unpack[_ModelFormOptionInputs]) -> Self: ...
    @classmethod
    def from_item(cls, item_type_or_item: 'type[BaseModel] | BaseModel', item: 'BaseModel | None' = None, /, **kwargs: Unpack[_ModelFormOptionInputs]) -> Self:
        """
        Create a ModelForm editing an in-memory item (no persistence).

        The form modifies the item in-place; form.item returns the same object.
        External changes to the item's attributes are not reflected in the widgets
        automatically — assign form.item = updated_item to push new values to the UI.

        Two call forms:
          from_item(instance)       — item_type inferred from instance
          from_item(Type, instance) — explicit type (e.g. for subclasses)
        """
        if item is None:
            if not isinstance(item_type_or_item, BaseModel):
                raise TypeError(f"item_type_or_item must be a BaseModel instance, got {type(item_type_or_item)}")
            item = item_type_or_item
            item_type = type(item)
        else:
            item_type = item_type_or_item  # type: ignore[assignment]
            if not isinstance(item_type, type) or not issubclass(item_type, BaseModel):
                raise TypeError(f"item_type_or_item must be a subclass of BaseModel, got {item_type}")
            if not isinstance(item, BaseModel):
                raise TypeError(f"item must be a BaseModel instance, got {type(item)}")
        ret = cls(item_type, **kwargs)
        ret._set_item(item)
        return ret

    @classmethod
    def from_adapter(cls, item_type: type[BaseModel], adapter: 'CollectionAdapter | ItemAdapter', key: str | None = None, **kwargs: Unpack[_ModelFormOptionInputs]) -> Self:
        """
        Create a ModelForm bound to an adapter.

        With key: wraps CollectionAdapter + key in a BoundItem.
        Without key: treats adapter directly as an ItemAdapter (e.g. JsonAdapter).
        """
        if not isinstance(item_type, type) or not issubclass(item_type, BaseModel):
            raise TypeError(f"item_type must be a subclass of BaseModel, got {item_type}")
        instance = cls(item_type, **kwargs)
        if key is not None:
            instance.load(BoundItem(adapter, key))  # type: ignore[arg-type]
        else:
            instance.load(adapter)  # type: ignore[arg-type]
        return instance

    @classmethod
    def from_json(cls, item_type: type[BaseModel], json_path: Path, *, create_if_not_exist: bool = True, lock_field: str | None = None, created_field: str | None = None, **kwargs: Unpack[_ModelFormOptionInputs]) -> Self:
        """
        Create a ModelForm bound to a single-item JSON file.
        The file is created with default values if it does not exist.
        Calls save() to persist changes; calls refresh() to re-read from disk.
        """
        if not isinstance(item_type, type) or not issubclass(item_type, BaseModel):
            raise TypeError(f"item_type must be a subclass of BaseModel, got {item_type}")
        instance = cls(item_type, **kwargs)
        instance.load(JsonAdapter(item_type, json_path, create_if_not_exist=create_if_not_exist, lock_field=lock_field, created_field=created_field))
        return instance

    # --- item and form state management ------------------------------------

    @property
    def item(self) -> BaseModel:
        """
        The last state of the edited item that validated as a whole — the state save() would
        persist. While any validation error is present the item keeps its previous values; the
        values currently in the widgets are available as `draft`.

        The object identity is stable across edits (the form writes fields in place), so
        NiceGUI bindings such as bind_text_from(form.item, 'name') keep working.
        """
        if self._validated_item is None:
            raise ValueError("No item set. Use from_item(), from_json(), from_adapter(), or load() first.")
        return self._validated_item

    @item.setter
    def item(self, value: BaseModel) -> None:
        """
        Replace the displayed item. Only valid for unbound forms (from_item).
        For adapter-bound forms use load() to navigate.
        """
        if self.adapter_bound:
            raise ValueError(
                "Cannot set item directly on an adapter-bound form. Use load() to navigate."
            )
        if not isinstance(value, BaseModel):
            raise TypeError(f"item must be a BaseModel instance, got {type(value)}")
        self._set_item(value)

    @property
    def draft(self) -> BaseModel:
        """
        The current widget values as a model instance — including values that fail validation
        and are therefore not in `item` yet. A copy: mutating it does not affect the form.
        """
        if self._current_item is None:
            raise ValueError("No item set. Use from_item(), from_json(), from_adapter(), or load() first.")
        return self._current_item.model_copy()

    def _set_item(self, value: BaseModel, in_place: bool = False) -> None:
        """
        Internal item assignment — bypasses the adapter-bound guard.

        in_place=True copies the values into the existing item instead of replacing it, so that
        NiceGUI bindings on form.item survive (used by refresh() and save(), which return the
        same logical item; load() navigates to a different one and rebinds).
        """
        if not (in_place and self._copy_into_item(value)):
            self._validated_item = value
        self._current_item = self._validated_item.model_copy()  # type: ignore[union-attr]
        self._push_item_to_widgets()
        self._validate()

    def _copy_into_item(self, source: BaseModel) -> bool:
        """
        Copy source's field values into the existing item, keeping its identity.
        Returns False when that is not possible (no item yet, different type, frozen model) —
        the caller then falls back to replacing the item.
        """
        target = self._validated_item
        if target is None or type(target) is not type(source):
            return False
        if type(target).model_config.get('frozen'):
            return False
        for name, field in type(target).model_fields.items():
            if field.frozen:
                continue  # pydantic raises on assignment to a frozen field
            setattr(target, name, getattr(source, name))
        return True

    # --- data adapter interaction ------------------------------------------

    @typing.overload
    def load(self, adapter: ItemAdapter) -> Self: ...
    @typing.overload
    def load(self, adapter: CollectionAdapter, key: str) -> Self: ...
    def load(self, adapter: 'ItemAdapter | CollectionAdapter', key: str | None = None) -> Self:
        """
        Bind the form to an adapter and load the item.

        Two call forms:
          load(item_adapter)       — any ItemAdapter (e.g. BoundItem, JsonAdapter)
          load(collection, key)    — convenience: wraps in BoundItem internally

        Use this for master-detail navigation (switching the displayed item at runtime).
        """
        # The overloads guarantee: with key -> CollectionAdapter, without key -> ItemAdapter.
        item_adapter: ItemAdapter
        if key is not None:
            item_adapter = BoundItem(typing.cast(CollectionAdapter, adapter), key)
        else:
            item_adapter = typing.cast(ItemAdapter, adapter)
        self._item_adapter = item_adapter
        item = item_adapter.read()
        if not isinstance(item, BaseModel):
            raise TypeError(f"item must be a BaseModel instance, got {type(item)}")
        self._set_item(item)
        return self

    @property
    def adapter_bound(self) -> bool:
        """True if the form is bound to a data adapter (save/refresh are available)."""
        return self._item_adapter is not None

    def refresh(self, notify: bool = True) -> None:
        """Reload the item from the adapter, discarding any unsaved edits.

        notify=False suppresses the ui.notify popup (e.g. for programmatic refreshes)."""
        if not self.adapter_bound:
            raise ValueError("No adapter set. Use from_adapter(), from_json(), or load() first.")
        item = self._item_adapter.read()  # type: ignore[union-attr]
        if not isinstance(item, BaseModel):
            raise TypeError(f"item must be a BaseModel instance, got {type(item)}")
        self._set_item(item, in_place=True)  # same item reloaded: keep bindings alive
        if notify:
            self._notify(self._text.form_refreshed, 'positive')

    def save(self, notify: bool = True) -> None:
        """Persist the current item to the adapter. No-op if validation errors are present.

        notify=False suppresses all ui.notify popups (success and error); errors are
        still logged and reflected in the form's validation state."""
        if self._item_adapter is None:
            raise ValueError("No adapter set. Use from_adapter(), from_json(), or load() first.")

        if self.has_validation_errors:
            if notify:
                self._notify(self._text.validation_errors, 'negative')
            return

        try:
            updated = self._item_adapter.save(self.item)
        except (ConflictError, StorageError) as e:
            log.error(f"save failed: {e}")
            if notify:
                self._notify(str(e), 'negative')  # the adapter's own message, not one of ours
            return
        if updated is not None and updated is not self._validated_item:
            # Adapters may return a new instance (e.g. with generated ids). Copy the values in
            # instead of rebinding, so bindings on form.item survive a save.
            if not self._copy_into_item(updated):
                self._validated_item = updated
            self._current_item = self._validated_item.model_copy()  # type: ignore[union-attr]
        if notify:
            self._notify(self._text.form_saved, 'positive')

    # --- widget management -------------------------------------------------

    @typing.overload
    def w(self, field_name: str) -> FormWidget: ...
    @typing.overload
    def w(self, field_name: str, widget_type: type[W]) -> W: ...
    def w(self, field_name: str, widget_type: 'type[W] | None' = None) -> 'FormWidget | W':
        """
        Return the rendered widget for a field, with optional type narrowing.

          form.w('name')                   # → ui.element (or ModelGrid / EditGridWrapper /
                                            #   CheckboxGroup for editgrid / checkbox_group fields)
          form.w('name', ui.input)         # → ui.input        (typed; raises TypeError if mismatch)
          form.w('perms', CheckboxGroup)   # → CheckboxGroup
          form.w('@test')                  # → ui.button       (an action, written as in the layout)

        Raises KeyError if the field has no widget (e.g. not yet rendered or excluded).
        Raises TypeError if the widget exists but is not an instance of widget_type.
        """
        if field_name.startswith('@'):
            # An action is addressed the way the layout writes it. Its button lives in
            # action_buttons rather than in widgets, which is keyed by field name and walked
            # by everything that pushes values, converts them and validates them.
            try:
                widget = self.action_buttons[field_name[1:]]
            except KeyError:
                raise KeyError(f"No button for action '{field_name}'. Check that the form is "
                               "rendered and the action is placed in the layout.")
            if widget_type is not None and not isinstance(widget, widget_type):
                raise TypeError(f"Button for '{field_name}' is {type(widget).__name__}, not {widget_type.__name__}")
            return widget  # type: ignore[return-value]
        try:
            widget = self.widgets[field_name]
        except KeyError:
            raise KeyError(f"No widget for field '{field_name}'. "
                           "Check that the form is rendered and the field is not excluded.")
        if widget_type is not None and not isinstance(widget, widget_type):
            raise TypeError(
                f"Widget for '{field_name}' is {type(widget).__name__}, not {widget_type.__name__}"
            )
        return widget  # type: ignore[return-value]

    def with_repositories(self, repositories: 'dict') -> Self:
        """
        Provide adapters for modelselect fields (dropdowns over a CollectionAdapter).

        Keys are either a **field name** (preferred — two fields can reference the same model
        through different collections, and a scalar key-select field is resolved by its name) or,
        for the SQLModel-relationship style, the related **model class**. Values are the
        CollectionAdapters. When a field-name-keyed adapter is given, the field's `item_type` is
        inferred from the adapter if not set. Additive: repeated calls merge (a later entry
        overrides an earlier one for the same key), so a wrapper's registrations combine with the
        form's own rather than replacing them. Returns self for chaining.
        """
        if not isinstance(repositories, dict):
            raise TypeError(f"repositories must be a dictionary, got {type(repositories)}")
        self._model_repositories = {**self._model_repositories, **repositories}  # merge; new wins
        return self

    def _push_item_to_widgets(self) -> None:
        """Push current item values into all rendered widgets."""
        for field_name, widget in self.widgets.items():
            widget_type = self._fields[field_name].widget_type
            if widget_type and widget_type != 'editgrid':
                self._from_current_item_to_widget_value(field_name, widget_type, widget)

    def on_change(self, callback: Handler[FieldChangeEventArguments]) -> Self:
        """
        Add a callback to be invoked when the form values change and
        the new values are successfully validated.
        """
        if not callable(callback):
            raise TypeError(f"callback must be callable, got {type(callback)}")
        self._change_handlers.append(callback)
        return self

    # --- widget rendering helpers ------------------------------------------

    def _wire_text_input(self, widget: Any, field_name: str) -> None:
        """Wire a text-input widget: validate on change, commit on blur."""
        widget.on_value_change(lambda vce, fn=field_name: self._handle_validate(fn, vce))
        widget.on('blur', lambda e, fn=field_name: self._handle_blur_event(fn, e))

    def _wire_immediate(self, widget: Any, field_name: str) -> None:
        """Wire an immediate widget: validate and commit on value change."""
        widget.on_value_change(lambda vce, fn=field_name: self._handle_validate_and_change(fn, vce))

    def _wire_widget(self, field_name: str, widget_type: str, widget: Any) -> None:
        """
        Connect a widget created by niceview.widgets to the form: change events, and the
        validation callback for the widget types that can show a message.
        Wiring happens after the initial value has been set, so that pushing the item's
        value into the widget does not fire a change event.
        """
        if widget_type in TEXT_INPUT_WIDGETS:
            self._wire_text_input(widget, field_name)
        else:
            self._wire_immediate(widget, field_name)
        if widget_type in VALIDATED_WIDGETS:
            widget.validation = lambda value, fn=field_name: self._get_field_error(fn, value)
            # return_result=False: NiceGUI refuses to return a result for an async validation
            # function, and field_info.validation may well be one.
            widget.validate(return_result=False)

    # --- widget rendering methods ------------------------------------------

    def _prepare_modelselect(self, field_name: str, field_info: FieldInfo) -> 'ui.select | None':
        """
        Resolve the repository of a modelselect field into field_info.options, so that the
        field can be rendered as a plain select.
        Returns None on success, or a disabled placeholder widget if no repository is
        registered for the field's item type.
        """
        repo = resolve_repository(self._model_repositories, field_name, field_info.item_type)
        if repo is None:
            target = field_info.item_type.__name__ if field_info.item_type else field_name
            log.warning(
                f"No repository for '{target}' — rendering '{field_name}' as a disabled "
                f"placeholder. Register one with with_repositories() (keyed by field name or type)."
            )
            widget = ui.select(options={}, label=field_info.label or field_name)
            widget.disable()
            return widget

        # A field-name-keyed repository knows its own model, so item_type can be inferred from it
        # (a plain scalar key like `author: str` cannot name the model itself).
        if field_info.item_type is None:
            field_info.item_type = getattr(repo, '_item_type', None)
        field_info.options = {repo.key_from_item(item): str(item) for item in repo}
        field_info.with_input = True  # a repository-backed select is searchable
        # Key-select: validate that a stored key still exists in the collection (a referenced
        # item may have been deleted). Object-select keys always come from the repo, so are valid.
        if not field_stores_model(field_info) and field_info.validation is None:
            valid_keys = set(field_info.options)
            field_info.validation = {
                text_of(self._text.unknown_selection): lambda v: v is None or v in valid_keys
            }
        return None

    def _get_fk_info(self, field_name: str) -> tuple[str, Any] | None:
        """
        For SQLModel parents: inspect the SQLAlchemy relationship to find the FK field
        on the child side and the current parent PK value.
        Returns (fk_field_name, parent_pk_value), or None if not determinable or
        if the parent item has no PK yet (new, unpersisted item).
        """
        try:
            from sqlalchemy import inspect as sa_inspect
            mapper = sa_inspect(type(self._validated_item))
            if mapper is None or not hasattr(mapper, 'relationships'):
                return None
            rel = mapper.relationships.get(field_name)
            if rel is None or not rel.synchronize_pairs:
                return None
            local_col, remote_col = rel.synchronize_pairs[0]
            parent_value = getattr(self._validated_item, local_col.key, None)
            if parent_value is None:
                return None  # parent not yet persisted — no valid FK to inject
            return remote_col.key, parent_value
        except Exception:
            return None

    def _render_editgrid_widget(self, field_name: str, field_info: FieldInfo) -> Any:
        # Local imports to avoid circular dependencies (grid/wrapper import form).
        from niceview.editwrapper import EditGridWrapper
        from niceview.modelgrid import ModelGrid, TableItemEventArguments
        from niceview.dataadapter import ListAdapter, FilteredAdapter

        def notify_change(e: TableItemEventArguments) -> None:
            if self.autosave:
                self.save()
            fce = FieldChangeEventArguments(
                sender=e.sender,
                client=e.client,
                form=self,
                field_name=field_name,
                previous_value=None,
                value=e.item,
            )
            for handler in self._change_handlers:
                handle_event(handler, fce)

        if not field_info.item_type:
            raise ValueError(f"Field {field_name} is a list but no item type is specified in FieldInfo or as a pydantic model type")

        # If a repository is registered for this field (by field name or child type) and the
        # parent has a valid PK, use a FilteredAdapter so mutations are persisted via the adapter.
        # Otherwise fall back to an in-memory ListAdapter.
        repo = resolve_repository(self._model_repositories, field_name, field_info.item_type)
        data: CollectionAdapter
        if repo is not None:
            fk_info = self._get_fk_info(field_name)
            if fk_info is not None:
                fk_field, parent_value = fk_info

                def matches_parent(item: Any, fk: str = fk_field, val: Any = parent_value) -> bool:
                    return getattr(item, fk, None) == val

                data = FilteredAdapter(repo, predicate=matches_parent, defaults={fk_field: parent_value})
            else:
                data = ListAdapter(field_info.item_type, getattr(self._validated_item, field_name))
        else:
            data = ListAdapter(field_info.item_type, getattr(self._validated_item, field_name))

        widget = ModelGrid(field_info.item_type, data)
        # An embedded grid is a section of the form, not a page of its own: its title takes the
        # chrome's section size, one step below the title of the wrapper around the form.
        chrome = self._style
        section_style = chrome.replace(title_classes=f'{chrome.section_title_classes} grow')
        if field_info.editable:
            edit_widget = EditGridWrapper(widget, title=field_info.label, chrome_style=section_style,
                                          chrome_text=self._chrome_text, place='form')
            if self._model_repositories:
                edit_widget.with_repositories(self._model_repositories)
            edit_widget.on_change(notify_change)
            edit_widget.render()
            return edit_widget  # type: ignore[return-value]
        else:
            ui.label(field_info.label).classes(chrome.section_title_classes)
            widget.render()
            return widget  # type: ignore[return-value]

    def _render_widget(self, field_name: str, field_info: FieldInfo) -> Any:
        """
        Create and wire a widget for the given field, based on its widget_type.

        The widget itself is built by niceview.widgets — the same code path as the
        model-free render_field(); this method adds what needs the model: the item's
        value, change events, validation state and the model-backed widget types.
        """
        if not field_info:
            raise ValueError(f"Field info for {field_name} not found")
        widget_type = field_info.widget_type
        if not widget_type:
            raise ValueError(f"Widget type for field {field_name} not found in field info")

        # editgrid brings its own chrome, styling and change handling.
        if widget_type == 'editgrid':
            return self._render_editgrid_widget(field_name, field_info)

        # modelselect is a select over a repository: resolve the options first, then let it
        # fall through to the normal select rendering below.
        if widget_type == 'modelselect':
            placeholder = self._prepare_modelselect(field_name, field_info)
            if placeholder is not None:
                apply_field_info(placeholder, field_info, self.description_as)
                return placeholder

        def push_value(widget: Any) -> None:
            self._from_current_item_to_widget_value(field_name, widget_type, widget)

        widget = create_widget(field_info, field_name, push_value, self.required_marker, self.description_as)
        self._wire_widget(field_name, widget_type, widget)
        return widget

    def render_field(self, field_name: str, **kwargs: Unpack[_FieldInfoInputs]) -> Any:
        """
        Render a single named field in the current NiceGUI context.

        Returns the created widget so callers can style it immediately:
          form.render_field('name').classes('w-full')

        Optional kwargs override FieldInfo attributes for this render only:
          form.render_field('name', label='Short name')
          form.render_field('is_active', label='')   # suppress label

        Unlike render(), this does not reset existing widgets — call it multiple
        times inside any layout structure to position fields individually.
        The non-field error label is not rendered; call render_nonfield_errors()
        separately to place it wherever needed.

        Raises ValueError for unknown or hidden fields.
        """
        if field_name not in self._fields:
            raise ValueError(f"Field '{field_name}' is not in the form's field set")
        field_info = self._fields[field_name]
        if not field_info:
            raise ValueError(f"Field info for '{field_name}' not found")
        if field_info.hidden:
            raise ValueError(f"Field '{field_name}' is hidden and cannot be rendered individually")
        if kwargs:
            field_info = _merge_field_infos(field_info, FieldInfo(**kwargs))
        widget = self._render_widget(field_name, self._styled(field_info))
        self.widgets[field_name] = widget
        return widget

    # --- actions -----------------------------------------------------------

    def render_action(self, name: str) -> ui.button:
        """
        Render one of the form's actions in the current NiceGUI context, for a layout built by
        hand out of render_field() calls — render() places the actions the layout names itself.

        Returns the created button, which is also kept in `action_buttons` and reachable as
        `form.w('@name')`.
        """
        name = name.lstrip('@')
        if name not in self._actions:
            raise ValueError(f"Unknown action '{name}' — the form's actions are: "
                             f"{sorted(self._actions) or 'none'}")
        button = self._render_action(name, self._actions[name])
        self.action_buttons[name] = button
        return button

    def _render_action(self, name: str, action: 'FormAction', *, place: Place = 'form',
                       classes: str | None = None, in_row: bool = False,
                       bottom_space: bool = False) -> ui.button:
        """
        Build one of *this form's* action buttons in the current NiceGUI context: the shared
        button of `render_action_button()`, wired to this form's click handler and, for
        `requires_valid`, to its validity.
        """
        chosen = classes or action.classes
        if in_row and not chosen:
            # A button belongs next to the *box* of the field beside it, not next to the field's
            # total height. Where that field keeps Quasar's 20px message strip free below its box
            # (see widgets.reserves_bottom_space), centering over the whole of it would put the
            # button half of that strip too low; the margin makes the centred margin box exactly
            # as much taller and lifts it back onto the box. A row of switches reserves nothing,
            # and then the plain centre is already right.
            chosen = 'self-center mb-5' if bottom_space else 'self-center'
        button = render_action_button(
            action, self._style, place, chosen,
            lambda event, n=name, a=action: self._handle_action(n, a, event))
        if action.requires_valid:
            self._gate_on_validity(button)
        return button

    def _handle_action(self, name: str, action: 'FormAction', event: ClickEventArguments) -> None:
        if action.on_click is None:
            return
        # The handler is typed for whichever place the action sits in; here it is a form's.
        handle_event(typing.cast('Handler[FormActionEventArguments]', action.on_click),
                     FormActionEventArguments(sender=event.sender, client=event.client,
                                              form=self, name=name, action=action))

    def _gate_on_validity(self, button: ui.button) -> None:
        """
        Disable a button while the form has validation errors — now, and after every change.
        Used by `requires_valid`, and by a wrapper for the actions in its own title row.
        """
        self._validity_gated.append(button)
        button.set_enabled(not self.has_validation_errors)

    def _styled(self, field_info: FieldInfo, layout_classes: str | None = None, in_row: bool = False) -> FieldInfo:
        """
        Apply the styling cascade to a field. Props and classes are handled differently, and
        the difference is not arbitrary: a Quasar prop has a key, so props from two sources
        merge per key and the later one wins. A CSS class has no key — 'w-full w-1/2' is
        decided by stylesheet order, not by the order in the class list — so classes cannot be
        merged meaningfully and the most specific source replaces the others wholesale.

          props:   the category's props (FieldStyle) + base_props + the field's own props
                   (additive, per key, the narrower source wins)
          classes: layout classes, else the field's own classes, else the form's
                   default_classes, else the application's

        In a row, 'min-w-0' is always added — it is layout mechanics, not styling, and cannot
        conflict with a width utility. 'flex-1' (equal share) is added only when no source
        asked for a width of its own, because it sets flex-basis to 0 and would silently
        override one.
        """
        field_style = get_field_style()
        # The application-wide category props ('' for 'editgrid', which brings its own chrome) —
        # the same layer the model-free render_field() applies, shared so they cannot drift.
        category_props = field_style_props(field_style, field_info.widget_type)

        explicit_classes = layout_classes or field_info.classes
        classes = [explicit_classes or self.default_classes or field_style.default_classes]
        if in_row:
            classes.append('min-w-0' if explicit_classes else 'flex-1 min-w-0')
        props = [category_props, self.base_props, field_info.props]

        overrides: dict[str, Any] = {}
        if any(classes):
            overrides['classes'] = ' '.join(c for c in classes if c)
        if any(props):
            overrides['props'] = ' '.join(p for p in props if p)
        if not overrides:
            return field_info
        return _merge_field_infos(field_info, FieldInfo(**overrides))

    def render_nonfield_errors(self) -> ui.label:
        """
        Render the non-field (model-level) validation error label in the current NiceGUI context.

        Validation runs model_validate() over the whole model, so an error with no widget to sit
        under — a cross-field @model_validator, or a field that is excluded or hidden — surfaces
        here rather than below a field.

        Returns the created ui.label so callers can style it:
          form.render_nonfield_errors().classes('q-mt-sm')

        Call this separately when using render_field() to control its placement.
        render() calls this automatically at the end.
        """
        self._nonfield_error_element = ui.label('').classes('text-negative w-full')
        self._nonfield_error_element.set_visibility(False)
        return self._nonfield_error_element

    def render(self) -> Self:
        """
        Render all non-hidden fields followed by the non-field error label.

        Fields are arranged according to the form's layout (from `layout=`, from the selected
        `Meta.profiles` entry, or simply one below the other), together with the actions the
        layout names as '@name'. Use render_field(), render_action() and render_nonfield_errors()
        instead when a layout the notation cannot express is needed.
        """
        # A re-render replaces the buttons of the layout — but not the ones a wrapper registered
        # for its own title row, which is drawn before the form and stays where it is.
        own = {id(button) for button in self.action_buttons.values()}
        self._validity_gated = [b for b in self._validity_gated if id(b) not in own]
        self.widgets = {}
        self.action_buttons = {}
        self._render_group(self._fields.layout)
        self.render_nonfield_errors()
        return self

    def _render_group(self, group: LayoutGroup) -> None:
        """Render a layout group's children into the current NiceGUI context."""
        bottom_space = group.row and any(
            reserves_bottom_space(self._fields[child.name], self.description_as)
            for child in group.children
            if isinstance(child, LayoutField) and not self._fields[child.name].hidden
        )
        for child in group.children:
            if isinstance(child, LayoutField):
                field_info = self._fields[child.name]
                if not field_info:
                    raise ValueError(f"Field {child.name} not found in field_infos")
                if field_info.hidden:
                    continue
                self.widgets[child.name] = self._render_widget(
                    child.name, self._styled(field_info, child.classes, in_row=group.row)
                )
            elif isinstance(child, LayoutAction):
                self.action_buttons[child.name] = self._render_action(
                    child.name, self._actions[child.name], classes=child.classes,
                    in_row=group.row, bottom_space=bottom_space
                )
            else:
                with self._layout_container(child):
                    self._render_group(child)

    def _layout_container(self, group: LayoutGroup) -> ui.element:
        """
        The container for a nested layout group: a section for a titled group ('#' draws a card
        around it, '##' only the heading), otherwise a row or a column. The defaults come from
        the chrome style (form_row_classes / form_column_classes / form_card_classes|props); a
        group's own `:classes` replaces them rather than adding to them — a class list has no key
        to merge on, and Tailwind resolves a duplicate utility by stylesheet order.
        """
        chrome = self._style
        if group.title is not None:
            section = (ui.card().props(chrome.form_card_props).classes(group.classes or chrome.form_card_classes)
                       if group.card else ui.column().classes(group.classes or chrome.form_column_classes))
            with section:
                ui.label(group.title).classes(chrome.card_title_classes if group.card
                                              else chrome.section_title_classes)
            return section
        if group.row:
            return ui.row().classes(group.classes or chrome.form_row_classes)
        return ui.column().classes(group.classes or chrome.form_column_classes)

    # --- value conversion --------------------------------------------------

    def _from_current_item_to_widget_value(self, field_name: str, widget_type: str, widget: Any) -> None:
        """Push the current item's field value into the widget."""
        value = getattr(self._current_item, field_name)

        if widget_type == 'modelselect':
            field_info = self._fields[field_name]
            if not field_stores_model(field_info):
                widget.value = value  # key-select: the field already holds the repository key
                return
            # object-select: the field holds the related item, the widget its key.
            repository = resolve_repository(self._model_repositories, field_name, field_info.item_type)
            if repository is None:
                raise ValueError(f"No repository for modelselect field '{field_name}'")
            widget.value = repository.key_from_item(value) if value is not None else None
            return

        widget.value = to_widget_value(self._fields[field_name], value, local_tz=self.local_tz)  # type: ignore[attr-defined]

    def _from_widget_value_to_current_item(self, field_name: str) -> None:
        """
        Read the widget value, convert it to the model type, and write it into _current_item.
        Exceptions should be handled by the caller.
        """
        if field_name not in self.widgets:
            raise ValueError(f"Widget for field {field_name} not found")
        widget = self.widgets[field_name]
        field_info = self._fields[field_name]

        if field_info.widget_type == 'modelselect':
            key = widget.value  # type: ignore[attr-defined]
            if not field_stores_model(field_info):
                # key-select: store the repository key straight into the (scalar) field.
                setattr(self._current_item, field_name, key)
                return
            repository = resolve_repository(self._model_repositories, field_name, field_info.item_type)
            if repository is None:
                raise ValueError(f"No repository for modelselect field '{field_name}'")
            value = repository.read(key) if key is not None else None
            # Sync FK field (e.g. author -> author_id) so pydantic validation sees the selection.
            # Do NOT also set the relationship attribute: SQLAlchemy would cascade-insert the
            # detached related instance, violating UNIQUE constraints on the related table.
            fk_field = f'{field_name}_id'
            assert self._current_item is not None
            if fk_field in type(self._current_item).model_fields:
                fk_val: Any
                if value is not None:
                    key_str = repository.key_from_item(value)
                    fk_type = type(self._current_item).model_fields[fk_field].annotation
                    fk_val = TypeAdapter(fk_type).validate_python(key_str)
                else:
                    fk_val = None
                setattr(self._current_item, fk_field, fk_val)
                return  # FK synced; skip setting the relationship object
        else:
            try:
                value = field_value(widget, field_info, local_tz=self.local_tz)
            except ValueError as e:
                raise ValueError(f"Field '{field_name}': {e}") from e

        setattr(self._current_item, field_name, value)

    # --- validation and event handling ------------------------------------

    def _own_field_error(self, field_name: str, value: Any) -> str | None:
        """
        Validation layer 1 for a field: `required` first, then field_info.validation — the
        rules that need no model and behave exactly as they do in render_field().
        An async validation function is displayed by the widget itself but skipped here: a
        commit cannot wait for it. See docs/components.md.
        """
        field_info = self._fields[field_name]
        error = required_error(field_info, value, self.required_message)
        if error is not None:
            return error
        result = run_validation(field_info.validation, value)
        if inspect.isawaitable(result):
            if inspect.iscoroutine(result):
                result.close()  # not awaited here — avoid "coroutine was never awaited"
            return None
        return result

    def _get_field_error(self, field_name: str, value: Any) -> Any:
        """
        NiceGUI validation callback, in layer order: the field's own rules (required, then
        field_info.validation) first, the model's error for this field second.
        Returns an awaitable when field_info.validation is an async function; NiceGUI resolves
        it in the background.
        """
        field_info = self._fields[field_name]
        error = required_error(field_info, value, self.required_message)
        if error is not None:
            return error
        result = run_validation(field_info.validation, value)
        if inspect.isawaitable(result):
            async def _combined() -> str | None:
                return await result or self._validation_error_messages.get(field_name)
            return _combined()
        return result or self._validation_error_messages.get(field_name)

    def _validate(self, extra_errors: 'dict[str, str] | None' = None) -> None:
        """
        Run all validation layers and refresh the error display.
        Layer 1 (required / field_info.validation) is evaluated per rendered widget and takes
        precedence over the model's message for the same field; `extra_errors` (conversion
        failures) wins over both.
        """
        if self._current_item is None:
            return
        field_errors, nonfield_errors = self._fields.validation_errors(self._current_item.model_dump())

        for field_name, widget in self.widgets.items():
            if not hasattr(widget, 'value'):
                continue  # composite widgets (editgrid) are not validated per value
            own_error = self._own_field_error(field_name, widget.value)
            if own_error:
                field_errors[field_name] = own_error
        if extra_errors:
            field_errors.update(extra_errors)

        self._validation_error_messages = field_errors
        self._nonfield_validation_errors = nonfield_errors

        if self._nonfield_error_element is not None:
            if nonfield_errors:
                self._nonfield_error_element.set_text(' | '.join(nonfield_errors))
                self._nonfield_error_element.set_visibility(True)
            else:
                self._nonfield_error_element.set_visibility(False)
        elif nonfield_errors and self.widgets and not self._warned_nonfield:
            self._warned_nonfield = True
            log.warning(
                "Model-level validation errors are blocking the form but are not displayed: "
                "call render_nonfield_errors() to place the message. Errors: "
                + ' | '.join(nonfield_errors)
            )

        for widget in self.widgets.values():
            if hasattr(widget, 'validate') and callable(widget.validate):
                # return_result=False: NiceGUI refuses to return a result for async validations
                widget.validate(return_result=False)

        valid = not self.has_validation_errors
        for button in self._validity_gated:
            button.set_enabled(valid)

    @property
    def has_validation_errors(self) -> bool:
        """True if any field-level or model-level validation errors are present."""
        return bool(self._validation_error_messages) or bool(self._nonfield_validation_errors)

    @property
    def validation_errors(self) -> dict[str, str]:
        """Field-level validation errors as {field_name: error_message}. Empty dict when valid."""
        return dict(self._validation_error_messages)

    @property
    def nonfield_validation_errors(self) -> list[str]:
        """Model-level (cross-field) validation errors. Empty list when valid."""
        return list(self._nonfield_validation_errors)

    def _handle_blur_event(self, field_name: str, event: Any) -> None:
        old = getattr(self._current_item, field_name, None) if self._current_item else None
        vce = ValueChangeEventArguments(
            sender=event.sender, client=event.client,
            value=event.sender.value,  # type: ignore[attr-defined]
            previous_value=old,
        )
        self._handle_value_change(field_name, vce)

    def _handle_validate(self, field_name: str, value_change_event: ValueChangeEventArguments) -> None:
        """Layers 1 and 2: validate the raw widget value, then convert it into the draft."""
        raw_value = value_change_event.sender.value  # type: ignore[attr-defined]
        extra_errors: dict[str, str] = {}

        if self._own_field_error(field_name, raw_value) is None:
            # Only a value the field itself accepts is converted and written to the draft, so
            # the model never sees a value the user was already told is wrong.
            if getattr(self._current_item, field_name, None) != raw_value:
                try:
                    self._from_widget_value_to_current_item(field_name)
                except Exception:
                    extra_errors[field_name] = "Error interpreting widget value"

        self._validate(extra_errors)

    def _committed_attr(self, field_name: str) -> str:
        """
        The attribute that carries a field's value on the item.
        For modelselect this is the FK field (e.g. author -> author_id): the draft holds the FK,
        not the relationship object, so that SQLAlchemy does not cascade-insert a detached
        instance on session.add().
        """
        if self._fields[field_name].widget_type == 'modelselect':
            fk_field = f'{field_name}_id'
            if fk_field in getattr(type(self._current_item), 'model_fields', {}):
                return fk_field
        return field_name

    def _handle_value_change(self, field_name: str, value_change_event: ValueChangeEventArguments) -> None:
        """
        Layer 3: write the draft into the item — but only when the item validates as a whole.

        All changed fields are committed together, not just the one that fired the event: an
        edit made while a cross-field error stood must not be lost when that error clears.
        The item is written in place, so NiceGUI bindings on form.item keep working.
        """
        if self._current_item is None or self._validated_item is None:
            return
        if self.has_validation_errors:
            return

        changes: list[tuple[str, str, Any, Any]] = []
        for name in self._fields:
            if self._fields[name].widget_type == 'editgrid':
                continue  # the grid mutates the item's list in place; nothing to sync
            attr = self._committed_attr(name)
            old_value = getattr(self._validated_item, attr, None)
            new_value = getattr(self._current_item, attr, None)
            if old_value != new_value:
                changes.append((name, attr, old_value, new_value))
        if not changes:
            return

        for _, attr, _, new_value in changes:
            setattr(self._validated_item, attr, new_value)

        if self.autosave and self._item_adapter is not None:
            self.save()

        for name, _, old_value, new_value in changes:
            fce = FieldChangeEventArguments(
                sender=value_change_event.sender,
                client=value_change_event.client,
                form=self,
                field_name=name,
                previous_value=old_value,
                value=new_value,
            )
            for handler in self._change_handlers:
                handle_event(handler, fce)

    def _handle_validate_and_change(self, field_name: str, value_change_event: ValueChangeEventArguments) -> None:
        self._handle_validate(field_name, value_change_event)
        self._handle_value_change(field_name, value_change_event)

item property writable

item: BaseModel

The last state of the edited item that validated as a whole — the state save() would persist. While any validation error is present the item keeps its previous values; the values currently in the widgets are available as draft.

The object identity is stable across edits (the form writes fields in place), so NiceGUI bindings such as bind_text_from(form.item, 'name') keep working.

draft property

draft: BaseModel

The current widget values as a model instance — including values that fail validation and are therefore not in item yet. A copy: mutating it does not affect the form.

adapter_bound property

adapter_bound: bool

True if the form is bound to a data adapter (save/refresh are available).

has_validation_errors property

has_validation_errors: bool

True if any field-level or model-level validation errors are present.

validation_errors property

validation_errors: dict[str, str]

Field-level validation errors as {field_name: error_message}. Empty dict when valid.

nonfield_validation_errors property

nonfield_validation_errors: list[str]

Model-level (cross-field) validation errors. Empty list when valid.

from_item classmethod

from_item(
    item: BaseModel,
    /,
    **kwargs: Unpack[_ModelFormOptionInputs],
) -> Self
from_item(
    item_type: type[BaseModel],
    item: BaseModel,
    /,
    **kwargs: Unpack[_ModelFormOptionInputs],
) -> Self
from_item(
    item_type_or_item: type[BaseModel] | BaseModel,
    item: BaseModel | None = None,
    /,
    **kwargs: Unpack[_ModelFormOptionInputs],
) -> Self

Create a ModelForm editing an in-memory item (no persistence).

The form modifies the item in-place; form.item returns the same object. External changes to the item's attributes are not reflected in the widgets automatically — assign form.item = updated_item to push new values to the UI.

Two call forms: from_item(instance) — item_type inferred from instance from_item(Type, instance) — explicit type (e.g. for subclasses)

Source code in niceview/modelform.py
@classmethod
def from_item(cls, item_type_or_item: 'type[BaseModel] | BaseModel', item: 'BaseModel | None' = None, /, **kwargs: Unpack[_ModelFormOptionInputs]) -> Self:
    """
    Create a ModelForm editing an in-memory item (no persistence).

    The form modifies the item in-place; form.item returns the same object.
    External changes to the item's attributes are not reflected in the widgets
    automatically — assign form.item = updated_item to push new values to the UI.

    Two call forms:
      from_item(instance)       — item_type inferred from instance
      from_item(Type, instance) — explicit type (e.g. for subclasses)
    """
    if item is None:
        if not isinstance(item_type_or_item, BaseModel):
            raise TypeError(f"item_type_or_item must be a BaseModel instance, got {type(item_type_or_item)}")
        item = item_type_or_item
        item_type = type(item)
    else:
        item_type = item_type_or_item  # type: ignore[assignment]
        if not isinstance(item_type, type) or not issubclass(item_type, BaseModel):
            raise TypeError(f"item_type_or_item must be a subclass of BaseModel, got {item_type}")
        if not isinstance(item, BaseModel):
            raise TypeError(f"item must be a BaseModel instance, got {type(item)}")
    ret = cls(item_type, **kwargs)
    ret._set_item(item)
    return ret

from_adapter classmethod

from_adapter(
    item_type: type[BaseModel],
    adapter: CollectionAdapter | ItemAdapter,
    key: str | None = None,
    **kwargs: Unpack[_ModelFormOptionInputs],
) -> Self

Create a ModelForm bound to an adapter.

With key: wraps CollectionAdapter + key in a BoundItem. Without key: treats adapter directly as an ItemAdapter (e.g. JsonAdapter).

Source code in niceview/modelform.py
@classmethod
def from_adapter(cls, item_type: type[BaseModel], adapter: 'CollectionAdapter | ItemAdapter', key: str | None = None, **kwargs: Unpack[_ModelFormOptionInputs]) -> Self:
    """
    Create a ModelForm bound to an adapter.

    With key: wraps CollectionAdapter + key in a BoundItem.
    Without key: treats adapter directly as an ItemAdapter (e.g. JsonAdapter).
    """
    if not isinstance(item_type, type) or not issubclass(item_type, BaseModel):
        raise TypeError(f"item_type must be a subclass of BaseModel, got {item_type}")
    instance = cls(item_type, **kwargs)
    if key is not None:
        instance.load(BoundItem(adapter, key))  # type: ignore[arg-type]
    else:
        instance.load(adapter)  # type: ignore[arg-type]
    return instance

from_json classmethod

from_json(
    item_type: type[BaseModel],
    json_path: Path,
    *,
    create_if_not_exist: bool = True,
    lock_field: str | None = None,
    created_field: str | None = None,
    **kwargs: Unpack[_ModelFormOptionInputs],
) -> Self

Create a ModelForm bound to a single-item JSON file. The file is created with default values if it does not exist. Calls save() to persist changes; calls refresh() to re-read from disk.

Source code in niceview/modelform.py
@classmethod
def from_json(cls, item_type: type[BaseModel], json_path: Path, *, create_if_not_exist: bool = True, lock_field: str | None = None, created_field: str | None = None, **kwargs: Unpack[_ModelFormOptionInputs]) -> Self:
    """
    Create a ModelForm bound to a single-item JSON file.
    The file is created with default values if it does not exist.
    Calls save() to persist changes; calls refresh() to re-read from disk.
    """
    if not isinstance(item_type, type) or not issubclass(item_type, BaseModel):
        raise TypeError(f"item_type must be a subclass of BaseModel, got {item_type}")
    instance = cls(item_type, **kwargs)
    instance.load(JsonAdapter(item_type, json_path, create_if_not_exist=create_if_not_exist, lock_field=lock_field, created_field=created_field))
    return instance

load

load(adapter: ItemAdapter) -> Self
load(adapter: CollectionAdapter, key: str) -> Self
load(
    adapter: ItemAdapter | CollectionAdapter,
    key: str | None = None,
) -> Self

Bind the form to an adapter and load the item.

Two call forms: load(item_adapter) — any ItemAdapter (e.g. BoundItem, JsonAdapter) load(collection, key) — convenience: wraps in BoundItem internally

Use this for master-detail navigation (switching the displayed item at runtime).

Source code in niceview/modelform.py
def load(self, adapter: 'ItemAdapter | CollectionAdapter', key: str | None = None) -> Self:
    """
    Bind the form to an adapter and load the item.

    Two call forms:
      load(item_adapter)       — any ItemAdapter (e.g. BoundItem, JsonAdapter)
      load(collection, key)    — convenience: wraps in BoundItem internally

    Use this for master-detail navigation (switching the displayed item at runtime).
    """
    # The overloads guarantee: with key -> CollectionAdapter, without key -> ItemAdapter.
    item_adapter: ItemAdapter
    if key is not None:
        item_adapter = BoundItem(typing.cast(CollectionAdapter, adapter), key)
    else:
        item_adapter = typing.cast(ItemAdapter, adapter)
    self._item_adapter = item_adapter
    item = item_adapter.read()
    if not isinstance(item, BaseModel):
        raise TypeError(f"item must be a BaseModel instance, got {type(item)}")
    self._set_item(item)
    return self

refresh

refresh(notify: bool = True) -> None

Reload the item from the adapter, discarding any unsaved edits.

notify=False suppresses the ui.notify popup (e.g. for programmatic refreshes).

Source code in niceview/modelform.py
def refresh(self, notify: bool = True) -> None:
    """Reload the item from the adapter, discarding any unsaved edits.

    notify=False suppresses the ui.notify popup (e.g. for programmatic refreshes)."""
    if not self.adapter_bound:
        raise ValueError("No adapter set. Use from_adapter(), from_json(), or load() first.")
    item = self._item_adapter.read()  # type: ignore[union-attr]
    if not isinstance(item, BaseModel):
        raise TypeError(f"item must be a BaseModel instance, got {type(item)}")
    self._set_item(item, in_place=True)  # same item reloaded: keep bindings alive
    if notify:
        self._notify(self._text.form_refreshed, 'positive')

save

save(notify: bool = True) -> None

Persist the current item to the adapter. No-op if validation errors are present.

notify=False suppresses all ui.notify popups (success and error); errors are still logged and reflected in the form's validation state.

Source code in niceview/modelform.py
def save(self, notify: bool = True) -> None:
    """Persist the current item to the adapter. No-op if validation errors are present.

    notify=False suppresses all ui.notify popups (success and error); errors are
    still logged and reflected in the form's validation state."""
    if self._item_adapter is None:
        raise ValueError("No adapter set. Use from_adapter(), from_json(), or load() first.")

    if self.has_validation_errors:
        if notify:
            self._notify(self._text.validation_errors, 'negative')
        return

    try:
        updated = self._item_adapter.save(self.item)
    except (ConflictError, StorageError) as e:
        log.error(f"save failed: {e}")
        if notify:
            self._notify(str(e), 'negative')  # the adapter's own message, not one of ours
        return
    if updated is not None and updated is not self._validated_item:
        # Adapters may return a new instance (e.g. with generated ids). Copy the values in
        # instead of rebinding, so bindings on form.item survive a save.
        if not self._copy_into_item(updated):
            self._validated_item = updated
        self._current_item = self._validated_item.model_copy()  # type: ignore[union-attr]
    if notify:
        self._notify(self._text.form_saved, 'positive')

w

w(field_name: str) -> FormWidget
w(field_name: str, widget_type: type[W]) -> W
w(
    field_name: str, widget_type: type[W] | None = None
) -> FormWidget | W

Return the rendered widget for a field, with optional type narrowing.

form.w('name') # → ui.element (or ModelGrid / EditGridWrapper / # CheckboxGroup for editgrid / checkbox_group fields) form.w('name', ui.input) # → ui.input (typed; raises TypeError if mismatch) form.w('perms', CheckboxGroup) # → CheckboxGroup form.w('@test') # → ui.button (an action, written as in the layout)

Raises KeyError if the field has no widget (e.g. not yet rendered or excluded). Raises TypeError if the widget exists but is not an instance of widget_type.

Source code in niceview/modelform.py
def w(self, field_name: str, widget_type: 'type[W] | None' = None) -> 'FormWidget | W':
    """
    Return the rendered widget for a field, with optional type narrowing.

      form.w('name')                   # → ui.element (or ModelGrid / EditGridWrapper /
                                        #   CheckboxGroup for editgrid / checkbox_group fields)
      form.w('name', ui.input)         # → ui.input        (typed; raises TypeError if mismatch)
      form.w('perms', CheckboxGroup)   # → CheckboxGroup
      form.w('@test')                  # → ui.button       (an action, written as in the layout)

    Raises KeyError if the field has no widget (e.g. not yet rendered or excluded).
    Raises TypeError if the widget exists but is not an instance of widget_type.
    """
    if field_name.startswith('@'):
        # An action is addressed the way the layout writes it. Its button lives in
        # action_buttons rather than in widgets, which is keyed by field name and walked
        # by everything that pushes values, converts them and validates them.
        try:
            widget = self.action_buttons[field_name[1:]]
        except KeyError:
            raise KeyError(f"No button for action '{field_name}'. Check that the form is "
                           "rendered and the action is placed in the layout.")
        if widget_type is not None and not isinstance(widget, widget_type):
            raise TypeError(f"Button for '{field_name}' is {type(widget).__name__}, not {widget_type.__name__}")
        return widget  # type: ignore[return-value]
    try:
        widget = self.widgets[field_name]
    except KeyError:
        raise KeyError(f"No widget for field '{field_name}'. "
                       "Check that the form is rendered and the field is not excluded.")
    if widget_type is not None and not isinstance(widget, widget_type):
        raise TypeError(
            f"Widget for '{field_name}' is {type(widget).__name__}, not {widget_type.__name__}"
        )
    return widget  # type: ignore[return-value]

with_repositories

with_repositories(repositories: dict) -> Self

Provide adapters for modelselect fields (dropdowns over a CollectionAdapter).

Keys are either a field name (preferred — two fields can reference the same model through different collections, and a scalar key-select field is resolved by its name) or, for the SQLModel-relationship style, the related model class. Values are the CollectionAdapters. When a field-name-keyed adapter is given, the field's item_type is inferred from the adapter if not set. Additive: repeated calls merge (a later entry overrides an earlier one for the same key), so a wrapper's registrations combine with the form's own rather than replacing them. Returns self for chaining.

Source code in niceview/modelform.py
def with_repositories(self, repositories: 'dict') -> Self:
    """
    Provide adapters for modelselect fields (dropdowns over a CollectionAdapter).

    Keys are either a **field name** (preferred — two fields can reference the same model
    through different collections, and a scalar key-select field is resolved by its name) or,
    for the SQLModel-relationship style, the related **model class**. Values are the
    CollectionAdapters. When a field-name-keyed adapter is given, the field's `item_type` is
    inferred from the adapter if not set. Additive: repeated calls merge (a later entry
    overrides an earlier one for the same key), so a wrapper's registrations combine with the
    form's own rather than replacing them. Returns self for chaining.
    """
    if not isinstance(repositories, dict):
        raise TypeError(f"repositories must be a dictionary, got {type(repositories)}")
    self._model_repositories = {**self._model_repositories, **repositories}  # merge; new wins
    return self

on_change

on_change(
    callback: Handler[FieldChangeEventArguments],
) -> Self

Add a callback to be invoked when the form values change and the new values are successfully validated.

Source code in niceview/modelform.py
def on_change(self, callback: Handler[FieldChangeEventArguments]) -> Self:
    """
    Add a callback to be invoked when the form values change and
    the new values are successfully validated.
    """
    if not callable(callback):
        raise TypeError(f"callback must be callable, got {type(callback)}")
    self._change_handlers.append(callback)
    return self

render_field

render_field(
    field_name: str, **kwargs: Unpack[_FieldInfoInputs]
) -> Any

Render a single named field in the current NiceGUI context.

Returns the created widget so callers can style it immediately: form.render_field('name').classes('w-full')

Optional kwargs override FieldInfo attributes for this render only: form.render_field('name', label='Short name') form.render_field('is_active', label='') # suppress label

Unlike render(), this does not reset existing widgets — call it multiple times inside any layout structure to position fields individually. The non-field error label is not rendered; call render_nonfield_errors() separately to place it wherever needed.

Raises ValueError for unknown or hidden fields.

Source code in niceview/modelform.py
def render_field(self, field_name: str, **kwargs: Unpack[_FieldInfoInputs]) -> Any:
    """
    Render a single named field in the current NiceGUI context.

    Returns the created widget so callers can style it immediately:
      form.render_field('name').classes('w-full')

    Optional kwargs override FieldInfo attributes for this render only:
      form.render_field('name', label='Short name')
      form.render_field('is_active', label='')   # suppress label

    Unlike render(), this does not reset existing widgets — call it multiple
    times inside any layout structure to position fields individually.
    The non-field error label is not rendered; call render_nonfield_errors()
    separately to place it wherever needed.

    Raises ValueError for unknown or hidden fields.
    """
    if field_name not in self._fields:
        raise ValueError(f"Field '{field_name}' is not in the form's field set")
    field_info = self._fields[field_name]
    if not field_info:
        raise ValueError(f"Field info for '{field_name}' not found")
    if field_info.hidden:
        raise ValueError(f"Field '{field_name}' is hidden and cannot be rendered individually")
    if kwargs:
        field_info = _merge_field_infos(field_info, FieldInfo(**kwargs))
    widget = self._render_widget(field_name, self._styled(field_info))
    self.widgets[field_name] = widget
    return widget

render_action

render_action(name: str) -> button

Render one of the form's actions in the current NiceGUI context, for a layout built by hand out of render_field() calls — render() places the actions the layout names itself.

Returns the created button, which is also kept in action_buttons and reachable as form.w('@name').

Source code in niceview/modelform.py
def render_action(self, name: str) -> ui.button:
    """
    Render one of the form's actions in the current NiceGUI context, for a layout built by
    hand out of render_field() calls — render() places the actions the layout names itself.

    Returns the created button, which is also kept in `action_buttons` and reachable as
    `form.w('@name')`.
    """
    name = name.lstrip('@')
    if name not in self._actions:
        raise ValueError(f"Unknown action '{name}' — the form's actions are: "
                         f"{sorted(self._actions) or 'none'}")
    button = self._render_action(name, self._actions[name])
    self.action_buttons[name] = button
    return button

render_nonfield_errors

render_nonfield_errors() -> label

Render the non-field (model-level) validation error label in the current NiceGUI context.

Validation runs model_validate() over the whole model, so an error with no widget to sit under — a cross-field @model_validator, or a field that is excluded or hidden — surfaces here rather than below a field.

Returns the created ui.label so callers can style it: form.render_nonfield_errors().classes('q-mt-sm')

Call this separately when using render_field() to control its placement. render() calls this automatically at the end.

Source code in niceview/modelform.py
def render_nonfield_errors(self) -> ui.label:
    """
    Render the non-field (model-level) validation error label in the current NiceGUI context.

    Validation runs model_validate() over the whole model, so an error with no widget to sit
    under — a cross-field @model_validator, or a field that is excluded or hidden — surfaces
    here rather than below a field.

    Returns the created ui.label so callers can style it:
      form.render_nonfield_errors().classes('q-mt-sm')

    Call this separately when using render_field() to control its placement.
    render() calls this automatically at the end.
    """
    self._nonfield_error_element = ui.label('').classes('text-negative w-full')
    self._nonfield_error_element.set_visibility(False)
    return self._nonfield_error_element

render

render() -> Self

Render all non-hidden fields followed by the non-field error label.

Fields are arranged according to the form's layout (from layout=, from the selected Meta.profiles entry, or simply one below the other), together with the actions the layout names as '@name'. Use render_field(), render_action() and render_nonfield_errors() instead when a layout the notation cannot express is needed.

Source code in niceview/modelform.py
def render(self) -> Self:
    """
    Render all non-hidden fields followed by the non-field error label.

    Fields are arranged according to the form's layout (from `layout=`, from the selected
    `Meta.profiles` entry, or simply one below the other), together with the actions the
    layout names as '@name'. Use render_field(), render_action() and render_nonfield_errors()
    instead when a layout the notation cannot express is needed.
    """
    # A re-render replaces the buttons of the layout — but not the ones a wrapper registered
    # for its own title row, which is drawn before the form and stays where it is.
    own = {id(button) for button in self.action_buttons.values()}
    self._validity_gated = [b for b in self._validity_gated if id(b) not in own]
    self.widgets = {}
    self.action_buttons = {}
    self._render_group(self._fields.layout)
    self.render_nonfield_errors()
    return self

FormAction dataclass

A button in a form that is not a field: 'Test connection' next to the host, 'Generate' next to the password. Declared in a table and placed by name — '@test' in the layout, or chrome_actions= on a wrapper for its title row.

The name is the key of that table; everything the name cannot carry lives here, the callback above all. Label and tooltip take a callable as well, so they can follow the client's language like every other text niceview renders.

Source code in niceview/modelform.py
@dataclass(frozen=True)
class FormAction:
    """
    A button in a form that is not a field: 'Test connection' next to the host, 'Generate' next
    to the password. Declared in a table and placed by name — '@test' in the layout, or
    `chrome_actions=` on a wrapper for its title row.

    The name is the key of that table; everything the name cannot carry lives here, the callback
    above all. Label and tooltip take a callable as well, so they can follow the client's
    language like every other text niceview renders.
    """
    label: TextValue = ''
    """The button's text. '' makes it an icon-only button, so it needs an `icon` then."""
    on_click: ('Handler[FormActionEventArguments] | Handler[GridActionEventArguments] | '
               'Handler[DrillDownActionEventArguments] | Handler[DrillDownListActionEventArguments] | None') = None
    """Event arguments of where the button sits, sync or async: FormActionEventArguments (a
    form), GridActionEventArguments (a grid selection), DrillDownActionEventArguments
    (detail_actions), or DrillDownListActionEventArguments (list_actions, no key/item)."""
    icon: str = ''
    """A Material icon name. Empty renders no icon."""
    tooltip: TextValue = ''
    """Shown on hover, unless the chrome style turns tooltips off."""
    props: str = ''
    """Quasar props, merged on top of the place and shape layers of the chrome style."""
    classes: str = ''
    """CSS classes of the button. In a row, they replace the default 'self-center'."""
    requires_valid: bool = False
    """Disable the button while the form has validation errors. Needs a form to ask —
    EditGridWrapper, list_actions, and a custom render_detail reject it instead."""

    def __post_init__(self) -> None:
        if not self.label and not self.icon:
            raise ValueError("A FormAction needs a label or an icon — a button with neither is invisible")

label class-attribute instance-attribute

label: TextValue = ''

The button's text. '' makes it an icon-only button, so it needs an icon then.

on_click class-attribute instance-attribute

on_click: (
    Handler[FormActionEventArguments]
    | Handler[GridActionEventArguments]
    | Handler[DrillDownActionEventArguments]
    | Handler[DrillDownListActionEventArguments]
    | None
) = None

Event arguments of where the button sits, sync or async: FormActionEventArguments (a form), GridActionEventArguments (a grid selection), DrillDownActionEventArguments (detail_actions), or DrillDownListActionEventArguments (list_actions, no key/item).

icon class-attribute instance-attribute

icon: str = ''

A Material icon name. Empty renders no icon.

tooltip class-attribute instance-attribute

tooltip: TextValue = ''

Shown on hover, unless the chrome style turns tooltips off.

props class-attribute instance-attribute

props: str = ''

Quasar props, merged on top of the place and shape layers of the chrome style.

classes class-attribute instance-attribute

classes: str = ''

CSS classes of the button. In a row, they replace the default 'self-center'.

requires_valid class-attribute instance-attribute

requires_valid: bool = False

Disable the button while the form has validation errors. Needs a form to ask — EditGridWrapper, list_actions, and a custom render_detail reject it instead.

FormActionEventArguments dataclass

Bases: UiEventArguments

What an action's on_click receives. form.item and form.draft are the point of it.

Source code in niceview/modelform.py
@dataclass(kw_only=True, slots=True)
class FormActionEventArguments(UiEventArguments):
    """What an action's `on_click` receives. `form.item` and `form.draft` are the point of it."""
    form: 'ModelForm'
    """The form the action belongs to."""
    name: str
    """The action's name, as declared in `actions`."""
    action: 'FormAction'
    """The FormAction itself (label, icon, on_click, ...)."""

form instance-attribute

form: ModelForm

The form the action belongs to.

name instance-attribute

name: str

The action's name, as declared in actions.

action instance-attribute

action: FormAction

The FormAction itself (label, icon, on_click, ...).

render_action_button

render_action_button(
    action: FormAction,
    style: ChromeStyle,
    place: Place,
    classes: str | None,
    on_click: Callable[..., Any],
) -> button

Build one action button in the current NiceGUI context — the same button wherever an action sits, so a form's '@name' and a wrapper's chrome_actions / list_actions / detail_actions cannot drift apart.

It goes through the same chrome layers as a Save or a Delete — the props of its place and the shape its label asks for — minus the role layer: the roles are niceview's closed vocabulary, and an application's own action is not one of them, so it styles itself.

What stays with the caller is the click (each place sends its own event arguments) and, where there is a form to ask, requires_valid.

Source code in niceview/modelform.py
def render_action_button(action: FormAction, style: ChromeStyle, place: Place, classes: str | None,
                         on_click: Callable[..., Any]) -> ui.button:
    """
    Build one action button in the current NiceGUI context — the same button wherever an action
    sits, so a form's '@name' and a wrapper's `chrome_actions` / `list_actions` / `detail_actions`
    cannot drift apart.

    It goes through the same chrome layers as a Save or a Delete — the props of its place and
    the shape its label asks for — minus the role layer: the roles are niceview's closed
    vocabulary, and an application's own action is not one of them, so it styles itself.

    What stays with the caller is the click (each place sends its own event arguments) and, where
    there is a form to ask, `requires_valid`.
    """
    button = chrome_button(None, text_of(action.label), action.icon or None,
                           text_of(action.tooltip), style, on_click, place)
    if chosen := (classes or action.classes):
        button.classes(chosen)
    if action.props:
        button.props(action.props)
    return button

FieldChangeEventArguments dataclass

Bases: UiEventArguments

What ModelForm's on_change receives when a field's value changes.

Source code in niceview/modelform.py
@dataclass(kw_only=True, slots=True)
class FieldChangeEventArguments(UiEventArguments):
    """What ModelForm's `on_change` receives when a field's value changes."""
    form: 'ModelForm'
    """The form the field belongs to."""
    field_name: str
    """Name of the changed field."""
    previous_value: Any
    """The field's value before this change."""
    value: Any
    """The field's new value."""

form instance-attribute

form: ModelForm

The form the field belongs to.

field_name instance-attribute

field_name: str

Name of the changed field.

previous_value instance-attribute

previous_value: Any

The field's value before this change.

value instance-attribute

value: Any

The field's new value.

EditFormWrapper

Chrome wrapper for ModelForm: renders title, description, and action buttons (save, refresh) above the form fields.

Title semantics: a form edits a single item, so there is no auto-generated title — omitted, None or '' all show no title; any other string is used verbatim. title defaults from the singular Meta.title (the collection wrappers use Meta.title_plural instead), description from Meta.description; both are overridden by these kwargs.

Intelligent button presets based on the factory method used: - from_item(): no buttons by default (in-memory, no adapter) - from_json(): save + refresh shown by default (adapter exists) - from_adapter(): save + refresh shown by default (adapter exists) Autosave suppresses the save button regardless.

chrome_actions adds the application's own buttons to that row — the same FormAction the form places between its fields, here left of Refresh and Save, so niceview's own buttons keep the right edge they always have.

After render(), the NiceGUI elements are exposed for further styling: wrapper.title → ui.label | None wrapper.save_button → ui.button | None wrapper.refresh_button → ui.button | None wrapper.action_buttons → dict[str, ui.button]

Source code in niceview/editwrapper.py
class EditFormWrapper():
    """
    Chrome wrapper for ModelForm: renders title, description, and action buttons
    (save, refresh) above the form fields.

    Title semantics: a form edits a single item, so there is no auto-generated title —
    omitted, None or '' all show no title; any other string is used verbatim. `title` defaults
    from the singular `Meta.title` (the collection wrappers use `Meta.title_plural` instead),
    `description` from `Meta.description`; both are overridden by these kwargs.

    Intelligent button presets based on the factory method used:
    - from_item():    no buttons by default (in-memory, no adapter)
    - from_json():    save + refresh shown by default (adapter exists)
    - from_adapter(): save + refresh shown by default (adapter exists)
    Autosave suppresses the save button regardless.

    `chrome_actions` adds the application's own buttons to that row — the same `FormAction` the
    form places between its fields, here left of Refresh and Save, so niceview's own buttons keep
    the right edge they always have.

    After render(), the NiceGUI elements are exposed for further styling:
        wrapper.title          → ui.label | None
        wrapper.save_button    → ui.button | None
        wrapper.refresh_button → ui.button | None
        wrapper.action_buttons → dict[str, ui.button]
    """
    _rendered: bool
    _title: str | None
    _description: str | None
    _save_button: str | None
    _refresh_button: str | None
    _chrome_actions: dict[str, FormAction]
    _chrome_style: ChromeStyle | None
    _chrome_text: ChromeText | None
    _place: Place

    # Exposed NiceGUI elements (populated by render())
    title: ui.label | None
    save_button: ui.button | None
    refresh_button: ui.button | None
    action_buttons: dict[str, ui.button]
    title_row: ui.row | None
    description: ui.markdown | None
    form: ModelForm

    def __init__(self, form: ModelForm, **kwargs: Unpack[_EditFormWrapperInputs]) -> None:
        has_adapter = form.adapter_bound
        autosave = form.autosave

        self._title = meta_option(form._item_type, kwargs, 'title', None) or None
        self._description = meta_option(form._item_type, kwargs, 'description', None)

        # Intelligent presets: show save/refresh when adapter exists, hide when autosave
        default_save = None if autosave else ('' if has_adapter else None)
        default_refresh = '' if has_adapter else None
        self._save_button = kwargs.pop('save_button', default_save)
        self._refresh_button = kwargs.pop('refresh_button', default_refresh)
        self._chrome_actions = ModelForm._checked_actions(kwargs.pop('chrome_actions', {}))
        self._chrome_style = kwargs.pop('chrome_style', None)
        self._chrome_text = kwargs.pop('chrome_text', None)
        self._place = kwargs.pop('place', 'toolbar')

        self._rendered = False
        self.title = None
        self.save_button = None
        self.refresh_button = None
        self.action_buttons = {}
        self.title_row = None
        self.description = None
        self.form = form

        if kwargs:
            raise TypeError(f"Unexpected keyword arguments for EditFormWrapper: {', '.join(kwargs.keys())}")

    @property
    def _style(self) -> ChromeStyle:
        return self._chrome_style or get_chrome_style()

    @property
    def _text(self) -> ChromeText:
        return self._chrome_text or get_chrome_text()

    # --- factory methods ---------------------------------------------------

    @classmethod
    def from_item(cls, item_type_or_item: 'type[BaseModel] | BaseModel', item: 'BaseModel | None' = None, /, **kwargs: Unpack[_EditFormWrapperFactoryInputs]) -> Self:
        """Create an EditFormWrapper backed by an in-memory item. Call render() (fluent: from_item(...).render()) to draw it."""
        wrapper_kwargs, repositories, form_kwargs = _split_form_kwargs(kwargs)
        if isinstance(item_type_or_item, BaseModel):
            if item is not None:
                raise TypeError("When passing an item instance as the first argument, do not pass a second item")
            form = ModelForm.from_item(item_type_or_item, **form_kwargs)
        else:
            if item is None:
                raise TypeError("When passing an item type as the first argument, an item instance is required")
            form = ModelForm.from_item(item_type_or_item, item, **form_kwargs)
        if repositories:
            form.with_repositories(repositories)
        return cls(form, **wrapper_kwargs)

    @classmethod
    def from_json(cls, item_type: type[BaseModel], json_path: Path, *, create_if_not_exist: bool = True, lock_field: str | None = None, created_field: str | None = None, **kwargs: Unpack[_EditFormWrapperFactoryInputs]) -> Self:
        """Create an EditFormWrapper backed by a JSON file with Save and Refresh buttons. Call render() to draw it."""
        wrapper_kwargs, repositories, form_kwargs = _split_form_kwargs(kwargs)
        form = ModelForm.from_json(item_type, json_path, create_if_not_exist=create_if_not_exist, lock_field=lock_field, created_field=created_field, **form_kwargs)
        if repositories:
            form.with_repositories(repositories)
        return cls(form, **wrapper_kwargs)

    @classmethod
    def from_adapter(cls, item_type: type[BaseModel], adapter: 'CollectionAdapter | ItemAdapter', key: str | None = None, **kwargs: Unpack[_EditFormWrapperFactoryInputs]) -> Self:
        """Create an EditFormWrapper backed by an adapter with Save and Refresh buttons. Call render() to draw it.

        With key: wraps CollectionAdapter + key in a BoundItem.
        Without key: treats adapter directly as an ItemAdapter (e.g. JsonAdapter).
        """
        wrapper_kwargs, repositories, form_kwargs = _split_form_kwargs(kwargs)
        form = ModelForm.from_adapter(item_type, adapter, key, **form_kwargs)
        if repositories:
            form.with_repositories(repositories)
        return cls(form, **wrapper_kwargs)

    # --- delegation --------------------------------------------------------

    def with_repositories(self, repositories: 'dict') -> Self:
        """Delegate to the inner ModelForm (which merges rather than replaces)."""
        self.form.with_repositories(repositories)
        return self

    def on_change(self, callback: Handler[FieldChangeEventArguments]) -> Self:
        """Delegate to the inner ModelForm's on_change."""
        self.form.on_change(callback)
        return self

    def load(self, adapter: 'ItemAdapter | CollectionAdapter', key: str | None = None) -> Self:
        """
        Load a specific item (master-detail navigation). Delegates to ModelForm.load().

        Two call forms:
          load(item_adapter)    — any ItemAdapter (BoundItem, JsonAdapter, …)
          load(collection, key) — convenience: wraps in BoundItem internally
        """
        if key is not None:
            self.form.load(adapter, key)  # type: ignore[arg-type]
        else:
            self.form.load(adapter)  # type: ignore[arg-type]
        return self

    # --- render ------------------------------------------------------------

    def render(self) -> Self:
        """Render title, description, action buttons, and the form into the current NiceGUI context."""
        if self._rendered:
            return self
        self.title = None
        self.save_button = None
        self.refresh_button = None
        self.action_buttons = {}
        self.title_row = None
        self.description = None

        style, text, place = self._style, self._text, self._place
        button_count = sum(b is not None for b in [self._save_button, self._refresh_button]) + len(self._chrome_actions)
        has_chrome = bool(self._title) or button_count > 0
        if has_chrome:
            with chrome_row(style) as self.title_row:
                if self._title:
                    self.title = chrome_title(self._title, style)
                if button_count:
                    if not self._title:
                        ui.space()
                    with chrome_buttons(style, button_count):
                        for name, action in self._chrome_actions.items():
                            self.action_buttons[name] = self.form._render_action(name, action, place=place)
                        if self._refresh_button is not None:
                            self.refresh_button = chrome_button('refresh', self._refresh_button, 'refresh', text_of(text.refresh_tooltip), style, lambda _: self.form.refresh(), place)
                        if self._save_button is not None:
                            self.save_button = chrome_button('save', self._save_button, 'save', text_of(text.save_tooltip), style, lambda _: self.form.save(), place)

        if self._description:
            self.description = ui.markdown(self._description)

        self.form.render()
        self._rendered = True
        return self

from_item classmethod

from_item(
    item_type_or_item: type[BaseModel] | BaseModel,
    item: BaseModel | None = None,
    /,
    **kwargs: Unpack[_EditFormWrapperFactoryInputs],
) -> Self

Create an EditFormWrapper backed by an in-memory item. Call render() (fluent: from_item(...).render()) to draw it.

Source code in niceview/editwrapper.py
@classmethod
def from_item(cls, item_type_or_item: 'type[BaseModel] | BaseModel', item: 'BaseModel | None' = None, /, **kwargs: Unpack[_EditFormWrapperFactoryInputs]) -> Self:
    """Create an EditFormWrapper backed by an in-memory item. Call render() (fluent: from_item(...).render()) to draw it."""
    wrapper_kwargs, repositories, form_kwargs = _split_form_kwargs(kwargs)
    if isinstance(item_type_or_item, BaseModel):
        if item is not None:
            raise TypeError("When passing an item instance as the first argument, do not pass a second item")
        form = ModelForm.from_item(item_type_or_item, **form_kwargs)
    else:
        if item is None:
            raise TypeError("When passing an item type as the first argument, an item instance is required")
        form = ModelForm.from_item(item_type_or_item, item, **form_kwargs)
    if repositories:
        form.with_repositories(repositories)
    return cls(form, **wrapper_kwargs)

from_json classmethod

from_json(
    item_type: type[BaseModel],
    json_path: Path,
    *,
    create_if_not_exist: bool = True,
    lock_field: str | None = None,
    created_field: str | None = None,
    **kwargs: Unpack[_EditFormWrapperFactoryInputs],
) -> Self

Create an EditFormWrapper backed by a JSON file with Save and Refresh buttons. Call render() to draw it.

Source code in niceview/editwrapper.py
@classmethod
def from_json(cls, item_type: type[BaseModel], json_path: Path, *, create_if_not_exist: bool = True, lock_field: str | None = None, created_field: str | None = None, **kwargs: Unpack[_EditFormWrapperFactoryInputs]) -> Self:
    """Create an EditFormWrapper backed by a JSON file with Save and Refresh buttons. Call render() to draw it."""
    wrapper_kwargs, repositories, form_kwargs = _split_form_kwargs(kwargs)
    form = ModelForm.from_json(item_type, json_path, create_if_not_exist=create_if_not_exist, lock_field=lock_field, created_field=created_field, **form_kwargs)
    if repositories:
        form.with_repositories(repositories)
    return cls(form, **wrapper_kwargs)

from_adapter classmethod

from_adapter(
    item_type: type[BaseModel],
    adapter: CollectionAdapter | ItemAdapter,
    key: str | None = None,
    **kwargs: Unpack[_EditFormWrapperFactoryInputs],
) -> Self

Create an EditFormWrapper backed by an adapter with Save and Refresh buttons. Call render() to draw it.

With key: wraps CollectionAdapter + key in a BoundItem. Without key: treats adapter directly as an ItemAdapter (e.g. JsonAdapter).

Source code in niceview/editwrapper.py
@classmethod
def from_adapter(cls, item_type: type[BaseModel], adapter: 'CollectionAdapter | ItemAdapter', key: str | None = None, **kwargs: Unpack[_EditFormWrapperFactoryInputs]) -> Self:
    """Create an EditFormWrapper backed by an adapter with Save and Refresh buttons. Call render() to draw it.

    With key: wraps CollectionAdapter + key in a BoundItem.
    Without key: treats adapter directly as an ItemAdapter (e.g. JsonAdapter).
    """
    wrapper_kwargs, repositories, form_kwargs = _split_form_kwargs(kwargs)
    form = ModelForm.from_adapter(item_type, adapter, key, **form_kwargs)
    if repositories:
        form.with_repositories(repositories)
    return cls(form, **wrapper_kwargs)

with_repositories

with_repositories(repositories: dict) -> Self

Delegate to the inner ModelForm (which merges rather than replaces).

Source code in niceview/editwrapper.py
def with_repositories(self, repositories: 'dict') -> Self:
    """Delegate to the inner ModelForm (which merges rather than replaces)."""
    self.form.with_repositories(repositories)
    return self

on_change

on_change(
    callback: Handler[FieldChangeEventArguments],
) -> Self

Delegate to the inner ModelForm's on_change.

Source code in niceview/editwrapper.py
def on_change(self, callback: Handler[FieldChangeEventArguments]) -> Self:
    """Delegate to the inner ModelForm's on_change."""
    self.form.on_change(callback)
    return self

load

load(
    adapter: ItemAdapter | CollectionAdapter,
    key: str | None = None,
) -> Self

Load a specific item (master-detail navigation). Delegates to ModelForm.load().

Two call forms: load(item_adapter) — any ItemAdapter (BoundItem, JsonAdapter, …) load(collection, key) — convenience: wraps in BoundItem internally

Source code in niceview/editwrapper.py
def load(self, adapter: 'ItemAdapter | CollectionAdapter', key: str | None = None) -> Self:
    """
    Load a specific item (master-detail navigation). Delegates to ModelForm.load().

    Two call forms:
      load(item_adapter)    — any ItemAdapter (BoundItem, JsonAdapter, …)
      load(collection, key) — convenience: wraps in BoundItem internally
    """
    if key is not None:
        self.form.load(adapter, key)  # type: ignore[arg-type]
    else:
        self.form.load(adapter)  # type: ignore[arg-type]
    return self

render

render() -> Self

Render title, description, action buttons, and the form into the current NiceGUI context.

Source code in niceview/editwrapper.py
def render(self) -> Self:
    """Render title, description, action buttons, and the form into the current NiceGUI context."""
    if self._rendered:
        return self
    self.title = None
    self.save_button = None
    self.refresh_button = None
    self.action_buttons = {}
    self.title_row = None
    self.description = None

    style, text, place = self._style, self._text, self._place
    button_count = sum(b is not None for b in [self._save_button, self._refresh_button]) + len(self._chrome_actions)
    has_chrome = bool(self._title) or button_count > 0
    if has_chrome:
        with chrome_row(style) as self.title_row:
            if self._title:
                self.title = chrome_title(self._title, style)
            if button_count:
                if not self._title:
                    ui.space()
                with chrome_buttons(style, button_count):
                    for name, action in self._chrome_actions.items():
                        self.action_buttons[name] = self.form._render_action(name, action, place=place)
                    if self._refresh_button is not None:
                        self.refresh_button = chrome_button('refresh', self._refresh_button, 'refresh', text_of(text.refresh_tooltip), style, lambda _: self.form.refresh(), place)
                    if self._save_button is not None:
                        self.save_button = chrome_button('save', self._save_button, 'save', text_of(text.save_tooltip), style, lambda _: self.form.save(), place)

    if self._description:
        self.description = ui.markdown(self._description)

    self.form.render()
    self._rendered = True
    return self