Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 4 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@

All notable changes to `:package_name` will be documented in this file.

## v1.3.6 - 2026-07-13
## v1.3.7 - 2026-07-13

### What's Changed

* Assign Users / Manage Permissions: add **Select all matching users** toggle to select every user matching the current filters (not just the first 50 search results)
* Replace built-in `user_filters` config with host-owned `assignment.filter_provider` contract for domain-specific assignment filters
* Fix `sharing.show_nested_shared_in_library` so it also gates root-level shared items in the main Library view (classic default `false` restores pre-v1.3.4 behavior)

**Full Changelog**: https://github.com/TappNetwork/Filament-Library/compare/v1.3.5...v1.3.6
**Full Changelog**: https://github.com/TappNetwork/Filament-Library/compare/v1.3.5...v1.3.7

## v1.3.5 - 2026-07-10

Expand Down
32 changes: 12 additions & 20 deletions config/filament-library.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,30 +65,22 @@

/*
|--------------------------------------------------------------------------
| User assignment filters
| User assignment filters (host-provided)
|--------------------------------------------------------------------------
|
| Optional filters shown when filter-based assignment (or the bulk manage
| action) is enabled. Host apps can enable community and role filters by
| pointing these options at their own models.
| When filter-based assignment is enabled, register a class that implements
| Tapp\FilamentLibrary\Contracts\UserAssignmentFilterProvider. The host app
| owns domain-specific filters (e.g. community, role, signup date).
|
| Example (in your app's config/filament-library.php):
|
| 'assignment' => [
| 'filter_provider' => \App\Library\LibraryUserAssignmentFilterProvider::class,
| ],
|
*/
'user_filters' => [
'community' => [
'enabled' => false,
'model' => null,
'title_attribute' => 'name',
'user_foreign_key' => 'community_id',
],
'role' => [
'enabled' => false,
'model' => null,
'title_attribute' => 'name',
],
'signup_date' => [
'enabled' => false,
'column' => 'created_at',
],
'assignment' => [
'filter_provider' => null,
],

/*
Expand Down
30 changes: 30 additions & 0 deletions src/Contracts/UserAssignmentFilterProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

namespace Tapp\FilamentLibrary\Contracts;

use Filament\Forms\Components\Field;
use Illuminate\Database\Eloquent\Builder;

interface UserAssignmentFilterProvider
{
/**
* Filter form fields shown before the users multi-select.
*
* @return array<int, Field>
*/
public function schema(): array;

/**
* Form field names whose values are passed to {@see apply()}.
*
* @return list<string>
*/
public function filterKeys(): array;

/**
* Scope the user query using filter form state.
*
* @param array<string, mixed> $filters
*/
public function apply(Builder $query, array $filters): Builder;
}
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ protected function filterBasedCreateAction(): CreateAction
return CreateAction::make()
->label('Assign Users')
->modalHeading('Assign User Permissions')
->modalDescription('Filter users by community, user level, and sign-up date, then assign permissions in bulk.')
->modalDescription('Filter users, then assign permissions in bulk.')
->schema([
...UserAssignmentFilters::schema('user_ids'),
Forms\Components\Select::make('role')
Expand Down
30 changes: 30 additions & 0 deletions src/Support/NullUserAssignmentFilterProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

namespace Tapp\FilamentLibrary\Support;

use Illuminate\Database\Eloquent\Builder;
use Tapp\FilamentLibrary\Contracts\UserAssignmentFilterProvider;

class NullUserAssignmentFilterProvider implements UserAssignmentFilterProvider
{
/**
* @return array<int, never>
*/
public function schema(): array
{
return [];
}

/**
* @return list<never>
*/
public function filterKeys(): array
{
return [];
}

public function apply(Builder $query, array $filters): Builder
{
return $query;
}
}
165 changes: 75 additions & 90 deletions src/Support/UserAssignmentFilters.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@

namespace Tapp\FilamentLibrary\Support;

use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Field;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Schema;
use Tapp\FilamentLibrary\Contracts\UserAssignmentFilterProvider;
use Tapp\FilamentLibrary\Forms\Components\UserSearchSelect;

class UserAssignmentFilters
Expand All @@ -19,51 +20,47 @@
public static function schema(string $usersField = 'user_ids'): array
{
return [
...static::filterFields(),
...static::filterFields($usersField),
static::selectAllMatchingToggle($usersField),
static::usersSelect($usersField),
];
}

/**
* @return array<int, Field>
*/
public static function filterFields(): array
public static function filterFields(string $usersField = 'user_ids'): array
{
$fields = [];

if (static::communityFilterEnabled()) {
$fields[] = Select::make('community_id')
->label('Community')
->options(fn (): array => static::communityOptions())
->searchable()
->preload()
->live()
->placeholder('All communities');
}
$provider = static::provider();

if (static::roleFilterEnabled()) {
$fields[] = Select::make('role_name')
->label('User Level')
->options(fn (): array => static::roleOptions())
->searchable()
->preload()
->live()
->placeholder('All user levels');
if (! $provider instanceof UserAssignmentFilterProvider) {
return [];
}

if (static::signupDateFilterEnabled()) {
$fields[] = DatePicker::make('signed_up_from')
->label('Signed Up From')
->live()
->native(false);
return collect($provider->schema())
->map(function (Field $field) use ($usersField): Field {
return $field
->live()
->afterStateUpdated(fn (Get $get, Set $set): mixed => static::refreshSelectedUsersIfSelectingAll($get, $set, $usersField));

Check failure on line 44 in src/Support/UserAssignmentFilters.php

View workflow job for this annotation

GitHub Actions / phpstan

Anonymous function with return type void returns null but should not return anything.
})
->all();
}

$fields[] = DatePicker::make('signed_up_until')
->label('Signed Up Until')
->live()
->native(false);
}
public static function selectAllMatchingToggle(string $usersField = 'user_ids'): Toggle
{
return Toggle::make('select_all_matching')
->label('Select all matching users')
->helperText('Selects every user matching the filters above, not just the first 50 search results. With no filters, this selects all users.')
->live()
->afterStateUpdated(function (Get $get, Set $set, mixed $state) use ($usersField): void {
if ($state) {
$set($usersField, static::filteredUserIds($get));

return;
}

return $fields;
$set($usersField, []);
});
}

public static function usersSelect(string $name = 'user_ids'): UserSearchSelect
Expand All @@ -72,7 +69,7 @@
->label('Users')
->placeholder('Search for users by name or email...')
->required()
->helperText('Select one or more users matching the filters above.')
->helperText('Select one or more users matching the filters above, or use “Select all matching users”.')
->options(fn (Get $get): array => static::filteredUserOptions($get))
->getSearchResultsUsing(fn (string $search, Get $get): array => static::filteredUserOptions($get, $search))
->getOptionLabelsUsing(fn (array $values): array => static::labelsForIds($values));
Expand All @@ -98,25 +95,10 @@
});
}

