From bd5685812e4e0025193096ea82f95e5e3b9aa0c8 Mon Sep 17 00:00:00 2001 From: 0ko <0ko@noreply.codeberg.org> Date: Tue, 10 Feb 2026 12:50:55 +0100 Subject: [PATCH] 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 --- templates/org/member/members.tmpl | 107 +++++++++++++------------- tests/e2e/org-members.test.e2e.ts | 50 ++++++++++++ tests/integration/org_members_test.go | 48 ++++++++++++ web_src/css/org.css | 52 +++++++++++++ 4 files changed, 204 insertions(+), 53 deletions(-) create mode 100644 tests/e2e/org-members.test.e2e.ts create mode 100644 tests/integration/org_members_test.go diff --git a/templates/org/member/members.tmpl b/templates/org/member/members.tmpl index dccf588f6a..0cbc1b5eb1 100644 --- a/templates/org/member/members.tmpl +++ b/templates/org/member/members.tmpl @@ -4,63 +4,64 @@
{{template "base/alert" .}} -
- {{range .Members}} +
+ {{range $idx, $_ := .Members}} + {{if ne $idx 0}} +
+ {{end}} {{$isPublic := index $.MembersIsPublicMember .ID}} -
- -
-
- {{template "shared/user/name" .}} - {{if not $isPublic}} - {{ctx.Locale.Tr "org.members.private"}} - {{end}} + {{ctx.AvatarUtils.Avatar . 48}} +
+

+ {{template "shared/user/name" .}} + {{if not $isPublic}} + {{ctx.Locale.Tr "org.members.private"}} + {{end}} +

+ {{if not $.PublicOnly}} +
+ {{ctx.Locale.Tr "org.members.member_role"}} + {{if index $.MembersIsUserOrgOwner .ID}}{{svg "octicon-shield-lock"}} {{ctx.Locale.Tr "org.members.owner"}}{{else}}{{ctx.Locale.Tr "org.members.member"}}{{end}} +
+ {{if $.IsOrganizationOwner}} +
+ {{ctx.Locale.Tr "admin.users.2fa"}} + + {{if index $.MembersTwoFaStatus .ID}} + {{svg "octicon-check"}} + {{else}} + {{svg "octicon-x"}} + {{end}} +
- {{if not $.PublicOnly}} -
- {{ctx.Locale.Tr "org.members.member_role"}} - {{if index $.MembersIsUserOrgOwner .ID}}{{svg "octicon-shield-lock"}} {{ctx.Locale.Tr "org.members.owner"}}{{else}}{{ctx.Locale.Tr "org.members.member"}}{{end}} -
- {{if $.IsOrganizationOwner}} -
- {{ctx.Locale.Tr "admin.users.2fa"}} - - {{if index $.MembersTwoFaStatus .ID}} - {{svg "octicon-check"}} - {{else}} - {{svg "octicon-x"}} - {{end}} - -
- {{end}} {{end}} -
-
- {{if or (eq $.SignedUser.ID .ID) $.IsOrganizationOwner}} - {{if $isPublic}} - {{svg "octicon-eye-closed" 12 "icon"}}{{ctx.Locale.Tr "org.members.public_helper"}} - {{else}} - {{svg "octicon-eye" 12 "icon"}}{{ctx.Locale.Tr "org.members.private_helper"}} - {{end}} + {{end}} +
+
+ {{if or (eq $.SignedUser.ID .ID) $.IsOrganizationOwner}} + {{if $isPublic}} + + {{svg "octicon-eye-closed"}} + {{ctx.Locale.Tr "org.members.public_helper"}} + + {{else}} + + {{svg "octicon-eye"}} + {{ctx.Locale.Tr "org.members.private_helper"}} + {{end}} - {{if eq $.SignedUser.ID .ID}} -
- -
- {{else if $.IsOrganizationOwner}} -
- -
- {{end}} -
+ {{end}} + {{if eq $.SignedUser.ID .ID}} + + {{else if $.IsOrganizationOwner}} + + {{end}}
{{end}}
diff --git a/tests/e2e/org-members.test.e2e.ts b/tests/e2e/org-members.test.e2e.ts new file mode 100644 index 0000000000..311ad0f7cc --- /dev/null +++ b/tests/e2e/org-members.test.e2e.ts @@ -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(); +}); diff --git a/tests/integration/org_members_test.go b/tests/integration/org_members_test.go new file mode 100644 index 0000000000..e51850126f --- /dev/null +++ b/tests/integration/org_members_test.go @@ -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()) + }) +} diff --git a/web_src/css/org.css b/web_src/css/org.css index 024c2688f5..240ed5c98e 100644 --- a/web_src/css/org.css +++ b/web_src/css/org.css @@ -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; }