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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions lib/src/ui/widgets/fields/field_factory.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import '../../../services/link_field_coordinator.dart';
import 'attach_field.dart';
import '../../../models/image_pick_source.dart';
import '../../../services/media_resolver.dart';
import '../../../utils/frappe_reserved_fields.dart';
import '../../../utils/media_store.dart';
import 'base_field.dart';
import 'button_field.dart';
Expand Down Expand Up @@ -66,6 +67,7 @@ import 'time_field.dart';
/// - [mediaResolver]
/// - [isOfflineMode]
/// - [imagePickSource]
/// - [doctype]
///
/// **Subclassing is the supported pattern; COMPOSITION is not.** A host that
/// wraps an inner `FieldFactory` and delegates to it must forward every field
Expand All @@ -84,6 +86,17 @@ class FieldFactory {
LinkFieldCoordinator? linkFieldCoordinator;
FieldStyle? defaultStyle;

/// The DocType whose form this factory is rendering, un-scrubbed (e.g.
/// `Item Group`). Assigned by `FrappeFormBuilder` from `meta.name`.
///
/// Needed only to recognise the one reserved fieldname that is
/// doctype-dependent — `add_nestedset_fields()` names the tree parent Link
/// `frappe.scrub(f"Parent {self.name}")`. With it unset the fixed reserved
/// names are still caught; only `parent_<scrubbed doctype>` is missed, so a
/// host that forgets it degrades rather than breaks. See
/// [isFrappeReservedField].
String? doctype;

/// When false, drop the length cap on `Data` fields entirely — Frappe stores
/// **Single** doctypes as `mediumtext` and exempts them from the cap
/// regardless of any explicit `DocField.length`. Default true (cap), matching
Expand Down Expand Up @@ -250,6 +263,10 @@ class FieldFactory {
onChanged: onChanged,
enabled: enabled,
style: fieldStyle,
allowPreselect: !isFrappeReservedField(
field.fieldname,
doctype: doctype,
),
);

case 'Table MultiSelect':
Expand Down Expand Up @@ -334,6 +351,10 @@ class FieldFactory {
getLinkFilterBuilder: getLinkFilterBuilder,
style: fieldStyle,
onIsLocalChanged: onIsLocalChanged,
allowPreselect: !isFrappeReservedField(
field.fieldname,
doctype: doctype,
),
);

case 'Table':
Expand Down
47 changes: 38 additions & 9 deletions lib/src/ui/widgets/fields/link_field.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:collection';

import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
Expand All @@ -8,6 +9,7 @@ import '../../../models/link_filter_result.dart';
import '../../../services/link_option_service.dart';
import '../../../services/link_field_coordinator.dart';
import '../../../database/entities/link_option_entity.dart';
import '../../../utils/frappe_reserved_fields.dart';
import '../../../utils/uuid_pattern.dart';
import 'field_helpers.dart';
import 'searchable_select.dart';
Expand All @@ -27,6 +29,16 @@ class LinkField extends BaseField {
/// form data so [UuidRewriter] can rewrite the value at push time.
final ValueChanged<bool>? onIsLocalChanged;

/// When false, the single-option preselect never fires for this field.
/// `FieldFactory` sets it from [isFrappeReservedField]. The Link fields
/// this actually protects are the framework-owned ones — `amended_from`
/// (`options: self`, `read_only`), `auto_repeat` (`options: Auto Repeat`,
/// `read_only`) and the `is_tree` `parent_<scrubbed doctype>` Link, which
/// is neither hidden nor read-only and so is the one that would otherwise
/// silently acquire a parent the user never picked. Defaults to true so a
/// host constructing this widget directly keeps the previous behaviour.
final bool allowPreselect;

const LinkField({
super.key,
required super.field,
Expand All @@ -41,17 +53,26 @@ class LinkField extends BaseField {
this.parentFormData = const {},
this.getLinkFilterBuilder,
this.onIsLocalChanged,
this.allowPreselect = true,
});

@override
Widget buildField(BuildContext context) {
// If options are provided directly, use them
if (options != null && options!.isNotEmpty) {
// Deduplicated, order-preserving. [options] is a `createField` parameter
// the SDK never populates itself, so the list is whatever the host
// passed — and `DropdownButton` asserts when two `DropdownMenuItem`s
// share the value it is showing ("There should be exactly one item with
// [DropdownButton]'s value"). Deduping also restores the preselect for a
// sole option written twice: the count is 1 again, not 2. Mirrors
// `SelectField._getRawOptions`.
final staticOptions = LinkedHashSet<String>.of(options!).toList();
// Validate initialValue is in options list
final initialValueStr = value?.toString();
String? validInitialValue;
if (initialValueStr != null && initialValueStr.isNotEmpty) {
if (options!.contains(initialValueStr)) {
if (staticOptions.contains(initialValueStr)) {
validInitialValue = initialValueStr;
} else {
// Value not in options - use null
Expand All @@ -63,12 +84,15 @@ class LinkField extends BaseField {
// Propagate `onIsLocalChanged` so an auto-picked UUID-shaped
// option (offline mobile_uuid) flips `<field>__is_local` for
// UuidRewriter at push time — matches `_applyOptionsAndAutoSelect`.
if (options!.length == 1 &&
(validInitialValue == null || validInitialValue.isEmpty)) {
validInitialValue = options!.first;
if (staticOptions.length == 1 &&
(validInitialValue == null || validInitialValue.isEmpty) &&
allowPreselect &&
enabled &&
!field.readOnly) {
validInitialValue = staticOptions.first;
WidgetsBinding.instance.addPostFrameCallback((_) {
onChanged?.call(options!.first);
onIsLocalChanged?.call(looksLikeMobileUuid(options!.first));
onChanged?.call(staticOptions.first);
onIsLocalChanged?.call(looksLikeMobileUuid(staticOptions.first));
});
}

Expand All @@ -85,14 +109,14 @@ class LinkField extends BaseField {
);
return FormBuilderDropdown<String>(
autovalidateMode: AutovalidateMode.onUserInteraction,
key: ValueKey('link_${field.fieldname}_${options!.length}'),
key: ValueKey('link_${field.fieldname}_${staticOptions.length}'),
name: field.fieldname ?? '',
initialValue: validInitialValue,
enabled: enabled && !field.readOnly,
isExpanded: true,
decoration: tap.decoration,
padding: tap.padding,
items: options!
items: staticOptions
.map(
(option) => DropdownMenuItem(value: option, child: Text(option)),
)
Expand Down Expand Up @@ -138,6 +162,7 @@ class LinkField extends BaseField {
style: style,
onIsLocalChanged: onIsLocalChanged,
errorText: state.errorText,
allowPreselect: allowPreselect,
);
},
);
Expand Down Expand Up @@ -182,6 +207,7 @@ class _LinkFieldDropdown extends StatefulWidget {
final FieldStyle? style;
final ValueChanged<bool>? onIsLocalChanged;
final String? errorText;
final bool allowPreselect;

const _LinkFieldDropdown({
required this.field,
Expand All @@ -198,6 +224,7 @@ class _LinkFieldDropdown extends StatefulWidget {
this.style,
this.onIsLocalChanged,
this.errorText,
this.allowPreselect = true,
});

@override
Expand Down Expand Up @@ -256,7 +283,9 @@ class _LinkFieldDropdownState extends State<_LinkFieldDropdown> {
_isLoading = false;
_waitingForDependent = false;
});
if (options.length == 1) {
// `widget.enabled` already folds in `field.readOnly` (see the call site in
// [LinkField.buildField]), so this one clause covers both.
if (options.length == 1 && widget.allowPreselect && widget.enabled) {
final currentVal = widget.value?.toString();
final hasValidSelection =
currentVal != null &&
Expand Down
62 changes: 49 additions & 13 deletions lib/src/ui/widgets/fields/select_field.dart
Original file line number Diff line number Diff line change
@@ -1,30 +1,64 @@
import 'dart:collection';

import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import '../../../utils/frappe_reserved_fields.dart';
import '../../../utils/translate.dart';
import 'base_field.dart';
import 'field_helpers.dart';

/// Widget for Select field type. Supports single and multi-select (when field.allowMultiple).
class SelectField extends BaseField {
/// When false, the single-option preselect below never fires for this
/// field. `FieldFactory` sets it from [isFrappeReservedField] so a
/// framework-owned slot (`naming_series`, `amended_from`, the `is_tree`
/// `parent_<doctype>` Link, …) is never filled with a value the user did not
/// choose. Defaults to true so a host constructing this widget directly
/// keeps the previous behaviour.
final bool allowPreselect;

const SelectField({
super.key,
required super.field,
super.value,
super.onChanged,
super.enabled,
super.style,
this.allowPreselect = true,
});

/// Raw (untranslated) option keys — used as stored document values.
///
/// Deduplicated, order-preserving: `DropdownButton` asserts when two
/// `DropdownMenuItem`s share the value it is showing ("There should be
/// exactly one item with [DropdownButton]'s value"), so a DocType whose
/// `options` repeats a line would crash the field outright. Deduping also
/// restores the preselect for a sole option that happens to be written
/// twice — the count is 1 again, not 2.
List<String> _getRawOptions() {
if (field.options == null || field.options!.isEmpty) return [];
return field.options!
.split('\n')
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toList();
return LinkedHashSet<String>.of(
field.options!
.split('\n')
.map((e) => e.trim())
.where((e) => e.isNotEmpty),
).toList();
}

/// Whether the single-option preselect may fire.
///
/// `value == null` is the load-bearing clause. The form holds no entry for a
/// field it has never been given a value for, but an EXPLICIT clear stores
/// `''` (multi-select emits `_listToValue([])`). Preselecting on "no valid
/// selection" alone made the two indistinguishable, so unchecking the sole
/// option of a multi-select re-fired the preselect on the very next build
/// and pushed the value back — while `FormBuilderCheckboxGroup`, whose
/// `ValueKey` had not changed, stayed visibly unchecked. The widget and the
/// form data then disagreed, and `_handleSubmit`'s
/// `formValues.addAll(_formData)` let the form data win.
bool get _canPreselect =>
allowPreselect && enabled && !field.readOnly && value == null;

/// Translated display labels — used only for rendering.
List<String> _getOptions() {
final raw = _getRawOptions();
Expand Down Expand Up @@ -80,12 +114,13 @@ class SelectField extends BaseField {
.where((v) => rawOptions.contains(v))
.toList();

// Auto-select when exactly one option and no valid selection.
// Use raw English key for the stored value.
final displayList = rawOptions.length == 1 && validInitialList.isEmpty
? [rawOptions.first]
: validInitialList;
if (rawOptions.length == 1 && validInitialList.isEmpty) {
// Preselect when exactly one option and nothing is selected yet.
// Use raw English key for the stored value. See [_canPreselect] for why
// an explicitly-cleared value ('') is excluded.
final preselect =
rawOptions.length == 1 && validInitialList.isEmpty && _canPreselect;
final displayList = preselect ? [rawOptions.first] : validInitialList;
if (preselect) {
WidgetsBinding.instance.addPostFrameCallback((_) {
onChanged?.call(_listToValue([rawOptions.first]));
});
Expand Down Expand Up @@ -132,10 +167,11 @@ class SelectField extends BaseField {
}
}

// Auto-select when exactly one option and no valid selection.
// Preselect when exactly one option and nothing is selected yet.
// Emit raw English key — never a translated label.
if (rawOptions.length == 1 &&
(validInitialValue == null || validInitialValue.isEmpty)) {
(validInitialValue == null || validInitialValue.isEmpty) &&
_canPreselect) {
validInitialValue = rawOptions.first;
WidgetsBinding.instance.addPostFrameCallback((_) {
onChanged?.call(rawOptions.first);
Expand Down
5 changes: 5 additions & 0 deletions lib/src/ui/widgets/form_builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,11 @@ class _FrappeFormBuilderState extends State<FrappeFormBuilder>
// Frappe stores Single doctypes as mediumtext and exempts them from the
// implicit Data varchar(140) cap.
_fieldFactory.capDataLength = !widget.meta.isSingle;
// Un-scrubbed DocType name. Lets the factory recognise the `is_tree`
// `parent_<scrubbed doctype>` Link — the only reserved fieldname that is
// not the same on every DocType — and keep the single-option preselect off
// it. See [FieldFactory.doctype].
_fieldFactory.doctype = widget.meta.name;
_fieldFactory.errorTextResolver = _inlineTableErrorFor;
// The six attachment capabilities below are assigned ONLY when this widget
// was actually given one. An unconditional assignment clobbers a host that
Expand Down
87 changes: 87 additions & 0 deletions lib/src/utils/frappe_reserved_fields.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/// Frappe's reserved / framework-owned fieldnames, and the `frappe.scrub`
/// helper needed to derive the one that is doctype-dependent.
///
/// Used to keep the single-option preselect (see `SelectField` / `LinkField`)
/// off fields the framework owns: preselecting them writes a value the user
/// never chose into a slot Frappe assigns itself.
library;

/// Dart port of `frappe.scrub` (frappe/utils/data.py):
///
/// ```python
/// def scrub(txt: str) -> str:
/// return cstr(txt).replace(" ", "_").replace("-", "_").lower()
/// ```
///
/// Deliberately NOT [normalizeDoctypeTableName] (`database/table_name.dart`),
/// which additionally collapses every non-alphanumeric run to a single `_` and
/// strips leading/trailing `_`. That is correct for a SQLite identifier and
/// wrong here: `frappe.scrub` leaves apostrophes, slashes and repeated
/// separators alone, so a DocType named `Item's Group` yields the nestedset
/// parent field `parent_item's_group` — the table-name normalizer would
/// mispredict `parent_item_s_group` and the guard would miss the field.
String frappeScrub(String txt) =>
txt.replaceAll(' ', '_').replaceAll('-', '_').toLowerCase();

/// Reserved fieldnames that are the same on every DocType.
///
/// Split by origin, because the two halves reach a form differently:
///
/// **`std_fields` pseudo-docfields** — `name`, `owner`, `modified_by` are
/// declared `Link` only in `frappe/model/__init__.py`'s `std_fields` list, to
/// render the Report Builder / filter UI. They are not DocFields (on disk they
/// are plain `varchar(140)`), and `Meta.get_link_fields()` —
/// `self.get("fields", {"fieldtype": "Link", "options": ["!=", "[Select]"]})` —
/// scans DocFields only, so they never appear in it. Frappe additionally
/// REFUSES to create a DocField with these names (`scrub_field_names()` throws
/// `InvalidFieldNameError` for a `restricted` tuple that contains all three),
/// so they cannot reach `meta.fields` at all. Listed anyway as a cheap
/// belt-and-braces: the SDK renders whatever meta it is handed.
///
/// **Conditional real DocFields** — added by `frappe/core/doctype/doctype/
/// doctype.py` when the DocType opts in, and therefore genuinely present in
/// `meta.fields`:
///
/// | Field | Fieldtype | Options | Added when | Flags |
/// |----------------|-----------|---------------|-------------------------|--------------|
/// | `amended_from` | Link | self | `is_submittable` | `read_only` |
/// | `old_parent` | Link | self | `is_tree` | `hidden` |
/// | `auto_repeat` | Link | `Auto Repeat` | `allow_auto_repeat` | `read_only` |
///
/// `naming_series` is a Select and is convention rather than injection — the
/// DocType author declares it — but the name is reserved by that convention.
/// Frappe assigns it SERVER-SIDE at insert (`set_name_by_naming_series` →
/// `get_default_naming_series`, which returns the first TRUTHY option and
/// carries the comment *"Empty strings are used to avoid populating forms by
/// default"*). A client that preselects it defeats that.
///
/// The `is_tree` `parent_<scrubbed doctype>` field is doctype-dependent and so
/// is not in this set — see [isFrappeReservedField].
const frappeReservedFieldNames = <String>{
// std_fields pseudo-docfields (also in Frappe's `restricted` tuple).
'name',
'owner',
'modified_by',
// Conditional real DocFields.
'amended_from',
'old_parent',
'auto_repeat',
// Convention.
'naming_series',
};

/// Whether [fieldname] is a Frappe reserved / framework-owned field.
///
/// Pass [doctype] (the DocType's `name`, un-scrubbed) to also catch the
/// nestedset parent Link that `add_nestedset_fields()` adds to every
/// `is_tree` DocType. Frappe builds that fieldname as
/// `frappe.scrub(f"Parent {self.name}")`, so `Item Group` yields
/// `parent_item_group`. Without [doctype] the field is indistinguishable from
/// an ordinary `parent_*` field and is NOT flagged — `parent_company` on a
/// non-tree DocType is a perfectly normal user Link.
bool isFrappeReservedField(String? fieldname, {String? doctype}) {
if (fieldname == null || fieldname.isEmpty) return false;
if (frappeReservedFieldNames.contains(fieldname)) return true;
if (doctype == null || doctype.trim().isEmpty) return false;
return fieldname == frappeScrub('Parent $doctype');
}
Loading
Loading