diff --git a/docs/docs/92-development/03-ui.md b/docs/docs/92-development/03-ui.md
index 8186b63f3..520e157aa 100644
--- a/docs/docs/92-development/03-ui.md
+++ b/docs/docs/92-development/03-ui.md
@@ -41,6 +41,26 @@ The following list contains some tools and frameworks used by the Woodpecker UI.
- [Volar & vue-tsc](https://github.com/johnsoncodehk/volar/) for type-checking in .vue file
- use the take-over mode of Volar as described by [this guide](https://github.com/johnsoncodehk/volar/discussions/471)
+## Form components
+
+Reusable form controls live in `web/src/components/form/`. They are all built to be placed inside an `InputField`, which renders the label, the optional description and a docs link, and passes down the `id` the control has to attach to its input:
+
+```vue
+
+
+
+
+
+ {{ $t('some.description') }}
+
+
+```
+
+Besides the single-value controls (`TextField`, `NumberField`, `Checkbox`, `SelectField`, `RadioField`) there are two editors for collections:
+
+- `ListEditor` for a list of strings, such as plugin images or usernames
+- `KeyValueEditor` for a `Record`, such as environment variables
+
## Messages and Translations
Woodpecker uses [Vue I18n](https://vue-i18n.intlify.dev/) as translation library. New translations have to be added to `web/src/assets/locales/en.json`. The English source file will be automatically imported into [Weblate](https://translate.woodpecker-ci.org/) (the translation system used by Woodpecker) where all other languages will be translated by the community based on the English source.
diff --git a/web/src/assets/locales/en.json b/web/src/assets/locales/en.json
index 192e2cfb0..a2fd5e71d 100644
--- a/web/src/assets/locales/en.json
+++ b/web/src/assets/locales/en.json
@@ -612,6 +612,7 @@
"executable_desc": "Path to the addon executable.",
"save": "Save",
"add": "Add",
+ "delete": "Delete",
"skip_verify": "Skip SSL verification",
"skip_verify_desc": "Skip SSL verification for the API connection. This is not recommended for production use.",
"url": "URL",
diff --git a/web/src/components/form/ListEditor.test.ts b/web/src/components/form/ListEditor.test.ts
new file mode 100644
index 000000000..26ebc282f
--- /dev/null
+++ b/web/src/components/form/ListEditor.test.ts
@@ -0,0 +1,287 @@
+import { mount } from '@vue/test-utils';
+import { describe, expect, it } from 'vitest';
+import { defineComponent, h, nextTick, ref } from 'vue';
+import { createI18n } from 'vue-i18n';
+
+import en from '~/assets/locales/en.json';
+
+import ListEditor from './ListEditor.vue';
+
+// resolve the titles through the real locale file, so renaming a key there
+// fails this test instead of silently rendering the raw key in the app
+const i18n = createI18n({
+ legacy: false,
+ locale: 'en',
+ messages: { en },
+});
+
+const global = { plugins: [i18n] };
+
+async function mountListEditor(initialItems: string[] = [], props: Record = {}) {
+ const items = ref(initialItems);
+
+ const host = defineComponent({
+ setup() {
+ return () =>
+ h(ListEditor, {
+ id: 'images',
+ modelValue: items.value,
+ 'onUpdate:modelValue': (value: string[]) => {
+ items.value = value;
+ },
+ ...props,
+ });
+ },
+ });
+
+ const wrapper = mount(host, { global });
+ await nextTick();
+
+ // the last input is the one used to enter new items, the others are the
+ // disabled inputs rendering the existing entries
+ const inputs = () => wrapper.findAll('input');
+ const newItemInput = () => inputs().at(-1)!;
+ const addButton = () => wrapper.findAll('button').at(-1)!;
+
+ async function addItem(value: string) {
+ await newItemInput().setValue(value);
+ await addButton().trigger('click');
+ await nextTick();
+ }
+
+ return { wrapper, items, inputs, newItemInput, addButton, addItem };
+}
+
+describe('listEditor', () => {
+ it('renders an input per existing item plus one for new entries', async () => {
+ const { inputs } = await mountListEditor(['plugins/git', 'plugins/docker']);
+
+ expect(inputs()).toHaveLength(3);
+ expect(inputs()[0].element.value).toBe('plugins/git');
+ expect(inputs()[1].element.value).toBe('plugins/docker');
+ expect(inputs().at(-1)!.element.value).toBe('');
+ });
+
+ it('renders existing items as disabled so they cannot be edited in place', async () => {
+ const { inputs } = await mountListEditor(['plugins/git']);
+
+ expect(inputs()[0].element.disabled).toBe(true);
+ expect(inputs().at(-1)!.element.disabled).toBe(false);
+ });
+
+ it('emits the new list when an item is added via the button', async () => {
+ const { items, addItem } = await mountListEditor(['plugins/git']);
+
+ await addItem('plugins/docker');
+
+ expect(items.value).toStrictEqual(['plugins/git', 'plugins/docker']);
+ });
+
+ it('adds the item when enter is pressed', async () => {
+ const { items, newItemInput } = await mountListEditor([]);
+
+ await newItemInput().setValue('plugins/git');
+ await newItemInput().trigger('keydown.enter');
+ await nextTick();
+
+ expect(items.value).toStrictEqual(['plugins/git']);
+ });
+
+ it('clears the input after adding an item', async () => {
+ const { newItemInput, addItem } = await mountListEditor([]);
+
+ await addItem('plugins/git');
+
+ expect(newItemInput().element.value).toBe('');
+ });
+
+ it('does not mutate the array it was given', async () => {
+ const initialItems = ['plugins/git'];
+ const { addItem } = await mountListEditor(initialItems);
+
+ await addItem('plugins/docker');
+
+ expect(initialItems).toStrictEqual(['plugins/git']);
+ });
+
+ it('trims surrounding whitespace from added items', async () => {
+ const { items, addItem } = await mountListEditor([]);
+
+ await addItem(' plugins/git ');
+
+ expect(items.value).toStrictEqual(['plugins/git']);
+ });
+
+ it('ignores an empty or whitespace-only input', async () => {
+ const { items, addItem } = await mountListEditor([]);
+
+ await addItem('');
+ expect(items.value).toStrictEqual([]);
+
+ await addItem(' ');
+ expect(items.value).toStrictEqual([]);
+ });
+
+ it('ignores a duplicate and keeps it in the input so the user can see it was not added', async () => {
+ const { items, newItemInput, addItem } = await mountListEditor(['plugins/git']);
+
+ await addItem('plugins/git');
+
+ expect(items.value).toStrictEqual(['plugins/git']);
+ expect(newItemInput().element.value).toBe('plugins/git');
+ });
+
+ it('treats an entry that only differs by whitespace as a duplicate', async () => {
+ const { items, addItem } = await mountListEditor(['plugins/git']);
+
+ await addItem(' plugins/git ');
+
+ expect(items.value).toStrictEqual(['plugins/git']);
+ });
+
+ it('removes only the clicked item', async () => {
+ const { wrapper, items } = await mountListEditor(['plugins/git', 'plugins/docker', 'plugins/s3']);
+
+ // the delete button of the second entry
+ await wrapper.findAll('button')[1].trigger('click');
+ await nextTick();
+
+ expect(items.value).toStrictEqual(['plugins/git', 'plugins/s3']);
+ });
+
+ it('drops the row once its item is removed', async () => {
+ const { wrapper, inputs } = await mountListEditor(['plugins/git']);
+
+ await wrapper.findAll('button')[0].trigger('click');
+ await nextTick();
+
+ expect(inputs()).toHaveLength(1);
+ });
+
+ it('gives every input a unique id so the labels stay unambiguous', async () => {
+ const { inputs } = await mountListEditor(['plugins/git', 'plugins/docker']);
+
+ const ids = inputs().map((input) => input.element.id);
+
+ expect(new Set(ids).size).toBe(ids.length);
+ // the label of the surrounding InputField points at the new-item input
+ expect(inputs().at(-1)!.element.id).toBe('images');
+ });
+
+ it('renders all buttons as type=button so they never submit the surrounding form', async () => {
+ const { wrapper } = await mountListEditor(['plugins/git']);
+
+ const types = wrapper.findAll('button').map((button) => button.element.type);
+
+ expect(types).toStrictEqual(['button', 'button']);
+ });
+
+ it('passes the placeholder to the new-item input only', async () => {
+ const { inputs } = await mountListEditor(['plugins/git'], { placeholder: 'Plugin image' });
+
+ expect(inputs()[0].attributes('placeholder')).toBe('');
+ expect(inputs().at(-1)!.attributes('placeholder')).toBe('Plugin image');
+ });
+
+ it('titles the delete and add buttons', async () => {
+ const { wrapper } = await mountListEditor(['plugins/git']);
+
+ const buttons = wrapper.findAll('button');
+
+ expect(buttons[0].attributes('title')).toBe('Delete');
+ expect(buttons[1].attributes('title')).toBe('Add');
+ });
+
+ it('treats a missing modelValue as an empty list', async () => {
+ const items = ref(undefined);
+
+ const host = defineComponent({
+ setup() {
+ return () =>
+ h(ListEditor, {
+ modelValue: items.value,
+ 'onUpdate:modelValue': (value: string[]) => {
+ items.value = value;
+ },
+ });
+ },
+ });
+
+ const wrapper = mount(host, { global });
+ await nextTick();
+
+ expect(wrapper.findAll('input')).toHaveLength(1);
+
+ await wrapper.find('input').setValue('plugins/git');
+ await wrapper.find('button').trigger('click');
+ await nextTick();
+
+ expect(items.value).toStrictEqual(['plugins/git']);
+ });
+
+ it('commits unconfirmed input when the parent calls commitPendingItem', async () => {
+ const items = ref([]);
+ const editor = ref<{ commitPendingItem: () => void }>();
+
+ const host = defineComponent({
+ setup() {
+ return () =>
+ h(ListEditor, {
+ ref: editor,
+ modelValue: items.value,
+ 'onUpdate:modelValue': (value: string[]) => {
+ items.value = value;
+ },
+ });
+ },
+ });
+
+ const wrapper = mount(host, { global });
+ await nextTick();
+
+ // the user types but never presses enter or the add button
+ await wrapper.find('input').setValue('plugins/git');
+ expect(items.value).toStrictEqual([]);
+
+ editor.value!.commitPendingItem();
+ await nextTick();
+
+ expect(items.value).toStrictEqual(['plugins/git']);
+ });
+
+ it('commitPendingItem is a no-op when the input is empty', async () => {
+ const items = ref(['plugins/git']);
+ const editor = ref<{ commitPendingItem: () => void }>();
+
+ const host = defineComponent({
+ setup() {
+ return () =>
+ h(ListEditor, {
+ ref: editor,
+ modelValue: items.value,
+ 'onUpdate:modelValue': (value: string[]) => {
+ items.value = value;
+ },
+ });
+ },
+ });
+
+ mount(host, { global });
+ await nextTick();
+
+ editor.value!.commitPendingItem();
+ await nextTick();
+
+ expect(items.value).toStrictEqual(['plugins/git']);
+ });
+
+ it('reflects items added from outside the component', async () => {
+ const { items, inputs } = await mountListEditor(['plugins/git']);
+
+ items.value = ['plugins/git', 'plugins/docker'];
+ await nextTick();
+
+ expect(inputs()).toHaveLength(3);
+ expect(inputs()[1].element.value).toBe('plugins/docker');
+ });
+});
diff --git a/web/src/components/form/ListEditor.vue b/web/src/components/form/ListEditor.vue
new file mode 100644
index 000000000..59a9ccd2a
--- /dev/null
+++ b/web/src/components/form/ListEditor.vue
@@ -0,0 +1,61 @@
+
+
+
+
+
diff --git a/web/src/components/secrets/SecretEdit.vue b/web/src/components/secrets/SecretEdit.vue
index 0167e3b27..3eb56ad33 100644
--- a/web/src/components/secrets/SecretEdit.vue
+++ b/web/src/components/secrets/SecretEdit.vue
@@ -24,21 +24,12 @@
{{ $t('secrets.plugins.desc') }}
-
+
@@ -64,7 +55,7 @@