Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
96f15d5
feat: add new feature flags for field enhancements
ManukMinasyan Dec 8, 2025
05f61d7
feat: add auto-generate code from name feature
ManukMinasyan Dec 8, 2025
e1bc535
feat: add sections disabled mode with hidden default section
ManukMinasyan Dec 8, 2025
c7659cf
feat: add multi-value and unique per entity type settings
ManukMinasyan Dec 8, 2025
b752271
feat: refactor email field to use tags input for multi-value support
ManukMinasyan Dec 8, 2025
de7aed0
fix: add dark mode support and remove empty state borders
ManukMinasyan Dec 8, 2025
b14d37e
feat: add translations for new field settings and empty states
ManukMinasyan Dec 8, 2025
37e71bb
refactor: extract field creation logic to shared trait
ManukMinasyan Dec 8, 2025
b1032b5
chore: remove unused getTypeSettings method and import
ManukMinasyan Dec 8, 2025
148372d
feat: add searchable capability to email field type
ManukMinasyan Dec 8, 2025
b7d1f79
chore: remove unused fieldmanager variable and import
ManukMinasyan Dec 8, 2025
7aba8d1
refactor: extract shared field management methods to trait
ManukMinasyan Dec 8, 2025
cd9e48c
feat: add command to migrate email field values to array format
ManukMinasyan Dec 8, 2025
297c174
refactor: simplify field form layout and hide entity type
ManukMinasyan Dec 8, 2025
56ae025
fix: show all fields when sections disabled regardless of section
ManukMinasyan Dec 8, 2025
499d73e
fix: improve field card density and icon styling
ManukMinasyan Dec 9, 2025
0f9c671
fix: set default max_values when enabling allow_multiple toggle
ManukMinasyan Dec 9, 2025
ec790b9
refactor: rename SYSTEM_SECTIONS_DISABLED to UI_FLAT_FIELD_LAYOUT
ManukMinasyan Dec 9, 2025
10697a8
fix: respect flat layout feature flag in form and infolist builders
ManukMinasyan Dec 9, 2025
67297c0
feat: limit unique constraint option to applicable field types
ManukMinasyan Dec 9, 2025
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
18 changes: 18 additions & 0 deletions resources/lang/en/custom-fields.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
'default' => [
'new_section' => 'New Section',
],
'default_section_name' => 'Default',
],

'field' => [
Expand Down Expand Up @@ -65,6 +66,12 @@
],
'add_field' => 'Add Field',
'system_defined_cannot_delete' => 'System-defined fields cannot be deleted.',
'allow_multiple' => 'Allow Multiple Values',
'allow_multiple_help' => 'When enabled, users can enter multiple values for this field.',
'max_values' => 'Maximum Values',
'max_values_help' => 'The maximum number of values that can be entered.',
'unique_per_entity_type' => 'Unique Per Entity Type',
'unique_per_entity_type_help' => 'Each value can only be assigned to one record of this entity type.',
'validation' => [
'label' => 'Validation',
'rules' => 'Validation Rules',
Expand Down Expand Up @@ -315,6 +322,7 @@
'multi_parameter_missing' => 'This validation rule requires multiple parameters. Please add all required parameters.',
'parameter_missing' => 'This validation rule requires exactly :count parameter(s). Please add all required parameters.',
'invalid_rule_for_field_type' => 'The selected rule is not valid for this field type.',
'unique_value' => 'The value ":value" is already assigned to another record.',
],

'empty_states' => [
Expand All @@ -328,9 +336,19 @@
'description' => 'Drag and drop fields here or click the button below to add your first field.',
'icon' => 'heroicon-o-squares-plus',
],
'fields_no_sections' => [
'heading' => 'No custom fields yet',
'description' => 'Click the button below to add your first custom field.',
'icon' => 'heroicon-o-squares-plus',
],
],

'common' => [
'inactive' => 'Inactive',
],

'email' => [
'add_email_placeholder' => 'Add email address...',
],

];
78 changes: 46 additions & 32 deletions resources/views/filament/pages/custom-fields-management.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,43 +9,57 @@
</x-filament::tabs>

