feat(routes): auto-select route variables on click (#24)

* feat(routes): auto-select route variables on click

* style: apply TS style fixes
This commit is contained in:
Mazen Touati
2025-11-22 19:45:15 +01:00
committed by GitHub
parent 052cddeca6
commit 8e05ce4978
3 changed files with 627 additions and 7 deletions

View File

@@ -10,13 +10,14 @@ import {
AppSelectTrigger,
AppSelectValue,
} from '@/components/base/select';
import AppTooltipWrapper from '@/components/base/tooltip/AppTooltipWrapper.vue';
import { useRouteSegmentSelection } from '@/composables/request/useRouteSegmentSelection';
import { RouteDefinition } from '@/interfaces/routes/routes';
import { useConfigStore, useRequestStore } from '@/stores';
import { generateCurlCommand } from '@/utils/request';
import { cn } from '@/utils/ui';
import { CodeXml, CornerDownLeftIcon } from 'lucide-vue-next';
import { computed, HTMLAttributes, ref } from 'vue';
import AppTooltipWrapper from '@/components/base/tooltip/AppTooltipWrapper.vue';
import { useConfigStore } from '@/stores';
import { generateCurlCommand } from '@/utils/request';
import CurlExportDialog from './CurlExportDialog.vue';
interface RequestBuilderEndpointProps {
@@ -25,9 +26,6 @@ interface RequestBuilderEndpointProps {
const props = defineProps<RequestBuilderEndpointProps>();
import { RouteDefinition } from '@/interfaces/routes/routes';
import { useRequestStore } from '@/stores';
const availableMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
/*
@@ -75,6 +73,13 @@ const currentRouteUnsupportedMethods = computed(() => {
);
});
/*
* Route segment selection.
*/
const { handleClick: autoSelectRouteVariableSegmentWhenApplicable } =
useRouteSegmentSelection({ endpoint });
/*
* Actions.
*/
@@ -157,6 +162,7 @@ const populateCurlCommandExporterDialog = () => {
v-model="endpoint"
class="h-full flex-1 rounded-none border-0 text-xs shadow-none focus:ring-0 focus-visible:ring-0"
placeholder="<endpoint>"
@click="autoSelectRouteVariableSegmentWhenApplicable"
@keydown="executeCurrentRequestWhenEnterIsPressed"
/>
<div class="flex gap-2 pr-2">

View File

@@ -0,0 +1,285 @@
// composables/useRouteSegmentSelection.ts
import { nextTick, ref, Ref, watch } from 'vue';
export interface UseRouteSegmentSelectionOptions {
/**
* The endpoint URL to watch for changes
*/
endpoint: Ref<string>;
}
export interface UseRouteSegmentSelectionReturn {
/**
* Handler to be attached to the input's click event
*/
handleClick: (event: MouseEvent) => void;
/**
* Manually identify variable segments in a URL
*/
identifyVariableSegments: (url: string) => number[];
/**
* The indices of segments that were originally variables
*/
variableSegmentIndices: Ref<number[]>;
}
interface SegmentPosition {
start: number;
end: number;
}
/**
* Composable for handling automatic selection of route segments in endpoint inputs.
*
* This composable tracks variable segments (enclosed in braces like {id}) in route URLs
* and enables automatic selection when users click on those segments, even after
* they've been modified to contain actual values.
*
* @example
* ```ts
* const endpoint = ref('api/users/{id}/posts/{postId}');
* const { handleClick } = useRouteSegmentSelection({ endpoint });
*
* // In template: <input v-model="endpoint" @click="handleClick" />
* // Clicking on {id} or {postId} will select the entire segment
* // Even after changing to 'api/users/123/posts/456', clicking on '123' or '456' still selects the whole segment
* ```
*/
export function useRouteSegmentSelection(
options: UseRouteSegmentSelectionOptions,
): UseRouteSegmentSelectionReturn {
const { endpoint } = options;
const variableSegmentIndices = ref<number[]>([]);
/**
* Identifies which segments in a URL path are variables (wrapped in braces).
*
* @param url - The URL path to analyze (e.g., 'api/users/{id}/posts')
* @returns Array of segment indices that are variables (0-based)
*/
const identifyVariableSegments = (url: string): number[] => {
const segments = url.split('/');
const indices: number[] = [];
segments.forEach((segment: string, index: number) => {
const isVariable = segment.startsWith('{') && segment.endsWith('}');
if (isVariable) {
indices.push(index);
}
});
return indices;
};
/**
* Checks if a character is an opening brace.
*/
const isOpeningBrace = (char: string): boolean => char === '{';
/**
* Checks if a character is a closing brace.
*/
const isClosingBrace = (char: string): boolean => char === '}';
/**
* Searches backward from cursor position to find an opening brace.
* Returns -1 if a closing brace is found first or no opening brace exists.
*/
const findOpeningBracePosition = (text: string, cursorPos: number): number => {
for (let i = cursorPos; i >= 0; i--) {
if (isOpeningBrace(text[i])) {
return i;
}
if (isClosingBrace(text[i])) {
return -1;
}
}
return -1;
};
/**
* Searches forward from a position to find a closing brace.
* Returns -1 if an opening brace is found first or no closing brace exists.
*/
const findClosingBracePosition = (text: string, startPos: number): number => {
for (let i = startPos; i < text.length; i++) {
if (isClosingBrace(text[i])) {
return i + 1; // +1 to include the closing brace
}
if (isOpeningBrace(text[i])) {
return -1;
}
}
return -1;
};
/**
* Finds the position of a segment containing braces at the cursor position.
*
* @param text - The full input text
* @param cursorPos - Current cursor position
* @returns Segment position or null if not found
*/
const findBraceSegment = (
text: string,
cursorPos: number,
): SegmentPosition | null => {
const openingBracePos = findOpeningBracePosition(text, cursorPos);
if (openingBracePos === -1) {
return null;
}
const closingBracePos = findClosingBracePosition(text, cursorPos);
if (closingBracePos === -1) {
return null;
}
return {
start: openingBracePos,
end: closingBracePos,
};
};
/**
* Finds which segment index the cursor is currently in.
*/
const findSegmentIndexAtCursor = (text: string, cursorPos: number): number | null => {
const segments = text.split('/');
let charCount = 0;
for (let i = 0; i < segments.length; i++) {
const segmentLength = segments[i].length;
const segmentStart = charCount;
const segmentEnd = charCount + segmentLength;
const isCursorInSegment =
cursorPos >= segmentStart && cursorPos <= segmentEnd;
if (isCursorInSegment) {
return i;
}
charCount += segmentLength + 1; // +1 for the '/' separator
}
return null;
};
/**
* Calculates the start and end positions of a segment by its index.
*/
const getSegmentPosition = (
text: string,
segmentIndex: number,
): SegmentPosition | null => {
const segments = text.split('/');
if (segmentIndex >= segments.length) {
return null;
}
let charCount = 0;
for (let i = 0; i < segmentIndex; i++) {
charCount += segments[i].length + 1; // +1 for the '/' separator
}
return {
start: charCount,
end: charCount + segments[segmentIndex].length,
};
};
/**
* Finds the position of a segment that was originally a variable.
*
* @param text - The full input text
* @param cursorPos - Current cursor position
* @returns Segment position or null if not found
*/
const findOriginalVariableSegment = (
text: string,
cursorPos: number,
): SegmentPosition | null => {
const segmentIndex = findSegmentIndexAtCursor(text, cursorPos);
if (segmentIndex === null) {
return null;
}
const isOriginalVariable = variableSegmentIndices.value.includes(segmentIndex);
if (!isOriginalVariable) {
return null;
}
return getSegmentPosition(text, segmentIndex);
};
/**
* Selects a text range in the input element.
*/
const selectRange = (input: HTMLInputElement, position: SegmentPosition): void => {
nextTick(() => {
input.setSelectionRange(position.start, position.end);
});
};
/**
* Handles click events on the input to auto-select route segments.
* Priority: 1) Segments with braces, 2) Segments that were originally variables
*/
const handleClick = (event: MouseEvent): void => {
const input = event.target as HTMLInputElement;
if (!input || input.selectionStart === null) {
return;
}
const cursorPos = input.selectionStart;
const text = input.value;
// First priority: Check if we're inside a segment with braces
const braceSegment = findBraceSegment(text, cursorPos);
if (braceSegment) {
selectRange(input, braceSegment);
return;
}
// Second priority: Check if cursor is in a segment that was originally a variable
const originalSegment = findOriginalVariableSegment(text, cursorPos);
if (originalSegment) {
selectRange(input, originalSegment);
}
};
// Watch for endpoint changes to track variable segments
watch(
endpoint,
(newValue: string) => {
const hasVariableSegments = newValue?.includes('{');
if (hasVariableSegments) {
variableSegmentIndices.value = identifyVariableSegments(newValue);
}
},
{ immediate: true },
);
return {
handleClick,
identifyVariableSegments,
variableSegmentIndices,
};
}

View File

@@ -0,0 +1,329 @@
import { useRouteSegmentSelection } from '@/composables/request/useRouteSegmentSelection';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { nextTick, ref } from 'vue';
describe('useRouteSegmentSelection', () => {
describe('identifyVariableSegments', () => {
it('identifies single variable segment', () => {
const endpoint = ref('api/users/{id}');
const { identifyVariableSegments } = useRouteSegmentSelection({ endpoint });
const result = identifyVariableSegments('api/users/{id}');
expect(result).toEqual([2]);
});
it('identifies multiple variable segments', () => {
const endpoint = ref('api/users/{userId}/posts/{postId}');
const { identifyVariableSegments } = useRouteSegmentSelection({ endpoint });
const result = identifyVariableSegments('api/users/{userId}/posts/{postId}');
expect(result).toEqual([2, 4]);
});
it('returns empty array when no variable segments exist', () => {
const endpoint = ref('api/users/list');
const { identifyVariableSegments } = useRouteSegmentSelection({ endpoint });
const result = identifyVariableSegments('api/users/list');
expect(result).toEqual([]);
});
it('handles empty string', () => {
const endpoint = ref('');
const { identifyVariableSegments } = useRouteSegmentSelection({ endpoint });
const result = identifyVariableSegments('');
expect(result).toEqual([]);
});
it('ignores partial braces', () => {
const endpoint = ref('api/{users/posts}');
const { identifyVariableSegments } = useRouteSegmentSelection({ endpoint });
const result = identifyVariableSegments('api/{users/posts}');
expect(result).toEqual([]);
});
});
describe('variableSegmentIndices tracking', () => {
it('initializes with variable segments from endpoint', () => {
const endpoint = ref('api/users/{id}/posts/{postId}');
const { variableSegmentIndices } = useRouteSegmentSelection({ endpoint });
expect(variableSegmentIndices.value).toEqual([2, 4]);
});
it('updates when endpoint changes to include braces', async () => {
const endpoint = ref('api/users/123');
const { variableSegmentIndices } = useRouteSegmentSelection({ endpoint });
expect(variableSegmentIndices.value).toEqual([]);
endpoint.value = 'api/users/{id}';
await nextTick();
expect(variableSegmentIndices.value).toEqual([2]);
});
it('does not update when endpoint changes without braces', async () => {
const endpoint = ref('api/users/{id}');
const { variableSegmentIndices } = useRouteSegmentSelection({ endpoint });
expect(variableSegmentIndices.value).toEqual([2]);
endpoint.value = 'api/users/123';
await nextTick();
expect(variableSegmentIndices.value).toEqual([2]); // Should remain unchanged
});
});
describe('handleClick', () => {
let mockInput: HTMLInputElement;
beforeEach(() => {
mockInput = document.createElement('input');
mockInput.setSelectionRange = vi.fn();
document.body.appendChild(mockInput);
});
it('selects segment with braces when clicked', async () => {
const endpoint = ref('api/users/{id}');
const { handleClick } = useRouteSegmentSelection({ endpoint });
mockInput.value = 'api/users/{id}';
mockInput.selectionStart = 12; // Inside {id}
const event = new MouseEvent('click', { bubbles: true });
Object.defineProperty(event, 'target', {
value: mockInput,
enumerable: true,
});
handleClick(event);
await nextTick();
expect(mockInput.setSelectionRange).toHaveBeenCalledWith(10, 14); // <- Selects {id}
});
it('selects entire braced segment when clicking at opening brace', async () => {
const endpoint = ref('api/users/{userId}');
const { handleClick } = useRouteSegmentSelection({ endpoint });
mockInput.value = 'api/users/{userId}';
mockInput.selectionStart = 10; // <- At the {
const event = new MouseEvent('click', { bubbles: true });
Object.defineProperty(event, 'target', {
value: mockInput,
enumerable: true,
});
handleClick(event);
await nextTick();
expect(mockInput.setSelectionRange).toHaveBeenCalledWith(10, 18);
});
it('selects entire braced segment when clicking at closing brace', async () => {
const endpoint = ref('api/users/{userId}');
const { handleClick } = useRouteSegmentSelection({ endpoint });
mockInput.value = 'api/users/{userId}';
mockInput.selectionStart = 17; // <- At the }
const event = new MouseEvent('click', { bubbles: true });
Object.defineProperty(event, 'target', {
value: mockInput,
enumerable: true,
});
handleClick(event);
await nextTick();
expect(mockInput.setSelectionRange).toHaveBeenCalledWith(10, 18);
});
it('selects replaced variable segment when clicked', async () => {
const endpoint = ref('api/users/{id}');
const { handleClick } = useRouteSegmentSelection({ endpoint });
// User has replaced {id} with 123
mockInput.value = 'api/users/123';
mockInput.selectionStart = 11; // <- Inside 123
const event = new MouseEvent('click', { bubbles: true });
Object.defineProperty(event, 'target', {
value: mockInput,
enumerable: true,
});
handleClick(event);
await nextTick();
expect(mockInput.setSelectionRange).toHaveBeenCalledWith(10, 13); // <- Selects 123
});
it('selects multiple replaced variable segments independently', async () => {
const endpoint = ref('api/users/{userId}/posts/{postId}');
const { handleClick } = useRouteSegmentSelection({ endpoint });
mockInput.value = 'api/users/123/posts/456';
// Click on first replaced segment (123)
mockInput.selectionStart = 11;
let event = new MouseEvent('click', { bubbles: true });
Object.defineProperty(event, 'target', {
value: mockInput,
enumerable: true,
});
handleClick(event);
await nextTick();
expect(mockInput.setSelectionRange).toHaveBeenCalledWith(10, 13);
// Click on second replaced segment (456)
mockInput.selectionStart = 21;
event = new MouseEvent('click', { bubbles: true });
Object.defineProperty(event, 'target', {
value: mockInput,
enumerable: true,
});
handleClick(event);
await nextTick();
expect(mockInput.setSelectionRange).toHaveBeenCalledWith(20, 23);
});
it('does not select when clicking on non-variable segment', async () => {
const endpoint = ref('api/users/{id}');
const { handleClick } = useRouteSegmentSelection({ endpoint });
mockInput.value = 'api/users/{id}';
mockInput.selectionStart = 4; // <- Inside "users"
const event = new MouseEvent('click', { bubbles: true });
Object.defineProperty(event, 'target', {
value: mockInput,
enumerable: true,
});
handleClick(event);
await nextTick();
expect(mockInput.setSelectionRange).not.toHaveBeenCalled();
});
it('prioritizes braced segments over original variable segments', async () => {
const endpoint = ref('api/users/{id}');
const { handleClick } = useRouteSegmentSelection({ endpoint });
// User added braces back after replacing
mockInput.value = 'api/users/{newId}';
mockInput.selectionStart = 12; // <- Inside {newId}
const event = new MouseEvent('click', { bubbles: true });
Object.defineProperty(event, 'target', {
value: mockInput,
enumerable: true,
});
handleClick(event);
await nextTick();
expect(mockInput.setSelectionRange).toHaveBeenCalledWith(10, 17); // Selects {newId}
});
it('handles clicking at start of segment', async () => {
const endpoint = ref('api/users/{id}');
const { handleClick } = useRouteSegmentSelection({ endpoint });
mockInput.value = 'api/users/123';
mockInput.selectionStart = 10; // <- At the start of 123
const event = new MouseEvent('click', { bubbles: true });
Object.defineProperty(event, 'target', {
value: mockInput,
enumerable: true,
});
handleClick(event);
await nextTick();
expect(mockInput.setSelectionRange).toHaveBeenCalledWith(10, 13);
});
it('handles clicking at end of segment', async () => {
const endpoint = ref('api/users/{id}');
const { handleClick } = useRouteSegmentSelection({ endpoint });
mockInput.value = 'api/users/123';
mockInput.selectionStart = 13; // <- At end of 123
const event = new MouseEvent('click', { bubbles: true });
Object.defineProperty(event, 'target', {
value: mockInput,
enumerable: true,
});
handleClick(event);
await nextTick();
expect(mockInput.setSelectionRange).toHaveBeenCalledWith(10, 13);
});
it('handles empty segments gracefully', async () => {
const endpoint = ref('api/users/{}');
const { handleClick } = useRouteSegmentSelection({ endpoint });
mockInput.value = 'api/users/{}';
mockInput.selectionStart = 11; // <- Inside {}
const event = new MouseEvent('click', { bubbles: true });
Object.defineProperty(event, 'target', {
value: mockInput,
enumerable: true,
});
handleClick(event);
await nextTick();
expect(mockInput.setSelectionRange).toHaveBeenCalledWith(10, 12);
});
it('does not error when event target is null', async () => {
const endpoint = ref('api/users/{id}');
const { handleClick } = useRouteSegmentSelection({ endpoint });
const event = new MouseEvent('click', { bubbles: true });
expect(() => handleClick(event)).not.toThrow();
});
it('does not error when selectionStart is null', async () => {
const endpoint = ref('api/users/{id}');
const { handleClick } = useRouteSegmentSelection({ endpoint });
const mockInputWithoutSelection = document.createElement('input');
Object.defineProperty(mockInputWithoutSelection, 'selectionStart', {
value: null,
enumerable: true,
});
const event = new MouseEvent('click', { bubbles: true });
Object.defineProperty(event, 'target', {
value: mockInputWithoutSelection,
enumerable: true,
});
expect(() => handleClick(event)).not.toThrow();
});
});
});