0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-05-15 21:31:29 +02:00

Refactor htmx and fetch-action related code (#37186)

This is the first step (the hardest part):

* repo file list last commit message lazy load
* admin server status monitor
* watch/unwatch (normal page, watchers page)
* star/unstar (normal page, watchers page)
* project view, delete column
* workflow dispatch, switch the branch
* commit page: load branches and tags referencing this commit

The legacy "data-redirect" attribute is removed, it only makes the page
reload (sometimes using an incorrect link).

Also did cleanup for some devtest pages.
This commit is contained in:
wxiaoguang 2026-04-14 02:53:55 +08:00 committed by GitHub
parent 6eae04241d
commit 6bcb666a9d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 457 additions and 242 deletions

View File

@ -575,7 +575,6 @@ export default defineConfig([
'no-restricted-imports': [2, {paths: [ 'no-restricted-imports': [2, {paths: [
{name: 'jquery', message: 'Use the global $ instead', allowTypeImports: true}, {name: 'jquery', message: 'Use the global $ instead', allowTypeImports: true},
{name: 'htmx.org', message: 'Use the global htmx instead', allowTypeImports: true}, {name: 'htmx.org', message: 'Use the global htmx instead', allowTypeImports: true},
{name: 'idiomorph/htmx', message: 'Loaded in globals.ts', allowTypeImports: true},
]}], ]}],
'no-restricted-syntax': [2, 'WithStatement', 'ForInStatement', 'LabeledStatement', 'SequenceExpression'], 'no-restricted-syntax': [2, 'WithStatement', 'ForInStatement', 'LabeledStatement', 'SequenceExpression'],
'no-return-assign': [0], 'no-return-assign': [0],

View File

@ -35,6 +35,11 @@ func DeleteRedirectToCookie(resp http.ResponseWriter) {
} }
func RedirectLinkUserLogin(req *http.Request) string { func RedirectLinkUserLogin(req *http.Request) string {
if req.Header.Get("X-Gitea-Fetch-Action") != "" {
// when building the redirect link for a fetch request, the current link might be a partial page,
// so we only redirect to the login page without redirect_to parameter
return setting.AppSubURL + "/user/login"
}
return setting.AppSubURL + "/user/login?redirect_to=" + url.QueryEscape(setting.AppSubURL+req.URL.RequestURI()) return setting.AppSubURL + "/user/login?redirect_to=" + url.QueryEscape(setting.AppSubURL+req.URL.RequestURI())
} }

View File

@ -16,8 +16,8 @@ func FetchRedirectDelegate(resp http.ResponseWriter, req *http.Request) {
// 2. when use "window.reload()", the hash is not respected, the newly loaded page won't scroll to the hash target. // 2. when use "window.reload()", the hash is not respected, the newly loaded page won't scroll to the hash target.
// The typical page is "issue comment" page. The backend responds "/owner/repo/issues/1#comment-2", // The typical page is "issue comment" page. The backend responds "/owner/repo/issues/1#comment-2",
// then frontend needs this delegate to redirect to the new location with hash correctly. // then frontend needs this delegate to redirect to the new location with hash correctly.
redirect := req.PostFormValue("redirect") redirect := req.FormValue("redirect")
if !httplib.IsCurrentGiteaSiteURL(req.Context(), redirect) { if req.Method != http.MethodPost || !httplib.IsCurrentGiteaSiteURL(req.Context(), redirect) {
resp.WriteHeader(http.StatusBadRequest) resp.WriteHeader(http.StatusBadRequest)
return return
} }

View File

@ -45,7 +45,7 @@ func List(ctx *context.Context) {
func FetchActionTest(ctx *context.Context) { func FetchActionTest(ctx *context.Context) {
_ = ctx.Req.ParseForm() _ = ctx.Req.ParseForm()
ctx.Flash.Info("fetch-action: " + ctx.Req.Method + " " + ctx.Req.RequestURI + "\n" + ctx.Flash.Info("fetch action: " + ctx.Req.Method + " " + ctx.Req.RequestURI + "\n" +
"Form: " + ctx.Req.Form.Encode() + "\n" + "Form: " + ctx.Req.Form.Encode() + "\n" +
"PostForm: " + ctx.Req.PostForm.Encode(), "PostForm: " + ctx.Req.PostForm.Encode(),
) )
@ -241,9 +241,8 @@ func prepareMockDataUnicodeEscape(ctx *context.Context) {
func TmplCommon(ctx *context.Context) { func TmplCommon(ctx *context.Context) {
prepareMockData(ctx) prepareMockData(ctx)
if ctx.Req.Method == http.MethodPost { if ctx.Req.Method == http.MethodPost && ctx.FormBool("mock_response_delay") {
_ = ctx.Req.ParseForm() ctx.Flash.Info("form submit: "+ctx.Req.Method+" "+ctx.Req.RequestURI+"\n"+
ctx.Flash.Info("form: "+ctx.Req.Method+" "+ctx.Req.RequestURI+"\n"+
"Form: "+ctx.Req.Form.Encode()+"\n"+ "Form: "+ctx.Req.Form.Encode()+"\n"+
"PostForm: "+ctx.Req.PostForm.Encode(), "PostForm: "+ctx.Req.PostForm.Encode(),
true, true,

View File

@ -26,6 +26,5 @@ func ActionStar(ctx *context.Context) {
ctx.ServerError("GetRepositoryByName", err) ctx.ServerError("GetRepositoryByName", err)
return return
} }
ctx.RespHeader().Add("hx-trigger", "refreshUserCards") // see the `hx-trigger="refreshUserCards ..."` comments in tmpl
ctx.HTML(http.StatusOK, tplStarUnstar) ctx.HTML(http.StatusOK, tplStarUnstar)
} }

View File

@ -310,13 +310,15 @@ func renderDirectoryFiles(ctx *context.Context, timeout time.Duration) git.Entri
return nil return nil
} }
{ { // this block is for testing purpose only
if timeout != 0 && !setting.IsProd && !setting.IsInTesting { if timeout != 0 && !setting.IsProd && !setting.IsInTesting {
log.Debug("first call to get directory file commit info") log.Debug("first call to get directory file commit info")
clearFilesCommitInfo := func() { clearFilesCommitInfo := func() {
log.Warn("clear directory file commit info to force async loading on frontend") log.Warn("clear directory file commit info to force async loading on frontend")
for i := range files { for i := range files {
files[i].Commit = nil if i%2 == 0 { // for testing purpose, only clear half of the files' commit info
files[i].Commit = nil
}
} }
} }
_ = clearFilesCommitInfo _ = clearFilesCommitInfo

View File

@ -26,6 +26,5 @@ func ActionWatch(ctx *context.Context) {
ctx.ServerError("GetRepositoryByName", err) ctx.ServerError("GetRepositoryByName", err)
return return
} }
ctx.RespHeader().Add("hx-trigger", "refreshUserCards") // see the `hx-trigger="refreshUserCards ..."` comments in tmpl
ctx.HTML(http.StatusOK, tplWatchUnwatch) ctx.HTML(http.StatusOK, tplWatchUnwatch)
} }

View File

@ -1710,7 +1710,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Get("/forks", repo.Forks) m.Get("/forks", repo.Forks)
m.Get("/commit/{sha:([a-f0-9]{7,64})}.{ext:patch|diff}", repo.MustBeNotEmpty, repo.RawDiff) m.Get("/commit/{sha:([a-f0-9]{7,64})}.{ext:patch|diff}", repo.MustBeNotEmpty, repo.RawDiff)
m.Post("/lastcommit/*", context.RepoRefByType(git.RefTypeCommit), repo.LastCommit) m.Get("/lastcommit/*", context.RepoRefByType(git.RefTypeCommit), repo.LastCommit)
}, optSignIn, context.RepoAssignment, reqUnitCodeReader) }, optSignIn, context.RepoAssignment, reqUnitCodeReader)
// end "/{username}/{reponame}": repo code // end "/{username}/{reponame}": repo code

View File

@ -159,12 +159,10 @@ func (b *Base) Redirect(location string, status ...int) {
// So in this case, we should remove the session cookie from the response header // So in this case, we should remove the session cookie from the response header
removeSessionCookieHeader(b.Resp) removeSessionCookieHeader(b.Resp)
} }
// in case the request is made by htmx, have it redirect the browser instead of trying to follow the redirect inside htmx // In case the request is made by "fetch-action" module, make JS redirect to the new location
if b.Req.Header.Get("HX-Request") == "true" { // Otherwise, the JS fetch will follow the redirection and read a "login" page, embed it to the current page, which is not expected.
b.Resp.Header().Set("HX-Redirect", location) if b.Req.Header.Get("X-Gitea-Fetch-Action") != "" {
// we have to return a non-redirect status code so XMLHTTPRequest will not immediately follow the redirect b.JSON(http.StatusOK, map[string]any{"redirect": location})
// so as to give htmx redirect logic a chance to run
b.Status(http.StatusNoContent)
return return
} }
http.Redirect(b.Resp, b.Req, location, code) http.Redirect(b.Resp, b.Req, location, code)

View File

@ -38,9 +38,10 @@ func TestRedirect(t *testing.T) {
req, _ = http.NewRequest(http.MethodGet, "/", nil) req, _ = http.NewRequest(http.MethodGet, "/", nil)
resp := httptest.NewRecorder() resp := httptest.NewRecorder()
req.Header.Add("HX-Request", "true") req.Header.Add("X-Gitea-Fetch-Action", "1")
b := NewBaseContextForTest(resp, req) b := NewBaseContextForTest(resp, req)
b.Redirect("/other") b.Redirect("/other")
assert.Equal(t, "/other", resp.Header().Get("HX-Redirect")) assert.Contains(t, resp.Header().Get("Content-Type"), "application/json")
assert.Equal(t, http.StatusNoContent, resp.Code) assert.JSONEq(t, `{"redirect":"/other"}`, resp.Body.String())
assert.Equal(t, http.StatusOK, resp.Code)
} }

View File

@ -76,10 +76,7 @@
</h4> </h4>
{{/* TODO: make these stats work in multi-server deployments, likely needs per-server stats in DB */}} {{/* TODO: make these stats work in multi-server deployments, likely needs per-server stats in DB */}}
<div class="ui attached table segment"> <div class="ui attached table segment">
<div class="no-loading-indicator tw-hidden"></div> {{template "admin/system_status" .}}
<div hx-get="{{$.Link}}/system_status" hx-swap="morph:innerHTML" hx-trigger="every 5s" hx-indicator=".no-loading-indicator">
{{template "admin/system_status" .}}
</div>
</div> </div>
</div> </div>
{{template "admin/layout_footer" .}} {{template "admin/layout_footer" .}}

View File

@ -50,7 +50,7 @@
</div> </div>
</div> </div>
</div> </div>
<button class="ui small button" id="delete-selection" data-link="{{.Link}}/delete" data-redirect="?page={{.Page.Paginater.Current}}"> <button class="ui small button" id="delete-selection" data-link="{{.Link}}/delete">
<span class="text">{{ctx.Locale.Tr "admin.notices.delete_selected"}}</span> <span class="text">{{ctx.Locale.Tr "admin.notices.delete_selected"}}</span>
</button> </button>
</th> </th>

View File

@ -1,4 +1,4 @@
<dl class="admin-dl-horizontal"> <dl class="admin-dl-horizontal" data-fetch-url="{{AppSubUrl}}/-/admin/system_status" data-fetch-sync="$morph" data-fetch-trigger="every 5s">
<dt>{{ctx.Locale.Tr "admin.dashboard.server_uptime"}}</dt> <dt>{{ctx.Locale.Tr "admin.dashboard.server_uptime"}}</dt>
<dd><relative-time format="duration" datetime="{{.SysStatus.StartTime}}">{{.SysStatus.StartTime}}</relative-time></dd> <dd><relative-time format="duration" datetime="{{.SysStatus.StartTime}}">{{.SysStatus.StartTime}}</relative-time></dd>
<dt>{{ctx.Locale.Tr "admin.dashboard.current_goroutine"}}</dt> <dt>{{ctx.Locale.Tr "admin.dashboard.current_goroutine"}}</dt>

View File

@ -23,7 +23,7 @@
{{template "base/head_script" .}} {{template "base/head_script" .}}
{{template "custom/header" .}} {{template "custom/header" .}}
</head> </head>
<body hx-swap="outerHTML" hx-ext="morph" hx-push-url="false"> <body hx-swap="outerHTML" hx-push-url="false">
{{template "custom/body_outer_pre" .}} {{template "custom/body_outer_pre" .}}
<div class="full height"> <div class="full height">

View File

@ -3,7 +3,7 @@
<div> <div>
<h1>link-action</h1> <h1>link-action</h1>
<div> <div>
Use "window.fetch" to send a request to backend, the request is defined in an "A" or "BUTTON" element. The request is defined in an "A" or "BUTTON" element.
It might be renamed to "link-fetch-action" to match the "form-fetch-action". It might be renamed to "link-fetch-action" to match the "form-fetch-action".
</div> </div>
<div> <div>
@ -15,7 +15,6 @@
</div> </div>
<div> <div>
<h1>form-fetch-action</h1> <h1>form-fetch-action</h1>
<div>Use "window.fetch" to send a form request to backend</div>
<div class="flex-relaxed-list fetch-action-demo-forms"> <div class="flex-relaxed-list fetch-action-demo-forms">
<form method="get" action="./fetch-action-test?k=1" class="form-fetch-action"> <form method="get" action="./fetch-action-test?k=1" class="form-fetch-action">
<button name="btn">submit get</button> <button name="btn">submit get</button>
@ -25,7 +24,7 @@
<div><label><input name="check" type="checkbox"> check</label></div> <div><label><input name="check" type="checkbox"> check</label></div>
<div><button name="btn">submit post</button></div> <div><button name="btn">submit post</button></div>
</form> </form>
<form method="post" action="./no-such-uri" class="form-fetch-action"> <form method="post" action="/-/no-such-uri" class="form-fetch-action">
<div class="tw-py-8">bad action url</div> <div class="tw-py-8">bad action url</div>
<div><button name="btn">submit test</button></div> <div><button name="btn">submit test</button></div>
</form> </form>

View File

@ -1,21 +1,9 @@
{{template "devtest/devtest-header"}} {{template "devtest/devtest-header"}}
<div class="page-content devtest ui container"> <div class="page-content devtest ui container">
{{template "base/alert" .}}
<div class="modal-buttons flex-text-block tw-flex-wrap"></div> <div class="modal-buttons flex-text-block tw-flex-wrap"></div>
<script>
document.addEventListener('gitea:index-ready', () => {
for (const el of $('.ui.modal:not([data-skip-button])')) {
const $btn = $('<button class="ui button">').text(`${el.id}`).on('click', () => {
$(el).modal({onApprove() {alert('confirmed')}}).modal('show');
});
$('.modal-buttons').append($btn);
}
});
</script>
<div id="test-modal-form-1" class="ui mini modal"> <div id="test-modal-form-1" class="ui mini modal">
<div class="header">Form dialog (layout 1)</div> <div class="header">Form dialog (layout 1)</div>
<form class="content" method="post"> <form class="content" method="post" action="?mock_response_delay=1">
<div class="ui input tw-w-full"><input name="user_input"></div> <div class="ui input tw-w-full"><input name="user_input"></div>
{{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm")}} {{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm")}}
</form> </form>
@ -23,7 +11,7 @@
<div id="test-modal-form-2" class="ui mini modal"> <div id="test-modal-form-2" class="ui mini modal">
<div class="header">Form dialog (layout 2)</div> <div class="header">Form dialog (layout 2)</div>
<form method="post"> <form method="post" action="?mock_response_delay=1">
<div class="content"> <div class="content">
<div class="ui input tw-w-full"><input name="user_input"></div> <div class="ui input tw-w-full"><input name="user_input"></div>
{{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm")}} {{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm")}}
@ -33,7 +21,7 @@
<div id="test-modal-form-3" class="ui mini modal"> <div id="test-modal-form-3" class="ui mini modal">
<div class="header">Form dialog (layout 3)</div> <div class="header">Form dialog (layout 3)</div>
<form method="post"> <form method="post" action="?mock_response_delay=1">
<div class="content"> <div class="content">
<div class="ui input tw-w-full"><input name="user_input"></div> <div class="ui input tw-w-full"><input name="user_input"></div>
</div> </div>
@ -46,7 +34,7 @@
<div class="content"> <div class="content">
<div class="ui input tw-w-full"><input name="user_input"></div> <div class="ui input tw-w-full"><input name="user_input"></div>
</div> </div>
<form method="post"> <form method="post" action="?mock_response_delay=1">
{{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm")}} {{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm")}}
</form> </form>
</div> </div>
@ -54,7 +42,7 @@
<div id="test-modal-form-5" class="ui mini modal"> <div id="test-modal-form-5" class="ui mini modal">
<div class="header">Form dialog (layout 5)</div> <div class="header">Form dialog (layout 5)</div>
<div class="content"> <div class="content">
<form method="post"> <form method="post" action="?mock_response_delay=1">
<div class="ui input tw-w-full"><input name="user_input"></div> <div class="ui input tw-w-full"><input name="user_input"></div>
{{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm")}} {{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm")}}
</form> </form>

