fix: colors

This commit is contained in:
Eli Bosley
2025-05-23 20:48:07 -04:00
parent 7f9f4c68ac
commit f29d4f5318
2 changed files with 27 additions and 145 deletions

View File

@@ -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<string, any> = {
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<string, JsonSchema7> = {};
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 };

View File

@@ -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 || '';