diff --git a/app/Http/Controllers/GroupHistoryController.php b/app/Http/Controllers/GroupHistoryController.php new file mode 100644 index 00000000000..0d409450179 --- /dev/null +++ b/app/Http/Controllers/GroupHistoryController.php @@ -0,0 +1,86 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace App\Http\Controllers; + +use App\Models\Group; +use App\Models\User; +use App\Models\UserGroupEvent; + +class GroupHistoryController extends Controller +{ + public function index() + { + $rawParams = request()->all(); + $params = get_params($rawParams, null, [ + 'after:time', + 'before:time', + 'group:string', + 'sort:string', + 'user:string', + ], ['null_missing' => true]); + $query = UserGroupEvent::visibleForUser(auth()->user()); + $skipQuery = false; + + if ($params['after'] !== null) { + $query->where('created_at', '>', $params['after']); + } + + if ($params['before'] !== null) { + $query->where('created_at', '<', $params['before']); + } + + if ($params['group'] !== null) { + // Not `app('groups')->byIdentifier(...)` because that would create the group if not found + $groupId = app('groups')->allByIdentifier()->get($params['group'])?->getKey(); + + if ($groupId !== null) { + $query->where('group_id', $groupId); + } else { + $skipQuery = true; + } + } + + if ($params['user'] !== null) { + $userId = User::lookupWithHistory($params['user'], null, true)?->getKey(); + + if ($userId !== null) { + $query->where('user_id', $userId); + } else { + $skipQuery = true; + } + } + + if ($skipQuery) { + $cursor = null; + $events = collect(); + } else { + $cursorHelper = UserGroupEvent::makeDbCursorHelper($params['sort']); + [$events, $hasMore] = $query + ->cursorSort($cursorHelper, cursor_from_params($rawParams)) + ->limit(50) + ->getWithHasMore(); + $cursor = $cursorHelper->next($events, $hasMore); + } + + $eventGroupIds = $events->pluck('group_id'); + $groups = app('groups')->all()->filter( + fn (Group $group) => + $eventGroupIds->contains($group->getKey()) || + priv_check('GroupShow', $group)->can(), + ); + $json = [ + 'events' => json_collection($events, 'UserGroupEvent'), + 'groups' => json_collection($groups, 'Group'), + ...cursor_for_response($cursor), + ]; + + return is_json_request() + ? $json + : ext_view('group_history.index', compact('json')); + } +} diff --git a/app/Libraries/OsuAuthorize.php b/app/Libraries/OsuAuthorize.php index 8f562da0e4d..bc9116222b0 100644 --- a/app/Libraries/OsuAuthorize.php +++ b/app/Libraries/OsuAuthorize.php @@ -21,6 +21,7 @@ use App\Models\Forum\Topic; use App\Models\Forum\TopicCover; use App\Models\Genre; +use App\Models\Group; use App\Models\Language; use App\Models\LegacyMatch\LegacyMatch; use App\Models\Multiplayer\Room; @@ -30,6 +31,7 @@ use App\Models\Traits\ReportableInterface; use App\Models\User; use App\Models\UserContestEntry; +use App\Models\UserGroupEvent; use Carbon\Carbon; use Ds; @@ -1767,6 +1769,15 @@ public function checkForumTopicVote(?User $user, Topic $topic): string return 'ok'; } + public function checkGroupShow(?User $user, Group $group): string + { + if ($group->hasListing() || $user?->isGroup($group)) { + return 'ok'; + } + + return 'unauthorized'; + } + public function checkIsOwnClient(?User $user, Client $client): string { if ($user === null || $user->getKey() !== $client->user_id) { @@ -1914,6 +1925,24 @@ public function checkScorePin(?User $user, ScoreBest|Solo\Score $score): string return 'ok'; } + public function checkUserGroupEventShowActor(?User $user, UserGroupEvent $event): string + { + if ($event->group->identifier === 'default') { + return $user?->isPrivileged() ? 'ok' : 'unauthorized'; + } + + if ($user?->isGroup($event->group)) { + return 'ok'; + } + + return 'unauthorized'; + } + + public function checkUserGroupEventShowAll(?User $user): string + { + return 'unauthorized'; + } + /** * @param User|null $user * @param User $pageOwner diff --git a/app/Libraries/RouteSection.php b/app/Libraries/RouteSection.php index af03c091baf..ef13fd82132 100644 --- a/app/Libraries/RouteSection.php +++ b/app/Libraries/RouteSection.php @@ -70,6 +70,9 @@ class RouteSection 'friends_controller' => [ '_' => 'home', ], + 'group_history_controller' => [ + '_' => 'home', + ], 'groups_controller' => [ '_' => 'home', ], diff --git a/app/Models/UserGroupEvent.php b/app/Models/UserGroupEvent.php index 801ed25b403..90ee7aebc68 100644 --- a/app/Models/UserGroupEvent.php +++ b/app/Models/UserGroupEvent.php @@ -7,6 +7,8 @@ namespace App\Models; +use App\Models\Traits\WithDbCursorHelper; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\BelongsTo; use InvalidArgumentException; @@ -22,9 +24,12 @@ * @property string $type * @property-read User|null $user * @property int|null $user_id + * @method static Builder visibleForUser(User|null $user) */ class UserGroupEvent extends Model { + use WithDbCursorHelper; + public const GROUP_ADD = 'group_add'; public const GROUP_REMOVE = 'group_remove'; public const GROUP_RENAME = 'group_rename'; @@ -36,6 +41,16 @@ class UserGroupEvent extends Model public const UPDATED_AT = null; + protected const DEFAULT_SORT = 'id_desc'; + protected const SORTS = [ + 'id_asc' => [ + ['column' => 'id', 'order' => 'ASC'], + ], + 'id_desc' => [ + ['column' => 'id', 'order' => 'DESC'], + ], + ]; + protected $casts = [ 'details' => 'array', 'hidden' => 'boolean', @@ -147,6 +162,23 @@ public function user(): BelongsTo return $this->belongsTo(User::class, 'user_id'); } + public function scopeVisibleForUser(Builder $query, ?User $user): void + { + if (priv_check_user($user, 'UserGroupEventShowAll')->can()) { + return; + } + + $query->where('hidden', false); + + $userGroupIds = priv_check_user($user, 'IsSpecialScope')->can() + ? $user->groupIds()['active'] + : []; + + if (!empty($userGroupIds)) { + $query->orWhereIn('group_id', $userGroupIds); + } + } + public function getAttribute($key) { return match ($key) { diff --git a/app/Transformers/UserGroupEventTransformer.php b/app/Transformers/UserGroupEventTransformer.php new file mode 100644 index 00000000000..a750ea2c756 --- /dev/null +++ b/app/Transformers/UserGroupEventTransformer.php @@ -0,0 +1,51 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace App\Transformers; + +use App\Models\UserGroupEvent; +use League\Fractal\Resource\ResourceInterface; + +class UserGroupEventTransformer extends TransformerAbstract +{ + protected array $availableIncludes = [ + 'actor', + ]; + + protected array $defaultIncludes = [ + 'actor', + ]; + + protected $permissions = [ + 'actor' => 'UserGroupEventShowActor', + ]; + + public function transform(UserGroupEvent $event): array + { + $json = [ + 'created_at' => $event->created_at_json, + 'group_id' => $event->group_id, + 'hidden' => $event->isHidden(), + 'id' => $event->id, + 'type' => $event->type, + 'user_id' => $event->user_id, + ...$event->details, + ]; + + unset($json['actor_name']); + + return $json; + } + + public function includeActor(UserGroupEvent $event): ResourceInterface + { + return $this->primitive([ + 'id' => $event->actor_id, + 'name' => $event->details['actor_name'], + ]); + } +} diff --git a/database/factories/GroupFactory.php b/database/factories/GroupFactory.php index 18d6626f8cf..600da300d72 100644 --- a/database/factories/GroupFactory.php +++ b/database/factories/GroupFactory.php @@ -13,11 +13,22 @@ class GroupFactory extends Factory { protected $model = Group::class; + public function configure(): static + { + return $this->afterCreating(function () { + app('groups')->resetMemoized(); + }); + } + public function definition(): array { return [ 'group_name' => fn() => "{$this->faker->colorName()} {$this->faker->domainWord()}", 'group_desc' => fn() => $this->faker->sentence(), + 'identifier' => fn () => $this->faker->domainWord(), + + // depends on identifier + 'short_name' => fn (array $attr) => strtoupper($attr['identifier']), ]; } } diff --git a/database/factories/UserGroupEventFactory.php b/database/factories/UserGroupEventFactory.php new file mode 100644 index 00000000000..71bd892941a --- /dev/null +++ b/database/factories/UserGroupEventFactory.php @@ -0,0 +1,66 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace Database\Factories; + +use App\Models\Beatmap; +use App\Models\Group; +use App\Models\User; +use App\Models\UserGroupEvent; + +class UserGroupEventFactory extends Factory +{ + protected $model = UserGroupEvent::class; + + public function configure(): static + { + // Fill in details after making the Model so that the caller can set + // their own details without overwriting the entire array. + return $this->afterMaking(function (UserGroupEvent $event) { + $defaultDetails = [ + 'actor_name' => $event->actor?->username, + 'group_name' => $event->group->group_name, + 'user_name' => $event->user?->username, + ]; + + match ($event->type) { + UserGroupEvent::GROUP_RENAME => $defaultDetails['previous_group_name'] = + "Old {$event->group->group_name}", + + UserGroupEvent::USER_ADD, + UserGroupEvent::USER_ADD_PLAYMODES, + UserGroupEvent::USER_REMOVE_PLAYMODES => $defaultDetails['playmodes'] = + $this->faker->randomElements(array_keys(Beatmap::MODES)), + + default => null, + }; + + $event->details = array_merge($defaultDetails, $event->details); + }); + } + + public function definition(): array + { + return [ + 'actor_id' => User::factory(), + 'details' => [], + 'group_id' => Group::factory(), + 'hidden' => false, + 'type' => fn () => $this->faker->randomElement([ + UserGroupEvent::GROUP_ADD, + UserGroupEvent::GROUP_REMOVE, + UserGroupEvent::GROUP_RENAME, + UserGroupEvent::USER_ADD, + UserGroupEvent::USER_ADD_PLAYMODES, + UserGroupEvent::USER_REMOVE, + UserGroupEvent::USER_REMOVE_PLAYMODES, + UserGroupEvent::USER_SET_DEFAULT, + ]), + 'user_id' => User::factory(), + ]; + } +} diff --git a/package.json b/package.json index f69f4f855f0..4c0264aeacd 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ }, "dependencies": { "@discordapp/twemoji": "^14.0.2", - "@fortawesome/fontawesome-free": "^5.6.3", + "@fortawesome/fontawesome-free": "^5.15.4", "@types/bootstrap": "^3.3.0", "@types/d3": "^7.1.0", "@types/grecaptcha": "^3.0.1", diff --git a/resources/css/bem-index.less b/resources/css/bem-index.less index 71b4044667c..4a90ab34aff 100644 --- a/resources/css/bem-index.less +++ b/resources/css/bem-index.less @@ -178,6 +178,9 @@ @import "bem/game-mode"; @import "bem/game-mode-link"; @import "bem/grid-items"; +@import "bem/group-history"; +@import "bem/group-history-event"; +@import "bem/group-history-search-form"; @import "bem/header-buttons"; @import "bem/header-nav-mobile"; @import "bem/header-nav-v4"; diff --git a/resources/css/bem/btn-osu-big.less b/resources/css/bem/btn-osu-big.less index 51b87cbbcbd..2394496ecaa 100644 --- a/resources/css/bem/btn-osu-big.less +++ b/resources/css/bem/btn-osu-big.less @@ -349,6 +349,10 @@ margin-top: 10px; } + &--user-list-title { + flex-shrink: 0; + } + &--user-page-edit { border-radius: 10000px; width: 140px; diff --git a/resources/css/bem/group-history-event.less b/resources/css/bem/group-history-event.less new file mode 100644 index 00000000000..047659bae5d --- /dev/null +++ b/resources/css/bem/group-history-event.less @@ -0,0 +1,64 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +.group-history-event { + @_top: group-history-event; + + align-items: center; + display: flex; + font-size: @font-size--title-small; + gap: 15px; + + &__icon { + @icons: { + group-add: users; + group-remove: users-slash; + group-rename: users-cog; + user-add: user-plus; + user-add-playmodes: user-tag; + user-remove: user-minus; + user-remove-playmodes: user-tag; + user-set-default: user-cog; + }; + each(@icons, { + .@{_top}--@{key} & { + @icon-var: 'fa-var-@{value}'; + --icon: @@icon-var; + } + }); + + .fas(); + background-color: var(--group-colour, @osu-colour-b1); + border-radius: 10000px; + color: @osu-colour-b6; + padding: 3px 6px; + + &::before { + content: var(--icon); + } + } + + &__info { + color: @osu-colour-f1; + display: flex; + flex-direction: column; + flex-shrink: 0; + font-size: @font-size--normal; + gap: 0 15px; + + @media @desktop { + flex-direction: row-reverse; + } + } + + &__message { + @bold-events: group-add, group-remove, group-rename; + each(@bold-events, { + .@{_top}--@{value} & { + font-weight: bold; + } + }); + + flex-grow: 1; + } +} diff --git a/resources/css/bem/group-history-search-form.less b/resources/css/bem/group-history-search-form.less new file mode 100644 index 00000000000..8ba7d3657e5 --- /dev/null +++ b/resources/css/bem/group-history-search-form.less @@ -0,0 +1,63 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +.group-history-search-form { + --input-bg: @osu-colour-b5; + --input-border-radius: @border-radius--large; + background: @osu-colour-b4; + + &__content { + --vertical-gutter: 20px; + .default-gutter-v2(); + padding-top: var(--vertical-gutter); + padding-bottom: var(--vertical-gutter); + + &--buttons { + --vertical-gutter: 10px; + background-color: @osu-colour-b3; + display: flex; + gap: 10px; + justify-content: center; + } + + &--inputs { + display: grid; + gap: 10px; + grid-template-columns: repeat(2, 1fr) repeat(2, 180px); + + @media @mobile { + grid-template-columns: repeat(2, 1fr); + + > :nth-child(-n + 2) { + grid-column: span 2; + } + } + } + } + + &__input { + .reset-input(); + font-size: @font-size--title-small-3; + width: 100%; + } + + &__label { + color: var(--label-colour); + padding-bottom: 5px; + } + + &__select-container { + position: relative; + + &::after { + .fas(); + .center-content(); + content: @fa-var-chevron-down; + height: 100%; + padding-left: 10px; + position: absolute; + right: 5px; + pointer-events: none; + } + } +} diff --git a/resources/css/bem/group-history.less b/resources/css/bem/group-history.less new file mode 100644 index 00000000000..bda5856168d --- /dev/null +++ b/resources/css/bem/group-history.less @@ -0,0 +1,22 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +.group-history { + &__events { + display: flex; + flex-direction: column; + gap: 10px; + } + + &__none { + font-size: @font-size--title-small-3; + margin: 0; + text-align: center; + } + + &__staff-log { + font-size: @font-size--normal; + margin: 20px 0 0; + text-align: center; + } +} diff --git a/resources/css/bem/show-more-link.less b/resources/css/bem/show-more-link.less index 7e60b002891..226815b2e71 100644 --- a/resources/css/bem/show-more-link.less +++ b/resources/css/bem/show-more-link.less @@ -38,7 +38,8 @@ margin: 40px 0; } - &--chat-conversation-earlier-messages { + &--chat-conversation-earlier-messages, + &--group-history { margin: 20px auto 0; } diff --git a/resources/css/bem/user-list.less b/resources/css/bem/user-list.less index e50a718ec8c..df96dc1277b 100644 --- a/resources/css/bem/user-list.less +++ b/resources/css/bem/user-list.less @@ -17,6 +17,10 @@ &__title { .default-gutter-v2(); + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 10px; padding-top: 20px; padding-bottom: 0; margin: 0; diff --git a/resources/js/components/user-list.tsx b/resources/js/components/user-list.tsx index 7aac01adf38..3825a7e212d 100644 --- a/resources/js/components/user-list.tsx +++ b/resources/js/components/user-list.tsx @@ -4,6 +4,7 @@ import GameMode from 'interfaces/game-mode'; import GroupJson from 'interfaces/group-json'; import UserJson from 'interfaces/user-json'; +import { route } from 'laroute'; import { usernameSortAscending } from 'models/user'; import * as moment from 'moment'; import core from 'osu-core-singleton'; @@ -12,6 +13,7 @@ import { classWithModifiers } from 'utils/css'; import { trans } from 'utils/lang'; import { currentUrlParams } from 'utils/turbolinks'; import { updateQueryString } from 'utils/url'; +import BigButton from './big-button'; import { Sort } from './sort'; import { ViewMode, viewModes } from './user-card'; import { UserCards } from './user-cards'; @@ -151,7 +153,15 @@ export class UserList extends React.PureComponent {
{this.props.group != null && ( -

{this.props.group.name}

+

+ {this.props.group.name} + +

)} {this.props.group?.description != null && ( diff --git a/resources/js/entrypoints/group-history.tsx b/resources/js/entrypoints/group-history.tsx new file mode 100644 index 00000000000..a7cc98bb67d --- /dev/null +++ b/resources/js/entrypoints/group-history.tsx @@ -0,0 +1,17 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +import groupStore from 'group-history/group-store'; +import GroupHistoryJson from 'group-history/json'; +import Main from 'group-history/main'; +import core from 'osu-core-singleton'; +import * as React from 'react'; +import { parseJson } from 'utils/json'; + +core.reactTurbolinks.register('group-history', () => { + const json: GroupHistoryJson = parseJson('json-group-history'); + + groupStore.updateMany(json.groups); + + return
; +}); diff --git a/resources/js/group-history/event.tsx b/resources/js/group-history/event.tsx new file mode 100644 index 00000000000..7bcf1117c76 --- /dev/null +++ b/resources/js/group-history/event.tsx @@ -0,0 +1,92 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +import StringWithComponent from 'components/string-with-component'; +import TimeWithTooltip from 'components/time-with-tooltip'; +import UserLink from 'components/user-link'; +import UserGroupEventJson from 'interfaces/user-group-event-json'; +import { route } from 'laroute'; +import { kebabCase } from 'lodash'; +import * as React from 'react'; +import { classWithModifiers, groupColour } from 'utils/css'; +import { trans, transArray } from 'utils/lang'; +import groupStore from './group-store'; + +interface Props { + event: UserGroupEventJson; +} + +export default class Event extends React.PureComponent { + private get messageMappings() { + const event = this.props.event; + const mappings: Record = { + group: ( + + {event.group_name} + + ), + }; + + if ('playmodes' in event && event.playmodes != null) { + mappings.playmodes = transArray( + event.playmodes.map((mode) => trans(`beatmaps.mode.${mode}`)), + ); + } + + if ('previous_group_name' in event) { + mappings.previous_group = ( + + {event.previous_group_name} + + ); + } + + if (event.user_id != null) { + mappings.user = ; + } + + return mappings; + } + + private get messagePattern() { + const event = this.props.event; + const type = event.type === 'user_add' && event.playmodes != null + ? 'user_add_with_playmodes' + : event.type; + + return trans(`group_history.event.message.${type}`); + } + + render() { + return ( +
+ +
+ +
+
+ + {this.props.event.actor?.id != null && ( + + , + }} + pattern={trans('group_history.event.actor')} + /> + + )} +
+
+ ); + } +} diff --git a/resources/js/group-history/events.tsx b/resources/js/group-history/events.tsx new file mode 100644 index 00000000000..86aa5524594 --- /dev/null +++ b/resources/js/group-history/events.tsx @@ -0,0 +1,29 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +import UserGroupEventJson from 'interfaces/user-group-event-json'; +import { observer } from 'mobx-react'; +import * as React from 'react'; +import { trans } from 'utils/lang'; +import Event from './event'; + +interface Props { + events: UserGroupEventJson[]; +} + +@observer +export default class Events extends React.Component { + render() { + return this.props.events.length > 0 ? ( +
+ {this.props.events.map((event) => ( + + ))} +
+ ) : ( +

+ {trans('group_history.none')} +

+ ); + } +} diff --git a/resources/js/group-history/group-store.ts b/resources/js/group-history/group-store.ts new file mode 100644 index 00000000000..ac7b3fec0bd --- /dev/null +++ b/resources/js/group-history/group-store.ts @@ -0,0 +1,45 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +import GroupJson from 'interfaces/group-json'; +import { sortBy } from 'lodash'; +import { action, computed, makeObservable, observable } from 'mobx'; + +class GroupStore { + @observable byId = observable.map(); + + @computed + get byIdentifier() { + return this.groups.reduce( + (prev, group) => { + prev.set(group.identifier, group); + return prev; + }, + new Map(), + ); + } + + @computed + get groups() { + return sortBy([...this.byId.values()], 'name'); + } + + constructor() { + makeObservable(this); + } + + @action + update(group: GroupJson): void { + this.byId.set(group.id, group); + } + + @action + updateMany(groups: GroupJson[]): void { + for (const group of groups) { + this.update(group); + } + } +} + +const groupStore = new GroupStore(); +export default groupStore; diff --git a/resources/js/group-history/json.ts b/resources/js/group-history/json.ts new file mode 100644 index 00000000000..36176688b71 --- /dev/null +++ b/resources/js/group-history/json.ts @@ -0,0 +1,11 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +import GroupJson from 'interfaces/group-json'; +import UserGroupEventJson from 'interfaces/user-group-event-json'; + +export default interface GroupHistoryJson { + cursor_string: string | null; + events: UserGroupEventJson[]; + groups: GroupJson[]; +} diff --git a/resources/js/group-history/main.tsx b/resources/js/group-history/main.tsx new file mode 100644 index 00000000000..9d28d247f79 --- /dev/null +++ b/resources/js/group-history/main.tsx @@ -0,0 +1,133 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +import HeaderV4 from 'components/header-v4'; +import ShowMoreLink from 'components/show-more-link'; +import StringWithComponent from 'components/string-with-component'; +import UserGroupEventJson from 'interfaces/user-group-event-json'; +import { route } from 'laroute'; +import { action, makeObservable, observable } from 'mobx'; +import { observer } from 'mobx-react'; +import * as React from 'react'; +import { onErrorWithCallback } from 'utils/ajax'; +import { trans } from 'utils/lang'; +import { wikiUrl } from 'utils/url'; +import Events from './events'; +import groupStore from './group-store'; +import GroupHistoryJson from './json'; +import { getQueryFromUrl, GroupHistoryQuery, setUrlFromQuery } from './query'; +import SearchForm from './search-form'; + +interface Props { + cursorString: string | null; + events: UserGroupEventJson[]; +} + +@observer +export default class Main extends React.Component { + @observable private currentQuery: GroupHistoryQuery; + @observable private cursorString: string | null; + @observable private events: UserGroupEventJson[]; + @observable private loading?: 'more' | 'new'; + @observable private newQuery: GroupHistoryQuery; + private xhr?: JQuery.jqXHR; + + constructor(props: Props) { + super(props); + + const { parseError, query } = getQueryFromUrl(); + + this.currentQuery = query; + this.cursorString = props.cursorString; + this.events = props.events; + this.newQuery = { ...this.currentQuery }; + + makeObservable(this); + + if (parseError) { + this.onSearch(); + } + } + + componentWillUnmount() { + this.xhr?.abort(); + } + + render() { + return ( + <> + +
+ +
+
+ + +

+ + {trans('group_history.staff_log.wiki_articles')} + + ), + }} + pattern={trans('group_history.staff_log._')} + /> +

+
+ + ); + } + + @action + private loadEvents(query: GroupHistoryQuery & { cursor_string?: string }) { + this.xhr?.abort(); + this.loading = query.cursor_string == null ? 'new' : 'more'; + + this.xhr = $.ajax( + route('group-history.index'), + { + data: query, + dataType: 'JSON', + method: 'GET', + }, + ); + this.xhr + .done(action((response: GroupHistoryJson) => { + this.cursorString = response.cursor_string; + groupStore.updateMany(response.groups); + + if (query.cursor_string == null) { + this.currentQuery = { ...query }; + this.events = response.events; + setUrlFromQuery(query); + } else { + this.events.push(...response.events); + } + })) + .fail(onErrorWithCallback(() => this.loadEvents(query))) + .always(action(() => this.loading = undefined)); + } + + private readonly onSearch = () => this.loadEvents(this.newQuery); + + private readonly onShowMore = () => { + if (this.cursorString != null) { + this.loadEvents({ + ...this.currentQuery, + cursor_string: this.cursorString, + }); + } + }; +} diff --git a/resources/js/group-history/query.ts b/resources/js/group-history/query.ts new file mode 100644 index 00000000000..ed266607bdc --- /dev/null +++ b/resources/js/group-history/query.ts @@ -0,0 +1,55 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +import moment from 'moment'; +import { currentUrlParams } from 'utils/turbolinks'; +import { updateQueryString } from 'utils/url'; +import groupStore from './group-store'; + +export interface GroupHistoryQuery { + after?: string; + before?: string; + group?: string; + user?: string; +} + +export const emptyQuery: Readonly = Object.freeze({ + after: undefined, + before: undefined, + group: undefined, + user: undefined, +}); + +const paramValidators: Record boolean> = { + after: (value: string) => moment(value, moment.HTML5_FMT.DATE, true).isValid(), + before: (value: string) => moment(value, moment.HTML5_FMT.DATE, true).isValid(), + group: (value: string) => groupStore.byIdentifier.has(value), + user: (value: string) => value.length > 0, +}; + +export function getQueryFromUrl(): { parseError: boolean; query: GroupHistoryQuery } { + const params = currentUrlParams(); + let parseError = false; + const query: GroupHistoryQuery = {}; + + for (const key of Object.keys(emptyQuery) as (keyof GroupHistoryQuery)[]) { + const value = params.get(key); + + if (value != null && !paramValidators[key](value)) { + parseError = true; + query[key] = undefined; + } else { + query[key] = value ?? undefined; + } + } + + return { parseError, query }; +} + +export function setUrlFromQuery(query: Readonly): void { + history.replaceState( + history.state, + '', + updateQueryString(null, query), + ); +} diff --git a/resources/js/group-history/search-form.tsx b/resources/js/group-history/search-form.tsx new file mode 100644 index 00000000000..bca1da12ef7 --- /dev/null +++ b/resources/js/group-history/search-form.tsx @@ -0,0 +1,148 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +import BigButton from 'components/big-button'; +import InputContainer from 'components/input-container'; +import { isEqual } from 'lodash'; +import { action, computed, makeObservable } from 'mobx'; +import { observer } from 'mobx-react'; +import * as React from 'react'; +import { trans } from 'utils/lang'; +import groupStore from './group-store'; +import { emptyQuery, GroupHistoryQuery } from './query'; + +const bn = 'group-history-search-form'; + +interface Props { + currentQuery: GroupHistoryQuery; + loading: boolean; + newQuery: GroupHistoryQuery; + onSearch: () => void; +} + +@observer +export default class SearchForm extends React.Component { + @computed + private get newQueryIsEmpty() { + return isEqual(this.props.newQuery, emptyQuery); + } + + @computed + private get newQueryIsSame() { + return isEqual(this.props.newQuery, this.props.currentQuery); + } + + constructor(props: Props) { + super(props); + makeObservable(this); + } + + render() { + return ( +
+
+ +
+ +
+
+ + + + + + + + + +
+
+ + +
+
+ ); + } + + @action + private readonly onDateChange = (event: React.ChangeEvent) => { + event.preventDefault(); + + this.props.newQuery[event.currentTarget.name as 'after' | 'before'] = + event.currentTarget.value || undefined; + }; + + @action + private readonly onGroupChange = (event: React.ChangeEvent) => { + event.preventDefault(); + + this.props.newQuery.group = event.currentTarget.value || undefined; + }; + + @action + private readonly onReset = (event: React.MouseEvent) => { + event.preventDefault(); + + Object.assign(this.props.newQuery, emptyQuery); + }; + + private readonly onSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + if (!this.newQueryIsSame) { + this.props.onSearch(); + } + }; + + @action + private readonly onUserChange = (event: React.ChangeEvent) => { + event.preventDefault(); + + this.props.newQuery.user = event.currentTarget.value || undefined; + }; +} diff --git a/resources/js/interfaces/user-group-event-json.ts b/resources/js/interfaces/user-group-event-json.ts new file mode 100644 index 00000000000..5bad925aa0b --- /dev/null +++ b/resources/js/interfaces/user-group-event-json.ts @@ -0,0 +1,61 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +import GameMode from './game-mode'; + +interface UserGroupEventBase { + actor?: { + id: number; + name: string; + } | { + id: null; + name: null; + }; + created_at: string; + group_id: number; + group_name: string; + hidden: boolean; + id: number; +} + +interface GroupAddOrRemoveEvent extends UserGroupEventBase { + type: 'group_add' | 'group_remove'; + user_id: null; + user_name: null; +} + +interface GroupRenameEvent extends UserGroupEventBase { + previous_group_name: string; + type: 'group_rename'; + user_id: null; + user_name: null; +} + +interface UserAddEvent extends UserGroupEventBase { + playmodes: GameMode[] | null; + type: 'user_add'; + user_id: number; + user_name: string; +} + +interface UserAddOrRemovePlaymodesEvent extends UserGroupEventBase { + playmodes: GameMode[]; + type: 'user_add_playmodes' | 'user_remove_playmodes'; + user_id: number; + user_name: string; +} + +interface UserRemoveOrSetDefaultEvent extends UserGroupEventBase { + type: 'user_remove' | 'user_set_default'; + user_id: number; + user_name: string; +} + +type UserGroupEventJson = + | GroupAddOrRemoveEvent + | GroupRenameEvent + | UserAddEvent + | UserAddOrRemovePlaymodesEvent + | UserRemoveOrSetDefaultEvent; + +export default UserGroupEventJson; diff --git a/resources/lang/en/group_history.php b/resources/lang/en/group_history.php new file mode 100644 index 00000000000..ec3f31dcb14 --- /dev/null +++ b/resources/lang/en/group_history.php @@ -0,0 +1,39 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +return [ + 'none' => 'No group history found!', + 'view' => 'View group history', + + 'event' => [ + 'actor' => 'by :user', + + 'message' => [ + 'group_add' => ':group created.', + 'group_remove' => ':group deleted.', + 'group_rename' => ':previous_group renamed to :group.', + 'user_add' => ':user added to :group.', + 'user_add_with_playmodes' => ':user added to :group for :playmodes.', + 'user_add_playmodes' => ':playmodes added to :user\'s :group membership.', + 'user_remove' => ':user removed from :group.', + 'user_remove_playmodes' => ':playmodes removed from :user\'s :group membership.', + 'user_set_default' => ':user\'s default group set to :group.', + ], + ], + + 'form' => [ + 'after' => 'After', + 'before' => 'Before', + 'group' => 'Group', + 'group_all' => 'All groups', + 'user' => 'User', + 'user_prompt' => 'Username or ID', + ], + + 'staff_log' => [ + '_' => 'Older group history can be found in :wiki_articles.', + 'wiki_articles' => 'the staff log wiki articles', + ], +]; diff --git a/resources/lang/en/page_title.php b/resources/lang/en/page_title.php index 8d627192274..ce369e7ac1b 100644 --- a/resources/lang/en/page_title.php +++ b/resources/lang/en/page_title.php @@ -66,6 +66,9 @@ 'contests_controller' => [ '_' => 'contests', ], + 'group_history_controller' => [ + '_' => 'group history', + ], 'groups_controller' => [ 'show' => 'groups', ], diff --git a/resources/views/group_history/index.blade.php b/resources/views/group_history/index.blade.php new file mode 100644 index 00000000000..d235a94efa3 --- /dev/null +++ b/resources/views/group_history/index.blade.php @@ -0,0 +1,19 @@ +{{-- + Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. + See the LICENCE file in the repository root for full licence text. +--}} +@extends('master') + +@section('content') +
+@endsection + +@section("script") + @parent + + + + @include('layout._react_js', ['src' => 'js/group-history.js']) +@endsection diff --git a/routes/web.php b/routes/web.php index ac2994702d2..4c4da005da7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -195,6 +195,7 @@ Route::resource('chat', 'ChatController', ['only' => ['index']]); }); + Route::get('groups/history', 'GroupHistoryController@index')->name('group-history.index'); Route::resource('groups', 'GroupsController', ['only' => ['show']]); Route::group(['prefix' => 'home'], function () { diff --git a/tests/Browser/SanityTest.php b/tests/Browser/SanityTest.php index 8de65bb4184..8ce6760230d 100644 --- a/tests/Browser/SanityTest.php +++ b/tests/Browser/SanityTest.php @@ -257,8 +257,6 @@ private static function createScaffolding() self::$scaffolding['score'] = Score\Best\Osu::factory()->withReplay()->create(); self::$scaffolding['room'] = Room::factory()->create(['category' => 'spotlight']); - - app('groups')->resetMemoized(); } private static function filterLog(array $log) diff --git a/tests/Controllers/GroupHistoryControllerTest.php b/tests/Controllers/GroupHistoryControllerTest.php new file mode 100644 index 00000000000..990a241cd6b --- /dev/null +++ b/tests/Controllers/GroupHistoryControllerTest.php @@ -0,0 +1,91 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace Tests\Controllers; + +use App\Models\User; +use App\Models\UserGroupEvent; +use Illuminate\Database\Events\QueryExecuted; +use Tests\TestCase; + +class GroupHistoryControllerTest extends TestCase +{ + public function testIndexExcludesHiddenEventsWhenGuest(): void + { + $event = UserGroupEvent::factory()->create(['hidden' => true]); + + $response = $this + ->getJson(route('group-history.index')) + ->assertOk() + ->json(); + + $responseEventIds = collect($response['events'])->pluck('id'); + $this->assertNotContains($event->getKey(), $responseEventIds); + } + + public function testIndexExcludesHiddenEventsWhenNotInGroup(): void + { + $event = UserGroupEvent::factory()->create(['hidden' => true]); + $user = User::factory()->create(); + + $response = $this + ->actingAsVerified($user) + ->getJson(route('group-history.index')) + ->assertOk() + ->json(); + + $responseEventIds = collect($response['events'])->pluck('id'); + $this->assertNotContains($event->getKey(), $responseEventIds); + } + + public function testIndexIncludesHiddenEventsWhenInGroup(): void + { + $event = UserGroupEvent::factory()->create(['hidden' => true]); + $user = User::factory()->withGroup($event->group->identifier)->create(); + + $response = $this + ->actingAsVerified($user) + ->getJson(route('group-history.index')) + ->assertOk() + ->json(); + + $responseEventIds = collect($response['events'])->pluck('id'); + $this->assertContains($event->getKey(), $responseEventIds); + } + + public function testIndexNonexistentGroupSkipsQuery(): void + { + $queryCount = 0; + $this->getConnection()->listen(function (QueryExecuted $event) use (&$queryCount) { + if (strpos($event->sql, (new UserGroupEvent())->getTable()) !== false) { + $queryCount++; + } + }); + + $response = $this + ->getJson(route('group-history.index', ['group' => 'nonexistent group'])) + ->assertOk() + ->json(); + + $this->assertSame($queryCount, 0); + $this->assertNull($response['cursor_string']); + $this->assertEmpty($response['events']); + } + + public function textIndexListsEvents(): void + { + $event = UserGroupEvent::factory()->create(); + + $response = $this + ->getJson(route('group-history.index')) + ->assertOk() + ->json(); + + $responseEventIds = collect($response['events'])->pluck('id'); + $this->assertContains($event->getKey(), $responseEventIds); + } +} diff --git a/tests/Transformers/UserGroupEventTransformerTest.php b/tests/Transformers/UserGroupEventTransformerTest.php new file mode 100644 index 00000000000..9216a30b1b0 --- /dev/null +++ b/tests/Transformers/UserGroupEventTransformerTest.php @@ -0,0 +1,49 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace Tests\Transformers; + +use App\Models\User; +use App\Models\UserGroupEvent; +use Tests\TestCase; + +class UserGroupEventTransformerTest extends TestCase +{ + public function testActorNameNotInRoot(): void + { + $eventJson = json_item(UserGroupEvent::factory()->create(), 'UserGroupEvent'); + + $this->assertArrayNotHasKey('actor_name', $eventJson); + } + + public function testActorNotVisibleWhenGuest(): void + { + $eventJson = json_item(UserGroupEvent::factory()->create(), 'UserGroupEvent'); + + $this->assertArrayNotHasKey('actor', $eventJson); + } + + public function testActorNotVisibleWhenNotInGroup(): void + { + $this->actAsUser(User::factory()->create()); + + $eventJson = json_item(UserGroupEvent::factory()->create(), 'UserGroupEvent'); + + $this->assertArrayNotHasKey('actor', $eventJson); + } + + public function testActorVisibleWhenInGroup(): void + { + $event = UserGroupEvent::factory()->create(); + + $this->actAsUser(User::factory()->withGroup($event->group->identifier)->create()); + + $eventJson = json_item($event, 'UserGroupEvent'); + + $this->assertArrayHasKey('actor', $eventJson); + } +} diff --git a/yarn.lock b/yarn.lock index 2155208e7a3..b070815c1e7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -227,10 +227,10 @@ minimatch "^3.0.4" strip-json-comments "^3.1.1" -"@fortawesome/fontawesome-free@^5.6.3": - version "5.9.0" - resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-5.9.0.tgz#1aa5c59efb1b8c6eb6277d1e3e8c8f31998b8c8e" - integrity sha512-g795BBEzM/Hq2SYNPm/NQTIp3IWd4eXSH0ds87Na2jnrAUFX3wkyZAI4Gwj9DOaWMuz2/01i8oWI7P7T/XLkhg== +"@fortawesome/fontawesome-free@^5.15.4": + version "5.15.4" + resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.4.tgz#ecda5712b61ac852c760d8b3c79c96adca5554e5" + integrity sha512-eYm8vijH/hpzr/6/1CJ/V/Eb1xQFW2nnUKArb3z+yUWv7HTwj6M7SP957oMjfZjAHU6qpoNc2wQvIxBLWYa/Jg== "@istanbuljs/schema@^0.1.2": version "0.1.2"