diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/columns/enum.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/columns/enum.svelte index f8d6894e82..79d3f453fb 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/columns/enum.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/columns/enum.svelte @@ -40,10 +40,9 @@ - - - - - Enum elements have a maximum length of 255 characters. This limit can not be exceeded. - - - + ({ + InputSelect: (await import('$lib/elements/forms/inputSelect.svelte')).default +})); +vi.mock('$app/state', () => ({ page: { params: { region: 'fra', project: 'project' } } })); +vi.mock('$lib/stores/sdk', () => ({ sdk: { forProject: vi.fn() } })); + +afterEach(cleanup); + +it('does not restore a default that was removed while the column was required', async () => { + const user = userEvent.setup(); + const data = { + elements: ['New York', 'Tokyo'], + default: 'New York', + required: false, + array: false + }; + render(EnumColumn, { data }); + const toggle = screen.getByRole('checkbox', { name: /^Required/ }); + await user.click(toggle); + await user.click(screen.getByRole('button', { name: 'Remove New York' })); + await user.click(toggle); + + expect(data.default).toBeNull(); + expect(screen.getByRole('combobox', { name: /^Default value/ })).toHaveTextContent('NULL'); +}); + +it('preserves and selects multi-word defaults and clears a removed default', async () => { + const user = userEvent.setup(); + const data = { + elements: ['New York', 'Tokyo'], + default: 'New York', + required: false, + array: false + }; + render(EnumColumn, { data }); + const select = screen.getByRole('combobox', { name: /^Default value/ }); + expect(select).toHaveTextContent('New York'); + + await user.type(screen.getByRole('textbox', { name: /^Elements/ }), 'Nizhny Novgorod{Enter}'); + expect(data.default).toBe('New York'); + await user.click(select); + await user.click(screen.getByRole('option', { name: 'Nizhny Novgorod' })); + expect(data.default).toBe('Nizhny Novgorod'); + + await user.click(screen.getByRole('button', { name: 'Remove Nizhny Novgorod' })); + expect(data.default).toBeNull(); + expect(select).toHaveTextContent('NULL'); +}); + +it.each(['Required', 'Array'])('restores a valid default after toggling %s', async (name) => { + const user = userEvent.setup(); + const data = { elements: ['New York'], default: 'New York', required: false, array: false }; + render(EnumColumn, { data }); + const toggle = screen.getByRole('checkbox', { name: new RegExp(`^${name}`) }); + const select = screen.getByRole('combobox', { name: /^Default value/ }); + + await user.click(toggle); + expect(data.default).toBeNull(); + expect(select).toBeDisabled(); + + await user.click(toggle); + expect(data.default).toBe('New York'); + expect(select).toBeEnabled(); +}); + +it('creates an enum with complete values and the selected multi-word default', async () => { + const user = userEvent.setup(); + const createEnumColumn = vi.fn().mockResolvedValue({}); + vi.mocked(sdk.forProject).mockReturnValue({ tablesDB: { createEnumColumn } } as never); + const data = { elements: [], default: null, required: false, array: false }; + render(EnumColumn, { data }); + + await user.type(screen.getByRole('textbox', { name: /^Elements/ }), 'New York, NY{Enter}'); + await user.click(screen.getByRole('combobox', { name: /^Default value/ })); + await user.click(screen.getByRole('option', { name: 'New York, NY' })); + await submitEnum('database', 'table', 'city', data); + + expect(sdk.forProject).toHaveBeenCalledWith('fra', 'project'); + expect(createEnumColumn).toHaveBeenCalledExactlyOnceWith({ + databaseId: 'database', + tableId: 'table', + key: 'city', + elements: ['New York, NY'], + required: false, + xdefault: 'New York, NY', + array: false + }); +}); + +it.each([ + ['city', undefined], + ['destination', 'destination'] +])('edits complete enum values with key %s and the original route key', async (key, newKey) => { + const user = userEvent.setup(); + const updateEnumColumn = vi.fn().mockResolvedValue({}); + vi.mocked(sdk.forProject).mockReturnValue({ tablesDB: { updateEnumColumn } } as never); + const data = { + key, + elements: ['New York, NY', 'Tokyo'], + default: 'New York, NY', + required: false, + array: false + }; + render(EnumColumn, { data, editing: true }); + + await user.type(screen.getByRole('textbox', { name: /^Elements/ }), 'Nizhny Novgorod{Enter}'); + await user.click(screen.getByRole('button', { name: 'Remove Tokyo' })); + await updateEnum('database', 'table', data, 'city'); + + expect(updateEnumColumn).toHaveBeenCalledExactlyOnceWith({ + databaseId: 'database', + tableId: 'table', + key: 'city', + elements: ['New York, NY', 'Nizhny Novgorod'], + required: false, + xdefault: 'New York, NY', + newKey + }); +}); diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/columns/enumElements.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/columns/enumElements.svelte new file mode 100644 index 0000000000..5d79b0379b --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/columns/enumElements.svelte @@ -0,0 +1,93 @@ + + +
+ + (error = '')} + onkeydown={keydown} + onblur={blur} + oncompositionstart={() => (composing = true)} + oncompositionend={compositionEnd}> + + Add + + + + {#each elements as element, index} + remove(index)}> + {element} + + + {/each} + + +
diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/columns/enumElements.svelte.test.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/columns/enumElements.svelte.test.ts new file mode 100644 index 0000000000..8839a64c89 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/columns/enumElements.svelte.test.ts @@ -0,0 +1,231 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import EnumElements from './enumElements.svelte'; + +afterEach(cleanup); + +it('prevents edits while disabled and supports enabling the same control', async () => { + const user = userEvent.setup(); + const { rerender } = render(EnumElements, { elements: ['New York'], disabled: true }); + const input = screen.getByRole('textbox', { name: /^Elements/ }); + const remove = screen.getByRole('button', { name: 'Remove New York' }); + + expect(input).toBeDisabled(); + expect(remove).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled(); + await user.click(remove); + expect(remove).toBeInTheDocument(); + + await rerender({ elements: ['New York'], disabled: false }); + await user.type(input, 'Tokyo{Enter}'); + + expect(screen.getByRole('button', { name: 'Remove Tokyo' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Remove New York' })).toBeInTheDocument(); +}); + +it('waits for composition to finish when focus leaves during IME input', async () => { + render(EnumElements); + const input = screen.getByRole('textbox', { name: 'Elements' }); + await fireEvent.compositionStart(input); + await fireEvent.input(input, { target: { value: '東' } }); + await fireEvent.blur(input); + + expect(screen.queryByRole('button', { name: /^Remove / })).not.toBeInTheDocument(); + + await fireEvent.input(input, { target: { value: '東京' } }); + await fireEvent.compositionEnd(input); + + expect(screen.getByRole('button', { name: 'Remove 東京' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Remove 東' })).not.toBeInTheDocument(); + expect(input).toHaveValue(''); +}); + +it('removes the chosen value and restores input focus and required validation', async () => { + const user = userEvent.setup(); + render(EnumElements, { elements: ['New York', 'Tokyo'] }); + const input = screen.getByRole('textbox', { name: /^Elements/ }); + + await user.click(screen.getByRole('button', { name: 'Remove New York' })); + + expect(screen.queryByRole('button', { name: 'Remove New York' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Remove Tokyo' })).toBeInTheDocument(); + expect(input).toHaveFocus(); + + await user.click(screen.getByRole('button', { name: 'Remove Tokyo' })); + + expect(input).toHaveFocus(); + expect(input).toBeRequired(); + expect(input).toBeInvalid(); +}); + +it('does not commit the Enter key used to confirm an IME composition', async () => { + const user = userEvent.setup(); + render(EnumElements); + const input = screen.getByRole('textbox', { name: 'Elements' }); + await user.type(input, '東京'); + await fireEvent.keyDown(input, { key: 'Enter', isComposing: true }); + + expect(input).toHaveValue('東京'); + expect(screen.queryByRole('button', { name: /^Remove / })).not.toBeInTheDocument(); + + await user.keyboard('{Enter}'); + expect(screen.getByRole('button', { name: 'Remove 東京' })).toBeInTheDocument(); +}); + +it('commits a complete value on Tab without trapping keyboard focus', async () => { + const user = userEvent.setup(); + render(EnumElements); + const input = screen.getByRole('textbox', { name: 'Elements' }); + + await user.type(input, 'New York'); + await user.tab(); + + expect(screen.getByRole('button', { name: 'Remove New York' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Add' })).toHaveFocus(); + expect(input).toHaveValue(''); + + await user.click(input); + await user.tab(); + expect(screen.getAllByRole('button', { name: /^Remove / })).toHaveLength(1); +}); + +it('adds with Enter without submitting the surrounding form', async () => { + const user = userEvent.setup(); + const form = document.createElement('form'); + const submit = vi.fn((event: Event) => event.preventDefault()); + form.addEventListener('submit', submit); + document.body.append(form); + const save = document.createElement('button'); + save.type = 'submit'; + save.textContent = 'Save'; + + try { + render(EnumElements, { target: form }); + form.append(save); + await user.type(screen.getByRole('textbox', { name: 'Elements' }), 'New York{Enter}'); + + expect(screen.getByRole('button', { name: 'Remove New York' })).toBeInTheDocument(); + expect(submit).not.toHaveBeenCalled(); + + await user.click(save); + expect(submit).toHaveBeenCalledOnce(); + } finally { + form.remove(); + } +}); + +it.each(['a', '界', '😀'])('enforces the 255-character boundary for %s', async (character) => { + const user = userEvent.setup(); + render(EnumElements); + const input = screen.getByRole('textbox', { name: 'Elements' }); + await user.click(input); + await user.paste(character.repeat(256)); + expect(input).toBeInvalid(); + await user.click(screen.getByRole('button', { name: 'Add' })); + + expect(screen.getByText('Enum elements cannot exceed 255 characters.')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^Remove / })).not.toBeInTheDocument(); + expect(input).toBeInvalid(); + + await user.clear(input); + await user.paste(character.repeat(255)); + expect(input).toBeValid(); + await user.click(screen.getByRole('button', { name: 'Add' })); + + expect(screen.getAllByRole('button', { name: /^Remove / })).toHaveLength(1); + expect(input).toBeValid(); + expect( + screen.queryByText('Enum elements cannot exceed 255 characters.') + ).not.toBeInTheDocument(); +}); + +it('applies native length validation to the trimmed value', async () => { + const user = userEvent.setup(); + render(EnumElements); + const input = screen.getByRole('textbox', { name: 'Elements' }); + await user.click(input); + await user.paste(` ${'界'.repeat(255)} `); + + expect(input).toBeValid(); + await user.keyboard('{Enter}'); + expect(screen.getByRole('button', { name: /^Remove / })).toHaveAttribute( + 'aria-label', + `Remove ${'界'.repeat(255)}` + ); +}); + +it('rejects whitespace-only elements and trims only outside the value', async () => { + const user = userEvent.setup(); + render(EnumElements); + const input = screen.getByRole('textbox', { name: 'Elements' }); + + await user.type(input, ' {Enter}'); + + expect(screen.queryByRole('button', { name: /^Remove / })).not.toBeInTheDocument(); + expect(input).toHaveValue(''); + expect(input).toBeRequired(); + expect(input).toBeInvalid(); + + await user.type(input, ' New York {Enter}'); + + expect(screen.getByRole('button', { name: 'Remove New York' })).toHaveAttribute( + 'aria-label', + 'Remove New York' + ); + expect(input).not.toBeRequired(); + expect(input).toBeValid(); +}); + +it('ignores exact duplicates while preserving case-sensitive enum values', async () => { + const user = userEvent.setup(); + render(EnumElements, { elements: ['Home'] }); + const input = screen.getByRole('textbox', { name: /^Elements/ }); + + await user.type(input, 'Home{Enter}home{Enter}'); + + expect(screen.getAllByRole('button', { name: /^Remove / })).toHaveLength(2); + expect(screen.getByRole('button', { name: 'Remove Home' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Remove home' })).toBeInTheDocument(); + expect(input).toHaveValue(''); +}); + +it('preserves loaded elements exactly and follows changes to the edited column', async () => { + const { rerender } = render(EnumElements, { elements: ['New York, NY', '0', 'A B'] }); + + expect( + screen + .getAllByRole('button', { name: /^Remove / }) + .map((button) => button.getAttribute('aria-label')) + ).toEqual(['Remove New York, NY', 'Remove 0', 'Remove A B']); + + await rerender({ elements: ['Nizhny Novgorod'] }); + + expect(screen.getByRole('button', { name: 'Remove Nizhny Novgorod' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Remove New York, NY' })).not.toBeInTheDocument(); +}); + +it('keeps a multi-word enum element together when submitted', async () => { + const user = userEvent.setup(); + render(EnumElements); + + await user.type(screen.getByRole('textbox', { name: 'Elements' }), 'Nizhny Novgorod{Enter}'); + + expect(screen.getByText('Nizhny Novgorod')).toBeInTheDocument(); + expect(screen.queryByText('Nizhny')).not.toBeInTheDocument(); +}); + +it.each(['New York, NY', 'MTS BS73 Home', 'North / South'])( + 'keeps pasted %s as one element', + async (value) => { + const user = userEvent.setup(); + render(EnumElements); + + await user.click(screen.getByRole('textbox', { name: 'Elements' })); + await user.paste(value); + await user.click(screen.getByRole('button', { name: 'Add' })); + + expect(screen.getByRole('button', { name: `Remove ${value}` })).toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: /^Remove / })).toHaveLength(1); + } +);