chore: error wrapper generic

This commit is contained in:
Eli Bosley
2025-04-17 19:57:30 -04:00
parent f5e4607f70
commit dada8e63c5
34 changed files with 500 additions and 302 deletions

View File

@@ -21,9 +21,7 @@ const initialState = {
),
'docker-autostart': '/var/lib/docker/unraid-autostart' as const,
'docker-socket': '/var/run/docker.sock' as const,
'rclone-socket': resolvePath(
process.env.PATHS_RCLONE_SOCKET ?? ('/var/run/rclone.socket' as const)
),
'rclone-socket': resolvePath(process.env.PATHS_RCLONE_SOCKET ?? ('/var/run/rclone.socket' as const)),
'parity-checks': resolvePath(
process.env.PATHS_PARITY_CHECKS ?? ('/boot/config/parity-checks.log' as const)
),

View File

@@ -52,7 +52,11 @@ export class AuthService {
async validateCookiesWithCsrfToken(request: FastifyRequest): Promise<UserAccount> {
try {
if (request.method !== 'GET' && !request.url.startsWith('/graphql/api/rclone-webgui/') && (!this.validateCsrfToken(request.headers['x-csrf-token'] || request.query.csrf_token))) {
if (
request.method !== 'GET' &&
!request.url.startsWith('/graphql/api/rclone-webgui/') &&
!this.validateCsrfToken(request.headers['x-csrf-token'] || request.query.csrf_token)
) {
throw new UnauthorizedException('Invalid CSRF token');
}

View File

@@ -95,8 +95,8 @@ export class ConnectSettingsService {
getState: store.getState,
dispatch: store.dispatch,
});
}
return true;
}
return true;
}
async isSignedIn(): Promise<boolean> {

View File

@@ -59,7 +59,6 @@ export class DockerService implements OnModuleInit {
public async onModuleInit() {
try {
this.logger.debug('Warming Docker cache on startup...');
await this.getContainers({ skipCache: true });
await this.getNetworks({ skipCache: true });
this.logger.debug('Docker cache warming complete.');

View File

@@ -1,6 +1,6 @@
import { Module } from '@nestjs/common';
import { FlashBackupResolver } from './flash-backup.resolver.js';
import { FlashBackupResolver } from '@app/unraid-api/graph/resolvers/flash-backup/flash-backup.resolver.js';
import { RCloneModule } from '@app/unraid-api/graph/resolvers/rclone/rclone.module.js';
@Module({

View File

@@ -1,11 +1,10 @@
import { Inject, Logger } from '@nestjs/common';
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import {
FlashBackupStatus,
InitiateFlashBackupInput,
} from './flash-backup.model.js';
} from '@app/unraid-api/graph/resolvers/flash-backup/flash-backup.model.js';
import { RCloneService } from '@app/unraid-api/graph/resolvers/rclone/rclone.service.js';
@Resolver()

View File

@@ -1,10 +1,10 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { config as rawProviderConfig } from './config.js'; // Added .js extension
import { getProviderConfigSlice } from './rclone-jsonforms-config.js'; // Added .js extension
// Adjusted path assuming rclone.model.ts is sibling to jsonforms dir
import type { RCloneProviderOptionResponse } from '../rclone.model.js';
import type { JsonSchema7, Layout, SchemaBasedCondition } from '@jsonforms/core';
import { beforeEach, describe, expect, it } from 'vitest';
// Adjusted path assuming rclone.model.ts is sibling to jsonforms dir
import type { RCloneProviderOptionResponse } from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js';
import { config as rawProviderConfig } from '@app/unraid-api/graph/resolvers/rclone/jsonforms/config.js'; // Added .js extension
import { getProviderConfigSlice } from '@app/unraid-api/graph/resolvers/rclone/jsonforms/rclone-jsonforms-config.js'; // Added .js extension
// Placeholder type for UIElement if the original path doesn't resolve in tests
// Make placeholder more specific to include expected properties
@@ -15,7 +15,8 @@ type UIElement = {
options?: Record<string, any>;
rule?: {
effect: string; // RuleEffect is an enum, use string for simplicity in test type
condition: SchemaBasedCondition & { // Assert that it's SchemaBased
condition: SchemaBasedCondition & {
// Assert that it's SchemaBased
scope: string;
schema: JsonSchema7;
};
@@ -37,14 +38,17 @@ interface RawProviderConfigEntry {
// Process the raw config into the format expected by the functions under test
const providerOptionsMap: Record<string, RCloneProviderOptionResponse[]> = (
rawProviderConfig as RawProviderConfigEntry[]
).reduce((acc, provider) => {
if (provider.Name && Array.isArray(provider.Options)) {
// Ensure options conform to the expected type structure if necessary
// For now, we assume the structure matches RCloneProviderOptionResponse
acc[provider.Name] = provider.Options;
}
return acc;
}, {} as Record<string, RCloneProviderOptionResponse[]>);
).reduce(
(acc, provider) => {
if (provider.Name && Array.isArray(provider.Options)) {
// Ensure options conform to the expected type structure if necessary
// For now, we assume the structure matches RCloneProviderOptionResponse
acc[provider.Name] = provider.Options;
}
return acc;
},
{} as Record<string, RCloneProviderOptionResponse[]>
);
const providerNames = Object.keys(providerOptionsMap);
@@ -72,7 +76,7 @@ describe('getProviderConfigSlice', () => {
expect(result.elements).toEqual([]);
});
it('should return an empty slice if providerOptions are empty', () => {
it('should return an empty slice if providerOptions are empty', () => {
const result = getProviderConfigSlice({
selectedProvider: testProvider, // Valid provider
providerOptions: [], // Empty options
@@ -82,7 +86,7 @@ describe('getProviderConfigSlice', () => {
expect(result.elements).toEqual([]);
});
it('should return only standard options when type is \'standard\'', () => {
it("should return only standard options when type is 'standard'", () => {
const result = getProviderConfigSlice({
selectedProvider: testProvider,
providerOptions: s3Options,
@@ -96,17 +100,17 @@ describe('getProviderConfigSlice', () => {
expect(Object.keys(paramProps).length).toBeGreaterThan(0);
// Check that all properties included are standard (Advanced !== true)
const standardOptions = s3Options.filter(opt => opt.Advanced !== true);
const uniqueStandardOptionNames = [...new Set(standardOptions.map(opt => opt.Name))];
const standardOptions = s3Options.filter((opt) => opt.Advanced !== true);
const uniqueStandardOptionNames = [...new Set(standardOptions.map((opt) => opt.Name))];
// Assert against the count of UNIQUE standard option names
expect(Object.keys(paramProps).length).toEqual(uniqueStandardOptionNames.length);
// Check that each unique standard option name exists in the generated props
uniqueStandardOptionNames.forEach(name => {
uniqueStandardOptionNames.forEach((name) => {
expect(paramProps[name]).toBeDefined();
// Find the first option with this name to check title (or implement more complex logic if needed)
const correspondingOption = standardOptions.find(opt => opt.Name === name);
const correspondingOption = standardOptions.find((opt) => opt.Name === name);
expect(paramProps[name]?.title).toEqual(correspondingOption?.Name);
});
@@ -116,20 +120,21 @@ describe('getProviderConfigSlice', () => {
expect(result.elements.length).toEqual(uniqueStandardOptionNames.length);
// Check elements based on unique names
uniqueStandardOptionNames.forEach(name => {
uniqueStandardOptionNames.forEach((name) => {
// Use `as any` for type assertion on the result elements array
const elementsArray = result.elements as any[];
// Find element by scope instead of label
const expectedScope = `#/properties/parameters/properties/${name}`;
const element = elementsArray.find((el) => el.scope === expectedScope);
expect(element).toBeDefined(); // Check if element was found
if (element) { // Basic check
if (element) {
// Basic check
expect(element.type).toEqual('Control');
}
});
});
it('should return only advanced options when type is \'advanced\'', () => {
it("should return only advanced options when type is 'advanced'", () => {
const result = getProviderConfigSlice({
selectedProvider: testProvider,
providerOptions: s3Options,
@@ -143,16 +148,16 @@ describe('getProviderConfigSlice', () => {
expect(Object.keys(paramProps).length).toBeGreaterThan(0);
// Check that all properties included are advanced (Advanced === true)
const advancedOptions = s3Options.filter(opt => opt.Advanced === true);
const uniqueAdvancedOptionNames = [...new Set(advancedOptions.map(opt => opt.Name))];
const advancedOptions = s3Options.filter((opt) => opt.Advanced === true);
const uniqueAdvancedOptionNames = [...new Set(advancedOptions.map((opt) => opt.Name))];
// Assert against the count of UNIQUE advanced option names
expect(Object.keys(paramProps).length).toEqual(uniqueAdvancedOptionNames.length);
// Check that each unique advanced option name exists in the generated props
uniqueAdvancedOptionNames.forEach(name => {
uniqueAdvancedOptionNames.forEach((name) => {
expect(paramProps[name]).toBeDefined();
const correspondingOption = advancedOptions.find(opt => opt.Name === name);
const correspondingOption = advancedOptions.find((opt) => opt.Name === name);
expect(paramProps[name]?.title).toEqual(correspondingOption?.Name);
});
@@ -162,14 +167,15 @@ describe('getProviderConfigSlice', () => {
expect(result.elements.length).toEqual(uniqueAdvancedOptionNames.length);
// Check elements based on unique names
uniqueAdvancedOptionNames.forEach(name => {
uniqueAdvancedOptionNames.forEach((name) => {
// Use `as any` for type assertion on the result elements array
const elementsArray = result.elements as any[];
// Find element by scope instead of label
const expectedScope = `#/properties/parameters/properties/${name}`;
const element = elementsArray.find((el) => el.scope === expectedScope);
expect(element).toBeDefined(); // Check if element was found
if (element) { // Basic check
if (element) {
// Basic check
expect(element.type).toEqual('Control');
}
});
@@ -180,7 +186,7 @@ describe('getProviderConfigSlice', () => {
const aliasOptions = providerOptionsMap[testProviderNoAdvanced];
// Pre-check: Verify that the chosen provider actually has no advanced options in our data
const hasAdvanced = aliasOptions?.some(opt => opt.Advanced === true);
const hasAdvanced = aliasOptions?.some((opt) => opt.Advanced === true);
expect(hasAdvanced).toBe(false); // Ensure our assumption about 'alias' holds
const result = getProviderConfigSlice({

View File

@@ -1,4 +1,4 @@
import type { Layout, SchemaBasedCondition } from '@jsonforms/core';
import type { Layout, SchemaBasedCondition, ControlElement, LabelElement } from '@jsonforms/core';
import { JsonSchema7, RuleEffect } from '@jsonforms/core';
import { filter } from 'rxjs';
@@ -87,16 +87,30 @@ function translateRCloneOptionToJsonSchema({
*/
function getBasicConfigSlice({ providerTypes }: { providerTypes: string[] }): SettingSlice {
// Create UI elements for basic configuration (Step 1)
const basicConfigElements: UIElement[] = [
const basicConfigElements: (ControlElement | LabelElement | Layout)[] = [
{
type: 'Control',
type: 'HorizontalLayout',
scope: '#/properties/name',
label: 'Name of this remote (For your reference)',
options: {
placeholder: 'Enter a name',
format: 'string',
},
},
elements: [
{
type: 'Label',
scope: '#/properties/name',
text: 'Remote Name',
options: {
// Optional styling
},
} as LabelElement,
{
type: 'Control',
scope: '#/properties/name',
options: {
placeholder: 'Enter a name',
format: 'string',
description: 'Name to identify this remote configuration (e.g., my_google_drive). Use only letters, numbers, hyphens, and underscores.',
},
} as ControlElement,
],
} as Layout,
{
type: 'Control',
scope: '#/properties/type',
@@ -109,9 +123,35 @@ function getBasicConfigSlice({ providerTypes }: { providerTypes: string[] }): Se
type: 'Label',
text: 'Documentation Link',
options: {
description: 'For more information, refer to the [RClone Config Documentation](https://rclone.org/commands/rclone_config/).',
description:
'For more information, refer to the [RClone Config Documentation](https://rclone.org/commands/rclone_config/).',
},
},
// --- START: Added HorizontalLayout with visibility rule for testing ---
{
type: 'HorizontalLayout',
rule: {
effect: RuleEffect.HIDE,
condition: {
scope: '#/properties/name',
schema: { const: 'hide_me' }, // Hide if name is exactly 'hide_me'
},
},
elements: [
{
type: 'Label',
text: 'Hidden Field Label',
} as LabelElement,
{
type: 'Control',
scope: '#/properties/hiddenField', // Needs corresponding schema property
options: {
placeholder: 'This field is hidden if name is hide_me',
},
} as ControlElement,
],
} as Layout,
// --- END: Added HorizontalLayout with visibility rule for testing ---
];
// Define the data schema for basic configuration
@@ -130,6 +170,13 @@ function getBasicConfigSlice({ providerTypes }: { providerTypes: string[] }): Se
default: providerTypes.length > 0 ? providerTypes[0] : '',
enum: providerTypes,
},
// --- START: Added schema property for the hidden field ---
hiddenField: {
type: 'string',
title: 'Hidden Field',
description: 'This field should only be visible when the name is not \'hide_me\'',
},
// --- END: Added schema property for the hidden field ---
};
// Wrap the basic elements in a VerticalLayout marked for step 0
@@ -161,7 +208,7 @@ export function getProviderConfigSlice({
stepIndex: number; // Required step index for the rule
}): SettingSlice {
// Default properties when no provider is selected
let configProperties: DataSlice = {};
const configProperties: DataSlice = {};
if (!selectedProvider || !providerOptions || providerOptions.length === 0) {
return {
@@ -465,7 +512,8 @@ export function buildRcloneConfigSchema({
text: 'Configure RClone Remote',
options: {
format: 'title',
description: 'This 3-step process will guide you through setting up your RClone remote configuration.',
description:
'This 3-step process will guide you through setting up your RClone remote configuration.',
},
};

View File

@@ -1,9 +1,9 @@
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import crypto from 'crypto';
import { ChildProcess } from 'node:child_process';
import { existsSync } from 'node:fs';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { ChildProcess } from 'node:child_process';
import { execa } from 'execa';
import got from 'got';
@@ -115,7 +115,7 @@ export class RCloneApiService implements OnModuleInit, OnModuleDestroy {
'--rc-pass',
this.rclonePassword,
'--log-file',
logFilePath
logFilePath,
],
{ detached: false } // Keep attached to manage lifecycle
);
@@ -129,7 +129,9 @@ export class RCloneApiService implements OnModuleInit, OnModuleDestroy {
// Handle unexpected exit
this.rcloneProcess.on('exit', (code, signal) => {
this.logger.warn(`RClone process exited unexpectedly with code: ${code}, signal: ${signal}`);
this.logger.warn(
`RClone process exited unexpectedly with code: ${code}, signal: ${signal}`
);
this.rcloneProcess = null;
this.isInitialized = false;
});

View File

@@ -2,11 +2,10 @@ import { Injectable, Logger } from '@nestjs/common';
import { type Layout } from '@jsonforms/core';
import { buildRcloneConfigSchema } from '@app/unraid-api/graph/resolvers/rclone/jsonforms/rclone-jsonforms-config.js';
import { RCloneApiService } from '@app/unraid-api/graph/resolvers/rclone/rclone-api.service.js';
import { RCloneProviderOptionResponse } from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js';
import { buildRcloneConfigSchema } from './jsonforms/rclone-jsonforms-config.js';
import { RCloneApiService } from './rclone-api.service.js';
/**
* Service responsible for generating form UI schemas and form logic
*/

View File

@@ -59,10 +59,10 @@ export class RCloneBackupConfigForm {
@Field(() => GraphQLJSON)
uiSchema!: Layout;
@Field(() => String, { nullable: true })
providerType?: string;
@Field(() => GraphQLJSON, { nullable: true })
parameters?: Record<string, unknown>;
}

View File

@@ -1,18 +1,13 @@
import { Module } from '@nestjs/common';
import { RCloneService } from '@app/unraid-api/graph/resolvers/rclone/rclone.service.js';
import { RCloneApiService } from '@app/unraid-api/graph/resolvers/rclone/rclone-api.service.js';
import { RCloneFormService } from '@app/unraid-api/graph/resolvers/rclone/rclone-form.service.js';
import { RCloneBackupSettingsResolver } from '@app/unraid-api/graph/resolvers/rclone/rclone.resolver.js';
import { RCloneService } from '@app/unraid-api/graph/resolvers/rclone/rclone.service.js';
@Module({
imports: [],
providers: [
RCloneService,
RCloneApiService,
RCloneFormService,
RCloneBackupSettingsResolver,
],
exports: [RCloneService, RCloneApiService]
imports: [],
providers: [RCloneService, RCloneApiService, RCloneFormService, RCloneBackupSettingsResolver],
exports: [RCloneService, RCloneApiService],
})
export class RCloneModule {}

View File

@@ -1,22 +1,23 @@
import { Logger } from '@nestjs/common';
import { Args, Mutation, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql';
import { GraphQLJSON } from 'graphql-scalars';
import { AuthActionVerb, AuthPossession, UsePermissions } from '@app/unraid-api/graph/directives/use-permissions.directive.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.js';
import { RCloneApiService } from '@app/unraid-api/graph/resolvers/rclone/rclone-api.service.js';
import { CreateRCloneRemoteInput, RCloneBackupConfigForm, RCloneBackupSettings, RCloneRemote } from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js';
import { RCloneFormService } from './rclone-form.service.js';
import { RCloneService } from './rclone.service.js';
import { RCloneFormService } from '@app/unraid-api/graph/resolvers/rclone/rclone-form.service.js';
import {
CreateRCloneRemoteInput,
RCloneBackupConfigForm,
RCloneBackupSettings,
RCloneRemote,
} from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js';
import { RCloneService } from '@app/unraid-api/graph/resolvers/rclone/rclone.service.js';
@Resolver(() => RCloneBackupSettings)
export class RCloneBackupSettingsResolver {
@@ -86,4 +87,4 @@ export class RCloneBackupSettingsResolver {
return [];
}
}
}
}

View File

@@ -1,19 +1,11 @@
import { Injectable, Logger } from '@nestjs/common';
import { type Layout } from '@jsonforms/core';
import type { SettingSlice } from '@app/unraid-api/types/json-forms.js';
import { RCloneApiService } from '@app/unraid-api/graph/resolvers/rclone/rclone-api.service.js';
import { RCloneFormService } from '@app/unraid-api/graph/resolvers/rclone/rclone-form.service.js';
/**
* Types for rclone backup configuration UI
*/
@@ -69,7 +61,7 @@ export class RCloneService {
const providersResponse = await this.rcloneApiService.getProviders();
if (providersResponse) {
// Extract provider types
this._providerTypes = providersResponse.map((provider) => provider.Name);
this._providerTypes = providersResponse.map((provider) => provider.Name);
this._providerOptions = providersResponse;
this.logger.debug(`Loaded ${this._providerTypes.length} provider types`);
}
@@ -95,4 +87,4 @@ export class RCloneService {
async getConfiguredRemotes(): Promise<string[]> {
return this.rcloneApiService.listRemotes();
}
}
}

View File

@@ -1,19 +1,17 @@
import { Controller, Get, Logger, Param, Res, Req, All } from '@nestjs/common';
import { All, Controller, Get, Logger, Param, Req, Res } from '@nestjs/common';
import got from 'got';
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
import type { FastifyReply, FastifyRequest } from '@app/unraid-api/types/fastify.js';
import { Public } from '@app/unraid-api/auth/public.decorator.js';
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.js';
import { RestService } from '@app/unraid-api/rest/rest.service.js';
import got from 'got';
@Controller()
export class RestController {
protected logger = new Logger(RestController.name);
constructor(
private readonly restService: RestService,
) {}
constructor(private readonly restService: RestService) {}
@Get('/')
@Public()
@@ -56,7 +54,7 @@ export class RestController {
return res.status(500).send(`Error: Failed to get customizations`);
}
}
/*
/*
@All('/graphql/api/rclone-webgui/*')
@UsePermissions({
action: AuthActionVerb.READ,

View File

@@ -1,8 +1,9 @@
import { Module } from '@nestjs/common';
import { RCloneModule } from '@app/unraid-api/graph/resolvers/rclone/rclone.module.js';
import { RestController } from '@app/unraid-api/rest/rest.controller.js';
import { RestService } from '@app/unraid-api/rest/rest.service.js';
import { RCloneModule } from '@app/unraid-api/graph/resolvers/rclone/rclone.module.js';
@Module({
imports: [RCloneModule],
controllers: [RestController],

View File

@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import { mergeSettingSlices, createEmptySettingSlice } from './json-forms.js';
import type { SettingSlice, DataSlice, UIElement } from './json-forms.js';
import { describe, expect, it } from 'vitest';
import type { DataSlice, SettingSlice, UIElement } from '@app/unraid-api/types/json-forms.js';
import { createEmptySettingSlice, mergeSettingSlices } from '@app/unraid-api/types/json-forms.js';
describe('mergeSettingSlices', () => {
it('should return an empty slice when merging an empty array', () => {
@@ -87,4 +88,4 @@ describe('mergeSettingSlices', () => {
expect(mergedSlice.properties).toEqual(expectedProperties);
expect(mergedSlice.elements).toEqual(expectedElements);
});
});
});

View File

@@ -4,17 +4,14 @@ const useTeleport = () => {
const teleportTarget = ref<string | HTMLElement>('#modals');
const determineTeleportTarget = () => {
const myModalsComponent = document.querySelector('unraid-modals') || document.querySelector('uui-modals');
console.log('myModalsComponent', myModalsComponent, 'has shadowRoot', myModalsComponent?.shadowRoot);
const myModalsComponent =
document.querySelector('unraid-modals') || document.querySelector('uui-modals');
if (!myModalsComponent?.shadowRoot) return;
const potentialTarget = myModalsComponent.shadowRoot.querySelector('#modals');
if (!potentialTarget) return;
console.log('potentialTarget', potentialTarget);
teleportTarget.value = potentialTarget as HTMLElement;
console.log('[determineTeleportTarget] teleportTarget', teleportTarget.value);
};
onMounted(() => {

View File

@@ -1,32 +0,0 @@
<script setup lang="ts">
import { Label } from '@/components/form/label';
import { computed } from 'vue';
const props = defineProps<{
label: string;
errors?: string | string[];
}>();
const normalizedErrors = computed(() => {
if (!props.errors) return [];
return Array.isArray(props.errors) ? props.errors : [props.errors];
});
// ensures the label ends with a colon
// todo: in RTL locales, this probably isn't desirable
const formattedLabel = computed(() => {
return props.label.endsWith(':') ? props.label : `${props.label}:`;
});
</script>
<template>
<div class="grid grid-cols-settings items-baseline">
<Label class="text-end">{{ formattedLabel }}</Label>
<div class="ml-10 max-w-3xl">
<slot />
<div v-if="normalizedErrors.length > 0" class="mt-2 text-red-500 text-sm">
<p v-for="error in normalizedErrors" :key="error">{{ error }}</p>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { ControlElement } from '@jsonforms/core';
import { useJsonFormsControl } from '@jsonforms/vue';
import type { RendererProps } from '@jsonforms/vue';
import FormErrors from '@/forms/FormErrors.vue';
// Define props consistent with JsonForms renderers
const props = defineProps<RendererProps<ControlElement>>();
// Use the standard composable to get control state
const { control } = useJsonFormsControl(props);
</script>
<template>
<!-- Only render the wrapper if the control is visible -->
<div v-if="control.visible">
<!-- Render the actual control passed via the default slot -->
<slot />
<!-- Automatically display errors below the control -->
<FormErrors :errors="control.errors" />
</div>
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import { computed } from 'vue';
const props = defineProps<{
errors?: string | string[];
}>();
const normalizedErrors = computed(() => {
if (!props.errors) return [];
return Array.isArray(props.errors) ? props.errors : [props.errors];
});
</script>
<template>
<div v-if="normalizedErrors.length > 0" class="mt-2 text-red-500 text-sm">
<p v-for="error in normalizedErrors" :key="error">{{ error }}</p>
</div>
</template>

View File

@@ -0,0 +1,48 @@
<script lang="ts" setup>
/**
* HorizontalLayout component
*
* Renders form elements in a horizontal layout with labels aligned to the right
* and fields to the left. Consumes JSON Schema uischema to determine what elements
* to render.
*
* @prop schema - The JSON Schema
* @prop uischema - The UI Schema containing the layout elements
* @prop path - The current path
* @prop enabled - Whether the form is enabled
* @prop renderers - Available renderers
* @prop cells - Available cells
*/
import type { HorizontalLayout } from '@jsonforms/core';
import { DispatchRenderer, type RendererProps } from '@jsonforms/vue';
import { computed } from 'vue';
import { useJsonFormsVisibility } from './composables/useJsonFormsVisibility';
const props = defineProps<RendererProps<HorizontalLayout>>();
// Use the new composable
const { layout, isVisible } = useJsonFormsVisibility({ rendererProps: props });
const elements = computed(() => {
// Access elements from the layout object returned by the composable
return layout.layout.value.uischema.elements || [];
});
</script>
<template>
<div v-if="isVisible" class="flex flex-row gap-x-2">
<template v-for="(element, index) in elements" :key="index">
<DispatchRenderer
class="ml-10"
:schema="layout.layout.value.schema"
:uischema="element"
:path="layout.layout.value.path"
:enabled="layout.layout.value.enabled"
:renderers="layout.layout.value.renderers"
:cells="layout.layout.value.cells"
/>
</template>
</div>
</template>

View File

@@ -1,6 +1,5 @@
<script setup lang="ts">
import { Input } from '@/components/form/input';
import ControlLayout from '@/forms/ControlLayout.vue';
import { cn } from '@/lib/utils';
import type { ControlElement } from '@jsonforms/core';
import type { RendererProps } from '@jsonforms/vue';
@@ -29,7 +28,6 @@ const classOverride = computed(() => {
</script>
<template>
<ControlLayout v-if="control.visible" :label="control.label" :errors="control.errors">
<Input
v-model="value"
:type="inputType"
@@ -38,5 +36,4 @@ const classOverride = computed(() => {
:required="control.required"
:placeholder="control.schema.description"
/>
</ControlLayout>
</template>

View File

@@ -5,7 +5,6 @@ import {
NumberFieldIncrement,
NumberFieldInput,
} from '@/components/form/number';
import ControlLayout from '@/forms/ControlLayout.vue';
import { cn } from '@/lib/utils';
import type { ControlElement } from '@jsonforms/core';
import type { RendererProps } from '@jsonforms/vue';
@@ -34,7 +33,6 @@ const classOverride = computed(() => {
</script>
<template>
<ControlLayout v-if="control.visible" :label="control.label" :errors="control.errors">
<NumberField
v-model="value"
:min="min"
@@ -49,7 +47,6 @@ const classOverride = computed(() => {
>
<NumberFieldDecrement v-if="stepperEnabled" />
<NumberFieldInput />
<NumberFieldIncrement v-if="stepperEnabled" />
</NumberField>
</ControlLayout>
<NumberFieldIncrement v-if="stepperEnabled" />
</NumberField>
</template>

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
import ControlLayout from '@/forms/ControlLayout.vue';
import type { LabelElement } from '@jsonforms/core';
import type { RendererProps } from '@jsonforms/vue';
import { computed } from 'vue';
@@ -23,13 +22,11 @@ type PreconditionsLabelElement = LabelElement & {
// Each item should have a `text` and a `status` (boolean) property.
const props = defineProps<RendererProps<PreconditionsLabelElement>>();
const labelText = computed(() => props.uischema.text);
const items = computed(() => props.uischema.options?.items || []);
const description = computed(() => props.uischema.options?.description);
</script>
<template>
<ControlLayout :label="labelText">
<!-- Render each precondition as a list item with an icon bullet -->
<p v-if="description" class="mb-2">{{ description }}</p>
<ul class="list-none space-y-1">
@@ -39,5 +36,4 @@ const description = computed(() => props.uischema.options?.description);
<span>{{ item.text }}</span>
</li>
</ul>
</ControlLayout>
</template>

View File

@@ -9,7 +9,6 @@ import {
SelectValue,
} from '@/components/form/select';
import useTeleport from '@/composables/useTeleport';
import ControlLayout from '@/forms/ControlLayout.vue';
import type { ControlElement } from '@jsonforms/core';
import { useJsonFormsControl } from '@jsonforms/vue';
import type { RendererProps } from '@jsonforms/vue';
@@ -42,39 +41,38 @@ const onSelectOpen = () => {
</script>
<template>
<ControlLayout v-if="control.visible" :label="control.label" :errors="control.errors">
<Select
v-model="selected"
:disabled="!control.enabled"
:required="control.required"
@update:model-value="onChange"
@update:open="onSelectOpen"
>
<!-- The trigger shows the currently selected value (if any) -->
<SelectTrigger>
<SelectValue v-if="selected">{{ selected }}</SelectValue>
<span v-else>{{ control.schema.default ?? 'Select an option' }}</span>
</SelectTrigger>
<!-- The content includes the selectable options -->
<SelectContent :to="teleportTarget">
<template v-for="option in options" :key="option.value">
<TooltipProvider v-if="option.tooltip" :delay-duration="50">
<Tooltip>
<TooltipTrigger as-child>
<SelectItem :value="option.value">
<SelectItemText>{{ option.label }}</SelectItemText>
</SelectItem>
</TooltipTrigger>
<TooltipContent :to="teleportTarget" side="right" :side-offset="5">
<p class="max-w-xs">{{ option.tooltip }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<SelectItem v-else :value="option.value">
<SelectItemText>{{ option.label }}</SelectItemText>
</SelectItem>
</template>
</SelectContent>
</Select>
</ControlLayout>
<!-- The ControlWrapper now handles the v-if based on control.visible -->
<Select
v-model="selected"
:disabled="!control.enabled"
:required="control.required"
@update:model-value="onChange"
@update:open="onSelectOpen"
>
<!-- The trigger shows the currently selected value (if any) -->
<SelectTrigger>
<SelectValue v-if="selected">{{ selected }}</SelectValue>
<span v-else>{{ control.schema.default ?? 'Select an option' }}</span>
</SelectTrigger>
<!-- The content includes the selectable options -->
<SelectContent :to="teleportTarget">
<template v-for="option in options" :key="option.value">
<TooltipProvider v-if="option.tooltip" :delay-duration="50">
<Tooltip>
<TooltipTrigger as-child>
<SelectItem :value="option.value">
<SelectItemText>{{ option.label }}</SelectItemText>
</SelectItem>
</TooltipTrigger>
<TooltipContent :to="teleportTarget" side="right" :side-offset="5">
<p class="max-w-xs">{{ option.tooltip }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<SelectItem v-else :value="option.value">
<SelectItemText>{{ option.label }}</SelectItemText>
</SelectItem>
</template>
</SelectContent>
</Select>
</template>

View File

@@ -11,47 +11,56 @@ import {
type JsonSchema,
type Layout,
type UISchemaElement,
// Actions, // No longer needed
Actions, type CoreActions, type JsonFormsSubStates
} from '@jsonforms/core';
import { DispatchRenderer, useJsonFormsLayout, type RendererProps } from '@jsonforms/vue';
import { computed, ref } from 'vue'; // Import ref, remove inject/onMounted
import { computed, inject } from 'vue';
// Define props based on RendererProps<Layout>
const props = defineProps<RendererProps<Layout>>();
// --- JSON Forms Composables and Context ---
// --- JSON Forms Context Injection ---
const jsonforms = inject<JsonFormsSubStates>('jsonforms');
const dispatch = inject<(action: CoreActions) => void>('dispatch'); // Inject dispatch separately
if (!jsonforms || !dispatch) {
throw new Error("'jsonforms' or 'dispatch' context wasn't provided. Are you within JsonForms?");
}
const { core } = jsonforms; // Extract core state
// --- Layout Specific Composables ---
const { layout } = useJsonFormsLayout(props);
// --- Step Configuration ---
// Expect options.steps: [{ label: string, description: string }, ...]
// --- Step Configuration --- Use props.uischema
const stepsConfig = computed(() => props.uischema.options?.steps || []);
const numSteps = computed(() => stepsConfig.value.length);
// --- Current Step Logic ---
// Use local state for the current step index
const localCurrentStep = ref(0);
// currentStep now reflects the local state
const currentStep = computed(() => localCurrentStep.value);
// --- Current Step Logic --- Use injected core.data
const currentStep = computed(() => {
console.log('[SteppedLayout] currentStep computed. core.data.configStep:', core?.data?.configStep);
return core!.data?.configStep ?? 0;
});
const isLastStep = computed(() => currentStep.value === numSteps.value - 1);
// --- Step Update Logic ---
const updateStep = (newStep: number) => {
// Validate step index bounds
if (newStep < 0 || newStep >= stepsConfig.value.length) {
if (newStep < 0 || newStep >= numSteps.value) {
return;
}
// Simply update the local state
localCurrentStep.value = newStep;
// Update the 'configStep' property in the JSON Forms data
dispatch(Actions.update('configStep', () => newStep));
};
// --- Filtered Elements for Current Step ---
const currentStepElements = computed(() => {
return (props.uischema.elements || []).filter((element: UISchemaElement) => {
const filtered = (props.uischema.elements || []).filter((element: UISchemaElement) => {
return element.options?.step === currentStep.value;
});
console.log(`[SteppedLayout] currentStepElements computed for step ${currentStep.value}. Found elements:`, JSON.stringify(filtered.map(el => ({ type: el.type, scope: (el as any).scope })))); // Log type/scope
return filtered;
});
// --- Stepper State Logic ---
@@ -65,7 +74,7 @@ const getStepState = (stepIndex: number): StepState => {
</script>
<template>
<div v-if="layout.visible" class="stepped-layout space-y-6">
<div v-if="layout.visible" :key="currentStep" class="stepped-layout space-y-6">
<!-- Stepper Indicators -->
<Stepper :modelValue="currentStep + 1" class="text-foreground flex w-full items-start gap-2 text-sm">
<StepperItem
@@ -109,6 +118,10 @@ const getStepState = (stepIndex: number): StepState => {
</StepperItem>
</Stepper>
<!-- Add logging here -->
{{ console.log(`[SteppedLayout Template] Rendering step: ${currentStep}`) }}
{{ console.log(`[SteppedLayout Template] Elements for step ${currentStep}:`, JSON.stringify(currentStepElements.map(el => ({ type: el.type, scope: (el as any).scope })))) }}
<!-- Render elements for the current step -->
<div class="current-step-content rounded-md border p-4 shadow">
<DispatchRenderer
@@ -128,7 +141,7 @@ const getStepState = (stepIndex: number): StepState => {
<Button variant="outline" @click="updateStep(currentStep - 1)" :disabled="currentStep === 0">
Previous
</Button>
<Button @click="updateStep(currentStep + 1)" :disabled="currentStep >= stepsConfig.length - 1">
<Button v-if="!isLastStep" @click="updateStep(currentStep + 1)">
Next
</Button>
</div>

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import { Button } from '@/components/common/button';
import { Input } from '@/components/form/input';
import ControlLayout from '@/forms/ControlLayout.vue';
import type { ControlElement } from '@jsonforms/core';
import { useJsonFormsControl } from '@jsonforms/vue';
import type { RendererProps } from '@jsonforms/vue';
@@ -41,7 +40,6 @@ const placeholder = computed(() => control.value.uischema?.options?.placeholder
</script>
<template>
<ControlLayout v-if="control.visible" :label="control.label" :errors="control.errors">
<div class="space-y-4">
<p v-if="control.description" v-html="control.description" />
<div v-for="(item, index) in items" :key="index" class="flex gap-2">
@@ -72,5 +70,4 @@ const placeholder = computed(() => control.value.uischema?.options?.placeholder
Add Item
</Button>
</div>
</ControlLayout>
</template>

View File

@@ -1,6 +1,5 @@
<script setup lang="ts">
import { Switch as UuiSwitch } from '@/components/form/switch';
import ControlLayout from '@/forms/ControlLayout.vue';
import type { ControlElement } from '@jsonforms/core';
import { useJsonFormsControl } from '@jsonforms/vue';
import type { RendererProps } from '@jsonforms/vue';
@@ -15,15 +14,13 @@ const description = computed(() => props.uischema.options?.description);
</script>
<template>
<ControlLayout v-if="control.visible" :label="control.label" :errors="control.errors">
<p v-if="description" v-html="description" class="mb-2" />
<UuiSwitch
:id="control.id + '-input'"
:name="control.path"
:disabled="!control.enabled"
:required="control.required"
:modelValue="Boolean(control.data)"
@update:modelValue="onChange"
/>
</ControlLayout>
<p v-if="description" v-html="description" class="mb-2" />
<UuiSwitch
:id="control.id + '-input'"
:name="control.path"
:disabled="!control.enabled"
:required="control.required"
:modelValue="Boolean(control.data)"
@update:modelValue="onChange"
/>
</template>

View File

@@ -0,0 +1,48 @@
<script lang="ts" setup>
/**
* UnraidSettingsLayout component
*
* Renders form elements defined in a UI schema within a two-column grid layout.
* Typically used for settings pages where each row has a label on the left
* and the corresponding form control on the right.
* Consumes JSON Schema and UI Schema to determine what elements to render.
*
* @prop schema - The JSON Schema
* @prop uischema - The UI Schema containing the layout elements
* @prop path - The current path
* @prop enabled - Whether the form is enabled
* @prop renderers - Available renderers
* @prop cells - Available cells
*/
import type { HorizontalLayout } from '@jsonforms/core';
import { DispatchRenderer, type RendererProps } from '@jsonforms/vue';
import { computed } from 'vue';
import { useJsonFormsVisibility } from './composables/useJsonFormsVisibility';
const props = defineProps<RendererProps<HorizontalLayout>>();
// Use the new composable
const { layout, isVisible } = useJsonFormsVisibility({ rendererProps: props });
const elements = computed(() => {
// Access elements from the layout object returned by the composable
return layout.layout.value.uischema.elements || [];
});
</script>
<template>
<div v-if="isVisible" class="grid grid-cols-settings items-baseline">
<template v-for="(element, index) in elements" :key="index">
<DispatchRenderer
:schema="layout.layout.value.schema"
:uischema="element"
:path="layout.layout.value.path"
:enabled="layout.layout.value.enabled"
:renderers="layout.layout.value.renderers"
:cells="layout.layout.value.cells"
/>
</template>
</div>
</template>

View File

@@ -17,25 +17,28 @@
import type { VerticalLayout } from '@jsonforms/core';
import { DispatchRenderer, type RendererProps } from '@jsonforms/vue';
import { computed } from 'vue';
import { useJsonFormsVisibility } from './composables/useJsonFormsVisibility';
const props = defineProps<RendererProps<VerticalLayout>>();
const { layout, isVisible } = useJsonFormsVisibility({ rendererProps: props });
const elements = computed(() => {
return props.uischema?.elements || [];
return layout.layout.value.uischema.elements || [];
});
</script>
<template>
<div class="flex flex-col gap-y-2">
<div v-if="isVisible" class="flex flex-col gap-y-2">
<template v-for="(element, index) in elements" :key="index">
<DispatchRenderer
class="ml-10"
:schema="props.schema"
:schema="layout.layout.value.schema"
:uischema="element"
:path="props.path"
:enabled="props.enabled"
:renderers="props.renderers"
:cells="props.cells"
:path="layout.layout.value.path"
:enabled="layout.layout.value.enabled"
:renderers="layout.layout.value.renderers"
:cells="layout.layout.value.cells"
/>
</template>
</div>

View File

@@ -0,0 +1,27 @@
import type { Layout } from '@jsonforms/core';
import { useJsonFormsLayout, type RendererProps } from '@jsonforms/vue';
import { computed, type ComputedRef } from 'vue';
interface UseJsonFormsVisibilityProps<T extends Layout> {
rendererProps: RendererProps<T>;
}
interface UseJsonFormsVisibilityReturn {
layout: ReturnType<typeof useJsonFormsLayout>;
isVisible: ComputedRef<boolean>;
}
export function useJsonFormsVisibility<T extends Layout>(props: UseJsonFormsVisibilityProps<T>): UseJsonFormsVisibilityReturn {
const layout = useJsonFormsLayout(props.rendererProps);
const isVisible = computed(() => {
// The composable handles rule evaluation and provides the visibility status
// console.log('[useJsonFormsVisibility] isVisible computed. layout.layout.value.visible:', layout.layout.value.visible);
return !!layout.layout.value.visible;
});
return {
layout,
isVisible,
};
}

View File

@@ -1,5 +1,8 @@
import comboBoxRenderer from '@/forms/ComboBoxField.vue';
import ControlWrapper from '@/forms/ControlWrapper.vue';
import HorizontalLayout from '@/forms/HorizontalLayout.vue';
import inputFieldRenderer from '@/forms/InputField.vue';
import LabelRenderer from '@/forms/LabelRenderer.vue';
import MissingRenderer from '@/forms/MissingRenderer.vue';
import numberFieldRenderer from '@/forms/NumberField.vue';
import PreconditionsLabel from '@/forms/PreconditionsLabel.vue';
@@ -7,7 +10,6 @@ import selectRenderer from '@/forms/Select.vue';
import SteppedLayout from '@/forms/SteppedLayout.vue';
import StringArrayField from '@/forms/StringArrayField.vue';
import switchRenderer from '@/forms/Switch.vue';
import LabelRenderer from '@/forms/LabelRenderer.vue';
import VerticalLayout from '@/forms/VerticalLayout.vue';
import {
and,
@@ -24,7 +26,19 @@ import {
schemaMatches,
uiTypeIs,
} from '@jsonforms/core';
import type { JsonFormsRendererRegistryEntry, JsonSchema } from '@jsonforms/core';
import type { ControlElement, JsonFormsRendererRegistryEntry, JsonSchema } from '@jsonforms/core';
import type { RendererProps } from '@jsonforms/vue';
import { h, markRaw } from 'vue';
// Helper function to wrap control renderers with error display
// Returns a functional component
const withErrorWrapper = (RendererComponent: any) => {
return (props: RendererProps<ControlElement>) => {
return h(ControlWrapper, props, {
default: () => h(RendererComponent, props),
});
};
};
const isStringArray = (schema: JsonSchema): boolean => {
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return false;
@@ -32,71 +46,57 @@ const isStringArray = (schema: JsonSchema): boolean => {
return schema.type === 'array' && items?.type === 'string';
};
const formSwitchEntry: JsonFormsRendererRegistryEntry = {
renderer: switchRenderer,
tester: rankWith(4, and(isBooleanControl, optionIs('toggle', true))),
};
const formSelectEntry: JsonFormsRendererRegistryEntry = {
renderer: selectRenderer,
tester: rankWith(4, and(isEnumControl)),
};
const formComboBoxEntry: JsonFormsRendererRegistryEntry = {
renderer: comboBoxRenderer,
tester: rankWith(4, and(isControl, optionIs('format', 'combobox'))),
};
const numberFieldEntry: JsonFormsRendererRegistryEntry = {
renderer: numberFieldRenderer,
tester: rankWith(4, or(isNumberControl, isIntegerControl)),
};
const inputFieldEntry: JsonFormsRendererRegistryEntry = {
renderer: inputFieldRenderer,
tester: rankWith(3, isStringControl),
};
const stringArrayEntry: JsonFormsRendererRegistryEntry = {
renderer: StringArrayField,
tester: rankWith(4, and(isControl, schemaMatches(isStringArray))),
};
const preconditionsLabelEntry: JsonFormsRendererRegistryEntry = {
renderer: PreconditionsLabel,
tester: rankWith(3, and(uiTypeIs('Label'), optionIs('format', 'preconditions'))),
};
const missingRendererEntry: JsonFormsRendererRegistryEntry = {
renderer: MissingRenderer,
tester: rankWith(3, isControl),
};
const verticalLayoutEntry: JsonFormsRendererRegistryEntry = {
renderer: VerticalLayout,
tester: rankWith(2, and(isLayout, uiTypeIs('VerticalLayout'))),
};
const steppedLayoutEntry: JsonFormsRendererRegistryEntry = {
renderer: SteppedLayout,
tester: rankWith(3, and(isLayout, uiTypeIs('SteppedLayout'))),
};
const labelRendererEntry: JsonFormsRendererRegistryEntry = {
renderer: LabelRenderer,
tester: rankWith(3, and(uiTypeIs('Label'))),
};
export const jsonFormsRenderers = [
labelRendererEntry,
verticalLayoutEntry,
steppedLayoutEntry,
formSwitchEntry,
formSelectEntry,
formComboBoxEntry,
inputFieldEntry,
numberFieldEntry,
preconditionsLabelEntry,
stringArrayEntry,
missingRendererEntry,
export const jsonFormsRenderers: JsonFormsRendererRegistryEntry[] = [
// Layouts
{
renderer: markRaw(VerticalLayout),
tester: rankWith(2, and(isLayout, uiTypeIs('VerticalLayout'))),
},
{
renderer: markRaw(HorizontalLayout),
tester: rankWith(3, and(isLayout, uiTypeIs('HorizontalLayout'))),
},
{
renderer: markRaw(SteppedLayout),
tester: rankWith(3, and(isLayout, uiTypeIs('SteppedLayout'))),
},
// Controls
{
renderer: markRaw(withErrorWrapper(switchRenderer)),
tester: rankWith(4, and(isBooleanControl, optionIs('toggle', true))),
},
{
renderer: markRaw(withErrorWrapper(selectRenderer)),
tester: rankWith(4, and(isEnumControl)),
},
{
renderer: markRaw(withErrorWrapper(comboBoxRenderer)),
tester: rankWith(4, and(isControl, optionIs('format', 'combobox'))),
},
{
renderer: markRaw(withErrorWrapper(numberFieldRenderer)),
tester: rankWith(4, or(isNumberControl, isIntegerControl)),
},
{
renderer: markRaw(withErrorWrapper(inputFieldRenderer)),
tester: rankWith(3, isStringControl),
},
{
renderer: markRaw(withErrorWrapper(StringArrayField)),
tester: rankWith(4, and(isControl, schemaMatches(isStringArray))),
},
// Labels
{
renderer: markRaw(PreconditionsLabel),
tester: rankWith(3, and(uiTypeIs('Label'), optionIs('format', 'preconditions'))),
},
{
renderer: markRaw(LabelRenderer),
tester: rankWith(3, and(uiTypeIs('Label'))),
},
// Fallback / Meta
{
renderer: markRaw(withErrorWrapper(MissingRenderer)),
tester: rankWith(3, isControl), // Adjusted rank to ensure it's tested after more specific controls
},
];

View File

@@ -2,7 +2,7 @@
import { computed, ref, watch } from 'vue';
import { useMutation, useQuery } from '@vue/apollo-composable';
import { jsonFormsRenderers } from '@unraid/ui';
import { Button, jsonFormsRenderers } from '@unraid/ui';
import { JsonForms } from '@jsonforms/vue';
import type { CreateRCloneRemoteInput } from '~/composables/gql/graphql';
@@ -112,8 +112,28 @@ const renderers = [...jsonFormsRenderers];
// Handle form data changes
const onChange = ({ data }: { data: Record<string, unknown> }) => {
console.log('[RCloneConfig] onChange received data:', JSON.stringify(data));
formState.value = data as typeof formState.value;
};
// --- Submit Button Logic ---
const uiSchema = computed(() => formResult.value?.rcloneBackup?.configForm?.uiSchema);
// Assuming the stepped layout is the first element and its type indicates steps
const numSteps = computed(() => {
// Adjust selector based on actual UI schema structure if needed
if (uiSchema.value?.type === 'SteppedLayout') {
return uiSchema.value?.options?.steps?.length ?? 0;
} else if (uiSchema.value?.elements?.[0]?.type === 'SteppedLayout') {
// Check if it's the first element
return uiSchema.value?.elements[0].options?.steps?.length ?? 0;
}
return 0; // Default or indicate error/no steps
});
const isLastStep = computed(() => {
if (numSteps.value === 0) return false;
return formState.value.configStep === numSteps.value - 1;
});
</script>
<template>
@@ -140,6 +160,14 @@ const onChange = ({ data }: { data: Record<string, unknown> }) => {
@change="onChange"
/>
</div>
<!-- Submit Button (visible only on the last step) -->
<div v-if="!formLoading && uiSchema && isLastStep" class="mt-6 flex justify-end border-t border-gray-200 pt-6">
<Button :loading="isCreating" @click="submitForm">
Submit Configuration
</Button>
</div>
</div>
</div>
</template>