feat(dashboard): auto-slug app names and infer route host kind from input
Two related polish passes on forms the operator hits most. App create form: the slug field used to come before the name field and demanded the operator hand-roll a valid slug. Now the name field comes first and the slug is derived from it live, GitLab-style — Unicode NFKD-decomposed, combining marks stripped (so `Café` → `cafe`), `ß` mapped to `ss`, non-`[a-z0-9]` runs collapsed to `-`, trimmed and capped at the backend's 63-char limit. The auto-sync releases as soon as the operator edits the slug manually, and re-engages if they clear it. The slug input itself runs every keystroke and paste through the same normalizer, so dirty input never reaches the form state. Route create form: the three-way host-kind `<select>` plus a sometimes- disabled input was confusing — operators routinely picked the wrong kind, typed a host the app didn't claim, and only saw the error after hitting Create. Replace with a single text input that infers the kind from what's there (`*` → any, `*.foo.com` → wildcard, `foo.com` → strict), shows the detected kind as a colored chip beside the field, and suggests the app's existing domain claims via a `<datalist>`. The same matching logic the backend runs in `validate_route_host_against_app` now lives in `route-utils.ts` so the form can surface a soft "not covered by any claim" warning *before* submit. Path also pre-fills to `/` so the most common case is one click away. Lockfile drift from `npm install` (pre-existing 0.5.0 → 0.5.1 version sync, npm metadata cleanup) is folded in here since it surfaced during this work. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
import { api, ApiError, type App } from '$lib/api';
|
||||
import { slugify, SLUG_MAX } from '$lib/slugify';
|
||||
|
||||
let apps = $state<App[] | null>(null);
|
||||
let listError = $state<string | null>(null);
|
||||
@@ -10,10 +11,34 @@
|
||||
let createSlug = $state('');
|
||||
let createName = $state('');
|
||||
let createDescription = $state('');
|
||||
// Auto-derive slug from name until the user takes manual control of
|
||||
// the slug field. Clearing the slug input releases the lock so the
|
||||
// auto-derive resumes — matches the GitLab project-create UX.
|
||||
let slugTouched = $state(false);
|
||||
let creating = $state(false);
|
||||
let createError = $state<string | null>(null);
|
||||
let createHistoricalConflict = $state<App | null>(null);
|
||||
|
||||
function onNameInput(event: Event) {
|
||||
const value = (event.target as HTMLInputElement).value;
|
||||
createName = value;
|
||||
if (!slugTouched) {
|
||||
createSlug = slugify(value);
|
||||
}
|
||||
}
|
||||
|
||||
function onSlugInput(event: Event) {
|
||||
const raw = (event.target as HTMLInputElement).value;
|
||||
const normalized = slugify(raw);
|
||||
createSlug = normalized;
|
||||
// Re-sync the input element so a paste of "Hello World!" shows
|
||||
// "hello-world" immediately, not the raw value.
|
||||
if (raw !== normalized) {
|
||||
(event.target as HTMLInputElement).value = normalized;
|
||||
}
|
||||
slugTouched = normalized.length > 0;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
listError = null;
|
||||
@@ -33,6 +58,7 @@
|
||||
createDescription = '';
|
||||
createError = null;
|
||||
createHistoricalConflict = null;
|
||||
slugTouched = false;
|
||||
}
|
||||
|
||||
async function submitCreate(event: Event, forceTakeover = false) {
|
||||
@@ -88,17 +114,28 @@
|
||||
<form class="create-form" onsubmit={(e) => submitCreate(e)}>
|
||||
<div class="row">
|
||||
<label>
|
||||
<span>Slug</span>
|
||||
<span>Name</span>
|
||||
<input
|
||||
bind:value={createSlug}
|
||||
value={createName}
|
||||
oninput={onNameInput}
|
||||
required
|
||||
pattern="[a-z0-9][a-z0-9-]*"
|
||||
placeholder="my-app"
|
||||
placeholder="My App"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Name</span>
|
||||
<input bind:value={createName} required placeholder="My App" />
|
||||
<span>Slug</span>
|
||||
<input
|
||||
value={createSlug}
|
||||
oninput={onSlugInput}
|
||||
required
|
||||
pattern="[a-z0-9][a-z0-9-]*"
|
||||
maxlength={SLUG_MAX}
|
||||
placeholder="my-app"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import {
|
||||
api,
|
||||
ApiError,
|
||||
type AppDomain,
|
||||
type ExecutionLog,
|
||||
type Route,
|
||||
type RouteInput,
|
||||
@@ -12,7 +13,13 @@
|
||||
type VersionInfo
|
||||
} from '$lib/api';
|
||||
import { logLevelColor, statusColor } from '$lib/styles';
|
||||
import { guessHostKind, guessPathKind, pathKindMismatchWarning } from '$lib/route-utils';
|
||||
import {
|
||||
checkHostAgainstClaims,
|
||||
guessPathKind,
|
||||
hostSuggestions,
|
||||
parseHostInput,
|
||||
pathKindMismatchWarning
|
||||
} from '$lib/route-utils';
|
||||
import CodeEditor from '$lib/CodeEditor.svelte';
|
||||
import { format as formatRhai } from '$lib/rhai';
|
||||
|
||||
@@ -39,6 +46,7 @@
|
||||
let info = $state<VersionInfo | null>(null);
|
||||
|
||||
let appSlug = $state<string | null>(null);
|
||||
let appDomains = $state<AppDomain[]>([]);
|
||||
|
||||
async function loadScript() {
|
||||
scriptLoading = true;
|
||||
@@ -50,14 +58,23 @@
|
||||
editableDescription = script.description ?? '';
|
||||
editableTimeout = script.timeout_seconds;
|
||||
editableSandbox = { ...(script.sandbox ?? {}) };
|
||||
// Resolve the owning app's slug for the breadcrumb. Failure
|
||||
// is non-fatal — the page works without it.
|
||||
// Resolve the owning app's slug for the breadcrumb and its
|
||||
// domain claims for the route form's suggestions + live
|
||||
// validation. Both are non-fatal — the page works without
|
||||
// them.
|
||||
const appId = script.app_id;
|
||||
void api.apps
|
||||
.get(script.app_id)
|
||||
.get(appId)
|
||||
.then((a) => {
|
||||
appSlug = a.slug;
|
||||
})
|
||||
.catch(() => {});
|
||||
void api.domains
|
||||
.listForApp(appId)
|
||||
.then((d) => {
|
||||
appDomains = d;
|
||||
})
|
||||
.catch(() => {});
|
||||
} catch (e) {
|
||||
scriptError = e instanceof Error ? e.message : String(e);
|
||||
script = null;
|
||||
@@ -175,12 +192,14 @@
|
||||
let routesLoading = $state(true);
|
||||
|
||||
let showAddRoute = $state(false);
|
||||
let newRoutePath = $state('');
|
||||
let newRoutePath = $state('/');
|
||||
let newRoutePathKind = $state<'exact' | 'prefix' | 'param'>('exact');
|
||||
let newRouteHost = $state('');
|
||||
let newRouteHostKind = $state<'any' | 'strict' | 'wildcard'>('any');
|
||||
// Host input is free-form; the kind is derived from what the user
|
||||
// typed (see `parsedHost` below). Default `*` = Any, matching the
|
||||
// canonical display form for an unrestricted host.
|
||||
let newRouteHost = $state('*');
|
||||
let newRouteMethod = $state('');
|
||||
let routeKindAutoUpdate = $state(true);
|
||||
let pathKindAutoUpdate = $state(true);
|
||||
let creatingRoute = $state(false);
|
||||
let createRouteError = $state<string | null>(null);
|
||||
|
||||
@@ -188,17 +207,17 @@
|
||||
let previewMethod = $state('GET');
|
||||
let previewResult = $state<string | null>(null);
|
||||
|
||||
// Auto-update kind selectors as the user types.
|
||||
// Auto-update the path-kind selector as the user types.
|
||||
$effect(() => {
|
||||
if (routeKindAutoUpdate) {
|
||||
if (pathKindAutoUpdate) {
|
||||
newRoutePathKind = guessPathKind(newRoutePath);
|
||||
}
|
||||
});
|
||||
$effect(() => {
|
||||
if (routeKindAutoUpdate) {
|
||||
newRouteHostKind = guessHostKind(newRouteHost);
|
||||
}
|
||||
});
|
||||
|
||||
let parsedHost = $derived(parseHostInput(newRouteHost));
|
||||
let hostCheck = $derived(checkHostAgainstClaims(parsedHost, appDomains));
|
||||
let hostDatalistId = 'route-host-suggestions';
|
||||
let suggestions = $derived(hostSuggestions(appDomains));
|
||||
|
||||
let pathKindWarning = $derived(
|
||||
newRoutePath.trim() ? pathKindMismatchWarning(newRoutePath, newRoutePathKind) : null
|
||||
@@ -222,18 +241,18 @@
|
||||
createRouteError = null;
|
||||
try {
|
||||
const input: RouteInput = {
|
||||
host_kind: newRouteHostKind,
|
||||
host: newRouteHostKind === 'any' ? '' : newRouteHost.trim(),
|
||||
host_kind: parsedHost.kind,
|
||||
host: parsedHost.host,
|
||||
path_kind: newRoutePathKind,
|
||||
path: newRoutePath.trim(),
|
||||
method: newRouteMethod.trim() || null
|
||||
};
|
||||
await api.routes.create(id, input);
|
||||
showAddRoute = false;
|
||||
newRoutePath = '';
|
||||
newRouteHost = '';
|
||||
newRoutePath = '/';
|
||||
newRouteHost = '*';
|
||||
newRouteMethod = '';
|
||||
routeKindAutoUpdate = true;
|
||||
pathKindAutoUpdate = true;
|
||||
await loadRoutes();
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 409) {
|
||||
@@ -502,7 +521,7 @@
|
||||
<span>Path</span>
|
||||
<input
|
||||
bind:value={newRoutePath}
|
||||
oninput={() => (routeKindAutoUpdate = true)}
|
||||
oninput={() => (pathKindAutoUpdate = true)}
|
||||
placeholder="/greet, /greet/:name, /webhooks/*"
|
||||
required
|
||||
autocomplete="off"
|
||||
@@ -513,7 +532,7 @@
|
||||
<span>Path kind</span>
|
||||
<select
|
||||
bind:value={newRoutePathKind}
|
||||
onchange={() => (routeKindAutoUpdate = false)}
|
||||
onchange={() => (pathKindAutoUpdate = false)}
|
||||
>
|
||||
<option value="exact">exact</option>
|
||||
<option value="param">param</option>
|
||||
@@ -532,31 +551,43 @@
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>
|
||||
<span>Host kind</span>
|
||||
<select
|
||||
bind:value={newRouteHostKind}
|
||||
onchange={() => (routeKindAutoUpdate = false)}
|
||||
>
|
||||
<option value="any">ANY</option>
|
||||
<option value="strict">strict</option>
|
||||
<option value="wildcard">wildcard</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class:disabled={newRouteHostKind === 'any'}>
|
||||
<span>Host</span>
|
||||
<input
|
||||
bind:value={newRouteHost}
|
||||
oninput={() => (routeKindAutoUpdate = true)}
|
||||
disabled={newRouteHostKind === 'any'}
|
||||
placeholder={newRouteHostKind === 'wildcard'
|
||||
? '*.example.com'
|
||||
: 'sub.example.com'}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label class="full">
|
||||
<span class="host-label">
|
||||
Host
|
||||
<span class="kind-chip kind-{parsedHost.kind}">
|
||||
{parsedHost.kind}
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
bind:value={newRouteHost}
|
||||
placeholder="* · app.example.com · *.example.com"
|
||||
list={hostDatalistId}
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<datalist id={hostDatalistId}>
|
||||
{#each suggestions as s (s)}
|
||||
<option value={s}></option>
|
||||
{/each}
|
||||
</datalist>
|
||||
<small class="muted">
|
||||
<code>*</code> = any host claimed by this app ·
|
||||
<code>*.foo.com</code> = wildcard · <code>foo.com</code> =
|
||||
strict
|
||||
</small>
|
||||
</label>
|
||||
{#if !hostCheck.ok}
|
||||
<div class="warning inline">
|
||||
{hostCheck.reason}.
|
||||
{#if appDomains.length > 0}
|
||||
Claims:
|
||||
{#each appDomains as d, i (d.id)}<code>{d.pattern}</code>{#if i < appDomains.length - 1},
|
||||
{/if}{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if pathKindWarning}
|
||||
<div class="warning inline">{pathKindWarning}</div>
|
||||
{/if}
|
||||
@@ -951,9 +982,6 @@
|
||||
font-size: 0.85rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
label.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
label.full {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1070,6 +1098,31 @@
|
||||
background: #581c87;
|
||||
color: #e9d5ff;
|
||||
}
|
||||
.host-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.kind-chip {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 0.25rem;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.kind-chip.kind-any {
|
||||
background: #1e293b;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
.kind-chip.kind-strict {
|
||||
background: #14532d;
|
||||
color: #bbf7d0;
|
||||
}
|
||||
.kind-chip.kind-wildcard {
|
||||
background: #1e3a8a;
|
||||
color: #bfdbfe;
|
||||
}
|
||||
.route-row .method {
|
||||
color: #fbbf24;
|
||||
font-weight: 700;
|
||||
|
||||
Reference in New Issue
Block a user