mirror of
https://github.com/go-gitea/gitea.git
synced 2026-04-14 08:28:07 +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:
@@ -575,7 +575,6 @@ export default defineConfig([
|
||||
'no-restricted-imports': [2, {paths: [
|
||||
{name: 'jquery', message: 'Use the global $ 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-return-assign': [0],
|
||||
|
||||
@@ -35,6 +35,11 @@ func DeleteRedirectToCookie(resp http.ResponseWriter) {
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
// 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.
|
||||
redirect := req.PostFormValue("redirect")
|
||||
if !httplib.IsCurrentGiteaSiteURL(req.Context(), redirect) {
|
||||
redirect := req.FormValue("redirect")
|
||||
if req.Method != http.MethodPost || !httplib.IsCurrentGiteaSiteURL(req.Context(), redirect) {
|
||||
resp.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ func List(ctx *context.Context) {
|
||||
|
||||
func FetchActionTest(ctx *context.Context) {
|
||||
_ = 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" +
|
||||
"PostForm: " + ctx.Req.PostForm.Encode(),
|
||||
)
|
||||
@@ -241,9 +241,8 @@ func prepareMockDataUnicodeEscape(ctx *context.Context) {
|
||||
|
||||
func TmplCommon(ctx *context.Context) {
|
||||
prepareMockData(ctx)
|
||||
if ctx.Req.Method == http.MethodPost {
|
||||
_ = ctx.Req.ParseForm()
|
||||
ctx.Flash.Info("form: "+ctx.Req.Method+" "+ctx.Req.RequestURI+"\n"+
|
||||
if ctx.Req.Method == http.MethodPost && ctx.FormBool("mock_response_delay") {
|
||||
ctx.Flash.Info("form submit: "+ctx.Req.Method+" "+ctx.Req.RequestURI+"\n"+
|
||||
"Form: "+ctx.Req.Form.Encode()+"\n"+
|
||||
"PostForm: "+ctx.Req.PostForm.Encode(),
|
||||
true,
|
||||
|
||||
@@ -26,6 +26,5 @@ func ActionStar(ctx *context.Context) {
|
||||
ctx.ServerError("GetRepositoryByName", err)
|
||||
return
|
||||
}
|
||||
ctx.RespHeader().Add("hx-trigger", "refreshUserCards") // see the `hx-trigger="refreshUserCards ..."` comments in tmpl
|
||||
ctx.HTML(http.StatusOK, tplStarUnstar)
|
||||
}
|
||||
|
||||
@@ -310,13 +310,15 @@ func renderDirectoryFiles(ctx *context.Context, timeout time.Duration) git.Entri
|
||||
return nil
|
||||
}
|
||||
|
||||
{
|
||||
{ // this block is for testing purpose only
|
||||
if timeout != 0 && !setting.IsProd && !setting.IsInTesting {
|
||||
log.Debug("first call to get directory file commit info")
|
||||
clearFilesCommitInfo := func() {
|
||||
log.Warn("clear directory file commit info to force async loading on frontend")
|
||||
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
|
||||
|
||||
@@ -26,6 +26,5 @@ func ActionWatch(ctx *context.Context) {
|
||||
ctx.ServerError("GetRepositoryByName", err)
|
||||
return
|
||||
}
|
||||
ctx.RespHeader().Add("hx-trigger", "refreshUserCards") // see the `hx-trigger="refreshUserCards ..."` comments in tmpl
|
||||
ctx.HTML(http.StatusOK, tplWatchUnwatch)
|
||||
}
|
||||
|
||||
@@ -1710,7 +1710,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
|
||||
m.Get("/forks", repo.Forks)
|
||||
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)
|
||||
// end "/{username}/{reponame}": repo code
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
if b.Req.Header.Get("HX-Request") == "true" {
|
||||
b.Resp.Header().Set("HX-Redirect", location)
|
||||
// we have to return a non-redirect status code so XMLHTTPRequest will not immediately follow the redirect
|
||||
// so as to give htmx redirect logic a chance to run
|
||||
b.Status(http.StatusNoContent)
|
||||
// In case the request is made by "fetch-action" module, make JS redirect to the new location
|
||||
// Otherwise, the JS fetch will follow the redirection and read a "login" page, embed it to the current page, which is not expected.
|
||||
if b.Req.Header.Get("X-Gitea-Fetch-Action") != "" {
|
||||
b.JSON(http.StatusOK, map[string]any{"redirect": location})
|
||||
return
|
||||
}
|
||||
http.Redirect(b.Resp, b.Req, location, code)
|
||||
|
||||
@@ -38,9 +38,10 @@ func TestRedirect(t *testing.T) {
|
||||
|
||||
req, _ = http.NewRequest(http.MethodGet, "/", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
req.Header.Add("HX-Request", "true")
|
||||
req.Header.Add("X-Gitea-Fetch-Action", "1")
|
||||
b := NewBaseContextForTest(resp, req)
|
||||
b.Redirect("/other")
|
||||
assert.Equal(t, "/other", resp.Header().Get("HX-Redirect"))
|
||||
assert.Equal(t, http.StatusNoContent, resp.Code)
|
||||
assert.Contains(t, resp.Header().Get("Content-Type"), "application/json")
|
||||
assert.JSONEq(t, `{"redirect":"/other"}`, resp.Body.String())
|
||||
assert.Equal(t, http.StatusOK, resp.Code)
|
||||
}
|
||||
|
||||
@@ -76,10 +76,7 @@
|
||||
</h4>
|
||||
{{/* TODO: make these stats work in multi-server deployments, likely needs per-server stats in DB */}}
|
||||
<div class="ui attached table segment">
|
||||
<div class="no-loading-indicator tw-hidden"></div>
|
||||
<div hx-get="{{$.Link}}/system_status" hx-swap="morph:innerHTML" hx-trigger="every 5s" hx-indicator=".no-loading-indicator">
|
||||
{{template "admin/system_status" .}}
|
||||
</div>
|
||||
{{template "admin/system_status" .}}
|
||||
</div>
|
||||
</div>
|
||||
{{template "admin/layout_footer" .}}
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
</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>
|
||||
</button>
|
||||
</th>
|
||||
|
||||
@@ -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>
|
||||
<dd><relative-time format="duration" datetime="{{.SysStatus.StartTime}}">{{.SysStatus.StartTime}}</relative-time></dd>
|
||||
<dt>{{ctx.Locale.Tr "admin.dashboard.current_goroutine"}}</dt>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
{{template "base/head_script" .}}
|
||||
{{template "custom/header" .}}
|
||||
</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" .}}
|
||||
|
||||
<div class="full height">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div>
|
||||
<h1>link-action</h1>
|
||||
<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".
|
||||
</div>
|
||||
<div>
|
||||
@@ -15,7 +15,6 @@
|
||||
</div>
|
||||
<div>
|
||||
<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">
|
||||
<form method="get" action="./fetch-action-test?k=1" class="form-fetch-action">
|
||||
<button name="btn">submit get</button>
|
||||
@@ -25,7 +24,7 @@
|
||||
<div><label><input name="check" type="checkbox"> check</label></div>
|
||||
<div><button name="btn">submit post</button></div>
|
||||
</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><button name="btn">submit test</button></div>
|
||||
</form>
|
||||
|
||||
@@ -1,21 +1,9 @@
|
||||
{{template "devtest/devtest-header"}}
|
||||
<div class="page-content devtest ui container">
|
||||
{{template "base/alert" .}}
|
||||
<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 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>
|
||||
{{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm")}}
|
||||
</form>
|
||||
@@ -23,7 +11,7 @@
|
||||
|
||||
<div id="test-modal-form-2" class="ui mini modal">
|
||||
<div class="header">Form dialog (layout 2)</div>
|
||||
<form method="post">
|
||||
<form method="post" action="?mock_response_delay=1">
|
||||
<div class="content">
|
||||
<div class="ui input tw-w-full"><input name="user_input"></div>
|
||||
{{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm")}}
|
||||
@@ -33,7 +21,7 @@
|
||||
|
||||
<div id="test-modal-form-3" class="ui mini modal">
|
||||
<div class="header">Form dialog (layout 3)</div>
|
||||
<form method="post">
|
||||
<form method="post" action="?mock_response_delay=1">
|
||||
<div class="content">
|
||||
<div class="ui input tw-w-full"><input name="user_input"></div>
|
||||
</div>
|
||||
@@ -46,7 +34,7 @@
|
||||
<div class="content">
|
||||
<div class="ui input tw-w-full"><input name="user_input"></div>
|
||||
</div>
|
||||
<form method="post">
|
||||
<form method="post" action="?mock_response_delay=1">
|
||||
{{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm")}}
|
||||
</form>
|
||||
</div>
|
||||
@@ -54,7 +42,7 @@
|
||||
<div id="test-modal-form-5" class="ui mini modal">
|
||||
<div class="header">Form dialog (layout 5)</div>
|
||||
<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>
|
||||
{{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm")}}
|
||||
</form>
|
||||
|
||||
@@ -45,17 +45,6 @@
|
||||
</div>
|
||||
</li>
|
||||
</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>
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
>
|
||||
{{svg "octicon-star"}} {{ctx.Locale.Tr "repo.projects.column.set_default"}}
|
||||
</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-content="{{ctx.Locale.Tr "repo.projects.column.deletion_desc"}}"
|
||||
>
|
||||
|
||||
@@ -4,13 +4,16 @@
|
||||
</div>
|
||||
<div id="runWorkflowDispatchModal" class="ui tiny modal">
|
||||
<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">
|
||||
<span class="ui inline required field">
|
||||
<label>{{ctx.Locale.Tr "actions.workflow.from_ref"}}:</label>
|
||||
</span>
|
||||
<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}}
|
||||
<div class="default text">{{index .Branches 0}}</div>
|
||||
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
|
||||
@@ -45,12 +48,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div id="runWorkflowDispatchModalInputs">
|
||||
{{template "repo/actions/workflow_dispatch_inputs" .}}
|
||||
</div>
|
||||
{{template "repo/actions/workflow_dispatch_inputs" .}}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<div id="runWorkflowDispatchModalInputs">
|
||||
{{if not .WorkflowDispatchConfig}}
|
||||
<div class="ui error message tw-block">{{/* using "ui message" in "ui form" needs to force to display */}}
|
||||
{{if not .CurWorkflowExists}}
|
||||
@@ -44,3 +45,4 @@
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</div>
|
||||
{{end}}
|
||||
<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"}}"
|
||||
>...</button>
|
||||
<div class="branch-and-tag-detail tw-hidden">
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
{{if and $.UpdateAllowed $.UpdateByRebaseAllowed}}
|
||||
<div class="tw-inline-block">
|
||||
<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">
|
||||
{{ctx.Locale.Tr "repo.pulls.update_branch"}}
|
||||
</span>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<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 -->
|
||||
<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>
|
||||
</button>
|
||||
</span>
|
||||
|
||||
@@ -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}}>
|
||||
{{$buttonText := ctx.Locale.Tr "repo.star"}}
|
||||
{{if $.IsStaringRepo}}{{$buttonText = ctx.Locale.Tr "repo.unstar"}}{{end}}
|
||||
<button type="submit" class="ui compact small basic button"{{if not $.IsSigned}} disabled{{end}} aria-label="{{$buttonText}}">
|
||||
{{svg (Iif $.IsStaringRepo "octicon-star-fill" "octicon-star")}}
|
||||
<span class="not-mobile" aria-hidden="true">{{$buttonText}}</span>
|
||||
</button>
|
||||
<a hx-boost="false" class="ui basic label" href="{{$.RepoLink}}/stars">
|
||||
{{CountFmt .Repository.NumStars}}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
<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"}}
|
||||
{{if $.IsStaringRepo}}{{$buttonText = ctx.Locale.Tr "repo.unstar"}}{{end}}
|
||||
<button type="button" class="ui compact small basic button" aria-label="{{$buttonText}}"
|
||||
{{if $.IsSigned}}
|
||||
data-fetch-method="post"
|
||||
data-fetch-url="{{$.RepoLink}}/action/{{if $.IsStaringRepo}}unstar{{else}}star{{end}}"
|
||||
data-fetch-sync="$closest(.ui.labeled.button)"
|
||||
{{else}}
|
||||
disabled
|
||||
{{end}}
|
||||
>
|
||||
{{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>
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
<!-- Refresh the content if a htmx response contains "HX-Trigger" header.
|
||||
This usually happens when a user stays on the watchers/stargazers page
|
||||
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"
|
||||
>
|
||||
{{/* need to reload after "watch/unwatch" or "star/unstar" fetch actions */}}
|
||||
<div class="user-cards" id="user-cards-container" data-fetch-trigger="fetch-reload">
|
||||
{{if .CardsTitle}}
|
||||
<h2 class="ui dividing header">
|
||||
{{.CardsTitle}}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
{{/* 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">
|
||||
{{template "repo/latest_commit" .}}
|
||||
<div>{{if and .LatestCommit .LatestCommit.Committer}}{{DateUtils.TimeSince .LatestCommit.Committer.When}}{{end}}</div>
|
||||
@@ -15,7 +21,7 @@
|
||||
{{$entry := $item.Entry}}
|
||||
{{$commit := $item.Commit}}
|
||||
{{$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}}
|
||||
{{if $entry.IsSubModule}}
|
||||
{{$submoduleLink := $submoduleFile.SubmoduleWebLinkTree ctx}}
|
||||
@@ -47,7 +53,7 @@
|
||||
{{end}}
|
||||
{{end}}
|
||||
</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}}
|
||||
{{$commitLink := printf "%s/commit/%s" $.RepoLink (PathEscape $commit.ID.String)}}
|
||||
{{ctx.RenderUtils.RenderCommitMessageLinkSubject $commit.Message $commitLink $.Repository}}
|
||||
|
||||
@@ -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}}>
|
||||
{{$buttonText := ctx.Locale.Tr "repo.watch"}}
|
||||
{{if $.IsWatchingRepo}}{{$buttonText = ctx.Locale.Tr "repo.unwatch"}}{{end}}
|
||||
<button type="submit" class="ui compact small basic button"{{if not $.IsSigned}} disabled{{end}} aria-label="{{$buttonText}}">
|
||||
{{svg "octicon-eye"}}
|
||||
<span class="not-mobile" aria-hidden="true">{{$buttonText}}</span>
|
||||
</button>
|
||||
<a hx-boost="false" class="ui basic label" href="{{.RepoLink}}/watchers">
|
||||
{{CountFmt .Repository.NumWatches}}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
<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"}}
|
||||
{{if $.IsWatchingRepo}}{{$buttonText = ctx.Locale.Tr "repo.unwatch"}}{{end}}
|
||||
<button type="button" class="ui compact small basic button" aria-label="{{$buttonText}}"
|
||||
{{if $.IsSigned}}
|
||||
data-fetch-method="post"
|
||||
data-fetch-url="{{$.RepoLink}}/action/{{if $.IsWatchingRepo}}unwatch{{else}}watch{{end}}"
|
||||
data-fetch-sync="$closest(.ui.labeled.button)"
|
||||
{{else}}
|
||||
disabled
|
||||
{{end}}
|
||||
>
|
||||
{{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>
|
||||
|
||||
@@ -31,7 +31,6 @@
|
||||
<div class="divider tw-my-0"></div>
|
||||
<div role="main" class="page-content status-page-500">
|
||||
<div class="ui container" >
|
||||
<style> .ui.message.flash-message { text-align: left; } </style>
|
||||
{{template "base/alert" .}}
|
||||
<div class="status-page-error">
|
||||
<div class="status-page-error-title">500 Internal Server Error</div>
|
||||
|
||||
7
types.d.ts
vendored
7
types.d.ts
vendored
@@ -41,6 +41,13 @@ declare module 'htmx.org/dist/htmx.esm.js' {
|
||||
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' {
|
||||
const value = await import('swagger-ui-dist');
|
||||
export default value.SwaggerUIBundle;
|
||||
|
||||
@@ -92,3 +92,8 @@
|
||||
white-space: nowrap;
|
||||
color: var(--color-text-light-1);
|
||||
}
|
||||
|
||||
#repo-files-table .repo-file-cell.is-loading::after {
|
||||
height: 40%;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
@@ -285,6 +285,6 @@ function initAdminNotice() {
|
||||
}
|
||||
}
|
||||
await POST(this.getAttribute('data-link')!, {data});
|
||||
window.location.href = this.getAttribute('data-redirect')!;
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {addDelegatedEventListener, createElementFromHTML, submitEventSubmitter} from '../utils/dom.ts';
|
||||
import {confirmModal, createConfirmModal} from './comp/ConfirmModal.ts';
|
||||
import type {RequestOpts} from '../types.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;
|
||||
|
||||
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"
|
||||
// more details are in the backend's fetch-redirect handler
|
||||
function fetchActionDoRedirect(redirect: string) {
|
||||
const form = document.createElement('form');
|
||||
const input = document.createElement('input');
|
||||
form.method = 'post';
|
||||
form.action = `${appSubUrl}/-/fetch-redirect`;
|
||||
input.type = 'hidden';
|
||||
input.name = 'redirect';
|
||||
input.value = redirect;
|
||||
form.append(input);
|
||||
const form = createElementFromHTML<HTMLFormElement>(html`<form method="post"></form>`);
|
||||
form.action = `${appSubUrl}/-/fetch-redirect?redirect=${encodeURIComponent(redirect)}`;
|
||||
document.body.append(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
async function fetchActionDoRequest(actionElem: HTMLElement, url: string, opt: RequestOpts) {
|
||||
const showErrorForResponse = (code: number, message: string) => {
|
||||
showErrorToast(`Error ${code || 'request'}: ${message}`);
|
||||
};
|
||||
|
||||
let respStatus = 0;
|
||||
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);
|
||||
function toggleLoadingIndicator(el: HTMLElement, opt: FetchActionOpts, isLoading: boolean) {
|
||||
const loadingIndicatorElems = opt.loadingIndicator === null ? [] : (opt.loadingIndicator === '' ? [el] : document.querySelectorAll(opt.loadingIndicator));
|
||||
for (const indicatorEl of loadingIndicatorElems) {
|
||||
if (isLoading) {
|
||||
if ('disabled' in indicatorEl) {
|
||||
indicatorEl.disabled = true;
|
||||
} 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 {
|
||||
showErrorForResponse(respStatus, respText);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name === 'SyntaxError') {
|
||||
showErrorForResponse(respStatus, (respText || '').substring(0, 100));
|
||||
} else if (e.name !== 'AbortError') {
|
||||
console.error('fetchActionDoRequest error', e);
|
||||
showErrorForResponse(respStatus, `${e}`);
|
||||
if ('disabled' in indicatorEl) {
|
||||
indicatorEl.disabled = false;
|
||||
} else {
|
||||
indicatorEl.classList.remove('is-loading', 'loading-icon-2px');
|
||||
}
|
||||
}
|
||||
}
|
||||
actionElem.classList.remove('is-loading', 'loading-icon-2px');
|
||||
}
|
||||
|
||||
async function onFormFetchActionSubmit(formEl: HTMLFormElement, e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
await submitFormFetchAction(formEl, {formSubmitter: submitEventSubmitter(e)});
|
||||
async function handleFetchActionSuccessJson(el: HTMLElement, respJson: any) {
|
||||
ignoreAreYouSure(el); // ignore the areYouSure check before reloading
|
||||
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 = {
|
||||
@@ -75,15 +140,8 @@ type SubmitFormFetchActionOpts = {
|
||||
formData?: FormData;
|
||||
};
|
||||
|
||||
export async function submitFormFetchAction(formEl: HTMLFormElement, opts: SubmitFormFetchActionOpts = {}) {
|
||||
if (formEl.classList.contains('is-loading')) return;
|
||||
|
||||
formEl.classList.add('is-loading');
|
||||
if (formEl.clientHeight < 50) {
|
||||
formEl.classList.add('loading-icon-2px');
|
||||
}
|
||||
|
||||
const formMethod = formEl.getAttribute('method') || 'get';
|
||||
function prepareFormFetchActionOpts(formEl: HTMLFormElement, opts: SubmitFormFetchActionOpts = {}): FetchActionOpts {
|
||||
const formMethodUpper = formEl.getAttribute('method')?.toUpperCase() || 'GET';
|
||||
const formActionUrl = formEl.getAttribute('action') || window.location.href;
|
||||
const formData = opts.formData ?? new FormData(formEl);
|
||||
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;
|
||||
const reqOpt = {
|
||||
method: formMethod.toUpperCase(),
|
||||
body: null as FormData | null,
|
||||
};
|
||||
if (formMethod.toLowerCase() === 'get') {
|
||||
let reqBody: FormData | undefined;
|
||||
if (formMethodUpper === 'GET') {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of formData) {
|
||||
params.append(key, value as string);
|
||||
@@ -107,25 +162,23 @@ export async function submitFormFetchAction(formEl: HTMLFormElement, opts: Submi
|
||||
}
|
||||
reqUrl += `?${params.toString()}`;
|
||||
} else {
|
||||
reqOpt.body = formData;
|
||||
reqBody = formData;
|
||||
}
|
||||
|
||||
await fetchActionDoRequest(formEl, reqUrl, reqOpt);
|
||||
return {
|
||||
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) {
|
||||
// A "link-action" can post AJAX request to its "data-url"
|
||||
// Then the browser is redirected to: the "redirect" in response, or "data-redirect" attribute, or current URL by reloading.
|
||||
// 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;
|
||||
};
|
||||
export async function submitFormFetchAction(formEl: HTMLFormElement, opts: SubmitFormFetchActionOpts = {}) {
|
||||
hideToastsAll();
|
||||
await performActionRequest(formEl, prepareFormFetchActionOpts(formEl, opts));
|
||||
}
|
||||
|
||||
async function confirmFetchAction(el: HTMLElement) {
|
||||
let elModal: HTMLElement | null = null;
|
||||
const dataModalConfirm = el.getAttribute('data-modal-confirm') || '';
|
||||
if (dataModalConfirm.startsWith('#')) {
|
||||
@@ -147,18 +200,179 @@ async function onLinkActionClick(el: HTMLElement, e: Event) {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!elModal) return true;
|
||||
return await confirmModal(elModal);
|
||||
}
|
||||
|
||||
if (!elModal) {
|
||||
await doRequest();
|
||||
async function performLinkFetchAction(el: HTMLElement) {
|
||||
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;
|
||||
}
|
||||
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)) {
|
||||
await doRequest();
|
||||
function initFetchActionTriggerEvery(el: HTMLElement, trigger: string) {
|
||||
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() {
|
||||
addDelegatedEventListener(document, 'submit', '.form-fetch-action', onFormFetchActionSubmit);
|
||||
addDelegatedEventListener(document, 'click', '.link-action', onLinkActionClick);
|
||||
// "fetch-action" is a general approach for elements to trigger fetch requests:
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -37,11 +37,6 @@ export function initCompWebHookEditor() {
|
||||
document.querySelector<HTMLButtonElement>('#test-delivery')?.addEventListener('click', async function () {
|
||||
this.classList.add('is-loading', 'disabled');
|
||||
await POST(this.getAttribute('data-link')!);
|
||||
setTimeout(() => {
|
||||
const redirectUrl = this.getAttribute('data-redirect');
|
||||
if (redirectUrl) {
|
||||
window.location.href = redirectUrl;
|
||||
}
|
||||
}, 5000);
|
||||
setTimeout(() => window.location.reload(), 5000);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {GET} from '../modules/fetch.ts';
|
||||
async function loadBranchesAndTags(area: Element, loadingButton: Element) {
|
||||
loadingButton.classList.add('disabled');
|
||||
try {
|
||||
const res = await GET(loadingButton.getAttribute('data-fetch-url')!);
|
||||
const res = await GET(loadingButton.getAttribute('data-url')!);
|
||||
const data = await res.json();
|
||||
hideElem(loadingButton);
|
||||
addTags(area, data.tags);
|
||||
|
||||
@@ -11,7 +11,6 @@ function initRepoPullRequestUpdate(el: HTMLElement) {
|
||||
const prUpdateDropdown = prUpdateButtonContainer.querySelector(':scope > .ui.dropdown')!;
|
||||
prUpdateButton.addEventListener('click', async function (e) {
|
||||
e.preventDefault();
|
||||
const redirect = this.getAttribute('data-redirect');
|
||||
this.classList.add('is-loading');
|
||||
let response: Response | undefined;
|
||||
try {
|
||||
@@ -29,8 +28,6 @@ function initRepoPullRequestUpdate(el: HTMLElement) {
|
||||
}
|
||||
if (data?.redirect) {
|
||||
window.location.href = data.redirect;
|
||||
} else if (redirect) {
|
||||
window.location.href = redirect;
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import jquery from 'jquery'; // 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.
|
||||
// To avoid breaking existing users and custom plugins, import jQuery globally without ES module.
|
||||
|
||||
@@ -185,5 +185,3 @@ document.body.addEventListener('htmx:responseError', (event) => {
|
||||
// TODO: add translations
|
||||
showErrorToast(`Error ${(event as HtmxEvent).detail.xhr.status} when calling ${(event as HtmxEvent).detail.requestConfig.path}`);
|
||||
});
|
||||
|
||||
document.dispatchEvent(new CustomEvent('gitea:index-ready'));
|
||||
|
||||
@@ -1,20 +1,57 @@
|
||||
import {showInfoToast, showWarningToast, showErrorToast} from './toast.ts';
|
||||
import type {Toast} from './toast.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>;
|
||||
|
||||
export function initDevtest() {
|
||||
registerGlobalInitFunc('initDevtestPage', () => {
|
||||
const els = document.querySelectorAll('.toast-test-button');
|
||||
if (!els.length) return;
|
||||
function initDevtestPage() {
|
||||
const toastButtons = document.querySelectorAll('.toast-test-button');
|
||||
if (toastButtons.length) {
|
||||
const levelMap: LevelMap = {info: showInfoToast, warning: showWarningToast, error: showErrorToast};
|
||||
for (const el of els) {
|
||||
for (const el of toastButtons) {
|
||||
el.addEventListener('click', () => {
|
||||
const level = el.getAttribute('data-toast-level')!;
|
||||
const message = el.getAttribute('data-toast-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);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import {isObject} from '../utils.ts';
|
||||
import type {RequestOpts} from '../types.ts';
|
||||
|
||||
// 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.
|
||||
export function request(url: string, {method = 'GET', data, headers = {}, ...other}: RequestOpts = {}): Promise<Response> {
|
||||
let body: string | FormData | URLSearchParams | undefined;
|
||||
@@ -14,17 +14,13 @@ export function request(url: string, {method = 'GET', data, headers = {}, ...oth
|
||||
body = JSON.stringify(data);
|
||||
}
|
||||
|
||||
const headersMerged = new Headers({
|
||||
...(contentType && {'content-type': contentType}),
|
||||
});
|
||||
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
headersMerged.set(name, value);
|
||||
headers = new Headers(headers);
|
||||
if (!headers.has('content-type') && contentType) {
|
||||
headers.set('content-type', contentType);
|
||||
}
|
||||
|
||||
return fetch(url, { // eslint-disable-line no-restricted-globals
|
||||
method,
|
||||
headers: headersMerged,
|
||||
headers,
|
||||
...other,
|
||||
...(body && {body}),
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
dirname, basename, extname, isObject, stripTags, parseIssueHref,
|
||||
parseUrl, translateMonth, translateDay, blobToDataURI,
|
||||
translateMonth, translateDay, blobToDataURI,
|
||||
toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64, isImageFile, isVideoFile, parseRepoOwnerPathInfo,
|
||||
urlQueryEscape,
|
||||
} from './utils.ts';
|
||||
@@ -68,18 +68,6 @@ test('parseRepoOwnerPathInfo', () => {
|
||||
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', () => {
|
||||
const originalLang = document.documentElement.lang;
|
||||
document.documentElement.lang = 'en-US';
|
||||
|
||||
@@ -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 */
|
||||
export function getCurrentLocale(): string {
|
||||
return document.documentElement.lang;
|
||||
|
||||
Reference in New Issue
Block a user