Skip to content

Support validate custom fields and save custom field values #4

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Jun 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
"require": {
"php": ">=8.0",
"doctrine/dbal": "^3.6",
"illuminate/support": "^8.0|^9.0"
"illuminate/support": "^8.0|^9.0",
"tightenco/parental": "^1.0"
},
"require-dev": {
"mockery/mockery": "^1.5",
Expand All @@ -41,7 +42,9 @@
},
"autoload": {
"psr-4": {
"OnrampLab\\CustomFields\\": "src/"
"OnrampLab\\CustomFields\\": "src/",
"OnrampLab\\CustomFields\\Database\\Factories\\": "database/factories"

}
},
"autoload-dev": {
Expand Down
7 changes: 4 additions & 3 deletions database/factories/CustomFieldFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@ public function definition()
{
return [
'friendly_name' => $this->faker->word(),
'field_key' => $this->faker->slug(),
'field_type' => $this->faker->randomElement(['text', 'integer']),
'key' => $this->faker->slug(),
'type' => $this->faker->randomElement(['text', 'integer']),
'available_options' => [],
'required' => $this->faker->boolean(),
'default_value' => $this->faker->optional()->word,
'description' => $this->faker->optional()->paragraph,
'model' => $this->faker->word(),
'model_class' => $this->faker->word(),
'contextable_id' => $this->faker->randomNumber(),
'contextable_type' => $this->faker->word(),
];
Expand Down
4 changes: 2 additions & 2 deletions database/factories/CustomFieldValueFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ public function definition()
return [
'custom_field_id' => CustomField::factory(),
'value' => $this->faker->word(),
'model_id' => $this->faker->randomNumber(),
'model_type' => $this->faker->word(),
'customizable_id' => $this->faker->randomNumber(),
'customizable_type' => $this->faker->word(),
];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,14 @@ public function up()
$table->string('friendly_name');
$table->string('key');
$table->enum('type', ['text', 'integer', 'float', 'datetime', 'select', 'boolean']);
$table->json('available_options');
$table->json('available_options')->nullable();
$table->boolean('required')->default(0);
$table->string('default_value')->nullable();
$table->text('description')->nullable();
$table->string('model_class');
$table->unsignedBigInteger('contextable_id')->nullable();
$table->string('contextable_type')->nullable();
$table->unique(['key', 'model_class', 'contextable_type', 'contextable_id']);
$table->unique(['key', 'model_class', 'contextable_type', 'contextable_id'], 'custom_fields_key_model_class_contextable_unique');
$table->timestamps();
});
}
Expand Down
55 changes: 55 additions & 0 deletions src/AttributeCastors/AvailableOptionsCastor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

namespace OnrampLab\CustomFields\AttributeCastors;

use InvalidArgumentException;
use OnrampLab\CustomFields\Models\CustomField;
use Illuminate\Support\Collection;
use OnrampLab\CustomFields\ValueObjects\AvailableOption;

class AvailableOptionsCastor
{
/**
* Cast the given value.
*
* @param CustomField $model
* @param string $key
* @param mixed $value
* @param array $attributes
* @return Collection<AvailableOption>
*/
public function get($model, $key, $value, $attributes)
{
$data = json_decode(data_get($attributes, 'available_options') ?? '[]', true);

return Collection::make($data)
->map(function (array $option) {
return new AvailableOption($option);
});
}

/**
* Prepare the given value for storage.
*
* @param CustomField $model
* @param string $key
* @param Collection|array $value
* @param array $attributes
* @return array
*/
public function set($model, $key, $value, $attributes)
{
$value = Collection::make($value);
$isValid = $value->every(function ($option) {
return $option instanceof AvailableOption;
});

if (!$isValid) {
throw new InvalidArgumentException('This given value is not an AvailableOption instance.');
}

return [
'available_options' => json_encode($value->toArray()),
];
}
}
21 changes: 21 additions & 0 deletions src/Concerns/Contextable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace OnrampLab\CustomFields\Concerns;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use OnrampLab\CustomFields\Models\CustomField;

/**
* @mixin Model
*/
trait Contextable
{
/**
* Retrieve model's custom fields
*/
public function customFields(): MorphMany
{
return $this->morphMany(CustomField::class, 'contextable');
}
}
103 changes: 103 additions & 0 deletions src/Concerns/Customizable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php

namespace OnrampLab\CustomFields\Concerns;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use OnrampLab\CustomFields\Models\CustomField;
use OnrampLab\CustomFields\Models\CustomFieldValue;
use OnrampLab\CustomFields\Observers\ModelObserver;

