diff --git a/app/RSpade/Core/Polymorphic_Field_Helper.php b/app/RSpade/Core/Polymorphic_Field_Helper.php new file mode 100755 index 000000000..d887779b2 --- /dev/null +++ b/app/RSpade/Core/Polymorphic_Field_Helper.php @@ -0,0 +1,187 @@ +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; + } +} diff --git a/app/RSpade/man/polymorphic.txt b/app/RSpade/man/polymorphic.txt new file mode 100755 index 000000000..3a77c6451 --- /dev/null +++ b/app/RSpade/man/polymorphic.txt @@ -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: + + + + 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) diff --git a/docs/CLAUDE.dist.md b/docs/CLAUDE.dist.md index 69f82f2f3..3b4dbfeee 100644 --- a/docs/CLAUDE.dist.md +++ b/docs/CLAUDE.dist.md @@ -924,6 +924,30 @@ public static function save(Request $request, array $params = []) { 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