feat(structure): geometry subtype and srid editor for postgres

This commit is contained in:
Tara authored and GitHub committed 2026-09-11 17:39:31 +08:00
1 parent 71cc734cdd
commit 27f21d5cc4
15 files changed
+329 -3

No files matched your search

@@ -76,6 +76,11 @@ import {
generateUniqueIndexName,
getColumnEditorControls,
getDataTypeOptions,
POSTGRES_GEOMETRY_TYPES,
combinePostgresGeometryType,
isPostgresGeometryDataType,
postgresGeometrySridValue,
postgresGeometryTypeValue,
getDataTypeLengthUnitOptions,
getDefaultLengthForType,
hasExistingColumnTypeChange,
@@ -2796,6 +2801,11 @@ function updateColumnDataTypeLength(column: EditableStructureColumn, value: stri
syncDamengIdentityForDataType(column);
}
function updatePostgresGeometryColumn(column: EditableStructureColumn, geomType: string, srid: string) {
const baseType = dataTypeBaseInputValue(databaseType.value, column.dataType);
column.dataType = combinePostgresGeometryType(baseType, geomType, srid);
}
function updateColumnDataTypeLengthUnit(column: EditableStructureColumn, value: unknown) {
const baseType = dataTypeBaseInputValue(databaseType.value, column.dataType);
const unit = value === "__default" ? "" : String(value ?? "");
@@ -4453,6 +4463,35 @@ watch(
</div>
</PopoverContent>
</Popover>
<div v-else-if="isPostgresGeometryDataType(databaseType, column.dataType)" class="flex min-w-0 flex-col gap-0.5">
<div class="flex min-w-0 items-center gap-1" :title="t('structureEditor.geometrySridHint')">
<SearchableSelect
:model-value="postgresGeometryTypeValue(column.dataType)"
:options="[...POSTGRES_GEOMETRY_TYPES]"
:allow-custom="true"
:clearable="true"
:placeholder="t('structureEditor.geometryTypePlaceholder')"
:search-placeholder="t('structureEditor.geometryTypePlaceholder')"
:empty-text="t('structureEditor.noMatchingType')"
:trigger-class="[structureMonoControlClass, 'min-w-0 flex-1']"
:disabled="isColumnTypeDisabled(column)"
@update:model-value="(v: string) => updatePostgresGeometryColumn(column, v, postgresGeometrySridValue(column.dataType))"
/>
<Input
:model-value="postgresGeometrySridValue(column.dataType)"
:class="[structureMonoControlClass, 'w-20 shrink-0']"
type="number"
min="0"
max="999999"
:placeholder="t('structureEditor.sridPlaceholder')"
:disabled="isColumnTypeDisabled(column)"
@update:model-value="(v: string | number) => updatePostgresGeometryColumn(column, postgresGeometryTypeValue(column.dataType), String(v))"
/>
</div>
<p v-if="!postgresGeometryTypeValue(column.dataType) && postgresGeometrySridValue(column.dataType)" class="text-[length:var(--structure-font-size)] text-muted-foreground leading-tight">
{{ t("structureEditor.geometryEmptyTypeHint") }}
</p>
</div>
<div v-else class="flex min-w-0 items-center gap-1">
<Input :model-value="dataTypeLengthInputValue(databaseType, column.dataType)" :class="[structureMonoControlClass, 'min-w-0 flex-1']" :disabled="isColumnLengthDisabled(column)" @update:model-value="updateColumnDataTypeLength(column, $event)" />
<Select v-if="columnLengthUnitOptions(column).length" :model-value="dataTypeLengthUnitValue(databaseType, column.dataType) || '__default'" :disabled="isColumnLengthUnitDisabled(column)" @update:model-value="updateColumnDataTypeLengthUnit(column, $event)">
@@ -2,6 +2,7 @@
import { computed, nextTick, ref, watch } from "vue";
import type { HTMLAttributes } from "vue";
import { Check, ChevronDown, Search, X } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import type { ButtonVariants } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -76,6 +77,7 @@ const helpPanel = ref<{ element?: HTMLElement }>();
const highlightIndex = ref(-1);
const activeHelpOption = ref<string>();
const helpPanelOffsetTop = ref(0);
const { t } = useI18n();
const selectedLabel = computed(() => {
if (!props.modelValue && !props.options.includes("")) return props.placeholder;
@@ -241,12 +243,29 @@ function handleKeydown(event: KeyboardEvent) {
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button type="button" :variant="triggerVariant" :disabled="disabled" :title="selectedLabel" :class="cn(triggerBaseClass, triggerClass)">
<!--
Keep the trigger Button as the outermost element: triggerClass carries the layout
utilities consumers depend on (flex-1 / min-w-0 / max-w-* / w-full / h-*), and any
wrapper element would become the flex item and swallow them. The clearable overlay
therefore anchors to this button (relative) instead of a wrapper. The overlay is a
span (not the svg itself) because the Button base sets [&_svg]:pointer-events-none,
which is also why the old in-trigger @pointerdown.stop never fired; @click.stop
keeps Reka's bubble-phase trigger onClick (onOpenToggle) from opening the dropdown.
-->
<Button type="button" :variant="triggerVariant" :disabled="disabled" :title="selectedLabel" :class="cn('relative', triggerBaseClass, triggerClass)">
<slot name="trigger-label" :value="modelValue" :label="selectedLabel" :loading="loading">
<span class="truncate">{{ loading ? loadingText : selectedLabel }}</span>
</slot>
<X v-if="clearable && !disabled && modelValue" :class="cn('shrink-0 opacity-60 hover:opacity-100', triggerIconClass)" @pointerdown.stop.prevent="emit('update:modelValue', '')" />
<ChevronDown v-else :class="cn('shrink-0 opacity-60', triggerIconClass)" />
<ChevronDown :class="cn('shrink-0 opacity-60', clearable && !disabled && modelValue && 'invisible', triggerIconClass)" />
<span
v-if="clearable && !disabled && modelValue"
class="absolute right-2 top-1/2 z-10 -translate-y-1/2 flex cursor-pointer items-center justify-center opacity-60 hover:opacity-100"
:aria-label="t('common.clear')"
:title="t('common.clear')"
@click.stop.prevent="emit('update:modelValue', '')"
>
<X :class="triggerIconClass" />
</span>
</Button>
</PopoverTrigger>
<PopoverContent :align="SEARCHABLE_SELECT_HELP_PANEL_ALIGN" :class="cn('w-auto max-w-[calc(100vw-1rem)] border-0 bg-transparent p-0 shadow-none ring-0', contentClass)" :style="contentStyle">
@@ -0,0 +1,70 @@
// @vitest-environment happy-dom
import { createApp, nextTick, type App } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
import { SearchableSelect } from "..";
const mountedApps: App[] = [];
function mountSelect(props: Record<string, unknown> = {}) {
const root = document.createElement("div");
document.body.append(root);
const emitted = { modelValue: [] as unknown[], open: [] as unknown[] };
const app = createApp(SearchableSelect, {
modelValue: "Point",
options: ["Point", "LineString"],
placeholder: "type",
searchPlaceholder: "search",
emptyText: "empty",
...props,
"onUpdate:modelValue": (value: unknown) => emitted.modelValue.push(value),
"onUpdate:open": (value: unknown) => emitted.open.push(value),
});
mountedApps.push(app);
app.mount(root);
return { root, emitted };
}
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
});
describe("SearchableSelect trigger", () => {
it("renders the trigger button itself as the layout participant", () => {
const { root } = mountSelect({ triggerClass: "min-w-0 flex-1" });
const trigger = root.querySelector("button");
// No wrapper element may sit between the parent layout context and the
// trigger: triggerClass utilities (flex-1 / min-w-0 / max-w-*) must stay
// effective on the element the consumer lays out.
expect(trigger).not.toBeNull();
expect(root.firstElementChild).toBe(trigger);
expect(trigger?.className).toContain("flex-1");
expect(trigger?.className).toContain("min-w-0");
});
it("clears through the overlay without toggling the popover open", async () => {
const { root, emitted } = mountSelect({ clearable: true });
const clear = root.querySelector<HTMLElement>('[title="common.clear"]');
expect(clear).not.toBeNull();
expect(clear?.getAttribute("aria-label")).toBe("common.clear");
clear?.click();
await nextTick();
expect(emitted.modelValue).toEqual([""]);
expect(emitted.open).toEqual([]);
});
it("keeps the chevron space reserved while the clear overlay is shown", () => {
const { root } = mountSelect({ clearable: true });
expect(root.querySelector("button .invisible")).not.toBeNull();
});
});