Files
nimbus/resources/js/composables/ui/useKeyValueParameters.ts
Mazen Touati e1b844cee0 feat(history): add history viewer and rewind (#38)
* feat(ui): add `input group` base component

* feat(history): add history viewer and rewind

* test: update selector snapshot

* test: add PW base page

* style: apply TS style fixes

* chore(history): request history wiki

* chore(history): remove unwanted symbol

* chore: fix type

* style: apply TS style fixes
2026-01-17 20:50:00 +01:00

340 lines
9.6 KiB
TypeScript

import { keyValueParametersConfig } from '@/config';
import { ParameterContract } from '@/interfaces/ui';
import { ParameterType } from '@/interfaces/ui/key-value-parameters';
import { useCounter, watchDebounced } from '@vueuse/core';
import { computed, onBeforeMount, reactive, ref, Ref, watch } from 'vue';
/**
* Manages key-value parameter state with unidirectional data flow.
*
* @param modelValue - The current parameters from the parent (read-only)
* @param onUpdate - Callback to notify parent of parameter changes
*/
export function useKeyValueParameters(
modelValue: Ref<ParameterContract[]>,
onUpdate: (parameters: ParameterContract[]) => void,
) {
const { count: nextParameterId, inc: incrementParametersId } = useCounter();
const parameters: Ref<ParameterContract[]> = ref([]);
const createParameterSkeleton = (id: number): ParameterContract => ({
type: ParameterType.Text,
id,
key: '',
value: '',
enabled: true,
});
/*
* Deletion state management.
*/
interface DeletionState {
deleting: boolean;
timeoutId?: number;
}
const deletionStatesForParameters = reactive(new Map<number, DeletionState>());
const bulkDeletionState: Ref<DeletionState> = ref({ deleting: false });
/**
* Initiates deletion confirmation for a parameter.
* Returns true if this is the confirmation click (second click).
*/
const initiateParameterDeletion = (identifier: number): boolean => {
const state = deletionStatesForParameters.get(identifier);
if (state?.deleting) {
clearParameterDeletionState(identifier);
return true;
}
setParameterDeletionState(identifier);
return false;
};
/**
* Checks if a parameter is marked for deletion.
*/
const isParameterMarkedForDeletion = (parameterId: number): boolean => {
return deletionStatesForParameters.get(parameterId)?.deleting ?? false;
};
/**
* Clears deletion state for a parameter.
*/
const clearParameterDeletionState = (parameterId: number): void => {
const state = deletionStatesForParameters.get(parameterId);
if (state?.timeoutId) {
clearTimeout(state.timeoutId);
}
deletionStatesForParameters.delete(parameterId);
};
/**
* Sets deletion state with automatic timeout.
*/
const setParameterDeletionState = (parameterId: number): void => {
const timeoutId: number = window.setTimeout(() => {
deletionStatesForParameters.delete(parameterId);
}, keyValueParametersConfig.DELETION_CONFIRMATION_TIMEOUT);
deletionStatesForParameters.set(parameterId, {
deleting: true,
timeoutId,
});
};
/**
* Initiates bulk deletion confirmation.
* Returns true if this is the confirmation click (second click).
*/
const initiateBulkDeletion = (): boolean => {
if (bulkDeletionState.value.deleting) {
clearBulkDeletionState();
return true;
}
markForBulkDeletion();
return false;
};
/**
* Checks if bulk deletion is marked for confirmation.
*/
const isBulkDeletionMarked = (): boolean => {
return bulkDeletionState.value.deleting;
};
/**
* Clears bulk deletion state.
*/
const clearBulkDeletionState = (): void => {
if (bulkDeletionState.value.timeoutId) {
clearTimeout(bulkDeletionState.value.timeoutId);
}
bulkDeletionState.value = { deleting: false };
};
/**
* Sets bulk deletion state with automatic timeout.
*/
const markForBulkDeletion = (): void => {
const timeoutId = window.setTimeout(() => {
bulkDeletionState.value = { deleting: false };
}, keyValueParametersConfig.DELETION_CONFIRMATION_TIMEOUT);
bulkDeletionState.value = { deleting: true, timeoutId };
};
/**
* Clears all deletion states.
*/
const clearAllDeletionStates = (): void => {
deletionStatesForParameters.forEach((_, id) => clearParameterDeletionState(id));
clearBulkDeletionState();
};
/*
* Computed.
*/
const areAllParametersDisabled = computed(() =>
parameters.value.every(parameter => !parameter.enabled),
);
const deletingAll = computed(() => isBulkDeletionMarked());
/**
* Reconciliation logic to update internal parameters from the parent modelValue.
*
* This replaces the current parameters with the ones from the modelValue,
* but tries to preserve existing IDs for keys that haven't changed to maintain reactivity/focus.
*/
const updateParametersFromParentModel = (): void => {
const incoming = modelValue.value ?? [];
// Map current parameters by id for reconciliation
const currentById = new Map(
parameters.value.map(parameter => [parameter.id, parameter]),
);
const nextParameters: ParameterContract[] = incoming.map(external => {
const existing = currentById.get(external.id);
if (existing) {
// Create a new object instead of mutating the existing one
// This prevents shared references between history and active state
return {
...existing,
id: external.id,
key: external.key,
value: external.value,
type: external.type,
enabled: external.enabled,
};
}
incrementParametersId();
return { id: nextParameterId.value, ...external };
});
// If internal state is empty, we must ensure at least one skeleton
if (nextParameters.length === 0) {
incrementParametersId();
nextParameters.push(createParameterSkeleton(nextParameterId.value));
}
// Only update if the resulting content is different from current internal state
// to avoid triggering redundant observers.
if (JSON.stringify(parameters.value) !== JSON.stringify(incoming)) {
parameters.value = nextParameters;
}
};
/**
* Notifies parent of parameter changes with deep cloned data.
* This prevents shared references and ensures unidirectional data flow.
*/
const notifyParentOfChanges = (): void => {
// Deep clone to prevent shared references
const clonedParameters = parameters.value.map(p => ({
id: p.id,
type: p.type,
key: p.key,
value: p.value,
enabled: p.enabled,
}));
onUpdate(clonedParameters);
};
// Watch for internal changes to notify parent
watchDebounced(
parameters,
() => {
notifyParentOfChanges();
},
{
deep: true,
debounce: keyValueParametersConfig.SYNC_DEBOUNCE_DELAY,
},
);
// Watch for external changes to sync from parent.
watch(
modelValue,
() => {
updateParametersFromParentModel();
},
{ deep: true },
);
// Initialize parameters from parent model
onBeforeMount(() => {
updateParametersFromParentModel();
if (parameters.value.length === 0) {
addNewEmptyParameter();
}
});
/**
* Adds a new empty parameter to the list for user input.
*/
const addNewEmptyParameter = (): void => {
incrementParametersId();
const newParameter = createParameterSkeleton(nextParameterId.value);
parameters.value.push(newParameter);
};
/**
* Toggles all parameters between enabled/disabled states.
*/
const toggleAllParametersEnabledState = () => {
const shouldEnableAll = areAllParametersDisabled.value;
parameters.value.forEach(
(parameter: ParameterContract) => (parameter.enabled = shouldEnableAll),
);
};
/**
* Handles two-step deletion confirmation for individual parameters.
*
* Prevents accidental deletions by requiring a second click within the configured timeout.
* First click marks for deletion, second click removes immediately.
*/
const triggerParameterDeletion = (
parameters: ParameterContract[],
index: number,
): void => {
const parameter = parameters[index];
if (!parameter) {
return;
}
const shouldDelete = initiateParameterDeletion(index);
if (!shouldDelete) {
return;
}
parameters.splice(index, 1);
};
/**
* Handles two-step deletion confirmation for all parameters.
*
* Prevents accidental bulk deletion by requiring confirmation within the configured timeout.
*/
const deleteAllParameters = (): void => {
const shouldDelete = initiateBulkDeletion();
if (!shouldDelete) {
return;
}
parameters.value = [];
};
/**
* Checks if a parameter is marked for deletion
*/
const checkParameterDeletion = (id: number): boolean => {
return isParameterMarkedForDeletion(id);
};
return {
// State
parameters,
deletingAll,
areAllParametersDisabled,
// Actions
addNewEmptyParameter,
toggleAllParametersEnabledState,
triggerParameterDeletion,
deleteAllParameters,
updateParametersFromParentModel,
// Utilities
isParameterMarkedForDeletion: checkParameterDeletion,
clearAllDeletionStates,
};
}