diff --git a/frontend/__tests__/components/GGenericInputFields.spec.js b/frontend/__tests__/components/GGenericInputFields.spec.js
new file mode 100644
index 0000000000..64e46c24c6
--- /dev/null
+++ b/frontend/__tests__/components/GGenericInputFields.spec.js
@@ -0,0 +1,587 @@
+//
+// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+
+import { nextTick } from 'vue'
+import { shallowMount } from '@vue/test-utils'
+
+import GGenericInputField from '@/components/GGenericInputField'
+import GGenericInputFields from '@/components/GGenericInputFields'
+
+import { useLogger } from '@/composables/useLogger'
+
+const TextFieldStub = {
+ name: 'VTextField',
+ props: {
+ modelValue: {
+ type: [String, Object, Array, Number, Boolean],
+ },
+ type: {
+ type: String,
+ },
+ autocomplete: {
+ type: String,
+ },
+ appendIcon: {
+ type: String,
+ },
+ errorMessages: {
+ type: Array,
+ default: () => [],
+ },
+ },
+ emits: [
+ 'update:modelValue',
+ 'blur',
+ 'click:append',
+ ],
+ template: `
+
+ `,
+}
+
+const SelectStub = {
+ name: 'VSelect',
+ props: {
+ modelValue: {
+ type: [String, Object, Array, Number, Boolean],
+ },
+ items: {
+ type: Array,
+ },
+ multiple: {
+ type: Boolean,
+ },
+ errorMessages: {
+ type: Array,
+ default: () => [],
+ },
+ },
+ emits: [
+ 'update:modelValue',
+ 'blur',
+ ],
+ template: '',
+}
+
+const TextareaStub = {
+ name: 'VTextarea',
+ props: {
+ modelValue: {
+ type: [String, Object, Array, Number, Boolean],
+ },
+ autocomplete: {
+ type: String,
+ },
+ appendIcon: {
+ type: String,
+ },
+ errorMessages: {
+ type: Array,
+ default: () => [],
+ },
+ },
+ emits: [
+ 'update:modelValue',
+ 'blur',
+ 'click:append',
+ ],
+ template: `
+
+ `,
+}
+
+function lastEmittedValue (wrapper) {
+ return wrapper.emitted('update:modelValue').at(-1)[0]
+}
+
+describe('GGenericInputFields', () => {
+ function mountInputFields (props) {
+ return shallowMount(GGenericInputFields, {
+ props,
+ global: {
+ stubs: {
+ GGenericInputField: true,
+ },
+ },
+ })
+ }
+
+ it('initializes field data with cloned configured defaults', async () => {
+ const defaultValue = ['one']
+ const wrapper = mountInputFields({
+ fields: [
+ {
+ key: 'regions',
+ label: 'Regions',
+ type: 'select-multiple',
+ defaultValue,
+ },
+ ],
+ modelValue: {},
+ })
+
+ await nextTick()
+
+ const emittedValue = lastEmittedValue(wrapper)
+ expect(emittedValue).toEqual({
+ regions: ['one'],
+ })
+
+ emittedValue.regions.push('two')
+ expect(defaultValue).toEqual(['one'])
+ })
+
+ it('keeps existing field data over configured defaults without a redundant emit', async () => {
+ const wrapper = mountInputFields({
+ fields: [
+ {
+ key: 'algorithm',
+ label: 'Algorithm',
+ type: 'select',
+ defaultValue: 'default',
+ },
+ ],
+ modelValue: {
+ algorithm: 'existing',
+ },
+ })
+
+ await nextTick()
+
+ expect(wrapper.findComponent(GGenericInputField).props('modelValue')).toBe('existing')
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
+ })
+
+ it('updates the complete field data when a child value changes', async () => {
+ const wrapper = mountInputFields({
+ fields: [
+ {
+ key: 'username',
+ label: 'Username',
+ type: 'text',
+ },
+ {
+ key: 'password',
+ label: 'Password',
+ type: 'password',
+ },
+ ],
+ modelValue: {
+ username: 'user',
+ password: 'old',
+ },
+ })
+
+ wrapper.findAllComponents(GGenericInputField)[1].vm.$emit('update:modelValue', 'new')
+ await nextTick()
+
+ expect(lastEmittedValue(wrapper)).toEqual({
+ username: 'user',
+ password: 'new',
+ })
+ })
+
+ it('does not emit for empty field data without defaults', async () => {
+ const wrapper = mountInputFields({
+ fields: [
+ {
+ key: 'accessKeyID',
+ label: 'Access Key ID',
+ type: 'text',
+ },
+ ],
+ modelValue: {},
+ })
+
+ await nextTick()
+
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
+ })
+})
+
+describe('GGenericInputField', () => {
+ const originalFileReader = globalThis.FileReader
+ let warnSpy
+
+ beforeEach(() => {
+ warnSpy = vi.spyOn(useLogger(), 'warn').mockImplementation(() => {})
+ })
+
+ afterEach(() => {
+ warnSpy.mockRestore()
+ vi.stubGlobal('FileReader', originalFileReader)
+ })
+
+ function mountInputField ({
+ field,
+ modelValue = '',
+ }) {
+ return shallowMount(GGenericInputField, {
+ props: {
+ field,
+ modelValue,
+ },
+ global: {
+ stubs: {
+ VSelect: SelectStub,
+ VTextarea: TextareaStub,
+ VTextField: TextFieldStub,
+ },
+ },
+ })
+ }
+
+ function mountStructuredInputField ({
+ field = {
+ key: 'secret',
+ label: 'Secret',
+ type: 'json-secret',
+ validators: {},
+ },
+ modelValue = '',
+ } = {}) {
+ return mountInputField({
+ field,
+ modelValue,
+ })
+ }
+
+ function createDropEvent (files) {
+ const event = new Event('drop', {
+ bubbles: true,
+ cancelable: true,
+ })
+ Object.defineProperty(event, 'dataTransfer', {
+ value: { files },
+ })
+ return event
+ }
+
+ it('supports array values for multi-select fields', async () => {
+ const wrapper = mountInputField({
+ field: {
+ key: 'regions',
+ label: 'Regions',
+ type: 'select-multiple',
+ values: ['one', 'two'],
+ },
+ modelValue: ['one'],
+ })
+ const select = wrapper.findComponent(SelectStub)
+
+ expect(select.props()).toMatchObject({
+ items: ['one', 'two'],
+ modelValue: ['one'],
+ multiple: true,
+ })
+
+ select.vm.$emit('update:modelValue', ['one', 'two'])
+ await nextTick()
+
+ expect(lastEmittedValue(wrapper)).toEqual(['one', 'two'])
+ })
+
+ it('hides password fields by default and toggles their visibility', async () => {
+ const wrapper = mountInputField({
+ field: {
+ key: 'password',
+ label: 'Password',
+ type: 'password',
+ },
+ })
+ const input = wrapper.findComponent(TextFieldStub)
+
+ expect(input.props()).toMatchObject({
+ appendIcon: 'mdi-eye',
+ autocomplete: 'off',
+ type: 'password',
+ })
+
+ input.vm.$emit('click:append')
+ await nextTick()
+
+ expect(input.props()).toMatchObject({
+ appendIcon: 'mdi-eye-off',
+ type: 'text',
+ })
+ })
+
+ it('parses structured input and reacts to later parent resets', async () => {
+ const wrapper = mountStructuredInputField({
+ field: {
+ key: 'secret',
+ label: 'Secret',
+ type: 'yaml-secret',
+ validators: {},
+ },
+ modelValue: {
+ existing: 'value',
+ },
+ })
+
+ expect(wrapper.find('textarea').element.value).toBe('existing: value\n')
+
+ await wrapper.find('textarea').setValue('edited: value\n')
+ expect(lastEmittedValue(wrapper)).toEqual({
+ edited: 'value',
+ })
+
+ await wrapper.setProps({
+ modelValue: {
+ reset: 'value',
+ },
+ })
+ await nextTick()
+
+ expect(wrapper.find('textarea').element.value).toBe('reset: value\n')
+ })
+
+ it('rejects YAML arrays as Secret data', async () => {
+ const wrapper = mountStructuredInputField({
+ field: {
+ key: 'secretData',
+ label: 'Secret Data',
+ type: 'yaml-secret',
+ validators: {
+ isYAML: {
+ type: 'isValidObject',
+ },
+ },
+ },
+ })
+
+ await wrapper.find('textarea').setValue('- item\n')
+ await wrapper.find('textarea').trigger('blur')
+ await nextTick()
+
+ expect(wrapper.findComponent(TextareaStub).props('errorMessages')).toEqual([
+ 'You need to enter secret data as YAML key-value pairs',
+ ])
+ })
+
+ it.each([
+ {
+ name: 'regex',
+ validator: {
+ type: 'regex',
+ pattern: '^valid$',
+ },
+ value: 'invalid',
+ },
+ {
+ name: 'GUID',
+ validator: {
+ type: 'guid',
+ },
+ value: 'invalid',
+ },
+ {
+ name: 'alphanumeric underscore',
+ validator: {
+ type: 'alphaNumUnderscore',
+ },
+ value: 'invalid-value',
+ },
+ {
+ name: 'base64',
+ validator: {
+ type: 'base64',
+ },
+ value: 'invalid%',
+ },
+ {
+ name: 'URL',
+ validator: {
+ type: 'url',
+ },
+ value: 'invalid',
+ },
+ {
+ name: 'maximum length',
+ validator: {
+ type: 'maxLength',
+ length: 2,
+ },
+ value: 'long',
+ },
+ {
+ name: 'minimum length',
+ validator: {
+ type: 'minLength',
+ length: 3,
+ },
+ value: 'x',
+ },
+ ])('compiles the $name validator', async ({ validator, value }) => {
+ const wrapper = mountInputField({
+ field: {
+ key: 'value',
+ label: 'Value',
+ type: 'text',
+ validators: {
+ validation: validator,
+ },
+ },
+ })
+
+ const input = wrapper.find('input')
+ await input.setValue(value)
+ await wrapper.setProps({ modelValue: value })
+ await input.trigger('blur')
+ await nextTick()
+
+ expect(wrapper.findComponent(TextFieldStub).props('errorMessages')).toHaveLength(1)
+ })
+
+ it.each([
+ ['flag', false],
+ ['count', 0],
+ ['value', ''],
+ ])('accepts an explicitly expected %s object property', async (key, expectedValue) => {
+ const wrapper = mountStructuredInputField({
+ field: {
+ key: 'secret',
+ label: 'Secret',
+ type: 'json-secret',
+ validators: {
+ property: {
+ type: 'hasObjectProp',
+ key: [key],
+ value: expectedValue,
+ },
+ },
+ },
+ modelValue: {
+ [key]: expectedValue,
+ },
+ })
+
+ await wrapper.find('textarea').trigger('blur')
+ await nextTick()
+
+ expect(wrapper.findComponent(TextareaStub).props('errorMessages')).toEqual([])
+ })
+
+ it('supports nested array paths for object-property validation', async () => {
+ const wrapper = mountStructuredInputField({
+ field: {
+ key: 'secret',
+ label: 'Secret',
+ type: 'json-secret',
+ validators: {
+ property: {
+ type: 'hasObjectProp',
+ key: ['credentials', 'type'],
+ value: 'service_account',
+ },
+ },
+ },
+ modelValue: {
+ credentials: {
+ type: 'service_account',
+ },
+ },
+ })
+
+ await wrapper.find('textarea').trigger('blur')
+ await nextTick()
+
+ expect(wrapper.findComponent(TextareaStub).props('errorMessages')).toEqual([])
+ })
+
+ it('does not mutate validator configuration when deriving messages', async () => {
+ const field = {
+ key: 'secret',
+ label: 'Secret',
+ type: 'json-secret',
+ validators: {
+ validObject: {
+ type: 'isValidObject',
+ },
+ projectID: {
+ type: 'hasObjectProp',
+ key: 'project_id',
+ pattern: /^[a-z][a-z0-9-]+$/,
+ },
+ },
+ }
+
+ mountStructuredInputField({ field })
+ await nextTick()
+
+ expect(field.validators.validObject).not.toHaveProperty('message')
+ expect(field.validators.projectID).not.toHaveProperty('message')
+ })
+
+ it('warns for unsupported validator types', async () => {
+ mountInputField({
+ field: {
+ key: 'foo',
+ label: 'Foo',
+ type: 'text',
+ validators: {
+ unsupported: {
+ type: 'doesNotExist',
+ },
+ },
+ },
+ })
+
+ await nextTick()
+
+ expect(warnSpy).toHaveBeenCalledWith('Ignoring unsupported validator type \'doesNotExist\' for field \'foo\'')
+ })
+
+ it('imports a JSON file by extension when its MIME type is missing', async () => {
+ vi.stubGlobal('FileReader', class {
+ readAsText () {
+ this.onload({
+ target: {
+ result: '{"foo":"bar"}',
+ },
+ })
+ }
+ })
+ const wrapper = mountStructuredInputField()
+
+ wrapper.find('textarea').element.dispatchEvent(createDropEvent([{
+ name: 'secret.json',
+ type: '',
+ }]))
+ await nextTick()
+
+ expect(lastEmittedValue(wrapper)).toEqual({
+ foo: 'bar',
+ })
+ })
+
+ it('shows and clears file-drop rejection errors', async () => {
+ const wrapper = mountStructuredInputField()
+ const textarea = wrapper.find('textarea')
+
+ textarea.element.dispatchEvent(createDropEvent([{
+ name: 'secret.txt',
+ type: 'text/plain',
+ }]))
+ await nextTick()
+
+ expect(wrapper.findComponent(TextareaStub).props('errorMessages')).toEqual([
+ 'File "secret.txt" was rejected. Expected a JSON file (.json), but received text/plain.',
+ ])
+
+ await textarea.setValue('{"foo":"bar"}')
+
+ expect(wrapper.findComponent(TextareaStub).props('errorMessages')).toEqual([])
+ })
+})
diff --git a/frontend/__tests__/components/GSecretDialogGeneric.spec.js b/frontend/__tests__/components/GSecretDialogGeneric.spec.js
new file mode 100644
index 0000000000..9a2a1c6006
--- /dev/null
+++ b/frontend/__tests__/components/GSecretDialogGeneric.spec.js
@@ -0,0 +1,151 @@
+//
+// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+
+import {
+ defineComponent,
+ nextTick,
+ onMounted,
+} from 'vue'
+import { mount } from '@vue/test-utils'
+import { createTestingPinia } from '@pinia/testing'
+import { load as yamlLoad } from 'js-yaml'
+
+import GSecretDialogGeneric from '@/components/Credentials/GSecretDialogGeneric'
+
+import { useSecretContext } from '@/composables/credential/useSecretContext'
+
+import { encodeBase64 } from '@/utils'
+
+const TextareaStub = {
+ name: 'VTextarea',
+ props: {
+ modelValue: {
+ type: [String, Object],
+ },
+ },
+ emits: [
+ 'update:modelValue',
+ 'blur',
+ 'click:append',
+ ],
+ template: `
+
+ `,
+}
+
+describe('GSecretDialogGeneric', () => {
+ let secretContext
+ let getSecretValidations
+
+ function mountDialog ({ credential } = {}) {
+ const SecretDialogStub = defineComponent({
+ name: 'GSecretDialog',
+ props: {
+ credential: {
+ type: Object,
+ },
+ secretValidations: {
+ type: Object,
+ required: true,
+ },
+ },
+ setup (props) {
+ secretContext = useSecretContext()
+ getSecretValidations = () => props.secretValidations
+ onMounted(() => {
+ if (props.credential) {
+ secretContext.setSecretManifest(props.credential)
+ } else {
+ secretContext.createSecretManifest()
+ }
+ })
+ },
+ template: '
',
+ })
+
+ return mount(GSecretDialogGeneric, {
+ props: {
+ credential,
+ modelValue: true,
+ providerType: 'generic-provider',
+ vendorType: 'dns',
+ },
+ global: {
+ plugins: [
+ createTestingPinia(),
+ ],
+ stubs: {
+ GSecretDialog: SecretDialogStub,
+ VTextarea: TextareaStub,
+ },
+ },
+ })
+ }
+
+ it('creates the same top-level Secret data from a YAML mapping', async () => {
+ const wrapper = mountDialog()
+ await nextTick()
+
+ expect(getSecretValidations().$invalid).toBe(true)
+
+ await wrapper.find('textarea').setValue([
+ 'serviceaccount.json: credentials',
+ 'project: my-project',
+ '',
+ ].join('\n'))
+
+ expect(secretContext.secretManifest.value.data).toEqual({
+ 'serviceaccount.json': encodeBase64('credentials'),
+ project: encodeBase64('my-project'),
+ })
+ expect(getSecretValidations().$invalid).toBe(false)
+ })
+
+ it('loads and updates all values when editing an existing Secret', async () => {
+ const credential = {
+ apiVersion: 'v1',
+ kind: 'Secret',
+ metadata: {
+ name: 'existing-secret',
+ namespace: 'garden-project',
+ },
+ type: 'Opaque',
+ data: {
+ endpoint: encodeBase64('https://example.org'),
+ token: encodeBase64('old-token'),
+ },
+ }
+ const wrapper = mountDialog({ credential })
+ await nextTick()
+
+ expect(yamlLoad(wrapper.find('textarea').element.value)).toEqual({
+ endpoint: 'https://example.org',
+ token: 'old-token',
+ })
+ expect(secretContext.secretManifest.value.data).toEqual(credential.data)
+
+ await wrapper.find('textarea').setValue([
+ 'endpoint: https://example.net',
+ 'token: new-token',
+ '',
+ ].join('\n'))
+
+ expect(secretContext.secretManifest.value).toMatchObject({
+ apiVersion: 'v1',
+ kind: 'Secret',
+ metadata: credential.metadata,
+ type: 'Opaque',
+ data: {
+ endpoint: encodeBase64('https://example.net'),
+ token: encodeBase64('new-token'),
+ },
+ })
+ })
+})
diff --git a/frontend/__tests__/composables/useStructuredTextField.spec.js b/frontend/__tests__/composables/useStructuredTextField.spec.js
new file mode 100644
index 0000000000..275eec5c01
--- /dev/null
+++ b/frontend/__tests__/composables/useStructuredTextField.spec.js
@@ -0,0 +1,77 @@
+//
+// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+
+import { computed } from 'vue'
+
+import { useStructuredTextField } from '@/composables/useStructuredTextField'
+
+describe('useStructuredTextField', () => {
+ it.each([
+ {
+ type: 'json-secret',
+ text: '{\n "enabled": true\n}',
+ value: { enabled: true },
+ },
+ {
+ type: 'yaml-secret',
+ text: 'enabled: true\n',
+ value: { enabled: true },
+ },
+ ])('serializes and parses $type objects', ({ type, text, value }) => {
+ const {
+ rawText,
+ setRawTextWithValue,
+ parseRawTextToObject,
+ } = useStructuredTextField(computed(() => type))
+
+ setRawTextWithValue(value)
+
+ expect(rawText.value).toBe(text)
+ expect(parseRawTextToObject()).toEqual(value)
+ })
+
+ it('keeps invalid structured strings so users can correct them', () => {
+ const {
+ rawText,
+ setRawTextWithValue,
+ parseRawTextToObject,
+ } = useStructuredTextField(computed(() => 'json-secret'))
+
+ setRawTextWithValue('{"broken":')
+
+ expect(rawText.value).toBe('{"broken":')
+ expect(parseRawTextToObject()).toBeUndefined()
+ })
+
+ it.each([
+ ['array', '- item\n'],
+ ['scalar', 'value\n'],
+ ])('rejects a YAML %s root', (description, value) => {
+ const {
+ rawText,
+ parseRawTextToObject,
+ } = useStructuredTextField(computed(() => 'yaml-secret'))
+
+ rawText.value = value
+
+ expect(parseRawTextToObject()).toBeUndefined()
+ })
+
+ it('normalizes empty values to empty text', () => {
+ const {
+ rawText,
+ setRawTextWithValue,
+ parseRawTextToObject,
+ } = useStructuredTextField(computed(() => 'yaml'))
+
+ setRawTextWithValue({})
+ expect(rawText.value).toBe('')
+ expect(parseRawTextToObject()).toBeNull()
+
+ setRawTextWithValue(null)
+ expect(rawText.value).toBe('')
+ })
+})
diff --git a/frontend/__tests__/utils/index.spec.js b/frontend/__tests__/utils/index.spec.js
index 2bc71d35db..941a6c7781 100644
--- a/frontend/__tests__/utils/index.spec.js
+++ b/frontend/__tests__/utils/index.spec.js
@@ -21,12 +21,173 @@ import {
isEmail,
convertToGi,
convertToGibibyte,
+ handleTextFieldDrop,
} from '@/utils'
import pick from 'lodash/pick'
import find from 'lodash/find'
describe('utils', () => {
+ describe('#handleTextFieldDrop', () => {
+ const originalFileReader = globalThis.FileReader
+
+ afterEach(() => {
+ vi.stubGlobal('FileReader', originalFileReader)
+ })
+
+ function createTextField () {
+ const element = document.createElement('div')
+ document.body.appendChild(element)
+ return { $el: element }
+ }
+
+ function createDropEvent (files) {
+ const event = new Event('drop', {
+ bubbles: true,
+ cancelable: true,
+ })
+ Object.defineProperty(event, 'dataTransfer', {
+ value: { files },
+ })
+ return event
+ }
+
+ it('accepts files by configured extension if the MIME type is missing', () => {
+ vi.stubGlobal('FileReader', class {
+ readAsText () {
+ this.onload({
+ target: {
+ result: '{"foo":"bar"}',
+ },
+ })
+ }
+ })
+
+ const onDrop = vi.fn()
+ const onReject = vi.fn()
+ const textField = createTextField()
+ const dispose = handleTextFieldDrop(textField, /json/, onDrop, {
+ acceptedFileDescription: 'a JSON file (.json)',
+ acceptedFileExtensions: ['json'],
+ onReject,
+ })
+
+ textField.$el.dispatchEvent(createDropEvent([{
+ name: 'secret.JSON',
+ type: '',
+ }]))
+
+ expect(onDrop).toHaveBeenCalledWith('{"foo":"bar"}')
+ expect(onReject).not.toHaveBeenCalled()
+
+ dispose()
+ textField.$el.remove()
+ })
+
+ it('rejects missing and multiple files', () => {
+ const onDrop = vi.fn()
+ const onReject = vi.fn()
+ const textField = createTextField()
+ const dispose = handleTextFieldDrop(textField, /json/, onDrop, {
+ acceptedFileDescription: 'a JSON file (.json)',
+ onReject,
+ })
+
+ textField.$el.dispatchEvent(createDropEvent([]))
+ textField.$el.dispatchEvent(createDropEvent([
+ { name: 'one.json', type: 'application/json' },
+ { name: 'two.json', type: 'application/json' },
+ ]))
+
+ expect(onDrop).not.toHaveBeenCalled()
+ expect(onReject).toHaveBeenNthCalledWith(1, {
+ code: 'missing-file',
+ file: undefined,
+ message: 'Drop a JSON file (.json) to import its contents.',
+ })
+ expect(onReject).toHaveBeenNthCalledWith(2, {
+ code: 'too-many-files',
+ file: undefined,
+ message: 'Drop only one file at a time. Expected a JSON file (.json).',
+ })
+
+ dispose()
+ textField.$el.remove()
+ })
+
+ it('rejects unsupported file types', () => {
+ const onDrop = vi.fn()
+ const onReject = vi.fn()
+ const textField = createTextField()
+ const file = {
+ name: 'secret.txt',
+ type: 'text/plain',
+ }
+ const dispose = handleTextFieldDrop(textField, /json/, onDrop, {
+ acceptedFileDescription: 'a JSON file (.json)',
+ acceptedFileExtensions: ['.json'],
+ onReject,
+ })
+
+ textField.$el.dispatchEvent(createDropEvent([file]))
+
+ expect(onDrop).not.toHaveBeenCalled()
+ expect(onReject).toHaveBeenCalledWith({
+ code: 'unsupported-file-type',
+ file,
+ message: 'File "secret.txt" was rejected. Expected a JSON file (.json), but received text/plain.',
+ })
+
+ dispose()
+ textField.$el.remove()
+ })
+
+ it('reports file read errors', () => {
+ vi.stubGlobal('FileReader', class {
+ readAsText () {
+ this.onerror()
+ }
+ })
+
+ const onDrop = vi.fn()
+ const onReject = vi.fn()
+ const textField = createTextField()
+ const file = {
+ name: 'secret.json',
+ type: 'application/json',
+ }
+ const dispose = handleTextFieldDrop(textField, /json/, onDrop, {
+ acceptedFileDescription: 'a JSON file (.json)',
+ onReject,
+ })
+
+ textField.$el.dispatchEvent(createDropEvent([file]))
+
+ expect(onDrop).not.toHaveBeenCalled()
+ expect(onReject).toHaveBeenCalledWith({
+ code: 'read-error',
+ file,
+ message: 'Could not read file "secret.json".',
+ })
+
+ dispose()
+ textField.$el.remove()
+ })
+
+ it('removes event listeners when disposed', () => {
+ const textField = createTextField()
+ const removeEventListenerSpy = vi.spyOn(textField.$el, 'removeEventListener')
+ const dispose = handleTextFieldDrop(textField, /json/)
+
+ dispose()
+
+ expect(removeEventListenerSpy).toHaveBeenCalledWith('dragover', expect.any(Function), false)
+ expect(removeEventListenerSpy).toHaveBeenCalledWith('drop', expect.any(Function), false)
+
+ textField.$el.remove()
+ })
+ })
+
describe('authorization', () => {
describe('#canI', () => {
let rulesReview
diff --git a/frontend/__tests__/utils/inputFieldTypes.spec.js b/frontend/__tests__/utils/inputFieldTypes.spec.js
new file mode 100644
index 0000000000..02d3f38f1a
--- /dev/null
+++ b/frontend/__tests__/utils/inputFieldTypes.spec.js
@@ -0,0 +1,50 @@
+//
+// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+
+import {
+ isJsonFieldType,
+ isSecretFieldType,
+ isStructuredFieldType,
+ isStructuredSecretFieldType,
+ isYamlFieldType,
+ structuredFieldTypes,
+} from '@/utils/inputFieldTypes'
+
+describe('inputFieldTypes', () => {
+ it('classifies structured field types', () => {
+ expect(structuredFieldTypes).toEqual(new Set([
+ 'json',
+ 'json-secret',
+ 'yaml',
+ 'yaml-secret',
+ ]))
+
+ expect(isStructuredFieldType('json')).toBe(true)
+ expect(isStructuredFieldType('json-secret')).toBe(true)
+ expect(isStructuredFieldType('yaml')).toBe(true)
+ expect(isStructuredFieldType('yaml-secret')).toBe(true)
+ expect(isStructuredFieldType('text')).toBe(false)
+ })
+
+ it('classifies format and secret variants', () => {
+ expect(isJsonFieldType('json')).toBe(true)
+ expect(isJsonFieldType('json-secret')).toBe(true)
+ expect(isJsonFieldType('yaml')).toBe(false)
+
+ expect(isYamlFieldType('yaml')).toBe(true)
+ expect(isYamlFieldType('yaml-secret')).toBe(true)
+ expect(isYamlFieldType('json')).toBe(false)
+
+ expect(isStructuredSecretFieldType('json-secret')).toBe(true)
+ expect(isStructuredSecretFieldType('yaml-secret')).toBe(true)
+ expect(isStructuredSecretFieldType('password')).toBe(false)
+
+ expect(isSecretFieldType('password')).toBe(true)
+ expect(isSecretFieldType('json-secret')).toBe(true)
+ expect(isSecretFieldType('yaml-secret')).toBe(true)
+ expect(isSecretFieldType('json')).toBe(false)
+ })
+})
diff --git a/frontend/src/components/Credentials/GSecretDialogGeneric.vue b/frontend/src/components/Credentials/GSecretDialogGeneric.vue
index 8d5104aa57..7e32916905 100644
--- a/frontend/src/components/Credentials/GSecretDialogGeneric.vue
+++ b/frontend/src/components/Credentials/GSecretDialogGeneric.vue
@@ -9,23 +9,15 @@ SPDX-License-Identifier: Apache-2.0
v-model="visible"
:secret-validations="v$"
:binding="binding"
+ :credential="credential"
:provider-type="providerType"
:vendor-type="vendorType"
>
-
- (hideSecret = !hideSecret)"
- @blur="v$.data.$touch()"
- />
-
+
@@ -43,29 +35,18 @@ SPDX-License-Identifier: Apache-2.0
diff --git a/frontend/src/components/GGenericInputField.vue b/frontend/src/components/GGenericInputField.vue
new file mode 100644
index 0000000000..fdbb8ef865
--- /dev/null
+++ b/frontend/src/components/GGenericInputField.vue
@@ -0,0 +1,404 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/GGenericInputFields.vue b/frontend/src/components/GGenericInputFields.vue
new file mode 100644
index 0000000000..36304e030e
--- /dev/null
+++ b/frontend/src/components/GGenericInputFields.vue
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/composables/useStructuredTextField.js b/frontend/src/composables/useStructuredTextField.js
new file mode 100644
index 0000000000..4260032b55
--- /dev/null
+++ b/frontend/src/composables/useStructuredTextField.js
@@ -0,0 +1,73 @@
+//
+// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+
+import {
+ computed,
+ ref,
+} from 'vue'
+import {
+ dump as yamlDump,
+ load as yamlLoad,
+} from 'js-yaml'
+
+import {
+ isJsonFieldType,
+ isYamlFieldType,
+} from '@/utils/inputFieldTypes'
+
+export function useStructuredTextField (typeRef) {
+ const rawText = ref('')
+
+ const isYaml = computed(() => isYamlFieldType(typeRef.value))
+ const isJson = computed(() => isJsonFieldType(typeRef.value))
+
+ function setRawTextWithValue (value) {
+ try {
+ if (!value || (typeof value === 'object' && Object.keys(value).length === 0)) {
+ rawText.value = ''
+ } else if (typeof value === 'string') {
+ rawText.value = value
+ } else if (isYaml.value) {
+ rawText.value = yamlDump(value)
+ } else if (isJson.value) {
+ rawText.value = JSON.stringify(value, null, 2)
+ } else {
+ rawText.value = ''
+ }
+ } catch {
+ rawText.value = ''
+ }
+ }
+
+ function parseRawTextToObject () {
+ if (!rawText.value) {
+ return null
+ }
+
+ let parsed
+ try {
+ if (isYaml.value) {
+ parsed = yamlLoad(rawText.value)
+ } else if (isJson.value) {
+ parsed = JSON.parse(rawText.value)
+ }
+ } catch {
+ return undefined
+ }
+
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ return undefined
+ }
+
+ return parsed
+ }
+
+ return {
+ rawText,
+ setRawTextWithValue,
+ parseRawTextToObject,
+ }
+}
diff --git a/frontend/src/utils/index.js b/frontend/src/utils/index.js
index 97fff46453..1619eb3770 100644
--- a/frontend/src/utils/index.js
+++ b/frontend/src/utils/index.js
@@ -52,26 +52,89 @@ export function emailToDisplayName (value) {
}
}
-export function handleTextFieldDrop (textField, fileTypePattern, onDrop = () => {}) {
+function fileExtension (file) {
+ const name = file?.name ?? ''
+ const extensionIndex = name.lastIndexOf('.')
+ if (extensionIndex === -1) {
+ return ''
+ }
+ return name.slice(extensionIndex).toLowerCase()
+}
+
+function normalizedFileExtensions (extensions = []) {
+ return extensions.map(extension => {
+ const normalizedExtension = extension.toLowerCase()
+ return normalizedExtension.startsWith('.')
+ ? normalizedExtension
+ : `.${normalizedExtension}`
+ })
+}
+
+function fileTypeDescription (file) {
+ return file.type || fileExtension(file) || 'unknown file type'
+}
+
+export function handleTextFieldDrop (textField, fileTypePattern, onDrop = () => {}, {
+ acceptedFileDescription = 'a supported file',
+ acceptedFileExtensions = [],
+ onReject = () => {},
+} = {}) {
+ const extensions = normalizedFileExtensions(acceptedFileExtensions)
+
+ function rejectDrop (code, message, file) {
+ onReject({
+ code,
+ file,
+ message,
+ })
+ }
+
+ function isAcceptedFile (file) {
+ fileTypePattern.lastIndex = 0
+ if (file.type && fileTypePattern.test(file.type)) {
+ return true
+ }
+
+ const extension = fileExtension(file)
+ return !!extension && extensions.includes(extension)
+ }
+
function drop (event) {
event.stopPropagation()
event.preventDefault()
- const files = event.dataTransfer.files
- if (files.length) {
- const file = files[0]
- if (fileTypePattern.test(file.type)) {
- const reader = new FileReader()
- const onLoaded = event => {
- try {
- const result = JSON.parse(event.target.result)
-
- onDrop(JSON.stringify(result, null, ' '))
- } catch (err) { /* ignore error */ }
- }
- reader.onloadend = onLoaded
- reader.readAsText(file)
- }
+ const files = event.dataTransfer?.files ?? []
+ if (!files.length) {
+ rejectDrop('missing-file', `Drop ${acceptedFileDescription} to import its contents.`)
+ return
+ }
+
+ if (files.length > 1) {
+ rejectDrop('too-many-files', `Drop only one file at a time. Expected ${acceptedFileDescription}.`)
+ return
+ }
+
+ const file = files[0]
+ if (!isAcceptedFile(file)) {
+ rejectDrop(
+ 'unsupported-file-type',
+ `File "${file.name}" was rejected. Expected ${acceptedFileDescription}, but received ${fileTypeDescription(file)}.`,
+ file,
+ )
+ return
+ }
+
+ const reader = new FileReader()
+ reader.onload = event => {
+ onDrop(event.target.result)
+ }
+ reader.onerror = () => {
+ rejectDrop('read-error', `Could not read file "${file.name}".`, file)
+ }
+ try {
+ reader.readAsText(file)
+ } catch {
+ rejectDrop('read-error', `Could not read file "${file.name}".`, file)
}
}
@@ -81,9 +144,14 @@ export function handleTextFieldDrop (textField, fileTypePattern, onDrop = () =>
event.dataTransfer.dropEffect = 'copy'
}
- const textarea = textField.$refs['input-slot']
- textarea.addEventListener('dragover', dragOver, false)
- textarea.addEventListener('drop', drop, false)
+ const field = textField.$el
+ field.addEventListener('dragover', dragOver, false)
+ field.addEventListener('drop', drop, false)
+
+ return () => {
+ field.removeEventListener('dragover', dragOver, false)
+ field.removeEventListener('drop', drop, false)
+ }
}
export function getErrorMessages (property) {
diff --git a/frontend/src/utils/inputFieldTypes.js b/frontend/src/utils/inputFieldTypes.js
new file mode 100644
index 0000000000..fb8ce8b38e
--- /dev/null
+++ b/frontend/src/utils/inputFieldTypes.js
@@ -0,0 +1,50 @@
+//
+// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+
+export const jsonFieldTypes = new Set([
+ 'json',
+ 'json-secret',
+])
+
+export const yamlFieldTypes = new Set([
+ 'yaml',
+ 'yaml-secret',
+])
+
+export const structuredFieldTypes = new Set([
+ ...jsonFieldTypes,
+ ...yamlFieldTypes,
+])
+
+export const structuredSecretFieldTypes = new Set([
+ 'json-secret',
+ 'yaml-secret',
+])
+
+export const secretFieldTypes = new Set([
+ 'password',
+ ...structuredSecretFieldTypes,
+])
+
+export function isJsonFieldType (type) {
+ return jsonFieldTypes.has(type)
+}
+
+export function isYamlFieldType (type) {
+ return yamlFieldTypes.has(type)
+}
+
+export function isStructuredFieldType (type) {
+ return structuredFieldTypes.has(type)
+}
+
+export function isStructuredSecretFieldType (type) {
+ return structuredSecretFieldTypes.has(type)
+}
+
+export function isSecretFieldType (type) {
+ return secretFieldTypes.has(type)
+}