Back to catalog

react-plugin-implementation

Fast and concise JavaScript, TypeScript utilities for React.

  • Category: discovered
  • Tags: github, community
  • Version: 1.0.0
  • Author: walid
  • Downloads: 0
  • GitHub stars: 1
  • Updated: 2025-11-21

Download the latest version (v1.0.0) from this page.

SKILL.md

# React Plugin Implementation Skill

## 역할
당신은 React 기반 @canard/schema-form 플러그인 구현 패턴 전문가입니다.

## 핵심 책임
1. **컴포넌트 구현 패턴**: 비제어 컴포넌트 우선 패턴, 최적화 훅 사용법 제시
2. **test 조건 작성**: FormTypeTestObject 및 FormTypeTestFn 작성 가이드
3. **성능 최적화**: useMemo, useHandle, useCallback 활용 전략
4. **플러그인 구조**: SchemaFormPlugin 타입 준수 구조 설계
5. **formTypeInputDefinitions 우선순위**: 컴포넌트 배열 순서 최적화

## 작동 방식

### 1. 구현 패턴 참조
**knowledge/component-patterns.md**를 통해 다음 패턴들을 제공합니다:
- 비제어 컴포넌트 패턴 (`defaultValue` + `onChange`)
- 제어 컴포넌트 패턴 (실시간 validation 필요 시)
- Array/Object 구조 컴포넌트 특수 처리
- 값 전달 우선순위 (직접 props > context > jsonSchema)

### 2. 최적화 전략
**knowledge/optimization-strategies.md**를 활용하여:
- `useHandle` 훅으로 이벤트 핸들러 최적화
- `useMemo`로 props 연산 캐싱
- 불필요한 리렌더링 방지 전략
- Context 구독 최적화

### 3. Test 조건 작성법
**knowledge/test-mapping-conditions.md**로:
- 단순 조건: 객체 형태 사용
- 복합 조건: 함수 형태 사용
- 우선순위 순서 결정 (구체적 조건 → 일반적 조건)

## 제공하는 패턴

### 기본 컴포넌트 구조

```typescript
import { useMemo } from 'react';
import { useHandle } from '@winglet/react-utils';
import type { FormTypeInputPropsWithSchema } from '@canard/schema-form';

interface FormTypeInput[Name]Props
  extends FormTypeInputPropsWithSchema<ValueType, SchemaType, ContextType>,
          ContextType {
  // 컴포넌트별 추가 props
}

const FormTypeInput[Name] = ({
  // Schema/Node 정보
  jsonSchema,
  node,
  path,
  name,
  
  // 상태
  required,
  disabled,
  readOnly,
  errors,
  
  // 값
  defaultValue,
  onChange,
  
  // Context
  context,
  
  // 직접 props (Context에서도 올 수 있음)
  size: sizeProp,
  variant: variantProp,
  
  // 추가 props
  placeholder,
}: FormTypeInput[Name]Props) => {
  // 1. useMemo로 props 우선순위 적용
  const [size, variant, label] = useMemo(() => {
    return [
      sizeProp || context.size || 'medium',
      variantProp || context.variant || 'standard',
      jsonSchema.label || jsonSchema.title || name,
    ];
  }, [sizeProp, context, jsonSchema, variantProp, name]);
  
  // 2. useHandle로 이벤트 핸들러 최적화
  const handleChange = useHandle((event: ChangeEvent<HTMLInputElement>) => {
    onChange(event.target.value);
  });
  
  // 3. 비제어 컴포넌트 패턴 사용
  return (
    <[UILibraryComponent]
      id={path}
      name={name}
      required={required}
      disabled={disabled || readOnly}
      defaultValue={defaultValue}  // ✅ 비제어
      onChange={handleChange}
      size={size}
      variant={variant}
      placeholder={placeholder || jsonSchema.placeholder}
      error={errors.length > 0}
    />
  );
};

export const FormTypeInput[Name]Definition = {
  Component: FormTypeInput[Name],
  test: { type: 'string' },  // 또는 함수 형태
} satisfies FormTypeInputDefinition;
```

### 플러그인 구조

