diff --git a/app/Http/Controllers/GroupHistoryController.php b/app/Http/Controllers/GroupHistoryController.php index f63fe3d4a61..fb1589b5d25 100644 --- a/app/Http/Controllers/GroupHistoryController.php +++ b/app/Http/Controllers/GroupHistoryController.php @@ -7,6 +7,7 @@ namespace App\Http\Controllers; +use App\Models\Group; use App\Models\User; use App\Models\UserGroupEvent; @@ -16,11 +17,10 @@ public function index() { $rawParams = request()->all(); $params = get_params($rawParams, null, [ - 'group:string', + 'group', 'max_date:time', 'min_date:time', - 'sort:string', - 'user:string', + 'user', ], ['null_missing' => true]); $query = UserGroupEvent::visibleForUser(auth()->user()); @@ -35,11 +35,19 @@ public function index() } if ($params['max_date'] !== null) { + $params['max_date']->endOfDay(); + $query->where('created_at', '<=', $params['max_date']); + + $params['max_date'] = json_date($params['max_date']); } if ($params['min_date'] !== null) { + $params['min_date']->startOfDay(); + $query->where('created_at', '>=', $params['min_date']); + + $params['min_date'] = json_date($params['min_date']); } if ($params['user'] !== null) { @@ -52,16 +60,28 @@ public function index() } } - $cursorHelper = UserGroupEvent::makeDbCursorHelper($params['sort']); + $cursorHelper = UserGroupEvent::makeDbCursorHelper($rawParams['sort'] ?? null); + $params['sort'] = $cursorHelper->getSortName(); [$events, $hasMore] = $query ->cursorSort($cursorHelper, cursor_from_params($rawParams)) ->limit(50) ->getWithHasMore(); - $cursor = $cursorHelper->next($events, $hasMore); - return [ + $eventGroupIds = $events->pluck('group_id'); + $groups = app('groups')->all()->filter( + fn (Group $group) => + $eventGroupIds->contains($group->getKey()) || + priv_check('GroupShow', $group)->can(), + ); + $json = [ + ...cursor_for_response($cursorHelper->next($events, $hasMore)), 'events' => json_collection($events, 'UserGroupEvent'), - ...cursor_for_response($cursor), + 'groups' => json_collection($groups, 'Group'), + 'params' => $params, ]; + + return is_json_request() + ? $json + : ext_view('group_history.index', compact('json')); } } diff --git a/app/Singletons/OsuAuthorize.php b/app/Singletons/OsuAuthorize.php index 1ae47f43737..fa124b82ce7 100644 --- a/app/Singletons/OsuAuthorize.php +++ b/app/Singletons/OsuAuthorize.php @@ -23,6 +23,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; @@ -1805,6 +1806,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) { @@ -1954,6 +1964,10 @@ public function checkScorePin(?User $user, ScoreBest|Solo\Score $score): string 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'; } diff --git a/app/Singletons/RouteSection.php b/app/Singletons/RouteSection.php index 81bee010e06..a8e69505b6a 100644 --- a/app/Singletons/RouteSection.php +++ b/app/Singletons/RouteSection.php @@ -70,6 +70,9 @@ class RouteSection 'friends_controller' => [ '_' => 'home', ], + 'group_history_controller' => [ + '_' => 'home', + ], 'groups_controller' => [ '_' => 'home', ], diff --git a/app/Transformers/UserGroupEventTransformer.php b/app/Transformers/UserGroupEventTransformer.php index 5075f6696c7..6cdf67010af 100644 --- a/app/Transformers/UserGroupEventTransformer.php +++ b/app/Transformers/UserGroupEventTransformer.php @@ -43,6 +43,10 @@ public function transform(UserGroupEvent $event): array public function includeActor(UserGroupEvent $event): ResourceInterface { + if ($event->actor_id === null) { + return $this->null(); + } + return $this->primitive([ 'id' => $event->actor_id, 'username' => $event->details['actor_name'], diff --git a/package.json b/package.json index aa2cbb091cf..086bcc52905 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "@discordapp/twemoji": "^14.0.2", - "@fortawesome/fontawesome-free": "^5.6.3", + "@fortawesome/fontawesome-free": "^5.15.4", "@types/autosize": "^4.0.1", "@types/bootstrap": "^3.3.0", "@types/cloudflare-turnstile": "^0.1.5", diff --git a/resources/css/bem-index.less b/resources/css/bem-index.less index 497d2660257..c36cb1bf211 100644 --- a/resources/css/bem-index.less +++ b/resources/css/bem-index.less @@ -192,6 +192,9 @@ @import "bem/game-mode-link"; @import "bem/github-user"; @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/form-select.less b/resources/css/bem/form-select.less index 9707f24f967..8b5f83d46de 100644 --- a/resources/css/bem/form-select.less +++ b/resources/css/bem/form-select.less @@ -2,6 +2,8 @@ // See the LICENCE file in the repository root for full licence text. .form-select { + @_top: form-select; + .default-border-radius(); background-color: @osu-colour-b6; color: @osu-colour-c1; @@ -40,6 +42,12 @@ flex: 1; } + &--group-history { + background-color: inherit; + font-size: @font-size--title-small-3; + margin: -5px; + } + &--simple-form { background-color: @osu-colour-b4; color: @osu-colour-c1; @@ -52,5 +60,10 @@ background-color: inherit; border-radius: inherit; max-width: 100%; + text-overflow: ellipsis; + + .@{_top}--group-history & { + width: 100%; + } } } diff --git a/resources/css/bem/group-history-event.less b/resources/css/bem/group-history-event.less new file mode 100644 index 00000000000..c0cba6348d5 --- /dev/null +++ b/resources/css/bem/group-history-event.less @@ -0,0 +1,59 @@ +// 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 { + align-items: center; + display: flex; + font-size: @font-size--title-small; + gap: 15px; + + @types: { + group_add: @fa-var-users, bold; + group_remove: @fa-var-users-slash, bold; + group_rename: @fa-var-users-cog, bold; + user_add: @fa-var-user-plus, normal; + user_add_playmodes: @fa-var-user-tag, normal; + user_remove: @fa-var-user-minus, normal; + user_remove_playmodes: @fa-var-user-tag, normal; + user_set_default: @fa-var-user-cog, normal; + }; + each(@types, { + &--@{key} { + --icon: extract(@value, 1); + --message-weight: extract(@value, 2); + } + }); + + &__icon { + background-color: var(--group-colour, hsl(var(--hsl-b1))); + border-radius: 10000px; + color: hsl(var(--hsl-b6)); + line-height: 1; + padding: 3px 6px; + + &::before { + .fas(); + .fa-fw(); + content: var(--icon); + } + } + + &__info { + align-items: flex-end; + color: hsl(var(--hsl-f1)); + column-gap: 15px; + display: flex; + flex-direction: column; + flex-shrink: 0; + font-size: @font-size--normal; + + @media @desktop { + flex-direction: row-reverse; + } + } + + &__message { + flex-grow: 1; + font-weight: var(--message-weight); + } +} 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..9fa9042e88f --- /dev/null +++ b/resources/css/bem/group-history-search-form.less @@ -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. + +.group-history-search-form { + --input-bg: hsl(var(--hsl-b5)); + --input-border-radius: @border-radius--large; + background: hsl(var(--hsl-b4)); + + &__content { + .default-gutter-v2(); + padding-bottom: 20px; + padding-top: 20px; + + &--buttons { + background-color: hsl(var(--hsl-b3)); + display: flex; + gap: 10px; + justify-content: center; + padding-bottom: 10px; + padding-top: 10px; + } + + &--inputs { + color-scheme: dark; + display: grid; + gap: 10px; + grid-template-columns: repeat(2, 1fr) repeat(2, 180px); + + @media @mobile { + grid-template-columns: repeat(2, 1fr); + } + } + } + + &__input { + .reset-input(); + font-size: @font-size--title-small-3; + width: 100%; + } + + &__label { + color: var(--label-colour); + padding-bottom: 5px; + } +} diff --git a/resources/css/bem/group-history.less b/resources/css/bem/group-history.less new file mode 100644 index 00000000000..3cc64fdae70 --- /dev/null +++ b/resources/css/bem/group-history.less @@ -0,0 +1,16 @@ +// 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; + } +} diff --git a/resources/css/bem/input-container.less b/resources/css/bem/input-container.less index 26bbec3bccf..b3eca76c1d1 100644 --- a/resources/css/bem/input-container.less +++ b/resources/css/bem/input-container.less @@ -49,6 +49,12 @@ background: none; } + &--group-history-wide { + @media @mobile { + grid-column-end: span 2; + } + } + &__label { display: flex; justify-content: space-between; @@ -57,4 +63,3 @@ font-size: @font-size--normal; } } - diff --git a/resources/css/bem/osu-page.less b/resources/css/bem/osu-page.less index b8eec4cd564..a14993c6508 100644 --- a/resources/css/bem/osu-page.less +++ b/resources/css/bem/osu-page.less @@ -188,6 +188,16 @@ .default(); } + &--group-history-footer { + .default(); + .default-gutter-v2(); + background-color: hsl(var(--hsl-b4)); + font-size: @font-size--normal; + padding-bottom: 10px; + padding-top: 10px; + text-align: center; + } + &--info-bar { .default-gutter-v2(); padding-top: 5px; 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/js/entrypoints/group-history.tsx b/resources/js/entrypoints/group-history.tsx new file mode 100644 index 00000000000..859dbe6a4d6 --- /dev/null +++ b/resources/js/entrypoints/group-history.tsx @@ -0,0 +1,8 @@ +// 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 Main from 'group-history/main'; +import core from 'osu-core-singleton'; +import * as React from 'react'; + +core.reactTurbolinks.register('group-history', () =>
); diff --git a/resources/js/group-history/event.tsx b/resources/js/group-history/event.tsx new file mode 100644 index 00000000000..6b42a863ae7 --- /dev/null +++ b/resources/js/group-history/event.tsx @@ -0,0 +1,86 @@ +// 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 * 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.rulesets = 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 != 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..eb1bca0b00c --- /dev/null +++ b/resources/js/group-history/events.tsx @@ -0,0 +1,32 @@ +// 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 { classWithModifiers } from 'utils/css'; +import { trans } from 'utils/lang'; +import Event from './event'; + +const bn = 'group-history'; + +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..9997d32ef64 --- /dev/null +++ b/resources/js/group-history/group-store.ts @@ -0,0 +1,40 @@ +// 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 = new Map(); + + @computed + get all() { + return sortBy([...this.byId.values()], 'name'); + } + + @computed + get byIdentifier() { + const byIdentifier = new Map(); + + for (const group of this.byId.values()) { + byIdentifier.set(group.identifier, group); + } + + return byIdentifier; + } + + constructor() { + makeObservable(this); + } + + @action + update(groups: GroupJson[]): void { + for (const group of groups) { + this.byId.set(group.id, 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..37d91c95bb8 --- /dev/null +++ b/resources/js/group-history/json.ts @@ -0,0 +1,18 @@ +// 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[]; + params: { + group: string | null; + max_date: string | null; + min_date: string | null; + sort: string; + user: string | null; + }; +} diff --git a/resources/js/group-history/main.tsx b/resources/js/group-history/main.tsx new file mode 100644 index 00000000000..b7bcfbdd88f --- /dev/null +++ b/resources/js/group-history/main.tsx @@ -0,0 +1,163 @@ +// 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 { omit } from 'lodash'; +import { action, autorun, computed, makeObservable, observable } from 'mobx'; +import { disposeOnUnmount, observer } from 'mobx-react'; +import * as React from 'react'; +import { onErrorWithCallback } from 'utils/ajax'; +import { parseJson, storeJson } from 'utils/json'; +import { trans } from 'utils/lang'; +import { updateQueryString, wikiUrl } from 'utils/url'; +import Events from './events'; +import groupStore from './group-store'; +import GroupHistoryJson from './json'; +import SearchForm from './search-form'; + +type MoreParams = GroupHistoryJson['params'] & { cursor_string: string }; + +export const formParamKeys = ['group', 'max_date', 'min_date', 'user'] as const; +const jsonId = 'json-group-history'; + +@observer +export default class Main extends React.Component { + @observable private currentParams: GroupHistoryJson['params']; + @observable private events: UserGroupEventJson[]; + @observable private loading: 'more' | 'new' | false = false; + @observable private moreParams!: MoreParams | null; + @observable private readonly newParams: GroupHistoryJson['params']; + private xhr?: JQuery.jqXHR; + + @computed + private get newParamsSameAsCurrent() { + return formParamKeys.every((key) => this.newParams[key] === this.currentParams[key]); + } + + constructor(props: Record) { + super(props); + makeObservable(this); + + const json = parseJson(jsonId); + + groupStore.update(json.groups); + this.currentParams = json.params; + this.events = json.events; + this.newParams = { ...this.currentParams }; + this.setMoreParamsFromJson(json); + + // If the "group" param doesn't match any group we can show as a select + // option, set it to null in the new params. This prevents the new params + // from initially being out of sync with the displayed form controls + if (json.params.group != null && !groupStore.byIdentifier.has(json.params.group)) { + this.newParams.group = null; + } + + disposeOnUnmount(this, autorun(() => storeJson(jsonId, { + cursor_string: this.moreParams?.cursor_string ?? null, + events: this.events, + groups: groupStore.all, + params: this.currentParams, + }))); + } + + componentWillUnmount() { + this.xhr?.abort(); + } + + render() { + return ( + <> + +
+ +
+
+ + +
+
+ + {trans('group_history.staff_log.wiki_articles')} + + ), + }} + pattern={trans('group_history.staff_log._')} + /> +
+ + ); + } + + @action + private loadEvents(params: GroupHistoryJson['params'] | MoreParams) { + this.xhr?.abort(); + this.currentParams = omit(params, 'cursor_string'); + this.loading = 'cursor_string' in params ? 'more' : 'new'; + + this.xhr = $.ajax( + route('group-history.index'), + { + data: params, + dataType: 'json', + method: 'GET', + }, + ) + .done(action((response: GroupHistoryJson) => { + groupStore.update(response.groups); + this.setMoreParamsFromJson(response); + + if (this.loading === 'new') { + this.events = response.events; + } else { + this.events.push(...response.events); + } + })) + .fail(onErrorWithCallback(() => this.loadEvents(params))) + .always(action(() => this.loading = false)); + } + + private readonly onMore = () => { + if (this.moreParams != null) { + this.loadEvents(this.moreParams); + } + }; + + private readonly onNewSearch = () => { + if (this.newParamsSameAsCurrent) { + return; + } + + // Update the query string of the URL when starting a new search. Remove + // "sort" from the query if it's set to the default + Turbolinks.controller.replaceHistory(updateQueryString(null, { + ...this.newParams, + sort: this.newParams.sort === 'id_desc' ? null : this.newParams.sort, + })); + + this.loadEvents(this.newParams); + }; + + private setMoreParamsFromJson(json: GroupHistoryJson) { + this.moreParams = json.cursor_string == null + ? null + : { ...json.params, cursor_string: json.cursor_string }; + } +} diff --git a/resources/js/group-history/search-form.tsx b/resources/js/group-history/search-form.tsx new file mode 100644 index 00000000000..b28f41e674f --- /dev/null +++ b/resources/js/group-history/search-form.tsx @@ -0,0 +1,126 @@ +// 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 { action, makeObservable } from 'mobx'; +import { observer } from 'mobx-react'; +import * as React from 'react'; +import { trans } from 'utils/lang'; +import groupStore from './group-store'; +import GroupHistoryJson from './json'; +import { formParamKeys } from './main'; + +const bn = 'group-history-search-form'; + +interface Props { + disabled: boolean; + loading: boolean; + newParams: GroupHistoryJson['params']; + onSearch: () => void; +} + +@observer +export default class SearchForm extends React.Component { + constructor(props: Props) { + super(props); + makeObservable(this); + } + + render() { + const newParamsEmpty = formParamKeys.every((key) => this.props.newParams[key] == null); + + return ( +
+
+ +
+ +
+
+ + + + + + + + + +
+
+ + +
+
+ ); + } + + @action + private readonly onChange = (event: React.ChangeEvent) => { + event.preventDefault(); + + this.props.newParams[event.currentTarget.name as (typeof formParamKeys)[number]] = + event.currentTarget.value || null; + }; + + @action + private readonly onReset = (event: React.MouseEvent) => { + event.preventDefault(); + + for (const key of formParamKeys) { + this.props.newParams[key] = null; + } + }; + + private readonly onSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + this.props.onSearch(); + }; +} diff --git a/resources/js/interfaces/user-group-event-json.ts b/resources/js/interfaces/user-group-event-json.ts index 83222f7f2dc..5c8014b404e 100644 --- a/resources/js/interfaces/user-group-event-json.ts +++ b/resources/js/interfaces/user-group-event-json.ts @@ -6,11 +6,8 @@ import Ruleset from './ruleset'; interface UserGroupEventBase { actor?: { id: number; - name: string; - } | { - id: null; - name: null; - }; + username: string; + } | null; created_at: string; group_id: number; group_name: string; diff --git a/resources/lang/en/group_history.php b/resources/lang/en/group_history.php new file mode 100644 index 00000000000..fcbc64bf16b --- /dev/null +++ b/resources/lang/en/group_history.php @@ -0,0 +1,38 @@ +. 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!', + + '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 :rulesets.', + 'user_add_playmodes' => ':rulesets added to :user\'s :group membership.', + 'user_remove' => ':user removed from :group.', + 'user_remove_playmodes' => ':rulesets removed from :user\'s :group membership.', + 'user_set_default' => ':user\'s default group set to :group.', + ], + ], + + 'form' => [ + 'group' => 'Group', + 'group_all' => 'All groups', + 'max_date' => 'To', + 'min_date' => 'From', + '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 cc6440af8e8..880658f1c58 100644 --- a/resources/lang/en/page_title.php +++ b/resources/lang/en/page_title.php @@ -70,6 +70,9 @@ '_' => 'contests', 'judge' => 'contest judging', ], + '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/tests/Controllers/GroupHistoryControllerTest.php b/tests/Controllers/GroupHistoryControllerTest.php index f09ab0907eb..0a6da95043a 100644 --- a/tests/Controllers/GroupHistoryControllerTest.php +++ b/tests/Controllers/GroupHistoryControllerTest.php @@ -56,7 +56,7 @@ public function testIndexIncludesHiddenEventsWhenInGroup(): void $this->assertContains($event->getKey(), $responseEventIds); } - public function textIndexListsEvents(): void + public function testIndexListsEvents(): void { $event = UserGroupEvent::factory()->create(); diff --git a/yarn.lock b/yarn.lock index 3dcfdfa41b4..de719b8b01e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -249,10 +249,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== "@isaacs/cliui@^8.0.2": version "8.0.2"