<div class="custom-fields-component">
<div
x-sortable
wire:end.stop="updateSectionsOrder($event.target.sortable.toArray())"
class="flex flex-col gap-y-6"
>
@foreach ($this->sections as $section)
@livewire('manage-custom-field-section', ['entityType' => $this->currentEntityType, 'section' => $section], key($section->id . str()->random(16)))
@endforeach
@if($this->isSectionsDisabled)
{{-- Sections disabled mode: show flat field list with new component --}}
@if($this->sections->first())
@livewire('manage-fields-without-sections', [
'entityType' => $this->currentEntityType,
'section' => $this->sections->first(),
], key('fields-without-sections-' . $this->currentEntityType))
@endif
@else
{{-- Normal sections mode --}}
<div
x-sortable
wire:end.stop="updateSectionsOrder($event.target.sortable.toArray())"
class="flex flex-col gap-y-6"
>
@foreach ($this->sections as $section)
@livewire('manage-custom-field-section', [
'entityType' => $this->currentEntityType,
'section' => $section,
], key($section->id . str()->random(16)))
@endforeach

@if(!count($this->sections))
<div class="fi-ta-empty-state px-6 py-16">
<div class="fi-ta-empty-state-content mx-auto grid max-w-md justify-items-center text-center">
<div class="fi-ta-empty-state-icon-ctn mb-6 rounded-full bg-primary-50 p-4 dark:bg-primary-950/50">
<x-filament::icon
icon="{{ __('custom-fields::custom-fields.empty_states.sections.icon') }}"
class="fi-ta-empty-state-icon h-8 w-8 text-primary-500 dark:text-primary-400"
/>
</div>
@if(!count($this->sections))
<div class="px-6 py-16">
<div class="mx-auto grid max-w-md justify-items-center text-center">
<div class="fi-ta-empty-state-icon-ctn mb-6 rounded-full bg-primary-50 p-4 dark:bg-primary-950/50">
<x-filament::icon
icon="{{ __('custom-fields::custom-fields.empty_states.sections.icon') }}"
class="fi-ta-empty-state-icon h-8 w-8 text-primary-500 dark:text-primary-400"
/>
</div>

<h3 class="fi-ta-empty-state-heading text-lg font-semibold leading-7 text-gray-950 dark:text-white mb-2">
{{ __('custom-fields::custom-fields.empty_states.sections.heading') }}
</h3>
<h3 class="fi-ta-empty-state-heading text-lg font-semibold leading-7 text-gray-950 dark:text-white mb-2">
{{ __('custom-fields::custom-fields.empty_states.sections.heading') }}
</h3>

<p class="fi-ta-empty-state-description text-sm text-gray-600 dark:text-gray-400 mb-6 leading-relaxed">
{{ __('custom-fields::custom-fields.empty_states.sections.description') }}
</p>
<p class="fi-ta-empty-state-description text-sm text-gray-600 dark:text-gray-400 mb-6 leading-relaxed">
{{ __('custom-fields::custom-fields.empty_states.sections.description') }}
</p>

<div class="fi-ta-empty-state-action">
{{ $this->createSectionAction }}
<div class="fi-ta-empty-state-action">
{{ $this->createSectionAction }}
</div>
</div>
</div>
</div>
@else
<div class="mt-6 flex justify-center">
{{ $this->createSectionAction }}
</div>
@endif
</div>
@else
<div class="mt-6 flex justify-center">
{{ $this->createSectionAction }}
</div>
@endif
</div>
@endif
</div>
</x-filament-panels::page>
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
<x-filament::icon-button
icon="heroicon-m-bars-4"
color="gray"
class="text-gray-500"
size="xs"
x-sortable-handle
/>

