ux
ux copied to clipboard
[Live][RFC] Defining `LiveProp` writable/exposed permutations for non-scalar types
Had a lengthy discussion with @weaverryan about how writable/exposed works with objects/arrays. We came to the conclusion that we should formally define (and implement) how this should work.
Here are our conclusions but open for discussion!
For objects/arrays:
LiveProp(writable: true): any manipulation is possibleLiveProp(writable: false, exposed: ['k1', 'k2']): only property pathk1andk2can be modified.
For Doctrine entity's, we'd have a bit of alternate logic because there could be a scenario where you want fields to be editable but not allow the entity to be swapped for another:
LiveProp(writable: true): any property can be manipulated AND can be swapped for another (I believe this is the behaviour now)LiveProp(writable: false, exposed: ['prop1', 'prop2']): Onlyprop1&prop2can be modified - the entity CANNOT be swapped for anotherLiveProp(writable: false, exposed: ['@id', 'prop1', 'prop2']): Onlyprop1&prop2can be modified AND the entity can be swapped for another. This adds a special@idsyntax that let's the hydrator know it can be swapped.
I believe this does make writable: true, exposed: ['k1', 'k2'] not really valid (writable: true means anything about the array/object can be manipulated). So I think we can deprecate exposed and allow writable to be an array:
- For arrays/non-entity objects -
LiveProp(writable: ['k1', 'k2'])would imply only property pathsk1/k2can be modified. - For entity objects -
LiveProp(writable: ['k1', 'k2'])would imply only property pathk1/k2can be modified and the entity CANNOT be swapped for another. - For entity objects -
LiveProp(writable: ['@id', 'k1', 'k2'])would imply only property pathk1/k2` can be modified and the entity CAN be swapped for another.
I've thought a lot about this, and I think yes, we've got it... but there is some internal weirdness. To double-check, let's look at one of the ugliest situations. All of these situations use a basic variant of this same DTO:
class AddressDto
{
public function __construct(
public ?string $street1 = null,
public ?string $street2 = null,
public ?string $city = null
)
{
}
}
A) (De)Hydration to a scalar + NOT writable
#[AsLiveComponent('hydration_test')]
class HydrationTestComponent
{
#[LiveProp]
public string $name;
#[LiveProp(
dehydrateWith: 'dehydrateAddress',
hydrateWith: 'hydrateAddress'
)]
public AddressDto $dto;
public function dehydrateAddress(AddressDto $addressDto)
{
return (string) $addressDto;
}
public function hydrateAddress(string $address)
{
// pretend we can transform the string somehow back into the object
return AddressDto::fromString($address);
}
}
This is actually how entities work: they dehydrate to an "id" and hydrate back to the entity object.
In this situation, the "data" going back and forth would look like this (assuming we use | as a simple separator to create the string):
{
"name": "Ryan",
"address": "555 Main St|Apt 418|Grand Rapids"
}
B) (De)Hydration to a scalar + YES writable
Now take the same situation, but make at least one field writable:
#[LiveProp(
dehydrateWith: 'dehydrateAddress',
hydrateWith: 'hydrateAddress',
writable: ['street1', 'street2']
)]
public AddressDto $dto;
Now the JSON structure would take on this form:
{
"name": "Ryan",
"address": {
"@id": "555 Main St|Apt 418|Grand Rapids",
"street1": "555 Main St",
"street2": "Apt 418"
}
}
So, the "dehydrated" string is moved under @id. This string is what will be passed later to hydrate(). If the user changes street1 and street2, the frontend may send an Ajax request back to the server that looks like this:
{
"name": "Ryan",
"address": {
"@id": "555 Main St|Apt 418|Grand Rapids",
"street1": "404 Not Found St",
"street2": "Suite 100"
}
}
So, the @id is unchanged (and MUST be unchanged because @id is not a writable field). The @id value would be passed to hydrate() to create the object. THEN the new values of street1 and street2 would be written onto this.
If this were an entity AND @id is writable (and had a value of, e.g. 22), then you could see how the frontend could change @id to 44 and/or change street1 or street2.
C) (De)Hydration to an array + NOT writable
Now take that same component, but imagine that the user dehydrates AddressDto to an array:
#[AsLiveComponent('hydration_test')]
class HydrationTestComponent
{
#[LiveProp]
public string $name;
#[LiveProp(
dehydrateWith: 'dehydrateAddress',
hydrateWith: 'hydrateAddress',
)]
public AddressDto $dto;
public function dehydrateAddress(AddressDto $addressDto)
{
// NOTE: should the user be forced to json_encode() to return a scalar
// SEE BELOW
return [
'street1' => $addressDto->street1,
'street2' => $addressDto->street2,
'city' => $addressDto->city,
];
}
public function hydrateAddress(array $address)
{
return new AddressDto(
$address['street1'],
$address['street2'],
$address['city']
);
}
}
This time, the structure of the JSON would look like this:
{
"name": "ryan",
"address": "{\"street1\":\"555 Main St\",\"street2\":\"Apt 418\",\"city\":\"Grand Rapids\"}"
}
Yes, that is JSON inside of a string. But, it's totally consistent with the situation above where we dehydrate to a scalar! Well, except that we further json_encode() the array for them. In this moment, I actually think we shouldn't do that: the user should json_encode() in dehydrateAddress() and, in general, we should force dehydrators to return strings.
D) (De)Hydration to an array + YES writable
Same as above, except that some fields are writable:
#[LiveProp(
dehydrateWith: 'dehydrateAddress',
hydrateWith: 'hydrateAddress',
writable: ['street1', 'street2']
)]
public AddressDto $dto;
The JSON in this case looks like this:
{
"name": "ryan",
"address": {
"@id": "{\"street1\":\"555 Main St\",\"street2\":\"Apt 418\",\"city\":\"Grand Rapids\"}",
"street1": "555 Main St",
"street2": "Apt 418"
}
}
EXTRA: How to get all object properties for "writable: true"?
So far, we've forced people to have expose: ['street1', 'street2']: to explicitly list which properties they should expose. That made it easy for us to loop over the "expose" array and use the property accessor to grab each value when creating the data to send to the frontend.
If we use writable: true with an array, then we can still easily loop over that and grab every key.
If we use writable: true with an object, I think we can use the property_info service - it implements PropertyListExtractorInterface - https://symfony.com/doc/current/components/property_info.html#list-information - though I haven't used this directly before. This would miss any "virtual properties" (something with a getter and setter, but no actual property behind it), but that's fine. If you want to target those, use writable: ['thatPropertyName'] instead of allowing everything.
Btw, a corollary reality to all of this is that any "writable properties" on an object must be mutable. That's because we (A) hydrate the original object using the original @id scalar (e.g. an entity id or even a JSON string) and THEN (B) loop over sent data and set that onto the object. But, this fact is already true in today's implementation :).
Random idea that pop up while reading the first time the description:
instead of the special @id value to express that it can be replaced, what about being explicit 2 parameters editable and replaceable?
then writable: true in an alias for editable: true, replaceable: true.
and about writable being an array, it could "pass" the value to editable, as replaceable can only be a bool.
so we can have writable: ['p1', 'p2'] be an alias for editable: |'p1', 'p2'], replaceable: true
Note: I don't know about Live Component internals, just sharing this quick idea 🙂
instead of the special @id value to express that it can be replaced, what about being explicit 2 parameters editable and replaceable?
I'd need to think more about the exact option names, but a few people have already expressed dislike about the @id idea. So exposing that functionality via a new option is probably a better idea. I had been thinking about writableIdentity=true to have the same meaning as the writable: ['@id'] in my original idea.