diff --git a/AGENTS.md b/AGENTS.md index 589dea7865f..6c7e50fea4f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,5 +7,6 @@ - Never force-push, amend, or squash unless asked. Use new commits and normal push for pull request updates - Preserve existing code comments, do not remove or rewrite comments that are still relevant - In TypeScript, use `!` (non-null assertion) instead of `?.`/`??` when a value is known to always exist +- For CSS layout, prefer `flex-*` helpers over per-child `tw-ml-*` / `tw-mr-*` margins; fall back to `tw-*` utilities when specificity requires `!important` - Include authorship attribution in issue and pull request comments - Add `Co-Authored-By` lines to all commits, indicating name and model used diff --git a/models/issues/issue.go b/models/issues/issue.go index 655cdebdfc6..838d41a3005 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -592,6 +592,17 @@ func GetIssueByID(ctx context.Context, id int64) (*Issue, error) { return issue, nil } +func GetIssueByRepoID(ctx context.Context, repoID, issueID int64) (*Issue, error) { + issue := new(Issue) + has, err := db.GetEngine(ctx).ID(issueID).Where("repo_id=?", repoID).Get(issue) + if err != nil { + return nil, err + } else if !has { + return nil, ErrIssueNotExist{issueID, repoID, 0} + } + return issue, nil +} + // GetIssuesByIDs return issues with the given IDs. // If keepOrder is true, the order of the returned issues will be the same as the given IDs. func GetIssuesByIDs(ctx context.Context, issueIDs []int64, keepOrder ...bool) (IssueList, error) { diff --git a/models/issues/issue_project.go b/models/issues/issue_project.go index 3bb09363019..f78daf77f88 100644 --- a/models/issues/issue_project.go +++ b/models/issues/issue_project.go @@ -115,17 +115,10 @@ func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_mo panic("newColumnID must not be zero") // shouldn't happen } - res := struct { - MaxSorting int64 - IssueCount int64 - }{} - if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as issue_count").Table("project_issue"). - Where("project_id=?", newProjectID). - And("project_board_id=?", newColumnID). - Get(&res); err != nil { + newSorting, err := project_model.GetColumnIssueNextSorting(ctx, newProjectID, newColumnID) + if err != nil { return err } - newSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0) return db.Insert(ctx, &project_model.ProjectIssue{ IssueID: issue.ID, ProjectID: newProjectID, diff --git a/models/project/column.go b/models/project/column.go index 7365204f18e..997e82ddf90 100644 --- a/models/project/column.go +++ b/models/project/column.go @@ -185,7 +185,7 @@ func deleteColumnByID(ctx context.Context, columnID int64) error { return err } - if err = column.moveIssuesToAnotherColumn(ctx, defaultColumn); err != nil { + if err = moveIssuesToAnotherColumn(ctx, column, defaultColumn); err != nil { return err } diff --git a/models/project/column_test.go b/models/project/column_test.go index 948e012c62d..6437a764ed3 100644 --- a/models/project/column_test.go +++ b/models/project/column_test.go @@ -59,7 +59,7 @@ func Test_moveIssuesToAnotherColumn(t *testing.T) { assert.Len(t, issues, 1) assert.EqualValues(t, 3, issues[0].ID) - err = column1.moveIssuesToAnotherColumn(t.Context(), column2) + err = moveIssuesToAnotherColumn(t.Context(), column1, column2) assert.NoError(t, err) issues, err = column1.GetIssues(t.Context()) diff --git a/models/project/issue.go b/models/project/issue.go index 47d1537ec73..c89f5243054 100644 --- a/models/project/issue.go +++ b/models/project/issue.go @@ -33,38 +33,45 @@ func deleteProjectIssuesByProjectID(ctx context.Context, projectID int64) error return err } -func (c *Column) moveIssuesToAnotherColumn(ctx context.Context, newColumn *Column) error { - if c.ProjectID != newColumn.ProjectID { - return errors.New("columns have to be in the same project") - } - - if c.ID == newColumn.ID { - return nil - } - +// GetColumnIssueNextSorting returns the sorting value to append an issue at the end of the column. +func GetColumnIssueNextSorting(ctx context.Context, projectID, columnID int64) (int64, error) { res := struct { MaxSorting int64 IssueCount int64 }{} - if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as issue_count"). + if _, err := db.GetEngine(ctx).Select("max(sorting) AS max_sorting, count(*) AS issue_count"). Table("project_issue"). - Where("project_id=?", newColumn.ProjectID). - And("project_board_id=?", newColumn.ID). + Where("project_id=?", projectID). + And("project_board_id=?", columnID). Get(&res); err != nil { - return err + return 0, err + } + return util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0), nil +} + +func moveIssuesToAnotherColumn(ctx context.Context, oldColumn, newColumn *Column) error { + if oldColumn.ProjectID != newColumn.ProjectID { + return errors.New("columns have to be in the same project") } - issues, err := c.GetIssues(ctx) - if err != nil { - return err - } - if len(issues) == 0 { + if oldColumn.ID == newColumn.ID { return nil } - nextSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0) + movedIssues, err := oldColumn.GetIssues(ctx) + if err != nil { + return err + } + if len(movedIssues) == 0 { + return nil + } + + nextSorting, err := GetColumnIssueNextSorting(ctx, newColumn.ProjectID, newColumn.ID) + if err != nil { + return err + } return db.WithTx(ctx, func(ctx context.Context) error { - for i, issue := range issues { + for i, issue := range movedIssues { issue.ProjectColumnID = newColumn.ID issue.Sorting = nextSorting + int64(i) if _, err := db.GetEngine(ctx).ID(issue.ID).Cols("project_board_id", "sorting").Update(issue); err != nil { diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 8efafd5c4b7..819195efe2d 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -1407,11 +1407,12 @@ "repo.issues.new": "New Issue", "repo.issues.new.title_empty": "Title cannot be empty", "repo.issues.new.labels": "Labels", - "repo.issues.new.no_label": "No Label", + "repo.issues.new.no_labels": "No labels", "repo.issues.new.clear_labels": "Clear labels", "repo.issues.new.projects": "Projects", "repo.issues.new.clear_projects": "Clear projects", - "repo.issues.new.no_projects": "No project", + "repo.issues.new.no_projects": "No projects", + "repo.issues.new.no_column": "No column", "repo.issues.new.open_projects": "Open Projects", "repo.issues.new.closed_projects": "Closed Projects", "repo.issues.new.no_items": "No items", diff --git a/routers/web/repo/issue_page_meta.go b/routers/web/repo/issue_page_meta.go index 639333ab425..be609d8cdfb 100644 --- a/routers/web/repo/issue_page_meta.go +++ b/routers/web/repo/issue_page_meta.go @@ -33,12 +33,15 @@ type issueSidebarAssigneesData struct { CandidateAssignees []*user_model.User } +type issueSidebarProjectCardData struct { + Project *project_model.Project + Columns []*project_model.Column + SelectedColumn *project_model.Column +} + type issueSidebarProjectsData struct { SelectedProjectIDs []int64 // TODO: support multiple projects in the future - - // the "selected" fields are only valid when len(SelectedProjectIDs)==1 - SelectedProjectColumns []*project_model.Column - SelectedProjectColumn *project_model.Column + ProjectCards []*issueSidebarProjectCardData OpenProjects []*project_model.Project ClosedProjects []*project_model.Project @@ -172,30 +175,37 @@ func (d *IssuePageMetaData) retrieveProjectData(ctx *context.Context) { if d.Issue == nil || d.Issue.Project == nil { return } - d.ProjectsData.SelectedProjectIDs = []int64{d.Issue.Project.ID} columns, err := d.Issue.Project.GetColumns(ctx) if err != nil { ctx.ServerError("GetProjectColumns", err) return } - d.ProjectsData.SelectedProjectColumns = columns columnID, err := d.Issue.ProjectColumnID(ctx) if err != nil { ctx.ServerError("ProjectColumnID", err) return } + var selectedColumn *project_model.Column for _, col := range columns { if col.ID == columnID { - d.ProjectsData.SelectedProjectColumn = col + selectedColumn = col break } } + d.ProjectsData.ProjectCards = []*issueSidebarProjectCardData{ + { + Project: d.Issue.Project, + Columns: columns, + SelectedColumn: selectedColumn, + }, + } + d.ProjectsData.SelectedProjectIDs = make([]int64, 0, len(d.ProjectsData.ProjectCards)) + for _, card := range d.ProjectsData.ProjectCards { + d.ProjectsData.SelectedProjectIDs = append(d.ProjectsData.SelectedProjectIDs, card.Project.ID) + } } func (d *IssuePageMetaData) retrieveProjectsDataForIssueWriter(ctx *context.Context) { - if d.Issue != nil && d.Issue.Project != nil { - d.ProjectsData.SelectedProjectIDs = []int64{d.Issue.Project.ID} - } d.ProjectsData.OpenProjects, d.ProjectsData.ClosedProjects = retrieveProjectsInternal(ctx, ctx.Repo.Repository) } diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index c9bdc5be76e..be12674223e 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -464,6 +464,54 @@ func UpdateIssueProject(ctx *context.Context) { ctx.JSONOK() } +// UpdateIssueProjectColumn moves an issue to a different column within its project +func UpdateIssueProjectColumn(ctx *context.Context) { + issue, err := issues_model.GetIssueByRepoID(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("issue_id")) + if err != nil { + ctx.NotFoundOrServerError("GetIssueByID", issues_model.IsErrIssueNotExist, err) + return + } + column, err := project_model.GetColumn(ctx, ctx.FormInt64("id")) + if err != nil { + ctx.NotFoundOrServerError("GetColumn", project_model.IsErrProjectColumnNotExist, err) + return + } + + if err := issue.LoadProject(ctx); err != nil { + ctx.ServerError("LoadProject", err) + return + } + + issueProjects := []*project_model.Project{issue.Project} // TODO: this is for the multiple project support in the future + + // it must make sure the requested column is in this issue's projects + var columnProject *project_model.Project + for _, project := range issueProjects { + if column.ProjectID == project.ID { + columnProject = project + break + } + } + if columnProject == nil { + ctx.NotFound(nil) + return + } + + // append to the end of the target column so we don't collide with existing sorting values + newSorting, err := project_model.GetColumnIssueNextSorting(ctx, columnProject.ID, column.ID) + if err != nil { + ctx.ServerError("GetColumnIssueNextSorting", err) + return + } + + if err := project_service.MoveIssuesOnProjectColumn(ctx, ctx.Doer, column, map[int64]int64{newSorting: issue.ID}); err != nil { + ctx.ServerError("MoveIssuesOnProjectColumn", err) + return + } + + ctx.JSONOK() +} + // DeleteProjectColumn allows for the deletion of a project column func DeleteProjectColumn(ctx *context.Context) { if ctx.Doer == nil { diff --git a/routers/web/web.go b/routers/web/web.go index 1dff6cbc04e..e0ff54fcff5 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1355,6 +1355,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Post("/labels", reqRepoIssuesOrPullsWriter, repo.UpdateIssueLabel) m.Post("/milestone", reqRepoIssuesOrPullsWriter, repo.UpdateIssueMilestone) m.Post("/projects", reqRepoIssuesOrPullsWriter, reqRepoProjectsReader, repo.UpdateIssueProject) + m.Post("/projects/column", reqRepoIssuesOrPullsWriter, reqRepoProjectsWriter, repo.UpdateIssueProjectColumn) m.Post("/assignee", reqRepoIssuesOrPullsWriter, repo.UpdateIssueAssignee) m.Post("/status", reqRepoIssuesOrPullsWriter, repo.UpdateIssueStatus) m.Post("/delete", reqRepoAdmin, repo.BatchDeleteIssues) diff --git a/templates/repo/issue/sidebar/label_list.tmpl b/templates/repo/issue/sidebar/label_list.tmpl index d30196326b3..3eb7ba8d7a5 100644 --- a/templates/repo/issue/sidebar/label_list.tmpl +++ b/templates/repo/issue/sidebar/label_list.tmpl @@ -45,7 +45,7 @@
- {{ctx.Locale.Tr "repo.issues.new.no_label"}} + {{ctx.Locale.Tr "repo.issues.new.no_labels"}} {{range $data.AllLabels}} {{if .IsChecked}} diff --git a/templates/repo/issue/sidebar/project_list.tmpl b/templates/repo/issue/sidebar/project_list.tmpl index ca3b6bdd7ab..a92621eb8e5 100644 --- a/templates/repo/issue/sidebar/project_list.tmpl +++ b/templates/repo/issue/sidebar/project_list.tmpl @@ -1,8 +1,9 @@ {{$pageMeta := .}} {{$data := .ProjectsData}} -{{$issueProject := NIL}}{{if and $pageMeta.Issue $pageMeta.Issue.Project}}{{$issueProject = $pageMeta.Issue.Project}}{{end}}
-
@@ -24,7 +25,8 @@
{{ctx.Locale.Tr "repo.issues.new.open_projects"}}
{{range $data.OpenProjects}}
- {{svg .IconName 18 "tw-shrink-0"}}{{.Title}} + {{svg "octicon-check"}} + {{svg .IconName 18}}{{.Title}} {{end}} {{end}} @@ -33,19 +35,67 @@
{{ctx.Locale.Tr "repo.issues.new.closed_projects"}}
{{range $data.ClosedProjects}} - {{svg .IconName 18 "tw-shrink-0"}}{{.Title}} + {{svg "octicon-check"}} + {{svg .IconName 18}}{{.Title}} {{end}} {{end}}
- + +{{/* project cards (column selectors) */}} +{{if not $data.ProjectCards}} +
+
{{ctx.Locale.Tr "repo.issues.new.no_projects"}}
+
+{{else}} +
+ {{range $card := $data.ProjectCards}} + {{$selectedColumn := $card.SelectedColumn}} + + {{end}} +
+{{end}} diff --git a/tests/e2e/issue-project.test.ts b/tests/e2e/issue-project.test.ts new file mode 100644 index 00000000000..451006c3f02 --- /dev/null +++ b/tests/e2e/issue-project.test.ts @@ -0,0 +1,67 @@ +import {env} from 'node:process'; +import {test, expect} from '@playwright/test'; +import {login, apiCreateRepo, apiCreateIssue, apiDeleteRepo, createProjectColumn, randomString, timeoutFactor} from './utils.ts'; + +test('assign issue to project and change column', async ({page}) => { + const repoName = `e2e-issue-project-${randomString(8)}`; + const user = env.GITEA_TEST_E2E_USER; + await Promise.all([login(page), apiCreateRepo(page.request, {name: repoName})]); + + await page.goto(`/${user}/${repoName}/projects/new`); + await page.locator('input[name="title"]').fill('Kanban Board'); + await page.getByRole('button', {name: 'Create Project'}).click(); + + const projectLink = page.locator('.milestone-list a', {hasText: 'Kanban Board'}).first(); + await expect(projectLink).toBeVisible(); + const href = await projectLink.getAttribute('href'); + const projectID = href!.split('/').pop(); + + // columns created via POST because the web UI uses modals that are hard to drive + await Promise.all(['Backlog', 'In Progress', 'Done'].map((title) => + createProjectColumn(page.request, user, repoName, projectID!, title), + )); + + await apiCreateIssue(page.request, user, repoName, {title: 'Column picker test'}); + + // Same ceiling as tests/e2e/events.test.ts; Playwright defaults are 5000*factor (see playwright.config.ts). + const slowTimeout = 15_000 * timeoutFactor; + + await page.goto(`/${user}/${repoName}/issues/1`); + await page.locator('.sidebar-project-combo .ui.dropdown').click(); + await Promise.all([ + page.waitForResponse( + (resp) => resp.url().includes('/issues/projects') && resp.status() === 200, + {timeout: slowTimeout}, + ), + page.locator('.sidebar-project-combo .menu .item', {hasText: 'Kanban Board'}).click(), + ]); + + const columnCombo = page.locator('.sidebar-project-column-combo'); + await expect(columnCombo).toBeVisible({timeout: slowTimeout}); + await columnCombo.locator('.ui.dropdown').click(); + await columnCombo.locator('.menu').waitFor({state: 'visible', timeout: slowTimeout}); + + const inProgressItem = columnCombo.locator('a.item', {hasText: 'In Progress'}); + await expect(inProgressItem).toBeVisible({timeout: slowTimeout}); + await inProgressItem.scrollIntoViewIfNeeded(); + + await Promise.all([ + page.waitForResponse( + (resp) => + resp.request().method() === 'POST' && + resp.url().includes('/issues/projects/column') && + resp.ok(), + {timeout: slowTimeout}, + ), + inProgressItem.click(), + ]); + + await expect(columnCombo.getByTestId('sidebar-project-column-text')).toContainText('In Progress', { + timeout: slowTimeout, + }); + await expect(page.locator('.timeline-item', {hasText: 'moved this to In Progress'})).toBeVisible({ + timeout: slowTimeout, + }); + + await apiDeleteRepo(page.request, user, repoName); +}); diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts index 08de0241268..0b00ab129a6 100644 --- a/tests/e2e/utils.ts +++ b/tests/e2e/utils.ts @@ -74,6 +74,13 @@ export async function apiCreateBranch(requestContext: APIRequestContext, owner: }), 'apiCreateBranch'); } +export async function createProjectColumn(requestContext: APIRequestContext, owner: string, repo: string, projectID: string, title: string) { + await apiRetry(() => requestContext.post(`${baseUrl()}/${owner}/${repo}/projects/${projectID}/columns/new`, { + headers: apiHeaders(), + form: {title}, + }), 'createProjectColumn'); +} + export async function apiDeleteRepo(requestContext: APIRequestContext, owner: string, name: string) { await apiRetry(() => requestContext.delete(`${baseUrl()}/api/v1/repos/${owner}/${name}`, { headers: apiHeaders(), diff --git a/tests/integration/project_test.go b/tests/integration/project_test.go index 1e38322dbf4..ddf0743040d 100644 --- a/tests/integration/project_test.go +++ b/tests/integration/project_test.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "strconv" + "strings" "testing" issues_model "code.gitea.io/gitea/models/issues" @@ -89,6 +90,110 @@ func TestMoveRepoProjectColumns(t *testing.T) { assert.NoError(t, project_model.DeleteProjectByID(t.Context(), project1.ID)) } +func TestUpdateIssueProjectColumn(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // fixture: issue 3 is in project 1 of repo user2/repo1, column "In Progress" (id=2) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3}) + assert.EqualValues(t, 1, issue.RepoID) + + sess := loginUser(t, "user2") + + t.Run("MoveColumn", func(t *testing.T) { + req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects/column", map[string]string{ + "issue_id": "3", + "id": "3", + }) + sess.MakeRequest(t, req, http.StatusOK) + + pi := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{IssueID: 3}) + assert.EqualValues(t, 3, pi.ProjectColumnID) + }) + + t.Run("InvalidIssueID", func(t *testing.T) { + req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects/column", map[string]string{ + "issue_id": "0", + "id": "3", + }) + sess.MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("WrongRepo", func(t *testing.T) { + req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects/column", map[string]string{ + "issue_id": "6", + "id": "3", + }) + sess.MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("WrongProject", func(t *testing.T) { + project2 := project_model.Project{ + Title: "second project on repo1", + RepoID: 1, + Type: project_model.TypeRepository, + TemplateType: project_model.TemplateTypeNone, + } + require.NoError(t, project_model.NewProject(t.Context(), &project2)) + require.NoError(t, project_model.NewColumn(t.Context(), &project_model.Column{ + Title: "other column", + ProjectID: project2.ID, + })) + columns, err := project2.GetColumns(t.Context()) + require.NoError(t, err) + require.NotEmpty(t, columns) + + req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects/column", map[string]string{ + "issue_id": "1", + "id": strconv.FormatInt(columns[0].ID, 10), + }) + sess.MakeRequest(t, req, http.StatusNotFound) + }) +} + +func TestIssueSidebarProjectColumn(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // fixture: issue 5 (index=4) is in project 1 of repo user2/repo1, column "Done" (id=3) + sess := loginUser(t, "user2") + + req := NewRequest(t, "GET", "/user2/repo1/issues/4") + resp := sess.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + cards := htmlDoc.Find(".sidebar-project-card") + assert.Equal(t, 1, cards.Length()) + + title := cards.Find(".sidebar-project-card a.suppressed .gt-ellipsis") + assert.Contains(t, strings.TrimSpace(title.Text()), "First project") + + columnCombo := cards.Find(".sidebar-project-column-combo") + assert.Equal(t, 1, columnCombo.Length()) + + defaultItem := columnCombo.Find(`.menu .item[data-value="1"]`) + assert.Equal(t, 1, defaultItem.Length()) + + inProgressItem := columnCombo.Find(`.menu .item[data-value="2"]`) + assert.Equal(t, 1, inProgressItem.Length()) + doneItem := columnCombo.Find(`.menu .item[data-value="3"]`) + assert.Equal(t, 1, doneItem.Length()) + + comboVal, exists := columnCombo.Find("input.combo-value").Attr("value") + assert.True(t, exists) + assert.Equal(t, "3", comboVal) + + req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects?issue_ids=5", map[string]string{ + "id": "0", + }) + sess.MakeRequest(t, req, http.StatusOK) + + req = NewRequest(t, "GET", "/user2/repo1/issues/4") + resp = sess.MakeRequest(t, req, http.StatusOK) + htmlDoc = NewHTMLParser(t, resp.Body) + + cards = htmlDoc.Find(".sidebar-project-card") + assert.Equal(t, 0, cards.Length()) +} + // getProjectIssueIDs returns the set of issue IDs rendered as cards on the project board page. func getProjectIssueIDs(t *testing.T, htmlDoc *HTMLDoc) map[int64]struct{} { t.Helper() diff --git a/web_src/css/base.css b/web_src/css/base.css index 08033e4a6f0..d9207a18bf4 100644 --- a/web_src/css/base.css +++ b/web_src/css/base.css @@ -782,11 +782,17 @@ tr.top-line-blame:first-of-type { .color-icon { display: inline-block; + flex-shrink: 0; border-radius: var(--border-radius-full); height: 14px; width: 14px; } +.icon-size-8 { + width: 8px; + height: 8px; +} + .rss-icon { display: inline-flex; color: var(--color-text-light-1); diff --git a/web_src/css/modules/svg.css b/web_src/css/modules/svg.css index e32fa0911f1..7adfc77a818 100644 --- a/web_src/css/modules/svg.css +++ b/web_src/css/modules/svg.css @@ -6,10 +6,6 @@ fill: currentcolor; } -.middle .svg { - vertical-align: middle; -} - /* some browsers like Chrome have a bug: when a SVG is in a "display: none" container and referenced somewhere else by ``, it won't be rendered correctly. e.g.: ".kts -> kotlin" */ .svg-icon-container { @@ -50,3 +46,9 @@ .svg[width="36"] { min-width: 36px; } .svg[width="48"] { min-width: 48px; } .svg[width="56"] { min-width: 56px; } + +/* when the svg is used in menu or item, it's certain that we don't want it to be shrunk */ +.menu .svg, +.item .svg { + flex-shrink: 0; +} diff --git a/web_src/css/repo.css b/web_src/css/repo.css index 51745b4adac..428d2e0714e 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -62,12 +62,23 @@ visibility: hidden; } +.sidebar-project-card { + border: 1px solid var(--color-secondary); + border-radius: var(--border-radius); + margin-top: var(--gap-block); + padding: 8px; +} + .issue-content-right .ui.list.labels-list { display: flex; gap: var(--gap-inline); flex-wrap: wrap; } +.issue-content-right .empty-list { + font-size: 12px; +} + @media (max-width: 767.98px) { .issue-content-left, .issue-content-right {