View File

@ -45,17 +45,6 @@
</div> </div>
</li> </li>
</ul> </ul>
<script>
document.addEventListener('gitea:index-ready', () => {
const $buttons = $('#devtest-button-samples').find('button.ui');
const $buttonStyles = $('input[name*="button-style"]');
$buttonStyles.on('click', () => $buttonStyles.map((_, el) => $buttons.toggleClass(el.value, el.checked)));
const $buttonStates = $('input[name*="button-state"]');
$buttonStates.on('click', () => $buttonStates.map((_, el) => $buttons.prop(el.value, el.checked)));
});
</script>
</div> </div>
</div> </div>

View File

@ -108,7 +108,7 @@
> >
{{svg "octicon-star"}} {{ctx.Locale.Tr "repo.projects.column.set_default"}} {{svg "octicon-star"}} {{ctx.Locale.Tr "repo.projects.column.set_default"}}
</a> </a>
<a class="item button link-action" data-url="{{$.Link}}/{{.ID}}" data-link-action-method="DELETE" <a class="item button link-action" data-url="{{$.Link}}/{{.ID}}" data-fetch-method="DELETE"
data-modal-confirm-header="{{ctx.Locale.Tr "repo.projects.column.delete"}}" data-modal-confirm-header="{{ctx.Locale.Tr "repo.projects.column.delete"}}"
data-modal-confirm-content="{{ctx.Locale.Tr "repo.projects.column.deletion_desc"}}" data-modal-confirm-content="{{ctx.Locale.Tr "repo.projects.column.deletion_desc"}}"
> >

