phonograph/svelte/src/field-adder.svelte

298 lines
8.6 KiB
Svelte
Raw Normal View History

<!--
@component
2025-11-12 23:00:30 +00:00
An interactive UI that sits in the header row of a table and allows the user to
quickly add a new field based on an existing column or configure a field backed
by a new column to be added to the relation in the database.
Note: The form interface is implemented with a literal HTML <form> element with
method="post", meaning that the parent page is reloaded upon field creation. It
is not necessary to update the table DOM dynamically upon successful form
submission.
-->
<!--TODO: disable new column creation if the relation is a Postgres view.-->
<!--TODO: display a warning if column name already exists and its type is
incompatible with the current presentation configuration.-->
<script lang="ts">
2025-11-12 23:00:30 +00:00
import { toLowerCase } from "zod";
import FieldDetails from "./field-details.svelte";
import {
all_presentation_tags,
type Presentation,
} from "./presentation.svelte";
type Assert<_T extends true> = void;
type Props = {
/**
* An array of all existing column names visible in the current relation,
* to the current user. This is used to populate the autocomplete combobox.
*/
columns?: {
name: string;
regtype: string;
}[];
};
let { columns = [] }: Props = $props();
let expanded = $state(false);
let name_customized = $state(false);
let name_value = $state("");
let label_value = $state("");
let presentation_customized = $state(false);
let presentation_value = $state<Presentation | undefined>(
get_empty_presentation("Text"),
);
let popover_element = $state<HTMLDivElement | undefined>();
let search_input_element = $state<HTMLInputElement | undefined>();
// Hacky workaround because as of September 2025 implicit anchor association
// is still pretty broken, at least in Firefox.
let anchor_name = $state(`--anchor-${Math.floor(Math.random() * 1000000)}`);
// If the database-friendly column name has not been explicitly set, keep it
// synchronized with the human-friendly field label as typed in the table
// header cell.
$effect(() => {
if (!name_customized) {
// FIXME: apply transformations to make SQL-friendly
name_value = label_value
.toLocaleLowerCase("en-US")
.replace(/\s+/g, "_")
.replace(/^[^a-z]/, "_")
.replace(/[^a-z0-9]/g, "_");
}
});
// When the field name as entered corresponds to an existing column's name,
// the presentation state is updated to the default for that column's Postgres
// type, *unless* the presentation has already been customized by the user.
$effect(() => {
if (!presentation_customized) {
// TODO: Should this normalize the comparison to lowercase?
const regtype =
columns.find(({ name }) => name === name_value)?.regtype ?? "";
const presentation_tag = presentation_tags_by_regtype[regtype]?.[0];
console.info(`Determining default presentation tag for ${regtype}.`);
if (presentation_tag) {
presentation_value = get_empty_presentation(presentation_tag);
}
}
});
// TODO: Move to presentation.svelte.rs
// Elements of array are compatible presentation tags; first element is the
// default.
const presentation_tags_by_regtype: Readonly<
Record<string, ReadonlyArray<(typeof all_presentation_tags)[number]>>
> = {
text: ["Text", "Dropdown"],
"timestamp with time zone": ["Timestamp"],
uuid: ["Uuid"],
};
function get_empty_presentation(
tag: (typeof all_presentation_tags)[number],
): Presentation {
if (tag === "Dropdown") {
2025-10-25 05:32:22 +00:00
return { t: "Dropdown", c: { allow_custom: true, options: [] } };
}
2025-11-12 23:00:30 +00:00
if (tag === "Numeric") {
return { t: "Numeric", c: {} };
}
if (tag === "Text") {
return { t: "Text", c: { input_mode: { t: "SingleLine", c: {} } } };
}
if (tag === "Timestamp") {
2025-11-12 23:00:30 +00:00
return { t: "Timestamp", c: { format: "yyyy-MM-dd'T'HH:mm:ssXXX" } };
}
if (tag === "Uuid") {
return { t: "Uuid", c: {} };
}
type _ = Assert<typeof tag extends never ? true : false>;
throw new Error("this should be unreachable");
}
2025-11-12 23:00:30 +00:00
function toggle_expanded() {
expanded = !expanded;
if (expanded) {
2025-11-12 23:00:30 +00:00
// Delay focus call until next frame so that the element has an
// opportunity to render.
setTimeout(() => {
search_input_element?.focus();
}, 0);
}
}
function handle_name_input() {
name_customized = true;
}
</script>
2025-11-12 23:00:30 +00:00
<div class="container">
<div
class={["header-lookalike", expanded && "visible"]}
style:anchor-name={anchor_name}
>
<input
bind:this={search_input_element}
bind:value={label_value}
class="field-adder__label-input"
onclick={() => {
popover_element?.showPopover();
}}
oninput={() => {
popover_element?.showPopover();
}}
onkeydown={(ev) => {
if (ev.key === "Escape") {
toggle_expanded();
}
}}
type="text"
/>
<form method="post" action="add-field">
<div
bind:this={popover_element}
class="phono-popover popover"
popover="auto"
style:position-anchor={anchor_name}
>
2025-11-12 23:00:30 +00:00
<div class="completions" role="listbox">
{#each columns
.map(({ name }) => name)
.filter((name) => name
.toLocaleLowerCase("en-US")
.includes(label_value.toLocaleLowerCase("en-US")) || name
.toLocaleLowerCase("en-US")
.includes(name_value.toLocaleLowerCase("en-US"))) as completion}
<button
aria-selected={name_value === completion}
onclick={() => {
if (!name_customized) {
label_value = completion;
}
name_value = completion;
search_input_element?.focus();
}}
role="option"
type="button"
>
{completion}
</button>
{/each}
</div>
<div class="configs">
{#if presentation_value}
<FieldDetails
bind:name_value
bind:label_value
bind:presentation={presentation_value}
on_name_input={handle_name_input}
on_presentation_input={() => {
presentation_customized = true;
}}
/>
{/if}
<button class="button--primary" type="submit">Create</button>
</div>
</div>
</form>
</div>
2025-11-12 23:00:30 +00:00
<div class="field-adder__summary-buttons">
<button
aria-label="toggle field adder"
class="button--clear"
onclick={toggle_expanded}
type="button"
>
<i class="ti ti-{expanded ? 'x' : 'plus'}"></i>
</button>
</div>
2025-11-12 23:00:30 +00:00
</div>
<style lang="css">
/*
I've been annoyed by some of the rough edges around global SCSS in web
components, and independently curious about simplifying the build process by
replacing SCSS with modern vanilla CSS entirely, so trying something new here.
TBD whether it gets adopted more widely, or reverted.
*/
@import "../../css_dist/main.css";
.container {
--default-border-color: #ccc;
--viewer-th-background: #0001;
--viewer-th-border: solid 1px #ccc;
--viewer-th-font-family: "Funnel Sans";
--viewer-th-font-weight: bolder;
--viewer-th-padding-x: 8px;
--viewer-th-padding-y: 4px;
align-items: stretch;
display: flex;
height: 100%;
}
.header-lookalike {
align-items: stretch;
background: var(--viewer-th-background);
border: var(--viewer-th-border);
border-top: none;
&:first-child {
border-left: none;
}
display: none;
&.visible {
display: flex;
}
flex-direction: column;
font-family: var(--viewer-th-font-family);
font-weight: var(--viewer-th-font-weight);
height: 100%; /* css hack to make percentage based cell heights work */
justify-content: center;
padding: var(--viewer-th-padding-y) var(--viewer-th-padding-x);
}
.popover:popover-open {
display: grid;
grid-template: "completions configs" 1fr / 1fr 2fr;
left: anchor(left);
position: absolute;
top: anchor(bottom);
width: 480px;
}
.completions {
border-right: solid 1px var(--default-border-color);
grid-area: completions;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: start;
& > button {
display: block;
padding: 0.5rem;
font-weight: normal;
text-align: left;
width: 100%;
&:hover,
&:focus,
&[aria-selected="true"] {
background: #0000001f;
}
}
}
.configs {
grid-area: configs;
padding: 16px;
}
</style>