Skip to content

Commit e2d33d8

Browse files
authored
Merge pull request #675 from bezhanSalleh/streamlined-localization
Streamline permission label resolution system
2 parents a1c4211 + 5327821 commit e2d33d8

File tree

11 files changed

+637
-133
lines changed

11 files changed

+637
-133
lines changed

README.md

Lines changed: 80 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,10 @@ The easiest and most intuitive way to add access management to your Filament pan
105105
- [Generate Command Options (recap)](#generate-command-options-recap)
106106
- [Localization](#localization)
107107
- [Configuration](#configuration-4)
108-
- [Key](#key)
109-
- [Default](#default)
108+
- [How It Works](#how-it-works)
109+
- [Generating Translation Files](#generating-translation-files)
110+
- [Translation Keys](#translation-keys)
111+
- [Default Package Translations](#default-package-translations)
110112
- [Upgrade](#upgrade)
111113
- [Translations](#translations)
112114
- [Testing](#testing)
@@ -576,6 +578,8 @@ shield:super-admin [--user=] [--panel=] [--tenant=]
576578
shield:seeder [--generate] [--option=permissions_via_roles|direct_permissions] [--force]
577579

578580
shield:publish --panel={panel} [--cluster=] [--nested] [--force]
581+
582+
shield:translation {locale} [--panel=] [--path=]
579583
```
580584

581585
### Generate Command Options (recap)
@@ -593,85 +597,93 @@ shield:publish --panel={panel} [--cluster=] [--nested] [--force]
593597
```
594598

595599
## Localization
596-
Shield supports multiple languages out of the box. When enabled, you can provide translated labels for
597-
permissions to create a more localized experience for your international users.
600+
Shield supports multiple languages out of the box. When enabled, you can provide translated labels for
601+
permissions to create a more localized experience for your app's users.
598602

599603
### Configuration
600604
```php
601605
'localization' => [
602606
'enabled' => false,
603-
'key' => 'filament-shield::filament-shield',
607+
'key' => 'shield-permissions', // could be any name you want
604608
],
605609
```
606-
### Key
607-
You can translate the permission labels by creating the translations files for your application's
608-
supported locales following Laravel's localization conventions. The translation file can be
609-
named anything you want.
610-
For example, you can create a file named `permissions.php` per locale and then set the
611-
`localization.key` in the config as `localization.key' => 'permissions'`.
612-
613-
Now given that Filament entities(Resources, Pages, Widgets) can be already localized using their own methods,
614-
you only need to provide translations for the resource prefixes, page and widget names, and any custom permissions you have defined.
615-
For custom permissions, the keys will be the same as you define them, but in snake cased format.
616-
To get the list of keys that you need to provide translations for, you can run the following code snippet in Tinker or wherever you want:
617610

618-
```php
619-
use BezhanSalleh\FilamentShield\Facades\FilamentShield;
611+
### How It Works
612+
613+
Shield uses a **fallback chain** for resolving permission labels:
614+
615+
1. **User's translation file** (when `localization.enabled = true`)
616+
- Checks `lang/{locale}/{key}.php` where `{key}` is your configured localization key
617+
2. **Package's default translations**
618+
- Falls back to `resource_permission_prefixes_labels` for standard affixes (view, create, update, etc.)
619+
3. **Headline fallback**
620+
- Converts the key to a readable format (e.g., `force_delete_any` → "Force Delete Any")
620621

621-
return collect(FilamentShield::getAllResourcePermissionsWithLabels())
622-
->keys()
623-
->transform(
624-
fn($value) => str($value)
625-
->before(config("filament-shield.permissions.separator"))
626-
->snake()
627-
->toString()
628-
)
629-
->merge(
630-
collect(FilamentShield::getPages())
631-
->merge(FilamentShield::getWidgets())
632-
->flatMap->permissions->keys()
633-
->transform(
634-
fn($value) => str($value)
635-
->after(config("filament-shield.permissions.separator"))
636-
->snake()
637-
->toString()
638-
)
639-
)
640-
->merge(
641-
collect(FilamentShield::getCustomPermissions())
642-
->keys()
643-
->transform(fn($value) => str($value)->snake()->toString())
644-
)
645-
->unique()
646-
->values()
647-
->toArray();
622+
### Generating Translation Files
623+
624+
The easiest way to create a translation file is using the `shield:translation` command:
625+
626+
```bash
627+
php artisan shield:translation en --panel=admin
648628
```
649-
**Example output:** running the above in the Filament Demo app context, with `custom_permissions` => ['Impersonate:User', 'View:IconLibrary'] will give you the following output:
629+
630+
This generates a file at `lang/en/shield-permissions.php` containing all permission labels:
631+
650632
```php
651-
[
652-
"view_any",
653-
"view",
654-
"create",
655-
"update",
656-
"delete",
657-
"restore",
658-
"force_delete",
659-
"force_delete_any",
660-
"restore_any",
661-
"replicate",
662-
"reorder",
663-
"products_cluster",
664-
"stats_overview_widget",
665-
"orders_chart",
666-
"customers_chart",
667-
"latest_orders",
668-
"impersonate:_user",
669-
"view:_icon_library",
670-
]
633+
<?php
634+
635+
/**
636+
* Shield Permission Labels
637+
*
638+
* Translate the values below to localize permission labels in your application.
639+
*/
640+
641+
return [
642+
// Resource affixes
643+
'create' => 'Create',
644+
'delete' => 'Delete',
645+
'delete_any' => 'Delete Any',
646+
'force_delete' => 'Force Delete',
647+
'force_delete_any' => 'Force Delete Any',
648+
'replicate' => 'Replicate',
649+
'reorder' => 'Reorder',
650+
'restore' => 'Restore',
651+
'restore_any' => 'Restore Any',
652+
'update' => 'Update',
653+
'view' => 'View',
654+
'view_any' => 'View Any',
655+
656+
// Pages (permission key in snake_case)
657+
'view_dashboard' => 'Dashboard',
658+
659+
// Widgets (permission key in snake_case)
660+
'view_stats_overview' => 'Stats Overview',
661+
662+
// Custom permissions
663+
'approve_posts' => 'Approve Posts',
664+
];
671665
```
672666

673-
### Default
674-
if you want to use the default translations provided by the package for the commonly used set of permissions for resources, you can set the `localization.key` in the config as `localization.key' => 'filament-shield::filament-shield.resource_permission_prefixes_labels'` and enable localization by setting `localization.enabled` to `true`.
667+
### Translation Keys
668+
669+
All translation keys are in **snake_case** format:
670+
671+
| Permission Type | Original Key | Translation Key |
672+
|-----------------|--------------|-----------------|
673+
| Resource affix | `viewAny` | `view_any` |
674+
| Resource affix | `forceDeleteAny` | `force_delete_any` |
675+
| Page permission | `view:Dashboard` | `view_dashboard` |
676+
| Widget permission | `view:StatsOverview` | `view_stats_overview` |
677+
| Custom permission | `Approve:Posts` | `approve_posts` |
678+
679+
### Default Package Translations
680+
681+
Shield includes translations for standard resource affixes in 32 languages. When `localization.enabled = false`,
682+
the package automatically uses these translations for affixes like `view`, `create`, `update`, `delete`, etc.
683+
684+
For entity labels (Resources, Pages, Widgets), Filament's entity related methods are used
685+
(`getModelLabel()`, `getTitle()`, `getHeading()`, etc.).
686+
675687

676688
# Upgrade
677689
Upgrading from `3.x|4.0.0-Beta*` versions to 4.x requires careful consideration due to significant changes in the package's architecture and functionality. Here are the key steps and considerations for a successful upgrade:

src/Commands/Concerns/CanManipulateFiles.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ protected function checkForCollision(array $paths): bool
1414
{
1515
foreach ($paths as $path) {
1616
if ($this->fileExists($path)) {
17-
$this->components->error($path . ' already exists, aborting.');
17+
$this->components->warn($path . ' already exists, aborting.');
1818

1919
return true;
2020
}

src/Commands/SetupCommand.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -229,10 +229,11 @@ protected function manageMigrations(): void
229229
Schema::disableForeignKeyConstraints();
230230
DB::table('migrations')->where('migration', 'like', '%create_permission_tables')->delete();
231231
if (config('database.default') === 'pgsql') {
232-
$this->getTables()->each(fn (string $table) => DB::statement("DROP TABLE IF EXISTS {$table} CASCADE"));
232+
$this->getTables()->each(fn (string $table) => DB::statement(sprintf('DROP TABLE IF EXISTS %s CASCADE', $table)));
233233
} else {
234-
$this->getTables()->each(fn (string $table) => DB::statement("DROP TABLE IF EXISTS {$table}"));
234+
$this->getTables()->each(fn (string $table) => DB::statement('DROP TABLE IF EXISTS ' . $table));
235235
}
236+
236237
Schema::enableForeignKeyConstraints();
237238
}
238239

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace BezhanSalleh\FilamentShield\Commands;
6+
7+
use BezhanSalleh\FilamentShield\Commands\Concerns\CanManipulateFiles;
8+
use BezhanSalleh\FilamentShield\Facades\FilamentShield;
9+
use BezhanSalleh\FilamentShield\Support\Utils;
10+
use Filament\Facades\Filament;
11+
use Illuminate\Console\Command;
12+
use Illuminate\Console\Prohibitable;
13+
use Illuminate\Contracts\Console\PromptsForMissingInput;
14+
use Illuminate\Support\Str;
15+
use Symfony\Component\Console\Attribute\AsCommand;
16+
17+
use function Laravel\Prompts\confirm;
18+
use function Laravel\Prompts\select;
19+
use function Laravel\Prompts\text;
20+
21+
#[AsCommand(name: 'shield:translation', description: 'Generate a translation file for permission labels for the given locale.')]
22+
class TranslationCommand extends Command implements PromptsForMissingInput
23+
{
24+
use CanManipulateFiles;
25+
use Prohibitable;
26+
27+
/** @var string */
28+
public $signature = 'shield:translation
29+
{locale : The locale to generate the file for}
30+
{--panel= : Panel ID to get the permissions from}
31+
{--path= : Custom path for the translations file}
32+
';
33+
34+
public function handle(): int
35+
{
36+
if ($this->isProhibited()) {
37+
return Command::FAILURE;
38+
}
39+
40+
$panel = $this->option('panel') ?: select(
41+
label: 'Which panel do you want to generate permission translations for?',
42+
options: collect(Filament::getPanels())->keys()->toArray()
43+
);
44+
45+
Filament::setCurrentPanel(Filament::getPanel($panel));
46+
47+
$locale = $this->argument('locale');
48+
$localizationKey = Utils::getConfig()->localization->key;
49+
50+
$defaultFilename = Str::of($localizationKey)->afterLast('::')->toString();
51+
$defaultPath = lang_path(sprintf('%s/%s.php', $locale, $defaultFilename));
52+
53+
$path = $this->option('path') ?: text(
54+
label: 'Where would you like to save the translations file?',
55+
default: $defaultPath
56+
);
57+
58+
$translations = $this->gatherPermissionLabels();
59+
60+
if ($this->checkForCollision([$path])) {
61+
$confirmed = confirm('The file already exists. Do you want to overwrite it?', default: false);
62+
if (! $confirmed) {
63+
return Command::FAILURE;
64+
}
65+
}
66+
67+
$this->writeTranslationsFile($path, $translations);
68+
69+
$this->components->info('Translations file generated at: ' . $path);
70+
71+
return Command::SUCCESS;
72+
}
73+
74+
protected function gatherPermissionLabels(): array
75+
{
76+
$translations = [];
77+
78+
// Resource permission affixes (view, view_any, create, update, delete, etc.)
79+
$this->gatherAffixLabels($translations);
80+
81+
// Page permissions (snake_case of permission key)
82+
$this->gatherPageLabels($translations);
83+
84+
// Widget permissions (snake_case of permission key)
85+
$this->gatherWidgetLabels($translations);
86+
87+
// Custom permissions (snake_case of permission key)
88+
$this->gatherCustomPermissionLabels($translations);
89+
90+
ksort($translations);
91+
92+
return $translations;
93+
}
94+
95+
protected function gatherAffixLabels(array &$translations): void
96+
{
97+
$resources = FilamentShield::getResources() ?? [];
98+
99+
$affixes = collect($resources)
100+
->flatMap(fn (array $resource): array => array_keys($resource['permissions']))
101+
->unique()
102+
->values()
103+
->toArray();
104+
105+
foreach ($affixes as $affix) {
106+
$localizationKey = Utils::toLocalizationKey($affix);
107+
$translations[$localizationKey] = FilamentShield::getAffixLabel($affix);
108+
}
109+
}
110+
111+
protected function gatherPageLabels(array &$translations): void
112+
{
113+
$pages = FilamentShield::getPages() ?? [];
114+
115+
foreach ($pages as $page) {
116+
foreach ($page['permissions'] as $key => $label) {
117+
$localizationKey = Utils::toLocalizationKey($key);
118+
$translations[$localizationKey] = $label;
119+
}
120+
}
121+
}
122+
123+
protected function gatherWidgetLabels(array &$translations): void
124+
{
125+
$widgets = FilamentShield::getWidgets() ?? [];
126+
127+
foreach ($widgets as $widget) {
128+
foreach ($widget['permissions'] as $key => $label) {
129+
$localizationKey = Utils::toLocalizationKey($key);
130+
$translations[$localizationKey] = $label;
131+
}
132+
}
133+
}
134+
135+
protected function gatherCustomPermissionLabels(array &$translations): void
136+
{
137+
$customPermissions = FilamentShield::getCustomPermissions() ?? [];
138+
139+
foreach ($customPermissions as $key => $label) {
140+
$localizationKey = Utils::toLocalizationKey($key);
141+
$translations[$localizationKey] = $label;
142+
}
143+
}
144+
145+
protected function writeTranslationsFile(string $path, array $translations): void
146+
{
147+
$content = "<?php\n\n";
148+
$content .= "/**\n";
149+
$content .= " * Shield Permission Labels\n";
150+
$content .= " *\n";
151+
$content .= " * Translate the values below to localize permission labels in your application.\n";
152+
$content .= " */\n\n";
153+
$content .= "return [\n";
154+
155+
foreach ($translations as $key => $label) {
156+
$escapedKey = addslashes((string) $key);
157+
$escapedLabel = addslashes((string) $label);
158+
$content .= " '{$escapedKey}' => '{$escapedLabel}',\n";
159+
}
160+
161+
$content .= "];\n";
162+
163+
$this->writeFile($path, $content);
164+
}
165+
}

src/Concerns/HasEntityTransformers.php

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -68,20 +68,19 @@ public function transformWidgets(): ?array
6868
->toArray();
6969
}
7070

71-
/**
72-
* @return array<string, string>|null
73-
*/
74-
public function transformCustomPermissions(bool $localizedOrFormatted = false): ?array
71+
/** @return array<string, string> */
72+
public function transformCustomPermissions(bool $localized = false): ?array
7573
{
76-
$config = Utils::getConfig();
74+
$permissionCase = Utils::getConfig()->permissions->case;
7775

78-
return collect($config->custom_permissions)
79-
->mapWithKeys(function (string $label, int | string $key) use ($config, $localizedOrFormatted): array {
76+
return collect(Utils::getConfig()->custom_permissions)
77+
->mapWithKeys(function (string $label, int | string $key) use ($localized, $permissionCase): array {
8078
$permission = is_numeric($key) ? $label : $key;
79+
$configLabel = is_numeric($key) ? null : $label;
8180

8281
return [
83-
$this->format($config->permissions->case, $permission) => $localizedOrFormatted
84-
? $this->getPermissionLabel($permission)
82+
$this->format($permissionCase, $permission) => $localized
83+
? $this->getCustomPermissionLabel($permission, $configLabel)
8584
: Str::of($label)->headline()->toString(),
8685
];
8786
})

0 commit comments

Comments
 (0)