Add cascading location picker: select parent first, then child
Deploy to LXC / deploy (push) Successful in 19s

LocationPicker component shows parent locations first. Once a parent
is selected, a second dropdown appears with its children. If the
parent has no children, it's selected directly. Used in device
create/edit, component create/edit, and installation log forms.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 16:59:49 +07:00
parent 63b57e8ac3
commit 948617a285
11 changed files with 94 additions and 46 deletions
@@ -0,0 +1,71 @@
<script lang="ts">
interface Location {
id: string;
name: string;
parentId: string | null;
}
interface Props {
locations: Location[];
value?: string | null;
name?: string;
id?: string;
}
let { locations, value = $bindable(null), name = 'locationId', id = 'locationId' }: Props = $props();
const parents = $derived(locations.filter((l) => !l.parentId));
const childrenOf = $derived((parentId: string) => locations.filter((l) => l.parentId === parentId));
// Find current parent from value
const selectedLoc = $derived(locations.find((l) => l.id === value));
let selectedParent = $state<string>('');
// Init selectedParent from current value
$effect(() => {
if (selectedLoc) {
selectedParent = selectedLoc.parentId ?? selectedLoc.id;
}
});
const children = $derived(selectedParent ? childrenOf(selectedParent) : []);
const parentIsLeaf = $derived(selectedParent ? childrenOf(selectedParent).length === 0 : false);
// When parent changes, auto-select it if it has no children
$effect(() => {
if (selectedParent && parentIsLeaf) {
value = selectedParent;
} else if (selectedParent && children.length > 0 && !children.find((c) => c.id === value)) {
value = null;
}
});
</script>
<div class="space-y-2">
<select
onchange={(e) => {
selectedParent = (e.target as HTMLSelectElement).value;
value = null;
}}
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="">No location</option>
{#each parents as loc}
<option value={loc.id} selected={loc.id === selectedParent}>{loc.name}</option>
{/each}
</select>
{#if selectedParent && children.length > 0}
<select
onchange={(e) => { value = (e.target as HTMLSelectElement).value || selectedParent; }}
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="" selected={value === selectedParent}>{parents.find(p => p.id === selectedParent)?.name} (general)</option>
{#each children as child}
<option value={child.id} selected={child.id === value}>{child.name}</option>
{/each}
</select>
{/if}
<input type="hidden" {name} {id} value={value ?? ''} />
</div>
@@ -22,7 +22,7 @@ export const load: PageServerLoad = async ({ params }) => {
const [component] = await db.select().from(components).where(eq(components.id, params.id)); const [component] = await db.select().from(components).where(eq(components.id, params.id));
if (!component) error(404, 'Component not found'); if (!component) error(404, 'Component not found');
const locationList = await db.select({ id: locations.id, name: locations.name }).from(locations); const locationList = await db.select({ id: locations.id, name: locations.name, parentId: locations.parentId }).from(locations).orderBy(locations.name);
return { component, locations: locationList }; return { component, locations: locationList };
}; };
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { COMPONENT_TYPES, COMPONENT_CONDITIONS } from '$lib/constants.js'; import { COMPONENT_TYPES, COMPONENT_CONDITIONS } from '$lib/constants.js';
import LocationPicker from '$lib/components/ui/LocationPicker.svelte';
let { data, form } = $props(); let { data, form } = $props();
const c = $derived(data.component); const c = $derived(data.component);
@@ -88,13 +89,7 @@
{#if !c.currentDeviceId} {#if !c.currentDeviceId}
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"> <div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Storage Location</h2> <h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Storage Location</h2>
<select id="locationId" name="locationId" <LocationPicker locations={data.locations} value={c.locationId} name="locationId" />
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="">No location</option>
{#each data.locations as loc}
<option value={loc.id} selected={loc.id === (form?.values?.locationId ?? c.locationId)}>{loc.name}</option>
{/each}
</select>
</div> </div>
{/if} {/if}
@@ -26,8 +26,9 @@ export const load: PageServerLoad = async () => {
.where(eq(devices.disabled, false)) .where(eq(devices.disabled, false))
.orderBy(devices.title); .orderBy(devices.title);
const locationList = await db const locationList = await db
.select({ id: locations.id, name: locations.name }) .select({ id: locations.id, name: locations.name, parentId: locations.parentId })
.from(locations); .from(locations)
.orderBy(locations.name);
return { devices: deviceList, locations: locationList }; return { devices: deviceList, locations: locationList };
}; };
+3 -8
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { COMPONENT_TYPES, COMPONENT_CONDITIONS } from '$lib/constants.js'; import { COMPONENT_TYPES, COMPONENT_CONDITIONS } from '$lib/constants.js';
import LocationPicker from '$lib/components/ui/LocationPicker.svelte';
let { data, form } = $props(); let { data, form } = $props();
</script> </script>
@@ -99,14 +100,8 @@
</select> </select>
</div> </div>
<div> <div>
<label for="locationId" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Storage Location</label> <span class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Storage Location</span>
<select id="locationId" name="locationId" <LocationPicker locations={data.locations} name="locationId" />
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="">No location</option>
{#each data.locations as loc}
<option value={loc.id}>{loc.name}</option>
{/each}
</select>
</div> </div>
</div> </div>
@@ -35,7 +35,7 @@ export const load: PageServerLoad = async ({ params }) => {
compDetails = cd ?? null; compDetails = cd ?? null;
} }
const locationList = await db.select({ id: locations.id, name: locations.name }).from(locations); const locationList = await db.select({ id: locations.id, name: locations.name, parentId: locations.parentId }).from(locations).orderBy(locations.name);
return { device, computerDetails: compDetails, locations: locationList }; return { device, computerDetails: compDetails, locations: locationList };
}; };
@@ -2,6 +2,7 @@
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { DEVICE_CATEGORIES, DEVICE_CONDITIONS, VOLTAGE_OPTIONS, FREQUENCY_OPTIONS } from '$lib/constants.js'; import { DEVICE_CATEGORIES, DEVICE_CONDITIONS, VOLTAGE_OPTIONS, FREQUENCY_OPTIONS } from '$lib/constants.js';
import AutocompleteInput from '$lib/components/ui/AutocompleteInput.svelte'; import AutocompleteInput from '$lib/components/ui/AutocompleteInput.svelte';
import LocationPicker from '$lib/components/ui/LocationPicker.svelte';
let { data, form } = $props(); let { data, form } = $props();
@@ -157,14 +158,8 @@
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"> <div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Location & Notes</h2> <h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Location & Notes</h2>
<div class="mb-4"> <div class="mb-4">
<label for="locationId" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Location</label> <span class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Location</span>
<select id="locationId" name="locationId" <LocationPicker locations={data.locations} value={d.locationId} />
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="">No location</option>
{#each data.locations as loc}
<option value={loc.id} selected={loc.id === (form?.values?.locationId ?? d.locationId)}>{loc.name}</option>
{/each}
</select>
</div> </div>
<div> <div>
<label for="generalNotes" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">General Notes</label> <label for="generalNotes" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">General Notes</label>
+1 -1
View File
@@ -27,7 +27,7 @@ const deviceSchema = z.object({
}); });
export const load: PageServerLoad = async () => { export const load: PageServerLoad = async () => {
const locationList = await db.select({ id: locations.id, name: locations.name }).from(locations); const locationList = await db.select({ id: locations.id, name: locations.name, parentId: locations.parentId }).from(locations).orderBy(locations.name);
return { locations: locationList }; return { locations: locationList };
}; };
+3 -8
View File
@@ -2,6 +2,7 @@
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { DEVICE_CATEGORIES, DEVICE_CONDITIONS, VOLTAGE_OPTIONS, FREQUENCY_OPTIONS } from '$lib/constants.js'; import { DEVICE_CATEGORIES, DEVICE_CONDITIONS, VOLTAGE_OPTIONS, FREQUENCY_OPTIONS } from '$lib/constants.js';
import AutocompleteInput from '$lib/components/ui/AutocompleteInput.svelte'; import AutocompleteInput from '$lib/components/ui/AutocompleteInput.svelte';
import LocationPicker from '$lib/components/ui/LocationPicker.svelte';
let { data, form } = $props(); let { data, form } = $props();
@@ -179,14 +180,8 @@
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Location & Notes</h2> <h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Location & Notes</h2>
<div class="mb-4"> <div class="mb-4">
<label for="locationId" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Location</label> <span class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Location</span>
<select id="locationId" name="locationId" <LocationPicker locations={data.locations} />
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="">No location</option>
{#each data.locations as loc}
<option value={loc.id} selected={form?.values?.locationId === loc.id}>{loc.name}</option>
{/each}
</select>
</div> </div>
<div> <div>
@@ -32,8 +32,9 @@ export const load: PageServerLoad = async ({ url }) => {
.from(components) .from(components)
.orderBy(components.title); .orderBy(components.title);
const locationList = await db const locationList = await db
.select({ id: locations.id, name: locations.name }) .select({ id: locations.id, name: locations.name, parentId: locations.parentId })
.from(locations); .from(locations)
.orderBy(locations.name);
return { return {
devices: deviceList, devices: deviceList,
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { INSTALLATION_ACTIONS } from '$lib/constants.js'; import { INSTALLATION_ACTIONS } from '$lib/constants.js';
import LocationPicker from '$lib/components/ui/LocationPicker.svelte';
let { data, form } = $props(); let { data, form } = $props();
@@ -59,14 +60,8 @@
{#if action === 'removed'} {#if action === 'removed'}
<div class="mb-4"> <div class="mb-4">
<label for="storageLocationId" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Storage Location</label> <span class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Storage Location</span>
<select id="storageLocationId" name="storageLocationId" <LocationPicker locations={data.locations} name="storageLocationId" />
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="">No location</option>
{#each data.locations as loc}
<option value={loc.id}>{loc.name}</option>
{/each}
</select>
</div> </div>
{/if} {/if}