Files
nimbus/resources/js/components/common/KeyValueParameters/KeyValueParameters.vue
Mazen Touati 6ba071dc98 test: front-end tests cleanup (round 1)
the aim is to make the tests more about the behavior rather than implementation, add some missing tests, and improve the code.
2025-11-16 19:03:40 +01:00

267 lines
8.9 KiB
Vue

<script setup lang="ts">
import { AppBadge } from '@/components/base/badge';
import { AppButton } from '@/components/base/button';
import { AppInput } from '@/components/base/input';
import {
AppSelect,
AppSelectContent,
AppSelectItem,
AppSelectTrigger,
AppSelectValue,
} from '@/components/base/select';
import { AppSwitch } from '@/components/base/switch';
import { useKeyValueParameters } from '@/composables/ui/useKeyValueParameters';
import { ExtendedParameter, ParametersExternalContract } from '@/interfaces/ui';
import { useValueGeneratorStore } from '@/stores';
import { cn } from '@/utils';
import {
EyeClosedIcon,
EyeIcon,
PlusIcon,
SparklesIcon,
Trash2Icon,
} from 'lucide-vue-next';
import { computed, type HTMLAttributes, ref } from 'vue';
import AppTooltipWrapper from '../../base/tooltip/AppTooltipWrapper.vue';
/*
* Props.
*/
const props = withDefaults(
defineProps<{
freeFormTypes?: boolean;
class?: HTMLAttributes['class'];
}>(),
{
freeFormTypes: false,
class: undefined,
},
);
/*
* Model.
*/
const model = defineModel<ParametersExternalContract[]>();
const modelRef = computed({
get: () => model.value ?? [],
set: value => (model.value = value),
});
/*
* Composables.
*/
const {
parameters,
deletingAll,
isParameterMarkedForDeletion,
areAllParametersDisabled,
addNewEmptyParameter,
toggleAllParametersEnabledState,
triggerParameterDeletion,
deleteAllParameters,
} = useKeyValueParameters(modelRef);
const { openCommand, closeCommand } = useValueGeneratorStore();
// Focus management
const focusedInputIndex = ref<number | null>(null);
const focusedInputRef = ref<HTMLInputElement | null>(null);
/*
* Event Handlers.
*/
const handleValueInputFocus = (index: number, inputRef: HTMLInputElement) => {
focusedInputIndex.value = index;
focusedInputRef.value = inputRef;
};
const handleValueInputBlur = (event: FocusEvent) => {
const relatedTarget = event.relatedTarget as HTMLElement;
// Prevent blur if focus is moving to the value generator menu
if (relatedTarget?.closest('[data-ValueGenerator-focus-hook]')) {
return;
}
focusedInputIndex.value = null;
focusedInputRef.value = null;
// Delay command closure to prevent race condition between blur and command opening
// Problem: When user clicks generator button, blur fires immediately and closes command
// before openCommand can complete, causing command to flash open then close
// Solution: 100ms delay allows openCommand to finish before closeCommand executes
setTimeout(closeCommand, 100);
};
const handleGeneratorClick = () => {
if (!focusedInputRef.value) {
return;
}
openCommand(focusedInputRef.value);
};
const handleDeleteParameter = (index: number) => {
triggerParameterDeletion(parameters.value, index);
};
/*
* Computed Properties.
*/
const shouldShowGeneratorIcon = (index: number, parameter: ExtendedParameter) => {
return focusedInputIndex.value === index && parameter.enabled;
};
</script>
<template>
<div
:class="cn('flex h-full flex-col overflow-hidden', props.class)"
data-testid="kv-container"
>
<!-- Header Actions -->
<div class="flex h-8 items-center overflow-hidden border-b p-0">
<AppButton
variant="ghost"
size="xs"
class="px-panel h-full -translate-x-0.5 rounded-none text-xs"
data-testid="add-button"
@click="addNewEmptyParameter"
>
<PlusIcon />
Add
</AppButton>
<AppButton
variant="ghost"
size="xs"
class="px-panel h-full -translate-x-0.5 rounded-none text-xs"
:disabled="parameters.length === 0"
data-testid="enable-all-button"
@click="toggleAllParametersEnabledState"
>
<EyeIcon v-if="areAllParametersDisabled" />
<EyeClosedIcon v-else />
{{ areAllParametersDisabled ? 'Enable All' : 'Disable All' }}
</AppButton>
<AppButton
variant="ghost"
size="xs"
class="px-panel h-full -translate-x-0.5 rounded-none text-xs"
:class="{
'!text-red-500 hover:text-red-500 dark:!text-rose-700 dark:hover:text-red-700':
deletingAll,
}"
:disabled="parameters.length === 0"
data-testid="delete-all-button"
@click="deleteAllParameters"
>
<Trash2Icon />
Delete All
</AppButton>
</div>
<!-- Parameters List -->
<div class="flex-1 overflow-y-auto">
<div
v-for="(parameter, index) in parameters"
:key="parameter.id"
class="flex h-8 border-b"
data-testid="parameter-row"
>
<!-- Parameter Inputs -->
<div class="flex flex-1">
<!-- Type Selector (conditional) -->
<AppSelect
v-if="freeFormTypes"
v-model="parameter.type"
default-value="text"
>
<AppSelectTrigger
class="pl-panel h-full w-[80px] rounded-none border-0 border-r p-0 text-xs shadow-none focus:ring-0"
data-testid="type-selector"
>
<AppSelectValue placeholder="Select a Type" />
</AppSelectTrigger>
<AppSelectContent>
<AppSelectItem value="text">Text</AppSelectItem>
<AppSelectItem value="file" disabled>
File
<AppBadge variant="outline" class="ml-0.5 px-1 py-0">
soon
</AppBadge>
</AppSelectItem>
</AppSelectContent>
</AppSelect>
<!-- Key Input -->
<AppInput
v-model="parameter.key"
placeholder="Key"
class="selector-key h-full flex-1 rounded-none border-0 border-r shadow-none focus:ring-0 focus-visible:ring-0"
:disabled="!parameter.enabled"
name="kv-key"
data-testid="kv-key"
/>
<!-- Value Input -->
<AppInput
v-model="parameter.value"
placeholder="Value"
class="pl-panel h-full flex-1 rounded-none border-0 border-r shadow-none focus:ring-0 focus-visible:ring-0"
:disabled="!parameter.enabled"
name="kv-value"
data-testid="kv-value"
@focus="handleValueInputFocus(index, $event.target)"
@blur="handleValueInputBlur"
/>
</div>
<!-- Enable/Disable Toggle -->
<div class="flex items-center justify-center border-r px-2">
<AppTooltipWrapper value="Enable/Disable">
<AppSwitch v-model="parameter.enabled" class="h-4 w-8" />
</AppTooltipWrapper>
</div>
<!-- Action Button: Generator or Delete -->
<div
v-if="shouldShowGeneratorIcon(index, parameter)"
class="flex cursor-pointer items-center justify-center p-2"
title="Generate Value"
data-testid="generator-button"
@mousedown.prevent="handleGeneratorClick"
>
<SparklesIcon
class="text-subtle size-4 transition-colors hover:text-zinc-800 dark:hover:text-zinc-200"
/>
</div>
<div
v-else
class="flex items-center justify-center px-2"
data-testid="delete-button"
>
<AppTooltipWrapper
value="Delete"
:on-click="() => handleDeleteParameter(index)"
>
<Trash2Icon
class="size-4"
:class="{
'text-rose-500 dark:text-rose-700':
isParameterMarkedForDeletion(parameter.id),
}"
/>
</AppTooltipWrapper>
</div>
</div>
</div>
</div>
</template>