Expand All @@ -30,7 +32,7 @@
x-sortable-group="fields"
data-section-id="{{ $section->id }}"
default="12"
class="fi-sc fi-sc-has-gap fi-grid lg:fi-grid-cols"
class="fi-sc fi-sc-has-gap fi-sc-dense fi-grid lg:fi-grid-cols"
style="--cols-lg: repeat(12, minmax(0, 12fr)); --cols-default: repeat(2, minmax(0, 1fr));"
@end.stop="$wire.updateFieldsOrder($event.to.getAttribute('data-section-id'), $event.to.sortable.toArray())"
>
Expand All @@ -40,8 +42,8 @@ class="fi-sc fi-sc-has-gap fi-grid lg:fi-grid-cols"

@if(!count($this->fields))
<div class="fi-grid-col" style="--col-span-default: span 12 / span 12;">
<div class="fi-ta-empty-state py-12">
<div class="fi-ta-empty-state-content mx-auto grid max-w-xs justify-items-center text-center">
<div class="py-12">
<div class="mx-auto grid max-w-xs justify-items-center text-center">
<div class="fi-ta-empty-state-icon-ctn mb-4 rounded-full bg-gray-50 p-3 dark:bg-gray-800/50">
<x-filament::icon
icon="{{ __('custom-fields::custom-fields.empty_states.fields.icon') }}"
Expand Down
6 changes: 3 additions & 3 deletions resources/views/livewire/manage-custom-field-width.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class="relative"
<template x-for="(width, index) in widths" :key="index">
<div
wire:click="$parent.setWidth(fieldId, width)"
class="h-6 flex-1 cursor-pointer bg-gray-200 hover:bg-gray-300 transition-colors"
class="h-6 flex-1 cursor-pointer bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 transition-colors"
:class="{
'rounded-s-md': index === 0,
'rounded-e-md': index === widths.length - 1
Expand All @@ -26,12 +26,12 @@ class="h-full w-full border-gray-300 transition-colors duration-200"
'bg-primary-600 hover:bg-primary-600/80': isSelected(width),
'rounded-s-md': index === 0 && isSelected(width),
'rounded-e-md': index === widths.length - 1 && isSelected(width),
'border-s': index !== widths.length - 1
'border-s': index !== widths.length && index !== 0,
}"
></div>
</div>
</template>
</div>
<div class="absolute w-full h-full font-semibold text-sm flex items-center justify-center text-black">{{ $selectedWidth }}%</div>
<div class="absolute w-full h-full font-semibold text-sm flex items-center justify-center text-gray-900 dark:text-white">{{ $selectedWidth }}%</div>
</div>
</div>
4 changes: 3 additions & 1 deletion resources/views/livewire/manage-custom-field.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ class="fi-section !px-2 fi-compact !py-2 shadow-none fi-grid-col flex justify-be
<x-filament::icon-button
icon="heroicon-m-bars-3"
color="gray"
class="ml-0.5 text-gray-500"
size="xs"
/>

<x-filament::icon
:icon="$field->typeData?->icon ?? 'heroicon-o-document-text'"
class="h-5 w-5 text-gray-500 dark:text-gray-400"
class="h-4.5 w-4.5 text-neutral-700 dark:text-neutral-400 ml-2"
:aria-label="$field->name"
/>

Expand Down
44 changes: 44 additions & 0 deletions resources/views/livewire/manage-fields-without-sections.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<div class="flex flex-col gap-y-6">
@if(count($this->fields))
<div
x-sortable
x-sortable-group="fields"
class="fi-sc fi-sc-has-gap fi-sc-dense fi-grid lg:fi-grid-cols"
style="--cols-lg: repeat(12, minmax(0, 12fr)); --cols-default: repeat(2, minmax(0, 1fr));"
@end.stop="$wire.updateFieldsOrder($event.to.sortable.toArray())"
>
@foreach ($this->fields as $field)
@livewire('manage-custom-field', ['field' => $field], key($field->id . $field->width->value . str()->random(16)))
@endforeach
</div>

<div class="flex justify-center">
{{ $this->createFieldAction() }}
</div>
@else
<div class="px-6 py-16">
<div class="mx-auto grid max-w-md justify-items-center text-center">
<div class="fi-ta-empty-state-icon-ctn mb-6 rounded-full bg-primary-50 p-4 dark:bg-primary-950/50">
<x-filament::icon
icon="{{ __('custom-fields::custom-fields.empty_states.fields_no_sections.icon') }}"
class="fi-ta-empty-state-icon h-8 w-8 text-primary-500 dark:text-primary-400"
/>
</div>

