mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-05-13 06:20:24 +00:00
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| |-|-| ||| ### Preview (mobile) |Before|After| |-|-| ||| ### Preview (Guest) |Before|After| |-|-| ||| ## 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:
parent
4ce2212c9f
commit
bd5685812e
4 changed files with 204 additions and 53 deletions
|
|
@ -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>
|
||||
|
|
|
|||
50
tests/e2e/org-members.test.e2e.ts
Normal file
50
tests/e2e/org-members.test.e2e.ts
Normal 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();
|
||||
});
|
||||
48
tests/integration/org_members_test.go
Normal file
48
tests/integration/org_members_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue