Add Polymorphic_Field_Helper for JSON-encoded polymorphic form fields
Fix incorrect data-sid selector in route-debug help example Fix Form_Utils to use component.$sid() instead of data-sid selector Add response helper functions and use _message as reserved metadata key 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
187
app/RSpade/Core/Polymorphic_Field_Helper.php
Executable file
187
app/RSpade/Core/Polymorphic_Field_Helper.php
Executable file
@@ -0,0 +1,187 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\RSpade\Core;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polymorphic Field Helper
|
||||||
|
*
|
||||||
|
* Handles JSON-encoded polymorphic field values from form components.
|
||||||
|
* Provides parsing, validation, and security checks for polymorphic relationships.
|
||||||
|
*
|
||||||
|
* Polymorphic fields are submitted as JSON: {"model":"Contact_Model","id":123}
|
||||||
|
* This class parses that JSON and validates the model type against a whitelist.
|
||||||
|
*
|
||||||
|
* Usage in controllers:
|
||||||
|
*
|
||||||
|
* use App\RSpade\Core\Polymorphic_Field_Helper;
|
||||||
|
*
|
||||||
|
* // Parse and validate a polymorphic field
|
||||||
|
* $eventable = Polymorphic_Field_Helper::parse($params['eventable'], [
|
||||||
|
* Contact_Model::class,
|
||||||
|
* Project_Model::class,
|
||||||
|
* ]);
|
||||||
|
*
|
||||||
|
* // Quick validation with error message
|
||||||
|
* $error = $eventable->validate('Please select an entity');
|
||||||
|
* if ($error) {
|
||||||
|
* $errors['eventable'] = $error;
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // Or check states manually
|
||||||
|
* if ($eventable->is_empty()) {
|
||||||
|
* $errors['eventable'] = 'Please select a contact or project';
|
||||||
|
* } elseif (!$eventable->is_valid()) {
|
||||||
|
* $errors['eventable'] = 'Invalid entity type selected';
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // Use the values
|
||||||
|
* $model->eventable_type = $eventable->model;
|
||||||
|
* $model->eventable_id = $eventable->id;
|
||||||
|
*/
|
||||||
|
#[Instantiatable]
|
||||||
|
class Polymorphic_Field_Helper
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The model class name (e.g., "Contact_Model")
|
||||||
|
*/
|
||||||
|
public ?string $model = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The entity ID
|
||||||
|
*/
|
||||||
|
public ?int $id = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether any value was provided in the input
|
||||||
|
*/
|
||||||
|
private bool $was_provided = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the provided model type is in the allowed list
|
||||||
|
*/
|
||||||
|
private bool $model_allowed = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of allowed model basenames for error messages
|
||||||
|
*/
|
||||||
|
private array $allowed_models = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a JSON-encoded polymorphic field value
|
||||||
|
*
|
||||||
|
* @param string|null $json_value The JSON string from form submission (e.g., '{"model":"Contact_Model","id":123}')
|
||||||
|
* @param array $allowed_model_classes Array of allowed model class names (use Model::class syntax)
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public static function parse(?string $json_value, array $allowed_model_classes): self
|
||||||
|
{
|
||||||
|
$instance = new self();
|
||||||
|
$instance->allowed_models = array_map(fn($class) => class_basename($class), $allowed_model_classes);
|
||||||
|
|
||||||
|
if (empty($json_value)) {
|
||||||
|
return $instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($json_value, true);
|
||||||
|
if (!is_array($decoded)) {
|
||||||
|
return $instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
$instance->was_provided = true;
|
||||||
|
|
||||||
|
if (!empty($decoded['model']) && !empty($decoded['id'])) {
|
||||||
|
$instance->model = $decoded['model'];
|
||||||
|
$instance->id = (int) $decoded['id'];
|
||||||
|
$instance->model_allowed = in_array($instance->model, $instance->allowed_models);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if no value was provided (empty string or null)
|
||||||
|
*/
|
||||||
|
public function is_empty(): bool
|
||||||
|
{
|
||||||
|
return !$this->was_provided || $this->model === null || $this->id === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the value is valid (provided and model type is allowed)
|
||||||
|
*/
|
||||||
|
public function is_valid(): bool
|
||||||
|
{
|
||||||
|
return !$this->is_empty() && $this->model_allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a value was provided but is invalid (wrong model type or malformed)
|
||||||
|
*/
|
||||||
|
public function is_invalid(): bool
|
||||||
|
{
|
||||||
|
return $this->was_provided && !$this->is_valid();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the model class name (e.g., "Contact_Model")
|
||||||
|
*/
|
||||||
|
public function get_model(): ?string
|
||||||
|
{
|
||||||
|
return $this->model;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the entity ID
|
||||||
|
*/
|
||||||
|
public function get_id(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get allowed model names for error messages
|
||||||
|
*/
|
||||||
|
public function get_allowed_models(): array
|
||||||
|
{
|
||||||
|
return $this->allowed_models;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and return error message if invalid, null if valid
|
||||||
|
*
|
||||||
|
* Use this for required polymorphic fields.
|
||||||
|
*
|
||||||
|
* @param string $empty_message Message when field is empty/missing
|
||||||
|
* @param string $invalid_message Message when model type is not allowed
|
||||||
|
* @return string|null Error message or null if valid
|
||||||
|
*/
|
||||||
|
public function validate(string $empty_message, string $invalid_message = 'Invalid entity type selected'): ?string
|
||||||
|
{
|
||||||
|
if ($this->is_empty()) {
|
||||||
|
return $empty_message;
|
||||||
|
}
|
||||||
|
if (!$this->model_allowed) {
|
||||||
|
return $invalid_message;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate for optional field (only invalid if provided but wrong type)
|
||||||
|
*
|
||||||
|
* Use this for optional polymorphic fields where empty is acceptable.
|
||||||
|
*
|
||||||
|
* @param string $invalid_message Message when model type is not allowed
|
||||||
|
* @return string|null Error message or null if valid/empty
|
||||||
|
*/
|
||||||
|
public function validate_optional(string $invalid_message = 'Invalid entity type selected'): ?string
|
||||||
|
{
|
||||||
|
if ($this->is_empty()) {
|
||||||
|
return null; // Empty is OK for optional fields
|
||||||
|
}
|
||||||
|
if (!$this->model_allowed) {
|
||||||
|
return $invalid_message;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
256
app/RSpade/man/polymorphic.txt
Executable file
256
app/RSpade/man/polymorphic.txt
Executable file
@@ -0,0 +1,256 @@
|
|||||||
|
POLYMORPHIC(7) RSX Framework Manual POLYMORPHIC(7)
|
||||||
|
|
||||||
|
NAME
|
||||||
|
polymorphic - JSON-encoded polymorphic field handling
|
||||||
|
|
||||||
|
SYNOPSIS
|
||||||
|
Server-side:
|
||||||
|
use App\RSpade\Core\Polymorphic_Field_Helper;
|
||||||
|
|
||||||
|
$field = Polymorphic_Field_Helper::parse($params['fieldname'], [
|
||||||
|
Contact_Model::class,
|
||||||
|
Project_Model::class,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($error = $field->validate('Please select an entity')) {
|
||||||
|
$errors['fieldname'] = $error;
|
||||||
|
}
|
||||||
|
|
||||||
|
$model->poly_type = $field->model;
|
||||||
|
$model->poly_id = $field->id;
|
||||||
|
|
||||||
|
Client-side (form input value format):
|
||||||
|
{"model":"Contact_Model","id":123}
|
||||||
|
|
||||||
|
DESCRIPTION
|
||||||
|
Polymorphic fields allow a single database relationship to reference
|
||||||
|
multiple model types. For example, an Activity can be related to either
|
||||||
|
a Contact or a Project. This document describes the standard pattern for
|
||||||
|
handling polymorphic fields in RSX applications.
|
||||||
|
|
||||||
|
The Problem
|
||||||
|
|
||||||
|
Traditional form handling passes polymorphic data as separate fields:
|
||||||
|
|
||||||
|
eventable_type=Contact_Model
|
||||||
|
eventable_id=123
|
||||||
|
|
||||||
|
This approach has issues:
|
||||||
|
- Requires custom form submission handlers to inject hidden fields
|
||||||
|
- Field naming is inconsistent (type vs _type, id vs _id)
|
||||||
|
- No standard validation pattern
|
||||||
|
- Security validation (allowed model types) is ad-hoc
|
||||||
|
|
||||||
|
The Solution
|
||||||
|
|
||||||
|
Polymorphic fields are submitted as a single JSON-encoded value:
|
||||||
|
|
||||||
|
eventable={"model":"Contact_Model","id":123}
|
||||||
|
|
||||||
|
Benefits:
|
||||||
|
- Single field to validate
|
||||||
|
- Standard val() getter/setter pattern on client
|
||||||
|
- Polymorphic_Field_Helper handles parsing and security validation
|
||||||
|
- Clean, reusable code on both client and server
|
||||||
|
|
||||||
|
SERVER-SIDE USAGE
|
||||||
|
Polymorphic_Field_Helper Class
|
||||||
|
|
||||||
|
Location: App\RSpade\Core\Polymorphic_Field_Helper
|
||||||
|
|
||||||
|
Parse a field value:
|
||||||
|
|
||||||
|
use App\RSpade\Core\Polymorphic_Field_Helper;
|
||||||
|
|
||||||
|
$eventable = Polymorphic_Field_Helper::parse($params['eventable'], [
|
||||||
|
Contact_Model::class,
|
||||||
|
Project_Model::class,
|
||||||
|
]);
|
||||||
|
|
||||||
|
The second argument is the whitelist of allowed model classes. Always
|
||||||
|
use Model::class syntax - never hardcode model name strings.
|
||||||
|
|
||||||
|
Validation Methods
|
||||||
|
|
||||||
|
Required field validation:
|
||||||
|
|
||||||
|
if ($error = $eventable->validate('Please select an entity')) {
|
||||||
|
$errors['eventable'] = $error;
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional field validation:
|
||||||
|
|
||||||
|
if ($error = $parent->validate_optional('Invalid parent type')) {
|
||||||
|
$errors['parent'] = $error;
|
||||||
|
}
|
||||||
|
|
||||||
|
Accessing Values
|
||||||
|
|
||||||
|
After validation:
|
||||||
|
|
||||||
|
$model->eventable_type = $eventable->model; // "Contact_Model"
|
||||||
|
$model->eventable_id = $eventable->id; // 123
|
||||||
|
|
||||||
|
For optional fields, id will be null if not provided:
|
||||||
|
|
||||||
|
$model->parent_id = $parent->id; // null or integer
|
||||||
|
|
||||||
|
State Checking Methods
|
||||||
|
|
||||||
|
$field->is_empty() // No value provided
|
||||||
|
$field->is_valid() // Value provided and model type allowed
|
||||||
|
$field->is_invalid() // Value provided but model type not allowed
|
||||||
|
|
||||||
|
CLIENT-SIDE IMPLEMENTATION
|
||||||
|
Building a Polymorphic Picker Component
|
||||||
|
|
||||||
|
To create a picker for polymorphic fields, build a component that:
|
||||||
|
|
||||||
|
1. Maintains a hidden input with the JSON-encoded value
|
||||||
|
2. Implements val() to get/set as {model: 'Model_Name', id: number}
|
||||||
|
3. Syncs the hidden input on every change
|
||||||
|
4. Handles pre-load value setting (value set before options loaded)
|
||||||
|
|
||||||
|
Hidden Input Pattern
|
||||||
|
|
||||||
|
The component template should include a hidden input:
|
||||||
|
|
||||||
|
<input type="hidden" $sid="hidden_value" name="<%= this.args.name %>" />
|
||||||
|
|
||||||
|
On value change, sync to hidden input as JSON:
|
||||||
|
|
||||||
|
_sync_hidden_value() {
|
||||||
|
const $hidden = this.$sid('hidden_value');
|
||||||
|
if (this._value && this._value.model && this._value.id) {
|
||||||
|
$hidden.val(JSON.stringify(this._value));
|
||||||
|
} else {
|
||||||
|
$hidden.val('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val() Implementation
|
||||||
|
|
||||||
|
The val() method should work before and after component loads:
|
||||||
|
|
||||||
|
val(value) {
|
||||||
|
if (arguments.length === 0) {
|
||||||
|
// Getter
|
||||||
|
return this._value || this._pending_value || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setter
|
||||||
|
const parsed = value ? {model: value.model, id: parseInt(value.id)} : null;
|
||||||
|
|
||||||
|
if (this.data.loading) {
|
||||||
|
this._pending_value = parsed;
|
||||||
|
this._value = parsed;
|
||||||
|
this._sync_hidden_value();
|
||||||
|
} else {
|
||||||
|
this._value = parsed;
|
||||||
|
this._apply_to_ui();
|
||||||
|
this._sync_hidden_value();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Using Tom Select
|
||||||
|
|
||||||
|
For searchable dropdowns, use Tom Select with a compound value format:
|
||||||
|
|
||||||
|
// Compound value: "Model_Name:id"
|
||||||
|
valueField: 'compound_value',
|
||||||
|
|
||||||
|
onChange: function(compound_value) {
|
||||||
|
if (!compound_value) {
|
||||||
|
that._value = null;
|
||||||
|
} else {
|
||||||
|
const parts = compound_value.split(':');
|
||||||
|
that._value = {model: parts[0], id: parseInt(parts[1])};
|
||||||
|
}
|
||||||
|
that._sync_hidden_value();
|
||||||
|
}
|
||||||
|
|
||||||
|
Add options with the compound format:
|
||||||
|
|
||||||
|
all_options.push({
|
||||||
|
compound_value: 'Contact_Model:' + contact.id,
|
||||||
|
model: 'Contact_Model',
|
||||||
|
id: contact.id,
|
||||||
|
name: contact.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
COMPLETE EXAMPLE
|
||||||
|
Controller
|
||||||
|
|
||||||
|
use App\RSpade\Core\Polymorphic_Field_Helper;
|
||||||
|
|
||||||
|
#[Ajax_Endpoint]
|
||||||
|
public static function save(Request $request, array $params = [])
|
||||||
|
{
|
||||||
|
$eventable = Polymorphic_Field_Helper::parse($params['eventable'] ?? null, [
|
||||||
|
Contact_Model::class,
|
||||||
|
Project_Model::class,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
if ($error = $eventable->validate('Please select an entity')) {
|
||||||
|
$errors['eventable'] = $error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($errors)) {
|
||||||
|
return response_form_error('Please correct errors', $errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
$activity = new Activity_Model();
|
||||||
|
$activity->eventable_type = $eventable->model;
|
||||||
|
$activity->eventable_id = $eventable->id;
|
||||||
|
$activity->save();
|
||||||
|
|
||||||
|
return ['success' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
DATABASE SCHEMA
|
||||||
|
Polymorphic relationships use two columns:
|
||||||
|
|
||||||
|
$table->string('eventable_type');
|
||||||
|
$table->unsignedBigInteger('eventable_id');
|
||||||
|
|
||||||
|
The type column stores the model class basename (e.g., "Contact_Model").
|
||||||
|
|
||||||
|
SECURITY CONSIDERATIONS
|
||||||
|
Model Type Validation
|
||||||
|
|
||||||
|
CRITICAL: Always validate that submitted model types are in the allowed
|
||||||
|
list. Attackers could submit arbitrary model names to exploit the
|
||||||
|
polymorphic relationship.
|
||||||
|
|
||||||
|
The Polymorphic_Field_Helper enforces this automatically:
|
||||||
|
|
||||||
|
// Only Contact_Model and Project_Model are accepted
|
||||||
|
$eventable = Polymorphic_Field_Helper::parse($params['eventable'], [
|
||||||
|
Contact_Model::class,
|
||||||
|
Project_Model::class,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Submitting {"model":"User_Model","id":1} will fail validation
|
||||||
|
$eventable->is_valid(); // false
|
||||||
|
|
||||||
|
Use Model::class, Not Strings
|
||||||
|
|
||||||
|
Always use the ::class constant to specify allowed models:
|
||||||
|
|
||||||
|
// CORRECT - uses class constant
|
||||||
|
Polymorphic_Field_Helper::parse($value, [Contact_Model::class]);
|
||||||
|
|
||||||
|
// WRONG - hardcoded string
|
||||||
|
Polymorphic_Field_Helper::parse($value, ['Contact_Model']);
|
||||||
|
|
||||||
|
Using ::class ensures:
|
||||||
|
- IDE autocompletion and refactoring support
|
||||||
|
- Compile-time error if class doesn't exist
|
||||||
|
- Consistent naming with actual class names
|
||||||
|
|
||||||
|
SEE ALSO
|
||||||
|
form_conventions(7), ajax_error_handling(7)
|
||||||
|
|
||||||
|
RSX Framework 2025-12-23 POLYMORPHIC(7)
|
||||||
@@ -924,6 +924,30 @@ public static function save(Request $request, array $params = []) {
|
|||||||
|
|
||||||
Details: `php artisan rsx:man form_conventions`
|
Details: `php artisan rsx:man form_conventions`
|
||||||
|
|
||||||
|
### Polymorphic Form Fields
|
||||||
|
|
||||||
|
For fields that can reference multiple model types (e.g., an Activity linked to either a Contact or Project), use JSON-encoded polymorphic values.
|
||||||
|
|
||||||
|
```php
|
||||||
|
use App\RSpade\Core\Polymorphic_Field_Helper;
|
||||||
|
|
||||||
|
$eventable = Polymorphic_Field_Helper::parse($params['eventable'], [
|
||||||
|
Contact_Model::class,
|
||||||
|
Project_Model::class,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($error = $eventable->validate('Please select an entity')) {
|
||||||
|
$errors['eventable'] = $error;
|
||||||
|
}
|
||||||
|
|
||||||
|
$model->eventable_type = $eventable->model;
|
||||||
|
$model->eventable_id = $eventable->id;
|
||||||
|
```
|
||||||
|
|
||||||
|
Client submits: `{"model":"Contact_Model","id":123}`. Always use `Model::class` for the whitelist.
|
||||||
|
|
||||||
|
Details: `php artisan rsx:man polymorphic`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## MODALS
|
## MODALS
|
||||||
|
|||||||
Reference in New Issue
Block a user