```typescript
// src/index.ts
import type { SchemaFormPlugin } from '@canard/schema-form';

import { FormError } from './components/FormError';
import { FormGroup } from './components/FormGroup';
import { FormInput } from './components/FormInput';
import { FormLabel } from './components/FormLabel';
import { formatError } from './components/formatError';
import { formTypeInputDefinitions } from './formTypeInputs';

export const plugin = {
  FormGroup,
  FormLabel,
  FormInput,
  FormError,
  formatError,
  formTypeInputDefinitions,
} satisfies SchemaFormPlugin;

export type * from './type';
export * from './formTypeInputs';
```

### formTypeInputDefinitions 우선순위

```typescript
// src/formTypeInputs/index.ts
import type { FormTypeInputDefinition } from '@canard/schema-form';

// ⚠️ 순서가 매우 중요! 구체적 조건이 앞에 와야 함

export const formTypeInputDefinitions: FormTypeInputDefinition[] = [
  // === Phase 1: 가장 구체적 (format + formType 등) ===
  FormTypeInputPasswordDefinition,     // type: string, format: password
  FormTypeInputTextareaDefinition,     // type: string, format: textarea
  FormTypeInputSliderDefinition,       // type: number, formType: slider
  FormTypeInputRadioGroupDefinition,   // type: string, formType: radio
  
  // === Phase 2: 중간 (format 또는 formType) ===
  FormTypeInputDateDefinition,         // type: string, format: date
  FormTypeInputTimeDefinition,         // type: string, format: time
  
  // === Phase 3: Enum 조건 (함수 형태) ===
  FormTypeInputStringEnumDefinition,   // type: string, enum exists
  FormTypeInputNumberEnumDefinition,   // type: number, enum exists
  
  // === Phase 4: 구조 타입 ===
  FormTypeInputArrayDefinition,        // type: array
  FormTypeInputObjectDefinition,       // type: object
  
  // === Phase 5: 일반 타입 (마지막) ===
  FormTypeInputNumberDefinition,       // type: number | integer
  FormTypeInputBooleanDefinition,      // type: boolean
  FormTypeInputStringDefinition,       // type: string (가장 일반적 - 마지막!)
];
```

**우선순위 원칙**:
1. 특수 조건 (format + formType) 먼저
2. 중간 조건 (format 또는 formType)
3. Enum 조건 (복합 함수)
4. 구조 타입 (array, object)
5. 일반 타입 (type만) 마지막

## 특수 패턴

### Array 컴포넌트 (ChildNodeComponents 사용)

```typescript
const FormTypeInputArray = ({
  jsonSchema,
  path,
  ChildNodeComponents,  // ✅ canard-form이 제공
  context,
}: FormTypeInputArrayProps) => {
  return (
    <Box>
      {ChildNodeComponents.map((ChildComponent, index) => (
        <Box key={index} sx={{ mb: 2 }}>
          <ChildComponent />  {/* ✅ 자동으로 props 전달됨 */}
        </Box>
      ))}
      {/* 추가/제거 버튼만 UI 스타일링 */}
      <Button onClick={/* add item */}>Add</Button>
    </Box>
  );
};
```

### 래퍼 컴포넌트 패턴 (DatePicker 등)

```typescript
const FormTypeInputDate = ({
  defaultValue,
  onChange,
  ...props
}: FormTypeInputDateProps) => {
  // 값 변환 로직
  const dateValue = useMemo(() => {
    return defaultValue ? parseISO(defaultValue) : null;
  }, [defaultValue]);
  
  const handleChange = useHandle((newDate: Date | null) => {
    onChange(newDate ? formatISO(newDate) : '');
  });
  
  return (
    <LocalizationProvider dateAdapter={AdapterDateFns}>
      <DatePicker
        value={dateValue}
        onChange={handleChange}
        slotProps={{
          textField: {
            id: props.path,
            name: props.name,
            required: props.required,
            disabled: props.disabled,
          },
        }}
      />
    </LocalizationProvider>
  );
};
```

## 성능 최적화 체크리스트