View File

@ -4,13 +4,16 @@
</div> </div>
<div id="runWorkflowDispatchModal" class="ui tiny modal"> <div id="runWorkflowDispatchModal" class="ui tiny modal">
<div class="content"> <div class="content">
<form id="runWorkflowDispatchForm" class="ui form" action="{{$.Link}}/run?workflow={{$.CurWorkflow}}&actor={{$.CurActor}}&status={{.Status}}" method="post"> <form id="runWorkflowDispatchForm" class="ui form ignore-dirty" action="{{$.Link}}/run?workflow={{$.CurWorkflow}}&actor={{$.CurActor}}&status={{.Status}}" method="post">
<div class="ui inline field required tw-flex tw-items-center"> <div class="ui inline field required tw-flex tw-items-center">
<span class="ui inline required field"> <span class="ui inline required field">
<label>{{ctx.Locale.Tr "actions.workflow.from_ref"}}:</label> <label>{{ctx.Locale.Tr "actions.workflow.from_ref"}}:</label>
</span> </span>
<div class="ui inline field dropdown button select-branch branch-selector-dropdown ellipsis-text-items"> <div class="ui inline field dropdown button select-branch branch-selector-dropdown ellipsis-text-items">
<input type="hidden" name="ref" hx-sync="this:replace" hx-target="#runWorkflowDispatchModalInputs" hx-swap="innerHTML" hx-get="{{$.Link}}/workflow-dispatch-inputs?workflow={{$.CurWorkflow}}" hx-trigger="change" value="refs/heads/{{index .Branches 0}}"> <input type="hidden" name="ref" value="refs/heads/{{index .Branches 0}}"
data-fetch-trigger="change" data-fetch-sync="$body #runWorkflowDispatchModalInputs"
data-fetch-url="{{$.Link}}/workflow-dispatch-inputs?workflow={{$.CurWorkflow}}"
>
{{svg "octicon-git-branch" 14}} {{svg "octicon-git-branch" 14}}
<div class="default text">{{index .Branches 0}}</div> <div class="default text">{{index .Branches 0}}</div>
{{svg "octicon-triangle-down" 14 "dropdown icon"}} {{svg "octicon-triangle-down" 14 "dropdown icon"}}
@ -45,12 +48,8 @@
</div> </div>
</div> </div>
</div> </div>
<div class="divider"></div> <div class="divider"></div>
{{template "repo/actions/workflow_dispatch_inputs" .}}
<div id="runWorkflowDispatchModalInputs">
{{template "repo/actions/workflow_dispatch_inputs" .}}
</div>
</form> </form>
</div> </div>
</div> </div>

View File

@ -1,3 +1,4 @@
<div id="runWorkflowDispatchModalInputs">
{{if not .WorkflowDispatchConfig}} {{if not .WorkflowDispatchConfig}}
<div class="ui error message tw-block">{{/* using "ui message" in "ui form" needs to force to display */}} <div class="ui error message tw-block">{{/* using "ui message" in "ui form" needs to force to display */}}
{{if not .CurWorkflowExists}} {{if not .CurWorkflowExists}}
@ -44,3 +45,4 @@
</div> </div>
{{end}} {{end}}
{{end}} {{end}}
</div>

View File

@ -8,7 +8,7 @@
</div> </div>
{{end}} {{end}}
<button class="ui button ellipsis-button load-branches-and-tags tw-mt-2" aria-expanded="false" <button class="ui button ellipsis-button load-branches-and-tags tw-mt-2" aria-expanded="false"
data-fetch-url="{{.RepoLink}}/commit/{{.CommitID}}/load-branches-and-tags" data-url="{{.RepoLink}}/commit/{{.CommitID}}/load-branches-and-tags"
data-tooltip-content="{{ctx.Locale.Tr "repo.commit.load_referencing_branches_and_tags"}}" data-tooltip-content="{{ctx.Locale.Tr "repo.commit.load_referencing_branches_and_tags"}}"
>...</button> >...</button>
<div class="branch-and-tag-detail tw-hidden"> <div class="branch-and-tag-detail tw-hidden">

View File

@ -9,7 +9,7 @@
{{if and $.UpdateAllowed $.UpdateByRebaseAllowed}} {{if and $.UpdateAllowed $.UpdateByRebaseAllowed}}
<div class="tw-inline-block"> <div class="tw-inline-block">
<div id="update-pr-branch-with-base" class="ui buttons"> <div id="update-pr-branch-with-base" class="ui buttons">
<button class="ui button" data-do="{{$.Issue.Link}}/update" data-redirect="{{$.Issue.Link}}"> <button class="ui button" data-do="{{$.Issue.Link}}/update">
<span class="button-text"> <span class="button-text">
{{ctx.Locale.Tr "repo.pulls.update_branch"}} {{ctx.Locale.Tr "repo.pulls.update_branch"}}
</span> </span>

View File

