Merge pull request '[UI] Fix HTMX support for profile card' (#4538) from gusted/htmx-support into forgejo

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/4538
Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org>
This commit is contained in:
Earl Warren 2024-07-17 03:55:01 +00:00
commit 3c8cd43fec
6 changed files with 86 additions and 41 deletions

View file

@ -341,7 +341,6 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileDb
// Action response for follow/unfollow user request
func Action(ctx *context.Context) {
var err error
var redirectViaJSON bool
action := ctx.FormString("action")
if ctx.ContextUser.IsOrganization() && (action == "block" || action == "unblock") {
@ -357,10 +356,8 @@ func Action(ctx *context.Context) {
err = user_model.UnfollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
case "block":
err = user_service.BlockUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
redirectViaJSON = true
case "unblock":
err = user_model.UnblockUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
redirectViaJSON = true
}
if err != nil {
@ -371,21 +368,15 @@ func Action(ctx *context.Context) {
}
if ctx.ContextUser.IsOrganization() {
ctx.Flash.Error(ctx.Tr("org.follow_blocked_user"))
ctx.Flash.Error(ctx.Tr("org.follow_blocked_user"), true)
} else {
ctx.Flash.Error(ctx.Tr("user.follow_blocked_user"))
ctx.Flash.Error(ctx.Tr("user.follow_blocked_user"), true)
}
}
if redirectViaJSON {
ctx.JSON(http.StatusOK, map[string]any{
"redirect": ctx.ContextUser.HomeLink(),
})
return
}
if ctx.ContextUser.IsIndividual() {
shared_user.PrepareContextForProfileBigAvatar(ctx)
ctx.Data["IsHTMX"] = true
ctx.HTML(http.StatusOK, tplProfileBigAvatar)
return
} else if ctx.ContextUser.IsOrganization() {

View file

@ -1,20 +1,23 @@
{{if .Flash.ErrorMsg}}
<div class="ui negative message flash-message flash-error">
<div id="flash-message" class="ui negative message flash-message flash-error" hx-swap-oob="true">
<p>{{.Flash.ErrorMsg | SanitizeHTML}}</p>
</div>
{{end}}
{{if .Flash.SuccessMsg}}
<div class="ui positive message flash-message flash-success">
<div id="flash-message" class="ui positive message flash-message flash-success" hx-swap-oob="true">
<p>{{.Flash.SuccessMsg | SanitizeHTML}}</p>
</div>
{{end}}
{{if .Flash.InfoMsg}}
<div class="ui info message flash-message flash-info">
<div id="flash-message" class="ui info message flash-message flash-info" hx-swap-oob="true">
<p>{{.Flash.InfoMsg | SanitizeHTML}}</p>
</div>
{{end}}
{{if .Flash.WarningMsg}}
<div class="ui warning message flash-message flash-warning">
<div id="flash-message" class="ui warning message flash-message flash-warning" hx-swap-oob="true">
<p>{{.Flash.WarningMsg | SanitizeHTML}}</p>
</div>
{{end}}
{{if and (not .Flash.ErrorMsg) (not .Flash.SuccessMsg) (not .Flash.InfoMsg) (not .Flash.WarningMsg) (not .IsHTMX)}}
<div id="flash-message" hx-swap-oob="true"></div>
{{end}}

View file

@ -1,4 +1,7 @@
<div id="profile-avatar-card" class="ui card">
{{if .IsHTMX}}
{{template "base/alert" .}}
{{end}}
<div id="profile-avatar-card" class="ui card" hx-swap="morph">
<div id="profile-avatar" class="content tw-flex">
{{if eq .SignedUserID .ContextUser.ID}}
<a class="image" href="{{AppSubUrl}}/user/settings" data-tooltip-content="{{ctx.Locale.Tr "user.change_avatar"}}">
@ -109,14 +112,13 @@
</button>
{{end}}
</li>
<li class="block">
<li class="block" hx-target="#profile-avatar-card" hx-indicator="#profile-avatar-card">
{{if $.IsBlocked}}
<button class="ui basic red button link-action" data-url="{{.ContextUser.HomeLink}}?action=unblock&redirect_to={{$.Link}}">
<button class="ui basic red button" hx-post="{{.ContextUser.HomeLink}}?action=unblock">
{{svg "octicon-person"}} {{ctx.Locale.Tr "user.unblock"}}
</button>
{{else}}
<button type="submit" class="ui basic orange button delete-button"
data-modal-id="block-user" data-url="{{.ContextUser.HomeLink}}?action=block">
<button type="submit" class="ui basic orange button" data-modal-id="block-user" hx-post="{{.ContextUser.HomeLink}}?action=block" hx-confirm="-">
{{svg "octicon-blocked"}} {{ctx.Locale.Tr "user.block"}}
</button>
{{end}}

View file

@ -0,0 +1,41 @@
// @ts-check
import {test, expect} from '@playwright/test';
import {login_user, load_logged_in_context} from './utils_e2e.js';
test('Follow actions', async ({browser}, workerInfo) => {
await login_user(browser, workerInfo, 'user2');
const context = await load_logged_in_context(browser, workerInfo, 'user2');
const page = await context.newPage();
await page.goto('/user1');
await page.waitForLoadState('networkidle');
// Check if following and then unfollowing works.
// This checks that the event listeners of
// the buttons aren't dissapearing.
const followButton = page.locator('.follow');
await expect(followButton).toContainText('Follow');
await followButton.click();
await expect(followButton).toContainText('Unfollow');
await followButton.click();
await expect(followButton).toContainText('Follow');
// Simple block interaction.
await expect(page.locator('.block')).toContainText('Block');
await page.locator('.block').click();
await expect(page.locator('#block-user')).toBeVisible();
await page.locator('#block-user .ok').click();
await expect(page.locator('.block')).toContainText('Unblock');
await expect(page.locator('#block-user')).not.toBeVisible();
// Check that following the user yields in a error being shown.
await followButton.click();
const flashMessage = page.locator('#flash-message');
await expect(flashMessage).toBeVisible();
await expect(flashMessage).toContainText('You cannot follow this user because you have blocked this user or this user has blocked you.');
// Unblock interaction.
await page.locator('.block').click();
await expect(page.locator('.block')).toContainText('Block');
});

View file

@ -34,15 +34,8 @@ func BlockUser(t *testing.T, doer, blockedUser *user_model.User) {
"_csrf": GetCSRF(t, session, "/"+blockedUser.Name),
"action": "block",
})
resp := session.MakeRequest(t, req, http.StatusOK)
session.MakeRequest(t, req, http.StatusOK)
type redirect struct {
Redirect string `json:"redirect"`
}
var respBody redirect
DecodeJSON(t, resp, &respBody)
assert.EqualValues(t, "/"+blockedUser.Name, respBody.Redirect)
assert.True(t, unittest.BeanExists(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: doer.ID}))
}
@ -303,11 +296,10 @@ func TestBlockActions(t *testing.T) {
"_csrf": GetCSRF(t, session, "/"+blockedUser.Name),
"action": "follow",
})
session.MakeRequest(t, req, http.StatusOK)
resp := session.MakeRequest(t, req, http.StatusOK)
flashCookie := session.GetCookie(forgejo_context.CookieNameFlash)
assert.NotNil(t, flashCookie)
assert.EqualValues(t, "error%3DYou%2Bcannot%2Bfollow%2Bthis%2Buser%2Bbecause%2Byou%2Bhave%2Bblocked%2Bthis%2Buser%2Bor%2Bthis%2Buser%2Bhas%2Bblocked%2Byou.", flashCookie.Value)
htmlDoc := NewHTMLParser(t, resp.Body)
assert.Contains(t, htmlDoc.Find("#flash-message").Text(), "You cannot follow this user because you have blocked this user or this user has blocked you.")
// Assert it still doesn't exist.
unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: doer.ID, FollowID: blockedUser.ID})
@ -323,11 +315,10 @@ func TestBlockActions(t *testing.T) {
"_csrf": GetCSRF(t, session, "/"+doer.Name),
"action": "follow",
})
session.MakeRequest(t, req, http.StatusOK)
resp := session.MakeRequest(t, req, http.StatusOK)
flashCookie := session.GetCookie(forgejo_context.CookieNameFlash)
assert.NotNil(t, flashCookie)
assert.EqualValues(t, "error%3DYou%2Bcannot%2Bfollow%2Bthis%2Buser%2Bbecause%2Byou%2Bhave%2Bblocked%2Bthis%2Buser%2Bor%2Bthis%2Buser%2Bhas%2Bblocked%2Byou.", flashCookie.Value)
htmlDoc := NewHTMLParser(t, resp.Body)
assert.Contains(t, htmlDoc.Find("#flash-message").Text(), "You cannot follow this user because you have blocked this user or this user has blocked you.")
unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: blockedUser.ID, FollowID: doer.ID})
})

View file

@ -295,11 +295,11 @@ async function linkAction(e) {
export function initGlobalLinkActions() {
function showDeletePopup(e) {
e.preventDefault();
const $this = $(this);
const $this = $(this || e.target);
const dataArray = $this.data();
let filter = '';
if (this.getAttribute('data-modal-id')) {
filter += `#${this.getAttribute('data-modal-id')}`;
if ($this[0].getAttribute('data-modal-id')) {
filter += `#${$this[0].getAttribute('data-modal-id')}`;
}
const $dialog = $(`.delete.modal${filter}`);
@ -317,6 +317,10 @@ export function initGlobalLinkActions() {
$($this.data('form')).trigger('submit');
return;
}
if ($this[0].getAttribute('hx-confirm')) {
e.detail.issueRequest(true);
return;
}
const postData = new FormData();
for (const [key, value] of Object.entries(dataArray)) {
if (key && key.startsWith('data')) {
@ -338,6 +342,19 @@ export function initGlobalLinkActions() {
// Helpers.
$('.delete-button').on('click', showDeletePopup);
document.addEventListener('htmx:confirm', (e) => {
e.preventDefault();
// htmx:confirm is triggered for every HTMX request, even those that don't
// have the `hx-confirm` attribute specified. To avoid opening modals for
// those elements, check if 'e.detail.question' is empty, which contains the
// value of the `hx-confirm` attribute.
if (!e.detail.question) {
e.detail.issueRequest(true);
} else {
showDeletePopup(e);
}
});
}
function initGlobalShowModal() {