- [ ] 비제어 컴포넌트 패턴 적용 (`defaultValue` 사용)
- [ ] `useMemo`로 props 연산 최적화
- [ ] `useHandle`로 이벤트 핸들러 메모이제이션
- [ ] Context 구독 최소화
- [ ] 조건부 렌더링 최적화
- [ ] ChildNodeComponents는 직접 렌더링 (props 전파 금지)

## 제약 조건

- 제어 컴포넌트는 **반드시 필요한 경우에만** 사용 (실시간 validation, 조건부 렌더링 등)
- `useHandle` 훅은 `@winglet/react-utils`에서 import
- `satisfies FormTypeInputDefinition`으로 타입 검증 필수
- formTypeInputDefinitions 배열 순서 변경 시 반드시 우선순위 검토

## 출력 형식

### 구현 코드 제공 시

```markdown
## FormTypeInput[Name] 구현

**매핑 조건**: [조건 설명]

**구현 코드**:
[TypeScript 코드]

**최적화 포인트**:
- [최적화 1]
- [최적화 2]

**주의사항**:
- [주의사항 1]
```

## 사용 시나리오

### 시나리오 1: FormTypeInputString 기본 구현

**상황**: MUI TextField를 사용한 가장 기본적인 문자열 입력 컴포넌트 구현

**단계**:

#### Step 1: 타입 정의 및 기본 구조

```typescript
// src/formTypeInputs/FormTypeInputString.tsx
import { useMemo } from 'react';
import { useHandle } from '@winglet/react-utils';
import type { FormTypeInputPropsWithSchema } from '@canard/schema-form';
import { TextField } from '@mui/material';
import type { TextFieldProps } from '@mui/material';

// 1️⃣ Context 타입 정의 (plugin의 공통 context)
interface MuiFormContext {
  size?: 'small' | 'medium' | 'large';
  variant?: 'standard' | 'outlined' | 'filled';
}

// 2️⃣ Component Props = FormTypeInputPropsWithSchema + Context + 추가 props
interface FormTypeInputStringProps
  extends FormTypeInputPropsWithSchema<string, never, MuiFormContext>,
          MuiFormContext {
  placeholder?: string;
  multiline?: boolean;
  rows?: number;
}

// 3️⃣ 컴포넌트 구현
const FormTypeInputString = ({
  // Schema/Node 정보
  jsonSchema,
  node,
  path,
  name,

  // 상태
  required,
  disabled,
  readOnly,
  errors,

  // 값 (비제어 컴포넌트)
  defaultValue,
  onChange,

  // Context
  context,

  // 직접 props (우선순위 높음)
  size: sizeProp,
  variant: variantProp,

  // 추가 props
  placeholder,
  multiline,
  rows,
}: FormTypeInputStringProps) => {
  // 4️⃣ useMemo로 props 우선순위 적용 (직접 props > context > 기본값)
  const [size, variant, label] = useMemo(() => {
    return [
      sizeProp ?? context.size ?? 'medium',
      variantProp ?? context.variant ?? 'outlined',
      jsonSchema.label ?? jsonSchema.title ?? name,
    ];
  }, [sizeProp, context.size, variantProp, context.variant, jsonSchema.label, jsonSchema.title, name]);

  // 5️⃣ 에러 처리
  const hasError = errors.length > 0;
  const errorMessage = hasError ? errors[0].message : undefined;

  // 6️⃣ useHandle로 이벤트 핸들러 최적화
  const handleChange = useHandle((event: React.ChangeEvent<HTMLInputElement>) => {
    onChange(event.target.value);
  });

  // 7️⃣ 비제어 컴포넌트 렌더링
  return (
    <TextField
      id={path}
      name={name}
      label={label}
      required={required}
      disabled={disabled || readOnly}
      defaultValue={defaultValue ?? ''}  // ✅ 비제어 컴포넌트
      onChange={handleChange}
      size={size}
      variant={variant}
      placeholder={placeholder ?? jsonSchema.placeholder}
      error={hasError}
      helperText={errorMessage ?? jsonSchema.description}
      multiline={multiline}
      rows={rows}
      fullWidth
    />
  );
};

// 8️⃣ Definition 객체 (test 조건 포함)
export const FormTypeInputStringDefinition = {
  Component: FormTypeInputString,
  test: { type: 'string' },  // type이 'string'일 때 매칭
} satisfies FormTypeInputDefinition;
```

