Skip to content

Commit fa16f6a

Browse files
committed
Preserve admin-hidden nested field values on form save
1 parent 7102bb0 commit fa16f6a

3 files changed

Lines changed: 336 additions & 0 deletions

File tree

packages/builder/src/Services/CustomFieldsManager.php

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ public function __construct(
3434
protected StorableFieldCollector $storableFieldCollector,
3535
protected BuilderLocaleResolver $localeResolver,
3636
protected EntityRegistry $entityRegistry,
37+
protected ClonedFieldGroupResolver $clonedFieldGroupResolver,
3738
) {}
3839

3940
/**
@@ -269,6 +270,8 @@ public function saveFromFormData(string $resourceClass, Model $record, array $da
269270
return;
270271
}
271272

273+
$values = $this->preserveAdminHiddenNestedValues($entity, $record, $fields, $values, $locale);
274+
272275
$this->saveValues(
273276
$this->locationContextForResource($resourceClass)->entity,
274277
$record,
@@ -278,6 +281,55 @@ public function saveFromFormData(string $resourceClass, Model $record, array $da
278281
);
279282
}
280283

284+
/**
285+
* Nested fields with visible_admin:false are not rendered in the form, but
286+
* their keys can still appear in a crafted compound payload. Replace those
287+
* keys with the already-stored values so the form path cannot overwrite them.
288+
*
289+
* @param Collection<int, FieldDefinition> $fields
290+
* @param array<string, mixed> $values
291+
* @return array<string, mixed>
292+
*/
293+
protected function preserveAdminHiddenNestedValues(
294+
string $entity,
295+
Model $record,
296+
Collection $fields,
297+
array $values,
298+
string $locale,
299+
): array {
300+
$existing = $record->exists
301+
? $this->loadCachedValues($entity, $record, $fields, $locale)
302+
: [];
303+
304+
foreach ($fields as $field) {
305+
if (! array_key_exists($field->name, $values)) {
306+
continue;
307+
}
308+
309+
if (! $this->fieldTypeRegistry->get($field->type)->hasSubFields()) {
310+
continue;
311+
}
312+
313+
$children = $field->type === 'clone'
314+
? $this->clonedFieldGroupResolver->compoundChildren($field)
315+
: ($field->type === 'flexible_content' ? $field->layouts() : $field->children);
316+
317+
if ($children->isEmpty()) {
318+
continue;
319+
}
320+
321+
$values[$field->name] = FieldVisibility::mergePreservingHidden(
322+
$field,
323+
$values[$field->name],
324+
$existing[$field->name] ?? null,
325+
FieldVisibility::ADMIN,
326+
$children,
327+
);
328+
}
329+
330+
return $values;
331+
}
332+
281333
/**
282334
* @param array<string, mixed> $values
283335
* @param Collection<int, FieldDefinition> $fields

packages/builder/src/Support/FieldVisibility.php

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,139 @@ public static function filterFields(Collection $fields, string $context): Collec
5656
: $field->withChildren(self::filterFields($field->children, $context)))
5757
->values();
5858
}
59+
60+
/**
61+
* Merge form-submitted compound values with already stored ones.
62+
*
63+
* Nested fields hidden in $context are taken from $existing (never from
64+
* the request). Visible nested fields use $submitted. This prevents
65+
* crafted payloads from writing admin-hidden nested keys while keeping
66+
* those values intact across form saves.
67+
*
68+
* @param Collection<int, FieldDefinition>|null $children Override for clone fields
69+
*/
70+
public static function mergePreservingHidden(
71+
FieldDefinition $field,
72+
mixed $submitted,
73+
mixed $existing,
74+
string $context,
75+
?Collection $children = null,
76+
): mixed {
77+
$children ??= $field->children;
78+
79+
if ($children->isEmpty() || ! is_array($submitted)) {
80+
return $submitted;
81+
}
82+
83+
$existing = is_array($existing) ? $existing : [];
84+
85+
return match ($field->type) {
86+
'repeater' => self::mergePreservingHiddenRepeaterRows($children, $submitted, $existing, $context),
87+
'flexible_content' => self::mergePreservingHiddenFlexibleItems($children, $submitted, $existing, $context),
88+
default => self::mergePreservingHiddenCompoundRow($children, $submitted, $existing, $context),
89+
};
90+
}
91+
92+
/**
93+
* @param Collection<int, FieldDefinition> $children
94+
* @param array<string, mixed> $submitted
95+
* @param array<string, mixed> $existing
96+
* @return array<string, mixed>
97+
*/
98+
protected static function mergePreservingHiddenCompoundRow(
99+
Collection $children,
100+
array $submitted,
101+
array $existing,
102+
string $context,
103+
): array {
104+
$merged = [];
105+
106+
foreach ($children as $child) {
107+
if (! $child->isVisibleIn($context)) {
108+
if (array_key_exists($child->name, $existing)) {
109+
$merged[$child->name] = $existing[$child->name];
110+
}
111+
112+
continue;
113+
}
114+
115+
if (! array_key_exists($child->name, $submitted)) {
116+
$merged[$child->name] = null;
117+
118+
continue;
119+
}
120+
121+
$merged[$child->name] = self::mergePreservingHidden(
122+
$child,
123+
$submitted[$child->name],
124+
$existing[$child->name] ?? null,
125+
$context,
126+
);
127+
}
128+
129+
return $merged;
130+
}
131+
132+
/**
133+
* @param Collection<int, FieldDefinition> $children
134+
* @param array<int, mixed> $submitted
135+
* @param array<int, mixed> $existing
136+
* @return list<array<string, mixed>>
137+
*/
138+
protected static function mergePreservingHiddenRepeaterRows(
139+
Collection $children,
140+
array $submitted,
141+
array $existing,
142+
string $context,
143+
): array {
144+
$merged = [];
145+
146+
foreach (array_values($submitted) as $index => $row) {
147+
if (! is_array($row)) {
148+
continue;
149+
}
150+
151+
$existingRow = is_array($existing[$index] ?? null) ? $existing[$index] : [];
152+
$merged[] = self::mergePreservingHiddenCompoundRow($children, $row, $existingRow, $context);
153+
}
154+
155+
return $merged;
156+
}
157+
158+
/**
159+
* @param Collection<int, FieldDefinition> $layouts
160+
* @param array<int, mixed> $submitted
161+
* @param array<int, mixed> $existing
162+
* @return list<array{type: string, data: array<string, mixed>}>
163+
*/
164+
protected static function mergePreservingHiddenFlexibleItems(
165+
Collection $layouts,
166+
array $submitted,
167+
array $existing,
168+
string $context,
169+
): array {
170+
$layoutsByName = $layouts->keyBy('name');
171+
$merged = [];
172+
173+
foreach (array_values($submitted) as $index => $item) {
174+
if (! is_array($item)) {
175+
continue;
176+
}
177+
178+
$type = (string) ($item['type'] ?? '');
179+
$data = is_array($item['data'] ?? null) ? $item['data'] : [];
180+
$existingItem = is_array($existing[$index] ?? null) ? $existing[$index] : [];
181+
$existingData = is_array($existingItem['data'] ?? null) ? $existingItem['data'] : [];
182+
$layout = $layoutsByName->get($type);
183+
184+
$merged[] = [
185+
'type' => $type,
186+
'data' => $layout instanceof FieldDefinition
187+
? self::mergePreservingHiddenCompoundRow($layout->children, $data, $existingData, $context)
188+
: $data,
189+
];
190+
}
191+
192+
return $merged;
193+
}
59194
}

