-
Notifications
You must be signed in to change notification settings - Fork 882
feat(ui): use backend normalize method to validate user-entered branch names #12488
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,37 +1,108 @@ | ||
| <script lang="ts"> | ||
| import { Textbox } from "@gitbutler/ui"; | ||
| import { slugify } from "@gitbutler/ui/utils/string"; | ||
| import { STACK_SERVICE } from "$lib/stacks/stackService.svelte"; | ||
| import { debounce } from "$lib/utils/debounce"; | ||
| import { inject } from "@gitbutler/core/context"; | ||
| import { Icon, Textbox } from "@gitbutler/ui"; | ||
|
|
||
| type Props = { | ||
| value?: string; | ||
| helperText?: string; | ||
| onslugifiedvalue?: (slugified: string | undefined) => void; | ||
| onnormalizedvalue?: (normalized: string | undefined) => void; | ||
| onvalidationchange?: (isValid: boolean) => void; | ||
| [key: string]: any; | ||
| }; | ||
|
|
||
| let { | ||
| value = $bindable(), | ||
| helperText, | ||
|
|
||
| onslugifiedvalue, | ||
| onnormalizedvalue, | ||
| onvalidationchange, | ||
| ...restProps | ||
| }: Props = $props(); | ||
|
|
||
| const stackService = inject(STACK_SERVICE); | ||
|
|
||
| let textbox = $state<ReturnType<typeof Textbox>>(); | ||
| let isValidating = $state(false); | ||
| let validationError = $state<string | undefined>(); | ||
| let validationCounter = $state(0); | ||
|
|
||
| let normalizedResult = $state<{ fromValue: string; normalized: string } | undefined>(); | ||
|
|
||
| const slugifiedName = $derived(value && slugify(value)); | ||
| const namesDiverge = $derived(!!value && slugifiedName !== value); | ||
| const isValidState = $derived( | ||
| !isValidating && | ||
| !validationError && | ||
| !!value && | ||
| !!normalizedResult?.normalized && | ||
| normalizedResult.fromValue === value, | ||
| ); | ||
| $effect(() => { | ||
| onvalidationchange?.(isValidState); | ||
| }); | ||
|
|
||
| const namesDiverge = $derived( | ||
| !!normalizedResult && normalizedResult.normalized !== normalizedResult.fromValue, | ||
| ); | ||
| const computedHelperText = $derived( | ||
| namesDiverge ? `Will be created as '${slugifiedName}'` : helperText, | ||
| namesDiverge && normalizedResult | ||
| ? `Will be created as '${normalizedResult.normalized}'` | ||
| : helperText, | ||
| ); | ||
|
|
||
| const debouncedNormalize = debounce(async (inputValue: string) => { | ||
| if (!inputValue) { | ||
| isValidating = false; | ||
| validationError = undefined; | ||
| normalizedResult = undefined; | ||
| onnormalizedvalue?.(undefined); | ||
| return; | ||
| } | ||
|
|
||
| const currentValidation = ++validationCounter; | ||
| isValidating = true; | ||
| validationError = undefined; | ||
|
|
||
| try { | ||
| const result = await stackService.normalizeBranchName(inputValue); | ||
| // Only update if the value hasn't changed during the async call | ||
| // and no newer validation has started | ||
| if (value === inputValue && currentValidation === validationCounter) { | ||
| normalizedResult = { fromValue: inputValue, normalized: result }; | ||
| onnormalizedvalue?.(result); | ||
| validationError = undefined; | ||
| } | ||
| } catch { | ||
| if (value === inputValue && currentValidation === validationCounter) { | ||
| normalizedResult = undefined; | ||
| onnormalizedvalue?.(undefined); | ||
| validationError = "Invalid branch name"; | ||
| } | ||
|
Comment on lines
+74
to
+79
|
||
| } finally { | ||
| if (currentValidation === validationCounter) { | ||
| isValidating = false; | ||
| } | ||
| } | ||
|
Comment on lines
+80
to
+84
|
||
| }, 100); | ||
|
|
||
| $effect(() => { | ||
| onslugifiedvalue?.(slugifiedName); | ||
| debouncedNormalize(value || ""); | ||
| }); | ||
|
Comment on lines
87
to
89
|
||
|
|
||
| export async function selectAll() { | ||
| await textbox?.selectAll(); | ||
| } | ||
| </script> | ||
|
|
||
| <Textbox bind:this={textbox} bind:value helperText={computedHelperText} {...restProps} /> | ||
| <Textbox | ||
| bind:this={textbox} | ||
| bind:value | ||
| helperText={computedHelperText} | ||
| error={validationError} | ||
| {...restProps} | ||
| > | ||
| {#snippet customIconRight()} | ||
| {#if isValidating} | ||
| <Icon name="spinner" /> | ||
| {/if} | ||
| {/snippet} | ||
| </Textbox> | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -42,7 +42,8 @@ | |||||
| // Persisted preference for branch placement | ||||||
| const addToLeftmost = persisted<boolean>(false, "branch-placement-leftmost"); | ||||||
|
|
||||||
| let slugifiedRefName: string | undefined = $state(); | ||||||
| let normalizedRefName: string | undefined = $state(); | ||||||
| let isBranchNameValid = $state(false); | ||||||
|
|
||||||
| // Get all stacks in the workspace | ||||||
| const allStacksQuery = $derived(stackService.stacks(projectId)); | ||||||
|
|
@@ -91,22 +92,22 @@ | |||||
| await createNewStack({ | ||||||
| projectId, | ||||||
| branch: { | ||||||
| name: slugifiedRefName, | ||||||
| name: normalizedRefName, | ||||||
| // If addToLeftmost is true, place at position 0 (leftmost) | ||||||
| // Otherwise, leave undefined to append to the right | ||||||
| order: $addToLeftmost ? 0 : undefined, | ||||||
| }, | ||||||
| }); | ||||||
| createRefModal?.close(); | ||||||
| } else { | ||||||
| if (!selectedStackId || !slugifiedRefName) { | ||||||
| if (!selectedStackId || !normalizedRefName) { | ||||||
| // TODO: Add input validation. | ||||||
| return; | ||||||
| } | ||||||
| await createNewBranch({ | ||||||
| projectId, | ||||||
| stackId: selectedStackId, | ||||||
| request: { targetPatch: undefined, name: slugifiedRefName }, | ||||||
| request: { targetPatch: undefined, name: normalizedRefName }, | ||||||
| }); | ||||||
| createRefModal?.close(); | ||||||
| } | ||||||
|
|
@@ -145,7 +146,8 @@ | |||||
| id={ElementId.NewBranchNameInput} | ||||||
| value={createRefName} | ||||||
|
||||||
| value={createRefName} | |
| bind:value={createRefName} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This variable is mainly used to provide an initial value when opening the modal for creating a new branch. It does not participate in subsequent updates, so two-way binding isn't necessary.
Uh oh!
There was an error while loading. Please reload this page.