Skip to content

New UI for Json field - #2038

Draft
MrVACO wants to merge 31 commits into
moonshine-software:5.xfrom
MrVACO:4.x
Draft

New UI for Json field#2038
MrVACO wants to merge 31 commits into
moonshine-software:5.xfrom
MrVACO:4.x

Conversation

@MrVACO

@MrVACO MrVACO commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

What was changed

  • Reworked the Json field rendering and nested field handling.
  • Fixed removing and drag-and-drop sorting for nested Json rows.
  • Added visual dividers for non-table Json preview output.
  • Added the localized Add text to the Json create button by default.
  • Added support for controlling create button text and icon visibility.
  • Added localized empty state output when no Json rows are added.
  • Improved Json field compatibility with nested fields, including field schema, raw values, prepared fields, and file hidden input indexing.
  • Added a dedicated Blade view for RelationRepeater instead of reusing the generic Json field view.
  • Updated RelationRepeater to render through moonshine::fields.relationships.relation-repeater.
  • Added localized empty state output for RelationRepeater when no items are added.
  • Aligned the RelationRepeater create button styling with the Json create button.
  • Added support for controlling RelationRepeater create button text and icon visibility.
  • Added compatibility methods to RelationRepeater: async(), disableAsync(), isAsync(), getRedirectAfter(), and getFormButtons().
New-UI-for-Json-field

Why?

The Json field needed more reliable nested behavior and a more consistent UI for create actions, empty states, previews, and nested row controls.

RelationRepeater was sharing the Json field view, so Json-specific rendering changes could accidentally affect relation repeater output. It also participates in relation handling flows that expect HasMany-like methods such as isAsync(), which could cause forms to fail with RelationRepeater::isAsync does not exist.

These changes separate RelationRepeater rendering from Json rendering, keep existing non-async behavior intact, and make both fields more consistent with MoonShine UI conventions.

Checklist

  • Tested

    • Tested manually
    • Tests added
  • Documentation - PR #1034

MrVACO added 2 commits July 2, 2026 10:58
# Conflicts:
#	src/UI/dist/assets/app.js
#	src/UI/dist/assets/main.css
#	src/UI/src/Fields/Json.php
#	tests/Feature/Fields/JsonFieldTest.php
#	tests/Unit/Fields/JsonFieldTest.php
@MrVACO
MrVACO marked this pull request as ready for review July 2, 2026 06:05
@MrVACO
MrVACO marked this pull request as draft July 3, 2026 17:17
@lee-to

lee-to commented Jul 6, 2026

Copy link
Copy Markdown
Member

I reviewed this PR and found a few risks that should be addressed before merge:

  1. MoonShineFormRequest::prepareJsonFieldsForValidation() only checks $payload[$name], where $name comes from getNameAttribute() with [] stripped. That works for a top-level data field, but it misses nested request payloads such as settings[items], items[0][data], relation/repeater rows, and other bracketed field names. Those values stay as encoded JSON strings before Laravel validation, so array and nested validation rules can fail for Json fields outside the simplest top-level case. This should probably use the field dot request name plus data_get/data_set, and relation-style indexed payloads likely need recursive handling.

  2. Object-mode Json reactivity appears to regress. The previous implementation added reactive attributes to nested object-mode fields with paths like json.child. The new implementation only reindexes fields and then renders nested controls with the Json component local x-model. Since reactive() on a HasFieldsContract field does not attach attributes to the parent itself, Json::make(...)->object()->reactive() no longer seems to expose data-reactive-column on the actual nested inputs, so the form reactive watcher will not observe changes.

  3. This removes or renames several public Json APIs and contracts: FieldWithComponentContract, RemovableContract, isObjectMode(), getCreateButton(), getCreateLimit(), isFilterMode(), getButtons(), isFilterEmpty(), and the custom getReactiveValue() behavior. If 4.x is expected to preserve userland compatibility, this is a breaking change and should either keep compatibility shims or be explicitly planned as a breaking API change.

I also ran PHPStan on the changed PHP classes and it passed. I could not get a valid Pest run from my temporary worktree because the symlinked vendor autoloaded classes from my main checkout, so I did not treat that test output as meaningful.

@lee-to

lee-to commented Jul 6, 2026

Copy link
Copy Markdown
Member

