Extract affix URL UI component
This commit is contained in:
@@ -7,14 +7,11 @@ import {
|
||||
Checkbox,
|
||||
Input,
|
||||
Select,
|
||||
UrlPrefixInput,
|
||||
} from "@silverbulletmd/silverbullet/ui";
|
||||
import { adminApi, listUsers } from "../api.ts";
|
||||
import { FolderPicker } from "../FolderPicker.tsx";
|
||||
import {
|
||||
FieldErrors,
|
||||
UrlPrefixInput,
|
||||
useSlugDefaults,
|
||||
} from "../space_fields.tsx";
|
||||
import { FieldErrors, useSlugDefaults } from "../space_fields.tsx";
|
||||
import type { Binding, FieldError, SpaceInfo, UserInfo } from "../types.ts";
|
||||
|
||||
export function SpaceForm({
|
||||
@@ -232,6 +229,7 @@ export function SpaceForm({
|
||||
{bindType === "prefix" ? (
|
||||
<UrlPrefixInput
|
||||
id="space-bind-value"
|
||||
origin={location.origin}
|
||||
value={prefix}
|
||||
onInput={setPrefix}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Fragment } from "preact";
|
||||
import { Button, Input } from "@silverbulletmd/silverbullet/ui";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
UrlPrefixInput,
|
||||
} from "@silverbulletmd/silverbullet/ui";
|
||||
import { FolderPicker } from "../../FolderPicker.tsx";
|
||||
import { FieldErrors, UrlPrefixInput } from "../../space_fields.tsx";
|
||||
import { FieldErrors } from "../../space_fields.tsx";
|
||||
import type { FieldError } from "../../types.ts";
|
||||
import {
|
||||
defaultFolder,
|
||||
@@ -84,6 +88,7 @@ export function SpaceStep({
|
||||
<label for="setup-prefix">Prefix</label>
|
||||
<UrlPrefixInput
|
||||
id="setup-prefix"
|
||||
origin={location.origin}
|
||||
value={values.prefix}
|
||||
onInput={onPrefixChange}
|
||||
/>
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* Lower-cased, filesystem-safe version of a space name, used to derive its
|
||||
* default folder and URL prefix. Shared by the space manager (`SpaceForm`) and
|
||||
* the first-run setup wizard (`Wizard`) — both built from `client/spaces_ui` —
|
||||
* so both derive identical defaults.
|
||||
*/
|
||||
export function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState } from "preact/hooks";
|
||||
import { Alert, Input } from "@silverbulletmd/silverbullet/ui";
|
||||
import { slugify } from "./slugify.ts";
|
||||
import { Alert, prefixFromName, slugify } from "@silverbulletmd/silverbullet/ui";
|
||||
import type { FieldError } from "./types.ts";
|
||||
|
||||
/**
|
||||
@@ -16,7 +15,7 @@ export function useSlugDefaults(folderTemplate: (slug: string) => string) {
|
||||
|
||||
function onNameChange(name: string) {
|
||||
const slug = slugify(name);
|
||||
if (!prefixTouched) setPrefix(slug ? `/${slug}` : "");
|
||||
if (!prefixTouched) setPrefix(prefixFromName(name));
|
||||
if (!folderTouched) setFolder(slug ? folderTemplate(slug) : "");
|
||||
}
|
||||
|
||||
@@ -36,28 +35,6 @@ export function useSlugDefaults(folderTemplate: (slug: string) => string) {
|
||||
};
|
||||
}
|
||||
|
||||
/** The prefix field, decorated with the origin it will be served from. */
|
||||
export function UrlPrefixInput({
|
||||
id,
|
||||
value,
|
||||
onInput,
|
||||
}: {
|
||||
id: string;
|
||||
value: string;
|
||||
onInput: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div class="sb-url-input">
|
||||
<span class="sb-url-affix">{location.origin}</span>
|
||||
<Input
|
||||
id={id}
|
||||
value={value}
|
||||
onInput={(e) => onInput(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Server-returned field errors, rendered the same way on every form. */
|
||||
export function FieldErrors({ errors }: { errors: FieldError[] }) {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { slugify } from "./slugify.ts";
|
||||
import { slugify } from "@silverbulletmd/silverbullet/ui";
|
||||
import type { FieldError } from "./types.ts";
|
||||
|
||||
/** Where a space is bound: the server root, or a URL prefix under it. */
|
||||
|
||||
@@ -197,56 +197,6 @@ body.sb-standalone.sb-page {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Binding-value input dressed up as the URL it will produce: fixed
|
||||
scheme/host parts as affixes around the editable segment. Shared by the
|
||||
setup wizard and the Space Manager's space form. */
|
||||
// Matches .sb-input's own surface so the affixes read as decoration on one
|
||||
// field rather than a differently-styled control sitting among the others.
|
||||
.sb-standalone .sb-url-input {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border: 0;
|
||||
border-radius: 0.5em;
|
||||
background: var(--text-field-background-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sb-standalone .sb-url-input:focus-within {
|
||||
outline: 2px solid var(--ui-accent-color);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.sb-standalone .sb-url-input .sb-input {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: auto;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
// The affixes are the parts you cannot type in, so they get their own darker
|
||||
// surface — the editable segment keeps the plain field background. Without
|
||||
// that contrast the whole control reads as one uniformly grey box and it stops
|
||||
// being obvious which part is the input.
|
||||
.sb-standalone .sb-url-affix {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.3em 0.6em;
|
||||
background: var(--subtle-background-color);
|
||||
color: var(--subtle-color);
|
||||
font-size: 0.9em;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sb-standalone .sb-url-affix:first-child {
|
||||
border-right: 1px solid #0002;
|
||||
}
|
||||
|
||||
.sb-standalone .sb-url-affix:last-child {
|
||||
border-left: 1px solid #0002;
|
||||
}
|
||||
|
||||
/* Header: wordmark (+ admin tab bar) on the left, quiet log-out link on the
|
||||
right.
|
||||
|
||||
@@ -218,4 +218,57 @@
|
||||
margin-left: 0.5em;
|
||||
font-size: 0.85em;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
/* Binding-value input dressed up as the URL it will produce: fixed
|
||||
scheme/host parts as affixes around the editable segment. Backs the
|
||||
`UrlPrefixInput` component, so it lives here rather than in the standalone
|
||||
page styles — the desktop app's dashboard uses the component too, and only
|
||||
links this stylesheet. */
|
||||
// Matches .sb-input's own surface so the affixes read as decoration on one
|
||||
// field rather than a differently-styled control sitting among the others.
|
||||
.sb-url-input {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border: 0;
|
||||
border-radius: 0.5em;
|
||||
background: var(--text-field-background-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sb-url-input:focus-within {
|
||||
outline: 2px solid var(--ui-accent-color);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.sb-url-input .sb-input {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: auto;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
// The affixes are the parts you cannot type in, so they get their own darker
|
||||
// surface — the editable segment keeps the plain field background. Without
|
||||
// that contrast the whole control reads as one uniformly grey box and it stops
|
||||
// being obvious which part is the input.
|
||||
.sb-url-affix {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.3em 0.6em;
|
||||
background: var(--subtle-background-color);
|
||||
color: var(--subtle-color);
|
||||
font-size: 0.9em;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sb-url-affix:first-child {
|
||||
border-right: 1px solid #0002;
|
||||
}
|
||||
|
||||
.sb-url-affix:last-child {
|
||||
border-left: 1px solid #0002;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ export { Badge } from "./badge.tsx";
|
||||
export type { BadgeProps } from "./badge.tsx";
|
||||
export { Progress } from "./progress.tsx";
|
||||
export type { ProgressProps } from "./progress.tsx";
|
||||
export { UrlPrefixInput } from "./url_prefix_input.tsx";
|
||||
export type { UrlPrefixInputProps } from "./url_prefix_input.tsx";
|
||||
export { normalizePrefix, prefixFromName, slugify } from "./slugify.ts";
|
||||
export { cx } from "./cx.ts";
|
||||
export { panelStyles } from "./panel_styles.ts";
|
||||
export type { PanelStylesOptions } from "./panel_styles.ts";
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { normalizePrefix, prefixFromName, slugify } from "./slugify.ts";
|
||||
|
||||
test("slugify lowercases and collapses non-alphanumerics", () => {
|
||||
expect(slugify("My Space!")).toBe("my-space");
|
||||
expect(slugify(" Spaced Out ")).toBe("spaced-out");
|
||||
expect(slugify("a---b")).toBe("a-b");
|
||||
expect(slugify("!!!")).toBe("");
|
||||
});
|
||||
|
||||
test("prefixFromName returns an absolute path", () => {
|
||||
// The leading slash is part of the value: a binding prefix is a path, and
|
||||
// the field renders it directly into the URL it shows.
|
||||
expect(prefixFromName("My Space")).toBe("/my-space");
|
||||
});
|
||||
|
||||
test("prefixFromName yields empty rather than a bare slash", () => {
|
||||
// "/" is a *root* binding, a meaningfully different thing from "no prefix
|
||||
// chosen yet" — defaulting an empty name to it would silently bind the
|
||||
// space to the server root.
|
||||
expect(prefixFromName("")).toBe("");
|
||||
expect(prefixFromName(" ")).toBe("");
|
||||
expect(prefixFromName("!!!")).toBe("");
|
||||
});
|
||||
|
||||
test("normalizePrefix slugifies a value that already has a slash", () => {
|
||||
// The desktop app's `provision_space` takes anything starting with "/"
|
||||
// as-is, so the client is what has to guarantee a clean slug.
|
||||
expect(normalizePrefix("/My Space!")).toBe("/my-space");
|
||||
expect(normalizePrefix("My Space!")).toBe("/my-space");
|
||||
});
|
||||
|
||||
test("normalizePrefix collapses repeated leading slashes", () => {
|
||||
expect(normalizePrefix("//wiki")).toBe("/wiki");
|
||||
expect(normalizePrefix("/wiki")).toBe("/wiki");
|
||||
});
|
||||
|
||||
test("normalizePrefix maps a slash-only value to a root binding", () => {
|
||||
expect(normalizePrefix("/")).toBe("");
|
||||
expect(normalizePrefix("")).toBe("");
|
||||
});
|
||||
|
||||
test("normalizePrefix is idempotent", () => {
|
||||
const once = normalizePrefix("/My Space!");
|
||||
expect(normalizePrefix(once)).toBe(once);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Lower-cased, filesystem- and URL-safe version of a space name, used to
|
||||
* derive its default folder and URL prefix.
|
||||
*
|
||||
* Mirrors the server's `slug::slugify`, so a prefix typed into the Space
|
||||
* Manager, the first-run wizard, or the desktop app's space form all reach the
|
||||
* server in the same shape.
|
||||
*/
|
||||
export function slugify(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* The default URL prefix for a space called `name`, as an absolute path.
|
||||
*
|
||||
* The leading slash is part of the value, not decoration: a binding prefix is
|
||||
* a path, the server stores and validates it that way, and showing it in the
|
||||
* field is what makes the assembled URL in `UrlPrefixInput` read correctly. An
|
||||
* empty name yields `""` rather than a bare `"/"`, which would bind the space
|
||||
* to the server root.
|
||||
*/
|
||||
export function prefixFromName(name: string): string {
|
||||
const slug = slugify(name);
|
||||
return slug ? `/${slug}` : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a user-typed prefix into the shape the server accepts: exactly one
|
||||
* leading slash, and a slugified body.
|
||||
*
|
||||
* Callers that hand a prefix straight to an API need this because a value
|
||||
* already starting with `/` is taken as-is by the desktop app's
|
||||
* `provision_space` command — it only slugifies input that lacks the slash, so
|
||||
* `/My Space!` would otherwise be sent through verbatim. Returns `""` for an
|
||||
* empty or slash-only input, the server's spelling of a root binding.
|
||||
*/
|
||||
export function normalizePrefix(raw: string): string {
|
||||
const slug = slugify(raw.replace(/^\/+/, ""));
|
||||
return slug ? `/${slug}` : "";
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Progress,
|
||||
Select,
|
||||
Tabs,
|
||||
UrlPrefixInput,
|
||||
} from "./index.ts";
|
||||
|
||||
test("Button primary emits both classes and merges consumer class", () => {
|
||||
@@ -90,3 +91,31 @@ test("Progress clamps value to a width percentage", () => {
|
||||
expect(render(h(Progress, { value: 2 }))).toMatch(/width:\s*100%/);
|
||||
expect(render(h(Progress, { value: -1 }))).toMatch(/width:\s*0%/);
|
||||
});
|
||||
|
||||
test("UrlPrefixInput shows the origin it is given, not the ambient one", () => {
|
||||
// The desktop app configures a *remote* sync server, so the origin cannot
|
||||
// be read from `location` the way the server-hosted Space Manager does.
|
||||
const html = render(
|
||||
h(UrlPrefixInput, {
|
||||
origin: "https://sb.example.com",
|
||||
value: "/notes",
|
||||
onInput: () => {},
|
||||
}),
|
||||
);
|
||||
expect(html).toContain("sb-url-input");
|
||||
expect(html).toContain(">https://sb.example.com</span>");
|
||||
expect(html).toContain('value="/notes"');
|
||||
});
|
||||
|
||||
test("UrlPrefixInput trims a trailing slash off the origin", () => {
|
||||
// Sync server URLs are commonly stored with one; without trimming the
|
||||
// assembled URL reads "https://h//notes".
|
||||
const html = render(
|
||||
h(UrlPrefixInput, {
|
||||
origin: "https://sb.example.com/",
|
||||
value: "/notes",
|
||||
onInput: () => {},
|
||||
}),
|
||||
);
|
||||
expect(html).toContain(">https://sb.example.com</span>");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Input } from "./input.tsx";
|
||||
|
||||
export type UrlPrefixInputProps = {
|
||||
id?: string;
|
||||
origin: string;
|
||||
value: string;
|
||||
onInput: (v: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The URL-prefix field, dressed up as the URL it will produce: a fixed origin
|
||||
* affix followed by the editable path segment.
|
||||
*/
|
||||
export function UrlPrefixInput({
|
||||
id,
|
||||
origin,
|
||||
value,
|
||||
onInput,
|
||||
}: UrlPrefixInputProps) {
|
||||
return (
|
||||
<div class="sb-url-input">
|
||||
<span class="sb-url-affix">{origin.replace(/\/+$/, "")}</span>
|
||||
<Input
|
||||
id={id}
|
||||
value={value}
|
||||
onInput={(e) => onInput(e.currentTarget.value)}
|
||||
spellcheck={false}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user