Blog

Dynamic content connected to items in Filament

Dec 14, 2022
Andreas Kviby
Admin panel, Form builder

How to connect dynamic content to items in Filament

This article is one way to solve things and I am not saying it is the best way, it works for us.

Use case for this solution

So let's imagine we have a table with products that are connected to customers. In our system the customer can add products with some standard information about every product. But then we have some customers that want a lot of extra content per product. Some of these content requirements are so way out from our standard that we needed to figure a way to add content based on customer and also product category.

Now some might say, just hook up a ProductContentResource, connect it to the product and then let them attach or create content to the products that way. That was a solution but it does not look good and really not on mobile. Also that is not the problem, the problem is that customer 1 wants to add length in ProductCategory Tables and customer 2 want to add dimension in ProductCategory Tools.

So now let's make this happen as easy as possible? Are you with me on this?

The table structure in this article

The below is the migrations for the tables needed to make this fly. They are in some way not 100% true because we have some stuff I won't share :)

Products

Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('description')->nullable();
$table->decimal('price',9,2)->nullable();
$table
->foreignId('user_id')
->index()
->constrained()
->cascadeOnDelete();
 
$table
->foreignId('category_id')
->index()
->constrained()
->cascadeOnDelete();
 
$table->foreignIdFor(\App\Models\Customer::class, 'customer_id');
 
$table->timestamps();

It's a joke but sorry, I am not a pro code, I am just a pro solver :)

FactTypes

What the heck is Fact Types? Well, I wanted to add a smooth way to add new Custom Fields to my Products, like Date, Text and more. I also needed to be able to set the max length, if they are required and placeholders and such. So I needed a FactType in my system.