@ -6,7 +6,7 @@
<div class="ui right"> <div class="ui right">
<!-- the button is wrapped with a span because the tooltip doesn't show on hover if we put data-tooltip-content directly on the button --> <!-- the button is wrapped with a span because the tooltip doesn't show on hover if we put data-tooltip-content directly on the button -->
<span data-tooltip-content="{{if or $isNew .Webhook.IsActive}}{{ctx.Locale.Tr "repo.settings.webhook.test_delivery_desc"}}{{else}}{{ctx.Locale.Tr "repo.settings.webhook.test_delivery_desc_disabled"}}{{end}}"> <span data-tooltip-content="{{if or $isNew .Webhook.IsActive}}{{ctx.Locale.Tr "repo.settings.webhook.test_delivery_desc"}}{{else}}{{ctx.Locale.Tr "repo.settings.webhook.test_delivery_desc_disabled"}}{{end}}">
<button class="ui tiny button{{if not (or $isNew .Webhook.IsActive)}} disabled{{end}}" id="test-delivery" data-link="{{.Link}}/test" data-redirect="{{.Link}}"> <button class="ui tiny button{{if not (or $isNew .Webhook.IsActive)}} disabled{{end}}" id="test-delivery" data-link="{{.Link}}/test">
<span class="text">{{ctx.Locale.Tr "repo.settings.webhook.test_delivery"}}</span> <span class="text">{{ctx.Locale.Tr "repo.settings.webhook.test_delivery"}}</span>
</button> </button>
</span> </span>

View File

@ -1,13 +1,19 @@
<form class="flex-text-inline" hx-boost="true" hx-target="this" method="post" action="{{$.RepoLink}}/action/{{if $.IsStaringRepo}}unstar{{else}}star{{end}}"> <div class="ui labeled button" {{if not $.IsSigned}}data-tooltip-content="{{ctx.Locale.Tr "repo.star_guest_user"}}"{{end}}>
<div class="ui labeled button" {{if not $.IsSigned}}data-tooltip-content="{{ctx.Locale.Tr "repo.star_guest_user"}}"{{end}}> {{$buttonText := ctx.Locale.Tr "repo.star"}}
{{$buttonText := ctx.Locale.Tr "repo.star"}} {{if $.IsStaringRepo}}{{$buttonText = ctx.Locale.Tr "repo.unstar"}}{{end}}
{{if $.IsStaringRepo}}{{$buttonText = ctx.Locale.Tr "repo.unstar"}}{{end}} <button type="button" class="ui compact small basic button" aria-label="{{$buttonText}}"
<button type="submit" class="ui compact small basic button"{{if not $.IsSigned}} disabled{{end}} aria-label="{{$buttonText}}"> {{if $.IsSigned}}
{{svg (Iif $.IsStaringRepo "octicon-star-fill" "octicon-star")}} data-fetch-method="post"
<span class="not-mobile" aria-hidden="true">{{$buttonText}}</span> data-fetch-url="{{$.RepoLink}}/action/{{if $.IsStaringRepo}}unstar{{else}}star{{end}}"
</button> data-fetch-sync="$closest(.ui.labeled.button)"
<a hx-boost="false" class="ui basic label" href="{{$.RepoLink}}/stars"> {{else}}
{{CountFmt .Repository.NumStars}} disabled
</a> {{end}}
</div> >
</form> {{svg (Iif $.IsStaringRepo "octicon-star-fill" "octicon-star")}}
<span class="not-mobile" aria-hidden="true">{{$buttonText}}</span>
</button>
<a class="ui basic label" href="{{$.RepoLink}}/stars">
{{CountFmt .Repository.NumStars}}
</a>
</div>

View File

@ -1,14 +1,5 @@
<!-- Refresh the content if a htmx response contains "HX-Trigger" header. {{/* need to reload after "watch/unwatch" or "star/unstar" fetch actions */}}
This usually happens when a user stays on the watchers/stargazers page <div class="user-cards" id="user-cards-container" data-fetch-trigger="fetch-reload">
when they watched/unwatched/starred/unstarred and the list should be refreshed.
To test go to the watchers page and click the watch button. The user cards should reload.
At the moment, no JS initialization would re-trigger (fortunately there is no JS for this page).
-->
<div class="no-loading-indicator tw-hidden"></div>
<div class="user-cards"
hx-trigger="refreshUserCards from:body" hx-indicator=".no-loading-indicator"
hx-get="" hx-swap="outerHTML" hx-select=".user-cards"
>
{{if .CardsTitle}} {{if .CardsTitle}}
<h2 class="ui dividing header"> <h2 class="ui dividing header">
{{.CardsTitle}} {{.CardsTitle}}

View File

@ -1,5 +1,11 @@
{{/* use grid layout, still use the old ID because there are many other CSS styles depending on this ID */}} {{/* use grid layout, still use the old ID because there are many other CSS styles depending on this ID */}}
<div id="repo-files-table" {{if .HasFilesWithoutLatestCommit}}hx-indicator="#repo-files-table .repo-file-cell.message" hx-trigger="load" hx-swap="morph" hx-post="{{.LastCommitLoaderURL}}"{{end}}> <div id="repo-files-table"
{{if .HasFilesWithoutLatestCommit}}
data-fetch-url="{{.LastCommitLoaderURL}}"
data-fetch-trigger="load" data-fetch-sync="$morph"
data-fetch-indicator="#repo-files-table .repo-file-cell.notready.message"
{{end}}
>
<div class="repo-file-line repo-file-last-commit"> <div class="repo-file-line repo-file-last-commit">
{{template "repo/latest_commit" .}} {{template "repo/latest_commit" .}}
<div>{{if and .LatestCommit .LatestCommit.Committer}}{{DateUtils.TimeSince .LatestCommit.Committer.When}}{{end}}</div> <div>{{if and .LatestCommit .LatestCommit.Committer}}{{DateUtils.TimeSince .LatestCommit.Committer.When}}{{end}}</div>
@ -15,7 +21,7 @@
{{$entry := $item.Entry}} {{$entry := $item.Entry}}
{{$commit := $item.Commit}} {{$commit := $item.Commit}}
{{$submoduleFile := $item.SubmoduleFile}} {{$submoduleFile := $item.SubmoduleFile}}
<div class="repo-file-cell name muted-links {{if not $commit}}notready{{end}}"> <div class="repo-file-cell name muted-links">
{{index $.FileIcons $entry.Name}} {{index $.FileIcons $entry.Name}}
{{if $entry.IsSubModule}} {{if $entry.IsSubModule}}
{{$submoduleLink := $submoduleFile.SubmoduleWebLinkTree ctx}} {{$submoduleLink := $submoduleFile.SubmoduleWebLinkTree ctx}}
@ -47,7 +53,7 @@
{{end}} {{end}}
{{end}} {{end}}
</div> </div>
<div class="repo-file-cell message commit-summary loading-icon-2px"> <div class="repo-file-cell message commit-summary {{if not $commit}}notready{{end}}">
{{if $commit}} {{if $commit}}
{{$commitLink := printf "%s/commit/%s" $.RepoLink (PathEscape $commit.ID.String)}} {{$commitLink := printf "%s/commit/%s" $.RepoLink (PathEscape $commit.ID.String)}}
{{ctx.RenderUtils.RenderCommitMessageLinkSubject $commit.Message $commitLink $.Repository}} {{ctx.RenderUtils.RenderCommitMessageLinkSubject $commit.Message $commitLink $.Repository}}

View File

@ -1,13 +1,19 @@
<form class="flex-text-inline" hx-boost="true" hx-target="this" method="post" action="{{$.RepoLink}}/action/{{if $.IsWatchingRepo}}unwatch{{else}}watch{{end}}"> <div class="ui labeled button" {{if not $.IsSigned}}data-tooltip-content="{{ctx.Locale.Tr "repo.watch_guest_user"}}"{{end}}>
<div class="ui labeled button" {{if not $.IsSigned}}data-tooltip-content="{{ctx.Locale.Tr "repo.watch_guest_user"}}"{{end}}> {{$buttonText := ctx.Locale.Tr "repo.watch"}}
{{$buttonText := ctx.Locale.Tr "repo.watch"}} {{if $.IsWatchingRepo}}{{$buttonText = ctx.Locale.Tr "repo.unwatch"}}{{end}}
{{if $.IsWatchingRepo}}{{$buttonText = ctx.Locale.Tr "repo.unwatch"}}{{end}} <button type="button" class="ui compact small basic button" aria-label="{{$buttonText}}"
<button type="submit" class="ui compact small basic button"{{if not $.IsSigned}} disabled{{end}} aria-label="{{$buttonText}}"> {{if $.IsSigned}}
{{svg "octicon-eye"}} data-fetch-method="post"
<span class="not-mobile" aria-hidden="true">{{$buttonText}}</span> data-fetch-url="{{$.RepoLink}}/action/{{if $.IsWatchingRepo}}unwatch{{else}}watch{{end}}"
</button> data-fetch-sync="$closest(.ui.labeled.button)"
<a hx-boost="false" class="ui basic label" href="{{.RepoLink}}/watchers"> {{else}}
{{CountFmt .Repository.NumWatches}} disabled
</a> {{end}}
</div> >
</form> {{svg "octicon-eye"}}
<span class="not-mobile" aria-hidden="true">{{$buttonText}}</span>
</button>
<a class="ui basic label" href="{{.RepoLink}}/watchers">
{{CountFmt .Repository.NumWatches}}
</a>
</div>

