diff --git a/webapp/packages/core-blocks/src/Tags/Tag.tsx b/webapp/packages/core-blocks/src/Tags/Tag.tsx index 23e9401455f..15ab9346807 100644 --- a/webapp/packages/core-blocks/src/Tags/Tag.tsx +++ b/webapp/packages/core-blocks/src/Tags/Tag.tsx @@ -19,7 +19,7 @@ export interface ITag { } interface Props extends ITag { - onRemove: (id: T) => void; + onRemove?: (id: T) => void; className?: string; } @@ -34,11 +34,13 @@ export const Tag = observer(function Tag({ id, label, )}
{label}
-
-
onRemove(id)}> - + {onRemove && ( +
+
onRemove(id)}> + +
-
+ )} ); }); diff --git a/webapp/packages/core-utils/src/index.ts b/webapp/packages/core-utils/src/index.ts index 4eafbcf4d4a..cfdd6140fac 100644 --- a/webapp/packages/core-utils/src/index.ts +++ b/webapp/packages/core-utils/src/index.ts @@ -89,3 +89,4 @@ export * from './bindFunctions.js'; export * from './isNumber.js'; export * from './getSubjectDifferences.js'; export * from './downloadImage.js'; +export * from './submitForm.js'; diff --git a/webapp/packages/core-utils/src/submitForm.test.ts b/webapp/packages/core-utils/src/submitForm.test.ts new file mode 100644 index 00000000000..45932f0bdaa --- /dev/null +++ b/webapp/packages/core-utils/src/submitForm.test.ts @@ -0,0 +1,253 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { afterEach, beforeEach, describe, expect, it, vitest } from 'vitest'; +import { objectToFormFields, submitForm } from './submitForm.js'; + +describe('submitForm', () => { + let submitSpy: ReturnType>; + let capturedForm: HTMLFormElement | null; + + // submit() is the moment when the form is fully populated and still attached to the DOM — + // submitForm removes it right after. We snapshot it from document.body here. + const getForm = (): HTMLFormElement => { + if (!capturedForm) { + throw new Error('submit() was not called'); + } + return capturedForm; + }; + const getInputs = (): HTMLInputElement[] => Array.from(getForm().querySelectorAll('input')); + + beforeEach(() => { + document.body.innerHTML = ''; + capturedForm = null; + submitSpy = vitest.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation(() => { + capturedForm = document.body.querySelector('form'); + }); + }); + + afterEach(() => { + submitSpy.mockRestore(); + }); + + it('should create a form with POST method and target _blank', () => { + submitForm('/api/test', []); + + const form = getForm(); + expect(form.tagName).toBe('FORM'); + expect(form.method.toLowerCase()).toBe('post'); + expect(form.action).toContain('/api/test'); + expect(form.target).toBe('_blank'); + expect(form.style.display).toBe('none'); + }); + + it('should attach the form to document.body before submit', () => { + submitForm('/api/test', []); + + // capturedForm is read inside submit(), so being non-null proves the form was in the DOM at that point + expect(capturedForm).not.toBeNull(); + }); + + it('should append hidden inputs for each field', () => { + submitForm('/api/test', [ + ['name', 'John'], + ['age', '30'], + ]); + + const inputs = getInputs(); + expect(inputs).toHaveLength(2); + expect(inputs[0]!.type).toBe('hidden'); + expect(inputs[0]!.name).toBe('name'); + expect(inputs[0]!.value).toBe('John'); + expect(inputs[1]!.type).toBe('hidden'); + expect(inputs[1]!.name).toBe('age'); + expect(inputs[1]!.value).toBe('30'); + }); + + it('should submit the form and remove it from the DOM', () => { + submitForm('/api/test', [['key', 'value']]); + + expect(submitSpy).toHaveBeenCalledTimes(1); + expect(document.body.querySelector('form')).toBeNull(); + }); + + it('should support repeated keys (e.g. for array fields)', () => { + submitForm('/api/test', [ + ['ids', '1'], + ['ids', '2'], + ['ids', '3'], + ]); + + const ids = getInputs().filter(i => i.name === 'ids'); + expect(ids).toHaveLength(3); + expect(ids.map(i => i.value)).toEqual(['1', '2', '3']); + }); + + it('should accept any iterable of key/value pairs', () => { + const fields = new Map([ + ['a', '1'], + ['b', '2'], + ]); + + submitForm('/api/test', fields); + + const inputs = getInputs(); + expect(inputs).toHaveLength(2); + expect(inputs.map(i => [i.name, i.value])).toEqual([ + ['a', '1'], + ['b', '2'], + ]); + }); + + it('should also accept a generator', () => { + function* gen(): Generator { + yield ['x', '1']; + yield ['y', '2']; + } + + submitForm('/api/test', gen()); + + expect(getInputs().map(i => [i.name, i.value])).toEqual([ + ['x', '1'], + ['y', '2'], + ]); + }); + + it('should handle an empty fields iterable', () => { + submitForm('/api/test', []); + + expect(getInputs()).toHaveLength(0); + expect(submitSpy).toHaveBeenCalledTimes(1); + expect(document.body.querySelector('form')).toBeNull(); + }); +}); + +describe('objectToFormFields', () => { + it('should convert primitive values to string fields', () => { + expect( + objectToFormFields({ + name: 'John', + age: 30, + active: true, + }), + ).toEqual([ + ['name', 'John'], + ['age', '30'], + ['active', 'true'], + ]); + }); + + it('should skip null, undefined, and empty string values', () => { + expect( + objectToFormFields({ + a: 'value', + b: null, + c: undefined, + d: '', + e: 'other', + }), + ).toEqual([ + ['a', 'value'], + ['e', 'other'], + ]); + }); + + it('should keep falsy non-empty values (0, false)', () => { + expect( + objectToFormFields({ + zero: 0, + falseFlag: false, + }), + ).toEqual([ + ['zero', '0'], + ['falseFlag', 'false'], + ]); + }); + + it('should expand arrays into repeated fields', () => { + expect( + objectToFormFields({ + tags: ['a', 'b', 'c'], + }), + ).toEqual([ + ['tags', 'a'], + ['tags', 'b'], + ['tags', 'c'], + ]); + }); + + it('should skip null/undefined/empty entries inside arrays', () => { + expect( + objectToFormFields({ + ids: [1, null, 2, undefined, '', 3], + }), + ).toEqual([ + ['ids', '1'], + ['ids', '2'], + ['ids', '3'], + ]); + }); + + it('should flatten nested objects, hoisting inner keys to top level', () => { + expect( + objectToFormFields({ + user: { + name: 'John', + age: 30, + }, + }), + ).toEqual([ + ['name', 'John'], + ['age', '30'], + ]); + }); + + it('should flatten deeply nested objects', () => { + expect( + objectToFormFields({ + outer: { + middle: { + leaf: 'value', + }, + }, + }), + ).toEqual([['leaf', 'value']]); + }); + + it('should handle nested objects mixed with arrays', () => { + expect( + objectToFormFields({ + meta: { + tags: ['x', 'y'], + name: 'foo', + }, + id: 1, + }), + ).toEqual([ + ['tags', 'x'], + ['tags', 'y'], + ['name', 'foo'], + ['id', '1'], + ]); + }); + + it('should return an empty array for an empty object', () => { + expect(objectToFormFields({})).toEqual([]); + }); + + it('should return an empty array when all values are skipped', () => { + expect( + objectToFormFields({ + a: null, + b: undefined, + c: '', + d: [], + e: [null, '', undefined], + }), + ).toEqual([]); + }); +}); diff --git a/webapp/packages/core-utils/src/submitForm.ts b/webapp/packages/core-utils/src/submitForm.ts new file mode 100644 index 00000000000..ab3bad723fb --- /dev/null +++ b/webapp/packages/core-utils/src/submitForm.ts @@ -0,0 +1,61 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +export function submitForm(url: string, fields: Iterable): void { + const form = document.createElement('form'); + form.method = 'POST'; + form.action = url; + form.target = '_blank'; + form.style.display = 'none'; + + for (const [name, value] of fields) { + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = name; + input.value = value; + form.appendChild(input); + } + + document.body.appendChild(form); + form.submit(); + document.body.removeChild(form); +} + +/** + * Flattens an object into form-encoded key/value pairs, skipping falsy values: + * - arrays become repeated fields (`key=v1&key=v2`) + * - nested objects are flattened — their inner keys become top-level fields (!collision of inner keys is not handled) + */ +export function objectToFormFields(obj: Record): Array<[string, string]> { + const fields: Array<[string, string]> = []; + for (const [key, value] of Object.entries(obj)) { + appendField(fields, key, value); + } + return fields; +} + +function appendField(fields: Array<[string, string]>, key: string, value: unknown): void { + if (value == null || value === '') { + return; + } + if (Array.isArray(value)) { + for (const item of value) { + if (item != null && item !== '') { + fields.push([key, String(item)]); + } + } + return; + } + if (typeof value === 'object') { + for (const [innerKey, innerValue] of Object.entries(value)) { + appendField(fields, innerKey, innerValue); + } + return; + } + fields.push([key, String(value)]); +}