From b998c3c1aa9c0a6945a3098fd0070bd063904bef Mon Sep 17 00:00:00 2001 From: Shudhanshu Singh Date: Sun, 12 Jul 2026 17:44:37 +0530 Subject: [PATCH] feat(actions): implement adaptive auto-refresh for workflow runs list (#38329) ### Description This PR implements an optimized, adaptive client-side auto-refresh mechanism for the Gitea Actions workflow runs list page. It allows users to see workflow progress updates dynamically without having to reload the page. Fixes https://github.com/go-gitea/gitea/issues/37457 --------- Signed-off-by: wxiaoguang Co-authored-by: wxiaoguang --- models/actions/run.go | 6 + routers/web/repo/actions/actions.go | 264 ++++++++++++------ templates/base/paginate.tmpl | 6 +- templates/repo/actions/list.tmpl | 7 +- templates/repo/actions/runs_list.tmpl | 29 +- .../actions/workflow_dispatch_inputs.tmpl | 2 +- web_src/js/features/repo-actions.ts | 29 ++ web_src/js/utils/dom.test.ts | 23 +- web_src/js/utils/dom.ts | 23 ++ 9 files changed, 282 insertions(+), 107 deletions(-) diff --git a/models/actions/run.go b/models/actions/run.go index 867b9932e99..0d6c2bbf155 100644 --- a/models/actions/run.go +++ b/models/actions/run.go @@ -277,6 +277,12 @@ func GetRunByRepoAndID(ctx context.Context, repoID, runID int64) (*ActionRun, er return &run, nil } +func GetRunsByRepoAndID(ctx context.Context, repoID int64, runIDs []int64) ([]*ActionRun, error) { + var runs []*ActionRun + err := db.GetEngine(ctx).In("id", runIDs).Where("repo_id=?", repoID).Find(&runs) + return runs, err +} + func GetRunByRepoAndIndex(ctx context.Context, repoID, runIndex int64) (*ActionRun, error) { run, has, err := db.Get[ActionRun](ctx, builder.Eq{"repo_id": repoID, "`index`": runIndex}) if err != nil { diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go index 1bdcffab789..7839ef31339 100644 --- a/routers/web/repo/actions/actions.go +++ b/routers/web/repo/actions/actions.go @@ -8,6 +8,7 @@ import ( stdCtx "context" "errors" "fmt" + "html/template" "net/http" "net/url" "slices" @@ -19,6 +20,7 @@ import ( repo_model "gitea.dev/models/repo" "gitea.dev/models/unit" "gitea.dev/modules/actions" + "gitea.dev/modules/base" "gitea.dev/modules/container" "gitea.dev/modules/git" "gitea.dev/modules/log" @@ -42,9 +44,9 @@ const ( ) type WorkflowInfo struct { - Entry git.TreeEntry - ErrMsg string - Workflow *act_model.Workflow + EntryName string + ErrMsg string + Workflow *act_model.Workflow } type workflowBadge struct { @@ -58,7 +60,7 @@ func (w WorkflowInfo) DisplayName() string { if w.Workflow != nil && w.Workflow.Name != "" { return w.Workflow.Name } - return w.Entry.Name() + return w.EntryName } // MustEnableActions check if actions are enabled in settings @@ -99,9 +101,22 @@ func List(ctx *context.Context) { if ctx.Written() { return } - curWorkflowRepoID := ctx.FormInt64("scoped_workflow_source_repo_id") - ctx.Data["CurWorkflowRepoID"] = curWorkflowRepoID - scopedNames := prepareScopedWorkflows(ctx, curWorkflowID, curWorkflowRepoID) + + data := prepareWorkflowList(ctx, curWorkflowID) + if data.partialRefresh { + // TODO: at the moment, the "partial refresh" depends on some heavy git&db operations (especially finding & parsing the workflow files) + // If it becomes a real problem in the future, maybe some "caches" can be used to optimize. + if !data.preparePartialRefreshRuns(ctx) { + return + } + if !data.prepareCommon(ctx, workflows) { + return + } + ctx.HTML(http.StatusOK, "repo/actions/runs_list") + return + } + + scopedNames := prepareScopedWorkflows(ctx, curWorkflowID, data.scopedWorkflowSourceRepoID) if ctx.Written() { return } @@ -109,16 +124,22 @@ func List(ctx *context.Context) { if ctx.Written() { return } - prepareWorkflowDispatchTemplate(ctx, workflows, curWorkflowID, curWorkflowRepoID) + prepareWorkflowDispatchTemplate(ctx, workflows, curWorkflowID, data.scopedWorkflowSourceRepoID) if ctx.Written() { return } - prepareWorkflowList(ctx, workflows, otherWorkflows, len(scopedNames) > 0) - if ctx.Written() { + if !data.prepareFullPageRuns(ctx, otherWorkflows) { + return + } + if !data.prepareCommon(ctx, workflows) { + return + } + if !data.prepareFullPageTemplate(ctx) { return } + ctx.Data["HasWorkflowsOrRuns"] = len(workflows) > 0 || len(otherWorkflows) > 0 || len(data.ActionRuns) > 0 || len(scopedNames) > 0 ctx.HTML(http.StatusOK, tplListActions) } @@ -127,7 +148,7 @@ func List(ctx *context.Context) { func prepareOtherWorkflows(ctx *context.Context, workflows []WorkflowInfo, scopedNames container.Set[string], curWorkflowID string) []string { listed := make(container.Set[string], len(workflows)) for _, w := range workflows { - listed.Add(w.Entry.Name()) + listed.Add(w.EntryName) } var other []string @@ -193,7 +214,7 @@ func prepareWorkflowTemplate(ctx *context.Context, commit *git.Commit) (workflow workflows = make([]WorkflowInfo, 0, len(entries)) for _, entry := range entries { - workflow := WorkflowInfo{Entry: *entry} + workflow := WorkflowInfo{EntryName: entry.Name()} content, err := actions.GetContentFromEntry(entry) if err != nil { ctx.ServerError("GetContentFromEntry", err) @@ -404,7 +425,7 @@ func prepareWorkflowDispatchTemplate(ctx *context.Context, workflowInfos []Workf curWorkflow = loadScopedWorkflowModel(ctx, repo, curWorkflowRepoID, curWorkflowID) } else { for _, workflowInfo := range workflowInfos { - if workflowInfo.Entry.Name() == curWorkflowID { + if workflowInfo.EntryName == curWorkflowID { if workflowInfo.Workflow == nil { log.Debug("CurWorkflowID %s is found but its workflowInfo.Workflow is nil", curWorkflowID) return @@ -452,71 +473,66 @@ func prepareWorkflowDispatchTemplate(ctx *context.Context, workflowInfos []Workf ctx.Data["Tags"] = tags } -func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWorkflows []string, hasScopedWorkflows bool) { - actorID := ctx.FormInt64("actor") - status := ctx.FormInt("status") - workflowID := ctx.FormString("workflow") - scopedWorkflowSourceRepoID := ctx.FormInt64("scoped_workflow_source_repo_id") - branch := ctx.FormString("branch") - page := ctx.FormInt("page") - if page <= 0 { - page = 1 - } - - // if status or actor query param is not given to frontend href, (href="//actions") - // they will be 0 by default, which indicates get all status or actors - ctx.Data["CurActor"] = actorID - ctx.Data["CurStatus"] = status - ctx.Data["CurBranch"] = branch - if actorID > 0 || status > int(actions_model.StatusUnknown) || branch != "" { - ctx.Data["IsFiltered"] = true - } - +func (data *actionRunListData) prepareFullPageRuns(ctx *context.Context, otherWorkflows []string) bool { opts := actions_model.FindRunOptions{ ListOptions: db.ListOptions{ - Page: page, + Page: max(ctx.FormInt("page"), 1), PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), }, RepoID: ctx.Repo.Repository.ID, - WorkflowID: workflowID, - WorkflowRepoID: scopedWorkflowSourceRepoID, - TriggerUserID: actorID, + WorkflowID: data.workflowID, + WorkflowRepoID: data.scopedWorkflowSourceRepoID, + TriggerUserID: data.actorID, } // Constrain scoped vs repo-level only for a listed workflow, whose link carries scoped_workflow_source_repo_id. - if workflowID != "" && !slices.Contains(otherWorkflows, workflowID) { - opts.IsScopedRun = optional.Some(scopedWorkflowSourceRepoID > 0) + if data.workflowID != "" && !slices.Contains(otherWorkflows, data.workflowID) { + opts.IsScopedRun = optional.Some(data.scopedWorkflowSourceRepoID > 0) } // if status is not StatusUnknown, it means user has selected a status filter - if actions_model.Status(status) != actions_model.StatusUnknown { - opts.Status = []actions_model.Status{actions_model.Status(status)} + if data.status != actions_model.StatusUnknown { + opts.Status = []actions_model.Status{data.status} } - if branch != "" { - opts.Ref = string(git.RefNameFromBranch(branch)) + if data.branch != "" { + opts.Ref = string(git.RefNameFromBranch(data.branch)) } runs, total, err := db.FindAndCount[actions_model.ActionRun](ctx, opts) if err != nil { ctx.ServerError("FindAndCount", err) - return + return false } + data.pager = context.NewPagination(total, opts.PageSize, opts.Page, 5) + data.pager.AddParamFromRequest(ctx.Req) + data.ActionRuns = runs + return true +} - for _, run := range runs { - run.Repo = ctx.Repo.Repository - } +type actionRunListData struct { + actorID int64 + status actions_model.Status + workflowID string + workflowDisplayName string + scopedWorkflowSourceRepoID int64 + branch string + refreshRunIDs []int64 + partialRefresh bool + pager *context.Pagination - if err := actions_model.RunList(runs).LoadTriggerUser(ctx); err != nil { - ctx.ServerError("LoadTriggerUser", err) - return - } - - if err := loadIsRefDeleted(ctx, ctx.Repo.Repository.ID, runs); err != nil { - log.Error("LoadIsRefDeleted", err) - } + // the following fields are used by the template + RefreshLink template.URL + RefreshIntervalMs int64 + ActionRuns []*actions_model.ActionRun + IsFiltered bool + WorkflowNames map[string]string + RunErrors map[int64]string + CurWorkflow string + CanWriteRepoUnitActions bool +} +func (data *actionRunListData) processActionRuns(ctx *context.Context) bool { // Check for each run if there is at least one online runner that can run its jobs - runErrors := make(map[int64]string) runners, err := db.Find[actions_model.ActionRunner](ctx, actions_model.FindRunnerOptions{ RepoID: ctx.Repo.Repository.ID, IsOnline: optional.Some(true), @@ -524,28 +540,30 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWo }) if err != nil { ctx.ServerError("FindRunners", err) - return + return false } - for _, run := range runs { + + data.RunErrors = make(map[int64]string) + for _, run := range data.ActionRuns { if !run.Status.In(actions_model.StatusWaiting, actions_model.StatusRunning, actions_model.StatusBlocked) { continue } jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, run.RepoID, run.ID) if err != nil { ctx.ServerError("GetRunJobsByRunID", err) - return + return false } for _, job := range jobs { if !job.Status.In(actions_model.StatusWaiting, actions_model.StatusBlocked) { continue } if err := actions.ValidateWorkflowContent(job.WorkflowPayload); err != nil { - runErrors[run.ID] = ctx.Locale.TrString("actions.runs.invalid_workflow_helper", err.Error()) + data.RunErrors[run.ID] = ctx.Locale.TrString("actions.runs.invalid_workflow_helper", err.Error()) break } if job.CallUses != "" { if _, err := actions_service.ResolveUses(ctx, job.CallUses); err != nil { - runErrors[run.ID] = ctx.Locale.TrString("actions.runs.invalid_reusable_workflow_uses", err.Error()) + data.RunErrors[run.ID] = ctx.Locale.TrString("actions.runs.invalid_reusable_workflow_uses", err.Error()) break } } @@ -558,54 +576,132 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWo } } if !hasOnlineRunner { - runErrors[run.ID] = ctx.Locale.TrString("actions.runs.no_matching_online_runner_helper", strings.Join(job.RunsOn, ",")) + data.RunErrors[run.ID] = ctx.Locale.TrString("actions.runs.no_matching_online_runner_helper", strings.Join(job.RunsOn, ",")) break } } } } - ctx.Data["RunErrors"] = runErrors + return true +} - ctx.Data["Runs"] = runs +func (data *actionRunListData) fillRefreshMeta(ctx *context.Context) bool { + hasActiveRuns := false + var actionRunIDs []int64 + for _, run := range data.ActionRuns { + if !hasActiveRuns && run.Status.In(actions_model.StatusWaiting, actions_model.StatusRunning, actions_model.StatusBlocked) { + hasActiveRuns = true + } + actionRunIDs = append(actionRunIDs, run.ID) + } + data.RefreshIntervalMs = util.Iif[int64](hasActiveRuns, 3*1000, 12*1000) + if !setting.IsProd { + data.RefreshIntervalMs = util.Iif[int64](hasActiveRuns, 1000, 2*1000) // faster in dev mode to make debug easier + } + if len(data.ActionRuns) == 0 { + data.RefreshIntervalMs = 0 + } + // Only refresh the items on the current page, do not keep loading "new" items. + // Otherwise, when end users are browsing the list page, they will just keep seeing "new" items coming in + // and "old" items disappearing, they won't be able to focus on an item that they are interested in, which is a bad user experience. + data.RefreshLink = templates.QueryBuild(setting.AppSubURL+ctx.Req.RequestURI, "run_ids", strings.Join(base.Int64sToStrings(actionRunIDs), ",")) + return true +} - workflowNames := make(map[string]string, len(workflows)) - workflowDisplayName := workflowID +func (data *actionRunListData) fillWorkflowNames(workflows []WorkflowInfo) bool { + data.WorkflowNames = make(map[string]string, len(workflows)) + data.workflowDisplayName = data.workflowID for _, wf := range workflows { displayName := wf.DisplayName() - workflowNames[wf.Entry.Name()] = displayName - if wf.Entry.Name() == workflowID { - workflowDisplayName = displayName + data.WorkflowNames[wf.EntryName] = displayName + if wf.EntryName == data.workflowID { + data.workflowDisplayName = displayName } } - ctx.Data["WorkflowNames"] = workflowNames - // A scoped workflow has no repo-level badge on this repo (the badge endpoint reads is_scoped_run=false runs), - // so don't offer the "create status badge" entry for it. - if scopedWorkflowSourceRepoID == 0 { - prepareWorkflowBadgeTemplate(ctx, workflowID, workflowDisplayName) + return true +} + +func prepareWorkflowList(ctx *context.Context, curWorkflowID string) *actionRunListData { + data := &actionRunListData{} + data.actorID = ctx.FormInt64("actor") + data.status = actions_model.Status(ctx.FormInt("status")) + data.workflowID = ctx.FormString("workflow") + data.scopedWorkflowSourceRepoID = ctx.FormInt64("scoped_workflow_source_repo_id") + data.branch = ctx.FormString("branch") + data.refreshRunIDs = ctx.FormStringInt64s("run_ids") + data.partialRefresh = ctx.Req.Form.Has("run_ids") + + data.IsFiltered = data.actorID > 0 || data.status != actions_model.StatusUnknown || data.branch != "" + data.CurWorkflow = curWorkflowID + data.CanWriteRepoUnitActions = ctx.Repo.Permission.CanWrite(unit.TypeActions) + return data +} + +func (data *actionRunListData) preparePartialRefreshRuns(ctx *context.Context) bool { + runs, err := actions_model.GetRunsByRepoAndID(ctx, ctx.Repo.Repository.ID, data.refreshRunIDs) + if err != nil { + ctx.ServerError("GetRunsByRepoAndID", err) + return false + } + data.ActionRuns = runs + return true +} + +func (data *actionRunListData) prepareCommon(ctx *context.Context, workflows []WorkflowInfo) bool { + for _, run := range data.ActionRuns { + run.Repo = ctx.Repo.Repository } + if err := actions_model.RunList(data.ActionRuns).LoadTriggerUser(ctx); err != nil { + ctx.ServerError("LoadTriggerUser", err) + return false + } + if err := loadIsRefDeleted(ctx, ctx.Repo.Repository.ID, data.ActionRuns); err != nil { + log.Error("LoadIsRefDeleted", err) + } + + if !data.processActionRuns(ctx) { + return false + } + if !data.fillRefreshMeta(ctx) { + return false + } + if !data.fillWorkflowNames(workflows) { + return false + } + ctx.Data["ActionRunListData"] = data + return true +} + +func (data *actionRunListData) prepareFullPageTemplate(ctx *context.Context) bool { + // A scoped workflow has no repo-level badge on this repo (the badge endpoint reads is_scoped_run=false runs), + // so don't offer the "create status badge" entry for it. + if data.scopedWorkflowSourceRepoID == 0 { + prepareWorkflowBadgeTemplate(ctx, data.workflowID, data.workflowDisplayName) + } + + // fill template data actors, err := actions_model.GetActors(ctx, ctx.Repo.Repository.ID) if err != nil { ctx.ServerError("GetActors", err) - return + return false } ctx.Data["Actors"] = shared_user.MakeSelfOnTop(ctx.Doer, actors) - ctx.Data["StatusInfoList"] = actions_model.GetStatusInfoList(ctx, ctx.Locale) - runBranches, err := actions_model.GetRunBranches(ctx, ctx.Repo.Repository.ID) if err != nil { ctx.ServerError("GetRunBranches", err) - return + return false } ctx.Data["RunBranches"] = runBranches - pager := context.NewPagination(total, opts.PageSize, opts.Page, 5) - pager.AddParamFromRequest(ctx.Req) - ctx.Data["Page"] = pager - ctx.Data["HasWorkflowsOrRuns"] = len(workflows) > 0 || len(otherWorkflows) > 0 || len(runs) > 0 || hasScopedWorkflows - - ctx.Data["CanWriteRepoUnitActions"] = ctx.Repo.Permission.CanWrite(unit.TypeActions) + ctx.Data["StatusInfoList"] = actions_model.GetStatusInfoList(ctx, ctx.Locale) + ctx.Data["CurActor"] = data.actorID + ctx.Data["CurStatus"] = int(data.status) // don't make template use its "String" method, otherwise the query parameter would be wrong + ctx.Data["CurBranch"] = data.branch + ctx.Data["CurWorkflowRepoID"] = data.scopedWorkflowSourceRepoID + ctx.Data["Page"] = data.pager + return true } func prepareWorkflowBadgeTemplate(ctx *context.Context, workflowID, displayName string) { diff --git a/templates/base/paginate.tmpl b/templates/base/paginate.tmpl index f6c1785ccf0..2ed8834f341 100644 --- a/templates/base/paginate.tmpl +++ b/templates/base/paginate.tmpl @@ -1,7 +1,9 @@ -{{$paginationParams := .Page.GetParams}} +{{$page := or $.Page ctx.RootData.Page}} {{$paginationLink := $.Link}} +{{if eq $paginationLink nil}}{{$paginationLink = ctx.RootData.Link}}{{end}} {{if eq $paginationLink AppSubUrl}}{{$paginationLink = print $paginationLink "/"}}{{end}} -{{with .Page.Paginater}} +{{$paginationParams := $page.GetParams}} +{{with $page.Paginater}} {{if or (eq .TotalPages -1) (gt .TotalPages 1)}} {{$showFirstLast := gt .TotalPages 1}}
diff --git a/templates/repo/actions/list.tmpl b/templates/repo/actions/list.tmpl index cb1b1c7fb5e..25629a69949 100644 --- a/templates/repo/actions/list.tmpl +++ b/templates/repo/actions/list.tmpl @@ -14,14 +14,14 @@
diff --git a/templates/repo/actions/runs_list.tmpl b/templates/repo/actions/runs_list.tmpl index a351e9401c5..b1c7623531e 100644 --- a/templates/repo/actions/runs_list.tmpl +++ b/templates/repo/actions/runs_list.tmpl @@ -1,12 +1,16 @@ -
- {{if not .Runs}} +{{$data := .ActionRunListData}} +
+ {{if not $data.ActionRuns}}
{{svg "octicon-no-entry" 48}} -

{{if $.IsFiltered}}{{ctx.Locale.Tr "actions.runs.no_results"}}{{else}}{{ctx.Locale.Tr "actions.runs.no_runs"}}{{end}}

+

{{if $data.IsFiltered}}{{ctx.Locale.Tr "actions.runs.no_results"}}{{else}}{{ctx.Locale.Tr "actions.runs.no_runs"}}{{end}}

{{end}} - {{range $run := .Runs}} -
+ {{range $run := $data.ActionRuns}} +
{{template "repo/icons/action_status" (dict "Status" $run.Status.String "IconVariant" "circle-fill")}} @@ -15,22 +19,22 @@
{{$title := or $run.Title (ctx.Locale.Tr "actions.runs.empty_commit_message")}} - {{ctx.RenderUtils.RenderCommitMessageLinkSubject $title $run.Link $.Repository}} + {{ctx.RenderUtils.RenderCommitMessageLinkSubject $title $run.Link $run.Repo}}
- {{$workflowName := index $.WorkflowNames $run.WorkflowID}} - {{if not $.CurWorkflow}}{{if $workflowName}}{{$workflowName}}{{else}}{{$run.WorkflowID}}{{end}} {{end}}#{{$run.Index}}: + {{$workflowName := index $data.WorkflowNames $run.WorkflowID}} + {{if not $data.CurWorkflow}}{{if $workflowName}}{{$workflowName}}{{else}}{{$run.WorkflowID}}{{end}} {{end}}#{{$run.Index}}: {{- if $run.ScheduleID -}} {{ctx.Locale.Tr "actions.runs.scheduled"}} {{- else -}} {{ctx.Locale.Tr "actions.runs.commit"}} - {{ShortSha $run.CommitSHA}} + {{ShortSha $run.CommitSHA}} {{ctx.Locale.Tr "actions.runs.pushed_by"}} {{$run.TriggerUser.GetDisplayName}} {{- end -}} - {{$errMsg := index $.RunErrors $run.ID}} + {{$errMsg := index $data.RunErrors $run.ID}} {{if $errMsg}} {{svg "octicon-alert" 16 "tw-text-red"}} @@ -52,12 +56,12 @@ {{svg "octicon-kebab-horizontal"}} -{{template "base/paginate" .}} diff --git a/templates/repo/actions/workflow_dispatch_inputs.tmpl b/templates/repo/actions/workflow_dispatch_inputs.tmpl index 47caa9bac42..13e976366ef 100644 --- a/templates/repo/actions/workflow_dispatch_inputs.tmpl +++ b/templates/repo/actions/workflow_dispatch_inputs.tmpl @@ -37,7 +37,7 @@
{{end}} {{range .workflows}} - {{if and .ErrMsg (eq .Entry.Name $.CurWorkflow)}} + {{if and .ErrMsg (eq .EntryName $.CurWorkflow)}}
{{svg "octicon-alert" 16 "tw-text-red"}} {{.ErrMsg}}
diff --git a/web_src/js/features/repo-actions.ts b/web_src/js/features/repo-actions.ts index 89ed94e0788..11f37a66fec 100644 --- a/web_src/js/features/repo-actions.ts +++ b/web_src/js/features/repo-actions.ts @@ -2,6 +2,9 @@ import {createApp} from 'vue'; import RepoActionView from '../components/RepoActionView.vue'; import {registerGlobalInitFunc} from '../modules/observer.ts'; import {html} from '../utils/html.ts'; +import {GET} from '../modules/fetch.ts'; +import {activePageTimerRefresh, createElementFromHTML, protectMorphElements, recoverMorphElements} from '../utils/dom.ts'; +import {Idiomorph} from 'idiomorph'; export function updateWorkflowBadgeFields(form: HTMLElement, branch: string): void { const badgeURLParsed = new URL(form.getAttribute('data-badge-url')!); @@ -27,6 +30,7 @@ function initWorkflowBadgeForm(form: HTMLElement): void { export function initRepositoryActions() { registerGlobalInitFunc('initWorkflowBadgeForm', initWorkflowBadgeForm); initRepositoryActionsView(); + registerGlobalInitFunc('initActionRunsList', initActionRunsList); } function initRepositoryActionsView() { @@ -103,3 +107,28 @@ function initRepositoryActionsView() { }); view.mount(el); } + +function initActionRunsList(el: HTMLElement) { + activePageTimerRefresh({ + interval: () => Number(el.getAttribute('data-action-runs-refresh-interval')), + async callback() { + const resp = await GET(el.getAttribute('data-action-runs-refresh-link')!); + if (!resp.ok || resp.status !== 200) return; + + const newEl = createElementFromHTML(await resp.text()); + for (const attr of newEl.attributes) el.setAttribute(attr.name, attr.value); + for (const newItem of newEl.querySelectorAll(':scope > .item')) { + const oldItem = el.querySelector(`#${newItem.id}`); + if (!oldItem) continue; + + // If the end user is operating the row, then don't refresh its content. + // Otherwise, there will be more edge cases and inconsistencies, e.g.: dropdown still shows old items but the icon has changed. + if (oldItem.querySelector('.ui.dropdown.active')) continue; + + const protectedElems = protectMorphElements(newItem); + Idiomorph.morph(oldItem, newItem, {morphStyle: 'outerHTML'}); + recoverMorphElements(el.querySelector(`#${newItem.id}`)!, protectedElems); + } + }, + }); +} diff --git a/web_src/js/utils/dom.test.ts b/web_src/js/utils/dom.test.ts index a30b29bab2c..de067082911 100644 --- a/web_src/js/utils/dom.test.ts +++ b/web_src/js/utils/dom.test.ts @@ -1,8 +1,7 @@ import { - createElementFromAttrs, - createElementFromHTML, - queryElemChildren, - querySingleVisibleElem, + createElementFromAttrs, createElementFromHTML, + queryElemChildren, querySingleVisibleElem, + protectMorphElements, recoverMorphElements, toggleElem, } from './dom.ts'; @@ -54,3 +53,19 @@ test('toggleElem', () => { toggleElem(el.children, true); expect(el.outerHTML).toEqual('
a
b
'); }); + +test('protectMorphElements', () => { + const el = createElementFromHTML('
foo
'); + const protectedElems = protectMorphElements(el); + + const span = el.querySelector('span')!; + const spanMorphProtectId = span.getAttribute('data-morph-protect'); + expect(spanMorphProtectId).toBeTruthy(); + expect(el.outerHTML).toEqual(`
foo
`); + span.textContent = 'bar'; + span.classList.add('new-class'); + expect(el.outerHTML).toEqual(`
bar
`); + + recoverMorphElements(el, protectedElems); + expect(el.outerHTML).toEqual('
foo
'); +}); diff --git a/web_src/js/utils/dom.ts b/web_src/js/utils/dom.ts index 010a96eee9d..aa17298c9b7 100644 --- a/web_src/js/utils/dom.ts +++ b/web_src/js/utils/dom.ts @@ -411,3 +411,26 @@ export function activePageTimerRefresh(opts: ActivePageTimerOptions): ActivePage timer.start(); return timer; } + +type ProtectedMorphElements = Record; + +export function protectMorphElements(el: Element): ProtectedMorphElements { + // Some components like Dropdown manage their internal states and styles. + // If morph changes any style or layout, then the Dropdown will stop working. + // So, protect such fragile components ahead, then morph, then recover. + const ret: ProtectedMorphElements = {}; + for (const it of el.querySelectorAll('.ui.dropdown, [data-morph-protect]')) { + const id = generateElemId(); + ret[id] = it.outerHTML; + it.setAttribute('data-morph-protect', id); + } + return ret; +} + +export function recoverMorphElements(el: Element, protectedElems: ProtectedMorphElements) { + for (const [id, html] of Object.entries(protectedElems)) { + const it = el.querySelector(`[data-morph-protect="${CSS.escape(id)}"]`); + if (!it) continue; + it.outerHTML = html; + } +}