Review

Nice work overall — the new Alpine-based Json UI is a big usability upgrade, the nested rendering is much cleaner than the old TableBuilder approach, and splitting RelationRepeater into its own view is the right call. That said, I found several regressions and BC breaks that I think should be addressed before merge.

Blocking

  1. File uploads inside Json no longer work. The field now serializes all rows into a single hidden JSON input (payload), so a File field inside Json can't deliver an actual upload anymore — fieldType() has no File case (falls back to text), and even if the file input rendered, $_FILES can't travel through a JSON string. prepareRecursiveRowBeforeApply() only preserves the existing hidden_* path. The previously passing feature tests apply as base with file / apply as base with file stay hidden were deleted rather than adapted, which confirms the behavior loss. If dropping file support inside Json is intentional, it needs a loud changelog note + an exception when a File field is passed to fields(); otherwise this silently breaks existing admin panels.

  2. filterMode() renders an unusable filter. filterMode() calls creatable(false). With an empty filter value, viewData() produces rows: [], the blade shows only the empty-state ghost, and the add button is hidden (@if($creatable && ! $hideCreateButton)). The old implementation pushed one blank row when !isCreatable() && blank($values) so the filter always had inputs. Now a Json filter has no inputs and no way to add one. The apply as filter feature test was deleted too.

  3. Key/value payload heuristic can silently reshape valid data. In normalizeRows(), when keyValuePayloadMatchesFields() matches, a list of rows is collapsed into a single row (rowFromKeyValuePayload()). Since normalizeRows() is also used in prepareValueOnApply(), this doesn't just affect preview — it rewrites data on save. Any non-keyValue Json whose stored rows happen to have literal key/value columns with key values matching the field columns will lose rows. This compat shim would be safer restricted to preview rendering only, or gated behind an explicit flag.

BC breaks (4.x)

  1. reorderable default flipped and signature narrowed. Old: protected bool $isReorderable = true + reorderable(Closure|bool|null $condition = null). New: default false + reorderable(bool $condition = true). Existing projects silently lose drag sorting, and any code passing a closure now throws a TypeError.

  2. Removed/renamed public API without deprecation shims: getComponent(), getButtons(), isFilterMode(), isObjectMode()isObject(), getCreateLimit()getCreatableLimit(), isFilterEmpty()isFilteringEmpty(); Json no longer implements FieldWithComponentContract/RemovableContract (any instanceof checks change behavior). Also modifyTable() is now only honored in table/preview mode and is silently ignored on edit forms — worth documenting or throwing.

Non-blocking

  1. fieldsSchema() is rebuilt per row. normalizeRow() calls fieldsSchema() inside its loop, and for nested Json each call re-renders create/remove buttons through Blade (renderCreateButton/renderButtons/renderRemoveButton). With N rows and nested fields that's N full schema builds + N×buttons Blade renders per request. Memoizing the schema on the instance would fix it.

  2. MoonShineFormRequest::prepareJsonFieldsForValidation() swallows all errorscatch (Throwable) { return; } around getFormFields() will hide genuine resource misconfiguration during validation; catching a narrower exception (or logging) would be safer.

  3. Test coverage shrank. Besides the file tests, apply as relation (RelationRepeater), apply as filter, and import/export feature tests were removed without replacements — the unit suite grew nicely, but these end-to-end paths are now untested.

Verdict: request changes — items 1, 2 and 4 are user-visible regressions for existing 4.x projects; the rest can be follow-ups.


Review by Claude Fable 5

@lee-to

lee-to commented Jul 20, 2026

Copy link
Copy Markdown
Member

Follow-up review after the fixes on 6d39838b9.

A lot of the previous feedback has been addressed: the public contracts and compatibility methods are back, filter mode and reorderable behavior are restored, the key/value apply heuristic is limited to preview, the schema is memoized, swallowed exceptions were removed, and the deleted feature coverage was restored. The focused unit and feature suites pass, PHPStan reports no errors, and the PR checks are green.