if (static::communityFilterEnabled() && filled($filters['community_id'] ?? null)) {
$foreignKey = config('filament-library.user_filters.community.user_foreign_key', 'community_id');
$query->where($foreignKey, $filters['community_id']);
}
$provider = static::provider();

if (static::roleFilterEnabled() && filled($filters['role_name'] ?? null)) {
$query->whereHas('roles', function (Builder $roleQuery) use ($filters): void {
$roleQuery->where('name', $filters['role_name']);
});
}

$signupColumn = config('filament-library.user_filters.signup_date.column', 'created_at');

if (static::signupDateFilterEnabled() && filled($filters['signed_up_from'] ?? null)) {
$query->whereDate($signupColumn, '>=', $filters['signed_up_from']);
}

if (static::signupDateFilterEnabled() && filled($filters['signed_up_until'] ?? null)) {
$query->whereDate($signupColumn, '<=', $filters['signed_up_until']);
if ($provider instanceof UserAssignmentFilterProvider) {
$query = $provider->apply($query, $filters);
}

return $query;
Expand All @@ -127,12 +109,7 @@
*/
public static function filteredUserOptions(Get $get, ?string $search = null): array
{
$filters = [
'community_id' => $get('community_id'),
'role_name' => $get('role_name'),
'signed_up_from' => $get('signed_up_from'),
'signed_up_until' => $get('signed_up_until'),
];
$filters = static::filtersFromGet($get);

$userModel = static::userModel();

Expand All @@ -145,6 +122,19 @@
->all();
}

/**
* @return list<int|string>
*/
public static function filteredUserIds(Get $get): array
{
$filters = static::filtersFromGet($get);
$userModel = static::userModel();

return static::applyFilters($userModel::query(), $filters)
->pluck((new $userModel)->getKeyName())
->all();
}

/**
* @param array<int, int|string> $values
* @return array<int|string, string>
Expand Down Expand Up @@ -182,50 +172,45 @@
return config('filament-library.user_model', config('auth.providers.users.model', 'App\\Models\\User'));
}

public static function communityFilterEnabled(): bool
public static function provider(): ?UserAssignmentFilterProvider
{
return (bool) config('filament-library.user_filters.community.enabled', false)
&& filled(config('filament-library.user_filters.community.model'));
}
$class = config('filament-library.assignment.filter_provider');

public static function roleFilterEnabled(): bool
{
return (bool) config('filament-library.user_filters.role.enabled', false)
&& filled(config('filament-library.user_filters.role.model'));
}
if (! filled($class)) {
return null;
}

public static function signupDateFilterEnabled(): bool
{
return (bool) config('filament-library.user_filters.signup_date.enabled', true);
$provider = app($class);

if (! $provider instanceof UserAssignmentFilterProvider) {
return null;
}

return $provider;
}

/**
* @return array<int|string, string>
* @return array<string, mixed>
*/
protected static function communityOptions(): array
protected static function filtersFromGet(Get $get): array
{
/** @var class-string<Model> $model */
$model = config('filament-library.user_filters.community.model');
$title = config('filament-library.user_filters.community.title_attribute', 'name');
$provider = static::provider();

if (! $provider instanceof UserAssignmentFilterProvider) {
return [];
}

return $model::query()
->orderBy($title)
->pluck($title, (new $model)->getKeyName())
return collect($provider->filterKeys())
->mapWithKeys(fn (string $key): array => [$key => $get($key)])
->all();
}

/**
* @return array<string, string>
*/
protected static function roleOptions(): array
protected static function refreshSelectedUsersIfSelectingAll(Get $get, Set $set, string $usersField): void
{
/** @var class-string<Model> $model */
$model = config('filament-library.user_filters.role.model');
$title = config('filament-library.user_filters.role.title_attribute', 'name');
if (! $get('select_all_matching')) {
return;
}

return $model::query()
->orderBy($title)
->pluck($title, $title)
->all();
$set($usersField, static::filteredUserIds($get));
}
}
Loading