diff --git a/CHANGELOG.md b/CHANGELOG.md index 735c003..41feb01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial release +- `TranslatableInput` field: multi-language text/textarea whose value is an array keyed by locale, with a globe button that opens a dialog to edit every locale, per-locale validation via a `TranslationsRule` (errors reported as `name.en`, `name.ar`, `name.ckb`, ...) and locale metadata (native name and text direction) read from the panel's `config('app.available_locales')` list by default, so languages are defined once in `config/app.php`. An optional `laravilt-forms.locales` config key, shipped empty, overrides that list with plain codes or per-locale `name`/`direction`/`label` metadata when content languages differ from UI languages. Shipped for Blade, Vue and React. +- `required()` is now mirrored as a `required` validation rule on every field, so it is enforced even when other rules such as `maxLength()` are set (previously the schema only added it for fields with no other rules). ### Changed diff --git a/README.md b/README.md index 23f12b9..bb17741 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ php artisan make:component CustomField ## Field Types -**Basic:** TextInput, Textarea, NumberField, Select, Checkbox, CheckboxList, Radio, Toggle, ToggleButtons, Hidden +**Basic:** TextInput, Textarea, TranslatableInput, NumberField, Select, Checkbox, CheckboxList, Radio, Toggle, ToggleButtons, Hidden **Date & Time:** DatePicker, DateTimePicker, TimePicker, DateRangePicker diff --git a/config/laravilt-forms.php b/config/laravilt-forms.php index 9957ce0..ebe815b 100644 --- a/config/laravilt-forms.php +++ b/config/laravilt-forms.php @@ -29,5 +29,40 @@ 'middleware' => ['web', 'auth', 'throttle:120,1'], ], + /* + |-------------------------------------------------------------------------- + | Locales + |-------------------------------------------------------------------------- + | + | The locales offered by TranslatableInput when a field does not call + | ->locales() itself. Leave this empty to reuse the languages the panel + | already knows about from config('app.available_locales'), the same list + | the Locale & Timezone settings page shows, so a project defines its + | languages once in config/app.php: + | + | 'available_locales' => [ + | ['value' => 'en', 'label' => 'English', 'dir' => 'ltr'], + | ['value' => 'ar', 'label' => 'العربية', 'dir' => 'rtl'], + | ], + | + | Set it only when the languages content is written in differ from the + | languages the UI is shown in. Each entry may be a plain code or a + | code => metadata pair with a native "name" and a "direction" (ltr or + | rtl); an optional "label" overrides the short badge on the globe button. + | + | 'locales' => ['en', 'ar', 'ckb'], + | 'locales' => [ + | 'en' => ['name' => 'English', 'direction' => 'ltr'], + | 'ar' => ['name' => 'العربية', 'direction' => 'rtl'], + | 'ckb' => ['name' => 'کوردی', 'direction' => 'rtl'], + | ], + | + | A plain code gets the code as its name and "ltr" as its direction. When + | both lists are empty the field falls back to the application locale. + | + */ + + 'locales' => [], + // Add your configuration options here ]; diff --git a/docs/index.md b/docs/index.md index c48fe61..26e9a71 100644 --- a/docs/index.md +++ b/docs/index.md @@ -45,6 +45,7 @@ php artisan make:form UserForm --resource ### 📝 Basic Fields - **TextInput**: Single-line text input with validation - **Textarea**: Multi-line text input +- **TranslatableInput**: Multi-language text input with a per-locale dialog - **NumberField**: Numeric input with min/max - **Select**: Dropdown select with search - **Checkbox**: Single checkbox @@ -261,6 +262,116 @@ TextInput::make('name') ->helperText('Enter your full name'); ``` +### TranslatableInput + +A multi-language text field. Its value is an array keyed by locale code +(`['en' => 'Title', 'ar' => 'العنوان', 'ckb' => 'ناونیشان']`). The main input edits the active +locale; a globe button inside the input opens a dialog with one input per +locale (RTL locales render with `dir="rtl"`), so long titles and content stay +readable. The globe is hidden when only one +locale is allowed. + +```php +TranslatableInput::make('name') + ->label('Name') + ->locales(['en', 'ar', 'ckb']) // defaults to the configured locales, see below + ->activeLocale('en') // defaults to the app locale when allowed + ->required() // every locale is required... + ->requiredLocales(['en']) // ...unless you narrow it down + ->maxLength(255) // applied per locale + ->multiline() // textarea per locale + ->rows(4); +``` + +Locales resolve in this order: explicit `locales()`, then +`config('laravilt-forms.locales')` (published to `config/laravilt-forms.php`), +then `config('app.available_locales')`, then the application locale. + +By default the field reuses the languages the panel already knows about. That +is the same list the [Locale & Timezone](../auth/profile/preferences.md) +settings page shows, so a project defines its languages once in +`config/app.php`; `label` becomes the native name shown in the dialog and +`dir` sets `dir` on each input: + +```php +// config/app.php +'available_locales' => [ + ['value' => 'en', 'label' => 'English', 'dir' => 'ltr'], + ['value' => 'ar', 'label' => 'العربية', 'dir' => 'rtl'], + ['value' => 'ckb', 'label' => 'کوردی', 'dir' => 'rtl'], +], +``` + +Set `laravilt-forms.locales` only when the languages content is written in +differ from the languages the UI is shown in, for example an English-only +admin panel that manages content in three languages. It accepts plain codes or +per-locale metadata, and an optional `label` overrides the short badge on the +globe button: + +```php +// config/laravilt-forms.php +'locales' => [ + 'en' => ['name' => 'English', 'direction' => 'ltr'], + 'ar' => ['name' => 'العربية', 'direction' => 'rtl'], + 'ckb' => ['name' => 'کوردی', 'direction' => 'rtl'], +], +// or simply: 'locales' => ['en', 'ar', 'ckb'], +``` + +Names and directions are looked up in `laravilt-forms.locales` first and +`app.available_locales` second, so plain codes in the forms config still pick +up the names the panel defines. A code found in neither is shown as its own +code, labelled with its uppercased base code, and rendered LTR. + +Every locale is validated on its own and errors are reported per locale key +(`name.en`, `name.ar`, ...), which the field shows next to the matching input. +`getValidationRules()` returns one rule list for the field, as the schema +expects, with a `TranslationsRule` carrying the per-locale rules; use +`getLocaleValidationRules()` for flat keys when validating by hand: + +```php +$field = TranslatableInput::make('name')->locales(['en', 'ar', 'ckb'])->required()->maxLength(120); + +$field->getValidationRules(); +// ['required', 'array', TranslationsRule(en|ar|ckb => ['required', 'string', 'max:120'])] + +$field->getLocaleValidationRules(); +// [ +// 'name' => ['required', 'array'], +// 'name.en' => ['required', 'string', 'max:120'], +// 'name.ar' => ['required', 'string', 'max:120'], +// 'name.ckb' => ['required', 'string', 'max:120'], +// ] +``` + +Messages use the field label plus the locale, e.g. "The Name (EN) field is +required.", and `validationMessages(['required' => '...'])` applies to every +locale (`['en.required' => '...']` targets one). + +`hydrateState()` / `dehydrateState()` accept a JSON string, an array or `null` +and always return an array with every allowed locale present, so the field works +with both storage styles: + +```php +// spatie/laravel-translatable +class Product extends Model +{ + use \Spatie\Translatable\HasTranslations; + + public $translatable = ['name']; +} + +// or a plain JSON column +class Category extends Model +{ + protected $casts = ['name' => 'array']; +} + +// Both accept the field's value as-is: +$product->setTranslations('name', $data['name']); // or $product->name = $data['name']; +$category->name = $data['name']; +``` + ### Select ```php @@ -592,6 +703,7 @@ class ProductForm extends Form ### Basic - TextInput - Textarea +- TranslatableInput - NumberField - Select - Checkbox diff --git a/docs/mcp-server.md b/docs/mcp-server.md index 16e077a..e33557f 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -119,6 +119,7 @@ For MCP tools to provide field type information: ### Basic Fields - TextInput: Single-line text input - Textarea: Multi-line text input +- TranslatableInput: Multi-language text input (value keyed by locale) - NumberField: Numeric input - Select: Dropdown select - Checkbox: Single checkbox diff --git a/lang/ar/forms.php b/lang/ar/forms.php index f36913c..4df11f3 100644 --- a/lang/ar/forms.php +++ b/lang/ar/forms.php @@ -93,4 +93,11 @@ 'search_placeholder' => 'بحث في الصلاحيات...', 'no_results' => 'لم يتم العثور على صلاحيات', ], + + // TranslatableInput Component + 'translatable_input' => [ + 'translations' => 'الترجمات', + 'description' => 'حرّر هذا الحقل بكل اللغات.', + 'done' => 'تم', + ], ]; diff --git a/lang/ckb/forms.php b/lang/ckb/forms.php index 0e7e055..a7eaa7f 100644 --- a/lang/ckb/forms.php +++ b/lang/ckb/forms.php @@ -93,4 +93,11 @@ 'search_placeholder' => 'گەڕان بۆ دەسەڵاتەکان...', 'no_results' => 'هیچ دەسەڵاتێک نەدۆزرایەوە', ], + + // TranslatableInput Component + 'translatable_input' => [ + 'translations' => 'وەرگێڕانەکان', + 'description' => 'ئەم خانەیە بە هەموو زمانەکان دەستکاری بکە.', + 'done' => 'تەواو', + ], ]; diff --git a/lang/en/forms.php b/lang/en/forms.php index 26632d3..00ba5ee 100644 --- a/lang/en/forms.php +++ b/lang/en/forms.php @@ -93,4 +93,11 @@ 'search_placeholder' => 'Search permissions...', 'no_results' => 'No permissions found', ], + + // TranslatableInput Component + 'translatable_input' => [ + 'translations' => 'Translations', + 'description' => 'Edit this field in every language.', + 'done' => 'Done', + ], ]; diff --git a/resources/js/app.ts b/resources/js/app.ts index 00def27..9aed132 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -3,6 +3,7 @@ import Form from './components/Form.vue' import FieldWrapper from './components/FieldWrapper.vue' import TextInput from './components/fields/TextInput.vue' import Textarea from './components/fields/Textarea.vue' +import TranslatableInput from './components/fields/TranslatableInput.vue' import Select from './components/fields/Select.vue' import Checkbox from './components/fields/Checkbox.vue' import CheckboxList from './components/fields/CheckboxList.vue' @@ -43,6 +44,7 @@ export default { // Field components app.component('laravilt-text-input', TextInput) app.component('laravilt-textarea', Textarea) + app.component('laravilt-translatable-input', TranslatableInput) app.component('laravilt-select', Select) app.component('laravilt-checkbox', Checkbox) app.component('laravilt-checkbox-list', CheckboxList) diff --git a/resources/js/components-only.js b/resources/js/components-only.js index 948849a..8f841f2 100644 --- a/resources/js/components-only.js +++ b/resources/js/components-only.js @@ -6,6 +6,7 @@ import LaraviltForm from './components/Form.vue'; import LaraviltFieldWrapper from './components/FieldWrapper.vue'; import LaraviltTextInput from './components/fields/TextInput.vue'; import LaraviltTextarea from './components/fields/Textarea.vue'; +import LaraviltTranslatableInput from './components/fields/TranslatableInput.vue'; import LaraviltSelect from './components/fields/SelectWrapper.vue'; import LaraviltCheckbox from './components/fields/Checkbox.vue'; import LaraviltCheckboxList from './components/fields/CheckboxList.vue'; @@ -45,6 +46,7 @@ export default { // Field components app.component('laravilt-text-input', LaraviltTextInput); app.component('laravilt-textarea', LaraviltTextarea); + app.component('laravilt-translatable-input', LaraviltTranslatableInput); app.component('laravilt-select', LaraviltSelect); app.component('laravilt-checkbox', LaraviltCheckbox); app.component('laravilt-checkbox-list', LaraviltCheckboxList); diff --git a/resources/js/components/fields/TranslatableInput.vue b/resources/js/components/fields/TranslatableInput.vue new file mode 100644 index 0000000..03cc026 --- /dev/null +++ b/resources/js/components/fields/TranslatableInput.vue @@ -0,0 +1,379 @@ +