mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-29 00:59:39 +09:00
Compare commits
4
Commits
678b5aba30
...
d2bd1589fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2bd1589fe | ||
|
|
b998c3c1aa | ||
|
|
65c5a5ff7b | ||
|
|
aba0eb1749 |
@@ -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 {
|
||||
|
||||
+18
-30
@@ -27,6 +27,7 @@ import (
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/references"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/translation"
|
||||
@@ -643,36 +644,18 @@ func UpdateCommentAttachments(ctx context.Context, c *Comment, uuids []string) e
|
||||
}
|
||||
|
||||
// LoadAssigneeUserAndTeam if comment.Type is CommentTypeAssignees, then load assignees
|
||||
func (c *Comment) LoadAssigneeUserAndTeam(ctx context.Context) error {
|
||||
var err error
|
||||
|
||||
func (c *Comment) LoadAssigneeUserAndTeam(ctx context.Context) (err error) {
|
||||
if c.AssigneeID > 0 && c.Assignee == nil {
|
||||
c.Assignee, err = user_model.GetUserByID(ctx, c.AssigneeID)
|
||||
_, c.Assignee, err = user_model.GetPossibleUserByID(ctx, c.AssigneeID)
|
||||
if err != nil {
|
||||
if !user_model.IsErrUserNotExist(err) {
|
||||
return err
|
||||
}
|
||||
c.Assignee = user_model.NewGhostUser()
|
||||
}
|
||||
} else if c.AssigneeTeamID > 0 && c.AssigneeTeam == nil {
|
||||
if err = c.LoadIssue(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = c.Issue.LoadRepo(ctx); err != nil {
|
||||
}
|
||||
if c.AssigneeTeamID > 0 && c.AssigneeTeam == nil {
|
||||
_, c.AssigneeTeam, err = organization.GetPossibleTeamByID(ctx, c.AssigneeTeamID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = c.Issue.Repo.LoadOwner(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.Issue.Repo.Owner.IsOrganization() {
|
||||
c.AssigneeTeam, err = organization.GetTeamByID(ctx, c.AssigneeTeamID)
|
||||
if err != nil && !organization.IsErrTeamNotExist(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -795,8 +778,7 @@ func (c *Comment) MetaSpecialDoerTr(locale translation.Locale) template.HTML {
|
||||
}
|
||||
|
||||
func (c *Comment) TimelineRequestedReviewTr(locale translation.Locale, createdStr template.HTML) template.HTML {
|
||||
if c.AssigneeID > 0 {
|
||||
// it guarantees LoadAssigneeUserAndTeam has been called, and c.Assignee is Ghost user but not nil if the user doesn't exist
|
||||
if c.Assignee != nil {
|
||||
if c.RemovedAssignee {
|
||||
if c.PosterID == c.AssigneeID {
|
||||
return locale.Tr("repo.issues.review.remove_review_request_self", createdStr)
|
||||
@@ -805,14 +787,20 @@ func (c *Comment) TimelineRequestedReviewTr(locale translation.Locale, createdSt
|
||||
}
|
||||
return locale.Tr("repo.issues.review.add_review_request", c.Assignee.GetDisplayName(), createdStr)
|
||||
}
|
||||
teamName := "Ghost Team"
|
||||
if c.AssigneeTeam != nil {
|
||||
teamName = c.AssigneeTeam.Name
|
||||
if c.RemovedAssignee {
|
||||
return locale.Tr("repo.issues.review.remove_review_request", c.AssigneeTeam.Name, createdStr)
|
||||
}
|
||||
return locale.Tr("repo.issues.review.add_review_request", c.AssigneeTeam.Name, createdStr)
|
||||
}
|
||||
|
||||
// impossible fallback
|
||||
assigneePrompt := fmt.Sprintf("(AssigneeID=%d, AssigneeTeamID=%d)", c.AssigneeID, c.AssigneeTeam.ID)
|
||||
setting.PanicInDevOrTesting("unknown timeline pull request review event comment: id=%d, %s", c.ID, assigneePrompt)
|
||||
if c.RemovedAssignee {
|
||||
return locale.Tr("repo.issues.review.remove_review_request", teamName, createdStr)
|
||||
return locale.Tr("repo.issues.review.remove_review_request", assigneePrompt, createdStr)
|
||||
}
|
||||
return locale.Tr("repo.issues.review.add_review_request", teamName, createdStr)
|
||||
return locale.Tr("repo.issues.review.add_review_request", assigneePrompt, createdStr)
|
||||
}
|
||||
|
||||
// CreateComment creates comment with context
|
||||
|
||||
@@ -45,6 +45,19 @@ func TestCreateComment(t *testing.T) {
|
||||
unittest.AssertInt64InRange(t, now, then, int64(updatedIssue.UpdatedUnix))
|
||||
}
|
||||
|
||||
func TestLoadAssigneeUserAndTeam_DeletedTeamBecomesGhostTeam(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 15})
|
||||
comment := &issues_model.Comment{
|
||||
Type: issues_model.CommentTypeAssignees,
|
||||
IssueID: issue.ID,
|
||||
AssigneeTeamID: 999999, // non-existing team ID
|
||||
}
|
||||
assert.NoError(t, comment.LoadAssigneeUserAndTeam(t.Context()))
|
||||
assert.NotNil(t, comment.AssigneeTeam)
|
||||
assert.EqualValues(t, -1, comment.AssigneeTeam.ID)
|
||||
}
|
||||
|
||||
func Test_UpdateCommentAttachment(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ package organization
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -92,6 +93,15 @@ func (t *Team) IsPublic() bool { return t.Visibility.IsPublic() }
|
||||
func (t *Team) IsLimited() bool { return t.Visibility.IsLimited() }
|
||||
func (t *Team) IsPrivate() bool { return t.Visibility.IsPrivate() }
|
||||
|
||||
const (
|
||||
ghostTeamID = -1
|
||||
ghostTeamName = "(deleted team)"
|
||||
)
|
||||
|
||||
func newGhostTeam() *Team {
|
||||
return &Team{ID: ghostTeamID, Name: ghostTeamName, LowerName: ghostTeamName}
|
||||
}
|
||||
|
||||
// CanNonMemberReadMeta reports whether a non-member, non-owner doer may read
|
||||
// the team's metadata, based on the team's visibility tier and the parent org's
|
||||
// visibility. Privileged callers (site admins, org owners, team members) are
|
||||
@@ -270,6 +280,17 @@ func GetTeamByID(ctx context.Context, teamID int64) (*Team, error) {
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func GetPossibleTeamByID(ctx context.Context, teamID int64) (int64, *Team, error) {
|
||||
t, err := GetTeamByID(ctx, teamID)
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
t = newGhostTeam()
|
||||
return t.ID, t, nil
|
||||
} else if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return t.ID, t, nil
|
||||
}
|
||||
|
||||
// IncrTeamRepoNum increases the number of repos for the given team by 1
|
||||
func IncrTeamRepoNum(ctx context.Context, teamID int64) error {
|
||||
_, err := db.GetEngine(ctx).Incr("num_repos").ID(teamID).Update(new(Team))
|
||||
|
||||
@@ -12,9 +12,20 @@ import (
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/proxy"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// httpClient returns an HTTP client that honors Gitea's proxy configuration.
|
||||
var httpClient = util.OnceValue[*http.Client]{
|
||||
Func: func() *http.Client {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.Proxy = proxy.Proxy()
|
||||
return &http.Client{Transport: transport}
|
||||
},
|
||||
}
|
||||
|
||||
// Response is the structure of JSON returned from API
|
||||
type Response struct {
|
||||
Success bool `json:"success"`
|
||||
@@ -40,7 +51,7 @@ func Verify(ctx context.Context, response string) (bool, error) {
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
resp, err := httpClient.Value().Do(req)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("Failed to send CAPTCHA response: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package turnstile
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHTTPClientHonorsProxy(t *testing.T) {
|
||||
proxyURL, err := url.Parse("http://proxy.example.com:3128")
|
||||
require.NoError(t, err)
|
||||
|
||||
defer test.MockVariableValue(&setting.Proxy.Enabled, true)()
|
||||
defer test.MockVariableValue(&setting.Proxy.ProxyURL, proxyURL.String())()
|
||||
defer test.MockVariableValue(&setting.Proxy.ProxyURLFixed, proxyURL)()
|
||||
defer test.MockVariableValue(&setting.Proxy.ProxyHosts, []string{"**"})()
|
||||
httpClient.Reset()
|
||||
transport, ok := httpClient.Value().Transport.(*http.Transport)
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, transport.Proxy)
|
||||
|
||||
// The Turnstile verification request must be routed through the configured proxy.
|
||||
req := httptest.NewRequest(http.MethodPost, "https://any.example.com", nil)
|
||||
got, err := transport.Proxy(req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got)
|
||||
assert.Equal(t, proxyURL.String(), got.String())
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
type onceValueResult[T any] struct {
|
||||
value T
|
||||
panic any
|
||||
}
|
||||
|
||||
// OnceValue is similar to Golang's "sync.OnceValue", but can be reset.
|
||||
type OnceValue[T any] struct {
|
||||
Func func() T
|
||||
mu sync.Mutex
|
||||
res atomic.Pointer[onceValueResult[T]]
|
||||
}
|
||||
|
||||
func (o *OnceValue[T]) Value() T {
|
||||
res := o.res.Load()
|
||||
if res == nil {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
res = o.res.Load()
|
||||
if res == nil {
|
||||
res = &onceValueResult[T]{}
|
||||
defer func() {
|
||||
res.panic = recover()
|
||||
o.res.Store(res)
|
||||
if res.panic != nil {
|
||||
panic(res.panic)
|
||||
}
|
||||
}()
|
||||
res.value = o.Func()
|
||||
}
|
||||
}
|
||||
if res.panic != nil {
|
||||
panic(res.panic)
|
||||
}
|
||||
return res.value
|
||||
}
|
||||
|
||||
func (o *OnceValue[T]) Reset() {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
o.res.Store(nil)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestOnceValue(t *testing.T) {
|
||||
t.Run("RepeatCall", func(t *testing.T) {
|
||||
callCount := 0
|
||||
o := OnceValue[int]{Func: func() int {
|
||||
callCount++
|
||||
return 42
|
||||
}}
|
||||
assert.Equal(t, 42, o.Value())
|
||||
assert.Equal(t, 42, o.Value())
|
||||
assert.Equal(t, 1, callCount)
|
||||
o.Reset()
|
||||
assert.Equal(t, 42, o.Value())
|
||||
assert.Equal(t, 2, callCount)
|
||||
assert.Equal(t, 42, o.Value())
|
||||
assert.Equal(t, 2, callCount)
|
||||
})
|
||||
|
||||
t.Run("Panic", func(t *testing.T) {
|
||||
callCount := 0
|
||||
doPanic := true
|
||||
o := OnceValue[int]{Func: func() int {
|
||||
callCount++
|
||||
if doPanic {
|
||||
panic("some error")
|
||||
}
|
||||
return 42
|
||||
}}
|
||||
assert.PanicsWithValue(t, "some error", func() { o.Value() })
|
||||
assert.PanicsWithValue(t, "some error", func() { o.Value() })
|
||||
assert.Equal(t, 1, callCount)
|
||||
doPanic = false
|
||||
o.Reset()
|
||||
assert.Equal(t, 42, o.Value())
|
||||
assert.Equal(t, 2, callCount)
|
||||
assert.Equal(t, 42, o.Value())
|
||||
assert.Equal(t, 2, callCount)
|
||||
})
|
||||
}
|
||||
@@ -40,6 +40,7 @@ var timeStrGlobalVars = sync.OnceValue(func() *timeStrGlobalVarsType {
|
||||
})
|
||||
|
||||
func TimeEstimateParse(timeStr string) (int64, error) {
|
||||
timeStr = strings.TrimSpace(timeStr)
|
||||
if timeStr == "" {
|
||||
return 0, nil
|
||||
}
|
||||
@@ -51,7 +52,13 @@ func TimeEstimateParse(timeStr string) (int64, error) {
|
||||
if matches[0][0] != 0 || matches[len(matches)-1][1] != len(timeStr) {
|
||||
return 0, fmt.Errorf("invalid time string: %s", timeStr)
|
||||
}
|
||||
prevEnd := 0
|
||||
for _, match := range matches {
|
||||
// only whitespace may separate two units, otherwise the string contains invalid content like "1h x 2m"
|
||||
if strings.TrimSpace(timeStr[prevEnd:match[0]]) != "" {
|
||||
return 0, fmt.Errorf("invalid time string: %s", timeStr)
|
||||
}
|
||||
prevEnd = match[1]
|
||||
amount, err := strconv.ParseInt(timeStr[match[2]:match[3]], 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid time string: %v", err)
|
||||
|
||||
@@ -22,6 +22,9 @@ func TestTimeStr(t *testing.T) {
|
||||
{"1s", 1, false},
|
||||
{"1h 1m 1s", 3600 + 60 + 1, false},
|
||||
{"1d1x", 0, true},
|
||||
{"1h 2x 3m", 0, true},
|
||||
{"1h_2m", 0, true},
|
||||
{"1h,1m", 0, true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.input, func(t *testing.T) {
|
||||
|
||||
@@ -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="/<repoLink>/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) {
|
||||
|
||||
@@ -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}}
|
||||
<div class="center page buttons">
|
||||
|
||||
@@ -14,14 +14,14 @@
|
||||
<div class="ui fluid vertical menu">
|
||||
<a class="item {{if not $.CurWorkflow}}active{{end}}" href="?actor={{$.CurActor}}&status={{$.CurStatus}}&branch={{$.CurBranch}}">{{ctx.Locale.Tr "actions.runs.all_workflows"}}</a>
|
||||
{{range .workflows}}
|
||||
<a class="item flex-text-block {{if and (eq .Entry.Name $.CurWorkflow) (not $.CurWorkflowScopedRepoID)}}active{{end}}" href="?workflow={{.Entry.Name}}&actor={{$.CurActor}}&status={{$.CurStatus}}&branch={{$.CurBranch}}">
|
||||
<a class="item flex-text-block {{if and (eq .EntryName $.CurWorkflow) (not $.CurWorkflowScopedRepoID)}}active{{end}}" href="?workflow={{.EntryName}}&actor={{$.CurActor}}&status={{$.CurStatus}}&branch={{$.CurBranch}}">
|
||||
<span class="gt-ellipsis" data-tooltip-content="{{.DisplayName}}">{{.DisplayName}}</span>
|
||||
|
||||
{{if .ErrMsg}}
|
||||
<span class="flex-text-inline tw-shrink-0" data-tooltip-content="{{.ErrMsg}}">{{svg "octicon-alert" 16 "tw-text-red"}}</span>
|
||||
{{end}}
|
||||
|
||||
{{if $.ActionsConfig.IsWorkflowDisabled .Entry.Name}}
|
||||
{{if $.ActionsConfig.IsWorkflowDisabled .EntryName}}
|
||||
<div class="ui red label tw-shrink-0">{{ctx.Locale.Tr "disabled"}}</div>
|
||||
{{end}}
|
||||
</a>
|
||||
@@ -153,7 +153,8 @@
|
||||
{{end}}
|
||||
|
||||
<div class="ui attached segment">
|
||||
{{template "repo/actions/runs_list" .}}
|
||||
{{template "repo/actions/runs_list" dict "ActionRunListData" $.ActionRunListData}}
|
||||
{{template "base/paginate" dict "Page" $.Page}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
<div class="flex-divided-list items-with-main run-list">
|
||||
{{if not .Runs}}
|
||||
{{$data := .ActionRunListData}}
|
||||
<div class="flex-divided-list items-with-main run-list" data-global-init="initActionRunsList"
|
||||
data-action-runs-refresh-link="{{$data.RefreshLink}}"
|
||||
data-action-runs-refresh-interval="{{$data.RefreshIntervalMs}}"
|
||||
>
|
||||
{{if not $data.ActionRuns}}
|
||||
<div class="empty-placeholder">
|
||||
{{svg "octicon-no-entry" 48}}
|
||||
<h2>{{if $.IsFiltered}}{{ctx.Locale.Tr "actions.runs.no_results"}}{{else}}{{ctx.Locale.Tr "actions.runs.no_runs"}}{{end}}</h2>
|
||||
<h2>{{if $data.IsFiltered}}{{ctx.Locale.Tr "actions.runs.no_results"}}{{else}}{{ctx.Locale.Tr "actions.runs.no_runs"}}{{end}}</h2>
|
||||
</div>
|
||||
{{end}}
|
||||
{{range $run := .Runs}}
|
||||
<div class="item tw-items-center">
|
||||
{{range $run := $data.ActionRuns}}
|
||||
<div id="action-run-item-{{$run.ID}}" class="item tw-items-center" data-status="{{$run.Status.String}}">
|
||||
<div class="item-leading">
|
||||
<span data-tooltip-content="{{ctx.Locale.Tr (printf "actions.status.%s" $run.Status.String)}}">
|
||||
{{template "repo/icons/action_status" (dict "Status" $run.Status.String "IconVariant" "circle-fill")}}
|
||||
@@ -15,22 +19,22 @@
|
||||
<div class="item-main">
|
||||
<div class="item-title">
|
||||
{{$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}}
|
||||
</div>
|
||||
<div class="item-body">
|
||||
{{$workflowName := index $.WorkflowNames $run.WorkflowID}}
|
||||
<span><b>{{if not $.CurWorkflow}}{{if $workflowName}}{{$workflowName}}{{else}}{{$run.WorkflowID}}{{end}} {{end}}#{{$run.Index}}</b>:</span>
|
||||
{{$workflowName := index $data.WorkflowNames $run.WorkflowID}}
|
||||
<span><b>{{if not $data.CurWorkflow}}{{if $workflowName}}{{$workflowName}}{{else}}{{$run.WorkflowID}}{{end}} {{end}}#{{$run.Index}}</b>:</span>
|
||||
|
||||
{{- if $run.ScheduleID -}}
|
||||
{{ctx.Locale.Tr "actions.runs.scheduled"}}
|
||||
{{- else -}}
|
||||
{{ctx.Locale.Tr "actions.runs.commit"}}
|
||||
<a href="{{$.RepoLink}}/commit/{{$run.CommitSHA}}">{{ShortSha $run.CommitSHA}}</a>
|
||||
<a href="{{$run.Repo.Link}}/commit/{{$run.CommitSHA}}">{{ShortSha $run.CommitSHA}}</a>
|
||||
{{ctx.Locale.Tr "actions.runs.pushed_by"}}
|
||||
<a href="{{$run.TriggerUser.HomeLink}}">{{$run.TriggerUser.GetDisplayName}}</a>
|
||||
{{- end -}}
|
||||
|
||||
{{$errMsg := index $.RunErrors $run.ID}}
|
||||
{{$errMsg := index $data.RunErrors $run.ID}}
|
||||
{{if $errMsg}}
|
||||
<span class="flex-text-inline" data-tooltip-content="{{$errMsg}}">
|
||||
{{svg "octicon-alert" 16 "tw-text-red"}}
|
||||
@@ -52,12 +56,12 @@
|
||||
{{svg "octicon-kebab-horizontal"}}
|
||||
<div class="menu flex-items-menu">
|
||||
<a class="item" href="{{$run.Link}}/workflow">{{svg "octicon-play"}}{{ctx.Locale.Tr "actions.runs.view_workflow_file"}}</a>
|
||||
{{if and $.CanWriteRepoUnitActions (not $run.Status.IsDone)}}
|
||||
{{if and $data.CanWriteRepoUnitActions (not $run.Status.IsDone)}}
|
||||
<a class="item link-action" data-url="{{$run.Link}}/cancel">
|
||||
{{svg "octicon-x"}}{{ctx.Locale.Tr "actions.runs.cancel"}}
|
||||
</a>
|
||||
{{end}}
|
||||
{{if and $.CanWriteRepoUnitActions $run.Status.IsDone}}
|
||||
{{if and $data.CanWriteRepoUnitActions $run.Status.IsDone}}
|
||||
<a class="item link-action"
|
||||
data-url="{{$run.Link}}/delete"
|
||||
data-modal-confirm="{{ctx.Locale.Tr "actions.runs.delete.description"}}"
|
||||
@@ -71,4 +75,3 @@
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{template "base/paginate" .}}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
</div>
|
||||
{{end}}
|
||||
{{range .workflows}}
|
||||
{{if and .ErrMsg (eq .Entry.Name $.CurWorkflow)}}
|
||||
{{if and .ErrMsg (eq .EntryName $.CurWorkflow)}}
|
||||
<div class="ui field">
|
||||
<div>{{svg "octicon-alert" 16 "tw-text-red"}} {{.ErrMsg}}</div>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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('<div><div class="">a</div><div class="">b</div></div>');
|
||||
});
|
||||
|
||||
test('protectMorphElements', () => {
|
||||
const el = createElementFromHTML('<div><span data-morph-protect="">foo</span></div>');
|
||||
const protectedElems = protectMorphElements(el);
|
||||
|
||||
const span = el.querySelector('span')!;
|
||||
const spanMorphProtectId = span.getAttribute('data-morph-protect');
|
||||
expect(spanMorphProtectId).toBeTruthy();
|
||||
expect(el.outerHTML).toEqual(`<div><span data-morph-protect="${spanMorphProtectId}">foo</span></div>`);
|
||||
span.textContent = 'bar';
|
||||
span.classList.add('new-class');
|
||||
expect(el.outerHTML).toEqual(`<div><span data-morph-protect="${spanMorphProtectId}" class="new-class">bar</span></div>`);
|
||||
|
||||
recoverMorphElements(el, protectedElems);
|
||||
expect(el.outerHTML).toEqual('<div><span data-morph-protect="">foo</span></div>');
|
||||
});
|
||||
|
||||
@@ -411,3 +411,26 @@ export function activePageTimerRefresh(opts: ActivePageTimerOptions): ActivePage
|
||||
timer.start();
|
||||
return timer;
|
||||
}
|
||||
|
||||
type ProtectedMorphElements = Record<string, string>;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user