#### Step 2: 최적화 포인트

```typescript
// ✅ GOOD: useMemo로 props 연산 캐싱
const [size, variant, label] = useMemo(() => {
  return [
    sizeProp ?? context.size ?? 'medium',
    variantProp ?? context.variant ?? 'outlined',
    jsonSchema.label ?? jsonSchema.title ?? name,
  ];
}, [sizeProp, context.size, variantProp, context.variant, jsonSchema.label, jsonSchema.title, name]);

// ❌ BAD: 매 렌더링마다 연산
const size = sizeProp ?? context.size ?? 'medium';
const variant = variantProp ?? context.variant ?? 'outlined';

// ✅ GOOD: useHandle로 이벤트 핸들러 메모이제이션
const handleChange = useHandle((event: React.ChangeEvent<HTMLInputElement>) => {
  onChange(event.target.value);
});

// ❌ BAD: 매 렌더링마다 새 함수 생성
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
  onChange(event.target.value);
};
```

#### Step 3: 테스트 작성

```typescript
// src/formTypeInputs/__tests__/FormTypeInputString.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FormTypeInputString } from '../FormTypeInputString';

describe('FormTypeInputString', () => {
  it('renders with defaultValue', () => {
    render(
      <FormTypeInputString
        jsonSchema={{ type: 'string' }}
        path="test"
        name="test"
        defaultValue="initial value"
        onChange={jest.fn()}
        required={false}
        disabled={false}
        readOnly={false}
        errors={[]}
        context={{}}
      />
    );

    expect(screen.getByDisplayValue('initial value')).toBeInTheDocument();
  });

  it('calls onChange when user types', async () => {
    const onChange = jest.fn();
    const user = userEvent.setup();

    render(
      <FormTypeInputString
        jsonSchema={{ type: 'string' }}
        path="test"
        name="test"
        onChange={onChange}
        required={false}
        disabled={false}
        readOnly={false}
        errors={[]}
        context={{}}
      />
    );

    await user.type(screen.getByRole('textbox'), 'hello');

    expect(onChange).toHaveBeenCalled();
  });
});
```

**결과**: 기본 문자열 입력 컴포넌트 완성, 비제어 패턴으로 성능 최적화

---

### 시나리오 2: FormTypeInputDate (값 변환 패턴)

**상황**: MUI DatePicker를 사용한 날짜 입력 컴포넌트, ISO 8601 문자열 ↔ Date 객체 변환 필요

**단계**:

#### Step 1: 날짜 변환 로직 구현

