From c344b9058f7cbb0fa3627a925a34c92ae7e8f563 Mon Sep 17 00:00:00 2001 From: RemiBonnet Date: Fri, 20 Mar 2026 10:25:07 +0100 Subject: [PATCH 1/6] wip --- .../service/$serviceId/variables.tsx | 27 +++++++++++- .../variables-action-toolbar.spec.tsx | 42 +++++++++++++++++++ .../variables-action-toolbar.tsx | 22 ++++++++-- 3 files changed, 85 insertions(+), 6 deletions(-) create mode 100644 libs/domains/variables/feature/src/lib/variables-action-toolbar/variables-action-toolbar.spec.tsx diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables.tsx index 67db6574d02..8ddb23842b0 100644 --- a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables.tsx +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables.tsx @@ -2,8 +2,12 @@ import { createFileRoute, useParams } from '@tanstack/react-router' import { Suspense } from 'react' import { match } from 'ts-pattern' import { useDeployService, useService } from '@qovery/domains/services/feature' -import { VariableList, VariablesActionToolbar } from '@qovery/domains/variables/feature' -import { Heading, LoaderSpinner, Section, toast } from '@qovery/shared/ui' +import { + ImportEnvironmentVariableModalFeature, + VariableList, + VariablesActionToolbar, +} from '@qovery/domains/variables/feature' +import { Heading, LoaderSpinner, Section, toast, useModal } from '@qovery/shared/ui' import { useDocumentTitle } from '@qovery/shared/util-hooks' export const Route = createFileRoute( @@ -35,6 +39,7 @@ function RouteComponent() { projectId, environmentId, }) + const { openModal, closeModal } = useModal() const toasterCallback = () => { if (!service?.serviceType) { @@ -65,6 +70,24 @@ function RouteComponent() { projectId={projectId} environmentId={environmentId} serviceId={serviceId} + importEnvFileAccess="dropdown" + onImportEnvFile={() => + openModal({ + content: ( + + ), + options: { + width: 750, + }, + }) + } onCreateVariable={() => toast( 'SUCCESS', diff --git a/libs/domains/variables/feature/src/lib/variables-action-toolbar/variables-action-toolbar.spec.tsx b/libs/domains/variables/feature/src/lib/variables-action-toolbar/variables-action-toolbar.spec.tsx new file mode 100644 index 00000000000..870cf7591fe --- /dev/null +++ b/libs/domains/variables/feature/src/lib/variables-action-toolbar/variables-action-toolbar.spec.tsx @@ -0,0 +1,42 @@ +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { VariablesActionToolbar, type VariablesActionToolbarProps } from './variables-action-toolbar' + +const props: VariablesActionToolbarProps = { + scope: 'APPLICATION', + projectId: 'project-id', + environmentId: 'environment-id', + serviceId: 'service-id', +} + +describe('VariablesActionToolbar', () => { + it('should keep the import button by default when env file import is enabled', () => { + renderWithProviders() + + expect(screen.getByRole('button', { name: /import variable/i })).toBeInTheDocument() + }) + + it('should render the env file import before doppler in dropdown mode', async () => { + const { userEvent } = renderWithProviders( + + ) + + await userEvent.click(screen.getAllByRole('button')[0]) + + const menuItems = await screen.findAllByRole('menuitem') + + expect(menuItems[0]).toHaveTextContent('Import from .env file') + expect(menuItems[1]).toHaveTextContent('Import from Doppler') + }) + + it('should call the env file import callback when selected from the dropdown', async () => { + const onImportEnvFile = jest.fn() + const { userEvent } = renderWithProviders( + + ) + + await userEvent.click(screen.getAllByRole('button')[0]) + await userEvent.click(await screen.findByRole('menuitem', { name: /import from \.env file/i })) + + expect(onImportEnvFile).toHaveBeenCalledTimes(1) + }) +}) diff --git a/libs/domains/variables/feature/src/lib/variables-action-toolbar/variables-action-toolbar.tsx b/libs/domains/variables/feature/src/lib/variables-action-toolbar/variables-action-toolbar.tsx index 6d2e0dd1c8e..484b616e45c 100644 --- a/libs/domains/variables/feature/src/lib/variables-action-toolbar/variables-action-toolbar.tsx +++ b/libs/domains/variables/feature/src/lib/variables-action-toolbar/variables-action-toolbar.tsx @@ -1,5 +1,5 @@ import { type APIVariableScopeEnum, type VariableResponse } from 'qovery-typescript-axios' -import { ActionToolbar, Button, DropdownMenu, Icon, Tooltip, useModal } from '@qovery/shared/ui' +import { Button, DropdownMenu, Icon, Tooltip, useModal } from '@qovery/shared/ui' import { CreateUpdateVariableModal } from '../create-update-variable-modal/create-update-variable-modal' type Scope = Exclude @@ -7,6 +7,7 @@ type Scope = Exclude export type VariablesActionToolbarProps = { onCreateVariable?: (variable: VariableResponse | void) => void onImportEnvFile?: () => void + importEnvFileAccess?: 'button' | 'dropdown' } & ( | { scope: Extract @@ -25,8 +26,15 @@ export type VariablesActionToolbarProps = { } ) -export function VariablesActionToolbar({ onCreateVariable, onImportEnvFile, ...props }: VariablesActionToolbarProps) { +export function VariablesActionToolbar({ + onCreateVariable, + onImportEnvFile, + importEnvFileAccess = 'button', + ...props +}: VariablesActionToolbarProps) { const { openModal, closeModal } = useModal() + const hasImportEnvFile = Boolean(onImportEnvFile) + const showImportButton = hasImportEnvFile && importEnvFileAccess === 'button' const _onCreateVariable = (isFile?: boolean) => openModal({ @@ -48,7 +56,7 @@ export function VariablesActionToolbar({ onCreateVariable, onImportEnvFile, ...p return (
- {onImportEnvFile ? ( + {showImportButton ? (
@@ -296,6 +304,9 @@ export function ImportEnvironmentVariableModalFeature(props: ImportEnvironmentVa }) const { mutateAsync: importVariables, isLoading: isImportVariablesLoading } = useImportVariables() + const { mutateAsync: createVariable } = useCreateVariable() + const { mutateAsync: editVariable } = useEditVariable() + const { mutateAsync: deleteVariable } = useDeleteVariable() return ( @@ -314,6 +325,23 @@ export function ImportEnvironmentVariableModalFeature(props: ImportEnvironmentVa return } const vars = formatData(methods.getValues(), keys) + + if (scope === APIVariableScopeEnum.TERRAFORM) { + await importVariablesWithMutations({ + vars, + overwriteEnabled, + existingVars: existingEnvVars, + projectId: props.projectId, + environmentId: 'environmentId' in props ? props.environmentId : undefined, + serviceId: 'serviceId' in props ? props.serviceId : undefined, + createVariable, + editVariable, + deleteVariable, + }) + props.closeModal() + return + } + await importVariables({ serviceType: scope as unknown as ServiceTypeForVariableEnum, serviceId: parentId, diff --git a/libs/domains/variables/feature/src/lib/import-environment-variable-modal/utils/handle-submit.spec.tsx b/libs/domains/variables/feature/src/lib/import-environment-variable-modal/utils/handle-submit.spec.tsx index 0697ecaa65b..6b1feda4d27 100644 --- a/libs/domains/variables/feature/src/lib/import-environment-variable-modal/utils/handle-submit.spec.tsx +++ b/libs/domains/variables/feature/src/lib/import-environment-variable-modal/utils/handle-submit.spec.tsx @@ -1,4 +1,5 @@ -import { formatData } from './handle-submit' +import { APIVariableScopeEnum } from 'qovery-typescript-axios' +import { formatData, importVariablesWithMutations } from './handle-submit' describe('formatData()', () => { it('should format the data correctly', () => { @@ -30,3 +31,92 @@ describe('formatData()', () => { ]) }) }) + +describe('importVariablesWithMutations()', () => { + it('should skip variables already existing on the same scope when overwrite is disabled', async () => { + const createVariable = jest.fn() + const editVariable = jest.fn() + const deleteVariable = jest.fn() + + await importVariablesWithMutations({ + vars: [ + { + name: 'EXISTING_VAR', + value: 'new-value', + scope: APIVariableScopeEnum.TERRAFORM, + is_secret: false, + }, + ], + overwriteEnabled: false, + existingVars: [ + { + id: 'variable-id', + key: 'EXISTING_VAR', + value: 'old-value', + scope: APIVariableScopeEnum.TERRAFORM, + is_secret: false, + created_at: '2024-01-01T00:00:00Z', + }, + ], + projectId: 'project-id', + environmentId: 'environment-id', + serviceId: 'service-id', + createVariable, + editVariable, + deleteVariable, + }) + + expect(createVariable).not.toHaveBeenCalled() + expect(editVariable).not.toHaveBeenCalled() + expect(deleteVariable).not.toHaveBeenCalled() + }) + + it('should delete and recreate a variable when overwrite is enabled and secret mode changes', async () => { + const createVariable = jest.fn().mockResolvedValue(undefined) + const editVariable = jest.fn() + const deleteVariable = jest.fn().mockResolvedValue(undefined) + + await importVariablesWithMutations({ + vars: [ + { + name: 'EXISTING_VAR', + value: 'new-value', + scope: APIVariableScopeEnum.TERRAFORM, + is_secret: true, + }, + ], + overwriteEnabled: true, + existingVars: [ + { + id: 'variable-id', + key: 'EXISTING_VAR', + value: 'old-value', + scope: APIVariableScopeEnum.TERRAFORM, + is_secret: false, + description: 'existing description', + created_at: '2024-01-01T00:00:00Z', + }, + ], + projectId: 'project-id', + environmentId: 'environment-id', + serviceId: 'service-id', + createVariable, + editVariable, + deleteVariable, + }) + + expect(deleteVariable).toHaveBeenCalledWith({ variableId: 'variable-id' }) + expect(createVariable).toHaveBeenCalledWith({ + variableRequest: { + key: 'EXISTING_VAR', + value: 'new-value', + is_secret: true, + variable_scope: APIVariableScopeEnum.TERRAFORM, + variable_parent_id: 'service-id', + description: 'existing description', + enable_interpolation_in_file: undefined, + }, + }) + expect(editVariable).not.toHaveBeenCalled() + }) +}) diff --git a/libs/domains/variables/feature/src/lib/import-environment-variable-modal/utils/handle-submit.ts b/libs/domains/variables/feature/src/lib/import-environment-variable-modal/utils/handle-submit.ts index 3b93ebf2fe3..0617b6326bf 100644 --- a/libs/domains/variables/feature/src/lib/import-environment-variable-modal/utils/handle-submit.ts +++ b/libs/domains/variables/feature/src/lib/import-environment-variable-modal/utils/handle-submit.ts @@ -1,4 +1,10 @@ -import { type APIVariableScopeEnum, type VariableImportRequestVarsInner } from 'qovery-typescript-axios' +import { + APIVariableScopeEnum, + type VariableEditRequest, + type VariableImportRequestVarsInner, + type VariableRequest, +} from 'qovery-typescript-axios' +import { type EnvironmentVariableSecretOrPublic } from '@qovery/shared/interfaces' export function formatData(data: { [key: string]: string }, keys: string[]) { const vars: VariableImportRequestVarsInner[] = [] @@ -14,3 +20,102 @@ export function formatData(data: { [key: string]: string }, keys: string[]) { } return vars } + +interface ImportVariablesWithMutationsProps { + vars: VariableImportRequestVarsInner[] + overwriteEnabled: boolean + existingVars: EnvironmentVariableSecretOrPublic[] + projectId: string + environmentId?: string + serviceId?: string + createVariable: (props: { variableRequest: VariableRequest }) => Promise + editVariable: (props: { variableId: string; variableEditRequest: VariableEditRequest }) => Promise + deleteVariable: (props: { variableId: string }) => Promise +} + +function getVariableParentId( + scope: APIVariableScopeEnum, + ids: Pick +) { + switch (scope) { + case APIVariableScopeEnum.PROJECT: + return ids.projectId + case APIVariableScopeEnum.ENVIRONMENT: + if (!ids.environmentId) { + throw new Error('Missing environmentId for environment-scoped variable import') + } + return ids.environmentId + case APIVariableScopeEnum.APPLICATION: + case APIVariableScopeEnum.CONTAINER: + case APIVariableScopeEnum.JOB: + case APIVariableScopeEnum.HELM: + case APIVariableScopeEnum.TERRAFORM: + if (!ids.serviceId) { + throw new Error('Missing serviceId for service-scoped variable import') + } + return ids.serviceId + default: + throw new Error(`Unsupported variable scope for import: ${scope}`) + } +} + +function toVariableRequest( + variable: VariableImportRequestVarsInner, + ids: Pick, + existingVar?: EnvironmentVariableSecretOrPublic +): VariableRequest { + return { + key: variable.name, + value: variable.value, + is_secret: variable.is_secret, + variable_scope: variable.scope, + variable_parent_id: getVariableParentId(variable.scope, ids), + description: existingVar?.description, + enable_interpolation_in_file: existingVar?.enable_interpolation_in_file, + } +} + +export async function importVariablesWithMutations({ + vars, + overwriteEnabled, + existingVars, + projectId, + environmentId, + serviceId, + createVariable, + editVariable, + deleteVariable, +}: ImportVariablesWithMutationsProps) { + for (const variable of vars) { + const existingVar = existingVars.find((envVar) => envVar.key === variable.name && envVar.scope === variable.scope) + + if (!existingVar) { + await createVariable({ + variableRequest: toVariableRequest(variable, { projectId, environmentId, serviceId }), + }) + continue + } + + if (!overwriteEnabled) { + continue + } + + if (existingVar.is_secret === variable.is_secret) { + await editVariable({ + variableId: existingVar.id, + variableEditRequest: { + key: variable.name, + value: variable.value, + description: existingVar.description, + enable_interpolation_in_file: existingVar.enable_interpolation_in_file, + }, + }) + continue + } + + await deleteVariable({ variableId: existingVar.id }) + await createVariable({ + variableRequest: toVariableRequest(variable, { projectId, environmentId, serviceId }, existingVar), + }) + } +} diff --git a/libs/domains/variables/feature/src/lib/variable-list/variable-list.tsx b/libs/domains/variables/feature/src/lib/variable-list/variable-list.tsx index fef654ac92c..a66a4013dfd 100644 --- a/libs/domains/variables/feature/src/lib/variable-list/variable-list.tsx +++ b/libs/domains/variables/feature/src/lib/variable-list/variable-list.tsx @@ -614,7 +614,7 @@ export function VariableList({ const hideServiceLinkColumn = isServiceScope && !isBuiltInTable return (
- + {tableInstance.getHeaderGroups().map((headerGroup) => ( @@ -634,7 +634,7 @@ export function VariableList({ type="button" className={twMerge( 'flex items-center gap-1', - header.column.getCanSort() ? 'cursor-pointer select-none' : '' + header.column.getCanSort() ? 'cursor-pointer select-none truncate' : '' )} onClick={header.column.getToggleSortingHandler()} > diff --git a/libs/pages/application/src/lib/ui/import-environment-variable-modal/import-environment-variable-modal.spec.tsx b/libs/pages/application/src/lib/ui/import-environment-variable-modal/import-environment-variable-modal.spec.tsx deleted file mode 100644 index 4fa6c7c850c..00000000000 --- a/libs/pages/application/src/lib/ui/import-environment-variable-modal/import-environment-variable-modal.spec.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import { act, findAllByTestId, fireEvent, getByTestId, render, screen, waitFor } from '__tests__/utils/setup-jest' -import { wrapWithReactHookForm } from '__tests__/utils/wrap-with-react-hook-form' -import { APIVariableScopeEnum } from 'qovery-typescript-axios' -import { jsonToForm } from '../../feature/import-environment-variable-modal-feature/utils/file-to-form' -import ImportEnvironmentVariableModal, { - type ImportEnvironmentVariableModalProps, -} from './import-environment-variable-modal' - -describe('ImportEnvironmentVariableModal', () => { - const props: ImportEnvironmentVariableModalProps = { - onSubmit: jest.fn(), - closeModal: jest.fn(), - triggerToggleAll: jest.fn(), - toggleAll: false, - showDropzone: false, - dropzoneGetInputProps: jest.fn(), - dropzoneGetRootProps: jest.fn(), - dropzoneIsDragActive: false, - existingVars: [], - } - - it('should render successfully', async () => { - const { baseElement } = render(wrapWithReactHookForm()) - - await waitFor(() => { - expect(baseElement).toBeTruthy() - }) - }) - - describe('with a lot of entries', function () { - it('should loop and print forms line', async () => { - const json = JSON.stringify({ - key1: 'value1', - key2: 'value2', - key3: 'value3', - keyEmpty: '', - }) - const defaultValues = jsonToForm(json) - props.keys = Object.keys(JSON.parse(json)) - - const { baseElement } = render( - wrapWithReactHookForm(, { defaultValues }) - ) - - const formRows = await findAllByTestId(baseElement, 'form-row') - await waitFor(() => { - expect(formRows).toHaveLength(props.keys.length) - }) - }) - }) - - describe('with only one entry', () => { - const json = JSON.stringify({ - key1: 'value1', - }) - const defaultValues = jsonToForm(json) - - beforeEach(() => { - props.keys = Object.keys(JSON.parse(json)) - }) - - it('should render row with correct form inputs', async () => { - const { baseElement } = render( - wrapWithReactHookForm(, { defaultValues }) - ) - - await waitFor(async () => { - const formRows = await findAllByTestId(baseElement, 'form-row') - expect(formRows[0].querySelectorAll('input')).toHaveLength(3) - expect(formRows[0].querySelectorAll('select')).toHaveLength(1) - expect(formRows[0].querySelectorAll('[data-testid="input-toggle"]')).toHaveLength(1) - }) - }) - - it('should disabled button if form is not well filled up', async () => { - const { baseElement } = render( - wrapWithReactHookForm(, { defaultValues }) - ) - - await act(() => { - const input = screen.getByLabelText('key1_key') - fireEvent.input(input, { target: { value: 'asdfasf' } }) - }) - - await act(() => { - const input = screen.getByLabelText('key1_key') - fireEvent.input(input, { target: { value: '' } }) - }) - - await waitFor(() => { - const warningIcon = screen.getByTestId('warning-icon-left') - expect(warningIcon).toBeInTheDocument() - }) - - await waitFor(async () => { - const button = await getByTestId(baseElement, 'submit-button') - expect(button).toBeDisabled() - }) - }) - - it('should close the modal on cancel', async () => { - const spy = jest.fn() - render(wrapWithReactHookForm(, { defaultValues })) - - await act(() => { - screen.getByRole('button', { name: 'Cancel' }).click() - }) - - expect(spy).toHaveBeenCalled() - }) - - it('should change the scope for all on select change', async () => { - const spy = jest.fn() - render( - wrapWithReactHookForm(, { defaultValues }) - ) - - await act(() => { - const select = screen.getByTestId('select-scope-for-all') - fireEvent.change(select, { target: { value: APIVariableScopeEnum.PROJECT } }) - }) - - expect(spy).toHaveBeenCalledWith(APIVariableScopeEnum.PROJECT) - }) - - it('should toggle all on click on secret toggle', async () => { - const spy = jest.fn() - render( - wrapWithReactHookForm(, { - defaultValues, - }) - ) - - await act(() => { - const toggle = screen.getByTestId('toggle-for-all') - fireEvent.click(toggle) - }) - - expect(spy).toHaveBeenCalledWith(false) - }) - - it('should show a warning icon if variable name begins with QOVERY', async () => { - const json = JSON.stringify({ - key1: 'value1', - }) - const defaultValues = jsonToForm(json) - props.keys = Object.keys(JSON.parse(json)) - - render(wrapWithReactHookForm(, { defaultValues })) - - await act(() => { - const input = screen.getByLabelText('key1_key') - fireEvent.input(input, { target: { value: 'QOVERY_OIUSOD' } }) - }) - - await waitFor(() => { - const warningIcon = screen.getByTestId('warning-icon-left') - expect(warningIcon).toBeInTheDocument() - }) - }) - }) - - describe('dropzone test', () => { - beforeEach(() => { - props.showDropzone = true - }) - - it('should render dropzone', async () => { - const defaultValues = {} - render(wrapWithReactHookForm(, { defaultValues })) - expect(await screen.getByTestId('drop-input')).toBeInTheDocument() - }) - }) -}) diff --git a/libs/pages/application/src/lib/ui/import-environment-variable-modal/import-environment-variable-modal.tsx b/libs/pages/application/src/lib/ui/import-environment-variable-modal/import-environment-variable-modal.tsx deleted file mode 100644 index 37fd3a53b14..00000000000 --- a/libs/pages/application/src/lib/ui/import-environment-variable-modal/import-environment-variable-modal.tsx +++ /dev/null @@ -1,211 +0,0 @@ -import { APIVariableScopeEnum } from 'qovery-typescript-axios' -import { useState } from 'react' -import { type DropzoneRootProps } from 'react-dropzone' -import { Controller, useFormContext } from 'react-hook-form' -import { type ServiceType } from '@qovery/domains/services/data-access' -import { type EnvironmentVariableSecretOrPublic } from '@qovery/shared/interfaces' -import { Button, Dropzone, Icon, InputSelectSmall, InputTextSmall, InputToggle } from '@qovery/shared/ui' -import { computeAvailableScope, generateScopeLabel } from '@qovery/shared/util-js' -import { validateKey, warningMessage } from '../../feature/import-environment-variable-modal-feature/utils/form-check' - -export interface ImportEnvironmentVariableModalProps { - onSubmit: () => void - keys?: string[] - availableScopes: APIVariableScopeEnum[] - closeModal: () => void - loading: boolean - triggerToggleAll: (b: boolean) => void - toggleAll: boolean - changeScopeForAll: (value: APIVariableScopeEnum | undefined) => void - showDropzone: boolean - dropzoneGetRootProps: (props?: T) => T - dropzoneGetInputProps: (props?: T) => T - dropzoneIsDragActive: boolean - existingVars: EnvironmentVariableSecretOrPublic[] - deleteKey: (key: string) => void - overwriteEnabled: boolean - setOverwriteEnabled: (b: boolean) => void - serviceType?: ServiceType -} - -export function ImportEnvironmentVariableModal(props: ImportEnvironmentVariableModalProps) { - const { control, formState, getValues, trigger } = useFormContext() - const { keys = [], loading = false, availableScopes = computeAvailableScope(undefined, false) } = props - const [localScope, setLocalScope] = useState(APIVariableScopeEnum.ENVIRONMENT) - - // write a regex pattern that rejects spaces - const pattern = /^[^\s]+$/ - - return ( -
-

Import variables from .env file

- - {props.showDropzone ? ( -
- - -
- ) : ( - <> -
- -
- -
-
- Variable - Value - Scope - Secret -
- -
-

Apply for all

-
- ({ value: s, label: generateScopeLabel(s) }))} - onChange={(value?: string) => { - props.changeScopeForAll(value as APIVariableScopeEnum) - setLocalScope(value as APIVariableScopeEnum) - trigger().then() - }} - /> -
- -
-
-
- {keys?.map((key) => ( -
- - validateKey(value, props.existingVars, getValues(key + '_scope') as APIVariableScopeEnum), - }} - render={({ field, fieldState: { error } }) => ( - - )} - /> - - ( - - )} - /> - - ( - { - field.onChange(e) - trigger(key + '_key').then() - }} - items={availableScopes.map((s) => ({ value: s, label: generateScopeLabel(s) }))} - /> - )} - /> - -
- } - /> -
- -
- -
-
- ))} - -
- - -
-
- - )} -
- ) -} - -export default ImportEnvironmentVariableModal diff --git a/libs/pages/clusters/src/lib/ui/page-clusters-create/step-kubeconfig/step-kubeconfig.tsx b/libs/pages/clusters/src/lib/ui/page-clusters-create/step-kubeconfig/step-kubeconfig.tsx index 563d3a37912..d6a065211bb 100644 --- a/libs/pages/clusters/src/lib/ui/page-clusters-create/step-kubeconfig/step-kubeconfig.tsx +++ b/libs/pages/clusters/src/lib/ui/page-clusters-create/step-kubeconfig/step-kubeconfig.tsx @@ -30,20 +30,23 @@ export function StepKubeconfig({ onSubmit }: StepKubeconfigProps) { rules: { required: true }, }) - const onDrop = useCallback((acceptedFiles: File[]) => { - acceptedFiles.forEach((file) => { - const reader = new FileReader() + const onDrop = useCallback( + (acceptedFiles: File[]) => { + acceptedFiles.forEach((file) => { + const reader = new FileReader() - reader.onabort = () => console.log('file reading was aborted') - reader.onerror = () => console.log('file reading has failed') - reader.onload = () => { - fileSize.onChange(file.size / 1000) - fileName.onChange(file.name) - fileContent.onChange(reader.result) - } - reader.readAsText(file) - }) - }, []) + reader.onabort = () => console.log('file reading was aborted') + reader.onerror = () => console.log('file reading has failed') + reader.onload = () => { + fileSize.onChange(file.size / 1000) + fileName.onChange(file.name) + fileContent.onChange(reader.result) + } + reader.readAsText(file) + }) + }, + [fileContent, fileName, fileSize] + ) const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop, maxFiles: 1, @@ -78,7 +81,12 @@ export function StepKubeconfig({ onSubmit }: StepKubeconfigProps) {
) : ( -
+
diff --git a/libs/shared/ui/src/lib/components/dropzone/dropzone.tsx b/libs/shared/ui/src/lib/components/dropzone/dropzone.tsx index 356b30a5cad..18da9e9e7f7 100644 --- a/libs/shared/ui/src/lib/components/dropzone/dropzone.tsx +++ b/libs/shared/ui/src/lib/components/dropzone/dropzone.tsx @@ -13,7 +13,7 @@ export function Dropzone({ isDragActive, typeFile = '.env', className }: Dropzon return (
( onClick={() => setCurrentType(currentType === 'password' ? 'text' : 'password')} className="absolute right-2 -translate-y-[0.5px] transform text-sm text-neutral-subtle hover:text-neutral" > - +
)}
diff --git a/libs/shared/util-js/src/lib/compute-available-environment-variable-scope.spec.ts b/libs/shared/util-js/src/lib/compute-available-environment-variable-scope.spec.ts index 456700676b8..1a49e0985e9 100644 --- a/libs/shared/util-js/src/lib/compute-available-environment-variable-scope.spec.ts +++ b/libs/shared/util-js/src/lib/compute-available-environment-variable-scope.spec.ts @@ -1,5 +1,9 @@ import { APIVariableScopeEnum } from 'qovery-typescript-axios' -import { computeAvailableScope, getScopeHierarchy } from './compute-available-environment-variable-scope' +import { + computeAvailableScope, + generateScopeLabel, + getScopeHierarchy, +} from './compute-available-environment-variable-scope' describe('computeAvailableEnvironmentVariableScope', () => { describe('when the scope is not set', () => { @@ -67,3 +71,9 @@ describe('getScopeHierarchy', () => { expect(hierarchy).toBe(2) }) }) + +describe('generateScopeLabel', () => { + it('should return Service for terraform scope', () => { + expect(generateScopeLabel(APIVariableScopeEnum.TERRAFORM)).toBe('Service') + }) +}) diff --git a/libs/shared/util-js/src/lib/compute-available-environment-variable-scope.ts b/libs/shared/util-js/src/lib/compute-available-environment-variable-scope.ts index 5f502cd2ce3..2575c1d9705 100644 --- a/libs/shared/util-js/src/lib/compute-available-environment-variable-scope.ts +++ b/libs/shared/util-js/src/lib/compute-available-environment-variable-scope.ts @@ -86,7 +86,8 @@ export function generateScopeLabel(scope: APIVariableScopeEnum): string { scope === APIVariableScopeEnum.APPLICATION || scope === APIVariableScopeEnum.JOB || scope === APIVariableScopeEnum.CONTAINER || - scope === APIVariableScopeEnum.HELM + scope === APIVariableScopeEnum.HELM || + scope === APIVariableScopeEnum.TERRAFORM ) return 'Service' return upperCaseFirstLetter(scope) as string From cbf49623397004661f4fe04ce1cf5e9a523c781c Mon Sep 17 00:00:00 2001 From: RemiBonnet Date: Fri, 20 Mar 2026 14:32:06 +0100 Subject: [PATCH 3/6] refactor(import-environment-variable-modal): remove unused variable mutation functions and clean up import logic --- .../import-environment-variable-modal.tsx | 25 +--- .../utils/handle-submit.spec.tsx | 92 +-------------- .../utils/handle-submit.ts | 107 +----------------- .../__snapshots__/variable-list.spec.tsx.snap | 12 +- .../input-text-small.spec.tsx | 3 +- 5 files changed, 10 insertions(+), 229 deletions(-) diff --git a/libs/domains/variables/feature/src/lib/import-environment-variable-modal/import-environment-variable-modal.tsx b/libs/domains/variables/feature/src/lib/import-environment-variable-modal/import-environment-variable-modal.tsx index 943906147ad..171e8b1daf7 100644 --- a/libs/domains/variables/feature/src/lib/import-environment-variable-modal/import-environment-variable-modal.tsx +++ b/libs/domains/variables/feature/src/lib/import-environment-variable-modal/import-environment-variable-modal.tsx @@ -6,16 +6,13 @@ import { type ServiceType } from '@qovery/domains/services/data-access' import { type EnvironmentVariableSecretOrPublic } from '@qovery/shared/interfaces' import { Button, Dropzone, Icon, InputSelectSmall, InputTextSmall, InputToggle, useModal } from '@qovery/shared/ui' import { computeAvailableScope, generateScopeLabel, parseEnvText } from '@qovery/shared/util-js' -import { useCreateVariable } from '../hooks/use-create-variable/use-create-variable' -import { useDeleteVariable } from '../hooks/use-delete-variable/use-delete-variable' -import { useEditVariable } from '../hooks/use-edit-variable/use-edit-variable' import { useImportVariables } from '../hooks/use-import-variables/use-import-variables' import { useVariables } from '../hooks/use-variables/use-variables' import { changeScopeForAll } from './utils/change-scope-all' import { deleteEntry } from './utils/delete-entry' import { parsedToForm } from './utils/file-to-form' import { validateKey, warningMessage } from './utils/form-check' -import { formatData, importVariablesWithMutations } from './utils/handle-submit' +import { formatData } from './utils/handle-submit' import { onDrop } from './utils/on-drop' import { triggerToggleAll } from './utils/trigger-toggle-all' @@ -304,9 +301,6 @@ export function ImportEnvironmentVariableModalFeature(props: ImportEnvironmentVa }) const { mutateAsync: importVariables, isLoading: isImportVariablesLoading } = useImportVariables() - const { mutateAsync: createVariable } = useCreateVariable() - const { mutateAsync: editVariable } = useEditVariable() - const { mutateAsync: deleteVariable } = useDeleteVariable() return ( @@ -325,23 +319,6 @@ export function ImportEnvironmentVariableModalFeature(props: ImportEnvironmentVa return } const vars = formatData(methods.getValues(), keys) - - if (scope === APIVariableScopeEnum.TERRAFORM) { - await importVariablesWithMutations({ - vars, - overwriteEnabled, - existingVars: existingEnvVars, - projectId: props.projectId, - environmentId: 'environmentId' in props ? props.environmentId : undefined, - serviceId: 'serviceId' in props ? props.serviceId : undefined, - createVariable, - editVariable, - deleteVariable, - }) - props.closeModal() - return - } - await importVariables({ serviceType: scope as unknown as ServiceTypeForVariableEnum, serviceId: parentId, diff --git a/libs/domains/variables/feature/src/lib/import-environment-variable-modal/utils/handle-submit.spec.tsx b/libs/domains/variables/feature/src/lib/import-environment-variable-modal/utils/handle-submit.spec.tsx index 6b1feda4d27..0697ecaa65b 100644 --- a/libs/domains/variables/feature/src/lib/import-environment-variable-modal/utils/handle-submit.spec.tsx +++ b/libs/domains/variables/feature/src/lib/import-environment-variable-modal/utils/handle-submit.spec.tsx @@ -1,5 +1,4 @@ -import { APIVariableScopeEnum } from 'qovery-typescript-axios' -import { formatData, importVariablesWithMutations } from './handle-submit' +import { formatData } from './handle-submit' describe('formatData()', () => { it('should format the data correctly', () => { @@ -31,92 +30,3 @@ describe('formatData()', () => { ]) }) }) - -describe('importVariablesWithMutations()', () => { - it('should skip variables already existing on the same scope when overwrite is disabled', async () => { - const createVariable = jest.fn() - const editVariable = jest.fn() - const deleteVariable = jest.fn() - - await importVariablesWithMutations({ - vars: [ - { - name: 'EXISTING_VAR', - value: 'new-value', - scope: APIVariableScopeEnum.TERRAFORM, - is_secret: false, - }, - ], - overwriteEnabled: false, - existingVars: [ - { - id: 'variable-id', - key: 'EXISTING_VAR', - value: 'old-value', - scope: APIVariableScopeEnum.TERRAFORM, - is_secret: false, - created_at: '2024-01-01T00:00:00Z', - }, - ], - projectId: 'project-id', - environmentId: 'environment-id', - serviceId: 'service-id', - createVariable, - editVariable, - deleteVariable, - }) - - expect(createVariable).not.toHaveBeenCalled() - expect(editVariable).not.toHaveBeenCalled() - expect(deleteVariable).not.toHaveBeenCalled() - }) - - it('should delete and recreate a variable when overwrite is enabled and secret mode changes', async () => { - const createVariable = jest.fn().mockResolvedValue(undefined) - const editVariable = jest.fn() - const deleteVariable = jest.fn().mockResolvedValue(undefined) - - await importVariablesWithMutations({ - vars: [ - { - name: 'EXISTING_VAR', - value: 'new-value', - scope: APIVariableScopeEnum.TERRAFORM, - is_secret: true, - }, - ], - overwriteEnabled: true, - existingVars: [ - { - id: 'variable-id', - key: 'EXISTING_VAR', - value: 'old-value', - scope: APIVariableScopeEnum.TERRAFORM, - is_secret: false, - description: 'existing description', - created_at: '2024-01-01T00:00:00Z', - }, - ], - projectId: 'project-id', - environmentId: 'environment-id', - serviceId: 'service-id', - createVariable, - editVariable, - deleteVariable, - }) - - expect(deleteVariable).toHaveBeenCalledWith({ variableId: 'variable-id' }) - expect(createVariable).toHaveBeenCalledWith({ - variableRequest: { - key: 'EXISTING_VAR', - value: 'new-value', - is_secret: true, - variable_scope: APIVariableScopeEnum.TERRAFORM, - variable_parent_id: 'service-id', - description: 'existing description', - enable_interpolation_in_file: undefined, - }, - }) - expect(editVariable).not.toHaveBeenCalled() - }) -}) diff --git a/libs/domains/variables/feature/src/lib/import-environment-variable-modal/utils/handle-submit.ts b/libs/domains/variables/feature/src/lib/import-environment-variable-modal/utils/handle-submit.ts index 0617b6326bf..3b93ebf2fe3 100644 --- a/libs/domains/variables/feature/src/lib/import-environment-variable-modal/utils/handle-submit.ts +++ b/libs/domains/variables/feature/src/lib/import-environment-variable-modal/utils/handle-submit.ts @@ -1,10 +1,4 @@ -import { - APIVariableScopeEnum, - type VariableEditRequest, - type VariableImportRequestVarsInner, - type VariableRequest, -} from 'qovery-typescript-axios' -import { type EnvironmentVariableSecretOrPublic } from '@qovery/shared/interfaces' +import { type APIVariableScopeEnum, type VariableImportRequestVarsInner } from 'qovery-typescript-axios' export function formatData(data: { [key: string]: string }, keys: string[]) { const vars: VariableImportRequestVarsInner[] = [] @@ -20,102 +14,3 @@ export function formatData(data: { [key: string]: string }, keys: string[]) { } return vars } - -interface ImportVariablesWithMutationsProps { - vars: VariableImportRequestVarsInner[] - overwriteEnabled: boolean - existingVars: EnvironmentVariableSecretOrPublic[] - projectId: string - environmentId?: string - serviceId?: string - createVariable: (props: { variableRequest: VariableRequest }) => Promise - editVariable: (props: { variableId: string; variableEditRequest: VariableEditRequest }) => Promise - deleteVariable: (props: { variableId: string }) => Promise -} - -function getVariableParentId( - scope: APIVariableScopeEnum, - ids: Pick -) { - switch (scope) { - case APIVariableScopeEnum.PROJECT: - return ids.projectId - case APIVariableScopeEnum.ENVIRONMENT: - if (!ids.environmentId) { - throw new Error('Missing environmentId for environment-scoped variable import') - } - return ids.environmentId - case APIVariableScopeEnum.APPLICATION: - case APIVariableScopeEnum.CONTAINER: - case APIVariableScopeEnum.JOB: - case APIVariableScopeEnum.HELM: - case APIVariableScopeEnum.TERRAFORM: - if (!ids.serviceId) { - throw new Error('Missing serviceId for service-scoped variable import') - } - return ids.serviceId - default: - throw new Error(`Unsupported variable scope for import: ${scope}`) - } -} - -function toVariableRequest( - variable: VariableImportRequestVarsInner, - ids: Pick, - existingVar?: EnvironmentVariableSecretOrPublic -): VariableRequest { - return { - key: variable.name, - value: variable.value, - is_secret: variable.is_secret, - variable_scope: variable.scope, - variable_parent_id: getVariableParentId(variable.scope, ids), - description: existingVar?.description, - enable_interpolation_in_file: existingVar?.enable_interpolation_in_file, - } -} - -export async function importVariablesWithMutations({ - vars, - overwriteEnabled, - existingVars, - projectId, - environmentId, - serviceId, - createVariable, - editVariable, - deleteVariable, -}: ImportVariablesWithMutationsProps) { - for (const variable of vars) { - const existingVar = existingVars.find((envVar) => envVar.key === variable.name && envVar.scope === variable.scope) - - if (!existingVar) { - await createVariable({ - variableRequest: toVariableRequest(variable, { projectId, environmentId, serviceId }), - }) - continue - } - - if (!overwriteEnabled) { - continue - } - - if (existingVar.is_secret === variable.is_secret) { - await editVariable({ - variableId: existingVar.id, - variableEditRequest: { - key: variable.name, - value: variable.value, - description: existingVar.description, - enable_interpolation_in_file: existingVar.enable_interpolation_in_file, - }, - }) - continue - } - - await deleteVariable({ variableId: existingVar.id }) - await createVariable({ - variableRequest: toVariableRequest(variable, { projectId, environmentId, serviceId }, existingVar), - }) - } -} diff --git a/libs/domains/variables/feature/src/lib/variable-list/__snapshots__/variable-list.spec.tsx.snap b/libs/domains/variables/feature/src/lib/variable-list/__snapshots__/variable-list.spec.tsx.snap index f32391617af..ffba2e1e28c 100644 --- a/libs/domains/variables/feature/src/lib/variable-list/__snapshots__/variable-list.spec.tsx.snap +++ b/libs/domains/variables/feature/src/lib/variable-list/__snapshots__/variable-list.spec.tsx.snap @@ -20,7 +20,7 @@ exports[`VariableList should match snapshot 1`] = ` class="no-scrollbar overflow-y-hidden overflow-x-scroll rounded-md border border-neutral bg-surface-neutral" >
+
diff --git a/libs/domains/variables/feature/src/lib/variable-list/variable-list.tsx b/libs/domains/variables/feature/src/lib/variable-list/variable-list.tsx index a66a4013dfd..c4af355ee76 100644 --- a/libs/domains/variables/feature/src/lib/variable-list/variable-list.tsx +++ b/libs/domains/variables/feature/src/lib/variable-list/variable-list.tsx @@ -614,7 +614,7 @@ export function VariableList({ const hideServiceLinkColumn = isServiceScope && !isBuiltInTable return (
- + {tableInstance.getHeaderGroups().map((headerGroup) => ( From da199189a0704e65fb2f27ce877c3023be6cd5e8 Mon Sep 17 00:00:00 2001 From: RemiBonnet Date: Fri, 20 Mar 2026 14:45:50 +0100 Subject: [PATCH 5/6] fix(variables): handle optional serviceType in service variables component --- .../service/$serviceId/variables.tsx | 2 +- .../input-text-small.spec.tsx | 26 +++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables.tsx index 8ddb23842b0..85aa5d266a4 100644 --- a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables.tsx +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables.tsx @@ -80,7 +80,7 @@ function RouteComponent() { environmentId={environmentId} serviceId={serviceId} closeModal={closeModal} - serviceType={service.serviceType} + serviceType={service?.serviceType} /> ), options: { diff --git a/libs/shared/ui/src/lib/components/inputs/input-text-small/input-text-small.spec.tsx b/libs/shared/ui/src/lib/components/inputs/input-text-small/input-text-small.spec.tsx index 7468c322a9e..8c0139d4362 100644 --- a/libs/shared/ui/src/lib/components/inputs/input-text-small/input-text-small.spec.tsx +++ b/libs/shared/ui/src/lib/components/inputs/input-text-small/input-text-small.spec.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from '__tests__/utils/setup-jest' +import { renderWithProviders, screen } from '@qovery/shared/util-tests' import InputTextSmall, { type InputTextSmallProps } from './input-text-small' describe('InputTextSmall', () => { @@ -11,14 +11,14 @@ describe('InputTextSmall', () => { }) it('should render successfully', () => { - const { baseElement } = render() + const { baseElement } = renderWithProviders() expect(baseElement).toBeTruthy() }) - it('should apply the accurate classes as input actions', () => { + it('should apply the accurate classes as input actions', async () => { props.error = 'some error' - const { rerender } = render() + const { rerender, userEvent } = renderWithProviders() let inputContainer = screen.queryByTestId('input') as HTMLDivElement @@ -31,17 +31,17 @@ describe('InputTextSmall', () => { inputContainer = screen.queryByTestId('input') as HTMLDivElement const input = screen.getByRole('textbox') - fireEvent.change(input, { target: { value: 'some new text value' } }) + await userEvent.type(input, 'some new text value') expect(inputContainer).not.toHaveClass('input--error') }) it('should set the text value when the input event is emitted', async () => { - render() + const { userEvent } = renderWithProviders() const input = screen.getByRole('textbox') - fireEvent.change(input, { target: { value: 'some new text value' } }) + await userEvent.type(input, 'some new text value') expect(input as HTMLInputElement).toHaveValue('some new text value') }) @@ -53,7 +53,7 @@ describe('InputTextSmall', () => { }) it('should render the error icon and not print the bottom error', () => { - render() + renderWithProviders() const warningIcon = screen.getByTestId('warning-icon-left') expect(warningIcon).toBeInTheDocument() @@ -71,7 +71,7 @@ describe('InputTextSmall', () => { }) it('should render the error icon and not print the bottom error', () => { - render() + renderWithProviders() const warningIcon = screen.getByTestId('warning-icon-left') expect(warningIcon).toBeInTheDocument() @@ -83,23 +83,23 @@ describe('InputTextSmall', () => { }) it('should render icon', async () => { - render() + const { userEvent } = renderWithProviders() const input = screen.getByRole('textbox') - fireEvent.change(input, { target: { value: 'some new text value' } }) + await userEvent.type(input, 'some new text value') expect(input as HTMLInputElement).toHaveValue('some new text value') }) it('should have a show hide button and button should toggle input type', async () => { - render() + const { userEvent } = renderWithProviders() const button = screen.getByTestId('show-password-button') expect(button).toBeInTheDocument() const input = screen.getByRole('textbox') - fireEvent.click(button) + await userEvent.click(button) expect(input).toHaveAttribute('type', 'password') }) }) From d37b994b36f21c8abdf42e8f799b9b0c45d32c18 Mon Sep 17 00:00:00 2001 From: RemiBonnet Date: Fri, 20 Mar 2026 17:05:39 +0100 Subject: [PATCH 6/6] refactor(import-environment-variable-modal): update modal structure with Dialog and Section components for improved accessibility and styling --- .../import-environment-variable-modal.tsx | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/libs/domains/variables/feature/src/lib/import-environment-variable-modal/import-environment-variable-modal.tsx b/libs/domains/variables/feature/src/lib/import-environment-variable-modal/import-environment-variable-modal.tsx index 171e8b1daf7..9b6a389881a 100644 --- a/libs/domains/variables/feature/src/lib/import-environment-variable-modal/import-environment-variable-modal.tsx +++ b/libs/domains/variables/feature/src/lib/import-environment-variable-modal/import-environment-variable-modal.tsx @@ -1,10 +1,21 @@ +import * as Dialog from '@radix-ui/react-dialog' import { APIVariableScopeEnum, type ServiceTypeForVariableEnum } from 'qovery-typescript-axios' import { useCallback, useEffect, useState } from 'react' import { type DropzoneRootProps, useDropzone } from 'react-dropzone' import { Controller, FormProvider, useForm, useFormContext } from 'react-hook-form' import { type ServiceType } from '@qovery/domains/services/data-access' import { type EnvironmentVariableSecretOrPublic } from '@qovery/shared/interfaces' -import { Button, Dropzone, Icon, InputSelectSmall, InputTextSmall, InputToggle, useModal } from '@qovery/shared/ui' +import { + Button, + Dropzone, + Heading, + Icon, + InputSelectSmall, + InputTextSmall, + InputToggle, + useModal, +} from '@qovery/shared/ui' +import { Section } from '@qovery/shared/ui' import { computeAvailableScope, generateScopeLabel, parseEnvText } from '@qovery/shared/util-js' import { useImportVariables } from '../hooks/use-import-variables/use-import-variables' import { useVariables } from '../hooks/use-variables/use-variables' @@ -69,8 +80,10 @@ export function ImportEnvironmentVariableModal(props: ImportEnvironmentVariableM const pattern = /^[^\s]+$/ return ( -
-

Import variables from .env file

+
+ + Import variables from .env file + {props.showDropzone ? (
)} -
+
) }