1
0
mirror of https://github.com/flarum/core.git synced 2025-08-04 23:47:32 +02:00

feat(pm): messages anchor link (#4175)

This commit is contained in:
Sami Mazouz
2025-02-08 18:30:35 +01:00
committed by GitHub
parent 333bbb11e2
commit db1e36d545
19 changed files with 324 additions and 69 deletions

View File

@@ -24,7 +24,7 @@ return [
->css(__DIR__.'/less/forum.less') ->css(__DIR__.'/less/forum.less')
->jsDirectory(__DIR__.'/js/dist/forum') ->jsDirectory(__DIR__.'/js/dist/forum')
->route('/messages', 'messages') ->route('/messages', 'messages')
->route('/messages/dialog/{id:\d+}', 'messages.dialog'), ->route('/messages/dialog/{id:\d+}[/{near:\d+}]', 'messages.dialog'),
(new Extend\Frontend('admin')) (new Extend\Frontend('admin'))
->js(__DIR__.'/js/dist/admin.js') ->js(__DIR__.'/js/dist/admin.js')

View File

@@ -3,7 +3,7 @@ import DialogListState from '../forum/states/DialogListState';
declare module 'flarum/forum/routes' { declare module 'flarum/forum/routes' {
export interface ForumRoutes { export interface ForumRoutes {
dialog: (tag: Dialog) => string; dialog: (dialog: Dialog, near?: number) => string;
} }
} }

View File

@@ -13,6 +13,6 @@ export default [
allowGuest: false, allowGuest: false,
}), }),
'start', 'start',
98 95
), ),
]; ];

View File

