made RichSelect component and fixed dns node selection

This commit is contained in:
Maya
2025-08-25 12:01:24 -04:00
parent 70d211dcfc
commit 36e8fea09c
6 changed files with 363 additions and 235 deletions
+209
View File
@@ -0,0 +1,209 @@
<script lang="ts">
import { ChevronDown } from 'lucide-svelte';
export let label: string = '';
export let selectedValue: string = '';
export let options: RichSelectOption[] = [];
export let placeholder: string = 'Select an option...';
export let required: boolean = false;
export let disabled: boolean = false;
export let error: string | null = null;
export let onSelect: (value: string) => void;
// Optional props for customizing how options are rendered
export let getOptionIcon: ((option: RichSelectOption) => any) | null = null;
export let getOptionIconColor: ((option: RichSelectOption) => string) | null = null;
export let getOptionBadge: ((option: RichSelectOption) => string | null) | null = null;
export let getOptionBadgeColor: ((option: RichSelectOption) => string) | null = null;
export let getOptionTag: ((option: RichSelectOption) => RichSelectTag | null) | null = null;
export let getOptionStatusText: ((option: RichSelectOption) => string | null) | null = null;
export let showDescriptionInTrigger: boolean = false;
interface RichSelectOption {
value: string;
label: string;
description?: string;
disabled?: boolean;
metadata?: any;
}
interface RichSelectTag {
text: string;
textColor: string;
bgColor: string;
}
let isOpen = false;
let dropdownElement: HTMLDivElement;
$: selectedOption = options.find(opt => opt.value === selectedValue);
function handleSelect(value: string) {
const option = options.find(opt => opt.value === value);
if (option && !option.disabled) {
onSelect(value);
isOpen = false;
}
}
function handleClickOutside(event: MouseEvent) {
if (dropdownElement && !dropdownElement.contains(event.target as Node)) {
isOpen = false;
}
}
</script>
<svelte:window on:click={handleClickOutside} />
<div class="space-y-2" bind:this={dropdownElement}>
<!-- Label -->
{#if label}
<div class="block text-sm font-medium text-gray-300">
{label}
{#if required}
<span class="text-red-400 ml-1">*</span>
{/if}
</div>
{/if}
<!-- Dropdown Container -->
<div class="relative">
<!-- Dropdown Trigger -->
<button
type="button"
on:click={() => !disabled && (isOpen = !isOpen)}
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white
focus:outline-none focus:ring-2 focus:ring-blue-500 flex items-center justify-between
{error ? 'border-red-500' : ''}
{disabled ? 'opacity-50 cursor-not-allowed' : ''}"
{disabled}
>
<div class="flex items-center gap-3 flex-1 min-w-0">
{#if selectedOption}
<!-- Icon -->
{#if getOptionIcon}
{@const icon = getOptionIcon(selectedOption)}
{#if icon}
<div class="w-6 h-6 rounded bg-gray-600 flex items-center justify-center flex-shrink-0">
<svelte:component
this={icon}
class="w-3 h-3 {getOptionIconColor ? getOptionIconColor(selectedOption) : 'text-gray-300'}"
/>
</div>
{/if}
{/if}
<!-- Label and description -->
<div class="flex-1 min-w-0 text-left">
<span class="block truncate">{selectedOption.label}</span>
{#if showDescriptionInTrigger && selectedOption.description}
<span class="block text-xs text-gray-400 truncate">{selectedOption.description}</span>
{/if}
</div>
<!-- Tag -->
{#if getOptionTag}
{@const tag = getOptionTag(selectedOption)}
{#if tag}
<span class="inline-block px-2 py-0.5 text-xs rounded flex-shrink-0 {tag.textColor} {tag.bgColor}">
{tag.text}
</span>
{/if}
{/if}
{:else}
<span class="text-gray-400">{placeholder}</span>
{/if}
</div>
<ChevronDown class="w-4 h-4 text-gray-400 transition-transform flex-shrink-0 {isOpen ? 'rotate-180' : ''}" />
</button>
<!-- Description below trigger (optional) -->
{#if selectedOption && selectedOption.description && !showDescriptionInTrigger}
<div class="mt-2">
<p class="text-sm text-gray-400">
{selectedOption.description}
</p>
</div>
{/if}
<!-- Error Message -->
{#if error}
<div class="flex items-center gap-2 text-red-400 text-sm mt-1">
<span>{error}</span>
</div>
{/if}
<!-- Dropdown Menu -->
{#if isOpen && !disabled}
<div class="absolute z-50 w-full bg-gray-700 border border-gray-600 rounded-md shadow-lg max-h-96 overflow-y-auto mt-1">
{#each options as option}
{@const tag = getOptionTag ? getOptionTag(option) : null}
<button
type="button"
on:click={() => handleSelect(option.value)}
class="w-full px-3 py-3 text-left transition-colors border-b border-gray-600 last:border-b-0
{option.disabled ? 'cursor-not-allowed opacity-50' : 'hover:bg-gray-600'}"
disabled={option.disabled}
>
<div class="flex items-start gap-3">
<!-- Icon -->
{#if getOptionIcon}
{@const icon = getOptionIcon(option)}
{#if icon}
<div class="w-8 h-8 rounded-lg bg-gray-600 flex items-center justify-center mt-0.5 flex-shrink-0">
<svelte:component
this={icon}
class="w-4 h-4 {getOptionIconColor ? getOptionIconColor(option) : 'text-gray-300'}"
/>
</div>
{/if}
{/if}
<!-- Content -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<h4 class="font-medium text-white">{option.label}</h4>
<!-- Badge -->
{#if getOptionBadge}
{@const badge = getOptionBadge(option)}
{#if badge}
<span class="inline-block px-2 py-1 text-xs rounded
{getOptionBadgeColor ? getOptionBadgeColor(option) : 'bg-gray-600 text-gray-300'}">
{badge}
</span>
{/if}
{/if}
<!-- Tag -->
{#if tag}
<span class="inline-block px-2 py-1 text-xs rounded {tag.textColor} {tag.bgColor}">
{tag.text}
</span>
{/if}
</div>
<!-- Description -->
{#if option.description}
<p class="text-sm text-gray-400 mt-1 line-clamp-2">{option.description}</p>
{/if}
<!-- Status Text -->
{#if getOptionStatusText}
{@const statusText = getOptionStatusText(option)}
{#if statusText}
<p class="text-xs mt-1 text-gray-400">
{statusText}
</p>
{/if}
{/if}
</div>
</div>
</button>
{/each}
</div>
{/if}
</div>
</div>
+7
View File
@@ -1,10 +1,12 @@
<script lang="ts">
import { Circle } from "lucide-svelte";
import type { Component } from "svelte";
import { getBgColor, getTextColor } from "./colors";
export let icon = Circle;
export let enableIcon = false
export let color: string = 'gray';
export let bgColor: string = 'bg-gray-700/30';
export let textColor: string = 'text-gray-500';
@@ -13,6 +15,11 @@
export let badge: string = ''
if (color !== 'gray') {
bgColor = getBgColor(color);
textColor = getTextColor(color);
}
</script>
<div class="items-center space-x-2">
@@ -1,59 +0,0 @@
<script lang="ts">
import type { TestConfigSchema } from '$lib/components/tests/types';
export let schema: TestConfigSchema;
</script>
<div class="space-y-3">
<!-- Compatibility Status -->
<div class="flex items-center gap-3 p-3 rounded-lg border
{schema.compatibility === 'Compatible' ? 'bg-green-900/20 border-green-600' :
schema.compatibility === 'Conditional' ? 'bg-yellow-900/20 border-yellow-600' :
'bg-red-900/20 border-red-600'}">
<iconify-icon
icon="mdi:{schema.compatibility === 'Compatible' ? 'check-circle' :
schema.compatibility === 'Conditional' ? 'alert' :
'close-circle'}"
class="{schema.compatibility === 'Compatible' ? 'text-green-400' :
schema.compatibility === 'Conditional' ? 'text-yellow-400' :
'text-red-400'}"
></iconify-icon>
<div class="flex-1">
<span class="text-sm font-medium
{schema.compatibility === 'Compatible' ? 'text-green-200' :
schema.compatibility === 'Conditional' ? 'text-yellow-200' :
'text-red-200'}">
{#if schema.compatibility === 'Compatible'}
Compatible
{:else if schema.compatibility === 'Conditional'}
Conditionally Compatible
{:else}
Incompatible
{/if}
</span>
{#if schema.compatibility_reason}
<p class="text-xs mt-1
{schema.compatibility === 'Compatible' ? 'text-green-300' :
schema.compatibility === 'Conditional' ? 'text-yellow-300' :
'text-red-300'}">
{schema.compatibility_reason}
</p>
{/if}
</div>
</div>
<!-- Error Messages -->
{#if schema.errors.length > 0}
<div class="space-y-2">
{#each schema.errors as error}
<div class="flex items-start gap-2 p-3 bg-red-900/20 border border-red-600 rounded-lg">
<iconify-icon icon="mdi:alert-circle" class="text-red-400 mt-0.5"></iconify-icon>
<div class="flex-1">
<span class="text-sm text-red-200">{error.message}</span>
</div>
</div>
{/each}
</div>
{/if}
</div>
@@ -1,18 +1,53 @@
<script lang="ts">
import { AlertCircle, Server } from 'lucide-svelte';
import type { ConfigField } from '$lib/components/tests/types';
import type { ConfigField } from '$lib/components/tests/types'
import RichSelect from '../../../common/RichSelect.svelte';
export let field: ConfigField;
export let value: any;
export let error: string | null = null;
export let onUpdate: (value: any) => void;
// Stable value to prevent unnecessary re-renders
let inputValue = value || '';
// Use default value if no value is provided
function getInitialValue() {
// If a value is explicitly provided, use it
if (value !== undefined && value !== null && value !== '') {
return value;
}
// Otherwise, use the field's default value
if (field.default_value !== undefined && field.default_value !== null) {
return field.default_value;
}
// Fallback to empty string or appropriate default based on field type
switch (field.field_type.base_type) {
case 'boolean':
return false;
case 'integer':
return '';
case 'select':
case 'node_selector':
return '';
default:
return '';
}
}
// Update internal value when prop changes
$: if (value !== inputValue) {
inputValue = value || '';
// Initialize with the determined initial value
let inputValue = getInitialValue();
// Update internal value when prop changes, but preserve defaults
$: {
const newInitialValue = getInitialValue();
if (newInitialValue !== inputValue) {
inputValue = newInitialValue;
// Notify parent of the default value if it wasn't already set
if ((value === undefined || value === null || value === '') &&
field.default_value !== undefined && field.default_value !== null) {
onUpdate(field.default_value);
}
}
}
function handleInput(event: Event) {
@@ -32,6 +67,14 @@
// Call parent update
onUpdate(newValue);
}
function handleRichSelectChange(value: any) {
// Update internal value immediately
inputValue = value;
// Call parent update
onUpdate(value);
}
</script>
{#key field.id}
@@ -101,59 +144,34 @@
{/each}
</select>
{:else if field.field_type.base_type === 'node_selector'}
<div class="space-y-3">
{#if !field.field_type.options || field.field_type.options.length === 0}
<div class="p-4 bg-yellow-900/20 border border-yellow-600 rounded-lg">
<div class="flex items-center gap-2">
<AlertCircle class="w-4 h-4 text-yellow-400" />
<span class="text-sm text-yellow-200">No compatible nodes available</span>
</div>
<p class="text-xs text-yellow-300 mt-1">
Create a node with the required capabilities first.
</p>
</div>
{:else}
<div class="grid grid-cols-1 gap-3">
{#each field.field_type.options as nodeOption}
<label class="flex items-start gap-3 p-3 bg-gray-700/50 border border-gray-600 rounded-lg
cursor-pointer hover:bg-gray-700/70 transition-colors
{inputValue === nodeOption.value ? 'border-blue-500 bg-blue-900/20' : ''}">
<input
type="radio"
name={field.id}
value={nodeOption.value}
checked={inputValue === nodeOption.value}
on:change={handleInput}
class="mt-0.5 text-blue-600 bg-gray-700 border-gray-600 focus:ring-blue-500"
/>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<Server class="w-4 h-4 text-blue-400" />
<span class="font-medium text-white">{nodeOption.label}</span>
</div>
{#if nodeOption.description}
<p class="text-sm text-gray-400 mt-1">{nodeOption.description}</p>
{/if}
</div>
</label>
{/each}
</div>
{/if}
{:else if field.field_type.base_type === 'rich_select'}
<RichSelect
selectedValue={inputValue}
options={field.field_type.options?.map(opt => ({
value: opt.value,
label: opt.label,
description: opt.description,
disabled: opt.disabled || false
})) || []}
placeholder={field.placeholder || 'Select an option...'}
required={field.required}
error={error}
onSelect={handleRichSelectChange}
getOptionIcon={field.id.includes('node') ? () => Server : null}
getOptionIconColor={field.id.includes('node') ? () => 'text-blue-400' : null}
/>
{/if}
{#if error}
<div class="flex items-center gap-2 text-red-400 text-sm">
<AlertCircle class="w-4 h-4" />
<span>{error}</span>
</div>
{/if}
<!-- Help Text -->
{#if field.help_text && field.field_type.base_type !== 'boolean'}
<p class="text-xs text-gray-400">{field.help_text}</p>
{/if}
<!-- Error Message -->
{#if error}
<p class="text-xs text-red-400 flex items-center gap-1">
<AlertCircle class="w-3 h-3" />
{error}
</p>
{/if}
</div>
{/key}
@@ -14,8 +14,8 @@
import { type AssignedTest, type NodeFormData } from '$lib/components/nodes/types';
import { criticalityLevels } from '$lib/api/registry';
import DynamicField from './DynamicField.svelte';
import CompatibilityIndicator from './CompatibilityIndicator.svelte';
import TestTypeDropdown from './TestTypeDropdown.svelte';
import Tag from '$lib/components/common/Tag.svelte';
export let test: AssignedTest | null = null;
export let onClose: () => void;
@@ -235,12 +235,6 @@
</div>
{:else if schema}
<!-- Incompatible Test -->
{#if schema.compatibility !== 'Compatible'}
<CompatibilityIndicator
schema={schema}
/>
{/if}
{#if schema.compatibility === 'Compatible'}
<div class="bg-gray-800/50 rounded-lg p-4 border border-gray-700">
<label for="test-criticality" class="block text-sm font-medium text-gray-200 mb-2">
@@ -1,137 +1,96 @@
<script lang="ts">
import { ChevronDown, Server, CheckCircle, AlertTriangle, XCircle } from 'lucide-svelte';
import { Server } from 'lucide-svelte';
import { testTypes } from '$lib/api/registry';
import RichSelect from '../../../common/RichSelect.svelte'
export let selectedTestType: string;
export let onTestTypeChange: (testType: string) => void;
export let schemaCache: Map<string, any> = new Map(); // Schema cache from parent
let isOpen = false;
let dropdownElement: HTMLDivElement;
let types = $testTypes.sort((a,b) => schemaCache.get(a.id).compatibility == 'Compatible' ? -1 : 1)
$: types = $testTypes.sort((a, b) => {
const aSchema = schemaCache.get(a.id);
const bSchema = schemaCache.get(b.id);
if (aSchema?.compatibility === 'Compatible' && bSchema?.compatibility !== 'Compatible') return -1;
if (bSchema?.compatibility === 'Compatible' && aSchema?.compatibility !== 'Compatible') return 1;
return 0;
});
$: selectedTestInfo = types.find(t => t.id === selectedTestType);
function handleSelect(testType: string) {
onTestTypeChange(testType);
isOpen = false;
}
function handleClickOutside(event: MouseEvent) {
if (dropdownElement && !dropdownElement.contains(event.target as Node)) {
isOpen = false;
// Transform test types into RichSelectOption format
$: richOptions = types.map(testType => ({
value: testType.id,
label: testType.display_name,
description: testType.description,
disabled: false,
metadata: {
category: testType.category,
color: testType.color,
schema: schemaCache.get(testType.id)
}
}));
function getOptionIcon(option: any) {
return Server;
}
function getCompatibilityInfo(testTypeId: string) {
const schema = schemaCache.get(testTypeId);
function getOptionIconColor(option: any) {
return option.metadata?.color || 'text-gray-300';
}
function getOptionBadge(option: any) {
return option.metadata?.category || null;
}
function getOptionBadgeColor(option: any) {
return 'bg-gray-600 text-gray-300';
}
function getOptionTag(option: any) {
const schema = option.metadata?.schema;
if (!schema) return null;
return {
status: schema.compatibility,
reason: schema.compatibility_reason
};
}
function getCompatibilityColor(status: string) {
const status = schema.compatibility;
switch (status) {
case 'Compatible':
return 'text-green-400';
return {
text: status,
textColor: 'text-green-300',
bgColor: 'bg-green-900/30'
};
case 'Conditional':
return 'text-yellow-400';
return {
text: status,
textColor: 'text-yellow-300',
bgColor: 'bg-yellow-900/30'
};
case 'Incompatible':
return 'text-red-400';
return {
text: status,
textColor: 'text-red-300',
bgColor: 'bg-red-900/30'
};
default:
return 'text-gray-400';
return null;
}
}
function getOptionStatusText(option: any) {
const schema = option.metadata?.schema;
return schema?.compatibility_reason || null;
}
</script>
<svelte:window on:click={handleClickOutside} />
<div class="relative" bind:this={dropdownElement}>
<div class="block text-sm font-medium text-gray-300 mb-2">
Test Type
</div>
<!-- Dropdown Trigger -->
<button
type="button"
on:click={() => isOpen = !isOpen}
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white
focus:outline-none focus:ring-2 focus:ring-blue-500 flex items-center justify-between"
>
<div class="flex items-center gap-3">
{#if selectedTestInfo}
<div class="w-6 h-6 rounded bg-gray-600 flex items-center justify-center">
<Server class="w-3 h-3 {selectedTestInfo.color}" />
</div>
<span>{selectedTestInfo.display_name}</span>
{:else}
<span class="text-gray-400">Select a test type...</span>
{/if}
</div>
<ChevronDown class="w-4 h-4 text-gray-400 transition-transform {isOpen ? 'rotate-180' : ''}" />
</button>
<!-- Test Description - inline text below dropdown -->
{#if selectedTestInfo}
<div class="mt-2">
<p class="text-sm text-gray-400">
{selectedTestInfo.description}
<span class="ml-2 px-2 py-0.5 text-xs bg-gray-600 text-gray-300 rounded">
{selectedTestInfo.category}
</span>
</p>
</div>
{/if}
<!-- Dropdown Menu -->
{#if isOpen}
<div class="absolute z-50 w-full bg-gray-700 border border-gray-600 rounded-md shadow-lg max-h-96 overflow-y-auto" style="top: calc(100% + 0.5rem);">
{#each $testTypes as testType}
{@const compatibilityInfo = getCompatibilityInfo(testType.id)}
<button
type="button"
on:click={(compatibilityInfo?.status != 'Incompatible' ? () => handleSelect(testType.id) : () => {})}
class="w-full px-3 py-3 text-left transition-colors border-b border-gray-600 last:border-b-0
{compatibilityInfo?.status === 'Incompatible' ? 'cursor-default' : 'hover:bg-gray-600'}"
>
<div class="flex items-start gap-3">
<div class="w-8 h-8 rounded-lg bg-gray-600 flex items-center justify-center mt-0.5">
<Server class="w-4 h-4 {testType.color}" />
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<h4 class="font-medium text-white">{testType.display_name}</h4>
<span class="inline-block px-2 py-1 text-xs bg-gray-600 text-gray-300 rounded">
{testType.category}
</span>
{#if compatibilityInfo}
<span class="inline-block px-2 py-1 text-xs rounded
{compatibilityInfo.status === 'Compatible' ? 'bg-green-900/30 text-green-300' :
compatibilityInfo.status === 'Conditional' ? 'bg-yellow-900/30 text-yellow-300' :
'bg-red-900/30 text-red-300'}">
{compatibilityInfo.status}
</span>
{/if}
</div>
<p class="text-sm text-gray-400 mt-1 line-clamp-2">{testType.description}</p>
{#if compatibilityInfo?.reason}
<p class="text-xs mt-1 {getCompatibilityColor(compatibilityInfo.status)}">
{compatibilityInfo.reason}
</p>
{/if}
</div>
</div>
</button>
{/each}
</div>
{/if}
</div>
<RichSelect
label="Test Type"
selectedValue={selectedTestType}
options={richOptions}
placeholder="Select a test type..."
required={true}
showDescriptionInTrigger={false}
onSelect={onTestTypeChange}
{getOptionIcon}
{getOptionIconColor}
{getOptionBadge}
{getOptionBadgeColor}
{getOptionTag}
{getOptionStatusText}
/>