Explorar o código

feat: add new form field type "boolean" and "options" field

Nicolas Meienberger %!s(int64=2) %!d(string=hai) anos
pai
achega
5472b769da

+ 9 - 14
src/client/components/ui/Select/Select.tsx

@@ -14,8 +14,6 @@ const Select: React.FC<React.ComponentPropsWithoutRef<typeof SelectPrimitive.Roo
   return <SelectPrimitive.Root {...props}>{children}</SelectPrimitive.Root>;
 };
 
-const SelectGroup = SelectPrimitive.Group;
-
 const SelectValue = SelectPrimitive.Value;
 
 // Button
@@ -27,7 +25,14 @@ const SelectTrigger = React.forwardRef<React.ElementRef<typeof SelectPrimitive.T
           {label}
         </span>
       )}
-      <SelectPrimitive.Trigger ref={ref} className={clsx('d-flex w-100 align-items-center justify-content-between form-select', { 'is-invalid is-invalid-lite': error })} {...props}>
+      <SelectPrimitive.Trigger
+        id={props.name}
+        aria-label={props.name}
+        name={props.name}
+        ref={ref}
+        className={clsx('d-flex w-100 align-items-center justify-content-between form-select', { 'is-invalid is-invalid-lite': error })}
+        {...props}
+      >
         {children}
       </SelectPrimitive.Trigger>
       {error && <div className="invalid-feedback">{error}</div>}
@@ -51,11 +56,6 @@ const SelectContent = React.forwardRef<React.ElementRef<typeof SelectPrimitive.C
 ));
 SelectContent.displayName = SelectPrimitive.Content.displayName;
 
-const SelectLabel = React.forwardRef<React.ElementRef<typeof SelectPrimitive.Label>, React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>>(({ className, ...props }, ref) => (
-  <SelectPrimitive.Label ref={ref} className={clsx('', className)} {...props} />
-));
-SelectLabel.displayName = SelectPrimitive.Label.displayName;
-
 const SelectItem = React.forwardRef<React.ElementRef<typeof SelectPrimitive.Item>, React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>>(({ className, children, ...props }, ref) => (
   <SelectPrimitive.Item ref={ref} className={clsx('ps-8 position-relative d-flex align-items-center dropdown-item', className)} {...props}>
     <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
@@ -68,9 +68,4 @@ const SelectItem = React.forwardRef<React.ElementRef<typeof SelectPrimitive.Item
 ));
 SelectItem.displayName = SelectPrimitive.Item.displayName;
 
-const SelectSeparator = React.forwardRef<React.ElementRef<typeof SelectPrimitive.Separator>, React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>>(({ className, ...props }, ref) => (
-  <SelectPrimitive.Separator ref={ref} className={clsx('', className)} {...props} />
-));
-SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
-
-export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectLabel, SelectItem, SelectSeparator };
+export { Select, SelectValue, SelectTrigger, SelectContent, SelectItem };

+ 1 - 1
src/client/components/ui/Select/index.ts

@@ -1 +1 @@
-export { Select, SelectItem, SelectContent, SelectTrigger, SelectValue, SelectGroup, SelectLabel, SelectSeparator } from './Select';
+export { Select, SelectItem, SelectContent, SelectTrigger, SelectValue } from './Select';

+ 3 - 3
src/client/components/ui/Switch/Switch.tsx

@@ -7,13 +7,13 @@ import classes from './Switch.module.scss';
 
 type RootProps = typeof SwitchPrimitives.Root;
 
