feat(ui): convert org members list to grid (#11127)

Replaces #10789

Convert the layout from flex-list helpers to a CSS-native grid. This allows to having buttons in different rows aligned to each other while keeping the layout responsive, i.e. looking good on both desktop and mobile.

### Preview (desktop)

|Before|After|
|-|-|
|![j1](/attachments/85f244bf-0538-4c5d-8300-ae4f6744e5f3)|![new1](/attachments/a23c60f7-d7a3-4cb7-abc6-657541992204)|

### Preview (mobile)

|Before|After|
|-|-|
|![g1](/attachments/32b01933-652f-47e7-b97d-e35262cbbf44)|![new2](/attachments/927fb458-632c-4699-ba8b-bcb6a73ffe3d)|

### Preview (Guest)
|Before|After|
|-|-|
|![i1](/attachments/66d0d62d-5500-4c6d-8913-aad2cf69d1ab)|![new3](/attachments/0333b14e-2952-4c5b-9051-185840167dca)|

## Testing

Automated tests were added to make sure that actions in this list are still working, and for basic template logic. No tests were added for layout because layout being correct is an abstract concept that is difficult to explain to Playwright.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/11127
Reviewed-by: Antonin Delpeuch <wetneb@noreply.codeberg.org>
This commit is contained in:
0ko 2026-02-10 12:50:55 +01:00
parent 4ce2212c9f
commit bd5685812e
4 changed files with 204 additions and 53 deletions

View file

@ -4,63 +4,64 @@
<div class="ui container">
{{template "base/alert" .}}
<div class="flex-list">
{{range .Members}}
<div class="list">
{{range $idx, $_ := .Members}}
{{if ne $idx 0}}
<div class="divider"></div>
{{end}}
{{$isPublic := index $.MembersIsPublicMember .ID}}
<div class="flex-item {{if $.PublicOnly}}tw-items-center{{end}}">
<div class="flex-item-leading">
<a href="{{.HomeLink}}">{{ctx.AvatarUtils.Avatar . 48}}</a>
</div>
<div class="flex-item-main">
<div class="flex-item-title">
{{template "shared/user/name" .}}
{{if not $isPublic}}
<span class="ui label">{{ctx.Locale.Tr "org.members.private"}}</span>
{{end}}
<a href="{{.HomeLink}}">{{ctx.AvatarUtils.Avatar . 48}}</a>
<div>
<h3>
{{template "shared/user/name" .}}
{{if not $isPublic}}
<span class="ui label">{{ctx.Locale.Tr "org.members.private"}}</span>
{{end}}
</h3>
{{if not $.PublicOnly}}
<div>
{{ctx.Locale.Tr "org.members.member_role"}}
<strong>{{if index $.MembersIsUserOrgOwner .ID}}{{svg "octicon-shield-lock"}} {{ctx.Locale.Tr "org.members.owner"}}{{else}}{{ctx.Locale.Tr "org.members.member"}}{{end}}</strong>
</div>
{{if $.IsOrganizationOwner}}
<div>
{{ctx.Locale.Tr "admin.users.2fa"}}
<strong>
{{if index $.MembersTwoFaStatus .ID}}
<span class="text green">{{svg "octicon-check"}}</span>
{{else}}
{{svg "octicon-x"}}
{{end}}
</strong>
</div>
{{if not $.PublicOnly}}
<div class="flex-item-body">
{{ctx.Locale.Tr "org.members.member_role"}}
<strong class="flex-text-inline">{{if index $.MembersIsUserOrgOwner .ID}}{{svg "octicon-shield-lock"}} {{ctx.Locale.Tr "org.members.owner"}}{{else}}{{ctx.Locale.Tr "org.members.member"}}{{end}}</strong>
</div>
{{if $.IsOrganizationOwner}}
<div class="flex-item-body">
{{ctx.Locale.Tr "admin.users.2fa"}}
<strong>
{{if index $.MembersTwoFaStatus .ID}}
<span class="text green">{{svg "octicon-check"}}</span>
{{else}}
{{svg "octicon-x"}}
{{end}}
</strong>
</div>
{{end}}
{{end}}
</div>
<div class="flex-item-trailing">
{{if or (eq $.SignedUser.ID .ID) $.IsOrganizationOwner}}
{{if $isPublic}}
<a class="ui tiny button link-action" href data-url="{{$.OrgLink}}/members/action/private?uid={{.ID}}">{{svg "octicon-eye-closed" 12 "icon"}}{{ctx.Locale.Tr "org.members.public_helper"}}</a>
{{else}}
<a class="ui tiny button link-action" href data-url="{{$.OrgLink}}/members/action/public?uid={{.ID}}">{{svg "octicon-eye" 12 "icon"}}{{ctx.Locale.Tr "org.members.private_helper"}}</a>
{{end}}
{{end}}
</div>
<div class="actions">
{{if or (eq $.SignedUser.ID .ID) $.IsOrganizationOwner}}
{{if $isPublic}}
<a class="small secondary button link-action" href data-url="{{$.OrgLink}}/members/action/private?uid={{.ID}}">
{{svg "octicon-eye-closed"}}
{{ctx.Locale.Tr "org.members.public_helper"}}
</a>
{{else}}
<a class="small secondary button link-action" href data-url="{{$.OrgLink}}/members/action/public?uid={{.ID}}">
{{svg "octicon-eye"}}
{{ctx.Locale.Tr "org.members.private_helper"}}
</a>
{{end}}
{{if eq $.SignedUser.ID .ID}}
<form>
<button class="ui red tiny button delete-button" data-modal-id="leave-organization"
data-url="{{$.OrgLink}}/members/action/leave" data-datauid="{{.ID}}"
data-name="{{.DisplayName}}"
data-data-organization-name="{{$.Org.DisplayName}}">{{ctx.Locale.Tr "org.members.leave"}}</button>
</form>
{{else if $.IsOrganizationOwner}}
<form>
<button class="ui red tiny button delete-button" data-modal-id="remove-organization-member"
data-url="{{$.OrgLink}}/members/action/remove" data-datauid="{{.ID}}"
data-name="{{.DisplayName}}"
data-data-organization-name="{{$.Org.DisplayName}}">{{ctx.Locale.Tr "org.members.remove"}}</button>
</form>
{{end}}
</div>
{{end}}
{{if eq $.SignedUser.ID .ID}}
<button class="small danger button delete-button" data-modal-id="leave-organization"
data-url="{{$.OrgLink}}/members/action/leave" data-datauid="{{.ID}}"
data-name="{{.DisplayName}}"
data-data-organization-name="{{$.Org.DisplayName}}">{{ctx.Locale.Tr "org.members.leave"}}</button>
{{else if $.IsOrganizationOwner}}
<button class="small danger button delete-button" data-modal-id="remove-organization-member"
data-url="{{$.OrgLink}}/members/action/remove" data-datauid="{{.ID}}"
data-name="{{.DisplayName}}"
data-data-organization-name="{{$.Org.DisplayName}}">{{ctx.Locale.Tr "org.members.remove"}}</button>
{{end}}
</div>
{{end}}
</div>

View file

@ -0,0 +1,50 @@
// Copyright 2026 The Forgejo Authors
// SPDX-License-Identifier: GPL-3.0-or-later
// @watch start
// @watch end
import {expect} from '@playwright/test';
import {test} from './utils_e2e.ts';
test.use({user: 'user2'});
test('Toggle visibility', async ({page}) => {
page.goto('/org/org3/members');
// Button "Make hidden" for user2's row
const hideUser2 = page.locator('.link-action[data-url="/org/org3/members/action/private?uid=2"]');
// Button "Make visible" for user2's row
const showUser2 = page.locator('.link-action[data-url="/org/org3/members/action/public?uid=2"]');
await expect(hideUser2).toBeVisible();
await expect(showUser2).toBeHidden();
await hideUser2.click();
// Button action was flipped
await expect(hideUser2).toBeHidden();
await expect(showUser2).toBeVisible();
// Revert for repeatability
await showUser2.click();
});
test('Leave org', async ({page}) => {
page.goto('/org/org3/members');
// Button "Leave" for user2's row
const leaveButton = page.locator('.delete-button[data-url="/org/org3/members/action/leave"]');
// Click the button
await leaveButton.click();
// A confirmation modal will appear
await expect(page.locator('.modal#leave-organization')).toBeVisible();
// Proceed leaving the org
await page.locator('.modal#leave-organization .actions button.ok').click();
// Getting error is enough to know that the correct request went though
await expect(page.locator('.flash-error').getByText('You cannot remove the last user from the "owners" team.')).toBeVisible();
});

View file

@ -0,0 +1,48 @@
// Copyright 2026 The Forgejo Authors
// SPDX-License-Identifier: GPL-3.0-or-later
package integration
import (
"net/http"
"testing"
"forgejo.org/tests"
"github.com/stretchr/testify/assert"
)
func TestOrgMembersPage(t *testing.T) {
defer tests.PrepareTestEnv(t)()
testPage := "/org/org3/members"
t.Run("Guest PoV", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
doc := NewHTMLParser(t, MakeRequest(t, NewRequest(t, "GET", testPage), http.StatusOK).Body)
/* No interactive buttons - though such evaluation is easy to break in rename */
assert.Equal(t, 0, doc.Find(".members .list .link-action").Length())
assert.Equal(t, 0, doc.Find(".members .list .delete-button").Length())
})
t.Run("Member PoV", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
session := loginUser(t, "user4") // user4 is a member of org3
doc := NewHTMLParser(t, session.MakeRequest(t, NewRequest(t, "GET", testPage), http.StatusOK).Body)
/* Interactive buttons are only available for own entry in the list */
assert.Equal(t, 1, doc.Find(".members .list .link-action").Length())
assert.Equal(t, 1, doc.Find(".members .list .delete-button").Length())
})
t.Run("Owner PoV", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
session := loginUser(t, "user2") // user2 owns org3
doc := NewHTMLParser(t, session.MakeRequest(t, NewRequest(t, "GET", testPage), http.StatusOK).Body)
/* Interactive buttons are available for all entries in the list (> 2) */
assert.Less(t, 2, doc.Find(".members .list .link-action").Length())
assert.Less(t, 2, doc.Find(".members .list .delete-button").Length())
})
}

View file

@ -92,6 +92,58 @@
margin-top: 0;
}
.page-content.organization.members .list {
display: grid;
grid-template-columns: minmax(min-content, auto) 1fr fit-content(100%) fit-content(100%);
color: var(--color-text-light-2);
.divider {
grid-column: 1 / -1;
}
/* Username (+display name) use header */
h3 {
margin: 0;
font-size: 16px;
font-weight: var(--font-weight-semibold);
color: var(--color-text);
}
.actions {
display: contents;
}
.link-action, .delete-button {
align-self: start;
}
a:has(img) {
margin-inline-end: 0.5rem;
}
.delete-button {
margin-inline-start: var(--button-spacing);
}
}
@media (max-width: 767.98px) {
.page-content.organization.members .list {
/* Place both buttons in one column */
grid-template-columns: minmax(min-content, auto) 1fr fit-content(100%);
.actions {
display: flex;
flex-direction: column;
gap: var(--button-spacing);
}
.link-action, .delete-button {
width: 100%;
margin: 0;
}
}
}
.page-content.organization .teams .item {
padding: 10px 15px;
}