@@ -5,6 +5,9 @@ import type Dialog from './Dialog';
import type User from 'flarum/common/models/User'; import type User from 'flarum/common/models/User';
export default class DialogMessage extends Model { export default class DialogMessage extends Model {
number() {
return Model.attribute<number>('number').call(this);
}
content() { content() {
return Model.attribute<string | null | undefined>('content').call(this); return Model.attribute<string | null | undefined>('content').call(this);
} }

View File

@@ -24,14 +24,27 @@ export default class DialogSection<CustomAttrs extends IDialogStreamAttrs = IDia
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) { oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
super.oninit(vnode); super.oninit(vnode);
this.messages = new MessageStreamState({ this.messages = new MessageStreamState(this.requestParams());
this.messages.refresh();
}
requestParams(forgetNear = false): any {
const params: any = {
filter: { filter: {
dialog: this.attrs.dialog.id(), 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() { view() {

View File

@@ -105,7 +105,21 @@ export default abstract class Message<CustomAttrs extends IMessageAttrs = IMessa
const message = this.attrs.message; const message = this.attrs.message;
items.add('user', <PostUser post={message} />, 100); items.add('user', <PostUser post={message} />, 100);
items.add('meta', <PostMeta post={message} />); items.add(
'meta',
<PostMeta
post={message}
permalink={() => {
const dialog = message.dialog();
if (!dialog) {
return null;
}
return app.forum.attribute('baseOrigin') + app.route.dialog(dialog, message.number());
}}
/>
);
return items; return items;
} }

View File

@@ -77,18 +77,20 @@ export default class MessageStream<CustomAttrs extends IDialogStreamAttrs = IDia
content() { content() {
const items: Mithril.Children[] = []; const items: Mithril.Children[] = [];
const messages = this.attrs.state.getAllItems().sort((a, b) => 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 ReplyPlaceholder = this.replyPlaceholderComponent();
const LoadingPost = this.loadingPostComponent(); const LoadingPost = this.loadingPostComponent();
if (messages[0].id() !== (this.attrs.dialog.data.relationships?.firstMessage.data as ModelIdentifier).id) { if (messages[0].id() !== (this.attrs.dialog.data.relationships?.firstMessage.data as ModelIdentifier).id) {
items.push( items.push(
<div className="MessageStream-item" key="loadPrevious"> <div className="MessageStream-item" key="loadNext">
<Button <Button
onclick={() => this.whileMaintainingScroll(() => this.attrs.state.loadNext())} onclick={() => this.whileMaintainingScroll(() => this.attrs.state.loadNext())}
type="button" type="button"
className="Button Button--block MessageStream-loadPrev" className="Button Button--block MessageStream-loadNext"
> >
{app.translator.trans('flarum-messages.forum.messages_page.stream.load_previous_button')} {app.translator.trans('flarum-messages.forum.messages_page.stream.load_previous_button')}
</Button> </Button>
@@ -97,7 +99,7 @@ export default class MessageStream<CustomAttrs extends IDialogStreamAttrs = IDia
if (LoadingPost) { if (LoadingPost) {
items.push( items.push(
<div className="MessageStream-item" key="loading-prev"> <div className="MessageStream-item" key="loading-next">
<LoadingPost /> <LoadingPost />
</div> </div>
); );
@@ -106,6 +108,28 @@ export default class MessageStream<CustomAttrs extends IDialogStreamAttrs = IDia
messages.forEach((message, index) => items.push(this.messageItem(message, index))); messages.forEach((message, index) => 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(
<div className="MessageStream-item" key="loading-prev">
<LoadingPost />
</div>
);
}
items.push(
<div className="MessageStream-item" key="loadPrev">
<Button
onclick={() => this.whileMaintainingScroll(() => this.attrs.state.loadPrev())}
type="button"
className="Button Button--block MessageStream-loadPrev"
>
{app.translator.trans('flarum-messages.forum.messages_page.stream.load_next_button')}
</Button>
</div>
);
}
if (app.session.user!.canSendAnyMessage() && ReplyPlaceholder) { if (app.session.user!.canSendAnyMessage() && ReplyPlaceholder) {
items.push( items.push(
<div className="MessageStream-item" key="reply"> <div className="MessageStream-item" key="reply">
@@ -135,7 +159,7 @@ export default class MessageStream<CustomAttrs extends IDialogStreamAttrs = IDia
messageItem(message: DialogMessage, index: number) { messageItem(message: DialogMessage, index: number) {
return ( return (
<div className="MessageStream-item" key={index} data-id={message.id()}> <div className="MessageStream-item" key={index} data-id={message.id()} data-number={message.number()}>
{this.timeGap(message)} {this.timeGap(message)}
<Message message={message} /> <Message message={message} />
</div> </div>
@@ -177,7 +201,7 @@ export default class MessageStream<CustomAttrs extends IDialogStreamAttrs = IDia
return this.attrs.state.loadNext(); return this.attrs.state.loadNext();
} }
if (this.element.scrollTop + this.element.clientHeight === this.element.scrollHeight && this.attrs.state.hasPrev()) { if (this.element.scrollTop + this.element.clientHeight >= this.element.scrollHeight && this.attrs.state.hasPrev()) {
return this.attrs.state.loadPrev(); return this.attrs.state.loadPrev();
} }
@@ -186,16 +210,34 @@ export default class MessageStream<CustomAttrs extends IDialogStreamAttrs = IDia
} }
scrollToBottom() { scrollToBottom() {
this.element.scrollTop = this.element.scrollHeight; const near = m.route.param('near');
if (near) {
const $message = this.element.querySelector(`.MessageStream-item[data-number="${near}"]`);
if ($message) {
this.element.scrollTop = $message.getBoundingClientRect().top - this.element.getBoundingClientRect().top;
$message.classList.add('flash');
// forget near
window.history.replaceState(null, '', app.route.dialog(this.attrs.dialog));
} else {
this.element.scrollTop = this.element.scrollHeight;
}
} else {
this.element.scrollTop = this.element.scrollHeight;
}
} }
whileMaintainingScroll(callback: () => null | Promise<void>) { whileMaintainingScroll(callback: () => null | Promise<void>) {
const scrollTop = this.element.scrollTop; const scrollTop = this.element.scrollTop;
const scrollHeight = this.element.scrollHeight; const scrollHeight = this.element.scrollHeight;
const closerToBottomThanTop = scrollTop > (scrollHeight - this.element.clientHeight) / 2;
const result = callback(); const result = callback();
if (result instanceof Promise) { if (result instanceof Promise && !closerToBottomThanTop) {
result.then(() => { result.then(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
this.element.scrollTop = this.element.scrollHeight - scrollHeight + scrollTop; this.element.scrollTop = this.element.scrollHeight - scrollHeight + scrollTop;

View File

@@ -9,5 +9,6 @@ export default [
new Extend.Routes() // new Extend.Routes() //
.add('messages', '/messages', () => import('./components/MessagesPage')) .add('messages', '/messages', () => import('./components/MessagesPage'))
.add('dialog', '/messages/dialog/:id', () => 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 })),
]; ];

View File

@@ -1,5 +1,6 @@
import PaginatedListState, { PaginatedListParams } from 'flarum/common/states/PaginatedListState'; import PaginatedListState, { PaginatedListParams } from 'flarum/common/states/PaginatedListState';
import DialogMessage from '../../common/models/DialogMessage'; import DialogMessage from '../../common/models/DialogMessage';
import { ApiQueryParamsPlural } from 'flarum/common/Store';
export interface MessageStreamParams extends PaginatedListParams { export interface MessageStreamParams extends PaginatedListParams {
// //

View File

@@ -53,6 +53,7 @@ flarum-messages:
send_message_button: Send a Message send_message_button: Send a Message
stream: stream:
load_previous_button: Load previous messages load_previous_button: Load previous messages
load_next_button: Load next messages
start_of_the_conversation: Start of the conversation start_of_the_conversation: Start of the conversation
time_lapsed_text: => core.forum.post_stream.time_lapsed_text time_lapsed_text: => core.forum.post_stream.time_lapsed_text
title: Messages title: Messages

View File

@@ -0,0 +1,50 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Schema\Builder;
return [
'up' => 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');
});
}
];

View File

@@ -27,6 +27,7 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Tobyz\JsonApiServer\Context as OriginalContext; use Tobyz\JsonApiServer\Context as OriginalContext;
use Tobyz\JsonApiServer\Exception\BadRequestException;
/** /**
* @extends Resource\AbstractDatabaseResource<DialogMessage> * @extends Resource\AbstractDatabaseResource<DialogMessage>
@@ -86,6 +87,7 @@ class DialogMessageResource extends Resource\AbstractDatabaseResource
'mentionsGroups', 'mentionsGroups',
'mentionsTags', 'mentionsTags',
]) ])
->defaultSort('-number')
->eagerLoad(function () { ->eagerLoad(function () {
if ($this->extensions->isEnabled('flarum-mentions')) { if ($this->extensions->isEnabled('flarum-mentions')) {
return ['mentionsUsers', 'mentionsPosts', 'mentionsGroups', 'mentionsTags']; return ['mentionsUsers', 'mentionsPosts', 'mentionsGroups', 'mentionsTags'];
@@ -93,6 +95,35 @@ class DialogMessageResource extends Resource\AbstractDatabaseResource
return []; 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(), ->paginate(),
]; ];
} }
@@ -101,6 +132,7 @@ class DialogMessageResource extends Resource\AbstractDatabaseResource
{ {
return [ return [
Schema\Number::make('number'),
Schema\Str::make('content') Schema\Str::make('content')
->requiredOnCreate() ->requiredOnCreate()
->writableOnCreate() ->writableOnCreate()
@@ -161,7 +193,7 @@ class DialogMessageResource extends Resource\AbstractDatabaseResource
public function sorts(): array public function sorts(): array
{ {
return [ return [
SortColumn::make('createdAt'), SortColumn::make('number'),
]; ];
} }

View File

@@ -21,12 +21,14 @@ use Flarum\Tags\Tag;
use Flarum\User\User; use Flarum\User\User;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Query\Expression;
/** /**
* @property int $id * @property int $id
* @property int $dialog_id * @property int $dialog_id
* @property int|null $user_id * @property int|null $user_id
* @property string $content * @property string $content
* @property int|Expression $number
* @property \Carbon\Carbon $created_at * @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at * @property \Carbon\Carbon $updated_at
* @property-read Dialog $dialog * @property-read Dialog $dialog
@@ -48,6 +50,28 @@ class DialogMessage extends AbstractModel implements Formattable
protected $guarded = []; 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 public function dialog(): BelongsTo
{ {
return $this->belongsTo(Dialog::class); return $this->belongsTo(Dialog::class);

View File

@@ -39,12 +39,12 @@ class ListTest extends TestCase
['id' => 104, 'type' => 'direct'], ['id' => 104, 'type' => 'direct'],
], ],
DialogMessage::class => [ DialogMessage::class => [
['id' => 102, 'dialog_id' => 102, 'user_id' => 3, '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!'], ['id' => 103, 'dialog_id' => 102, 'user_id' => 4, 'content' => 'Hello, Astarion!', 'number' => 2],
['id' => 104, 'dialog_id' => 103, 'user_id' => 3, 'content' => 'Hello, Karlach!'], ['id' => 104, 'dialog_id' => 103, 'user_id' => 3, 'content' => 'Hello, Karlach!', 'number' => 1],
['id' => 105, 'dialog_id' => 103, 'user_id' => 5, 'content' => 'Hello, Astarion!'], ['id' => 105, 'dialog_id' => 103, 'user_id' => 5, 'content' => 'Hello, Astarion!', 'number' => 2],
['id' => 106, 'dialog_id' => 104, 'user_id' => 4, 'content' => 'Hello, Karlach!'], ['id' => 106, 'dialog_id' => 104, 'user_id' => 4, 'content' => 'Hello, Karlach!', 'number' => 1],
['id' => 107, 'dialog_id' => 104, 'user_id' => 5, 'content' => 'Hello, Gale!'], ['id' => 107, 'dialog_id' => 104, 'user_id' => 5, 'content' => 'Hello, Gale!', 'number' => 2],
], ],
'dialog_user' => [ 'dialog_user' => [
['dialog_id' => 102, 'user_id' => 3, 'joined_at' => Carbon::now()], ['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]], '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' => '<t>Hello, Gale!</t>', '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);
}
} }

View File

@@ -36,7 +36,7 @@ class CreateTest extends TestCase
['id' => 102, 'type' => 'direct'], ['id' => 102, 'type' => 'direct'],
], ],
DialogMessage::class => [ 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_user' => [
['dialog_id' => 102, 'user_id' => 4, 'joined_at' => Carbon::now()], ['dialog_id' => 102, 'user_id' => 4, 'joined_at' => Carbon::now()],

View File

@@ -37,16 +37,16 @@ class UpdateTest extends TestCase
['id' => 102, 'type' => 'direct', 'last_message_id' => 111], ['id' => 102, 'type' => 'direct', 'last_message_id' => 111],
], ],
DialogMessage::class => [ DialogMessage::class => [
['id' => 102, 'dialog_id' => 102, 'user_id' => 4, 'content' => '<p>Hello, Alice!</p>'], ['id' => 102, 'dialog_id' => 102, 'user_id' => 4, 'content' => '<p>Hello, Alice!</p>', 'number' => 1],
['id' => 103, 'dialog_id' => 102, 'user_id' => 3, 'content' => '<p>Hello, Bob!</p>'], ['id' => 103, 'dialog_id' => 102, 'user_id' => 3, 'content' => '<p>Hello, Bob!</p>', 'number' => 2],
['id' => 104, 'dialog_id' => 102, 'user_id' => 4, 'content' => '<p>Hello, Alice!</p>'], ['id' => 104, 'dialog_id' => 102, 'user_id' => 4, 'content' => '<p>Hello, Alice!</p>', 'number' => 3],
['id' => 105, 'dialog_id' => 102, 'user_id' => 3, 'content' => '<p>Hello, Bob!</p>'], ['id' => 105, 'dialog_id' => 102, 'user_id' => 3, 'content' => '<p>Hello, Bob!</p>', 'number' => 4],
['id' => 106, 'dialog_id' => 102, 'user_id' => 4, 'content' => '<p>Hello, Alice!</p>'], ['id' => 106, 'dialog_id' => 102, 'user_id' => 4, 'content' => '<p>Hello, Alice!</p>', 'number' => 5],
['id' => 107, 'dialog_id' => 102, 'user_id' => 3, 'content' => '<p>Hello, Bob!</p>'], ['id' => 107, 'dialog_id' => 102, 'user_id' => 3, 'content' => '<p>Hello, Bob!</p>', 'number' => 6],
['id' => 108, 'dialog_id' => 102, 'user_id' => 4, 'content' => '<p>Hello, Alice!</p>'], ['id' => 108, 'dialog_id' => 102, 'user_id' => 4, 'content' => '<p>Hello, Alice!</p>', 'number' => 7],
['id' => 109, 'dialog_id' => 102, 'user_id' => 3, 'content' => '<p>Hello, Bob!</p>'], ['id' => 109, 'dialog_id' => 102, 'user_id' => 3, 'content' => '<p>Hello, Bob!</p>', 'number' => 8],
['id' => 110, 'dialog_id' => 102, 'user_id' => 4, 'content' => '<p>Hello, Alice!</p>'], ['id' => 110, 'dialog_id' => 102, 'user_id' => 4, 'content' => '<p>Hello, Alice!</p>', 'number' => 9],
['id' => 111, 'dialog_id' => 102, 'user_id' => 3, 'content' => '<p>Hello, Bob!</p>'], ['id' => 111, 'dialog_id' => 102, 'user_id' => 3, 'content' => '<p>Hello, Bob!</p>', 'number' => 10],
], ],
'dialog_user' => [ 'dialog_user' => [
['dialog_id' => 102, 'user_id' => 3, 'last_read_message_id' => 0, 'last_read_at' => null, 'joined_at' => Carbon::now()], ['dialog_id' => 102, 'user_id' => 3, 'last_read_message_id' => 0, 'last_read_at' => null, 'joined_at' => Carbon::now()],

View File

@@ -1,8 +1,7 @@
import app from '../../common/app'; import app from '../../common/app';
import Model from '../Model'; import type Model from '../Model';
import { ApiQueryParamsPlural, ApiResponsePlural } from '../Store'; import type { ApiQueryParamsPlural, ApiResponsePlural } from '../Store';
import type Mithril from 'mithril'; import type Mithril from 'mithril';
import setRouteWithForcedRefresh from '../utils/setRouteWithForcedRefresh';
export type SortMapItem = export type SortMapItem =
| string | string
@@ -15,9 +14,9 @@ export type SortMap = {
[key: string]: SortMapItem; [key: string]: SortMapItem;
}; };
export interface Page<TModel> { export interface Page<TModel extends Model> {
number: number; number: number;
items: TModel[]; items: ApiResponsePlural<TModel> | TModel[];
hasPrev?: boolean; hasPrev?: boolean;
hasNext?: boolean; hasNext?: boolean;
@@ -73,7 +72,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
} }
public loadPrev(): Promise<void> { public loadPrev(): Promise<void> {
if (this.loadingPrev || this.getLocation().page === 1) return Promise.resolve(); if (this.loadingPrev || !this.hasPrev()) return Promise.resolve();
this.loadingPrev = true; this.loadingPrev = true;
@@ -140,7 +139,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
delete params.include; delete params.include;
} }
return app.store.find<T[]>(this.type, params).then((results) => { return app.store.find<T[]>(this.type, this.mutateRequestParams(params, page)).then((results) => {
const usedPerPage = results.payload?.meta?.perPage; const usedPerPage = results.payload?.meta?.perPage;
const usedTotal = results.payload?.meta?.page?.total; const usedTotal = results.payload?.meta?.page?.total;
@@ -160,6 +159,35 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
}); });
} }
protected mutateRequestParams(params: ApiQueryParamsPlural, page: number): ApiQueryParamsPlural {
/*
* Support use of page[near]=
*/
if (params.page?.near && this.hasItems()) {
delete params.page?.near;
const nextPage = this.location.page < page;
const offsets = this.getPages().map((page) => {
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. * Get the parameters that should be passed in the API request.
* Do not include page offset unless subclass overrides loadPage. * Do not include page offset unless subclass overrides loadPage.

View File

@@ -33,7 +33,6 @@ use Tobyz\JsonApiServer\Pagination\Pagination;
use Tobyz\JsonApiServer\Schema\Concerns\HasMeta; use Tobyz\JsonApiServer\Schema\Concerns\HasMeta;
use function Tobyz\JsonApiServer\json_api_response; use function Tobyz\JsonApiServer\json_api_response;
use function Tobyz\JsonApiServer\parse_sort_string;
class Index extends Endpoint class Index extends Endpoint
{ {
@@ -69,24 +68,18 @@ class Index extends Endpoint
protected function setUp(): void protected function setUp(): void
{ {
$this->route('GET', '/') $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; $collection = $context->collection;
// This model has a searcher API, so we'll use that instead of the default. // 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. // The searcher API allows swapping the default search engine for a custom one.
/** @var SearchManager $search */
$search = $context->api->getContainer()->make(SearchManager::class); $search = $context->api->getContainer()->make(SearchManager::class);
$modelClass = $collection instanceof AbstractDatabaseResource ? $collection->model() : null; $modelClass = $collection instanceof AbstractDatabaseResource ? $collection->model() : null;
if ($query instanceof Builder && $search->searchable($modelClass)) { if ($query instanceof Builder && $search->searchable($modelClass)) {
$actor = $context->getActor(); $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'); $sortIsDefault = ! $context->queryParam('sort');
$results = $search->query( $results = $search->query(
@@ -100,8 +93,8 @@ class Index extends Endpoint
else { else {
$context = $context->withQuery($query); $context = $context->withQuery($query);
$this->applySorts($query, $context); $this->applySorts($query, $context, $sort);
$this->applyFilters($query, $context); $this->applyFilters($query, $context, $filters);
if ($pagination && method_exists($pagination, 'apply')) { if ($pagination && method_exists($pagination, 'apply')) {
$pagination->apply($query); $pagination->apply($query);
@@ -129,8 +122,20 @@ class Index extends Endpoint
$pagination = ($this->paginationResolver)($context); $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) { if ($this->query) {
$context = ($this->query)($query, $pagination, $context); $context = ($this->query)($query, $pagination, $context, $filters, $sort, $offset, $limit);
if (! $context instanceof Context) { if (! $context instanceof Context) {
throw new RuntimeException('The Index endpoint query closure must return a Context instance.'); throw new RuntimeException('The Index endpoint query closure must return a Context instance.');
@@ -139,8 +144,8 @@ class Index extends Endpoint
/** @var Context $context */ /** @var Context $context */
$context = $context->withQuery($query); $context = $context->withQuery($query);
$this->applySorts($query, $context); $this->applySorts($query, $context, $sort);
$this->applyFilters($query, $context); $this->applyFilters($query, $context, $filters);
if ($pagination) { if ($pagination) {
$pagination->apply($query); $pagination->apply($query);
@@ -205,9 +210,9 @@ class Index extends Endpoint
return $this; 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; return;
} }
@@ -219,7 +224,7 @@ class Index extends Endpoint
$sorts = $collection->resolveSorts(); $sorts = $collection->resolveSorts();
foreach (parse_sort_string($sortString) as [$name, $direction]) { foreach ($sort as $name => $direction) {
foreach ($sorts as $field) { foreach ($sorts as $field) {
if ($field->name === $name && $field->isVisible($context)) { if ($field->name === $name && $field->isVisible($context)) {
$field->apply($query, $direction, $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; return;
} }
if (! is_array($filters)) {
throw (new BadRequestException('filter must be an array'))->setSource([
'parameter' => 'filter',
]);
}
$collection = $context->collection; $collection = $context->collection;
if (! $collection instanceof Listable) { if (! $collection instanceof Listable) {

View File

@@ -40,8 +40,10 @@ class ListTest extends TestCase
$this->request('GET', '/api/groups') $this->request('GET', '/api/groups')
); );
$this->assertEquals(200, $response->getStatusCode()); $body = $response->getBody()->getContents();
$data = json_decode($response->getBody()->getContents(), true);
$this->assertEquals(200, $response->getStatusCode(), $body);
$data = json_decode($body, true);
// The four default groups created by the installer // The four default groups created by the installer
$this->assertEquals(['1', '2', '3', '4'], Arr::pluck($data['data'], 'id')); $this->assertEquals(['1', '2', '3', '4'], Arr::pluck($data['data'], 'id'));