There are still a few issues that should be addressed before merge:

  1. File uploads still do not work through the rendered Json UI. renderFieldControl() assigns every cloned file control a static virtual name such as __moonshine_json[...][file]. Since the control HTML is cloned by x-for, every row submits that same name, while resolveAppliesCallback() looks for files under data.{rowIndex}.file. The hidden data input only contains serialized JSON and cannot carry a FileList. The new feature test posts an already-correct nested PHP array, so it verifies server-side apply behavior but not the actual browser form path. Please add a rendering/browser-level assertion for the generated upload names and route each file input to its real row path.

  2. Object-mode reactivity is still disconnected from the form reactive store. The nested inputs now have data-reactive-column="data.slug", but renderFieldControl() overrides their x-model with the local Json expression row["slug"]. FormBuilder.js watches reactive, not the Json component local rows, so editing these controls only updates the hidden payload and does not trigger the reactive request. The current test only checks for data-reactive-column; it should also verify that changing an object child updates reactive.data.child and triggers the watcher.

  3. Nested Json fields inside RelationRepeater are still not discovered for pre-validation decoding. prepareJsonFieldsForValidation() iterates getFormFields()->onlyFields() and only processes entries that are themselves Json. onlyFields() includes a RelationRepeater as a field and does not descend into its child fields, so a payload such as comments[0][meta] remains an encoded string. The added test invokes decodeJsonFieldPayload() directly and therefore bypasses this discovery problem. A test should invoke the full preparation method with a resource containing RelationRepeater -> Json.

  4. modifyTable() remains a 4.x behavior regression in edit mode. getComponent() was restored and applies the callback, but the new Json Blade view renders from viewData() and never uses that component. Consequently, rendering an editable Json field does not invoke modifyTable(), unlike the previous implementation. This either needs to affect the new edit UI or be handled as an explicit breaking change.

My follow-up verdict remains request changes, primarily because the upload and reactive paths are user-visible regressions that the current tests do not exercise.

@lee-to lee-to left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

очень много странных правок которые не влияют на скоуп Json поля

relation: $relation,
resourceUri: $resource ? $resource->getUriKey() : $this->router->extractResourceUri(),
pageUri: $page ? $page->getUriKey() : $this->router->extractPageUri()
resourceUri: $resource instanceof ResourceContract ? $resource->getUriKey() : $this->router->extractResourceUri(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

а какую проблему тут решаем? он либо нулл либо ресурс

resourceUri: $resource ? $resource->getUriKey() : $this->router->extractResourceUri(),
pageUri: $page ? $page->getUriKey() : $this->router->extractPageUri()
resourceUri: $resource instanceof ResourceContract ? $resource->getUriKey() : $this->router->extractResourceUri(),
pageUri: $page instanceof PageContract ? $page->getUriKey() : $this->router->extractPageUri()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

тут тоже

$components = [];

if ($metrics = $this->getMetricsComponent()) {
if (($metrics = $this->getMetricsComponent()) instanceof ComponentContract) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

а что еще этот метод может отдать?

&& $resource->can(Ability::CREATE);

$actionButton = $button
$actionButton = $button instanceof ActionButtonContract

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Другого и быть не может

};

$actionButton = $button
$actionButton = $button instanceof ActionButtonContract

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Другого и быть не может

->withoutWrapper()
->setRequestKeyPrefix($parent->getRequestKeyPrefix())
;
->setRequestKeyPrefix($parent->getRequestKeyPrefix());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

не надо трогать код стайл, после мержа все и так нормализуется а на ревью влияет

$resource = $request->getResource();

if (! $resource) {
if (!$resource instanceof CrudResourceContract) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

это опять погоня за инстансоф?

$this->prepareJsonFieldsForValidation();
}

protected function prepareJsonFieldsForValidation(): void

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

объясни изменения в этом классе

Comment thread phpstan.neon.dist
- '#PHPDoc tag @method for method .+make\(\)#'
excludePaths:
- ./src/Support/src/Traits/Makeable.php
- ./src/UI/src/Traits/Fields/HasVerticalMode.php

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

зачем деградация такая?

}

$casted = $cast ? $cast->cast($value) : new MixedDataWrapper($value);
$casted = $cast instanceof DataCasterContract ? $cast->cast($value) : new MixedDataWrapper($value);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

я все еще не понимаю как это все влияет на Json поле

@lee-to
lee-to changed the base branch from 4.x to 5.x August 8, 2026 07:50
@MrVACO
MrVACO marked this pull request as draft August 25, 2026 05:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants