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 // Action response for follow/unfollow user request
func Action(ctx *context.Context) { func Action(ctx *context.Context) {
var err error var err error
var redirectViaJSON bool
action := ctx.FormString("action") action := ctx.FormString("action")
if ctx.ContextUser.IsOrganization() && (action == "block" || action == "unblock") { 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) err = user_model.UnfollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
case "block": case "block":
err = user_service.BlockUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID) err = user_service.BlockUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
redirectViaJSON = true
case "unblock": case "unblock":
err = user_model.UnblockUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID) err = user_model.UnblockUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
redirectViaJSON = true
} }
if err != nil { if err != nil {
@ -371,21 +368,15 @@ func Action(ctx *context.Context) {
} }
if ctx.ContextUser.IsOrganization() { if ctx.ContextUser.IsOrganization() {
ctx.Flash.Error(ctx.Tr("org.follow_blocked_user")) ctx.Flash.Error(ctx.Tr("org.follow_blocked_user"), true)
} else { } 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() { if ctx.ContextUser.IsIndividual() {
shared_user.PrepareContextForProfileBigAvatar(ctx) shared_user.PrepareContextForProfileBigAvatar(ctx)
ctx.Data["IsHTMX"] = true
ctx.HTML(http.StatusOK, tplProfileBigAvatar) ctx.HTML(http.StatusOK, tplProfileBigAvatar)
return return
} else if ctx.ContextUser.IsOrganization() { } else if ctx.ContextUser.IsOrganization() {

View file

@ -1,20 +1,23 @@
{{if .Flash.ErrorMsg}} {{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> <p>{{.Flash.ErrorMsg | SanitizeHTML}}</p>
</div> </div>
{{end}} {{end}}
{{if .Flash.SuccessMsg}} {{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> <p>{{.Flash.SuccessMsg | SanitizeHTML}}</p>
</div> </div>
{{end}} {{end}}
{{if .Flash.InfoMsg}} {{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> <p>{{.Flash.InfoMsg | SanitizeHTML}}</p>
</div> </div>
{{end}} {{end}}
{{if .Flash.WarningMsg}} {{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> <p>{{.Flash.WarningMsg | SanitizeHTML}}</p>
</div> </div>
{{end}} {{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"> <div id="profile-avatar" class="content tw-flex">
{{if eq .SignedUserID .ContextUser.ID}} {{if eq .SignedUserID .ContextUser.ID}}
<a class="image" href="{{AppSubUrl}}/user/settings" data-tooltip-content="{{ctx.Locale.Tr "user.change_avatar"}}"> <a class="image" href="{{AppSubUrl}}/user/settings" data-tooltip-content="{{ctx.Locale.Tr "user.change_avatar"}}">
@ -109,14 +112,13 @@
</button> </button>
{{end}} {{end}}
</li> </li>
<li class="block"> <li class="block" hx-target="#profile-avatar-card" hx-indicator="#profile-avatar-card">
{{if $.IsBlocked}} {{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"}} {{svg "octicon-person"}} {{ctx.Locale.Tr "user.unblock"}}
</button> </button>
{{else}} {{else}}
<button type="submit" class="ui basic orange button delete-button" <button type="submit" class="ui basic orange button" data-modal-id="block-user" hx-post="{{.ContextUser.HomeLink}}?action=block" hx-confirm="-">
data-modal-id="block-user" data-url="{{.ContextUser.HomeLink}}?action=block">
{{svg "octicon-blocked"}} {{ctx.Locale.Tr "user.block"}} {{svg "octicon-blocked"}} {{ctx.Locale.Tr "user.block"}}
</button> </button>
{{end}} {{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), "_csrf": GetCSRF(t, session, "/"+blockedUser.Name),
"action": "block", "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})) 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), "_csrf": GetCSRF(t, session, "/"+blockedUser.Name),
"action": "follow", "action": "follow",
}) })
session.MakeRequest(t, req, http.StatusOK) resp := session.MakeRequest(t, req, http.StatusOK)
flashCookie := session.GetCookie(forgejo_context.CookieNameFlash) htmlDoc := NewHTMLParser(t, resp.Body)
assert.NotNil(t, flashCookie) 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.EqualValues(t, "error%3DYou%2Bcannot%2Bfollow%2Bthis%2Buser%2Bbecause%2Byou%2Bhave%2Bblocked%2Bthis%2Buser%2Bor%2Bthis%2Buser%2Bhas%2Bblocked%2Byou.", flashCookie.Value)
// Assert it still doesn't exist. // Assert it still doesn't exist.
unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: doer.ID, FollowID: blockedUser.ID}) 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), "_csrf": GetCSRF(t, session, "/"+doer.Name),
"action": "follow", "action": "follow",
}) })
session.MakeRequest(t, req, http.StatusOK) resp := session.MakeRequest(t, req, http.StatusOK)
flashCookie := session.GetCookie(forgejo_context.CookieNameFlash) htmlDoc := NewHTMLParser(t, resp.Body)
assert.NotNil(t, flashCookie) 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.EqualValues(t, "error%3DYou%2Bcannot%2Bfollow%2Bthis%2Buser%2Bbecause%2Byou%2Bhave%2Bblocked%2Bthis%2Buser%2Bor%2Bthis%2Buser%2Bhas%2Bblocked%2Byou.", flashCookie.Value)
unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: blockedUser.ID, FollowID: doer.ID}) 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() { export function initGlobalLinkActions() {
function showDeletePopup(e) { function showDeletePopup(e) {
e.preventDefault(); e.preventDefault();
const $this = $(this); const $this = $(this || e.target);
const dataArray = $this.data(); const dataArray = $this.data();
let filter = ''; let filter = '';
if (this.getAttribute('data-modal-id')) { if ($this[0].getAttribute('data-modal-id')) {
filter += `#${this.getAttribute('data-modal-id')}`; filter += `#${$this[0].getAttribute('data-modal-id')}`;
} }
const $dialog = $(`.delete.modal${filter}`); const $dialog = $(`.delete.modal${filter}`);
@ -317,6 +317,10 @@ export function initGlobalLinkActions() {
$($this.data('form')).trigger('submit'); $($this.data('form')).trigger('submit');
return; return;
} }
if ($this[0].getAttribute('hx-confirm')) {
e.detail.issueRequest(true);
return;
}
const postData = new FormData(); const postData = new FormData();
for (const [key, value] of Object.entries(dataArray)) { for (const [key, value] of Object.entries(dataArray)) {
if (key && key.startsWith('data')) { if (key && key.startsWith('data')) {
@ -338,6 +342,19 @@ export function initGlobalLinkActions() {
// Helpers. // Helpers.
$('.delete-button').on('click', showDeletePopup); $('.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() { function initGlobalShowModal() {