feat(ui): convert disable/enable workflow menu to JS-less dropdown (#10133)

* convert the dropdown (overflow menu) to the JS-less one
    * the "link" still relies on JS to make a POST, changing this is not in scope of this PR
    * fixed the weird behavior where opener changes it's color when hovering over the "link"
    * the bug where the script that refreshes the list once in a while closes an open dropdown is not fixed and is not in scope of this PR
* use border in the opener
    * it might not look as sleek but it is easier to see and better for the user to be able to understand that this is an active intractable element
* global dropdown improvements
    * add rounding rules for dropdowns with only one item - the first such case
    * add testing for rounding rules
    * added a devtest page to play with the dropdown component on

Preview

B: https://codeberg.org/forgejo/forgejo/attachments/1462cdda-71f5-45d0-a206-33bb17740cb8
A: https://codeberg.org/forgejo/forgejo/attachments/d3c265cb-6b77-40c8-9944-d9327f3bec65

B: https://codeberg.org/forgejo/forgejo/attachments/17f17c29-4dcd-4015-b5b9-6d438bd2eb0b
A: https://codeberg.org/forgejo/forgejo/attachments/d94e196c-725e-47de-b4de-ed97b148ceb6

B: https://codeberg.org/forgejo/forgejo/attachments/1813ded9-f619-47d9-bf15-ad4bcd3535ab
A: https://codeberg.org/forgejo/forgejo/attachments/09042e58-331e-414d-ac8f-0f1091033b7f

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10133
Reviewed-by: Otto <otto@codeberg.org>
This commit is contained in:
0ko 2025-11-21 16:59:01 +01:00
parent f4e3c0aaac
commit de3f376882
7 changed files with 173 additions and 9 deletions

View file

@ -0,0 +1,74 @@
{{template "base/head" .}}
<div class="page-content devtest ui container">
<h1>Dropdown</h1>
a.k.a. overflow menu, ellipsis menu
<div class="button-sequence">
<details class="dropdown" id="dropdown-1">
<summary class="Options">
Options {{svg "octicon-triangle-down" 14}}
</summary>
<div class="content">
<ul>
<li>
<a id="dd1_g1_i1" href="#noscroll">Item 1</a>
</li>
<li>
<a id="dd1_g1_i2" href="#noscroll">Item 2</a>
</li>
<li>
<a id="dd1_g1_i3" href="#noscroll">Item 3</a>
</li>
</ul>
</div>
</details>
<details class="dropdown" id="dropdown-2">
<summary class="Options">
Options {{svg "octicon-triangle-down" 14}}
</summary>
<div class="content">
<ul>
<li>
<a id="dd2_g1_i1" href="#noscroll">Item 1</a>
</li>
<li>
<a id="dd2_g1_i2" href="#noscroll">Item 2</a>
</li>
<li>
<a id="dd2_g1_i3" href="#noscroll">Item 3</a>
</li>
</ul>
<hr>
<ul>
<li>
<a id="dd2_g2_i1" href="#noscroll">Item 4</a>
</li>
<li>
<a id="dd2_g2_i2" href="#noscroll">Item 5</a>
</li>
<li>
<a id="dd2_g2_i3" href="#noscroll">Item 6</a>
</li>
</ul>
</div>
</details>
<details class="dropdown" id="dropdown-3">
<summary class="Options">
Options {{svg "octicon-triangle-down" 14}}
</summary>
<div class="content">
<ul>
<li>
<a id="dd3_g1_i1" href="#noscroll">Only item</a>
</li>
</ul>
</div>
</details>
</div>
</div>
{{template "base/footer" .}}

View file

@ -11,7 +11,7 @@
<details class="dropdown dir-rtl">
<summary class="options">
{{ctx.Locale.Tr "repo.issues.filter_sort"}}
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
{{svg "octicon-triangle-down" 14}}
</summary>
<div class="content">
<ul>

View file

@ -62,14 +62,24 @@
</div>
{{if .AllowDisableOrEnableWorkflow}}
<button class="ui jump dropdown btn interact-bg tw-p-2">
{{svg "octicon-kebab-horizontal"}}
<div class="menu">
<a class="item link-action" data-url="{{$.Link}}/{{if .CurWorkflowDisabled}}enable{{else}}disable{{end}}?workflow={{$.CurWorkflow}}&actor={{.CurActor}}&status={{$.CurStatus}}">
{{if .CurWorkflowDisabled}}{{ctx.Locale.Tr "actions.workflow.enable"}}{{else}}{{ctx.Locale.Tr "actions.workflow.disable"}}{{end}}
</a>
<details class="dropdown dir-rtl">
<summary class="border">
{{svg "octicon-kebab-horizontal"}}
</summary>
<div class="content">
<ul>
<li>
<a class="link-action" data-url="{{$.Link}}/{{if .CurWorkflowDisabled}}enable{{else}}disable{{end}}?workflow={{$.CurWorkflow}}&actor={{.CurActor}}&status={{$.CurStatus}}" href="#noscroll">
{{if .CurWorkflowDisabled}}
{{ctx.Locale.Tr "actions.workflow.enable"}}
{{else}}
{{ctx.Locale.Tr "actions.workflow.disable"}}
{{end}}
</a>
</li>
</ul>
</div>
</button>
</details>
{{end}}
</div>

View file

@ -70,6 +70,53 @@ test.describe('Workflow Authenticated user2', () => {
test('dispatch success', async ({page}) => {
await dispatchSuccess(page);
});
test('Disable/enable workflow', async ({page}) => {
await page.goto('/user2/test_workflows/actions?workflow=test-dispatch.yml');
const menuOpener = page.locator('.filter.menu details.dropdown > summary');
const disableButton = page.locator('a[data-url^="/user2/test_workflows/actions/disable"]');
const enableButton = page.locator('a[data-url^="/user2/test_workflows/actions/enable"]');
const disabledLabel = page.locator('.vertical.menu .item.active .ui.label').getByText('Disabled');
const flashBanner = page.locator('#flash-message');
// Overflow menu is hidden
await expect(disableButton).toBeHidden();
await expect(enableButton).toBeHidden();
await menuOpener.click();
// The current "Enabled" state is what previous tests left, but this test is built to not care
if (await disableButton.isVisible()) {
// Assert elemeents on page
await expect(enableButton).toBeHidden();
await expect(disabledLabel).toBeHidden();
// Flip the state
await disableButton.click();
await flashBanner.waitFor();
await menuOpener.click();
// Assert elemeents on page
await expect(enableButton).toBeVisible();
await expect(disableButton).toBeHidden();
await expect(disabledLabel).toBeVisible();
} else {
// Assert elemeents on page
await expect(enableButton).toBeVisible();
await expect(disabledLabel).toBeVisible();
// Flip the state
await enableButton.click();
await flashBanner.waitFor();
await menuOpener.click();
// Assert elemeents on page
await expect(enableButton).toBeHidden();
await expect(disableButton).toBeVisible();
await expect(disabledLabel).toBeHidden();
}
});
});
test('workflow dispatch box not available for unauthenticated users', async ({page}) => {

View file

@ -5,6 +5,7 @@
// templates/shared/user/actions_menu.tmpl
// templates/org/header.tmpl
// templates/explore/search.tmpl
// templates/devtest/dropdown.tmpl
// web_src/js/modules/dropdown.ts
// @watch end
@ -226,4 +227,33 @@ test.describe(`Visual properties`, () => {
expect(await activeItem.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe('rgb(226, 226, 229)');
expect(await inactiveItem.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe('rgba(0, 0, 0, 0)');
});
test('Devtest', async ({browser}) => {
const context = await browser.newContext({javaScriptEnabled: false});
const page = await context.newPage();
// `/devtest` has dropdowns with various combinations of items
await page.goto('/devtest/dropdown');
// Dropdown with just 3 items and nothing special
await page.locator(`#dropdown-1 > summary`).click();
expect(await page.locator(`#dd1_g1_i1`).evaluate((el) => getComputedStyle(el).borderRadius)).toBe('4px 4px 0px 0px');
expect(await page.locator(`#dd1_g1_i2`).evaluate((el) => getComputedStyle(el).borderRadius)).toBe('0px');
expect(await page.locator(`#dd1_g1_i3`).evaluate((el) => getComputedStyle(el).borderRadius)).toBe('0px 0px 4px 4px');
await page.keyboard.press('Enter'); // Exit dropdown - page is in noJS mode
// Dropdown with two groups of items separated with an <hr>
await page.locator(`#dropdown-2 > summary`).click();
expect(await page.locator(`#dd2_g1_i1`).evaluate((el) => getComputedStyle(el).borderRadius)).toBe('4px 4px 0px 0px');
expect(await page.locator(`#dd2_g1_i2`).evaluate((el) => getComputedStyle(el).borderRadius)).toBe('0px');
expect(await page.locator(`#dd2_g1_i3`).evaluate((el) => getComputedStyle(el).borderRadius)).toBe('0px');
expect(await page.locator(`#dd2_g2_i1`).evaluate((el) => getComputedStyle(el).borderRadius)).toBe('0px');
expect(await page.locator(`#dd2_g2_i2`).evaluate((el) => getComputedStyle(el).borderRadius)).toBe('0px');
expect(await page.locator(`#dd2_g2_i3`).evaluate((el) => getComputedStyle(el).borderRadius)).toBe('0px 0px 4px 4px');
await page.keyboard.press('Enter'); // Exit dropdown - page is in noJS mode
// Dropdown with only one item, which should be completely round
await page.locator(`#dropdown-3 > summary`).click();
expect(await page.locator(`#dd3_g1_i1`).evaluate((el) => getComputedStyle(el).borderRadius)).toBe('4px');
});
});

View file

@ -107,7 +107,7 @@ func TestE2e(t *testing.T) {
defer test.MockVariableValue(&setting.Quota.Enabled, true)()
defer test.MockVariableValue(&testE2eWebRoutes, routers.NormalRoutes())()
}
if testname == "buttons.test.e2e" {
if testname == "buttons.test.e2e" || testname == "dropdown.test.e2e" {
defer test.MockVariableValue(&setting.IsProd, false)()
defer test.MockVariableValue(&testE2eWebRoutes, routers.NormalRoutes())()
}

View file

@ -107,6 +107,9 @@ details.dropdown > .content > ul {
&:last-of-type > li:last-child {
border-radius: 0 0 var(--border-radius) var(--border-radius);
}
&:only-of-type > li:only-child {
border-radius: var(--border-radius);
}
}
/* General styling of list items */