2025-10-07 06:23:50 +00:00
|
|
|
<!--
|
|
|
|
|
@component
|
2025-11-12 23:00:30 +00:00
|
|
|
|
2025-10-07 06:23:50 +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";
|
2025-10-07 06:23:50 +00:00
|
|
|
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-10-07 06:23:50 +00:00
|
|
|
}
|
2025-11-12 23:00:30 +00:00
|
|
|
if (tag === "Numeric") {
|
|
|
|
|
return { t: "Numeric", c: {} };
|
|
|
|
|
}
|
2025-10-07 06:23:50 +00:00
|
|
|
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" } };
|
2025-10-07 06:23:50 +00:00
|
|
|
}
|
|
|
|
|
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() {
|
2025-10-07 06:23:50 +00:00
|
|
|
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.
|
2025-10-07 06:23:50 +00:00
|
|
|
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-10-07 06:23:50 +00:00
|
|
|
>
|
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>
|
2025-10-07 06:23:50 +00:00
|
|
|
</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>
|
2025-10-07 06:23:50 +00:00
|
|
|
</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>
|