Add cascading location picker: select parent first, then child
Deploy to LXC / deploy (push) Successful in 19s
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:
@@ -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 };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user