diff --git a/extensions/mentions/js/src/forum/mentionables/GroupMention.tsx b/extensions/mentions/js/src/forum/mentionables/GroupMention.tsx index 7703db5dd7..6793952ab7 100644 --- a/extensions/mentions/js/src/forum/mentionables/GroupMention.tsx +++ b/extensions/mentions/js/src/forum/mentionables/GroupMention.tsx @@ -5,6 +5,7 @@ import type Mithril from 'mithril'; import Badge from 'flarum/common/components/Badge'; import highlight from 'flarum/common/helpers/highlight'; import type AtMentionFormat from './formats/AtMentionFormat'; +import sortGroups from 'flarum/common/utils/sortGroups'; export default class GroupMention extends MentionableModel { type(): string { @@ -13,9 +14,11 @@ export default class GroupMention extends MentionableModel('groups').filter((g: Group) => { - return g.id() !== Group.GUEST_ID && g.id() !== Group.MEMBER_ID; - }) + sortGroups( + app.store.all('groups').filter((g: Group) => { + return g.id() !== Group.GUEST_ID && g.id() !== Group.MEMBER_ID; + }) + ) ); } diff --git a/framework/core/js/package.json b/framework/core/js/package.json index 6e8909151e..607a8f82f2 100644 --- a/framework/core/js/package.json +++ b/framework/core/js/package.json @@ -17,6 +17,7 @@ "mithril": "^2.2", "nanoid": "^3.1.30", "punycode": "^2.1.1", + "sortablejs": "^1.14.0", "textarea-caret": "^3.1.0", "throttle-debounce": "^3.0.1" }, @@ -27,6 +28,7 @@ "@types/jquery": "^3.5.10", "@types/mithril": "^2.0.8", "@types/punycode": "^2.1.0", + "@types/sortablejs": "^1.15.9", "@types/textarea-caret": "^3.0.1", "@types/ua-parser-js": "^0.7.36", "bundlewatch": "^0.3.2", diff --git a/framework/core/js/src/admin/components/GroupBar.tsx b/framework/core/js/src/admin/components/GroupBar.tsx new file mode 100644 index 0000000000..0c74b8f494 --- /dev/null +++ b/framework/core/js/src/admin/components/GroupBar.tsx @@ -0,0 +1,80 @@ +import sortable from 'sortablejs'; + +import app from '../../admin/app'; +import Component, { ComponentAttrs } from '../../common/Component'; +import GroupBadge from '../../common/components/GroupBadge'; +import Icon from '../../common/components/Icon'; +import EditGroupModal from './EditGroupModal'; +import sortGroups from '../../common/utils/sortGroups'; + +import type Group from '../../common/models/Group'; +import type Mithril from 'mithril'; + +export interface IGroupBarAttrs extends ComponentAttrs { + groups: Group[]; +} + +export default abstract class GroupBar extends Component { + groups: Group[] = []; + + oninit(vnode: Mithril.Vnode) { + super.oninit(vnode); + + this.groups = sortGroups(this.attrs.groups); + } + + view(): JSX.Element { + return ( +
+ {this.groups.map((group) => ( + + ))} + +
+ ); + } + + onGroupBarCreate(vnode: Mithril.VnodeDOM) { + sortable.create(vnode.dom as HTMLElement, { + group: 'groups', + delay: 50, + delayOnTouchOnly: true, + touchStartThreshold: 5, + animation: 150, + swapThreshold: 0.65, + dragClass: 'Group-Sortable-Dragging', + ghostClass: 'Group-Sortable-Placeholder', + + filter: '.Group--add', + onMove: (evt) => !evt.related.classList.contains('Group--add'), + + onSort: (e) => this.onSortUpdate(), + }); + } + + onSortUpdate() { + const order = this.$('.Group:not(.Group--add)') + .map(function () { + return $(this).data('id'); + }) + .get(); + + order.forEach((id, i) => { + app.store.getById('groups', id)?.pushData({ + attributes: { position: i }, + }); + }); + + app.request({ + url: app.forum.attribute('apiUrl') + '/groups/order', + method: 'POST', + body: { order }, + }); + } +} diff --git a/framework/core/js/src/admin/components/PermissionDropdown.tsx b/framework/core/js/src/admin/components/PermissionDropdown.tsx index e605e9d0bf..138193418d 100644 --- a/framework/core/js/src/admin/components/PermissionDropdown.tsx +++ b/framework/core/js/src/admin/components/PermissionDropdown.tsx @@ -5,6 +5,7 @@ import Separator from '../../common/components/Separator'; import Group from '../../common/models/Group'; import Badge from '../../common/components/Badge'; import GroupBadge from '../../common/components/GroupBadge'; +import sortGroups from '../../common/utils/sortGroups'; import Mithril from 'mithril'; function badgeForId(id: string) { @@ -95,21 +96,20 @@ export default class PermissionDropdown('groups') - .filter((group) => !excludedGroups.includes(group.id()!)) - .map((group) => ( - - )); + const availableGroups = sortGroups(app.store.all('groups').filter((group) => !excludedGroups.includes(group.id()!))); + + const groupButtons = availableGroups.map((group) => ( + + )); children.push(...groupButtons); } diff --git a/framework/core/js/src/admin/components/PermissionsPage.tsx b/framework/core/js/src/admin/components/PermissionsPage.tsx index 13557c1ed9..f40cd8ed5d 100644 --- a/framework/core/js/src/admin/components/PermissionsPage.tsx +++ b/framework/core/js/src/admin/components/PermissionsPage.tsx @@ -1,10 +1,8 @@ import app from '../../admin/app'; -import GroupBadge from '../../common/components/GroupBadge'; -import EditGroupModal from './EditGroupModal'; +import GroupBar from './GroupBar'; import Group from '../../common/models/Group'; import PermissionGrid from './PermissionGrid'; import AdminPage from './AdminPage'; -import Icon from '../../common/components/Icon'; import SettingDropdown from './SettingDropdown'; export default class PermissionsPage extends AdminPage { @@ -18,22 +16,12 @@ export default class PermissionsPage extends AdminPage { } content() { + const availableGroups = app.store.all('groups').filter((group) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()!) === -1); + return ( <>
- {app.store - .all('groups') - .filter((group) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()!) === -1) - .map((group) => ( - - ))} - +
diff --git a/framework/core/js/src/common/common.ts b/framework/core/js/src/common/common.ts index 7c8d25c8b5..2c6ed9129e 100644 --- a/framework/core/js/src/common/common.ts +++ b/framework/core/js/src/common/common.ts @@ -20,6 +20,7 @@ import './utils/abbreviateNumber'; import './utils/escapeRegExp'; import './utils/string'; import './utils/throttleDebounce'; +import './utils/sortGroups'; import './utils/Stream'; import './utils/SubtreeRetainer'; import './utils/setRouteWithForcedRefresh'; diff --git a/framework/core/js/src/common/components/EditUserModal.tsx b/framework/core/js/src/common/components/EditUserModal.tsx index b5bed5e2a0..8553ca5e73 100644 --- a/framework/core/js/src/common/components/EditUserModal.tsx +++ b/framework/core/js/src/common/components/EditUserModal.tsx @@ -3,6 +3,7 @@ import FormModal, { IFormModalAttrs } from '../../common/components/FormModal'; import Button from './Button'; import GroupBadge from './GroupBadge'; import Group from '../models/Group'; +import sortGroups from '../utils/sortGroups'; import extractText from '../utils/extractText'; import ItemList from '../utils/ItemList'; import Stream from '../utils/Stream'; @@ -22,6 +23,7 @@ export default class EditUserModal; protected password!: Stream; protected groups: Record> = {}; + protected availableGroups: Group[] = []; oninit(vnode: Mithril.Vnode) { super.oninit(vnode); @@ -36,10 +38,11 @@ export default class EditUserModal('groups') - .filter((group) => ![Group.GUEST_ID, Group.MEMBER_ID].includes(group.id()!)) - .forEach((group) => (this.groups[group.id()!] = Stream(userGroups.includes(group)))); + this.availableGroups = sortGroups(app.store.all('groups').filter((group) => ![Group.GUEST_ID, Group.MEMBER_ID].includes(group.id()!))); + + this.availableGroups.forEach((group) => { + this.groups[group.id()!] = Stream(userGroups.includes(group)); + }); } className() { @@ -139,25 +142,16 @@ export default class EditUserModal
- {Object.keys(this.groups) - .map((id) => app.store.getById('groups', id)) - .filter(Boolean) - .map( - (group) => - // Necessary because filter(Boolean) doesn't narrow out falsy values. - group && ( - - ) - )} + {this.availableGroups.map((group) => ( + + ))}
, 10 diff --git a/framework/core/js/src/common/models/Group.ts b/framework/core/js/src/common/models/Group.ts index 1100c572fa..120ede07b8 100644 --- a/framework/core/js/src/common/models/Group.ts +++ b/framework/core/js/src/common/models/Group.ts @@ -22,4 +22,8 @@ export default class Group extends Model { isHidden() { return Model.attribute('isHidden').call(this); } + + position() { + return Model.attribute('position').call(this); + } } diff --git a/framework/core/js/src/common/utils/sortGroups.ts b/framework/core/js/src/common/utils/sortGroups.ts new file mode 100644 index 0000000000..831f1cfae8 --- /dev/null +++ b/framework/core/js/src/common/utils/sortGroups.ts @@ -0,0 +1,5 @@ +import type Group from '../models/Group'; + +export default function sortGroups(groups: Group[]) { + return groups.slice().sort((a, b) => a.position() - b.position()); +} diff --git a/framework/core/js/webpack.config.cjs b/framework/core/js/webpack.config.cjs index 04df395ffc..c0f83b3bce 100755 --- a/framework/core/js/webpack.config.cjs +++ b/framework/core/js/webpack.config.cjs @@ -5,4 +5,9 @@ module.exports = merge(config(), { output: { library: 'flarum.core', }, + resolve: { + alias: { + sortablejs: require.resolve('sortablejs/Sortable.js'), + }, + }, }); diff --git a/framework/core/less/admin/PermissionsPage.less b/framework/core/less/admin/PermissionsPage.less index 8c2a40862c..f6fb9e86fc 100644 --- a/framework/core/less/admin/PermissionsPage.less +++ b/framework/core/less/admin/PermissionsPage.less @@ -1,10 +1,15 @@ -.PermissionsPage-groups { +.GroupBar { background: var(--control-bg); - border-radius: var(--border-radius); - display: block; - overflow-x: auto; + display: flex; + gap: 10px; + flex-wrap: wrap; padding: 10px; + border-radius: var(--border-radius); } +.Group-Sortable-Placeholder { + outline: 2px dashed var(--body-bg); + border-radius: var(--border-radius); + } .Group { width: 90px; display: inline-block; diff --git a/framework/core/migrations/2026_01_24_000000_add_position_to_groups_table.php b/framework/core/migrations/2026_01_24_000000_add_position_to_groups_table.php new file mode 100644 index 0000000000..e50e160933 --- /dev/null +++ b/framework/core/migrations/2026_01_24_000000_add_position_to_groups_table.php @@ -0,0 +1,40 @@ + function (Builder $schema) { + $schema->table('groups', function (Blueprint $table) { + $table->integer('position')->after('is_hidden')->nullable(); + }); + + $db = $schema->getConnection(); + + $ids = $db->table('groups') + ->orderBy('id') + ->pluck('id'); + + $position = 0; + foreach ($ids as $id) { + $db->table('groups') + ->where('id', $id) + ->update(['position' => $position]); + + $position++; + } + }, + + 'down' => function (Builder $schema) { + $schema->table('groups', function (Blueprint $table) { + $table->dropColumn('position'); + }); + } +]; diff --git a/framework/core/src/Api/Controller/OrderGroupsController.php b/framework/core/src/Api/Controller/OrderGroupsController.php new file mode 100644 index 0000000000..ddb4a7431c --- /dev/null +++ b/framework/core/src/Api/Controller/OrderGroupsController.php @@ -0,0 +1,40 @@ +assertAdmin(); + + $order = Arr::get($request->getParsedBody(), 'order'); + + if ($order === null) { + return new EmptyResponse(422); + } + + Group::query()->update(['position' => null]); + + foreach ($order as $position => $id) { + Group::where('id', $id)->update(['position' => $position]); + } + + return new EmptyResponse(204); + } +} diff --git a/framework/core/src/Api/Resource/GroupResource.php b/framework/core/src/Api/Resource/GroupResource.php index d73706353f..0afbf8dd71 100644 --- a/framework/core/src/Api/Resource/GroupResource.php +++ b/framework/core/src/Api/Resource/GroupResource.php @@ -93,6 +93,7 @@ public function fields(): array ->writable(), Schema\Boolean::make('isHidden') ->writable(), + Schema\Integer::make('position') ]; } diff --git a/framework/core/src/Api/routes.php b/framework/core/src/Api/routes.php index 9ae4cbb091..213c229ddb 100644 --- a/framework/core/src/Api/routes.php +++ b/framework/core/src/Api/routes.php @@ -53,6 +53,19 @@ $route->toController(Controller\SendConfirmationEmailController::class) ); + /* + |-------------------------------------------------------------------------- + | Groups + |-------------------------------------------------------------------------- + */ + + // Change order of groups + $map->post( + '/groups/order', + 'groups.order', + $route->toController(Controller\OrderGroupsController::class) + ); + /* |-------------------------------------------------------------------------- | Notifications diff --git a/framework/core/src/Group/Group.php b/framework/core/src/Group/Group.php index 7681aad2af..fba77c2441 100644 --- a/framework/core/src/Group/Group.php +++ b/framework/core/src/Group/Group.php @@ -27,6 +27,7 @@ * @property string|null $color * @property string|null $icon * @property bool $is_hidden + * @property int $position * @property-read \Illuminate\Database\Eloquent\Collection $users * @property-read \Illuminate\Database\Eloquent\Collection $permissions */ diff --git a/framework/core/src/Group/GroupFactory.php b/framework/core/src/Group/GroupFactory.php index 0064e43334..52b424cb95 100644 --- a/framework/core/src/Group/GroupFactory.php +++ b/framework/core/src/Group/GroupFactory.php @@ -21,6 +21,7 @@ public function definition(): array 'color' => $this->faker->hexColor, 'icon' => null, 'is_hidden' => false, + 'position' => null, ]; } } diff --git a/framework/core/tests/integration/api/groups/OrderTest.php b/framework/core/tests/integration/api/groups/OrderTest.php new file mode 100644 index 0000000000..b5f53a6415 --- /dev/null +++ b/framework/core/tests/integration/api/groups/OrderTest.php @@ -0,0 +1,117 @@ +prepareDatabase([ + User::class => [ + $this->normalUser(), + ], + Group::class => [ + ['id' => 5, 'name_singular' => 'Developer', 'name_plural' => 'Developers', 'is_hidden' => false, 'position' => 5], + ['id' => 6, 'name_singular' => 'Subscriber', 'name_plural' => 'Subscribers', 'is_hidden' => false, 'position' => 6], + ['id' => 7, 'name_singular' => 'VIP', 'name_plural' => 'VIPs', 'is_hidden' => false, 'position' => 7], + ], + ]); + } + + #[Test] + public function admin_can_reorder_groups() + { + $response = $this->send( + $this->request('POST', '/api/groups/order', [ + 'authenticatedAs' => 1, + 'json' => [ + 'order' => [6, 5], + ], + ]) + ); + + $this->assertEquals(204, $response->getStatusCode(), (string) $response->getBody()); + + $this->assertSame(1, Group::findOrFail(5)->position, 'Group 5 should be moved to position 1'); + $this->assertSame(0, Group::findOrFail(6)->position, 'Group 6 should be moved to position 0'); + + $this->assertNull(Group::findOrFail(7)->position, 'Group 7 should have null position when not included'); + } + + #[Test] + public function non_admin_cannot_reorder_groups() + { + $response = $this->send( + $this->request('POST', '/api/groups/order', [ + 'authenticatedAs' => 2, + 'json' => [ + 'order' => [6, 5], + ], + ]) + ); + + $this->assertEquals(403, $response->getStatusCode(), (string) $response->getBody()); + + $this->assertSame(5, Group::findOrFail(5)->position); + $this->assertSame(6, Group::findOrFail(6)->position); + $this->assertSame(7, Group::findOrFail(7)->position); + } + + #[Test] + public function rejects_missing_order_payload() + { + $response = $this->send( + $this->request('POST', '/api/groups/order', [ + 'authenticatedAs' => 1, + 'json' => [ + // empty payload + ], + ]) + ); + + $this->assertEquals(422, $response->getStatusCode(), (string) $response->getBody()); + + $this->assertSame(5, Group::findOrFail(5)->position); + $this->assertSame(6, Group::findOrFail(6)->position); + $this->assertSame(7, Group::findOrFail(7)->position); + } + + #[Test] + public function rejects_null_order() + { + $response = $this->send( + $this->request('POST', '/api/groups/order', [ + 'authenticatedAs' => 1, + 'json' => [ + 'order' => null, + ], + ]) + ); + + $this->assertEquals(422, $response->getStatusCode(), (string) $response->getBody()); + + $this->assertSame(5, Group::findOrFail(5)->position); + $this->assertSame(6, Group::findOrFail(6)->position); + $this->assertSame(7, Group::findOrFail(7)->position); + } +} diff --git a/yarn.lock b/yarn.lock index 2c30d56408..c4e34a2f6f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1587,6 +1587,11 @@ resolved "https://registry.yarnpkg.com/@types/sizzle/-/sizzle-2.3.8.tgz#518609aefb797da19bf222feb199e8f653ff7627" integrity sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg== +"@types/sortablejs@^1.15.9": + version "1.15.9" + resolved "https://registry.yarnpkg.com/@types/sortablejs/-/sortablejs-1.15.9.tgz#82d2337f54d4db827914d80dee5cf65539ae3c8b" + integrity sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ== + "@types/stack-utils@^2.0.0": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8"