View File

@ -31,7 +31,6 @@
<div class="divider tw-my-0"></div> <div class="divider tw-my-0"></div>
<div role="main" class="page-content status-page-500"> <div role="main" class="page-content status-page-500">
<div class="ui container" > <div class="ui container" >
<style> .ui.message.flash-message { text-align: left; } </style>
{{template "base/alert" .}} {{template "base/alert" .}}
<div class="status-page-error"> <div class="status-page-error">
<div class="status-page-error-title">500 Internal Server Error</div> <div class="status-page-error-title">500 Internal Server Error</div>

7
types.d.ts vendored
View File

@ -41,6 +41,13 @@ declare module 'htmx.org/dist/htmx.esm.js' {
export default value; export default value;
} }
declare module 'idiomorph' {
interface Idiomorph {
morph(existing: Node | string, replacement: Node | string, options?: {morphStyle: 'innerHTML' | 'outerHTML'}): void;
}
export const Idiomorph: Idiomorph;
}
declare module 'swagger-ui-dist/swagger-ui-es-bundle.js' { declare module 'swagger-ui-dist/swagger-ui-es-bundle.js' {
const value = await import('swagger-ui-dist'); const value = await import('swagger-ui-dist');
export default value.SwaggerUIBundle; export default value.SwaggerUIBundle;

View File

@ -92,3 +92,8 @@
white-space: nowrap; white-space: nowrap;
color: var(--color-text-light-1); color: var(--color-text-light-1);
} }
#repo-files-table .repo-file-cell.is-loading::after {
height: 40%;
border-width: 2px;
}

View File

@ -285,6 +285,6 @@ function initAdminNotice() {
} }
} }
await POST(this.getAttribute('data-link')!, {data}); await POST(this.getAttribute('data-link')!, {data});
window.location.href = this.getAttribute('data-redirect')!; window.location.reload();
}); });
} }

View File