<h3 class="fi-ta-empty-state-heading text-lg font-semibold leading-7 text-gray-950 dark:text-white mb-2">
{{ __('custom-fields::custom-fields.empty_states.fields_no_sections.heading') }}
</h3>

<p class="fi-ta-empty-state-description text-sm text-gray-600 dark:text-gray-400 mb-6 leading-relaxed">
{{ __('custom-fields::custom-fields.empty_states.fields_no_sections.description') }}
</p>

<div class="fi-ta-empty-state-action">
{{ $this->createFieldAction() }}
</div>
</div>
</div>
@endif

<x-filament-actions::modals/>
</div>
121 changes: 121 additions & 0 deletions src/Console/Commands/MigrateEmailFieldValuesCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<?php

declare(strict_types=1);

namespace Relaticle\CustomFields\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Throwable;

/**
* Migrates email field values from string_value to json_value format.
*
* This command is needed after the EmailFieldType was changed from STRING
* data type to MULTI_CHOICE, which stores values in json_value as an array.
*/
class MigrateEmailFieldValuesCommand extends Command
{
protected $signature = 'custom-fields:migrate-email-values
{--dry-run : Show what would be migrated without making changes}
{--force : Run without confirmation in production}';

protected $description = 'Migrate email field values from string_value to json_value array format';

public function handle(): int
{
$isDryRun = $this->option('dry-run');

if (app()->isProduction() && ! $isDryRun && ! $this->option('force') && ! $this->confirm('You are running in production. Are you sure you want to continue?')) {
$this->info('Migration cancelled.');

return self::SUCCESS;
}

$fieldTable = config('custom-fields.database.table_names.custom_fields');
$valueTable = config('custom-fields.database.table_names.custom_field_values');

// Find all email type custom fields
$emailFields = DB::table($fieldTable)
->where('type', 'email')
->pluck('id');

if ($emailFields->isEmpty()) {
$this->info('No email fields found. Nothing to migrate.');

return self::SUCCESS;
}

$this->info(sprintf('Found %d email field(s).', $emailFields->count()));

// Find values that need migration (have string_value but no json_value)
$valuesToMigrate = DB::table($valueTable)
->whereIn('custom_field_id', $emailFields)
->whereNotNull('string_value')
->where('string_value', '!=', '')
->where(function ($query): void {
$query->whereNull('json_value')
->orWhere('json_value', '=', '[]')
->orWhere('json_value', '=', 'null');
})
->get();

if ($valuesToMigrate->isEmpty()) {
$this->info('No email values need migration. All values are already in the correct format.');

return self::SUCCESS;
}

$this->info(sprintf('Found %d email value(s) to migrate.', $valuesToMigrate->count()));

if ($isDryRun) {
$this->warn('Dry run mode - no changes will be made.');
$this->newLine();

$this->table(
['ID', 'Entity Type', 'Entity ID', 'Current Value', 'New Format'],
$valuesToMigrate->map(fn ($value): array => [
$value->id,
$value->entity_type,
$value->entity_id,
$value->string_value,
json_encode([$value->string_value]),
])->toArray()
);

return self::SUCCESS;
}

$bar = $this->output->createProgressBar($valuesToMigrate->count());
$bar->start();

$migrated = 0;
$errors = 0;

foreach ($valuesToMigrate as $value) {
try {
DB::table($valueTable)
->where('id', $value->id)
->update([
'json_value' => json_encode([$value->string_value]),
'string_value' => null,
]);

$migrated++;
} catch (Throwable $e) {
$this->newLine();
$this->error(sprintf('Failed to migrate value ID %d: %s', $value->id, $e->getMessage()));
$errors++;
}

$bar->advance();
}

$bar->finish();
$this->newLine(2);

$this->info(sprintf('Migration complete. Migrated: %d, Errors: %d', $migrated, $errors));

return $errors > 0 ? self::FAILURE : self::SUCCESS;
}
}
Loading