```typescript
// src/formTypeInputs/FormTypeInputDate.tsx
import { useMemo } from 'react';
import { useHandle } from '@winglet/react-utils';
import type { FormTypeInputPropsWithSchema } from '@canard/schema-form';
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
import { parseISO, formatISO } from 'date-fns';
import type { MuiFormContext } from '../type';

interface FormTypeInputDateProps
  extends FormTypeInputPropsWithSchema<string, never, MuiFormContext>,
          MuiFormContext {
  minDate?: string;
  maxDate?: string;
}

const FormTypeInputDate = ({
  jsonSchema,
  path,
  name,
  required,
  disabled,
  readOnly,
  errors,
  defaultValue,  // ⚠️ ISO 8601 문자열 (예: "2024-01-15T00:00:00.000Z")
  onChange,      // ⚠️ ISO 8601 문자열로 전달해야 함
  context,
  size: sizeProp,
  variant: variantProp,
  minDate,
  maxDate,
}: FormTypeInputDateProps) => {
  // 1️⃣ ISO 문자열 → Date 객체 변환
  const dateValue = useMemo(() => {
    if (!defaultValue) return null;
    try {
      return parseISO(defaultValue);
    } catch {
      return null;
    }
  }, [defaultValue]);

  // 2️⃣ minDate, maxDate도 변환
  const [minDateObj, maxDateObj] = useMemo(() => {
    return [
      minDate ? parseISO(minDate) : undefined,
      maxDate ? parseISO(maxDate) : undefined,
    ];
  }, [minDate, maxDate]);

  // 3️⃣ Date 객체 → ISO 문자열 변환 핸들러
  const handleChange = useHandle((newDate: Date | null) => {
    if (newDate === null) {
      onChange('');
      return;
    }

    try {
      const isoString = formatISO(newDate);
      onChange(isoString);
    } catch {
      onChange('');
    }
  });

  const [size, variant, label] = useMemo(() => {
    return [
      sizeProp ?? context.size ?? 'medium',
      variantProp ?? context.variant ?? 'outlined',
      jsonSchema.label ?? jsonSchema.title ?? name,
    ];
  }, [sizeProp, context.size, variantProp, context.variant, jsonSchema.label, jsonSchema.title, name]);

  const hasError = errors.length > 0;
  const errorMessage = hasError ? errors[0].message : undefined;

  // 4️⃣ DatePicker 렌더링
  return (
    <LocalizationProvider dateAdapter={AdapterDateFns}>
      <DatePicker
        label={label}
        value={dateValue}  // ✅ Date 객체
        onChange={handleChange}  // ✅ Date 객체 받아서 ISO 문자열로 변환
        minDate={minDateObj}
        maxDate={maxDateObj}
        disabled={disabled || readOnly}
        slotProps={{
          textField: {
            id: path,
            name: name,
            required: required,
            size: size,
            variant: variant,
            error: hasError,
            helperText: errorMessage ?? jsonSchema.description,
            fullWidth: true,
          },
        }}
      />
    </LocalizationProvider>
  );
};

// 5️⃣ Definition 객체 (format: 'date' 조건)
export const FormTypeInputDateDefinition = {
  Component: FormTypeInputDate,
  test: { type: 'string', format: 'date' },  // type: string + format: date
} satisfies FormTypeInputDefinition;
```

#### Step 2: 에러 처리 및 유효성 검증

```typescript
// date-fns의 isValid 사용
import { parseISO, formatISO, isValid } from 'date-fns';

const handleChange = useHandle((newDate: Date | null) => {
  if (newDate === null) {
    onChange('');
    return;
  }

  // ✅ Date 유효성 검증
  if (!isValid(newDate)) {
    console.warn('Invalid date:', newDate);
    onChange('');
    return;
  }

  try {
    const isoString = formatISO(newDate);
    onChange(isoString);
  } catch (error) {
    console.error('Date conversion error:', error);
    onChange('');
  }
});
```

#### Step 3: 타임존 처리 (선택)

```typescript
// UTC 고정 변환이 필요한 경우
import { parseISO, formatISO } from 'date-fns';
import { zonedTimeToUtc, utcToZonedTime } from 'date-fns-tz';

const handleChange = useHandle((newDate: Date | null) => {
  if (newDate === null) {
    onChange('');
    return;
  }

  // 사용자 로컬 시간 → UTC 변환
  const utcDate = zonedTimeToUtc(newDate, Intl.DateTimeFormat().resolvedOptions().timeZone);
  const isoString = formatISO(utcDate);
  onChange(isoString);
});
```

**결과**: ISO 8601 문자열 ↔ Date 객체 변환이 완벽히 처리된 DatePicker 컴포넌트

**주의사항**:
- `@canard/schema-form`은 항상 ISO 8601 문자열로 값을 주고받음
- Date 객체는 컴포넌트 내부에서만 사용, onChange에는 반드시 문자열 전달
- `date-fns`의 `parseISO`, `formatISO` 사용 권장 (타임존 안전)

---

### 시나리오 3: FormTypeInputStringEnum (함수 형태 test 조건)

**상황**: Enum 값을 가진 문자열을 Select 또는 RadioGroup으로 표시

**단계**:

#### Step 1: 복합 조건 test 함수 작성