@ -1,73 +1,138 @@
import {request} from '../modules/fetch.ts'; import {GET, request} from '../modules/fetch.ts';
import {hideToastsAll, showErrorToast} from '../modules/toast.ts'; import {hideToastsAll, showErrorToast} from '../modules/toast.ts';
import {addDelegatedEventListener, createElementFromHTML, submitEventSubmitter} from '../utils/dom.ts'; import {addDelegatedEventListener, createElementFromHTML, submitEventSubmitter} from '../utils/dom.ts';
import {confirmModal, createConfirmModal} from './comp/ConfirmModal.ts'; import {confirmModal, createConfirmModal} from './comp/ConfirmModal.ts';
import type {RequestOpts} from '../types.ts';
import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts'; import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
import {registerGlobalSelectorFunc} from '../modules/observer.ts';
import {Idiomorph} from 'idiomorph';
import {parseDom} from '../utils.ts';
import {html} from '../utils/html.ts';
const {appSubUrl} = window.config; const {appSubUrl} = window.config;
type FetchActionOpts = {
method: string;
url: string;
headers?: HeadersInit;
body?: FormData;
// pseudo selectors/commands to update the current page with the response text when the response is text (html)
// e.g.: "$this", "$innerHTML", "$closest(tr) td .the-class", "$body #the-id"
successSync: string;
// null: no indicator
// empty string: the current element
// '.css-selector': find the element by selector
loadingIndicator: string | null;
};
// fetchActionDoRedirect does real redirection to bypass the browser's limitations of "location" // fetchActionDoRedirect does real redirection to bypass the browser's limitations of "location"
// more details are in the backend's fetch-redirect handler // more details are in the backend's fetch-redirect handler
function fetchActionDoRedirect(redirect: string) { function fetchActionDoRedirect(redirect: string) {
const form = document.createElement('form'); const form = createElementFromHTML<HTMLFormElement>(html`<form method="post"></form>`);
const input = document.createElement('input'); form.action = `${appSubUrl}/-/fetch-redirect?redirect=${encodeURIComponent(redirect)}`;
form.method = 'post';
form.action = `${appSubUrl}/-/fetch-redirect`;
input.type = 'hidden';
input.name = 'redirect';
input.value = redirect;
form.append(input);
document.body.append(form); document.body.append(form);
form.submit(); form.submit();
} }
async function fetchActionDoRequest(actionElem: HTMLElement, url: string, opt: RequestOpts) { function toggleLoadingIndicator(el: HTMLElement, opt: FetchActionOpts, isLoading: boolean) {
const showErrorForResponse = (code: number, message: string) => { const loadingIndicatorElems = opt.loadingIndicator === null ? [] : (opt.loadingIndicator === '' ? [el] : document.querySelectorAll(opt.loadingIndicator));
showErrorToast(`Error ${code || 'request'}: ${message}`); for (const indicatorEl of loadingIndicatorElems) {
}; if (isLoading) {
if ('disabled' in indicatorEl) {
let respStatus = 0; indicatorEl.disabled = true;
let respText = '';
try {
hideToastsAll();
const resp = await request(url, opt);
respStatus = resp.status;
respText = await resp.text();
const respJson = JSON.parse(respText);
if (respStatus === 200) {
let {redirect} = respJson;
redirect = redirect || actionElem.getAttribute('data-redirect');
ignoreAreYouSure(actionElem); // ignore the areYouSure check before reloading
if (redirect) {
fetchActionDoRedirect(redirect);
} else { } else {
window.location.reload(); indicatorEl.classList.add('is-loading');
if (indicatorEl.clientHeight < 50) indicatorEl.classList.add('loading-icon-2px');
} }
return;
}
if (respStatus >= 400 && respStatus < 500 && respJson?.errorMessage) {
// the code was quite messy, sometimes the backend uses "err", sometimes it uses "error", and even "user_error"
// but at the moment, as a new approach, we only use "errorMessage" here, backend can use JSONError() to respond.
showErrorToast(respJson.errorMessage, {useHtmlBody: respJson.renderFormat === 'html'});
} else { } else {
showErrorForResponse(respStatus, respText); if ('disabled' in indicatorEl) {
} indicatorEl.disabled = false;
} catch (e) { } else {
if (e.name === 'SyntaxError') { indicatorEl.classList.remove('is-loading', 'loading-icon-2px');
showErrorForResponse(respStatus, (respText || '').substring(0, 100)); }
} else if (e.name !== 'AbortError') {
console.error('fetchActionDoRequest error', e);
showErrorForResponse(respStatus, `${e}`);
} }
} }
actionElem.classList.remove('is-loading', 'loading-icon-2px');
} }
async function onFormFetchActionSubmit(formEl: HTMLFormElement, e: SubmitEvent) { async function handleFetchActionSuccessJson(el: HTMLElement, respJson: any) {
e.preventDefault(); ignoreAreYouSure(el); // ignore the areYouSure check before reloading
await submitFormFetchAction(formEl, {formSubmitter: submitEventSubmitter(e)}); if (respJson?.redirect) {
fetchActionDoRedirect(respJson.redirect);
} else {
window.location.reload();
}
}
async function handleFetchActionSuccess(el: HTMLElement, opt: FetchActionOpts, resp: Response) {
const isRespJson = resp.headers.get('content-type')?.includes('application/json');
const respText = await resp.text();
const respJson = isRespJson ? JSON.parse(respText) : null;
if (isRespJson) {
await handleFetchActionSuccessJson(el, respJson);
} else if (opt.successSync) {
await handleFetchActionSuccessSync(el, opt.successSync, respText);
} else {
showErrorToast(`Unsupported fetch action response, expected JSON but got: ${respText.substring(0, 200)}`);
}
}
async function handleFetchActionError(resp: Response) {
const isRespJson = resp.headers.get('content-type')?.includes('application/json');
const respText = await resp.text();
const respJson = isRespJson ? JSON.parse(await resp.text()) : null;
if (respJson?.errorMessage) {
// the code was quite messy, sometimes the backend uses "err", sometimes it uses "error", and even "user_error"
// but at the moment, as a new approach, we only use "errorMessage" here, backend can use JSONError() to respond.
showErrorToast(respJson.errorMessage, {useHtmlBody: respJson.renderFormat === 'html'});
} else {
showErrorToast(`Error ${resp.status} ${resp.statusText}. Response: ${respText.substring(0, 200)}`);
}
}
function buildFetchActionUrl(el: HTMLElement, opt: FetchActionOpts) {
let url = opt.url;
if ('name' in el && 'value' in el) {
// ref: https://htmx.org/attributes/hx-get/
// If the element with the hx-get attribute also has a value, this will be included as a parameter
const name = (el as HTMLInputElement).name;
const val = (el as HTMLInputElement).value;
const u = new URL(url, window.location.href);
if (name && !u.searchParams.has(name)) {
u.searchParams.set(name, val);
url = u.toString();
}
}
return url;
}
async function performActionRequest(el: HTMLElement, opt: FetchActionOpts) {
const attrIsLoading = 'data-fetch-is-loading';
if (el.getAttribute(attrIsLoading)) return;
if (!await confirmFetchAction(el)) return;
el.setAttribute(attrIsLoading, 'true');
toggleLoadingIndicator(el, opt, true);
try {
const url = buildFetchActionUrl(el, opt);
const headers = new Headers(opt.headers);
headers.set('X-Gitea-Fetch-Action', '1');
const resp = await request(url, {method: opt.method, body: opt.body, headers});
if (resp.ok) {
await handleFetchActionSuccess(el, opt, resp);
return;
}
await handleFetchActionError(resp);
} catch (e) {
if (e.name !== 'AbortError') {
console.error(`Fetch action request error:`, e);
showErrorToast(`Error: ${e.message ?? e}`);
}
} finally {
toggleLoadingIndicator(el, opt, false);
el.removeAttribute(attrIsLoading);
}
} }
type SubmitFormFetchActionOpts = { type SubmitFormFetchActionOpts = {
@ -75,15 +140,8 @@ type SubmitFormFetchActionOpts = {
formData?: FormData; formData?: FormData;
}; };
export async function submitFormFetchAction(formEl: HTMLFormElement, opts: SubmitFormFetchActionOpts = {}) { function prepareFormFetchActionOpts(formEl: HTMLFormElement, opts: SubmitFormFetchActionOpts = {}): FetchActionOpts {
if (formEl.classList.contains('is-loading')) return; const formMethodUpper = formEl.getAttribute('method')?.toUpperCase() || 'GET';
formEl.classList.add('is-loading');
if (formEl.clientHeight < 50) {
formEl.classList.add('loading-icon-2px');
}
const formMethod = formEl.getAttribute('method') || 'get';
const formActionUrl = formEl.getAttribute('action') || window.location.href; const formActionUrl = formEl.getAttribute('action') || window.location.href;
const formData = opts.formData ?? new FormData(formEl); const formData = opts.formData ?? new FormData(formEl);
const [submitterName, submitterValue] = [opts.formSubmitter?.getAttribute('name'), opts.formSubmitter?.getAttribute('value')]; const [submitterName, submitterValue] = [opts.formSubmitter?.getAttribute('name'), opts.formSubmitter?.getAttribute('value')];
@ -92,11 +150,8 @@ export async function submitFormFetchAction(formEl: HTMLFormElement, opts: Submi
} }
let reqUrl = formActionUrl; let reqUrl = formActionUrl;
const reqOpt = { let reqBody: FormData | undefined;
method: formMethod.toUpperCase(), if (formMethodUpper === 'GET') {
body: null as FormData | null,
};
if (formMethod.toLowerCase() === 'get') {
const params = new URLSearchParams(); const params = new URLSearchParams();
for (const [key, value] of formData) { for (const [key, value] of formData) {
params.append(key, value as string); params.append(key, value as string);
@ -107,25 +162,23 @@ export async function submitFormFetchAction(formEl: HTMLFormElement, opts: Submi
} }
reqUrl += `?${params.toString()}`; reqUrl += `?${params.toString()}`;
} else { } else {
reqOpt.body = formData; reqBody = formData;
} }
return {
await fetchActionDoRequest(formEl, reqUrl, reqOpt); method: formMethodUpper,
url: reqUrl,
body: reqBody,
loadingIndicator: '', // for form submit, by default, the loading indicator is the whole form
successSync: formEl.getAttribute('data-fetch-sync') ?? '', // by default, no fetch sync for form submit
};
} }
async function onLinkActionClick(el: HTMLElement, e: Event) { export async function submitFormFetchAction(formEl: HTMLFormElement, opts: SubmitFormFetchActionOpts = {}) {
// A "link-action" can post AJAX request to its "data-url" hideToastsAll();
// Then the browser is redirected to: the "redirect" in response, or "data-redirect" attribute, or current URL by reloading. await performActionRequest(formEl, prepareFormFetchActionOpts(formEl, opts));
// If the "link-action" has "data-modal-confirm" attribute, a "confirm modal dialog" will be shown before taking action. }
// Attribute "data-modal-confirm" can be a modal element by "#the-modal-id", or a string content for the modal dialog.
e.preventDefault();
const url = el.getAttribute('data-url')!;
const doRequest = async () => {
if ('disabled' in el) el.disabled = true; // el could be A or BUTTON, but "A" doesn't have the "disabled" attribute
await fetchActionDoRequest(el, url, {method: el.getAttribute('data-link-action-method') || 'POST'});
if ('disabled' in el) el.disabled = false;
};
async function confirmFetchAction(el: HTMLElement) {
let elModal: HTMLElement | null = null; let elModal: HTMLElement | null = null;
const dataModalConfirm = el.getAttribute('data-modal-confirm') || ''; const dataModalConfirm = el.getAttribute('data-modal-confirm') || '';
if (dataModalConfirm.startsWith('#')) { if (dataModalConfirm.startsWith('#')) {
@ -147,18 +200,179 @@ async function onLinkActionClick(el: HTMLElement, e: Event) {
}); });
} }
} }
if (!elModal) return true;
return await confirmModal(elModal);
}
if (!elModal) { async function performLinkFetchAction(el: HTMLElement) {
await doRequest(); hideToastsAll();
await performActionRequest(el, {
method: el.getAttribute('data-fetch-method') || 'POST', // by default, the method is POST for link-action
url: el.getAttribute('data-url')!,
loadingIndicator: el.getAttribute('data-fetch-indicator') || '', // by default, the link-action itself is the loading indicator
successSync: el.getAttribute('data-fetch-sync') ?? '', // by default, no fetch sync for link-action
});
}
type FetchActionTriggerType = 'click' | 'change' | 'every' | 'load' | 'fetch-reload';
async function performFetchActionTriggerRequest(el: HTMLElement, triggerType: FetchActionTriggerType) {
const isUserInitiated = triggerType === 'click' || triggerType === 'change';
// for user initiated action, by default, the loading indicator is the element itself, otherwise no loading indicator
const defaultLoadingIndicator = isUserInitiated ? '' : null;
if (isUserInitiated) hideToastsAll();
await performActionRequest(el, {
method: el.getAttribute('data-fetch-method') || 'GET', // by default, the method is GET for fetch trigger action
url: el.getAttribute('data-fetch-url')!,
loadingIndicator: el.getAttribute('data-fetch-indicator') ?? defaultLoadingIndicator,
successSync: el.getAttribute('data-fetch-sync') ?? '$this', // by default, the response will replace the current element
});
}
async function handleFetchActionSuccessSync(el: HTMLElement, successSync: string, respText: string) {
const cmds = successSync.split(' ').map((s) => s.trim()).filter(Boolean) || [];
let target = el, replaceInner = false, useMorph = false;
for (const cmd of cmds) {
if (cmd === '$this') {
target = el;
} else if (cmd === '$body') {
target = document.body;
} else if (cmd === '$innerHTML') {
replaceInner = true;
} else if (cmd === '$morph') {
useMorph = true;
} else if (cmd.startsWith('$closest(') && cmd.endsWith(')')) {
const selector = cmd.substring('$closest('.length, cmd.length - 1);
target = target.closest(selector) as HTMLElement;
} else {
target = target.querySelector(cmd) as HTMLElement;
}
}
if (useMorph) {
Idiomorph.morph(target, respText, {morphStyle: replaceInner ? 'innerHTML' : 'outerHTML'});
} else if (replaceInner) {
target.innerHTML = respText;
} else {
target.outerHTML = respText;
}
await fetchActionReloadOutdatedElements();
}
async function fetchActionReloadOutdatedElements() {
const outdatedElems: HTMLElement[] = [];
for (const outdated of document.querySelectorAll<HTMLElement>('[data-fetch-trigger~="fetch-reload"]')) {
if (!outdated.id) throw new Error(`Elements with "fetch-reload" trigger must have an id to be reloaded after fetch sync: ${outdated.outerHTML.substring(0, 100)}`);
outdatedElems.push(outdated);
}
if (!outdatedElems.length) return;
const resp = await GET(window.location.href);
if (!resp.ok) {
showErrorToast(`Failed to reload page content after fetch action: ${resp.status} ${resp.statusText}`);
return; return;
} }
const newPageHtml = await resp.text();
const newPageDom = parseDom(newPageHtml, 'text/html');
for (const oldEl of outdatedElems) {
// eslint-disable-next-line unicorn/prefer-query-selector
const newEl = newPageDom.getElementById(oldEl.id);
if (newEl) {
oldEl.replaceWith(newEl);
} else {
oldEl.remove();
}
}
}
if (await confirmModal(elModal)) { function initFetchActionTriggerEvery(el: HTMLElement, trigger: string) {
await doRequest(); const interval = trigger.substring('every '.length);
const match = /^(\d+)(ms|s)$/.exec(interval);
if (!match) throw new Error(`Invalid interval format: ${interval}`);
const num = parseInt(match[1], 10), unit = match[2];
const intervalMs = unit === 's' ? num * 1000 : num;
const fn = async () => {
try {
await performFetchActionTriggerRequest(el, 'every');
} finally {
// only continue if the element is still in the document
if (document.contains(el)) {
setTimeout(fn, intervalMs);
}
}
};
setTimeout(fn, intervalMs);
}
function initFetchActionTrigger(el: HTMLElement) {
const trigger = el.getAttribute('data-fetch-trigger');
// this trigger is managed internally, only triggered after fetch sync success, not triggered by event or timer
if (trigger === 'fetch-reload') return;
if (trigger === 'load') {
performFetchActionTriggerRequest(el, trigger);
} else if (trigger === 'change') {
el.addEventListener('change', () => performFetchActionTriggerRequest(el, trigger));
} else if (trigger?.startsWith('every ')) {
initFetchActionTriggerEvery(el, trigger);
} else if (!trigger || trigger === 'click') {
el.addEventListener('click', (e) => {
e.preventDefault();
performFetchActionTriggerRequest(el, 'click');
});
} else {
throw new Error(`Unsupported fetch trigger: ${trigger}`);
} }
} }
export function initGlobalFetchAction() { export function initGlobalFetchAction() {
addDelegatedEventListener(document, 'submit', '.form-fetch-action', onFormFetchActionSubmit); // "fetch-action" is a general approach for elements to trigger fetch requests:
addDelegatedEventListener(document, 'click', '.link-action', onLinkActionClick); // show confirm dialog (if any), show loading indicators, send fetch request, and redirect or update UI after success.
//
// Attributes:
//
// * data-fetch-method: the HTTP method to use
// * default to "GET" for "data-fetch-url" actions, "POST" for "link-action" elements
// * this attribute is ignored, the method will be determined by the form's "method" attribute, and default to "GET"
//
// * data-fetch-url: the URL for the request
//
// * data-fetch-trigger: the event to trigger the fetch action, can be:
// * "click", "change" (user-initiated events)
// * "load" (triggered on page load)
// * "every 5s" (also support "ms" unit)
// * "fetch-reload" (only triggered by fetch sync success to reload outdated content)
//
// * data-fetch-indicator: the loading indicator element selector
//
// * data-fetch-sync: when the response is text (html), the pseudo selectors/commands defined in "data-fetch-sync"
// will be used to update the content in the current page. It only supports some simple syntaxes that we need.
// "$" prefix means it is our private command (for special logic)
// * "" (empty string): replace the current element with the response
// * "$innerHTML": replace innerHTML of the current element with the response, instead of replacing the whole element (outerHTML)
// * "$morph": use morph algorithm to update the target element
// * "$body #the-id .the-class": query the selector one by one from body
// * "$closest(tr) td": pseudo command can help to find the target element in a more flexible way
//
// * data-modal-confirm: a "confirm modal dialog" will be shown before taking action.
// * it can be a string for the content of the modal dialog
// * it has "-header" and "-content" variants to set the header and content of the confirm modal
// * it can refer an existing modal element by "#the-modal-id"
addDelegatedEventListener(document, 'submit', '.form-fetch-action', async (el: HTMLFormElement, e) => {
// "fetch-action" will use the form's data to send the request
e.preventDefault();
await submitFormFetchAction(el, {formSubmitter: submitEventSubmitter(e)});
});
addDelegatedEventListener(document, 'click', '.link-action', async (el, e) => {
// `<a class="link-action" data-url="...">` is a shorthand for
// `<a data-fetch-trigger="click" data-fetch-method="post" data-fetch-url="..." data-fetch-indicator="">`
e.preventDefault();
await performLinkFetchAction(el);
});
registerGlobalSelectorFunc('[data-fetch-url]', initFetchActionTrigger);
} }