/**
* @mixin Model
*/
trait Customizable
{
protected ?array $validatedCustomFieldValues = [];

public static function bootCustomizable(): void
{
static::observe(ModelObserver::class);
}

/**
* Retrieve model's custom field values
*/
public function customFieldValues(): MorphMany
{
return $this->morphMany(CustomFieldValue::class, 'customizable');
}

/**
* Validate custom field values
*/
public function validateCustomFields(): void
{
$customFields = $this->getCustomFields();
if ($customFields->isEmpty()) {
return;
}
$tableColumns = $this->getTableColumns();
$modelAttributes = Collection::make($this->getAttributes());
$modelAttributeKeys = $modelAttributes->keys();
$customFieldColumns = $modelAttributeKeys->diff($tableColumns);
$customFieldsRules = $customFields->flatMap(function (CustomField $field) {
return $field->getValidationRule();
})->all();

$customFieldValues = $modelAttributes->only($customFieldColumns)->toArray();
$validator = Validator::make($customFieldValues, $customFieldsRules);
$this->validatedCustomFieldValues = $validator->validate();
$this->setRawAttributes($modelAttributes->only($tableColumns)->toArray());
}

protected function getTableColumns(): Collection
{
return Collection::make(Schema::getColumnListing($this->getTable()));
}

public function getCustomFields(): Collection
{
$context = $this->getContext();
$query = CustomField::query();
if (is_null($context)) {
$customFields = $query->get();
} else {
$customFields = $query
->where('contextable_type', get_class($context))
->where('contextable_id', $context->id)
->get();
}

return $customFields;
Comment on lines +65 to +75
Copy link

@ericHao22 ericHao22 Jun 1, 2023

Choose a reason for hiding this comment

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

❗ change request

這邊應該會有一個共同條件是modal class才對?不管context有沒有存在都需要的條件,組成query應該會如下所示:

$query = CustomField::query();
$query->where('model_class', get_class($this));

if ($context) {
    $query->where('contextable_type', get_class($context))
    $query->where('contextable_id', $context->id);
}

return $query->get();

Choose a reason for hiding this comment

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

💬 comment

在拿多型關聯的class name時會建議用modal身上的getMorphClass去拿會比較好,因為多型關聯用的class name是可以客製的

$query->where('contextable_type', $context->getMorphClass());

https://laravel.com/docs/8.x/eloquent-relationships#custom-polymorphic-types

Copy link
Contributor Author

Choose a reason for hiding this comment

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

fixed, thanks!!

}

public function getContext(): ?Model
{
return null;
}

public function saveCustomFieldValues(): void
{
$validatedCustomFieldValues = $this->validatedCustomFieldValues;
if (empty($validatedCustomFieldValues)) {
return;
}
$customFields = $this->getCustomFields();
$customFields->each(function ($customField) use ($validatedCustomFieldValues) {
if (array_key_exists($customField->key, $validatedCustomFieldValues)) {

Choose a reason for hiding this comment

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

💬 comment

可以考慮用guard pattern?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

可以!

$value = $validatedCustomFieldValues[$customField->key];
$constraints = [
'custom_field_id' => $customField->id,
'customizable_type' => get_class($this),

Choose a reason for hiding this comment

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

💬 comment

多型關聯的class name如同上面提到的

'customizable_id' => $this->id
];
$values = ['value' => $value];
CustomFieldValue::updateOrCreate($constraints, $values);
}
});

Choose a reason for hiding this comment

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

💬 comment

這裡更新完後要把$this->validatedCustomFieldValues清空嗎?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

這邊應該是不用 (?

}
}
17 changes: 17 additions & 0 deletions src/Models/BooleanCustomField.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

namespace OnrampLab\CustomFields\Models;

use Parental\HasParent;

class BooleanCustomField extends CustomField
{
use HasParent;

public function getValidationRule(): array
{
return [
$this->key => ['boolean'],
];
}
}
44 changes: 39 additions & 5 deletions src/Models/CustomField.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,21 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use OnrampLab\CustomFields\AttributeCastors\AvailableOptionsCastor;
use OnrampLab\CustomFields\Database\Factories\CustomFieldFactory;
use Parental\HasChildren;

class CustomField extends Model
{
use HasFactory;
use HasChildren;

public const TYPE_TEXT = 'text';
public const TYPE_INTEGER = 'integer';
public const TYPE_FLOAT = 'float';
public const TYPE_DATETIME = 'datetime';
public const TYPE_SELECT = 'select';

/**
* The attributes that are mass assignable.
Expand All @@ -18,18 +28,42 @@ class CustomField extends Model
*/
protected $fillable = [
'friendly_name',
'field_key',
'field_type',
'key',
'type',
'available_options',
'required',
'default_value',
'description',
'model',
'context_type',
'context_id'
'model_class',
'contextable_type',
'contextable_id'
];

protected $casts = [
'required' => 'boolean',
'available_options' => AvailableOptionsCastor::class,
];

protected array $childTypes = [
CustomField::TYPE_TEXT => TextCustomField::class,
CustomField::TYPE_INTEGER => IntegerCustomField::class,
CustomField::TYPE_FLOAT => FloatCustomField::class,
CustomField::TYPE_DATETIME => DateTimeCustomField::class,
CustomField::TYPE_SELECT => SelectCustomField::class
];

protected static function newFactory(): Factory
{
return CustomFieldFactory::new();
}

public function values(): HasMany
{
return $this->hasMany(CustomFieldValue::class);
}

public function getValidationRule(): array
{
return [];
}
}
10 changes: 8 additions & 2 deletions src/Models/CustomFieldValue.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use OnrampLab\CustomFields\Database\Factories\CustomFieldValueFactory;

class CustomFieldValue extends Model
Expand All @@ -19,12 +20,17 @@ class CustomFieldValue extends Model
protected $fillable = [
'custom_field_id',
'value',
'model_id',
'model_type',
'customizable_id',
'customizable_type',
];

protected static function newFactory(): Factory
{
return CustomFieldValueFactory::new();
}

public function customField(): BelongsTo

Choose a reason for hiding this comment

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

💬 comment

可能也可以叫做field就好?

{
return $this->belongsTo(CustomField::class);
}
}
17 changes: 17 additions & 0 deletions src/Models/DateTimeCustomField.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

namespace OnrampLab\CustomFields\Models;

use Parental\HasParent;

class DateTimeCustomField extends CustomField
{
use HasParent;

public function getValidationRule(): array
{
return [
$this->key => ['date'],

Choose a reason for hiding this comment

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

💬 comment

在想這個會不會太寬鬆,之後可以討論一下

];
}
}
17 changes: 17 additions & 0 deletions src/Models/FloatCustomField.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

namespace OnrampLab\CustomFields\Models;

use Parental\HasParent;

class FloatCustomField extends CustomField
{
use HasParent;

public function getValidationRule(): array
{
return [
$this->key => ['numeric'],
];
}
}
Loading