Schema::create('fact_types', function (Blueprint $table) {
$table->id();
$table->string('name'); // serial number
$table->string('placeholder')->nullable();
$table->string('error_message')->nullable();
$table->string('lang')->nullable(); // sv
$table->integer('sort_order')->default(0); // Sorting order
$table->boolean('required')->default(false); // yes or no
$table->string('field_type'); // serial
$table->integer('length')->nullable();
$table->integer('min')->nullable();
$table->integer('max')->nullable();
 
$table->foreignIdFor(\App\Models\Category::class, 'category_id');
$table->foreignIdFor(\App\Models\Customer::class, 'customer_id')->nullable();
$table->timestamps();

You can see above that it is connected to a Category ID and Customer ID here.

Categories

Well, I guess I don't need to show you, it's just a name, lang and parent_id in this table.

Facts - The magic one?

Well, I believe it is magic, but it is not. But I think it is...

$table->id();
$table->text('content');
$table->foreignIdFor(\App\Models\FactType::class, 'fact_type_id');
$table->foreignIdFor(\App\Models\Product::class, 'product_id');
 
$table->timestamps();

As you can see that table ain't big, it just holds the content some user enters in a Custom Field. It also gets which kind of field it should render and to which product this particular content is connected to.

Create a new product and display Custom Fields?

This was tricky because we have to render the Custom Fields inside a Tab inside the Form on Create Product.

Tabs\Tab::make('Custom Fields')
->schema(static::getFactTypeSchema()
),

In my Tab I get the custom fields schema using the static::getFactTypeSchema and getFactTypeSchema is my function as you can see below. You will get the whole shebang below and under that I will tell you a bit about it and what I believe is magic.

public function getFactTypeSchema(): array {
$customer_id = Auth::user()->customer->id;
$product_category_id = $this->categoryId;
 
$fieldsArray = [];
 
$factTypesOnThisProduct = FactType::where(function ($query) use ($product_category_id) {
$query->where('category_id', $product_category_id)->whereNull('customer_id');
})->orWhere(function ($query) use ($product_category_id, $customer_id) {
$query->where('category_id', $product_category_id)
->where('customer_id', $customer_id);
})->orderBy('sort_order', 'desc')->get();;
if ($factTypesOnThisProduct) {
 
foreach ($factTypesOnThisProduct as $factType) {
$isRequired = $factType->required;
if ($factType->field_type == 'date') {
$fieldsArray = array_merge([
DatePicker::make('fact_type_id_'.$factType->id)
->label($factType->name)
->required(function () use ($isRequired) {
return $isRequired;
})
->placeholder($factType->placeholder)
->format('Y-m-d')
->displayFormat('Y-m-d')
 
], $fieldsArray);
 
} elseif ($factType->field_type == 'text') {
 
$fieldsArray = array_merge([
TextInput::make('fact_type_id_'.$factType->id)
->label($factType->name)
->required(function () use ($isRequired) {
return $isRequired;
})
->placeholder($factType->placeholder)
->maxLength($factType->max)
->minLength($factType->min)
], $fieldsArray);
 
} elseif ($factType->field_type == 'number') {
$fieldsArray = array_merge([
TextInput::make('fact_type_id_'.$factType->id)
->label($factType->name)
->required(function () use ($isRequired) {
return $isRequired;
})
->placeholder($factType->placeholder)
->numeric()
->maxValue($factType->max)
->minValue($factType->min)
], $fieldsArray);
 
}
}
$fieldsArray = array_merge([
Placeholder::make('Extra fält för denna kategori')
 
], $fieldsArray);
}
 
return $fieldsArray;
}

So the thing is that I loop through the Custom Fields available on this Customer and inside the selected Category. Then I check which kind of type the field is, I store this inside the FactTypes in a string.

elseif ($factType->field_type == 'text') {
 
$fieldsArray = array_merge([
TextInput::make('fact_type_id_'.$factType->id)
->label($factType->name)
->required(function () use ($isRequired) {
return $isRequired;
})
->placeholder($factType->placeholder)
->maxLength($factType->max)
->minLength($factType->min)
], $fieldsArray);

The magic is above, I can create perfect TextInputs or any other Filament Form Component using this way, I then array_merge it into the outer array that I will return to the schema on the page. So this way I can create dynamic forms very easy.

The tricky updating part, puh!

When I was happy about the above code running and pressed SAVE my world came down on me, stated that I was a complete idiot. All the custom fields I make gets the id = fact_type_id_? where the ? is the id of the FactType. So when I press SAVE the Filament code will look for all fields inside my Products table and there are no fields called that. They are supposed to be called Custom Fields and not stored on the product.

So I got some help from Dan and the superb Discord channel on how to override the handleRecordUpdate. This function will execute before the actual data is saved into the Products table. So let's be creative as hell now and make this work.

protected function handleRecordUpdate(Model $record, array $data): Model
{
foreach ($data as $key => $value) {
if (str_contains($key, 'fact_type_id')) {
$array = explode('fact_type_id_', $key);
$fact_type_id = array_pop($array);
$updatedFact = Fact::where('product_id', $record->id)->where('fact_type_id', $fact_type_id)->first();
if ($updatedFact) {
if ($value === null) {
$updatedFact->delete();
} else {
$updatedFact->content = $value;
$updatedFact->save();
}
} else {
$newFact = new Fact();
$newFact->product_id = $record->id;
$newFact->fact_type_id = $fact_type_id;
$newFact->content = $value;
$newFact->save();
}
unset($data[$key]);
}
 
}
$record->update($data);
 
return $record;
}

As you can see I will loop through all fields in the CreateProduct Form. I will sort out the custom fields using the pattern I created by setting the id to fact_type_id_? and remember the ? was the id from the FactTypes. Then I just check if content is already stored in the database on this product (I want to reuse this on EditProduct) and if update, if not create a new Fact and store the content.

Then I do a trick, I use unset($data[$key]) and that will remove all my Custom Fields from the data array and then Filament will handle the saving of the Product itself.

Summary

This is one of the coolest solutions we came up with so it feels nice to share it back with the community. I hope you like it and please comment below, I am in such a need of positive feedback :)

avatar

what is the difference between :

->required(function () use ($isRequired) { return $isRequired; }) and

->required($isRequired)

avatar

good thought and I have probably just made an error

avatar

BTW, I forgot to say thank you for this useful article!

You have error_message field in your fact_types table, what's the purpose of this field as you never use it. Thanks for your feedback.

avatar

if the input has errors this fields content is returned

avatar

The content field in Facts is text type, but I got an error when trying to save an integer or numeric value in this field : Illuminate\Database\Grammar::parameterize(): Argument #1 ($values) must be of type array, int given

How did you solve saving field type number in content db field ?

FYI, I use MySQL

Thanks in advance,

avatar

I made it working by creating a Json cast.