View File

@ -37,11 +37,6 @@ export function initCompWebHookEditor() {
document.querySelector<HTMLButtonElement>('#test-delivery')?.addEventListener('click', async function () { document.querySelector<HTMLButtonElement>('#test-delivery')?.addEventListener('click', async function () {
this.classList.add('is-loading', 'disabled'); this.classList.add('is-loading', 'disabled');
await POST(this.getAttribute('data-link')!); await POST(this.getAttribute('data-link')!);
setTimeout(() => { setTimeout(() => window.location.reload(), 5000);
const redirectUrl = this.getAttribute('data-redirect');
if (redirectUrl) {
window.location.href = redirectUrl;
}
}, 5000);
}); });
} }

View File

@ -4,7 +4,7 @@ import {GET} from '../modules/fetch.ts';
async function loadBranchesAndTags(area: Element, loadingButton: Element) { async function loadBranchesAndTags(area: Element, loadingButton: Element) {
loadingButton.classList.add('disabled'); loadingButton.classList.add('disabled');
try { try {
const res = await GET(loadingButton.getAttribute('data-fetch-url')!); const res = await GET(loadingButton.getAttribute('data-url')!);
const data = await res.json(); const data = await res.json();
hideElem(loadingButton); hideElem(loadingButton);
addTags(area, data.tags); addTags(area, data.tags);

View File

@ -11,7 +11,6 @@ function initRepoPullRequestUpdate(el: HTMLElement) {
const prUpdateDropdown = prUpdateButtonContainer.querySelector(':scope > .ui.dropdown')!; const prUpdateDropdown = prUpdateButtonContainer.querySelector(':scope > .ui.dropdown')!;
prUpdateButton.addEventListener('click', async function (e) { prUpdateButton.addEventListener('click', async function (e) {
e.preventDefault(); e.preventDefault();
const redirect = this.getAttribute('data-redirect');
this.classList.add('is-loading'); this.classList.add('is-loading');
let response: Response | undefined; let response: Response | undefined;
try { try {
@ -29,8 +28,6 @@ function initRepoPullRequestUpdate(el: HTMLElement) {
} }
if (data?.redirect) { if (data?.redirect) {
window.location.href = data.redirect; window.location.href = data.redirect;
} else if (redirect) {
window.location.href = redirect;
} else { } else {
window.location.reload(); window.location.reload();
} }

View File

@ -1,6 +1,5 @@
import jquery from 'jquery'; // eslint-disable-line no-restricted-imports import jquery from 'jquery'; // eslint-disable-line no-restricted-imports
import htmx from 'htmx.org'; // eslint-disable-line no-restricted-imports import htmx from 'htmx.org'; // eslint-disable-line no-restricted-imports
import 'idiomorph/htmx'; // eslint-disable-line no-restricted-imports
// Some users still use inline scripts and expect jQuery to be available globally. // Some users still use inline scripts and expect jQuery to be available globally.
// To avoid breaking existing users and custom plugins, import jQuery globally without ES module. // To avoid breaking existing users and custom plugins, import jQuery globally without ES module.

View File

@ -185,5 +185,3 @@ document.body.addEventListener('htmx:responseError', (event) => {
// TODO: add translations // TODO: add translations
showErrorToast(`Error ${(event as HtmxEvent).detail.xhr.status} when calling ${(event as HtmxEvent).detail.requestConfig.path}`); showErrorToast(`Error ${(event as HtmxEvent).detail.xhr.status} when calling ${(event as HtmxEvent).detail.requestConfig.path}`);
}); });
document.dispatchEvent(new CustomEvent('gitea:index-ready'));

View File

@ -1,20 +1,57 @@
import {showInfoToast, showWarningToast, showErrorToast} from './toast.ts'; import {showInfoToast, showWarningToast, showErrorToast} from './toast.ts';
import type {Toast} from './toast.ts'; import type {Toast} from './toast.ts';
import {registerGlobalInitFunc} from './observer.ts'; import {registerGlobalInitFunc} from './observer.ts';
import {fomanticQuery} from './fomantic/base.ts';
import {createElementFromHTML} from '../utils/dom.ts';
import {html} from '../utils/html.ts';
type LevelMap = Record<string, (message: string) => Toast | null>; type LevelMap = Record<string, (message: string) => Toast | null>;
export function initDevtest() { function initDevtestPage() {
registerGlobalInitFunc('initDevtestPage', () => { const toastButtons = document.querySelectorAll('.toast-test-button');
const els = document.querySelectorAll('.toast-test-button'); if (toastButtons.length) {
if (!els.length) return;
const levelMap: LevelMap = {info: showInfoToast, warning: showWarningToast, error: showErrorToast}; const levelMap: LevelMap = {info: showInfoToast, warning: showWarningToast, error: showErrorToast};
for (const el of els) { for (const el of toastButtons) {
el.addEventListener('click', () => { el.addEventListener('click', () => {
const level = el.getAttribute('data-toast-level')!; const level = el.getAttribute('data-toast-level')!;
const message = el.getAttribute('data-toast-message')!; const message = el.getAttribute('data-toast-message')!;
levelMap[level](message); levelMap[level](message);
}); });
} }
}); }
const modalButtons = document.querySelector('.modal-buttons');
if (modalButtons) {
for (const el of document.querySelectorAll('.ui.modal:not([data-skip-button])')) {
const btn = createElementFromHTML(html`<button class="ui button">${el.id}</button`);
btn.addEventListener('click', () => fomanticQuery(el).modal('show'));
modalButtons.append(btn);
}
}
const sampleButtons = document.querySelectorAll('#devtest-button-samples button.ui.button');
if (sampleButtons.length) {
const buttonStyles = document.querySelectorAll<HTMLInputElement>('input[name*="button-style"]');
for (const elStyle of buttonStyles) {
elStyle.addEventListener('click', () => {
for (const btn of sampleButtons) {
for (const el of buttonStyles) {
if (el.value) btn.classList.toggle(el.value, el.checked);
}
}
});
}
const buttonStates = document.querySelectorAll<HTMLInputElement>('input[name*="button-state"]');
for (const elState of buttonStates) {
elState.addEventListener('click', () => {
for (const btn of sampleButtons) {
(btn as any)[elState.value] = elState.checked;
}
});
}
}
}
export function initDevtest() {
registerGlobalInitFunc('initDevtestPage', initDevtestPage);
} }

View File

@ -2,7 +2,7 @@ import {isObject} from '../utils.ts';
import type {RequestOpts} from '../types.ts'; import type {RequestOpts} from '../types.ts';
// fetch wrapper, use below method name functions and the `data` option to pass in data // fetch wrapper, use below method name functions and the `data` option to pass in data
// which will automatically set an appropriate headers. For json content, only object // which will automatically set an appropriate headers. For JSON content, only object
// and array types are currently supported. // and array types are currently supported.
export function request(url: string, {method = 'GET', data, headers = {}, ...other}: RequestOpts = {}): Promise<Response> { export function request(url: string, {method = 'GET', data, headers = {}, ...other}: RequestOpts = {}): Promise<Response> {
let body: string | FormData | URLSearchParams | undefined; let body: string | FormData | URLSearchParams | undefined;
@ -14,17 +14,13 @@ export function request(url: string, {method = 'GET', data, headers = {}, ...oth
body = JSON.stringify(data); body = JSON.stringify(data);
} }
const headersMerged = new Headers({ headers = new Headers(headers);
...(contentType && {'content-type': contentType}), if (!headers.has('content-type') && contentType) {
}); headers.set('content-type', contentType);
for (const [name, value] of Object.entries(headers)) {
headersMerged.set(name, value);
} }
return fetch(url, { // eslint-disable-line no-restricted-globals return fetch(url, { // eslint-disable-line no-restricted-globals
method, method,
headers: headersMerged, headers,
...other, ...other,
...(body && {body}), ...(body && {body}),
}); });

View File

@ -1,6 +1,6 @@
import { import {
dirname, basename, extname, isObject, stripTags, parseIssueHref, dirname, basename, extname, isObject, stripTags, parseIssueHref,
parseUrl, translateMonth, translateDay, blobToDataURI, translateMonth, translateDay, blobToDataURI,
toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64, isImageFile, isVideoFile, parseRepoOwnerPathInfo, toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64, isImageFile, isVideoFile, parseRepoOwnerPathInfo,
urlQueryEscape, urlQueryEscape,
} from './utils.ts'; } from './utils.ts';
@ -68,18 +68,6 @@ test('parseRepoOwnerPathInfo', () => {
window.config.appSubUrl = ''; window.config.appSubUrl = '';
}); });
test('parseUrl', () => {
expect(parseUrl('').pathname).toEqual('/');
expect(parseUrl('/path').pathname).toEqual('/path');
expect(parseUrl('/path?search').pathname).toEqual('/path');
expect(parseUrl('/path?search').search).toEqual('?search');
expect(parseUrl('/path?search#hash').hash).toEqual('#hash');
expect(parseUrl('https://localhost/path').pathname).toEqual('/path');
expect(parseUrl('https://localhost/path?search').pathname).toEqual('/path');
expect(parseUrl('https://localhost/path?search').search).toEqual('?search');
expect(parseUrl('https://localhost/path?search#hash').hash).toEqual('#hash');
});
test('translateMonth', () => { test('translateMonth', () => {
const originalLang = document.documentElement.lang; const originalLang = document.documentElement.lang;
document.documentElement.lang = 'en-US'; document.documentElement.lang = 'en-US';

View File

@ -84,11 +84,6 @@ export function parseIssuePageInfo(): IssuePageInfo {
}; };
} }
/** parse a URL, either relative '/path' or absolute 'https://localhost/path' */
export function parseUrl(str: string): URL {
return new URL(str, str.startsWith('http') ? undefined : window.location.origin);
}
/** return current locale chosen by user */ /** return current locale chosen by user */
export function getCurrentLocale(): string { export function getCurrentLocale(): string {
return document.documentElement.lang; return document.documentElement.lang;