```typescript
// src/formTypeInputs/FormTypeInputStringEnum.tsx
import { useMemo } from 'react';
import { useHandle } from '@winglet/react-utils';
import type { FormTypeInputPropsWithSchema, FormTypeTestFn } from '@canard/schema-form';
import { FormControl, FormLabel, RadioGroup, FormControlLabel, Radio, FormHelperText } from '@mui/material';
import type { MuiFormContext } from '../type';

interface FormTypeInputStringEnumProps
  extends FormTypeInputPropsWithSchema<string, never, MuiFormContext>,
          MuiFormContext {}

const FormTypeInputStringEnum = ({
  jsonSchema,
  path,
  name,
  required,
  disabled,
  readOnly,
  errors,
  defaultValue,
  onChange,
  context,
  size: sizeProp,
}: FormTypeInputStringEnumProps) => {
  // 1️⃣ enum 값 추출 (jsonSchema.enum은 배열)
  const enumValues = useMemo(() => {
    return jsonSchema.enum as string[] ?? [];
  }, [jsonSchema.enum]);

  // 2️⃣ enum 라벨 매핑 (enumLabels가 있으면 사용)
  const enumLabels = useMemo(() => {
    if (jsonSchema.enumLabels) {
      return jsonSchema.enumLabels as string[];
    }
    // enumLabels가 없으면 enum 값 그대로 사용
    return enumValues;
  }, [jsonSchema.enumLabels, enumValues]);

  const handleChange = useHandle((event: React.ChangeEvent<HTMLInputElement>) => {
    onChange(event.target.value);
  });

  const [size, label] = useMemo(() => {
    return [
      sizeProp ?? context.size ?? 'medium',
      jsonSchema.label ?? jsonSchema.title ?? name,
    ];
  }, [sizeProp, context.size, jsonSchema.label, jsonSchema.title, name]);

  const hasError = errors.length > 0;
  const errorMessage = hasError ? errors[0].message : undefined;

  // 3️⃣ RadioGroup 렌더링
  return (
    <FormControl error={hasError} required={required} disabled={disabled || readOnly} size={size}>
      <FormLabel id={`${path}-label`}>{label}</FormLabel>
      <RadioGroup
        aria-labelledby={`${path}-label`}
        name={name}
        defaultValue={defaultValue ?? ''}
        onChange={handleChange}
      >
        {enumValues.map((value, index) => (
          <FormControlLabel
            key={value}
            value={value}
            control={<Radio />}
            label={enumLabels[index] ?? value}
          />
        ))}
      </RadioGroup>
      {(hasError || jsonSchema.description) && (
        <FormHelperText>{errorMessage ?? jsonSchema.description}</FormHelperText>
      )}
    </FormControl>
  );
};

// 4️⃣ 함수 형태 test 조건 (복합 조건)
const testStringEnum: FormTypeTestFn = ({ jsonSchema }) => {
  // type이 'string'이고 enum이 있는 경우에만 true
  return (
    jsonSchema.type === 'string' &&
    Array.isArray(jsonSchema.enum) &&
    jsonSchema.enum.length > 0
  );
};

export const FormTypeInputStringEnumDefinition = {
  Component: FormTypeInputStringEnum,
  test: testStringEnum,  // ✅ 함수 형태
} satisfies FormTypeInputDefinition;
```

#### Step 2: Select 버전 (대안)

```typescript
// Select를 사용하는 버전 (많은 옵션일 때 유리)
import { FormControl, InputLabel, Select, MenuItem, FormHelperText } from '@mui/material';

const FormTypeInputStringEnumSelect = ({
  jsonSchema,
  path,
  name,
  required,
  disabled,
  readOnly,
  errors,
  defaultValue,
  onChange,
  context,
  size: sizeProp,
  variant: variantProp,
}: FormTypeInputStringEnumProps) => {
  const enumValues = useMemo(() => {
    return jsonSchema.enum as string[] ?? [];
  }, [jsonSchema.enum]);

  const enumLabels = useMemo(() => {
    if (jsonSchema.enumLabels) {
      return jsonSchema.enumLabels as string[];
    }
    return enumValues;
  }, [jsonSchema.enumLabels, enumValues]);

  const handleChange = useHandle((event: SelectChangeEvent<string>) => {
    onChange(event.target.value);
  });

  const [size, variant, label] = useMemo(() => {
    return [
      sizeProp ?? context.size ?? 'medium',
      variantProp ?? context.variant ?? 'outlined',
      jsonSchema.label ?? jsonSchema.title ?? name,
    ];
  }, [sizeProp, context.size, variantProp, context.variant, jsonSchema.label, jsonSchema.title, name]);