This commit is contained in:
Dan Harrin
2021-02-28 21:11:59 +00:00
parent 9566df53aa
commit ed67717e89
21 changed files with 434 additions and 91 deletions
@@ -2,13 +2,13 @@
namespace Filament\Database\Factories;
use Filament\Models\FilamentUser;
use Filament\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class FilamentUserFactory extends Factory
class UserFactory extends Factory
{
protected $model = FilamentUser::class;
protected $model = User::class;
public function definition()
{
@@ -18,6 +18,7 @@ class FilamentUserFactory extends Factory
'name' => $this->faker->name,
'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password
'remember_token' => Str::random(10),
'roles' => [],
];
}
}
@@ -15,6 +15,7 @@ class CreateFilamentUsersTable extends Migration
$table->boolean('is_admin')->default(0);
$table->string('name');
$table->string('password');
$table->json('roles')->nullable();
$table->rememberToken();
$table->timestamps();
});
@@ -1,22 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateFilamentRoleUserTable extends Migration
{
public function up()
{
Schema::create('filament_role_user', function (Blueprint $table) {
$table->id();
$table->string('role');
$table->foreignId('user_id')->constrained('filament_users')->cascadeOnDelete();
});
}
public function down()
{
Schema::dropIfExists('filament_role_user');
}
}
@@ -0,0 +1,323 @@
@pushonce('filament-scripts:multi-select-component')
<script>
function multiSelect(config) {
return {
autofocus: config.autofocus,
emptyOptionsMessage: config.emptyOptionsMessage,
focusedOptionIndex: null,
initialOptions: config.initialOptions,
name: config.name,
noSearchResultsMessage: config.noSearchResultsMessage,
open: false,
options: {},
required: config.required,
search: '',
value: config.value,
closeListbox: function () {
this.open = false
this.focusedOptionIndex = null
this.search = ''
},
evaluatePosition: function () {
let availableHeight = window.innerHeight - this.$refs.button.offsetHeight
let element = this.$refs.button
while (element) {
availableHeight -= element.offsetTop
element = element.offsetParent
}
if (this.$refs.listbox.offsetHeight <= availableHeight) {
this.$refs.listbox.style.bottom = 'auto'
return
}
this.$refs.listbox.style.bottom = `${this.$refs.button.offsetHeight}px`
},
focusNextOption: function () {
if (this.focusedOptionIndex === null) {
this.focusedOptionIndex = Object.keys(this.options).length - 1
return
}
if (this.focusedOptionIndex + 1 >= Object.keys(this.options).length) return
this.focusedOptionIndex++
this.$refs.listboxOptionsList.children[this.focusedOptionIndex].scrollIntoView({
block: 'center',
})
},
focusPreviousOption: function () {
if (this.focusedOptionIndex === null) {
this.focusedOptionIndex = 0
return
}
if (this.focusedOptionIndex <= 0) return
this.focusedOptionIndex--
this.$refs.listboxOptionsList.children[this.focusedOptionIndex].scrollIntoView({
block: 'center',
})
},
init: function () {
if (! this.value) {
this.value = []
}
this.setOptions()
if (this.autofocus) this.openListbox()
this.$watch('search', (() => {
if (! this.open || this.search === '' || this.search === null) {
this.setOptions()
this.focusedOptionIndex = 0
return
}
this.options = {}
for (const [key, label] of Object.entries(this.initialOptions)) {
if (
! this.value.includes(key) &&
label.toLowerCase().includes(this.search.toLowerCase())
) {
this.options[key] = label
}
}
this.focusedOptionIndex = 0
}))
this.$watch('value', (() => {
if (this.value) return
this.value = []
}))
},
openListbox: function () {
this.setOptions()
this.focusedOptionIndex = 0
this.open = true
this.$nextTick(() => {
this.$refs.search.focus()
this.evaluatePosition()
this.$refs.listboxOptionsList.children[this.focusedOptionIndex].scrollIntoView({
block: 'center'
})
})
},
selectOption: function (index = null) {
if (! this.open) {
this.closeListbox()
return
}
this.value.push(Object.keys(this.options)[index ?? this.focusedOptionIndex])
this.closeListbox()
},
setOptions: function () {
this.options = {}
for (const [key, label] of Object.entries(this.initialOptions)) {
if (! this.value.includes(key)) {
this.options[key] = label
}
}
},
toggleListboxVisibility: function () {
if (this.open) {
this.closeListbox()
return
}
this.openListbox()
},
unselectOption: function (keyToUnselect) {
this.value = this.value.filter((key) => {
return key !== keyToUnselect
})
},
}
}
</script>
@endpushonce
<x-forms::field-group
:column-span="$formComponent->columnSpan"
:error-key="$formComponent->name"
:for="$formComponent->id"
:help-message="__($formComponent->helpMessage)"
:hint="__($formComponent->hint)"
:label="__($formComponent->label)"
:required="$formComponent->required"
>
<div
x-data="multiSelect({
autofocus: {{ $formComponent->autofocus ? 'true' : 'false' }},
emptyOptionsMessage: '{{ __($formComponent->emptyOptionsMessage) }}',
initialOptions: {{ json_encode($formComponent->options) }},
name: '{{ $formComponent->name }}',
noSearchResultsMessage: '{{ __($formComponent->noSearchResultsMessage) }}',
required: {{ $formComponent->required ? 'true' : 'false' }},
@if (Str::of($formComponent->nameAttribute)->startsWith('wire:model'))
value: @entangle($formComponent->name){{ Str::of($formComponent->nameAttribute)->after('wire:model') }},
@endif
})"
x-init="init()"
x-on:click.away="closeListbox()"
x-on:keydown.escape.stop="closeListbox()"
{!! $formComponent->id ? "id=\"{$formComponent->id}\"" : null !!}
class="relative"
{!! Filament\format_attributes($formComponent->extraAttributes) !!}
>
@unless (Str::of($formComponent->nameAttribute)->startsWith(['wire:model', 'x-model']))
<input
x-model="value"
{!! $formComponent->name ? "{$formComponent->nameAttribute}=\"{$formComponent->name}\"" : null !!}
type="hidden"
/>
@endif
@unless($formComponent->disabled)
<div
x-ref="button"
x-on:click="toggleListboxVisibility()"
x-on:keydown.enter.stop.prevent="open ? selectOption() : openListbox()"
x-on:keydown.space="if (! open) openListbox()"
x-bind:aria-expanded="open"
aria-haspopup="listbox"
tabindex="1"
class="bg-white relative w-full border rounded shadow-sm pl-3 pr-10 py-2 text-left cursor-default focus:border-secondary-300 focus:ring focus:ring-secondary-200 focus:ring-opacity-50 {{ $formComponent->disabled ? 'text-gray-500' : '' }} {{ $errors->has($formComponent->name) ? 'border-danger-600 motion-safe:animate-shake' : 'border-gray-300' }}"
>
<span
x-show="! open"
class="block truncate text-gray-400"
>
{{ __($formComponent->placeholder) }}
</span>
<input
x-ref="search"
x-show="open"
x-model.debounce.500="search"
x-on:keydown.enter.stop.prevent="selectOption()"
x-on:keydown.arrow-up.stop.prevent="focusPreviousOption()"
x-on:keydown.arrow-down.stop.prevent="focusNextOption()"
type="search"
class="w-full h-full border-0 p-0 focus:ring-0 focus:outline-none"
/>
<span class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<x-heroicon-s-selector x-cloak class="w-5 h-5 text-gray-400" />
</span>
</div>
<div
x-ref="listbox"
x-show="open"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
role="listbox"
x-bind:aria-activedescendant="focusedOptionIndex ? '{{ $formComponent->name }}' + 'Option' + focusedOptionIndex : null"
tabindex="-1"
x-cloak
class="absolute z-10 w-full my-1 bg-white rounded shadow-sm border border-gray-300"
>
<ul
x-ref="listboxOptionsList"
class="py-1 overflow-auto text-base leading-6 rounded shadow-sm max-h-60 focus:outline-none"
>
<template x-for="(key, index) in Object.keys(options)" :key="index">
<li
x-bind:id="'{{ $formComponent->name }}' + 'Option' + focusedOptionIndex"
x-on:click="selectOption(index)"
x-on:mouseenter="focusedOptionIndex = index"
x-on:mouseleave="focusedOptionIndex = null"
role="option"
x-bind:aria-selected="focusedOptionIndex === index"
x-bind:class="{
'text-white bg-secondary-600': index === focusedOptionIndex,
'text-gray-900': index !== focusedOptionIndex,
}"
class="relative py-2 pl-3 text-gray-900 cursor-default select-none pr-9"
>
<span
x-text="Object.values(options)[index]"
x-bind:class="{
'font-medium': index === focusedOptionIndex,
'font-normal': index !== focusedOptionIndex,
}"
class="block font-normal truncate"
></span>
</li>
</template>
<div
x-show="! Object.keys(options).length"
x-text="! search ? emptyOptionsMessage : noSearchResultsMessage"
class="px-3 py-2 text-gray-900 cursor-default select-none text-sm"
></div>
</ul>
</div>
@endunless
<div>
<template x-for="key in value">
<button
@unless($formComponent->disabled)
x-on:click="unselectOption(key)"
@endunless
type="button"
class="my-1 w-full flex justify-between space-x-2 items-center font-mono text-xs py-2 px-3 border border-gray-300 bg-gray-100 text-gray-800 rounded shadow-sm inline-block relative @unless($formComponent->disabled) cursor-pointer transition duration-200 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 hover:bg-gray-200 transition-colors duration-200 @else cursor-default @endunless"
>
<span x-text="initialOptions[key] ?? key"></span>
@unless($formComponent->disabled)
<x-heroicon-s-x class="h-3 w-3 text-gray-500" />
@endunless
</button>
</template>
</div>
</div>
</x-forms::field-group>
@@ -117,7 +117,7 @@
if (this.autofocus) this.openListbox()
this.$watch('search', (() => {
if (!this.open || this.search === '' || this.search === null) {
if (! this.open || this.search === '' || this.search === null) {
this.options = this.initialOptions
this.focusedOptionIndex = 0
@@ -1,6 +1,6 @@
@pushonce('filament-scripts:tags-component')
@pushonce('filament-scripts:tags-input-component')
<script>
function tags(config) {
function tagsInput(config) {
return {
newTag: '',
@@ -61,7 +61,7 @@
:required="$formComponent->required"
>
<div
x-data="tags({
x-data="tagsInput({
separator: '{{ $formComponent->separator }}',
@if (Str::of($formComponent->nameAttribute)->startsWith('wire:model'))
value: @entangle($formComponent->name){{ Str::of($formComponent->nameAttribute)->after('wire:model') }},
@@ -0,0 +1,43 @@
<?php
namespace Filament\Forms\Components;
class MultiSelect extends Field
{
use Concerns\CanBeAutofocused;
use Concerns\HasPlaceholder;
public $emptyOptionsMessage = 'forms::fields.select.emptyOptionsMessage';
public $noSearchResultsMessage = 'forms::fields.select.noSearchResultsMessage';
public $options = [];
public function __construct($name)
{
parent::__construct($name);
$this->placeholder('forms::fields.select.placeholder');
}
public function emptyOptionsMessage($message)
{
$this->emptyOptionsMessage = $message;
return $this;
}
public function noSearchResultsMessage($message)
{
$this->noSearchResultsMessage = $message;
return $this;
}
public function options($options)
{
$this->options = $options;
return $this;
}
}
-1
View File
@@ -9,7 +9,6 @@ class Select extends Field
use Concerns\CanBeAutofocused;
use Concerns\CanBeCompared;
use Concerns\CanBeUnique;
use Concerns\CanBeLengthConstrained;
use Concerns\HasPlaceholder;
public $emptyOptionsMessage = 'forms::fields.select.emptyOptionsMessage';
+3 -3
View File
@@ -2,7 +2,7 @@
namespace Filament\Commands;
use Filament\Models\FilamentUser;
use Filament\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
@@ -24,7 +24,7 @@ class MakeUserCommand extends Command
$isAdmin = $this->confirm('Would you like this user to be an administrator?', true);
$user = FilamentUser::create([
$user = User::create([
'is_admin' => $isAdmin,
'name' => $name,
'email' => $email,
@@ -34,7 +34,7 @@ class MakeUserCommand extends Command
$loginUrl = route('filament.auth.login');
$this->info("Success! {$user->email} may now log in at {$loginUrl}.");
if (FilamentUser::count() === 1 && $this->confirm('Would you like to show some love by starring the repo?', true)) {
if (User::count() === 1 && $this->confirm('Would you like to show some love by starring the repo?', true)) {
if (PHP_OS_FAMILY === 'Darwin') exec('open https://github.com/laravel-filament/filament');
if (PHP_OS_FAMILY === 'Linux') exec('xdg-open https://github.com/laravel-filament/filament');
if (PHP_OS_FAMILY === 'Windows') exec('start https://github.com/laravel-filament/filament');
+2 -2
View File
@@ -5,7 +5,7 @@ namespace Filament;
use BladeUI\Icons\Factory as BladeUIFactory;
use Filament\Commands;
use Filament\Http\Middleware;
use Filament\Models\FilamentUser;
use Filament\Models\User;
use Filament\Pages\Page;
use Filament\Providers\RouteServiceProvider;
use Filament\Resources\Resource;
@@ -151,7 +151,7 @@ class FilamentServiceProvider extends ServiceProvider
$this->app['config']->set('auth.providers.filament_users', [
'driver' => 'eloquent',
'model' => FilamentUser::class,
'model' => User::class,
]);
$this->app['config']->set('forms', [
+2 -2
View File
@@ -5,7 +5,7 @@ namespace Filament\Http\Livewire;
use Filament\Forms\Components;
use Filament\Forms\Form;
use Filament\Forms\HasForm;
use Filament\Models\FilamentUser;
use Filament\Models\User;
use Filament\Pages\Page;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
@@ -36,7 +36,7 @@ class EditAccount extends Page
->email()
->disableAutocomplete()
->required()
->unique(FilamentUser::class, 'email', true),
->unique(User::class, 'email', true),
]),
Components\Fieldset::make('Set a new password', [
Components\TextInput::make('newPassword')
-17
View File
@@ -1,17 +0,0 @@
<?php
namespace Filament\Models;
use Illuminate\Database\Eloquent\Model;
class FilamentRoleUser extends Model
{
public $table = 'filament_role_user';
public $timestamps = false;
public function user()
{
return $this->belongsTo(FilamentUser::class, 'user_id');
}
}
@@ -2,18 +2,22 @@
namespace Filament\Models;
use Filament\Database\Factories\FilamentUserFactory;
use Filament\Database\Factories\UserFactory;
use Illuminate\Auth\Notifications\ResetPassword as ResetPasswordNotification;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\URL;
class FilamentUser extends Authenticatable
class User extends Authenticatable
{
use HasFactory;
use Notifiable;
protected $casts = [
'roles' => 'array',
];
protected $guarded = [];
protected $hidden = [
@@ -21,9 +25,11 @@ class FilamentUser extends Authenticatable
'remember_token',
];
protected $table = 'filament_users';
protected static function newFactory()
{
return FilamentUserFactory::new();
return UserFactory::new();
}
public function can($resource, $route = 'index')
@@ -33,10 +39,7 @@ class FilamentUser extends Authenticatable
public function hasRole($role)
{
return FilamentRoleUser::where([
['role', $role],
['user_id', $this->attributes['id']],
])->exists();
return in_array($role, $this->roles);
}
public function sendPasswordResetNotification($token)
@@ -0,0 +1,8 @@
<?php
namespace Filament\Resources\Forms\Components;
class MultiSelect extends \Filament\Forms\Components\MultiSelect
{
use Concerns\InteractsWithResource;
}
+12 -7
View File
@@ -2,26 +2,24 @@
namespace Filament\Resources;
use Filament\Models\FilamentUser;
use Filament\Filament;
use Filament\Models\User;
use Filament\Resources\Forms\Components;
use Filament\Resources\Forms\Form;
use Filament\Resources\Tables\Columns;
use Filament\Resources\Tables\Filter;
use Filament\Resources\Tables\Table;
use Filament\Resources\UserResource\Pages;
use Illuminate\Support\Str;
class UserResource extends Resource
{
public static $icon = 'heroicon-o-user-group';
public static $label = 'user';
public static $model = FilamentUser::class;
public static $model = User::class;
public static $routeNamePrefix = 'filament';
public static $slug = 'users';
public static function form(Form $form)
{
return $form
@@ -34,7 +32,7 @@ class UserResource extends Resource
->email()
->disableAutocomplete()
->required()
->unique(FilamentUser::class, 'email', true),
->unique(User::class, 'email', true),
]),
Components\Fieldset::make('Password', [
Components\TextInput::make('password')
@@ -69,6 +67,13 @@ class UserResource extends Resource
->directory('filament-avatars')
->disk(config('filament.default_filesystem_disk')),
Components\Checkbox::make('is_admin')->label('Administrator?'),
Components\MultiSelect::make('roles')
->placeholder('Select a role')
->options(
collect(Filament::getRoles())
->mapWithKeys(fn ($role) => [$role => Str::ucfirst($role::getLabel())])
->toArray(),
),
]),
]);
}
-1
View File
@@ -23,7 +23,6 @@ class Role
if (static::$label) return static::$label;
return (string) Str::of(class_basename(static::class))
->beforeLast('Role')
->kebab()
->replace('-', ' ');
}
+3 -3
View File
@@ -3,7 +3,7 @@
namespace Filament\Tests\Feature\Auth;
use Filament\Http\Livewire\Auth\Login;
use Filament\Models\FilamentUser;
use Filament\Models\User;
use Filament\Tests\TestCase;
use Livewire\Livewire;
@@ -20,7 +20,7 @@ class LoginTest extends TestCase
/** @test */
public function can_login()
{
$user = FilamentUser::factory()->create();
$user = User::factory()->create();
Livewire::test(Login::class)
->set('email', $user->email)
@@ -34,7 +34,7 @@ class LoginTest extends TestCase
/** @test */
public function shows_an_error_when_bad_login_attempt()
{
$user = FilamentUser::factory()->create();
$user = User::factory()->create();
Livewire::test(Login::class)
->set('email', $user->email)
+2 -2
View File
@@ -3,7 +3,7 @@
namespace Filament\Tests\Feature\Auth;
use Filament\Http\Livewire\Auth\Logout;
use Filament\Models\FilamentUser;
use Filament\Models\User;
use Filament\Tests\TestCase;
use Livewire\Livewire;
@@ -12,7 +12,7 @@ class LogoutTest extends TestCase
/** @test */
public function can_log_out()
{
$user = FilamentUser::factory()->create();
$user = User::factory()->create();
$this->be($user);
+2 -2
View File
@@ -3,7 +3,7 @@
namespace Filament\Tests\Feature\Auth;
use Filament\Http\Livewire\Auth\RequestPassword;
use Filament\Models\FilamentUser;
use Filament\Models\User;
use Filament\Tests\TestCase;
use Illuminate\Auth\Notifications\ResetPassword as ResetPasswordNotification;
use Illuminate\Support\Facades\Notification;
@@ -24,7 +24,7 @@ class RequestPasswordTest extends TestCase
{
Notification::fake();
$user = FilamentUser::factory()->create();
$user = User::factory()->create();
Livewire::test(RequestPassword::class)
->set('email', $user->email)
+4 -4
View File
@@ -3,7 +3,7 @@
namespace Filament\Tests\Feature\Auth;
use Filament\Http\Livewire\Auth\ResetPassword;
use Filament\Models\FilamentUser;
use Filament\Models\User;
use Filament\Tests\TestCase;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Password;
@@ -26,7 +26,7 @@ class ResetPasswordTest extends TestCase
protected function generateToken($user = null)
{
if (! $user) {
$user = FilamentUser::factory()->create();
$user = User::factory()->create();
}
return Password::broker('filament_users')->createToken($user);
@@ -35,7 +35,7 @@ class ResetPasswordTest extends TestCase
/** @test */
public function can_reset_password()
{
$user = FilamentUser::factory()->create();
$user = User::factory()->create();
$newPassword = Str::random();
Livewire::test(ResetPassword::class, [
@@ -68,7 +68,7 @@ class ResetPasswordTest extends TestCase
/** @test */
public function shows_an_error_when_invalid_token_supplied()
{
$user = FilamentUser::factory()->create();
$user = User::factory()->create();
$newPassword = Str::random();
Livewire::test(ResetPassword::class, [
+11 -11
View File
@@ -3,7 +3,7 @@
namespace Filament\Tests\Feature;
use Filament\Http\Livewire\EditAccount;
use Filament\Models\FilamentUser;
use Filament\Models\User;
use Filament\Tests\TestCase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth;
@@ -16,7 +16,7 @@ class EditAccountTest extends TestCase
/** @test */
public function can_view_account_page()
{
$user = FilamentUser::factory()->create();
$user = User::factory()->create();
$this->be($user);
@@ -30,9 +30,9 @@ class EditAccountTest extends TestCase
{
Storage::fake(config('filament.storage_disk'));
$user = FilamentUser::factory()->create();
$user = User::factory()->create();
// $newAvatar = UploadedFile::fake()->image('avatar.jpg');
$newUserDetails = FilamentUser::factory()->make();
$newUserDetails = User::factory()->make();
$newPassword = Str::random();
$this->be($user);
@@ -65,7 +65,7 @@ class EditAccountTest extends TestCase
// /** @test */
// public function can_delete_avatar()
// {
// $user = FilamentUser::factory()->create();
// $user = User::factory()->create();
// $newAvatar = UploadedFile::fake()->image('avatar.jpg');
//
// $this->be($user);
@@ -92,7 +92,7 @@ class EditAccountTest extends TestCase
// /** @test */
// public function new_avatar_is_image()
// {
// $user = FilamentUser::factory()->create();
// $user = User::factory()->create();
// $newAvatar = UploadedFile::fake()->create('document.txt');
//
// $this->be($user);
@@ -105,7 +105,7 @@ class EditAccountTest extends TestCase
/** @test */
public function new_password_contains_minimum_8_characters()
{
$user = FilamentUser::factory()->create();
$user = User::factory()->create();
$this->be($user);
@@ -118,7 +118,7 @@ class EditAccountTest extends TestCase
/** @test */
public function new_password_is_confirmed()
{
$user = FilamentUser::factory()->create();
$user = User::factory()->create();
$this->be($user);
@@ -132,7 +132,7 @@ class EditAccountTest extends TestCase
/** @test */
public function record_email_is_required()
{
$user = FilamentUser::factory()->create();
$user = User::factory()->create();
$this->be($user);
@@ -145,7 +145,7 @@ class EditAccountTest extends TestCase
/** @test */
public function record_email_is_valid_email()
{
$user = FilamentUser::factory()->create();
$user = User::factory()->create();
$this->be($user);
@@ -158,7 +158,7 @@ class EditAccountTest extends TestCase
/** @test */
public function record_name_is_required()
{
$user = FilamentUser::factory()->create();
$user = User::factory()->create();
$this->be($user);