mirror of
https://github.com/unraid/api.git
synced 2026-01-02 14:40:01 -06:00
chore: error wrapper generic
This commit is contained in:
@@ -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)
|
||||
),
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -95,8 +95,8 @@ export class ConnectSettingsService {
|
||||
getState: store.getState,
|
||||
dispatch: store.dispatch,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async isSignedIn(): Promise<boolean> {
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
23
unraid-ui/src/forms/ControlWrapper.vue
Normal file
23
unraid-ui/src/forms/ControlWrapper.vue
Normal 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>
|
||||
18
unraid-ui/src/forms/FormErrors.vue
Normal file
18
unraid-ui/src/forms/FormErrors.vue
Normal 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>
|
||||
48
unraid-ui/src/forms/HorizontalLayout.vue
Normal file
48
unraid-ui/src/forms/HorizontalLayout.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
48
unraid-ui/src/forms/UnraidSettingsLayout.vue
Normal file
48
unraid-ui/src/forms/UnraidSettingsLayout.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
27
unraid-ui/src/forms/composables/useJsonFormsVisibility.ts
Normal file
27
unraid-ui/src/forms/composables/useJsonFormsVisibility.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user