mirror of
https://github.com/flarum/core.git
synced 2025-08-04 15:37:51 +02:00
feat(pm): messages anchor link (#4175)
This commit is contained in:
@@ -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')
|
||||
|
2
extensions/messages/js/@types/shims.d.ts
vendored
2
extensions/messages/js/@types/shims.d.ts
vendored
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -13,6 +13,6 @@ export default [
|
||||
allowGuest: false,
|
||||
}),
|
||||
'start',
|
||||
98
|
||||
95
|
||||
),
|
||||
];
|
||||
|
@@ -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>('number').call(this);
|
||||
}
|
||||
content() {
|
||||
return Model.attribute<string | null | undefined>('content').call(this);
|
||||
}
|
||||
|
@@ -24,14 +24,27 @@ export default class DialogSection<CustomAttrs extends IDialogStreamAttrs = IDia
|
||||
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
|
||||
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() {
|
||||
|
@@ -105,7 +105,21 @@ export default abstract class Message<CustomAttrs extends IMessageAttrs = IMessa
|
||||
const message = this.attrs.message;
|
||||
|
||||
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;
|
||||
}
|
||||
|
@@ -77,18 +77,20 @@ export default class MessageStream<CustomAttrs extends IDialogStreamAttrs = IDia
|
||||
content() {
|
||||
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 LoadingPost = this.loadingPostComponent();
|
||||
|
||||
if (messages[0].id() !== (this.attrs.dialog.data.relationships?.firstMessage.data as ModelIdentifier).id) {
|
||||
items.push(
|
||||
<div className="MessageStream-item" key="loadPrevious">
|
||||
<div className="MessageStream-item" key="loadNext">
|
||||
<Button
|
||||
onclick={() => this.whileMaintainingScroll(() => this.attrs.state.loadNext())}
|
||||
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')}
|
||||
</Button>
|
||||
@@ -97,7 +99,7 @@ export default class MessageStream<CustomAttrs extends IDialogStreamAttrs = IDia
|
||||
|
||||
if (LoadingPost) {
|
||||
items.push(
|
||||
<div className="MessageStream-item" key="loading-prev">
|
||||
<div className="MessageStream-item" key="loading-next">
|
||||
<LoadingPost />
|
||||
</div>
|
||||
);
|
||||
@@ -106,6 +108,28 @@ export default class MessageStream<CustomAttrs extends IDialogStreamAttrs = IDia
|
||||
|
||||
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) {
|
||||
items.push(
|
||||
<div className="MessageStream-item" key="reply">
|
||||
@@ -135,7 +159,7 @@ export default class MessageStream<CustomAttrs extends IDialogStreamAttrs = IDia
|
||||
|
||||
messageItem(message: DialogMessage, index: number) {
|
||||
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)}
|
||||
<Message message={message} />
|
||||
</div>
|
||||
@@ -177,7 +201,7 @@ export default class MessageStream<CustomAttrs extends IDialogStreamAttrs = IDia
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -186,16 +210,34 @@ export default class MessageStream<CustomAttrs extends IDialogStreamAttrs = IDia
|
||||
}
|
||||
|
||||
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>) {
|
||||
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;
|
||||
|
@@ -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 })),
|
||||
];
|
||||
|
@@ -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 {
|
||||
//
|
||||
|
@@ -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
|
||||
|
@@ -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');
|
||||
});
|
||||
}
|
||||
];
|
@@ -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<DialogMessage>
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
|
||||
|
@@ -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);
|
||||
|
@@ -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' => '<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);
|
||||
}
|
||||
}
|
||||
|
@@ -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()],
|
||||
|
@@ -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' => '<p>Hello, Alice!</p>'],
|
||||
['id' => 103, 'dialog_id' => 102, 'user_id' => 3, 'content' => '<p>Hello, Bob!</p>'],
|
||||
['id' => 104, 'dialog_id' => 102, 'user_id' => 4, 'content' => '<p>Hello, Alice!</p>'],
|
||||
['id' => 105, 'dialog_id' => 102, 'user_id' => 3, 'content' => '<p>Hello, Bob!</p>'],
|
||||
['id' => 106, 'dialog_id' => 102, 'user_id' => 4, 'content' => '<p>Hello, Alice!</p>'],
|
||||
['id' => 107, 'dialog_id' => 102, 'user_id' => 3, 'content' => '<p>Hello, Bob!</p>'],
|
||||
['id' => 108, 'dialog_id' => 102, 'user_id' => 4, 'content' => '<p>Hello, Alice!</p>'],
|
||||
['id' => 109, 'dialog_id' => 102, 'user_id' => 3, 'content' => '<p>Hello, Bob!</p>'],
|
||||
['id' => 110, 'dialog_id' => 102, 'user_id' => 4, 'content' => '<p>Hello, Alice!</p>'],
|
||||
['id' => 111, 'dialog_id' => 102, 'user_id' => 3, 'content' => '<p>Hello, Bob!</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>', 'number' => 2],
|
||||
['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>', 'number' => 4],
|
||||
['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>', 'number' => 6],
|
||||
['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>', 'number' => 8],
|
||||
['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>', 'number' => 10],
|
||||
],
|
||||
'dialog_user' => [
|
||||
['dialog_id' => 102, 'user_id' => 3, 'last_read_message_id' => 0, 'last_read_at' => null, 'joined_at' => Carbon::now()],
|
||||
|
@@ -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<TModel> {
|
||||
export interface Page<TModel extends Model> {
|
||||
number: number;
|
||||
items: TModel[];
|
||||
items: ApiResponsePlural<TModel> | TModel[];
|
||||
|
||||
hasPrev?: boolean;
|
||||
hasNext?: boolean;
|
||||
@@ -73,7 +72,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -140,7 +139,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
|
||||
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 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.
|
||||
* Do not include page offset unless subclass overrides loadPage.
|
||||
|
@@ -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) {
|
||||
|
@@ -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'));
|
||||
|
Reference in New Issue
Block a user