Better user search filtering
This commit is contained in:
parent
6cc029cad6
commit
f51b730f51
5 changed files with 91 additions and 42 deletions
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace Pterodactyl\Http\Controllers\Api\Application\Users;
|
namespace Pterodactyl\Http\Controllers\Api\Application\Users;
|
||||||
|
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
use Pterodactyl\Models\User;
|
use Pterodactyl\Models\User;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
@ -49,20 +50,21 @@ class UserController extends ApplicationApiController
|
||||||
|
|
||||||
$users = QueryBuilder::for(User::query())
|
$users = QueryBuilder::for(User::query())
|
||||||
->allowedFilters([
|
->allowedFilters([
|
||||||
'id',
|
AllowedFilter::exact('id'),
|
||||||
'uuid',
|
AllowedFilter::exact('uuid'),
|
||||||
|
AllowedFilter::exact('external_id'),
|
||||||
'username',
|
'username',
|
||||||
'email',
|
'email',
|
||||||
'external_id',
|
AllowedFilter::callback('*', function (Builder $builder, $value) {
|
||||||
AllowedFilter::callback('*', function (Builder $builder) use ($request) {
|
foreach (Arr::wrap($value) as $datum) {
|
||||||
$value = trim($request->input('filters.*'), '%');
|
$datum = '%' . $datum . '%';
|
||||||
|
$builder->where(function (Builder $builder) use ($datum) {
|
||||||
return $builder->where(function (Builder $builder) use ($value) {
|
$builder->where('uuid', 'LIKE', $datum)
|
||||||
$builder->where('uuid', 'LIKE', $value . '%')
|
->orWhere('username', 'LIKE', $datum)
|
||||||
->orWhere('username', 'LIKE', $value . '%')
|
->orWhere('email', 'LIKE', $datum)
|
||||||
->orWhere('email', 'LIKE', $value . '%')
|
->orWhere('external_id', 'LIKE', $datum);
|
||||||
->orWhere('external_id', 'LIKE', $value . '%');
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
->allowedSorts(['id', 'uuid', 'username', 'email', 'admin_role_id'])
|
->allowedSorts(['id', 'uuid', 'username', 'email', 'admin_role_id'])
|
||||||
|
|
|
@ -16,7 +16,10 @@ const filters = [ 'id', 'uuid', 'external_id', 'username', 'email' ] as const;
|
||||||
const UsersContainer = () => {
|
const UsersContainer = () => {
|
||||||
const [ search, setSearch ] = useDebouncedState('', 500);
|
const [ search, setSearch ] = useDebouncedState('', 500);
|
||||||
const [ selected, setSelected ] = useState<UUID[]>([]);
|
const [ selected, setSelected ] = useState<UUID[]>([]);
|
||||||
const { data: users } = useGetUsers(extractSearchFilters(search, filters));
|
const { data: users } = useGetUsers(extractSearchFilters(search, filters, {
|
||||||
|
splitUnmatched: true,
|
||||||
|
returnUnmatched: true,
|
||||||
|
}));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = 'Admin | Users';
|
document.title = 'Admin | Users';
|
||||||
|
|
|
@ -3,18 +3,17 @@ import extractSearchFilters from '@/helpers/extractSearchFilters';
|
||||||
type TestCase = [ string, 0 | Record<string, string[]> ];
|
type TestCase = [ string, 0 | Record<string, string[]> ];
|
||||||
|
|
||||||
describe('@/helpers/extractSearchFilters.ts', function () {
|
describe('@/helpers/extractSearchFilters.ts', function () {
|
||||||
const _DEFAULT = 0x00;
|
|
||||||
const cases: TestCase[] = [
|
const cases: TestCase[] = [
|
||||||
[ '', {} ],
|
[ '', {} ],
|
||||||
[ 'hello world', _DEFAULT ],
|
[ 'hello world', {} ],
|
||||||
[ 'bar:xyz foo:abc', { bar: [ 'xyz' ], foo: [ 'abc' ] } ],
|
[ 'bar:xyz foo:abc', { bar: [ 'xyz' ], foo: [ 'abc' ] } ],
|
||||||
[ 'hello foo:abc', { foo: [ 'abc' ] } ],
|
[ 'hello foo:abc', { foo: [ 'abc' ] } ],
|
||||||
[ 'hello foo:abc world another bar:xyz hodor', { foo: [ 'abc' ], bar: [ 'xyz' ] } ],
|
[ 'hello foo:abc world another bar:xyz hodor', { foo: [ 'abc' ], bar: [ 'xyz' ] } ],
|
||||||
[ 'foo:1 foo:2 foo: 3 foo:4', { foo: [ '1', '2', '4' ] } ],
|
[ 'foo:1 foo:2 foo: 3 foo:4', { foo: [ '1', '2', '4' ] } ],
|
||||||
[ ' foo:123 foo:bar:123 foo: foo:string', { foo: [ '123', 'bar:123', 'string' ] } ],
|
[ ' foo:123 foo:bar:123 foo: foo:string', { foo: [ '123', 'bar:123', 'string' ] } ],
|
||||||
[ 'foo:1 bar:2 baz:3', { foo: [ '1' ], bar: [ '2' ] } ],
|
[ 'foo:1 bar:2 baz:3', { foo: [ '1' ], bar: [ '2' ] } ],
|
||||||
[ 'hello "world this" is quoted', _DEFAULT ],
|
[ 'hello "world this" is quoted', {} ],
|
||||||
[ 'hello "world foo:123 is" quoted', _DEFAULT ],
|
[ 'hello "world foo:123 is" quoted', {} ],
|
||||||
[ 'hello foo:"this is quoted" bar:"this \\"is deeply\\" quoted" world foo:another', {
|
[ 'hello foo:"this is quoted" bar:"this \\"is deeply\\" quoted" world foo:another', {
|
||||||
foo: [ 'this is quoted', 'another' ],
|
foo: [ 'this is quoted', 'another' ],
|
||||||
bar: [ 'this "is deeply" quoted' ],
|
bar: [ 'this "is deeply" quoted' ],
|
||||||
|
@ -23,23 +22,59 @@ describe('@/helpers/extractSearchFilters.ts', function () {
|
||||||
|
|
||||||
it.each(cases)('should return expected filters: [%s]', function (input, output) {
|
it.each(cases)('should return expected filters: [%s]', function (input, output) {
|
||||||
expect(extractSearchFilters(input, [ 'foo', 'bar' ])).toStrictEqual({
|
expect(extractSearchFilters(input, [ 'foo', 'bar' ])).toStrictEqual({
|
||||||
filters: output === _DEFAULT ? {
|
filters: output,
|
||||||
'*': [ input ],
|
|
||||||
} : output,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should allow modification of the default parameter', function () {
|
it('should allow modification of the default parameter', function () {
|
||||||
expect(extractSearchFilters('hello world', [ 'foo' ], 'default_param')).toStrictEqual({
|
expect(extractSearchFilters('hello world', [ 'foo' ], { defaultFilter: 'default_param', returnUnmatched: true })).toStrictEqual({
|
||||||
filters: {
|
filters: {
|
||||||
default_param: [ 'hello world' ],
|
default_param: [ 'hello world' ],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(extractSearchFilters('foo:123 bar', [ 'foo' ], 'default_param')).toStrictEqual({
|
expect(extractSearchFilters('foo:123 bar', [ 'foo' ], { defaultFilter: 'default_param' })).toStrictEqual({
|
||||||
filters: {
|
filters: {
|
||||||
foo: [ '123' ],
|
foo: [ '123' ],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[ '', {} ],
|
||||||
|
[ 'hello world', { '*': [ 'hello world' ] } ],
|
||||||
|
[ 'hello world foo:123 bar:456', { foo: [ '123' ], bar: [ '456' ], '*': [ 'hello world' ] } ],
|
||||||
|
[ 'hello world foo:123 another string', { foo: [ '123' ], '*': [ 'hello world another string' ] } ],
|
||||||
|
])('should return unmatched parameters: %s', function (input, output) {
|
||||||
|
expect(extractSearchFilters(input, [ 'foo', 'bar' ], { returnUnmatched: true })).toStrictEqual({
|
||||||
|
filters: output,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[ '', {} ],
|
||||||
|
[ 'hello world', { '*': [ 'hello', 'world' ] } ],
|
||||||
|
[ 'hello world foo:123 bar:456', { foo: [ '123' ], bar: [ '456' ], '*': [ 'hello', 'world' ] } ],
|
||||||
|
[ 'hello world foo:123 another string', { foo: [ '123' ], '*': [ 'hello', 'world', 'another', 'string' ] } ],
|
||||||
|
])('should split unmatched parameters: %s', function (input, output) {
|
||||||
|
expect(extractSearchFilters(input, [ 'foo', 'bar' ], {
|
||||||
|
returnUnmatched: true,
|
||||||
|
splitUnmatched: true,
|
||||||
|
})).toStrictEqual({
|
||||||
|
filters: output,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([ true, false ])('should return the unsplit value (splitting: %s)', function (split) {
|
||||||
|
const extracted = extractSearchFilters('hello foo:123 bar:123 world', [ 'foo' ], {
|
||||||
|
returnUnmatched: true,
|
||||||
|
splitUnmatched: split,
|
||||||
|
});
|
||||||
|
expect(extracted).toStrictEqual({
|
||||||
|
filters: {
|
||||||
|
foo: [ '123' ],
|
||||||
|
'*': split ? [ 'hello', 'bar:123', 'world' ] : [ 'hello bar:123 world' ],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,40 +1,49 @@
|
||||||
import { QueryBuilderParams } from '@/api/http';
|
import { QueryBuilderParams } from '@/api/http';
|
||||||
import splitStringWhitespace from '@/helpers/splitStringWhitespace';
|
import splitStringWhitespace from '@/helpers/splitStringWhitespace';
|
||||||
|
|
||||||
const extractSearchFilters = <T extends string, D extends string = string> (
|
interface Options<D extends string = string> {
|
||||||
|
defaultFilter?: D;
|
||||||
|
splitUnmatched?: boolean;
|
||||||
|
returnUnmatched?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractSearchFilters = <T extends string, D extends string = '*'> (
|
||||||
str: string,
|
str: string,
|
||||||
params: Readonly<T[]>,
|
params: Readonly<T[]>,
|
||||||
defaultFilter: D = '*' as D,
|
options?: Options<D>,
|
||||||
): QueryBuilderParams<T> | QueryBuilderParams<D> => {
|
): QueryBuilderParams<T> | QueryBuilderParams<D> | QueryBuilderParams<T & D> => {
|
||||||
const filters: Map<T, string[]> = new Map();
|
const opts: Required<Options<D>> = {
|
||||||
|
defaultFilter: options?.defaultFilter || '*' as D,
|
||||||
|
splitUnmatched: options?.splitUnmatched || false,
|
||||||
|
returnUnmatched: options?.returnUnmatched || false,
|
||||||
|
};
|
||||||
|
|
||||||
if (str.trim().length === 0) {
|
const filters: Map<T, string[]> = new Map();
|
||||||
return { filters: {} };
|
const unmatched: string[] = [];
|
||||||
}
|
|
||||||
|
|
||||||
for (const segment of splitStringWhitespace(str)) {
|
for (const segment of splitStringWhitespace(str)) {
|
||||||
const parts = segment.split(':');
|
const parts = segment.split(':');
|
||||||
const filter = parts[0] as T;
|
const filter = parts[0] as T;
|
||||||
const value = parts.slice(1).join(':');
|
const value = parts.slice(1).join(':');
|
||||||
// @ts-ignore
|
if (!filter || (parts.length > 1 && filter && !value)) {
|
||||||
if (!filter || !value || !params.includes(filter)) {
|
// do nothing
|
||||||
continue;
|
} else if (!params.includes(filter)) {
|
||||||
|
unmatched.push(segment);
|
||||||
|
} else {
|
||||||
|
filters.set(filter, [ ...(filters.get(filter) || []), value ]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
filters.set(filter, [ ...(filters.get(filter) || []), value ]);
|
if (opts.returnUnmatched && str.trim().length > 0) {
|
||||||
|
filters.set(opts.defaultFilter as any, opts.splitUnmatched ? unmatched : [ unmatched.join(' ') ]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.size === 0) {
|
if (filters.size === 0) {
|
||||||
return {
|
return { filters: {} };
|
||||||
filters: {
|
|
||||||
[defaultFilter]: [ str ] as Readonly<string[]>,
|
|
||||||
} as unknown as QueryBuilderParams<D>['filters'],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
// @ts-expect-error
|
||||||
filters: Object.fromEntries(filters) as unknown as QueryBuilderParams<T>['filters'],
|
return { filters: Object.fromEntries(filters) };
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default extractSearchFilters;
|
export default extractSearchFilters;
|
||||||
|
|
|
@ -26,7 +26,7 @@ module.exports = {
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
test: /\.tsx?$/,
|
test: /\.tsx?$/,
|
||||||
exclude: /node_modules/,
|
exclude: /node_modules|\.spec\.tsx?$/,
|
||||||
loader: 'babel-loader',
|
loader: 'babel-loader',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
Loading…
Reference in a new issue