Skip to content

Comments

[2.x] feat(core, mentions): implement ability to order groups#4350

Open
DavideIadeluca wants to merge 12 commits intoflarum:2.xfrom
glowingblue:di/user-group-ordering
Open

[2.x] feat(core, mentions): implement ability to order groups#4350
DavideIadeluca wants to merge 12 commits intoflarum:2.xfrom
glowingblue:di/user-group-ordering

Conversation

@DavideIadeluca
Copy link
Contributor

@DavideIadeluca DavideIadeluca commented Jan 24, 2026

Fixes #0000

Changes proposed in this pull request:

Reviewers should focus on:

  • Backend, how to handle the Guest and Member Group here? The DB migration does set a position, but the first time the order is changed in the frontend those groups' position would be set back to null. From a usability point of view it doesn't matter, but it could be argued it's a quirk we don't wanna have
  • Also the changes in the webpack config.. it appears like that the current minification step from Terser fails to parse the esm build of sortable js due to unsupported syntax. The alias I added forces webpack to use sortables UDM build which appears to fix the issues. Really unsure how we should address this issue and if maybe changes in flarums webpack config itself are needed.

This was the error message (reproducible both in CI and when running yarn build locally):

ERROR in admin.js
  admin.js from Terser plugin
  Unexpected token: punc ({) [webpack://../node_modules/sortablejs/modular/sortable.esm.js:1212,31][admin.js:22591,37]
      at js_error (/home/runner/work/framework/framework/node_modules/terser/dist/bundle.min.js:536:11)
      at croak (/home/runner/work/framework/framework/node_modules/terser/dist/bundle.min.js:1264:9)
      at token_error (/home/runner/work/framework/framework/node_modules/terser/dist/bundle.min.js:1272:9)
      at unexpected (/home/runner/work/framework/framework/node_modules/terser/dist/bundle.min.js:1278:9)
      at semicolon (/home/runner/work/framework/framework/node_modules/terser/dist/bundle.min.js:1316:56)
      at simple_statement (/home/runner/work/framework/framework/node_modules/terser/dist/bundle.min.js:1577:73)
      at statement (/home/runner/work/framework/framework/node_modules/terser/dist/bundle.min.js:1390:19)
      at _embed_tokens_wrapper (/home/runner/work/framework/framework/node_modules/terser/dist/bundle.min.js:1329:26)
      at block_ (/home/runner/work/framework/framework/node_modules/terser/dist/bundle.min.js:2168:20)
      at _function_body (/home/runner/work/framework/framework/node_modules/terser/dist/bundle.min.js:2080:21)

Screenshot
https://github.com/user-attachments/assets/8dcb047b-bd52-435e-a5b1-a5da46673664

Necessity

  • Has the problem that is being solved here been clearly explained?
  • If applicable, have various options for solving this problem been considered?
  • For core PRs, does this need to be in core, or could it be in an extension?
  • Are we willing to maintain this for years / potentially forever?

Confirmed

  • Frontend changes: tested on a local Flarum installation.
  • Backend changes: tests are green (run composer test).
  • Core developer confirmed locally this works as intended.
  • Tests have been added, or are not appropriate here.

Required changes:

  • Related documentation PR: (Remove if irrelevant)

@DavideIadeluca DavideIadeluca changed the title [2.x ] feat(core, mentions): implement ability to order groups [2.x] feat(core, mentions): implement ability to order groups Jan 24, 2026
@imorland imorland added this to the 2.0.0-beta.7 milestone Jan 24, 2026
@DavideIadeluca DavideIadeluca marked this pull request as ready for review January 29, 2026 09:10
@DavideIadeluca DavideIadeluca requested a review from a team as a code owner January 29, 2026 09:10
Copy link
Member

@imorland imorland left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this PR — group ordering is a welcome addition and the overall structure is good. A few issues need addressing before this can merge, from correctness problems down to minor polish. See inline comments for specifics.

Summary of required changes:

  • sortGroups: handle null positions (same pattern as sortTags)
  • GroupBar: remove abstract or use a concrete subclass in PermissionsPage — cannot instantiate an abstract class directly
  • GroupResource: add ->readonly() to the position field
  • OrderGroupsController: add input validation (type-check, verify IDs are real groups, protect Guest/Member)
  • Webpack alias: this fix belongs in flarum-webpack-config, not just core — otherwise extension authors who bundle sortablejs will hit the same Terser error
  • Clean up the two chore: debugging commits before merge (squash or rebase)
  • Error handling on the app.request in onSortUpdate — revert on failure

Questions for you:

  • Is GroupBar intended to be a base class that extensions subclass (as the twofactor example suggests), or should it be directly usable? If the former, PermissionsPage needs a concrete DefaultGroupBar extends GroupBar.
  • For the webpack fix — do you want to explore moving sortablejs to a Flarum external (exposed from core bundle) rather than an alias? That would be cleaner long-term.

import type Group from '../models/Group';

export default function sortGroups(groups: Group[]) {
return groups.slice().sort((a, b) => a.position() - b.position());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will behave incorrectly when position() returns nullnull - null coerces to 0 in JS, and null - 5 coerces to -5, so groups with a null position sort as if they have position 0, jumping to the front. This happens for new groups created after an ordering, and for any group not included in the order payload.

Please handle null positions explicitly, following the same pattern as sortTags.tsx:

export default function sortGroups(groups: Group[]) {
  return groups.slice().sort((a, b) => {
    const aPos = a.position();
    const bPos = b.position();

    if (aPos === null && bPos === null) return 0;
    if (aPos === null) return 1;
    if (bPos === null) return -1;

    return aPos - bPos;
  });
}

}

export default abstract class GroupBar<CustomAttrs extends IGroupBarAttrs = IGroupBarAttrs> extends Component<CustomAttrs> {
groups: Group[] = [];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GroupBar is declared abstract but PermissionsPage uses it directly via <GroupBar groups={availableGroups} />. TypeScript will compile this, but Mithril instantiates components at runtime — instantiating an abstract class throws in JS.

Either:

  1. Remove abstract if this is meant to be directly usable as-is, or
  2. Keep it abstract and introduce a concrete subclass (class DefaultGroupBar extends GroupBar {}) for PermissionsPage to use — which would also give extensions a clean extension point without having to extend an abstract class directly.

ghostClass: 'Group-Sortable-Placeholder',

filter: '.Group--add',
onMove: (evt) => !evt.related.classList.contains('Group--add'),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onSort receives an event e that is declared but never used. Either use it or remove the parameter:

onSort: () => this.onSortUpdate(),

app.store.getById<Group>('groups', id)?.pushData({
attributes: { position: i },
});
});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

app.request is fire-and-forget with no error handling. If the request fails, SortableJS has already reordered the DOM and the store has been updated via pushData, leaving the UI and server in an inconsistent state.

At minimum, revert the store positions on failure:

const previousPositions = order.map((id, i) => ({
  id,
  position: app.store.getById<Group>("groups", id)?.position() ?? null,
}));

app.request({ ... }).catch(() => {
  previousPositions.forEach(({ id, position }) => {
    app.store.getById<Group>("groups", id)?.pushData({ attributes: { position } });
  });
  m.redraw();
});

output: {
library: 'flarum.core',
},
resolve: {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This alias fixes the Terser/ESM issue for core, but any extension that bundles sortablejs directly will hit the same error. The fix should live in flarum-webpack-config so it applies everywhere, or sortablejs should be exposed as an external from the core bundle (similar to how mithril/jQuery are handled), so extensions can reference it without bundling their own copy.

Could you look into moving this to flarum-webpack-config? Or would you prefer we handle that separately?

->writable(),
Schema\Boolean::make('isHidden')
->writable(),
Schema\Integer::make('position')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

position should be ->readonly(). Without it, the field is writable via PATCH /api/groups/{id}, bypassing the ordering endpoint and its admin check entirely. Position should only be settable through POST /api/groups/order.

Schema\Integer::make(position)
    ->nullable()
    ->readonly(),

if ($order === null) {
return new EmptyResponse(422);
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few input validation gaps here:

  1. No type check — $order could be a non-array (e.g. a string), which would make foreach throw.
  2. No validation that the IDs in $order are real group IDs — an invalid ID silently does nothing but the reset at line 33 (update position = null for all) still runs.
  3. Guest (ID 2) and Member (ID 3) groups could be included in the payload, which would set positions on groups the frontend intentionally excludes. Consider ignoring or rejecting those IDs.

Suggested additions:

if (\!is_array($order)) {
    return new EmptyResponse(422);
}

* @property string|null $color
* @property string|null $icon
* @property bool $is_hidden
* @property int $position
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The migration adds ->nullable() and GroupFactory defaults to null, so this should be @property int|null $position.

.Group-Sortable-Placeholder {
outline: 2px dashed var(--body-bg);
border-radius: var(--border-radius);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent indentation — the rest of the file uses 2-space indent but .Group-Sortable-Placeholder uses 4-space. Please normalise.

@imorland imorland modified the milestones: 2.0.0-beta.7, 2.0.0-beta.8 Feb 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants