feat(structure): geometry subtype and srid editor for postgres
This commit is contained in:
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">
|
||||
|
||||
Vendored
+70
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user