-const Switch = React.forwardRef<React.ElementRef<RootProps>, React.ComponentPropsWithoutRef<RootProps> & { label?: string | React.ReactNode }>(({ className, ...props }, ref) => (
+const Switch = React.forwardRef<React.ElementRef<RootProps>, React.ComponentPropsWithoutRef<RootProps> & { label?: string | React.ReactNode }>(({ className, label, ...props }, ref) => (
   <label htmlFor={props.name} aria-labelledby={props.name} className={clsx('form-check form-switch form-check-sigle', className)}>
-    <SwitchPrimitives.Root name={props.name} className={clsx('form-check-input', classes.root)} {...props} ref={ref}>
+    <SwitchPrimitives.Root aria-label={props.name} className={clsx('form-check-input', classes.root)} {...props} ref={ref}>
       <SwitchPrimitives.Thumb />
     </SwitchPrimitives.Root>
     <span id={props.name} className="form-check-label text-muted">
-      {props.label}
+      {label}
     </span>
   </label>
 ));

+ 36 - 4
src/client/modules/Apps/components/InstallForm/InstallForm.test.tsx

@@ -1,4 +1,5 @@
 import React from 'react';
+import { faker } from '@faker-js/faker';
 import { fireEvent, render, screen, waitFor } from '../../../../../../tests/test-utils';
 import { FormField } from '../../../../core/types';
 import { InstallForm } from './InstallForm';
@@ -46,7 +47,19 @@ describe('Test: InstallForm', () => {
   });
 
   it('should show validation error when required field is empty', async () => {
-    const formFields: FormField[] = [{ env_variable: 'test-env', label: 'test-field', type: 'text', required: true }];
+    const formFields: FormField[] = [
+      { env_variable: 'test-env', label: 'test-field', type: 'text', required: true },
+      {
+        env_variable: 'test-select',
+        label: 'test-select',
+        type: 'text',
+        required: true,
+        options: [
+          { label: '1', value: '1' },
+          { label: '2', value: '2' },
+        ],
+      },
+    ];
 
     const onSubmit = jest.fn();
 
@@ -56,17 +69,36 @@ describe('Test: InstallForm', () => {
 
     await waitFor(() => {
       expect(screen.getByText('test-field is required')).toBeInTheDocument();
+      expect(screen.getByText('test-select is required')).toBeInTheDocument();
     });
   });
 
   it('should pre-fill fields if initialValues are provided', () => {
-    const formFields: FormField[] = [{ env_variable: 'test-env', label: 'test-field', type: 'text', required: true }];
+    const selectValue = faker.random.word();
+    const booleanValue = faker.datatype.boolean();
+
+    const formFields: FormField[] = [
+      { env_variable: 'test-env', label: 'test-field', type: 'text', required: true },
+      {
+        env_variable: 'test-select',
+        label: 'test-select',
+        type: 'text',
+        required: false,
+        options: [
+          { label: '1', value: '1' },
+          { label: 'Should appear', value: selectValue },
+        ],
+      },
+      { env_variable: 'test-boolean', label: 'test-boolean', type: 'boolean', required: true },
+    ];
 
     const onSubmit = jest.fn();
 
-    render(<InstallForm formFields={formFields} onSubmit={onSubmit} initalValues={{ 'test-env': 'test' }} />);
+    render(<InstallForm formFields={formFields} onSubmit={onSubmit} initalValues={{ 'test-env': 'test', 'test-select': selectValue, 'test-boolean': booleanValue }} />);
 
-    expect(screen.getByLabelText('test-field')).toHaveValue('test');
+    expect(screen.getByRole('textbox', { name: 'test-env' })).toHaveValue('test');
+    expect(screen.getByRole('combobox', { name: 'test-select' })).toHaveTextContent('Should appear');
+    expect(screen.getByRole('switch', { name: 'test-boolean' })).toHaveAttribute('aria-checked', booleanValue.toString());
   });
 
   it('should render expose switch when app is exposable', () => {

+ 55 - 12
src/client/modules/Apps/components/InstallForm/InstallForm.tsx

@@ -1,11 +1,14 @@
 import React, { useEffect } from 'react';
 import { Controller, useForm } from 'react-hook-form';
 
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/Select';
+import { Tooltip } from 'react-tooltip';
+import clsx from 'clsx';
 import { Button } from '../../../../components/ui/Button';
 import { Switch } from '../../../../components/ui/Switch';
 import { Input } from '../../../../components/ui/Input';
 import { validateAppConfig } from '../../utils/validators';
-import { FormField } from '../../../../core/types';
+import { type FormField } from '../../../../core/types';
 
 interface IProps {
   formFields: FormField[];
@@ -44,17 +47,57 @@ export const InstallForm: React.FC<IProps> = ({ formFields, onSubmit, initalValu
     }
   }, [initalValues, isDirty, setValue]);
 
-  const renderField = (field: FormField) => (
-    <Input
-      key={field.env_variable}
-      {...register(field.env_variable)}
-      label={field.label}
-      error={errors[field.env_variable]?.message}
-      disabled={loading}
-      className="mb-3"
-      placeholder={field.hint || field.label}
-    />
-  );
+  const renderField = (field: FormField) => {
+    const label = (
+      <>
+        {field.label}
+        {field.required && <span className="ms-1 text-danger">*</span>}
+        {Boolean(field.hint) && (
+          <>
+            <Tooltip anchorSelect={`.${field.env_variable}`}>{field.hint}</Tooltip>
+            <span className={clsx('ms-1 form-help', field.env_variable)}>?</span>
+          </>
+        )}
+      </>
+    );
+
+    if (field.type === 'boolean') {
+      return (
+        <Controller
+          control={control}
+          name={field.env_variable}
+          defaultValue={field.default}
+          render={({ field: { onChange, value, ref, ...props } }) => <Switch className="mb-3" ref={ref} checked={Boolean(value)} onCheckedChange={onChange} {...props} label={label} />}
+        />
+      );
+    }
+
+    if (Array.isArray(field.options)) {
+      return (
+        <Controller
+          control={control}
+          name={field.env_variable}
+          defaultValue={field.default}
+          render={({ field: { onChange, value, ref, ...props } }) => (
+            <Select value={value as string} defaultValue={field.default as string} onValueChange={onChange} {...props}>
+              <SelectTrigger className="mb-3" error={errors[field.env_variable]?.message} label={label}>
+                <SelectValue placeholder="Choose an option..." />
+              </SelectTrigger>
+              <SelectContent>
+                {field.options?.map((option) => (
+                  <SelectItem key={option.value} value={option.value}>
+                    {option.label}
+                  </SelectItem>
+                ))}
+              </SelectContent>
+            </Select>
+          )}
+        />
+      );
+    }
+
+    return <Input key={field.env_variable} {...register(field.env_variable)} label={label} error={errors[field.env_variable]?.message} disabled={loading} className="mb-3" placeholder={field.label} />;
+  };
 
   const renderExposeForm = () => (
     <>

+ 4 - 4
src/client/modules/Apps/components/InstallModal/InstallModal.test.tsx

@@ -22,8 +22,8 @@ describe('InstallModal', () => {
   it('renders the InstallForm with the correct props', () => {
     render(<InstallModal info={app} isOpen onClose={jest.fn()} onSubmit={jest.fn()} />);
 
-    expect(screen.getByLabelText(app.form_fields[0]?.label || '')).toBeInTheDocument();
-    expect(screen.getByLabelText(app.form_fields[1]?.label || '')).toBeInTheDocument();
+    expect(screen.getByRole('textbox', { name: app.form_fields[0]?.env_variable })).toBeInTheDocument();
+    expect(screen.getByRole('textbox', { name: app.form_fields[1]?.env_variable })).toBeInTheDocument();
   });
 
   it('calls onClose when the close button is clicked', () => {
@@ -38,8 +38,8 @@ describe('InstallModal', () => {
     const onSubmit = jest.fn();
     render(<InstallModal info={app} isOpen onClose={jest.fn()} onSubmit={onSubmit} />);
 
-    const hostnameInput = screen.getByLabelText(app.form_fields[0]?.label || '');
-    const passwordInput = screen.getByLabelText(app.form_fields[1]?.label || '');
+    const hostnameInput = screen.getByRole('textbox', { name: app.form_fields[0]?.env_variable });
+    const passwordInput = screen.getByRole('textbox', { name: app.form_fields[1]?.env_variable });
 
     fireEvent.change(hostnameInput, { target: { value: 'test-hostname' } });
     expect(hostnameInput).toHaveValue('test-hostname');

+ 1 - 0
src/server/services/apps/apps.types.ts

@@ -41,6 +41,7 @@ export const FIELD_TYPES = {
   FQDNIP: 'fqdnip',
   URL: 'url',
   RANDOM: 'random',
+  BOOLEAN: 'boolean',
 } as const;
 
 export type FieldType = (typeof FIELD_TYPES)[keyof typeof FIELD_TYPES];