diff --git a/extensions/messages/extend.php b/extensions/messages/extend.php index 84d0be376..2f2394922 100644 --- a/extensions/messages/extend.php +++ b/extensions/messages/extend.php @@ -24,7 +24,7 @@ return [ ->css(__DIR__.'/less/forum.less') ->jsDirectory(__DIR__.'/js/dist/forum') ->route('/messages', 'messages') - ->route('/messages/dialog/{id:\d+}', 'messages.dialog'), + ->route('/messages/dialog/{id:\d+}[/{near:\d+}]', 'messages.dialog'), (new Extend\Frontend('admin')) ->js(__DIR__.'/js/dist/admin.js') diff --git a/extensions/messages/js/@types/shims.d.ts b/extensions/messages/js/@types/shims.d.ts index b5fc4de82..3101cfa70 100644 --- a/extensions/messages/js/@types/shims.d.ts +++ b/extensions/messages/js/@types/shims.d.ts @@ -3,7 +3,7 @@ import DialogListState from '../forum/states/DialogListState'; declare module 'flarum/forum/routes' { export interface ForumRoutes { - dialog: (tag: Dialog) => string; + dialog: (dialog: Dialog, near?: number) => string; } } diff --git a/extensions/messages/js/src/admin/extend.ts b/extensions/messages/js/src/admin/extend.ts index 7edc2bc0f..735aaaf2b 100644 --- a/extensions/messages/js/src/admin/extend.ts +++ b/extensions/messages/js/src/admin/extend.ts @@ -13,6 +13,6 @@ export default [ allowGuest: false, }), 'start', - 98 + 95 ), ]; diff --git a/extensions/messages/js/src/common/models/DialogMessage.ts b/extensions/messages/js/src/common/models/DialogMessage.ts index 3dcab66a9..4e88179b0 100644 --- a/extensions/messages/js/src/common/models/DialogMessage.ts +++ b/extensions/messages/js/src/common/models/DialogMessage.ts @@ -5,6 +5,9 @@ import type Dialog from './Dialog'; import type User from 'flarum/common/models/User'; export default class DialogMessage extends Model { + number() { + return Model.attribute('number').call(this); + } content() { return Model.attribute('content').call(this); } diff --git a/extensions/messages/js/src/forum/components/DialogSection.tsx b/extensions/messages/js/src/forum/components/DialogSection.tsx index b7d431a6c..56bdfc3d7 100644 --- a/extensions/messages/js/src/forum/components/DialogSection.tsx +++ b/extensions/messages/js/src/forum/components/DialogSection.tsx @@ -24,14 +24,27 @@ export default class DialogSection) { super.oninit(vnode); - this.messages = new MessageStreamState({ + this.messages = new MessageStreamState(this.requestParams()); + + this.messages.refresh(); + } + + requestParams(forgetNear = false): any { + const params: any = { filter: { dialog: this.attrs.dialog.id(), }, - sort: '-createdAt', - }); + sort: '-number', + }; - this.messages.refresh(); + const near = m.route.param('near'); + + if (near && !forgetNear) { + params.page = params.page || {}; + params.page.near = parseInt(near); + } + + return params; } view() { diff --git a/extensions/messages/js/src/forum/components/Message.tsx b/extensions/messages/js/src/forum/components/Message.tsx index c725a42c4..b62cbb523 100644 --- a/extensions/messages/js/src/forum/components/Message.tsx +++ b/extensions/messages/js/src/forum/components/Message.tsx @@ -105,7 +105,21 @@ export default abstract class Message, 100); - items.add('meta', ); + items.add( + 'meta', + { + const dialog = message.dialog(); + + if (!dialog) { + return null; + } + + return app.forum.attribute('baseOrigin') + app.route.dialog(dialog, message.number()); + }} + /> + ); return items; } diff --git a/extensions/messages/js/src/forum/components/MessageStream.tsx b/extensions/messages/js/src/forum/components/MessageStream.tsx index 45fe8881d..69949ae40 100644 --- a/extensions/messages/js/src/forum/components/MessageStream.tsx +++ b/extensions/messages/js/src/forum/components/MessageStream.tsx @@ -77,18 +77,20 @@ export default class MessageStream a.createdAt().getTime() - b.createdAt().getTime()); + const messages = Array.from(new Map(this.attrs.state.getAllItems().map((msg) => [msg.id(), msg])).values()).sort( + (a, b) => a.number() - b.number() + ); const ReplyPlaceholder = this.replyPlaceholderComponent(); const LoadingPost = this.loadingPostComponent(); if (messages[0].id() !== (this.attrs.dialog.data.relationships?.firstMessage.data as ModelIdentifier).id) { items.push( -
+
@@ -97,7 +99,7 @@ export default class MessageStream +
); @@ -106,6 +108,28 @@ export default class MessageStream items.push(this.messageItem(message, index))); + if (messages[messages.length - 1].id() !== (this.attrs.dialog.data.relationships?.lastMessage.data as ModelIdentifier).id) { + if (LoadingPost) { + items.push( +
+ +
+ ); + } + + items.push( +
+ +
+ ); + } + if (app.session.user!.canSendAnyMessage() && ReplyPlaceholder) { items.push(
@@ -135,7 +159,7 @@ export default class MessageStream +
{this.timeGap(message)}
@@ -177,7 +201,7 @@ export default class MessageStream= this.element.scrollHeight && this.attrs.state.hasPrev()) { return this.attrs.state.loadPrev(); } @@ -186,16 +210,34 @@ export default class MessageStream null | Promise) { const scrollTop = this.element.scrollTop; const scrollHeight = this.element.scrollHeight; + const closerToBottomThanTop = scrollTop > (scrollHeight - this.element.clientHeight) / 2; + const result = callback(); - if (result instanceof Promise) { + if (result instanceof Promise && !closerToBottomThanTop) { result.then(() => { requestAnimationFrame(() => { this.element.scrollTop = this.element.scrollHeight - scrollHeight + scrollTop; diff --git a/extensions/messages/js/src/forum/extend.ts b/extensions/messages/js/src/forum/extend.ts index f5ac30b31..542d36c63 100644 --- a/extensions/messages/js/src/forum/extend.ts +++ b/extensions/messages/js/src/forum/extend.ts @@ -9,5 +9,6 @@ export default [ new Extend.Routes() // .add('messages', '/messages', () => import('./components/MessagesPage')) .add('dialog', '/messages/dialog/:id', () => import('./components/MessagesPage')) - .helper('dialog', (dialog: Dialog) => app.route('dialog', { id: dialog.id() })), + .add('dialog.message', '/messages/dialog/:id/:near', () => import('./components/MessagesPage')) + .helper('dialog', (dialog: Dialog, near?: number) => app.route(near ? 'dialog.message' : 'dialog', { id: dialog.id(), near: near })), ]; diff --git a/extensions/messages/js/src/forum/states/MessageStreamState.ts b/extensions/messages/js/src/forum/states/MessageStreamState.ts index 1c196073f..646c3a6c6 100644 --- a/extensions/messages/js/src/forum/states/MessageStreamState.ts +++ b/extensions/messages/js/src/forum/states/MessageStreamState.ts @@ -1,5 +1,6 @@ import PaginatedListState, { PaginatedListParams } from 'flarum/common/states/PaginatedListState'; import DialogMessage from '../../common/models/DialogMessage'; +import { ApiQueryParamsPlural } from 'flarum/common/Store'; export interface MessageStreamParams extends PaginatedListParams { // diff --git a/extensions/messages/locale/en.yml b/extensions/messages/locale/en.yml index 34c891567..18ecc76d2 100644 --- a/extensions/messages/locale/en.yml +++ b/extensions/messages/locale/en.yml @@ -53,6 +53,7 @@ flarum-messages: send_message_button: Send a Message stream: load_previous_button: Load previous messages + load_next_button: Load next messages start_of_the_conversation: Start of the conversation time_lapsed_text: => core.forum.post_stream.time_lapsed_text title: Messages diff --git a/extensions/messages/migrations/2025_01_31_000000_add_number_to_dialog_messages.php b/extensions/messages/migrations/2025_01_31_000000_add_number_to_dialog_messages.php new file mode 100644 index 000000000..0232d5bd4 --- /dev/null +++ b/extensions/messages/migrations/2025_01_31_000000_add_number_to_dialog_messages.php @@ -0,0 +1,50 @@ + function (Builder $schema) { + $schema->table('dialog_messages', function (Blueprint $table) { + $table->unsignedBigInteger('number')->nullable()->after('content'); + }); + + $numbers = []; + + $schema->getConnection() + ->table('dialogs') + ->orderBy('id') + ->each(function (object $dialog) use ($schema, &$numbers) { + $numbers[$dialog->id] = 0; + + $schema->getConnection() + ->table('dialog_messages') + ->where('dialog_id', $dialog->id) + ->orderBy('id') + ->each(function (object $message) use ($schema, &$numbers) { + $schema->getConnection() + ->table('dialog_messages') + ->where('id', $message->id) + ->update(['number' => ++$numbers[$message->dialog_id]]); + }); + + unset($numbers[$dialog->id]); + }); + + $schema->table('dialog_messages', function (Blueprint $table) { + $table->unsignedBigInteger('number')->nullable(false)->change(); + }); + }, + 'down' => function (Builder $schema) { + $schema->table('dialog_messages', function (Blueprint $table) { + $table->dropColumn('number'); + }); + } +]; diff --git a/extensions/messages/src/Api/Resource/DialogMessageResource.php b/extensions/messages/src/Api/Resource/DialogMessageResource.php index 06a91522f..356c0d65e 100644 --- a/extensions/messages/src/Api/Resource/DialogMessageResource.php +++ b/extensions/messages/src/Api/Resource/DialogMessageResource.php @@ -27,6 +27,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Arr; use Illuminate\Support\Carbon; use Tobyz\JsonApiServer\Context as OriginalContext; +use Tobyz\JsonApiServer\Exception\BadRequestException; /** * @extends Resource\AbstractDatabaseResource @@ -86,6 +87,7 @@ class DialogMessageResource extends Resource\AbstractDatabaseResource 'mentionsGroups', 'mentionsTags', ]) + ->defaultSort('-number') ->eagerLoad(function () { if ($this->extensions->isEnabled('flarum-mentions')) { return ['mentionsUsers', 'mentionsPosts', 'mentionsGroups', 'mentionsTags']; @@ -93,6 +95,35 @@ class DialogMessageResource extends Resource\AbstractDatabaseResource return []; }) + ->extractOffset(function (Context $context, array $defaultExtracts): int { + $queryParams = $context->request->getQueryParams(); + $near = intval(Arr::get($queryParams, 'page.near')); + + if ($near > 1) { + $sort = $defaultExtracts['sort']; + $filter = $defaultExtracts['filter']; + $dialogId = $filter['dialog'] ?? null; + + if (count($filter) > 1 || ! $dialogId || ($sort && $sort !== ['number' => 'desc'])) { + throw new BadRequestException( + 'You can only use page[near] with filter[dialog] and the default sort order' + ); + } + + $limit = $defaultExtracts['limit']; + + $index = DialogMessage::query() + ->where('dialog_id', $dialogId) + ->where('number', '>=', $near) + ->orderBy('number', 'desc') + ->whereVisibleTo($context->getActor()) + ->count(); + + return max(0, $index - $limit / 2); + } + + return $defaultExtracts['offset']; + }) ->paginate(), ]; } @@ -101,6 +132,7 @@ class DialogMessageResource extends Resource\AbstractDatabaseResource { return [ + Schema\Number::make('number'), Schema\Str::make('content') ->requiredOnCreate() ->writableOnCreate() @@ -161,7 +193,7 @@ class DialogMessageResource extends Resource\AbstractDatabaseResource public function sorts(): array { return [ - SortColumn::make('createdAt'), + SortColumn::make('number'), ]; } diff --git a/extensions/messages/src/DialogMessage.php b/extensions/messages/src/DialogMessage.php index 411b4620b..ae5ae99e3 100644 --- a/extensions/messages/src/DialogMessage.php +++ b/extensions/messages/src/DialogMessage.php @@ -21,12 +21,14 @@ use Flarum\Tags\Tag; use Flarum\User\User; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Query\Expression; /** * @property int $id * @property int $dialog_id * @property int|null $user_id * @property string $content + * @property int|Expression $number * @property \Carbon\Carbon $created_at * @property \Carbon\Carbon $updated_at * @property-read Dialog $dialog @@ -48,6 +50,28 @@ class DialogMessage extends AbstractModel implements Formattable protected $guarded = []; + protected $casts = [ + 'dialog_id' => 'integer', + 'user_id' => 'integer', + 'number' => 'integer', + ]; + + public static function boot() + { + parent::boot(); + + static::creating(function (self $message) { + $db = static::getConnectionResolver()->connection(); + + $message->number = new Expression('('. + $db->table('dialog_messages', 'dm') + ->whereRaw($db->getTablePrefix().'dm.dialog_id = '.intval($message->dialog_id)) + ->selectRaw('COALESCE(MAX('.$db->getTablePrefix().'dm.number), 0) + 1') + ->toSql() + .')'); + }); + } + public function dialog(): BelongsTo { return $this->belongsTo(Dialog::class); diff --git a/extensions/messages/tests/integration/api/ListTest.php b/extensions/messages/tests/integration/api/ListTest.php index 2265f1474..d60e3d680 100644 --- a/extensions/messages/tests/integration/api/ListTest.php +++ b/extensions/messages/tests/integration/api/ListTest.php @@ -39,12 +39,12 @@ class ListTest extends TestCase ['id' => 104, 'type' => 'direct'], ], DialogMessage::class => [ - ['id' => 102, 'dialog_id' => 102, 'user_id' => 3, 'content' => 'Hello, Gale!'], - ['id' => 103, 'dialog_id' => 102, 'user_id' => 4, 'content' => 'Hello, Astarion!'], - ['id' => 104, 'dialog_id' => 103, 'user_id' => 3, 'content' => 'Hello, Karlach!'], - ['id' => 105, 'dialog_id' => 103, 'user_id' => 5, 'content' => 'Hello, Astarion!'], - ['id' => 106, 'dialog_id' => 104, 'user_id' => 4, 'content' => 'Hello, Karlach!'], - ['id' => 107, 'dialog_id' => 104, 'user_id' => 5, 'content' => 'Hello, Gale!'], + ['id' => 102, 'dialog_id' => 102, 'user_id' => 3, 'content' => 'Hello, Gale!', 'number' => 1], + ['id' => 103, 'dialog_id' => 102, 'user_id' => 4, 'content' => 'Hello, Astarion!', 'number' => 2], + ['id' => 104, 'dialog_id' => 103, 'user_id' => 3, 'content' => 'Hello, Karlach!', 'number' => 1], + ['id' => 105, 'dialog_id' => 103, 'user_id' => 5, 'content' => 'Hello, Astarion!', 'number' => 2], + ['id' => 106, 'dialog_id' => 104, 'user_id' => 4, 'content' => 'Hello, Karlach!', 'number' => 1], + ['id' => 107, 'dialog_id' => 104, 'user_id' => 5, 'content' => 'Hello, Gale!', 'number' => 2], ], 'dialog_user' => [ ['dialog_id' => 102, 'user_id' => 3, 'joined_at' => Carbon::now()], @@ -125,4 +125,49 @@ class ListTest extends TestCase 'Karlach can see messages in dialogs with Astarion and Gale' => [5, [104, 105, 106, 107]], ]; } + + public function test_can_list_near_accessible_dialog_messages(): void + { + $messages = []; + + for ($i = 1; $i <= 40; $i++) { + $messages[] = ['id' => 200 + $i, 'dialog_id' => 200, 'user_id' => $i % 2 === 0 ? 3 : 4, 'content' => 'Hello, Gale!', 'number' => $i]; + } + + $this->prepareDatabase([ + Dialog::class => [ + ['id' => 200, 'type' => 'direct'], + ], + DialogMessage::class => $messages, + 'dialog_user' => [ + ['dialog_id' => 200, 'user_id' => 3, 'joined_at' => Carbon::now()], + ['dialog_id' => 200, 'user_id' => 4, 'joined_at' => Carbon::now()], + ], + ]); + + $this->database()->table('dialogs')->where('id', '!=', 200)->delete(); + $this->database()->table('dialog_messages')->where('dialog_id', '!=', 200)->delete(); + + $response = $this->send( + $this->request('GET', '/api/dialog-messages', [ + 'authenticatedAs' => 3, + ])->withQueryParams([ + 'include' => 'dialog', + 'page' => ['near' => 10], + 'filter' => ['dialog' => 200], + ]), + ); + + $json = $response->getBody()->getContents(); + $prettyJson = json_encode($json, JSON_PRETTY_PRINT); + + $this->assertEquals(200, $response->getStatusCode(), $prettyJson); + $this->assertJson($json); + + $data = json_decode($json, true)['data']; + $prettyJson = json_encode(json_decode($json), JSON_PRETTY_PRINT); + + $this->assertEquals(40, $this->database()->table('dialog_messages')->count()); + $this->assertCount(19, $data, $prettyJson); + } } diff --git a/extensions/messages/tests/integration/api/dialog_messages/CreateTest.php b/extensions/messages/tests/integration/api/dialog_messages/CreateTest.php index 902158d89..18ac78ae8 100644 --- a/extensions/messages/tests/integration/api/dialog_messages/CreateTest.php +++ b/extensions/messages/tests/integration/api/dialog_messages/CreateTest.php @@ -36,7 +36,7 @@ class CreateTest extends TestCase ['id' => 102, 'type' => 'direct'], ], DialogMessage::class => [ - ['id' => 102, 'dialog_id' => 102, 'user_id' => 4, 'content' => 'Hello, Karlach!'], + ['id' => 102, 'dialog_id' => 102, 'user_id' => 4, 'content' => 'Hello, Karlach!', 'number' => 1], ], 'dialog_user' => [ ['dialog_id' => 102, 'user_id' => 4, 'joined_at' => Carbon::now()], diff --git a/extensions/messages/tests/integration/api/dialogs/UpdateTest.php b/extensions/messages/tests/integration/api/dialogs/UpdateTest.php index 9f1b63b2f..e681ace1f 100644 --- a/extensions/messages/tests/integration/api/dialogs/UpdateTest.php +++ b/extensions/messages/tests/integration/api/dialogs/UpdateTest.php @@ -37,16 +37,16 @@ class UpdateTest extends TestCase ['id' => 102, 'type' => 'direct', 'last_message_id' => 111], ], DialogMessage::class => [ - ['id' => 102, 'dialog_id' => 102, 'user_id' => 4, 'content' => '

Hello, Alice!

'], - ['id' => 103, 'dialog_id' => 102, 'user_id' => 3, 'content' => '

Hello, Bob!

'], - ['id' => 104, 'dialog_id' => 102, 'user_id' => 4, 'content' => '

Hello, Alice!

'], - ['id' => 105, 'dialog_id' => 102, 'user_id' => 3, 'content' => '

Hello, Bob!

'], - ['id' => 106, 'dialog_id' => 102, 'user_id' => 4, 'content' => '

Hello, Alice!

'], - ['id' => 107, 'dialog_id' => 102, 'user_id' => 3, 'content' => '

Hello, Bob!

'], - ['id' => 108, 'dialog_id' => 102, 'user_id' => 4, 'content' => '

Hello, Alice!

'], - ['id' => 109, 'dialog_id' => 102, 'user_id' => 3, 'content' => '

Hello, Bob!

'], - ['id' => 110, 'dialog_id' => 102, 'user_id' => 4, 'content' => '

Hello, Alice!

'], - ['id' => 111, 'dialog_id' => 102, 'user_id' => 3, 'content' => '

Hello, Bob!

'], + ['id' => 102, 'dialog_id' => 102, 'user_id' => 4, 'content' => '

Hello, Alice!

', 'number' => 1], + ['id' => 103, 'dialog_id' => 102, 'user_id' => 3, 'content' => '

Hello, Bob!

', 'number' => 2], + ['id' => 104, 'dialog_id' => 102, 'user_id' => 4, 'content' => '

Hello, Alice!

', 'number' => 3], + ['id' => 105, 'dialog_id' => 102, 'user_id' => 3, 'content' => '

Hello, Bob!

', 'number' => 4], + ['id' => 106, 'dialog_id' => 102, 'user_id' => 4, 'content' => '

Hello, Alice!

', 'number' => 5], + ['id' => 107, 'dialog_id' => 102, 'user_id' => 3, 'content' => '

Hello, Bob!

', 'number' => 6], + ['id' => 108, 'dialog_id' => 102, 'user_id' => 4, 'content' => '

Hello, Alice!

', 'number' => 7], + ['id' => 109, 'dialog_id' => 102, 'user_id' => 3, 'content' => '

Hello, Bob!

', 'number' => 8], + ['id' => 110, 'dialog_id' => 102, 'user_id' => 4, 'content' => '

Hello, Alice!

', 'number' => 9], + ['id' => 111, 'dialog_id' => 102, 'user_id' => 3, 'content' => '

Hello, Bob!

', 'number' => 10], ], 'dialog_user' => [ ['dialog_id' => 102, 'user_id' => 3, 'last_read_message_id' => 0, 'last_read_at' => null, 'joined_at' => Carbon::now()], diff --git a/framework/core/js/src/common/states/PaginatedListState.ts b/framework/core/js/src/common/states/PaginatedListState.ts index d3de7b9ff..42c5aa661 100644 --- a/framework/core/js/src/common/states/PaginatedListState.ts +++ b/framework/core/js/src/common/states/PaginatedListState.ts @@ -1,8 +1,7 @@ import app from '../../common/app'; -import Model from '../Model'; -import { ApiQueryParamsPlural, ApiResponsePlural } from '../Store'; +import type Model from '../Model'; +import type { ApiQueryParamsPlural, ApiResponsePlural } from '../Store'; import type Mithril from 'mithril'; -import setRouteWithForcedRefresh from '../utils/setRouteWithForcedRefresh'; export type SortMapItem = | string @@ -15,9 +14,9 @@ export type SortMap = { [key: string]: SortMapItem; }; -export interface Page { +export interface Page { number: number; - items: TModel[]; + items: ApiResponsePlural | TModel[]; hasPrev?: boolean; hasNext?: boolean; @@ -73,7 +72,7 @@ export default abstract class PaginatedListState { - if (this.loadingPrev || this.getLocation().page === 1) return Promise.resolve(); + if (this.loadingPrev || !this.hasPrev()) return Promise.resolve(); this.loadingPrev = true; @@ -140,7 +139,7 @@ export default abstract class PaginatedListState(this.type, params).then((results) => { + return app.store.find(this.type, this.mutateRequestParams(params, page)).then((results) => { const usedPerPage = results.payload?.meta?.perPage; const usedTotal = results.payload?.meta?.page?.total; @@ -160,6 +159,35 @@ export default abstract class PaginatedListState { + if ('payload' in page.items) { + return page.items.payload.meta?.page?.offset || 0; + } + + return 0; + }); + + const minOffset = Math.min(...offsets); + const maxOffset = Math.max(...offsets); + + const limit = this.pageSize || PaginatedListState.DEFAULT_PAGE_SIZE; + + params.page ||= {}; + params.page.offset = nextPage ? maxOffset + limit : Math.max(minOffset - limit, 0); + } + + return params; + } + /** * Get the parameters that should be passed in the API request. * Do not include page offset unless subclass overrides loadPage. diff --git a/framework/core/src/Api/Endpoint/Index.php b/framework/core/src/Api/Endpoint/Index.php index c6f815f7c..4282d5a0c 100644 --- a/framework/core/src/Api/Endpoint/Index.php +++ b/framework/core/src/Api/Endpoint/Index.php @@ -33,7 +33,6 @@ use Tobyz\JsonApiServer\Pagination\Pagination; use Tobyz\JsonApiServer\Schema\Concerns\HasMeta; use function Tobyz\JsonApiServer\json_api_response; -use function Tobyz\JsonApiServer\parse_sort_string; class Index extends Endpoint { @@ -69,24 +68,18 @@ class Index extends Endpoint protected function setUp(): void { $this->route('GET', '/') - ->query(function ($query, ?Pagination $pagination, Context $context): Context { + ->query(function ($query, ?Pagination $pagination, Context $context, array $filters, ?array $sort, int $offset, ?int $limit): Context { $collection = $context->collection; // This model has a searcher API, so we'll use that instead of the default. // The searcher API allows swapping the default search engine for a custom one. + /** @var SearchManager $search */ $search = $context->api->getContainer()->make(SearchManager::class); $modelClass = $collection instanceof AbstractDatabaseResource ? $collection->model() : null; if ($query instanceof Builder && $search->searchable($modelClass)) { $actor = $context->getActor(); - $extracts = $this->defaultExtracts($context); - - $filters = $this->extractFilterValue($context, $extracts); - $sort = $this->extractSortValue($context, $extracts); - $limit = $this->extractLimitValue($context, $extracts); - $offset = $this->extractOffsetValue($context, $extracts); - $sortIsDefault = ! $context->queryParam('sort'); $results = $search->query( @@ -100,8 +93,8 @@ class Index extends Endpoint else { $context = $context->withQuery($query); - $this->applySorts($query, $context); - $this->applyFilters($query, $context); + $this->applySorts($query, $context, $sort); + $this->applyFilters($query, $context, $filters); if ($pagination && method_exists($pagination, 'apply')) { $pagination->apply($query); @@ -129,8 +122,20 @@ class Index extends Endpoint $pagination = ($this->paginationResolver)($context); + $extracts = $this->defaultExtracts($context); + + $filters = $this->extractFilterValue($context, $extracts); + $sort = $this->extractSortValue($context, $extracts); + $limit = $this->extractLimitValue($context, $extracts); + $offset = $this->extractOffsetValue($context, $extracts); + + if ($pagination instanceof OffsetPagination) { + $pagination->offset = $offset; + $pagination->limit = $limit; + } + if ($this->query) { - $context = ($this->query)($query, $pagination, $context); + $context = ($this->query)($query, $pagination, $context, $filters, $sort, $offset, $limit); if (! $context instanceof Context) { throw new RuntimeException('The Index endpoint query closure must return a Context instance.'); @@ -139,8 +144,8 @@ class Index extends Endpoint /** @var Context $context */ $context = $context->withQuery($query); - $this->applySorts($query, $context); - $this->applyFilters($query, $context); + $this->applySorts($query, $context, $sort); + $this->applyFilters($query, $context, $filters); if ($pagination) { $pagination->apply($query); @@ -205,9 +210,9 @@ class Index extends Endpoint return $this; } - final protected function applySorts($query, Context $context): void + final protected function applySorts($query, Context $context, ?array $sort): void { - if (! ($sortString = $context->queryParam('sort', $this->defaultSort))) { + if (! $sort) { return; } @@ -219,7 +224,7 @@ class Index extends Endpoint $sorts = $collection->resolveSorts(); - foreach (parse_sort_string($sortString) as [$name, $direction]) { + foreach ($sort as $name => $direction) { foreach ($sorts as $field) { if ($field->name === $name && $field->isVisible($context)) { $field->apply($query, $direction, $context); @@ -233,18 +238,12 @@ class Index extends Endpoint } } - final protected function applyFilters($query, Context $context): void + final protected function applyFilters($query, Context $context, array $filters): void { - if (! ($filters = $context->queryParam('filter'))) { + if (empty($filters)) { return; } - if (! is_array($filters)) { - throw (new BadRequestException('filter must be an array'))->setSource([ - 'parameter' => 'filter', - ]); - } - $collection = $context->collection; if (! $collection instanceof Listable) { diff --git a/framework/core/tests/integration/api/groups/ListTest.php b/framework/core/tests/integration/api/groups/ListTest.php index c6524ee13..5744b47d1 100644 --- a/framework/core/tests/integration/api/groups/ListTest.php +++ b/framework/core/tests/integration/api/groups/ListTest.php @@ -40,8 +40,10 @@ class ListTest extends TestCase $this->request('GET', '/api/groups') ); - $this->assertEquals(200, $response->getStatusCode()); - $data = json_decode($response->getBody()->getContents(), true); + $body = $response->getBody()->getContents(); + + $this->assertEquals(200, $response->getStatusCode(), $body); + $data = json_decode($body, true); // The four default groups created by the installer $this->assertEquals(['1', '2', '3', '4'], Arr::pluck($data['data'], 'id'));