diff --git a/api/src/unraid-api/graph/resolvers/rclone/jsonforms/rclone-jsonforms-config.ts b/api/src/unraid-api/graph/resolvers/rclone/jsonforms/rclone-jsonforms-config.ts index f08fd6c67..199f318b2 100644 --- a/api/src/unraid-api/graph/resolvers/rclone/jsonforms/rclone-jsonforms-config.ts +++ b/api/src/unraid-api/graph/resolvers/rclone/jsonforms/rclone-jsonforms-config.ts @@ -6,9 +6,6 @@ import { RCloneProviderOptionResponse } from '@app/unraid-api/graph/resolvers/rc import { createLabeledControl } from '@app/unraid-api/graph/utils/form-utils.js'; import { mergeSettingSlices } from '@app/unraid-api/types/json-forms.js'; -/** - * Translates RClone config option to JsonSchema properties - */ function translateRCloneOptionToJsonSchema({ option, }: { @@ -20,9 +17,7 @@ function translateRCloneOptionToJsonSchema({ description: option.Help || '', }; - // Add default value if available if (option.Default !== undefined && option.Default !== '') { - // RClone uses 'off' for SizeSuffix/Duration defaults sometimes if ((option.Type === 'SizeSuffix' || option.Type === 'Duration') && option.Default === 'off') { schema.default = 'off'; } else if (schema.type === 'number' && typeof option.Default === 'number') { @@ -32,47 +27,33 @@ function translateRCloneOptionToJsonSchema({ } else if (schema.type === 'boolean' && typeof option.Default === 'boolean') { schema.default = option.Default; } else if (schema.type === 'string') { - // Ensure default is a string if the type is string schema.default = String(option.Default); } } - // Add format hints const format = getJsonFormElementForType({ rcloneType: option.Type, examples: option.Examples?.map((example) => example.Value), isPassword: option.IsPassword, }); if (format && format !== schema.type && format !== 'combobox') { - // Don't add format if it's just the type (e.g., 'number') - // Don't add non-standard UI hints like 'combobox' to the schema format schema.format = format; } - // Add validation constraints and patterns if (option.Required) { - // Make '0' valid for required number/integer fields unless explicitly disallowed if (schema.type === 'string') { schema.minLength = 1; } - // Note: 'required' is usually handled at the object level in JSON Schema, - // but minLength/minimum provide basic non-empty checks. } - // Specific type-based validation switch (option.Type?.toLowerCase()) { case 'int': - // Handled by type: 'integer' in getJsonSchemaType break; case 'sizesuffix': - // Pattern allows 'off' or digits followed by optional size units (K, M, G, T, P) and optional iB/B - // Allows multiple concatenated values like 1G100M schema.pattern = '^(off|(\\d+([KMGTPE]i?B?)?)+)$'; schema.errorMessage = 'Invalid size format. Examples: "10G", "100M", "1.5GiB", "off".'; break; case 'duration': - // Pattern allows 'off' or digits (with optional decimal) followed by time units (ns, us, ms, s, m, h) - // Allows multiple concatenated values like 1h15m schema.pattern = '^(off|(d+(.d+)?(ns|us|\u00b5s|ms|s|m|h))+)$'; schema.errorMessage = 'Invalid duration format. Examples: "10s", "1.5m", "100ms", "1h15m", "off".'; @@ -82,14 +63,8 @@ function translateRCloneOptionToJsonSchema({ return schema; } -/** - * Step 1: Basic configuration - name and type selection - * Returns a SettingSlice containing properties and a VerticalLayout UI element with options.step = 0. - */ function getBasicConfigSlice({ providerTypes }: { providerTypes: string[] }): SettingSlice { - // Create UI elements for basic configuration (Step 1) const basicConfigElements: UIElement[] = [ - // --- START: Refactored 'name' field using helper --- createLabeledControl({ scope: '#/properties/name', label: 'Remote Name', @@ -99,11 +74,8 @@ function getBasicConfigSlice({ providerTypes }: { providerTypes: string[] }): Se placeholder: 'Enter a name', format: 'string', }, - // Add layoutOptions if needed, e.g., layoutOptions: { style: 'margin-bottom: 1em;' } }), - // --- END: Refactored 'name' field using helper --- - // --- START: Refactored 'type' field using helper --- createLabeledControl({ scope: '#/properties/type', label: 'Storage Provider Type', @@ -166,44 +138,34 @@ function getBasicConfigSlice({ providerTypes }: { providerTypes: string[] }): Se }; } -/** - * Step 1/2: Provider-specific configuration (standard or advanced). - * Returns a SettingSlice containing properties and a VerticalLayout UI element with options.step = stepIndex. - */ export function getProviderConfigSlice({ selectedProvider, providerOptions, - isAdvancedStep, // Flag to determine if this is for the advanced step + isAdvancedStep, stepIndex, }: { selectedProvider: string; providerOptions: RCloneProviderOptionResponse[]; - isAdvancedStep: boolean; // True if fetching advanced options, false for standard - stepIndex: number; // Required step index for the rule + isAdvancedStep: boolean; + stepIndex: number; }): SettingSlice { - // Default properties when no provider is selected const configProperties: DataSlice = {}; if (!selectedProvider || !providerOptions || providerOptions.length === 0) { return { properties: configProperties, - elements: [], // Return empty elements if no provider or options + elements: [], }; } - // Filter options based on whether we are fetching standard or advanced options const filteredOptions = providerOptions.filter((option) => { - // If fetching advanced, include only options marked as Advanced if (isAdvancedStep) { return option.Advanced === true; - } - // If fetching standard, include only options *not* marked as Advanced - else { + } else { return option.Advanced !== true; } }); - // Ensure uniqueness based on Name *within this slice* to prevent overwrites const uniqueOptionsByName = filteredOptions.reduce((acc, current) => { if (!acc.find((item) => item.Name === current.Name)) { acc.push(current); @@ -215,7 +177,6 @@ export function getProviderConfigSlice({ return acc; }, [] as RCloneProviderOptionResponse[]); - // If no options match the filter, return empty if (uniqueOptionsByName.length === 0) { return { properties: configProperties, @@ -223,9 +184,7 @@ export function getProviderConfigSlice({ }; } - // Create dynamic UI control elements based on unique provider options const controlElements = uniqueOptionsByName - // Filter out elements that are always hidden (Hide=1 without a provider filter) .filter((option) => { const providerFilter = option.Provider?.trim(); return !(option.Hide === 1 && !providerFilter); @@ -239,40 +198,31 @@ export function getProviderConfigSlice({ const controlOptions: Record = { placeholder: option.Default?.toString() || '', - // help: option.Help || '', // Help/Description should now be part of the control options for tooltip/aria required: option.Required || false, - format, // Pass format hint - // hide: option.Hide === 1, // Hiding is handled by the rule effect below if needed, or potentially JSON Forms renderer behavior + format, }; - // Use examples for placeholder if available if (option.Examples && option.Examples.length > 0) { const exampleValues = option.Examples.map((example) => example.Value).join(', '); controlOptions.placeholder = `e.g., ${exampleValues}`; } - // Only add toggle option for boolean fields without examples if (format === 'checkbox' && (!option.Examples || option.Examples.length === 0)) { controlOptions.toggle = true; } - // Add examples as suggestions for combobox if (format === 'combobox' && option.Examples && option.Examples.length > 0) { - // Check if the underlying type is boolean, handle undefined option.Type const isBooleanType = getJsonSchemaType(option.Type ?? '') === 'boolean'; controlOptions.suggestions = option.Examples.map((example) => ({ - // Parse string "true"/"false" to boolean if the type is boolean, handle potential null/undefined value: isBooleanType ? String(example.Value ?? '').toLowerCase() === 'true' : example.Value, - // Ensure label is also a string, even if value is null/undefined label: String(example.Value ?? ''), tooltip: example.Help || '', })); } - // --- Start: Add dynamic visibility rule based on Provider --- // - let providerRule: Rule | undefined = undefined; // Define rule type explicitly + let providerRule: Rule | undefined = undefined; const providerFilter = option.Provider?.trim(); if (providerFilter) { @@ -287,7 +237,6 @@ export function getProviderConfigSlice({ ? { not: { enum: providers } } : { enum: providers }; - // Show/Hide logic: If option.Hide === 1, we HIDE, otherwise default SHOW based on provider type const effect = option.Hide === 1 ? RuleEffect.HIDE : RuleEffect.SHOW; providerRule = { @@ -298,41 +247,19 @@ export function getProviderConfigSlice({ } as SchemaBasedCondition, }; } - } else if (option.Hide === 1) { - // If no provider filter, but Hide is set, create a rule to always hide - // This needs a condition that is always true, which is tricky. - // A simple approach is a condition that will likely always evaluate based on the schema. - // Alternatively, rely on JSON Forms renderer interpretation of a missing rule but Hide=1 property. - // For robustness, let's add a rule that hides if the field itself exists (which it always will if rendered). - // Note: This specific 'always hide' might need adjustment based on JSON Forms implementation details. - // A more direct approach might be filtering these out *before* mapping if always hidden. - // Let's assume for now `option.Hide=1` without a provider filter means it's *always* hidden. - // We can filter these out instead of creating a complex rule. - // Revisit this if hidden fields without provider filters are needed dynamically. - - // --- Simplified Logic: Filter out permanently hidden fields --- - if (option.Hide === 1 && !providerFilter) { - // Skip creating a UI element for this option entirely - // This case is now handled by the filter above - } } - // --- End: Add dynamic visibility rule based on Provider --- // - // --- Use the helper function --- const labeledControl = createLabeledControl({ scope: `#/properties/parameters/properties/${option.Name}`, - // Use Name as fallback label if Help is empty, otherwise use Help for label - label: option.Name, // Use Name for the label text + label: option.Name, description: option.Help || undefined, controlOptions: controlOptions, - rule: providerRule, // Apply the rule to the HorizontalLayout wrapper + rule: providerRule, }); - // Layout is a valid UIElement, no cast needed if filter handles nulls return labeledControl; }); - // Create dynamic properties schema based on unique provider options const paramProperties: Record = {}; uniqueOptionsByName.forEach((option) => { if (option) { @@ -340,62 +267,50 @@ export function getProviderConfigSlice({ } }); - // Only add parameters object if there are properties if (Object.keys(paramProperties).length > 0) { - // Ensure parameters object exists and has a properties key if (!configProperties.parameters) { configProperties.parameters = { type: 'object', properties: {} } as any; } else if (!(configProperties.parameters as any).properties) { (configProperties.parameters as any).properties = {}; } - // Merge the new paramProperties into the existing parameters.properties (configProperties.parameters as any).properties = { ...(configProperties.parameters as any).properties, ...paramProperties, }; } - // Wrap the control elements in a VerticalLayout marked for the specified stepIndex const verticalLayoutElement: UIElement = { type: 'VerticalLayout', - elements: controlElements, // Use the refactored elements - options: { step: stepIndex, showDividers: true }, // Assign stepIndex and add showDividers + elements: controlElements, + options: { step: stepIndex, showDividers: true }, }; return { properties: configProperties, - elements: [verticalLayoutElement], // Return the VerticalLayout as the single element + elements: [verticalLayoutElement], }; } -/** - * Helper function to convert RClone type to a basic JSON Schema type string (e.g., 'string', 'number', 'boolean'). - */ function getJsonSchemaType(rcloneType: string): string { switch (rcloneType?.toLowerCase()) { case 'int': - return 'integer'; // Use 'integer' for whole numbers - case 'size': // Assuming 'size' might imply large numbers, but 'number' is safer if decimals possible - case 'number': // If rclone explicitly uses 'number' + return 'integer'; + case 'size': + case 'number': return 'number'; case 'sizesuffix': case 'duration': - // Represent these as strings, validation handled by pattern/format return 'string'; case 'bool': return 'boolean'; case 'string': - case 'text': // Treat 'text' (multi-line) as 'string' in schema type - case 'password': // Passwords are strings + case 'text': + case 'password': default: - // Default to string if type is unknown or not provided return 'string'; } } -/** - * Helper function to get the appropriate UI format based on RClone option type - */ function getJsonFormElementForType({ rcloneType = '', examples = null, @@ -412,36 +327,26 @@ function getJsonFormElementForType({ switch (rcloneType?.toLowerCase()) { case 'int': case 'size': - // Schema type 'integer'/'number' is sufficient. - // UI framework should infer NumberField from schema type. return undefined; case 'sizesuffix': - return undefined; // Use default InputField (via isStringControl) + return undefined; case 'duration': - return undefined; // Use default InputField (via isStringControl) + return undefined; case 'bool': - // ALWAYS use checkbox/toggle for boolean fields, regardless of examples. - // RClone examples ("true"/"false") don't map well to UI boolean controls. return 'toggle'; case 'text': - // Consider 'textarea' format later if needed - return undefined; // Use default InputField (via isStringControl) + return undefined; case 'password': - return 'password'; // Explicit format for password managers etc. + return 'password'; case 'string': default: - // Use combobox for string fields with examples if (examples && examples.length > 0) { return 'combobox'; } - return undefined; // Use default InputField (via isStringControl) + return undefined; } } -/** - * Builds the complete settings schema for the RClone config UI, returning the final dataSchema and uiSchema. - * Integrates step-specific elements directly into the SteppedLayout's elements array. - */ export function buildRcloneConfigSchema({ providerTypes = [], selectedProvider = '', @@ -459,19 +364,16 @@ export function buildRcloneConfigSchema({ const optionsForProvider = providerOptions[selectedProvider] || []; const slicesToMerge: SettingSlice[] = []; - // Step 0: Basic Config (Always included) const basicSlice = getBasicConfigSlice({ providerTypes }); slicesToMerge.push(basicSlice); - // Step 1: Standard Provider Config (Always included if provider selected) if (selectedProvider && optionsForProvider.length > 0) { const standardConfigSlice = getProviderConfigSlice({ selectedProvider, providerOptions: optionsForProvider, - isAdvancedStep: false, // Fetch standard options + isAdvancedStep: false, stepIndex: 1, }); - // Only add if there are actual standard options if ( standardConfigSlice.elements.length > 0 || Object.keys(standardConfigSlice.properties).length > 0 @@ -480,16 +382,14 @@ export function buildRcloneConfigSchema({ } } - // Step 2: Advanced Provider Config (Conditionally included) let advancedConfigSlice: SettingSlice | null = null; if (showAdvanced && selectedProvider && optionsForProvider.length > 0) { advancedConfigSlice = getProviderConfigSlice({ selectedProvider, providerOptions: optionsForProvider, - isAdvancedStep: true, // Fetch advanced options + isAdvancedStep: true, stepIndex: 2, }); - // Only add if there are actual advanced options if ( advancedConfigSlice.elements.length > 0 || Object.keys(advancedConfigSlice.properties).length > 0 @@ -498,21 +398,13 @@ export function buildRcloneConfigSchema({ } } - // Merge all relevant slices const mergedSlices = mergeSettingSlices(slicesToMerge); - // Construct the final dataSchema - // Add explicit type annotation to satisfy stricter type checking const dataSchema: { properties: DataSlice; type: 'object' } = { type: 'object', properties: mergedSlices.properties, - // Add required fields if necessary, e.g., ['name', 'type'] - // required: ['name', 'type'], // Example: Make name and type required globally }; - // --- UI Schema Definition --- - - // Define steps based on whether advanced options are shown const steps = [{ label: 'Set up Remote Config', description: 'Name and provider selection' }]; if (selectedProvider) { @@ -527,17 +419,14 @@ export function buildRcloneConfigSchema({ steps.push({ label: 'Advanced Config', description: 'Optional advanced settings' }); } - // Define the SteppedLayout UI element const steppedLayoutElement: UIElement = { type: 'SteppedLayout', options: { - steps: steps, // Use dynamically generated steps + steps: steps, }, - // Nest the step content elements directly inside the SteppedLayout elements: mergedSlices.elements, }; - // Define the overall title label const titleLabel: UIElement = { type: 'Label', text: 'Configure RClone Remote', @@ -548,16 +437,9 @@ export function buildRcloneConfigSchema({ }, }; - // --- Merging and Final Output --- - - // Construct the final uiSchema with Title + SteppedLayout (containing steps) const uiSchema: Layout = { type: 'VerticalLayout', - elements: [ - titleLabel, - steppedLayoutElement, // The Stepper control, now containing its step elements - // Step content elements are now *inside* steppedLayoutElement.elements - ], + elements: [titleLabel, steppedLayoutElement], }; return { dataSchema, uiSchema }; diff --git a/unraid-ui/src/forms/ComboBoxField.vue b/unraid-ui/src/forms/ComboBoxField.vue index 21a2ea734..d5588f597 100644 --- a/unraid-ui/src/forms/ComboBoxField.vue +++ b/unraid-ui/src/forms/ComboBoxField.vue @@ -50,7 +50,7 @@ const handleOpenChange = (open: boolean) => { if (!open) { if (control.value.uischema.options?.strictSuggestions && suggestions.value.length > 0) { const isValid = suggestions.value.some( - suggestion => suggestion.value === inputValue.value || suggestion.label === inputValue.value + (suggestion) => suggestion.value === inputValue.value || suggestion.label === inputValue.value ); if (!isValid) { inputValue.value = control.value.data || '';