packages/builder/tests/Unit/FieldVisibilityTest.php

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,21 @@
33
declare(strict_types=1);
44

55
require_once __DIR__.'/../TestCase.php';
6+
require_once __DIR__.'/../Support/TestItem.php';
7+
require_once __DIR__.'/../Support/TestItemResource.php';
68

79
use Illuminate\Support\Facades\Cache;
810
use Moox\Builder\Data\FieldDefinition;
911
use Moox\Builder\Data\FieldGroupDefinition;
1012
use Moox\Builder\Models\Field;
1113
use Moox\Builder\Models\FieldGroup;
14+
use Moox\Builder\Models\FieldValue;
1215
use Moox\Builder\Registry\DefinitionRegistry;
1316
use Moox\Builder\Services\CustomFieldsManager;
1417
use Moox\Builder\Services\FieldGroupPersistence;
1518
use Moox\Builder\Support\FieldVisibility;
19+
use Moox\Builder\Tests\Support\TestItem;
20+
use Moox\Builder\Tests\Support\TestItemResource;
1621
use Moox\Builder\Tests\TestCase;
1722

1823
uses(TestCase::class);
@@ -181,3 +186,147 @@
181186
->and($apiNames)->not->toContain('admin-only')
182187
->and($apiNames)->not->toContain('note');
183188
});
189+
190+
it('preserves context-hidden nested keys from existing storage when merging form input', function (): void {
191+
$group = FieldDefinition::fromArray([
192+
'name' => 'address',
193+
'label' => 'Address',
194+
'type' => 'group',
195+
'children' => [
196+
['name' => 'city', 'label' => 'City', 'type' => 'text'],
197+
[
198+
'name' => 'internal_note',
199+
'label' => 'Internal note',
200+
'type' => 'text',
201+
'settings' => ['visible_admin' => false],
202+
],
203+
],
204+
]);
205+
206+
$merged = FieldVisibility::mergePreservingHidden(
207+
$group,
208+
[
209+
'city' => 'Berlin',
210+
'internal_note' => 'crafted-secret',
211+
],
212+
[
213+
'city' => 'Munich',
214+
'internal_note' => 'stored-secret',
215+
],
216+
FieldVisibility::ADMIN,
217+
);
218+
219+
expect($merged)->toBe([
220+
'city' => 'Berlin',
221+
'internal_note' => 'stored-secret',
222+
]);
223+
});
224+
225+
it('preserves admin-hidden nested values across form saves', function (): void {
226+
FieldGroup::query()->delete();
227+
Cache::forget(DefinitionRegistry::CACHE_KEY);
228+
229+
$group = FieldGroup::query()->create([
230+
'name' => 'Address group',
231+
'slug' => 'address-group',
232+
'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]],
233+
'active' => true,
234+
]);
235+
236+
app(FieldGroupPersistence::class)->sync($group, [
237+
'name' => 'Address group',
238+
'slug' => 'address-group',
239+
'active' => true,
240+
'sort' => 0,
241+
'target_entities' => ['item'],
242+
'fields' => [
243+
[
244+
'name' => 'address',
245+
'label' => 'Address',
246+
'type' => 'group',
247+
'children' => [
248+
[
249+
'name' => 'city',
250+
'label' => 'City',
251+
'type' => 'text',
252+
'required' => false,
253+
],
254+
[
255+
'name' => 'internal_note',
256+
'label' => 'Internal note',
257+
'type' => 'text',
258+
'required' => false,
259+
'settings' => ['visible_admin' => false],
260+
],
261+
],
262+
],
263+
],
264+
]);
265+
266+
Cache::forget(DefinitionRegistry::CACHE_KEY);
267+
268+
$this->createItemsTable();
269+
$record = TestItem::query()->create(['title' => 'Demo']);
270+
$manager = app(CustomFieldsManager::class);
271+
$fields = $manager->fieldsForEntity('item');
272+
273+
$manager->saveValues('item', $record, [
274+
'address' => [
275+
'city' => 'Munich',
276+
'internal_note' => 'stored-secret',
277+
],
278+
], $fields);
279+
280+
$manager->saveFromFormData(
281+
TestItemResource::class,
282+
$record,
283+
[
284+
'address' => [
285+
'city' => 'Berlin',
286+
'internal_note' => 'crafted-secret',
287+
],
288+
],
289+
);
290+
291+
$stored = FieldValue::query()
292+
->forRecord('item', $record->getKey())
293+
->where('field_name', 'address')
294+
->value('value_json');
295+
296+
expect($stored)->toMatchArray([
297+
'city' => 'Berlin',
298+
'internal_note' => 'stored-secret',
299+
]);
300+
});
301+
302+
it('preserves admin-hidden nested values when the form omits the key', function (): void {
303+
$group = FieldDefinition::fromArray([
304+
'name' => 'address',
305+
'label' => 'Address',
306+
'type' => 'group',
307+
'children' => [
308+
['name' => 'city', 'label' => 'City', 'type' => 'text'],
309+
[
310+
'name' => 'internal_note',
311+
'label' => 'Internal note',
312+
'type' => 'text',
313+
'settings' => ['visible_admin' => false],
314+
],
315+
],
316+
]);
317+
318+
$merged = FieldVisibility::mergePreservingHidden(
319+
$group,
320+
['city' => 'Berlin'],
321+
[
322+
'city' => 'Munich',
323+
'internal_note' => 'stored-secret',
324+
],
325+
FieldVisibility::ADMIN,
326+
);
327+
328+
expect($merged)->toBe([
329+
'city' => 'Berlin',
330+
'internal_note' => 'stored-secret',
331+
]);
332+
});

0 commit comments

Comments
 (0)