phonograph/svelte/src/field-adder.svelte

202 lines
6.2 KiB
Svelte
Raw Normal View History

<!--
@component
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">
import Combobox from "./combobox.svelte";
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") {
return { t: "Dropdown", c: { allow_custom: true } };
}
if (tag === "Text") {
return { t: "Text", c: { input_mode: { t: "SingleLine", c: {} } } };
}
if (tag === "Timestamp") {
return { t: "Timestamp", c: {} };
}
if (tag === "Uuid") {
return { t: "Uuid", c: {} };
}
type _ = Assert<typeof tag extends never ? true : false>;
throw new Error("this should be unreachable");
}
function handle_presentation_input() {
presentation_customized = true;
}
function handle_summary_toggle_button_click() {
expanded = !expanded;
if (expanded) {
setTimeout(() => {
search_input_element?.focus();
}, 0);
}
}
function handle_field_options_button_click() {
popover_element?.showPopover();
}
function handle_name_input() {
name_customized = true;
}
</script>
<form method="post" action="add-field">
<div class="field-adder__container">
<div
class="field-adder__header-lookalike"
style:display={expanded ? "block" : "none"}
>
<Combobox
bind:search_value={label_value}
bind:search_input_element
bind:value={label_value}
completions={columns
.map(({ name }) => name)
.filter((name) =>
name
.toLocaleLowerCase("en-US")
.includes(label_value.toLocaleLowerCase("en-US")),
)}
search_input_class="field-adder__label-input"
/>
</div>
<div class="field-adder__summary-buttons">
<button
aria-label="more field options"
class="button--clear"
onclick={handle_field_options_button_click}
style:anchor-name={anchor_name}
style:display={expanded ? "block" : "none"}
type="button"
>
2025-10-24 18:21:40 +00:00
<i class="ti ti-dots-vertical"></i>
</button>
<button
aria-label="toggle field adder"
class="button--clear"
onclick={handle_summary_toggle_button_click}
type="button"
>
2025-10-24 18:21:40 +00:00
<i class="ti ti-{expanded ? 'x' : 'plus'}"></i>
</button>
</div>
</div>
<div
bind:this={popover_element}
class="field-adder__popover"
popover="auto"
style:position-anchor={anchor_name}
>
<!--
The "advanced" details for creating a new column or customizing the behavior
of a field backed by an existing column overlap with the controls exposed when
editing the configuration of an existing field.
-->
{#if presentation_value}
<FieldDetails
bind:name_value
bind:label_value
bind:presentation={presentation_value}
on_name_input={handle_name_input}
on_presentation_input={handle_presentation_input}
/>
{/if}
<button class="button--primary" type="submit">Create</button>
</div>
</form>