mirror of
https://github.com/flarum/core.git
synced 2025-08-13 20:04:24 +02:00
Compare commits
27 Commits
v1.1.1
...
dw/2.0-use
Author | SHA1 | Date | |
---|---|---|---|
|
6a57183525 | ||
|
2c801711bb | ||
|
69b1dc7103 | ||
|
6200ffef9b | ||
|
5e84490fd0 | ||
|
2b0d55632e | ||
|
f7a78d85e3 | ||
|
972411673f | ||
|
7ebf535b25 | ||
|
a661376d16 | ||
|
5a1bf08d3f | ||
|
a9b1a518a2 | ||
|
9416b1c150 | ||
|
87f67744a8 | ||
|
4add23a984 | ||
|
c52c0987fb | ||
|
60f0ef0bd5 | ||
|
82d67919bb | ||
|
713d95eb36 | ||
|
d053bb5496 | ||
|
05121b928a | ||
|
0a7e885eab | ||
|
a65488000c | ||
|
4146a4c578 | ||
|
3f2e25b35f | ||
|
2a86c25297 | ||
|
919c543cbc |
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
php: [7.3, 7.4, '8.0']
|
||||
php: [7.3, 7.4, '8.0', '8.1']
|
||||
service: ['mysql:5.7', mariadb]
|
||||
prefix: ['', flarum_]
|
||||
|
||||
|
@@ -1,9 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## [1.1.1](https://github.com/flarum/core/compare/v1.1.0...v1.1.1)
|
||||
|
||||
### Fixed
|
||||
- Performance issue with very large communities.
|
||||
|
||||
## [1.1.0](https://github.com/flarum/core/compare/v1.0.4...v1.1.0)
|
||||
|
||||
|
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* The `formatNumber` utility localizes a number into a string with the
|
||||
* appropriate punctuation.
|
||||
* appropriate punctuation based on the provided locale otherwise will default to the users locale.
|
||||
*
|
||||
* @example
|
||||
* formatNumber(1234);
|
||||
* // 1,234
|
||||
*/
|
||||
export default function formatNumber(number: number): string;
|
||||
export default function formatNumber(number: number, locale?: string): string;
|
||||
|
4
js/dist/admin.js
generated
vendored
4
js/dist/admin.js
generated
vendored
File diff suppressed because one or more lines are too long
2
js/dist/admin.js.map
generated
vendored
2
js/dist/admin.js.map
generated
vendored
File diff suppressed because one or more lines are too long
4
js/dist/forum.js
generated
vendored
4
js/dist/forum.js
generated
vendored
File diff suppressed because one or more lines are too long
2
js/dist/forum.js.map
generated
vendored
2
js/dist/forum.js.map
generated
vendored
File diff suppressed because one or more lines are too long
@@ -141,8 +141,8 @@ export default class ExtensionPage extends AdminPage {
|
||||
items.add('version', <span className="ExtensionVersion">{this.extension.version}</span>);
|
||||
|
||||
if (!this.isEnabled()) {
|
||||
const uninstall = () => {
|
||||
if (confirm(app.translator.trans('core.admin.extension.confirm_uninstall'))) {
|
||||
const purge = () => {
|
||||
if (confirm(app.translator.trans('core.admin.extension.confirm_purge'))) {
|
||||
app
|
||||
.request({
|
||||
url: app.forum.attribute('apiUrl') + '/extensions/' + this.extension.id,
|
||||
@@ -154,10 +154,11 @@ export default class ExtensionPage extends AdminPage {
|
||||
}
|
||||
};
|
||||
|
||||
// TODO v2.0: rename `uninstall` to `purge`
|
||||
items.add(
|
||||
'uninstall',
|
||||
<Button icon="fas fa-trash-alt" className="Button Button--primary" onclick={uninstall.bind(this)}>
|
||||
{app.translator.trans('core.admin.extension.uninstall_button')}
|
||||
<Button icon="fas fa-trash-alt" className="Button Button--primary" onclick={purge.bind(this)}>
|
||||
{app.translator.trans('core.admin.extension.purge_button')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
@@ -79,7 +79,7 @@ export default class MailPage extends AdminPage {
|
||||
|
||||
return [
|
||||
this.buildSettingComponent({
|
||||
type: typeof this.setting(field)() === 'string' ? 'text' : 'select',
|
||||
type: typeof fieldInfo === 'string' ? 'text' : 'select',
|
||||
label: app.translator.trans(`core.admin.email.${field}_label`),
|
||||
setting: field,
|
||||
options: fieldInfo,
|
||||
|
@@ -38,6 +38,7 @@ export default class PermissionDropdown extends Dropdown {
|
||||
|
||||
attrs.className = 'PermissionDropdown';
|
||||
attrs.buttonClassName = 'Button Button--text';
|
||||
attrs.lazyDraw = true;
|
||||
}
|
||||
|
||||
view(vnode) {
|
||||
|
@@ -144,6 +144,7 @@ export default class PermissionGrid extends Component {
|
||||
{ value: '1', label: app.translator.trans('core.admin.permissions_controls.signup_open_button') },
|
||||
{ value: '0', label: app.translator.trans('core.admin.permissions_controls.signup_closed_button') },
|
||||
],
|
||||
lazyDraw: true,
|
||||
}),
|
||||
},
|
||||
90
|
||||
@@ -191,6 +192,7 @@ export default class PermissionGrid extends Component {
|
||||
{ value: '10', label: app.translator.trans('core.admin.permissions_controls.allow_ten_minutes_button') },
|
||||
{ value: 'reply', label: app.translator.trans('core.admin.permissions_controls.allow_until_reply_button') },
|
||||
],
|
||||
lazyDraw: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
@@ -38,11 +38,12 @@ export default class Dropdown extends Component {
|
||||
|
||||
view(vnode) {
|
||||
const items = vnode.children ? listItems(vnode.children) : [];
|
||||
const renderItems = this.attrs.lazyDraw ? this.showing : true;
|
||||
|
||||
return (
|
||||
<div className={'ButtonGroup Dropdown dropdown ' + this.attrs.className + ' itemCount' + items.length + (this.showing ? ' open' : '')}>
|
||||
{this.getButton(vnode.children)}
|
||||
{this.getMenu(items)}
|
||||
{renderItems && this.getMenu(items)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -54,13 +55,25 @@ export default class Dropdown extends Component {
|
||||
// bottom of the viewport. If it does, we will apply class to make it show
|
||||
// above the toggle button instead of below it.
|
||||
this.$().on('shown.bs.dropdown', () => {
|
||||
const { lazyDraw, onshow } = this.attrs;
|
||||
|
||||
this.showing = true;
|
||||
|
||||
if (this.attrs.onshow) {
|
||||
this.attrs.onshow();
|
||||
// If using lazy drawing, redraw before calling `onshow` function
|
||||
// to make sure the menu DOM exists in case the callback tries to use it.
|
||||
if (lazyDraw) {
|
||||
m.redraw.sync();
|
||||
}
|
||||
|
||||
m.redraw();
|
||||
if (typeof onshow === 'function') {
|
||||
onshow();
|
||||
}
|
||||
|
||||
// If not using lazy drawing, keep previous functionality
|
||||
// of redrawing after calling onshow()
|
||||
if (!lazyDraw) {
|
||||
m.redraw();
|
||||
}
|
||||
|
||||
const $menu = this.$('.Dropdown-menu');
|
||||
const isRight = $menu.hasClass('Dropdown-menu--right');
|
||||
|
@@ -10,9 +10,11 @@ Object.assign(Post.prototype, {
|
||||
|
||||
createdAt: Model.attribute('createdAt', Model.transformDate),
|
||||
user: Model.hasOne('user'),
|
||||
|
||||
contentType: Model.attribute('contentType'),
|
||||
content: Model.attribute('content'),
|
||||
contentHtml: Model.attribute('contentHtml'),
|
||||
renderFailed: Model.attribute('renderFailed'),
|
||||
contentPlain: computed('contentHtml', getPlainContent),
|
||||
|
||||
editedAt: Model.attribute('editedAt', Model.transformDate),
|
||||
|
@@ -89,8 +89,18 @@ Object.assign(User.prototype, {
|
||||
const user = this;
|
||||
|
||||
image.onload = function () {
|
||||
const colorThief = new ColorThief();
|
||||
user.avatarColor = colorThief.getColor(this);
|
||||
try {
|
||||
const colorThief = new ColorThief();
|
||||
user.avatarColor = colorThief.getColor(this);
|
||||
} catch (e) {
|
||||
// Completely white avatars throw errors due to a glitch in color thief
|
||||
// See https://github.com/lokesh/color-thief/issues/40
|
||||
if (e instanceof TypeError) {
|
||||
user.avatarColor = [255, 255, 255];
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
user.freshness = new Date();
|
||||
m.redraw();
|
||||
};
|
||||
|
@@ -1,11 +1,13 @@
|
||||
import app from '../../common/app';
|
||||
|
||||
/**
|
||||
* The `formatNumber` utility localizes a number into a string with the
|
||||
* appropriate punctuation.
|
||||
* appropriate punctuation based on the provided locale otherwise will default to the users locale.
|
||||
*
|
||||
* @example
|
||||
* formatNumber(1234);
|
||||
* // 1,234
|
||||
*/
|
||||
export default function formatNumber(number: number): string {
|
||||
return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
export default function formatNumber(number: number, locale: string = app.data.locale): string {
|
||||
return new Intl.NumberFormat(locale).format(number);
|
||||
}
|
||||
|
@@ -77,6 +77,7 @@ export default class ChangeEmailModal extends Modal {
|
||||
type="password"
|
||||
name="password"
|
||||
className="FormControl"
|
||||
autocomplete="current-password"
|
||||
placeholder={app.translator.trans('core.forum.change_email.confirm_password_placeholder')}
|
||||
bidi={this.password}
|
||||
disabled={this.loading}
|
||||
|
@@ -100,6 +100,7 @@ export default class CommentPost extends Post {
|
||||
' ' +
|
||||
classList({
|
||||
CommentPost: true,
|
||||
'Post--renderFailed': post.renderFailed(),
|
||||
'Post--hidden': post.isHidden(),
|
||||
'Post--edited': post.isEdited(),
|
||||
revealContent: this.revealContent,
|
||||
|
@@ -217,34 +217,33 @@ export default class IndexPage extends Page {
|
||||
*/
|
||||
viewItems() {
|
||||
const items = new ItemList();
|
||||
const sortMap = app.discussions.sortMap();
|
||||
|
||||
const sortOptions = {};
|
||||
for (const i in sortMap) {
|
||||
sortOptions[i] = app.translator.trans('core.forum.index_sort.' + i + '_button');
|
||||
}
|
||||
const sortOptions = Object.values(app.discussions.sortMap().toObject());
|
||||
|
||||
// Chooses the sort option with highest priority for now
|
||||
const defaultSortMethod = sortOptions.reduce((acc, option) => (acc.priority > option.priority ? acc : option), { priority: -9e10 });
|
||||
|
||||
// Find the selected search method, otherwise heed the default
|
||||
const activeSearchMethod = sortOptions.find((opt) => opt.itemName === app.search.params().sort) || defaultSortMethod;
|
||||
|
||||
items.add(
|
||||
'sort',
|
||||
Dropdown.component(
|
||||
{
|
||||
buttonClassName: 'Button',
|
||||
label: sortOptions[app.search.params().sort] || Object.keys(sortMap).map((key) => sortOptions[key])[0],
|
||||
label: app.translator.trans(`core.forum.index_sort.${activeSearchMethod.itemName}_button`),
|
||||
accessibleToggleLabel: app.translator.trans('core.forum.index_sort.toggle_dropdown_accessible_label'),
|
||||
},
|
||||
Object.keys(sortOptions).map((value) => {
|
||||
const label = sortOptions[value];
|
||||
const active = (app.search.params().sort || Object.keys(sortMap)[0]) === value;
|
||||
|
||||
return Button.component(
|
||||
sortOptions.map(({ itemName: sortingId, content: sortType }) =>
|
||||
Button.component(
|
||||
{
|
||||
icon: active ? 'fas fa-check' : true,
|
||||
onclick: app.search.changeSort.bind(app.search, value),
|
||||
onclick: app.search.changeSort.bind(app.search, sortType),
|
||||
active: active,
|
||||
},
|
||||
label
|
||||
);
|
||||
})
|
||||
app.translator.trans(`core.forum.index_sort.${sortingId}_button`)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
|
@@ -83,6 +83,7 @@ export default class LogInModal extends Modal {
|
||||
className="FormControl"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
placeholder={extractText(app.translator.trans('core.forum.log_in.password_placeholder'))}
|
||||
bidi={this.password}
|
||||
disabled={this.loading}
|
||||
|
@@ -104,6 +104,7 @@ export default class SignUpModal extends Modal {
|
||||
className="FormControl"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
placeholder={extractText(app.translator.trans('core.forum.sign_up.password_placeholder'))}
|
||||
bidi={this.password}
|
||||
disabled={this.loading}
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import app from '../../forum/app';
|
||||
import PaginatedListState, { Page } from '../../common/states/PaginatedListState';
|
||||
import Discussion from '../../common/models/Discussion';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
|
||||
export default class DiscussionListState extends PaginatedListState<Discussion> {
|
||||
protected extraDiscussions: Discussion[] = [];
|
||||
@@ -16,7 +17,7 @@ export default class DiscussionListState extends PaginatedListState<Discussion>
|
||||
requestParams() {
|
||||
const params: any = { include: ['user', 'lastPostedUser'], filter: this.params.filter || {} };
|
||||
|
||||
params.sort = this.sortMap()[this.params.sort];
|
||||
params.sort = this.sortMap().get(this.params.sort);
|
||||
|
||||
if (this.params.q) {
|
||||
params.filter.q = this.params.q;
|
||||
@@ -45,21 +46,22 @@ export default class DiscussionListState extends PaginatedListState<Discussion>
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a map of sort keys (which appear in the URL, and are used for
|
||||
* Get a list of sort keys (which appear in the URL, and are used for
|
||||
* translation) to the API sort value that they represent.
|
||||
*/
|
||||
sortMap() {
|
||||
const map: any = {};
|
||||
sortMap(): ItemList<string> {
|
||||
const sortItems = new ItemList<string>();
|
||||
|
||||
if (this.params.q) {
|
||||
map.relevance = '';
|
||||
sortItems.add('relevance', '', 100);
|
||||
}
|
||||
map.latest = '-lastPostedAt';
|
||||
map.top = '-commentCount';
|
||||
map.newest = '-createdAt';
|
||||
map.oldest = 'createdAt';
|
||||
|
||||
return map;
|
||||
sortItems.add('latest', '-lastPostedAt', 80);
|
||||
sortItems.add('top', '-commentCount', 60);
|
||||
sortItems.add('newest', '-createdAt', 40);
|
||||
sortItems.add('oldest', 'createdAt', 20);
|
||||
|
||||
return sortItems;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -1,6 +1,12 @@
|
||||
.Search {
|
||||
position: relative;
|
||||
|
||||
// TODO v2.0 check if this is supported by Firefox,
|
||||
// if so, consider switching to it.
|
||||
::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&-clear {
|
||||
// It looks very weird due to the padding given to the button..
|
||||
&:focus {
|
||||
|
@@ -185,6 +185,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.Post--renderFailed {
|
||||
background-color: @control-danger-bg;
|
||||
}
|
||||
|
||||
.Post--hidden {
|
||||
.Post-header, .Post-header a, .PostUser h3, .PostUser h3 a {
|
||||
color: @muted-more-color;
|
||||
|
@@ -115,7 +115,7 @@ core:
|
||||
# These translations are used on default extension pages.
|
||||
extension:
|
||||
configure_scopes: Configure Scopes
|
||||
confirm_uninstall: Uninstalling will remove all database entries and assets related to the extension. Are you sure you want to continue?
|
||||
confirm_purge: Purging will remove all database entries and assets related to the extension. It will not uninstall the extension; that must be done via Composer. Are you sure you want to continue?
|
||||
disabled: Disabled
|
||||
enable_to_see: Enable the extension to view and change settings.
|
||||
enabled: Enabled
|
||||
@@ -130,7 +130,7 @@ core:
|
||||
no_settings: This extension has no settings.
|
||||
open_modal: Open Settings
|
||||
permissions_title: Permissions
|
||||
uninstall_button: Uninstall
|
||||
purge_button: Purge
|
||||
|
||||
# These translations are used in the secondary header.
|
||||
header:
|
||||
@@ -516,6 +516,7 @@ core:
|
||||
title: => core.ref.edit_user
|
||||
username_heading: => core.ref.username
|
||||
username_label: => core.ref.username
|
||||
nothing_available: You are not allowed to edit this user.
|
||||
|
||||
# These translations are displayed as error messages.
|
||||
error:
|
||||
@@ -526,6 +527,7 @@ core:
|
||||
payload_too_large_message: The request payload was too large.
|
||||
permission_denied_message: You do not have permission to do that.
|
||||
rate_limit_exceeded_message: You're going a little too quickly. Please try again in a few seconds.
|
||||
render_failed_message: Sorry, we encountered an error while displaying this content. If you're a user, please try again later. If you're an administrator, take a look in your Flarum log files for more information.
|
||||
|
||||
# These translations are used in the loading indicator component.
|
||||
loading_indicator:
|
||||
|
@@ -132,6 +132,7 @@ class Client
|
||||
|
||||
if ($this->parent) {
|
||||
$request = $request
|
||||
->withAttribute('ipAddress', $this->parent->getAttribute('ipAddress'))
|
||||
->withAttribute('session', $this->parent->getAttribute('session'));
|
||||
$request = RequestUtil::withActor($request, RequestUtil::getActor($this->parent));
|
||||
}
|
||||
|
@@ -125,17 +125,6 @@ class ListDiscussionsController extends AbstractListController
|
||||
|
||||
$results = $results->getResults();
|
||||
|
||||
/*
|
||||
* @TODO replace in 1.2 with proper implementation!!!
|
||||
*/
|
||||
if (in_array('tags.state', $include, true)) {
|
||||
$results->load([
|
||||
'tags.state' => function ($query) use ($actor) {
|
||||
$query->where('user_id', $actor->id);
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
$this->loadRelations($results, $include);
|
||||
|
||||
if ($relations = array_intersect($include, ['firstPost', 'lastPost', 'mostRelevantPost'])) {
|
||||
|
@@ -10,8 +10,10 @@
|
||||
namespace Flarum\Api\Controller;
|
||||
|
||||
use Flarum\Api\Serializer\GroupSerializer;
|
||||
use Flarum\Group\Group;
|
||||
use Flarum\Group\Filter\GroupFilterer;
|
||||
use Flarum\Http\RequestUtil;
|
||||
use Flarum\Http\UrlGenerator;
|
||||
use Flarum\Query\QueryCriteria;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Tobscure\JsonApi\Document;
|
||||
|
||||
@@ -22,6 +24,38 @@ class ListGroupsController extends AbstractListController
|
||||
*/
|
||||
public $serializer = GroupSerializer::class;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public $sortFields = ['nameSingular', 'namePlural', 'isHidden'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
public $limit = -1;
|
||||
|
||||
/**
|
||||
* @var GroupFilterer
|
||||
*/
|
||||
protected $filterer;
|
||||
|
||||
/**
|
||||
* @var UrlGenerator
|
||||
*/
|
||||
protected $url;
|
||||
|
||||
/**
|
||||
* @param GroupFilterer $filterer
|
||||
* @param UrlGenerator $url
|
||||
*/
|
||||
public function __construct(GroupFilterer $filterer, UrlGenerator $url)
|
||||
{
|
||||
$this->filterer = $filterer;
|
||||
$this->url = $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
@@ -29,10 +63,25 @@ class ListGroupsController extends AbstractListController
|
||||
{
|
||||
$actor = RequestUtil::getActor($request);
|
||||
|
||||
$results = Group::whereVisibleTo($actor)->get();
|
||||
$filters = $this->extractFilter($request);
|
||||
$sort = $this->extractSort($request);
|
||||
$sortIsDefault = $this->sortIsDefault($request);
|
||||
|
||||
$this->loadRelations($results, []);
|
||||
$limit = $this->extractLimit($request);
|
||||
$offset = $this->extractOffset($request);
|
||||
|
||||
return $results;
|
||||
$criteria = new QueryCriteria($actor, $filters, $sort, $sortIsDefault);
|
||||
|
||||
$queryResults = $this->filterer->filter($criteria, $limit, $offset);
|
||||
|
||||
$document->addPaginationLinks(
|
||||
$this->url->to('api')->route('groups.index'),
|
||||
$request->getQueryParams(),
|
||||
$offset,
|
||||
$limit,
|
||||
$queryResults->areMoreResults() ? null : 0
|
||||
);
|
||||
|
||||
return $queryResults->getResults();
|
||||
}
|
||||
}
|
||||
|
51
src/Api/Controller/ShowGroupController.php
Normal file
51
src/Api/Controller/ShowGroupController.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?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.
|
||||
*/
|
||||
|
||||
namespace Flarum\Api\Controller;
|
||||
|
||||
use Flarum\Api\Serializer\GroupSerializer;
|
||||
use Flarum\Group\GroupRepository;
|
||||
use Flarum\Http\RequestUtil;
|
||||
use Illuminate\Support\Arr;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Tobscure\JsonApi\Document;
|
||||
|
||||
class ShowGroupController extends AbstractShowController
|
||||
{
|
||||
/**
|
||||
* @var GroupRepository
|
||||
*/
|
||||
protected $groups;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public $serializer = GroupSerializer::class;
|
||||
|
||||
/**
|
||||
* @param \Flarum\Group\GroupRepository $groups
|
||||
*/
|
||||
public function __construct(GroupRepository $groups)
|
||||
{
|
||||
$this->groups = $groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function data(ServerRequestInterface $request, Document $document)
|
||||
{
|
||||
$id = Arr::get($request->getQueryParams(), 'id');
|
||||
$actor = RequestUtil::getActor($request);
|
||||
|
||||
$group = $this->groups->findOrFail($id, $actor);
|
||||
|
||||
return $group;
|
||||
}
|
||||
}
|
@@ -9,12 +9,30 @@
|
||||
|
||||
namespace Flarum\Api\Serializer;
|
||||
|
||||
use Exception;
|
||||
use Flarum\Foundation\ErrorHandling\LogReporter;
|
||||
use Flarum\Post\CommentPost;
|
||||
use Flarum\Post\Post;
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
class BasicPostSerializer extends AbstractSerializer
|
||||
{
|
||||
/**
|
||||
* @var LogReporter
|
||||
*/
|
||||
protected $log;
|
||||
|
||||
/**
|
||||
* @var TranslatorInterface
|
||||
*/
|
||||
protected $translator;
|
||||
|
||||
public function __construct(LogReporter $log, TranslatorInterface $translator)
|
||||
{
|
||||
$this->log = $log;
|
||||
$this->translator = $translator;
|
||||
}
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
@@ -41,7 +59,14 @@ class BasicPostSerializer extends AbstractSerializer
|
||||
];
|
||||
|
||||
if ($post instanceof CommentPost) {
|
||||
$attributes['contentHtml'] = $post->formatContent($this->request);
|
||||
try {
|
||||
$attributes['contentHtml'] = $post->formatContent($this->request);
|
||||
$attributes['renderFailed'] = false;
|
||||
} catch (Exception $e) {
|
||||
$attributes['contentHtml'] = $this->translator->trans('core.lib.error.render_failed_message');
|
||||
$this->log->report($e);
|
||||
$attributes['renderFailed'] = true;
|
||||
}
|
||||
} else {
|
||||
$attributes['content'] = $post->content;
|
||||
}
|
||||
|
@@ -224,6 +224,13 @@ return function (RouteCollection $map, RouteHandlerFactory $route) {
|
||||
$route->toController(Controller\CreateGroupController::class)
|
||||
);
|
||||
|
||||
// Show a single group
|
||||
$map->get(
|
||||
'/groups/{id}',
|
||||
'groups.show',
|
||||
$route->toController(Controller\ShowGroupController::class)
|
||||
);
|
||||
|
||||
// Edit a group
|
||||
$map->patch(
|
||||
'/groups/{id}',
|
||||
|
@@ -13,6 +13,8 @@ use Flarum\Discussion\Filter\DiscussionFilterer;
|
||||
use Flarum\Discussion\Query as DiscussionQuery;
|
||||
use Flarum\Foundation\AbstractServiceProvider;
|
||||
use Flarum\Foundation\ContainerUtil;
|
||||
use Flarum\Group\Filter as GroupFilter;
|
||||
use Flarum\Group\Filter\GroupFilterer;
|
||||
use Flarum\Post\Filter as PostFilter;
|
||||
use Flarum\Post\Filter\PostFilterer;
|
||||
use Flarum\User\Filter\UserFilterer;
|
||||
@@ -41,6 +43,9 @@ class FilterServiceProvider extends AbstractServiceProvider
|
||||
UserQuery\EmailFilterGambit::class,
|
||||
UserQuery\GroupFilterGambit::class,
|
||||
],
|
||||
GroupFilterer::class => [
|
||||
GroupFilter\HiddenFilter::class,
|
||||
],
|
||||
PostFilterer::class => [
|
||||
PostFilter\AuthorFilter::class,
|
||||
PostFilter\DiscussionFilter::class,
|
||||
|
@@ -21,7 +21,7 @@ class Application
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const VERSION = '1.1.1';
|
||||
const VERSION = '1.2.0-dev';
|
||||
|
||||
/**
|
||||
* The IoC container for the Flarum application.
|
||||
|
40
src/Group/Filter/GroupFilterer.php
Normal file
40
src/Group/Filter/GroupFilterer.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?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.
|
||||
*/
|
||||
|
||||
namespace Flarum\Group\Filter;
|
||||
|
||||
use Flarum\Filter\AbstractFilterer;
|
||||
use Flarum\Group\GroupRepository;
|
||||
use Flarum\User\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class GroupFilterer extends AbstractFilterer
|
||||
{
|
||||
/**
|
||||
* @var GroupRepository
|
||||
*/
|
||||
protected $groups;
|
||||
|
||||
/**
|
||||
* @param GroupRepository $groups
|
||||
* @param array $filters
|
||||
* @param array $filterMutators
|
||||
*/
|
||||
public function __construct(GroupRepository $groups, array $filters, array $filterMutators)
|
||||
{
|
||||
parent::__construct($filters, $filterMutators);
|
||||
|
||||
$this->groups = $groups;
|
||||
}
|
||||
|
||||
protected function getQuery(User $actor): Builder
|
||||
{
|
||||
return $this->groups->query()->whereVisibleTo($actor);
|
||||
}
|
||||
}
|
26
src/Group/Filter/HiddenFilter.php
Normal file
26
src/Group/Filter/HiddenFilter.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?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.
|
||||
*/
|
||||
|
||||
namespace Flarum\Group\Filter;
|
||||
|
||||
use Flarum\Filter\FilterInterface;
|
||||
use Flarum\Filter\FilterState;
|
||||
|
||||
class HiddenFilter implements FilterInterface
|
||||
{
|
||||
public function getFilterKey(): string
|
||||
{
|
||||
return 'hidden';
|
||||
}
|
||||
|
||||
public function filter(FilterState $filterState, string $filterValue, bool $negate)
|
||||
{
|
||||
$filterState->getQuery()->where('is_hidden', $negate ? '!=' : '=', $filterValue);
|
||||
}
|
||||
}
|
@@ -21,7 +21,7 @@ class GroupRepository
|
||||
*/
|
||||
public function query()
|
||||
{
|
||||
return User::query();
|
||||
return Group::query();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -121,11 +121,17 @@ class RouteCollection
|
||||
return $this->dataGenerator->getData();
|
||||
}
|
||||
|
||||
protected function fixPathPart(&$part, $key, array $parameters)
|
||||
protected function fixPathPart($part, array $parameters, string $routeName)
|
||||
{
|
||||
if (is_array($part) && array_key_exists($part[0], $parameters)) {
|
||||
$part = $parameters[$part[0]];
|
||||
if (! is_array($part)) {
|
||||
return $part;
|
||||
}
|
||||
|
||||
if (! array_key_exists($part[0], $parameters)) {
|
||||
throw new \InvalidArgumentException("Could not generate URL for route '$routeName': no value provided for required part '$part[0]'.");
|
||||
}
|
||||
|
||||
return $parameters[$part[0]];
|
||||
}
|
||||
|
||||
public function getPath($name, array $parameters = [])
|
||||
@@ -151,9 +157,11 @@ class RouteCollection
|
||||
}
|
||||
}
|
||||
|
||||
array_walk($matchingParts, [$this, 'fixPathPart'], $parameters);
|
||||
$fixedParts = array_map(function ($part) use ($parameters, $name) {
|
||||
return $this->fixPathPart($part, $parameters, $name);
|
||||
}, $matchingParts);
|
||||
|
||||
return '/'.ltrim(implode('', $matchingParts), '/');
|
||||
return '/'.ltrim(implode('', $fixedParts), '/');
|
||||
}
|
||||
|
||||
throw new \RuntimeException("Route $name not found");
|
||||
|
@@ -87,8 +87,8 @@ class DatabaseConfig implements Arrayable
|
||||
}
|
||||
|
||||
if (! empty($this->prefix)) {
|
||||
if (! preg_match('/^[\pL\pM\pN_-]+$/u', $this->prefix)) {
|
||||
throw new ValidationFailed('The prefix may only contain characters, dashes and underscores.');
|
||||
if (! preg_match('/^[\pL\pM\pN_]+$/u', $this->prefix)) {
|
||||
throw new ValidationFailed('The prefix may only contain characters and underscores.');
|
||||
}
|
||||
|
||||
if (strlen($this->prefix) > 10) {
|
||||
|
@@ -16,6 +16,11 @@ use Symfony\Component\Mime\MimeTypes;
|
||||
|
||||
class AvatarValidator extends AbstractValidator
|
||||
{
|
||||
/**
|
||||
* @var \Illuminate\Validation\Validator
|
||||
*/
|
||||
protected $laravelValidator;
|
||||
|
||||
/**
|
||||
* Throw an exception if a model is not valid.
|
||||
*
|
||||
@@ -23,6 +28,8 @@ class AvatarValidator extends AbstractValidator
|
||||
*/
|
||||
public function assertValid(array $attributes)
|
||||
{
|
||||
$this->laravelValidator = $this->makeValidator($attributes);
|
||||
|
||||
$this->assertFileRequired($attributes['avatar']);
|
||||
$this->assertFileMimes($attributes['avatar']);
|
||||
$this->assertFileSize($attributes['avatar']);
|
||||
@@ -69,15 +76,21 @@ class AvatarValidator extends AbstractValidator
|
||||
$maxSize = $this->getMaxSize();
|
||||
|
||||
if ($file->getSize() / 1024 > $maxSize) {
|
||||
$this->raise('max.file', [':max' => $maxSize]);
|
||||
$this->raise('max.file', [':max' => $maxSize], 'max');
|
||||
}
|
||||
}
|
||||
|
||||
protected function raise($error, array $parameters = [])
|
||||
protected function raise($error, array $parameters = [], $rule = null)
|
||||
{
|
||||
$message = $this->translator->trans(
|
||||
"validation.$error",
|
||||
$parameters + [':attribute' => 'avatar']
|
||||
// When we switched to intl ICU message format, the translation parameters
|
||||
// have become required to be in the format `{param}`.
|
||||
// Therefore we cannot use the translator to replace the string params.
|
||||
// We use the laravel validator to make the replacements instead.
|
||||
$message = $this->laravelValidator->makeReplacements(
|
||||
$this->translator->trans("validation.$error"),
|
||||
'avatar',
|
||||
$rule ?? $error,
|
||||
array_values($parameters)
|
||||
);
|
||||
|
||||
throw new ValidationException(['avatar' => $message]);
|
||||
|
@@ -65,6 +65,148 @@ class ListTest extends TestCase
|
||||
$this->assertEquals(['1', '2', '3', '4', '10'], Arr::pluck($data['data'], 'id'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function filters_only_public_groups_for_admin()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('GET', '/api/groups', [
|
||||
'authenticatedAs' => 1,
|
||||
])
|
||||
->withQueryParams([
|
||||
'filter' => ['hidden' => 0],
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
$data = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
// The four default groups created by the installer without our hidden group
|
||||
$this->assertEquals(['1', '2', '3', '4'], Arr::pluck($data['data'], 'id'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function filters_only_hidden_groups_for_admin()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('GET', '/api/groups', [
|
||||
'authenticatedAs' => 1,
|
||||
])
|
||||
->withQueryParams([
|
||||
'filter' => ['hidden' => 1],
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
$data = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
// Only our hidden group
|
||||
$this->assertEquals(['10'], Arr::pluck($data['data'], 'id'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function filters_only_public_groups_for_guest()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('GET', '/api/groups')
|
||||
->withQueryParams([
|
||||
'filter' => ['hidden' => 0],
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
$data = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
// The four default groups created by the installer without our hidden group
|
||||
$this->assertEquals(['1', '2', '3', '4'], Arr::pluck($data['data'], 'id'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function hides_hidden_groups_when_filtering_for_guest()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('GET', '/api/groups')
|
||||
->withQueryParams([
|
||||
'filter' => ['hidden' => 1],
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
$data = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
// When guest attempts to filter for hidden groups, system should
|
||||
// still apply scoping and exclude those groups from results
|
||||
$this->assertEquals([], Arr::pluck($data['data'], 'id'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function paginates_groups_without_filter()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('GET', '/api/groups')
|
||||
->withQueryParams([
|
||||
'page' => ['limit' => '2', 'offset' => '2'],
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
$data = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
// Show second page of groups
|
||||
$this->assertEquals(['3', '4'], Arr::pluck($data['data'], 'id'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function paginates_groups_with_filter()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('GET', '/api/groups')
|
||||
->withQueryParams([
|
||||
'filter' => ['hidden' => 1],
|
||||
'page' => ['limit' => '1', 'offset' => '1'],
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
$data = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
// Show second page of groups. Because there is only one hidden group,
|
||||
// second page should be empty.
|
||||
$this->assertEmpty($data['data']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function sorts_groups_by_name()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('GET', '/api/groups', [
|
||||
'authenticatedAs' => 1,
|
||||
])
|
||||
->withQueryParams([
|
||||
'sort' => 'nameSingular',
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
$data = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
// Ascending alphabetical order is: Admin - Guest - Hidden - Member - Mod
|
||||
$this->assertEquals(['1', '2', '10', '3', '4'], Arr::pluck($data['data'], 'id'));
|
||||
}
|
||||
|
||||
protected function hiddenGroup(): array
|
||||
{
|
||||
return [
|
||||
|
126
tests/integration/api/groups/ShowTest.php
Normal file
126
tests/integration/api/groups/ShowTest.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?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.
|
||||
*/
|
||||
|
||||
namespace Flarum\Tests\integration\api\groups;
|
||||
|
||||
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
|
||||
use Flarum\Testing\integration\TestCase;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class ShowTest extends TestCase
|
||||
{
|
||||
use RetrievesAuthorizedUsers;
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->prepareDatabase([
|
||||
'groups' => [
|
||||
$this->hiddenGroup(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function shows_public_group_for_guest()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('GET', '/api/groups/1')
|
||||
);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
$data = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
// Default group created by the installer should be returned
|
||||
$this->assertEquals('1', Arr::get($data, 'data.id'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function shows_public_group_for_admin()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('GET', '/api/groups/1', [
|
||||
'authenticatedAs' => 1,
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
$data = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
// Default group created by the installer should be returned
|
||||
$this->assertEquals('1', Arr::get($data, 'data.id'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function hides_hidden_group_for_guest()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('GET', '/api/groups/10')
|
||||
);
|
||||
|
||||
// Hidden group should not be returned for guest
|
||||
$this->assertEquals(404, $response->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function shows_hidden_group_for_admin()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('GET', '/api/groups/10', [
|
||||
'authenticatedAs' => 1,
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
$data = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
// Hidden group should be returned for admin
|
||||
$this->assertEquals('10', Arr::get($data, 'data.id'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function rejects_request_for_non_existing_group()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('GET', '/api/groups/999', [
|
||||
'authenticatedAs' => 1,
|
||||
])
|
||||
);
|
||||
|
||||
// If group does not exist in database, controller
|
||||
// should reject the request with 404 Not found
|
||||
$this->assertEquals(404, $response->getStatusCode());
|
||||
}
|
||||
|
||||
protected function hiddenGroup(): array
|
||||
{
|
||||
return [
|
||||
'id' => 10,
|
||||
'name_singular' => 'Hidden',
|
||||
'name_plural' => 'Ninjas',
|
||||
'color' => null,
|
||||
'icon' => 'fas fa-wrench',
|
||||
'is_hidden' => 1
|
||||
];
|
||||
}
|
||||
}
|
82
tests/integration/api/posts/ShowTest.php
Normal file
82
tests/integration/api/posts/ShowTest.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?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.
|
||||
*/
|
||||
|
||||
namespace Flarum\Tests\integration\api\posts;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
|
||||
use Flarum\Testing\integration\TestCase;
|
||||
|
||||
class ShowTest extends TestCase
|
||||
{
|
||||
use RetrievesAuthorizedUsers;
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->prepareDatabase([
|
||||
'discussions' => [
|
||||
['id' => 1, 'title' => 'Discussion with post', 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'first_post_id' => 1, 'comment_count' => 1, 'is_private' => 0],
|
||||
],
|
||||
'posts' => [
|
||||
['id' => 1, 'discussion_id' => 1, 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'type' => 'comment', 'content' => '<t><p>valid</p></t>'],
|
||||
['id' => 2, 'discussion_id' => 1, 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'type' => 'comment', 'content' => '<tMALFORMED'],
|
||||
],
|
||||
'users' => [
|
||||
$this->normalUser(),
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function properly_formatted_post_rendered_correctly()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('GET', '/api/posts/1', [
|
||||
'authenticatedAs' => 2,
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
|
||||
$body = (string) $response->getBody();
|
||||
$this->assertJson($body);
|
||||
|
||||
$data = json_decode($body, true);
|
||||
|
||||
$this->assertEquals($data['data']['attributes']['contentHtml'], '<p>valid</p>');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function malformed_post_caught_by_renderer()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('GET', '/api/posts/2', [
|
||||
'authenticatedAs' => 2,
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
|
||||
$body = (string) $response->getBody();
|
||||
$this->assertJson($body);
|
||||
|
||||
$data = json_decode($body, true);
|
||||
|
||||
$this->assertEquals("Sorry, we encountered an error while displaying this content. If you're a user, please try again later. If you're an administrator, take a look in your Flarum log files for more information.", $data['data']['attributes']['contentHtml']);
|
||||
}
|
||||
}
|
@@ -44,4 +44,41 @@ class RouteCollectionTest extends TestCase
|
||||
|
||||
$this->assertEquals('/posts', $routeCollection->getPath('forum.posts.delete'));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function must_provide_required_parameters()
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage("Could not generate URL for route 'user': no value provided for required part 'user'.");
|
||||
|
||||
$routeCollection = (new RouteCollection)->addRoute('GET', '/user/{user}', 'user', function () {
|
||||
echo 'user';
|
||||
});
|
||||
|
||||
$routeCollection->getPath('user', []);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function dont_need_to_provide_optional_parameters()
|
||||
{
|
||||
$routeCollection = (new RouteCollection)->addRoute('GET', '/user/{user}[/{test}]', 'user', function () {
|
||||
echo 'user';
|
||||
});
|
||||
|
||||
$path = $routeCollection->getPath('user', ['user' => 'SomeUser']);
|
||||
|
||||
$this->assertEquals('/user/SomeUser', $path);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_provide_optional_parameters()
|
||||
{
|
||||
$routeCollection = (new RouteCollection)->addRoute('GET', '/user/{user}[/{test}]', 'user', function () {
|
||||
echo 'user';
|
||||
});
|
||||
|
||||
$path = $routeCollection->getPath('user', ['user' => 'SomeUser', 'test' => 'Flarum']);
|
||||
|
||||
$this->assertEquals('/user/SomeUser/Flarum', $path);
|
||||
}
|
||||
}
|
||||
|
@@ -19,11 +19,11 @@
|
||||
<input type="hidden" name="passwordToken" value="{{ $passwordToken }}">
|
||||
|
||||
<p class="form-group">
|
||||
<input type="password" class="form-control" name="password" placeholder="{{ $translator->trans('core.views.reset_password.new_password_label') }}">
|
||||
<input type="password" class="form-control" name="password" autocomplete="new-password" placeholder="{{ $translator->trans('core.views.reset_password.new_password_label') }}">
|
||||
</p>
|
||||
|
||||
<p class="form-group">
|
||||
<input type="password" class="form-control" name="password_confirmation" placeholder="{{ $translator->trans('core.views.reset_password.confirm_password_label') }}">
|
||||
<input type="password" class="form-control" name="password_confirmation" autocomplete="new-password" placeholder="{{ $translator->trans('core.views.reset_password.confirm_password_label') }}">
|
||||
</p>
|
||||
|
||||
<p class="form-group">
|
||||
|
Reference in New Issue
Block a user