Skip to content

Custom Components ​

FancyCRUD allows you to create and register custom field components, giving you complete control over how your form fields are rendered and behave. This is perfect for specialized inputs, third-party integrations, or unique UI requirements.

Why Custom Components? ​

While FancyCRUD provides built-in field types through wrappers, you may need:

  • Specialized Inputs: Rating stars, color pickers, sliders, WYSIWYG editors
  • Third-Party Integrations: Google Maps, Stripe elements, chart inputs
  • Business-Specific Fields: Custom dropdowns, compound fields, calculators
  • Unique Behaviors: Fields with special validation, formatting, or interactions

Understanding the Architecture ​

FancyCRUD provides a layered architecture for custom components:

  1. Core Composables (@fancy-crud/vue): Provide field logic, validation, and state management
  2. Wrapper Components: Integrate UI frameworks (Vuetify, Element Plus, etc.) with composables
  3. Field Registration: Components are registered by type in the configuration

Creating a Custom Component ​

Required Props ​

Every custom component must accept these props:

ts
interface ComponentProps {
  formId: symbol                 // Unique form identifier
  field: NormalizedField         // Field configuration and state
}

Using FancyCRUD Composables ​

FancyCRUD provides composables that handle the heavy lifting:

useTextField ​

For text-based inputs (text, email, password, etc.):

vue
<template>
  <div class="custom-text-field" v-bind="field.wrapper">
    <label v-if="field.label">{{ field.label }}</label>
    <input
      v-model="modelValue"
      :placeholder="field.placeholder"
      :disabled="field.disabled"
      :class="field.class"
    />
    <span v-if="hasFieldErrors" class="error">
      {{ hintText }}
    </span>
  </div>
</template>

<script setup lang="ts">
import type { NormalizedTextField } from '@fancy-crud/vue'
import { useTextField } from '@fancy-crud/vue'

const props = defineProps<{
  formId: symbol
  field: NormalizedTextField
}>()

const { modelValue, hintText, hasFieldErrors, validate } = useTextField(props)
</script>

useSelectField ​

For select/dropdown inputs:

vue
<template>
  <div class="custom-select-field" v-bind="field.wrapper">
    <label v-if="field.label">{{ field.label }}</label>
    <select
      v-model="modelValue"
      :disabled="field.disabled"
      v-bind="attrs"
    >
      <option
        v-for="option in options"
        :key="option[field.optionValue]"
        :value="option[field.optionValue]"
      >
        {{ option[field.optionLabel] }}
      </option>
    </select>
    <span v-if="hasFieldErrors" class="error">
      {{ hintText }}
    </span>
  </div>
</template>

<script setup lang="ts">
import type { NormalizedSelectField } from '@fancy-crud/vue'
import { useSelectField } from '@fancy-crud/vue'

const props = defineProps<{
  formId: symbol
  field: NormalizedSelectField
}>()

const { modelValue, options, attrs, hintText, hasFieldErrors } = useSelectField(props)
</script>

Composable Returns ​

The composables provide everything you need:

ts
// useTextField returns:
{
  modelValue: Ref<any>        // Reactive model value with debounce
  vmodel: ComputedRef<{       // v-model binding object
    modelValue: any
    'onUpdate:modelValue': (value) => void
  }>
  hintText: ComputedRef<string>      // Error or help text
  hasFieldErrors: ComputedRef<boolean> // Whether field has errors
  validate: (field) => void           // Validation function
}

// useSelectField additionally returns:
{
  options: ComputedRef<any[]>  // Processed options array
  attrs: ComputedRef<object>    // Filtered attributes (no type/options/wrapper)
}

Field Props ​

ts
interface Field {
  // Core properties
  id: string                    // Unique field identifier
  name: string                  // Field name
  modelKey: string              // Key for form data
  modelValue: any               // Current field value
  label?: string                // Field label
  placeholder?: string          // Input placeholder
  
  // State
  errors: string[]              // Validation errors
  wasFocused: boolean          // Whether field was focused
  disabled?: boolean           // Disabled state
  hidden?: boolean             // Hidden state
  
  // Styling
  class?: string               // CSS classes for input
  wrapper?: {                  // Wrapper configuration
    class?: string
  }
  
  // Behavior
  rules?: Function             // Validation rules
  debounceTime?: number        // Input debounce time
  
  // Custom properties
  [key: string]: any           // Any custom props you define
}

Creating Wrapper-Style Components ​

For maximum integration with UI frameworks, create components using the same pattern as FancyCRUD wrappers:

Vuetify Wrapper Example ​

vue
<script lang="ts">
import { VRating } from 'vuetify/components'
import type { NormalizedField } from '@fancy-crud/vue'
import { useTextField } from '@fancy-crud/vue'
import type { PropType } from 'vue'

export default defineComponent({
  props: {
    formId: {
      type: Symbol,
      required: true,
    },
    field: {
      type: Object as PropType<NormalizedField>,
      required: true,
    },
  },
  setup(props, { attrs, slots }) {
    const { hintText, vmodel, hasFieldErrors } = useTextField(props)

    return () =>
      h(VRating, {
        ...attrs,
        ...props.field,
        ...props.field.wrapper,
        ...vmodel.value,
        hint: hintText.value,
        errorMessages: hasFieldErrors.value ? [hintText.value] : [],
        rules: undefined,
      }, {
        ...slots,
      })
  },
})
</script>

Element Plus Wrapper Example ​

vue
<template>
  <div class="rating-field" v-bind="computedAttrs">
    <label v-if="field.label">{{ field.label }}</label>
    <el-rate
      v-model="modelValue"
      v-bind="field"
      :disabled="field.disabled"
      :max="field.max || 5"
    />
    <span v-if="hasFieldErrors" class="error-message">
      {{ hintText }}
    </span>
  </div>
</template>

<script setup lang="ts">
import { ElRate } from 'element-plus'
import type { NormalizedField } from '@fancy-crud/vue'
import { useTextField } from '@fancy-crud/vue'

const props = defineProps<{
  formId: symbol
  field: NormalizedField & { max?: number }
}>()

const attrs = useAttrs()

const { hintText, modelValue, hasFieldErrors } = useTextField(props)

const computedAttrs = computed(() => {
  return {
    ...attrs,
    ...props.field.wrapper,
  }
})
</script>

Registering Custom Components ​

Global Registration ​

Register components globally in your FancyCRUD configuration:

ts
import { defineConfig } from '@fancy-crud/vue'
import axios from 'axios'
import components, { styles } from '@fancy-crud/wrapper-vuetify'

// Import your custom components
import RatingField from './components/RatingField.vue'
import SliderField from './components/SliderField.vue'
import TagsField from './components/TagsField.vue'

export const fancyCrud = defineConfig({
  http: { request: axios },
  
  components: {
    ...components,
    
    // Register custom components
    rating: RatingField,
    slider: SliderField,
    tags: TagsField,
  },
  
  styles,
})

How Component Resolution Works ​

When rendering a form, FancyCRUD:

  1. Looks at the field's type property
  2. Searches for a registered component with that type name
  3. Falls back to the text component if not found
  4. Passes formId and field as props to the component
ts
// In your form
const form = useForm({
  fields: {
    userRating: {
      type: 'rating',  // Looks for registered 'rating' component
      label: 'Rate us',
      max: 5,
    },
  },
})

Using Custom Components ​

Once registered, use them in your forms with the component name as the type:

ts
import { useForm } from '@fancy-crud/vue'

const form = useForm({
  fields: {
    productRating: {
      type: 'rating',      // Your custom component
      label: 'Rate this product',
      max: 5,
    },
    priceRange: {
      type: 'slider',      // Your custom component
      label: 'Price Range',
      min: 0,
      max: 1000,
      step: 10,
    },
    tags: {
      type: 'tags',        // Your custom component
      label: 'Tags',
      placeholder: 'Add tags...',
    },
  },
  settings: {
    url: 'products/',
  },
})

Real-World Examples ​

Slider Component (Vuetify) ​

vue
<script lang="ts">
import { VSlider } from 'vuetify/components'
import type { NormalizedField } from '@fancy-crud/vue'
import { useTextField } from '@fancy-crud/vue'
import type { PropType } from 'vue'

export default defineComponent({
  props: {
    formId: {
      type: Symbol,
      required: true,
    },
    field: {
      type: Object as PropType<NormalizedField & { min?: number; max?: number; step?: number }>,
      required: true,
    },
  },
  setup(props, { attrs, slots }) {
    const { hintText, vmodel } = useTextField(props)

    return () =>
      h(VSlider, {
        ...attrs,
        ...props.field.wrapper,
        ...vmodel.value,
        label: props.field.label,
        min: props.field.min || 0,
        max: props.field.max || 100,
        step: props.field.step || 1,
        hint: hintText.value,
        disabled: props.field.disabled,
        thumbLabel: true,
        rules: undefined,
      }, {
        ...slots,
      })
  },
})
</script>

Usage:

ts
const form = useForm({
  fields: {
    volume: {
      type: 'slider',
      label: 'Volume',
      min: 0,
      max: 100,
      step: 5,
      modelValue: 50,
    },
  },
})

Rich Text Editor (with Quill) ​

vue
<template>
  <div class="rich-text-field" v-bind="field.wrapper">
    <label v-if="field.label">{{ field.label }}</label>
    
    <div ref="editorContainer" />
    
    <span v-if="hasFieldErrors" class="error-message">
      {{ hintText }}
    </span>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import Quill from 'quill'
import 'quill/dist/quill.snow.css'
import type { NormalizedField } from '@fancy-crud/vue'
import { useTextField } from '@fancy-crud/vue'

const props = defineProps<{
  formId: symbol
  field: NormalizedField & { toolbar?: any[][] }
}>()

const { modelValue, hintText, hasFieldErrors } = useTextField(props)

const editorContainer = ref<HTMLDivElement | null>(null)
let quill: Quill | null = null

onMounted(() => {
  if (!editorContainer.value) return
  
  quill = new Quill(editorContainer.value, {
    theme: 'snow',
    modules: {
      toolbar: props.field.toolbar || [
        ['bold', 'italic', 'underline'],
        ['link', 'image'],
        [{ list: 'ordered' }, { list: 'bullet' }],
      ],
    },
  })
  
  // Set initial content
  if (modelValue.value) {
    quill.root.innerHTML = modelValue.value
  }
  
  // Listen to changes
  quill.on('text-change', () => {
    if (quill) {
      modelValue.value = quill.root.innerHTML
    }
  })
})

// Watch for external changes
watch(() => modelValue.value, (newValue) => {
  if (quill && quill.root.innerHTML !== newValue) {
    quill.root.innerHTML = newValue || ''
  }
})
</script>

<style scoped>
.error-message {
  color: #f56565;
  font-size: 0.875rem;
  margin-top: 0.25rem;
  display: block;
}
</style>

Usage:

ts
const form = useForm({
  fields: {
    content: {
      type: 'richText',
      label: 'Article Content',
      toolbar: [
        ['bold', 'italic'],
        ['link'],
      ],
    },
  },
})

Tags Input (Element Plus) ​

vue
<template>
  <div class="tags-field" v-bind="computedAttrs">
    <label v-if="field.label">{{ field.label }}</label>
    
    <el-select
      v-model="modelValue"
      v-bind="field"
      multiple
      filterable
      allow-create
      default-first-option
      :placeholder="field.placeholder"
    />
    
    <span v-if="hasFieldErrors" class="error-message">
      {{ hintText }}
    </span>
  </div>
</template>

<script setup lang="ts">
import { ElSelect } from 'element-plus'
import type { NormalizedField } from '@fancy-crud/vue'
import { useTextField } from '@fancy-crud/vue'

const props = defineProps<{
  formId: symbol
  field: NormalizedField
}>()

const attrs = useAttrs()

const { modelValue, hintText, hasFieldErrors } = useTextField(props)

const computedAttrs = computed(() => ({
  ...attrs,
  ...props.field.wrapper,
}))
</script>

Usage:

ts
const form = useForm({
  fields: {
    tags: {
      type: 'tags',
      label: 'Tags',
      placeholder: 'Add tags...',
      modelValue: [],
    },
  },
})

Advanced Features ​

Automatic Validation ​

The composables (useTextField, useSelectField) automatically handle validation:

  • Call validate(field) on mount
  • Display errors through hasFieldErrors and hintText
  • Integrate with form-level validation rules
ts
const form = useForm({
  fields: {
    rating: {
      type: 'rating',
      label: 'Rating',
      rules: (value) => {
        if (!value) return 'Rating is required'
        if (value < 3) return 'Rating must be at least 3 stars'
        return true
      },
    },
  },
})

Debounce Support ​

The modelValue from composables automatically supports debouncing via the debounceTime field property:

ts
const form = useForm({
  fields: {
    search: {
      type: 'text',
      label: 'Search',
      debounceTime: 500,  // Wait 500ms before updating
    },
  },
})

The composable handles debouncing internally - no extra code needed!

Custom Data Transformation ​

Use parseModelValue to transform data before API submission:

ts
const form = useForm({
  fields: {
    priceRange: {
      type: 'slider',
      label: 'Price Range',
      min: 0,
      max: 1000,
      parseModelValue: (value) => {
        // Transform slider value before sending to API
        return { min: 0, max: value }
      },
    },
  },
})

Best Practices ​

1. Always Use Composables ​

Leverage useTextField or useSelectField for automatic:

  • Validation integration
  • Error handling
  • Debounce support
  • Reactive updates
vue
<script setup lang="ts">
import { useTextField } from '@fancy-crud/vue'

const props = defineProps<{
  formId: symbol
  field: NormalizedField
}>()

// Get everything you need from the composable
const { modelValue, hintText, hasFieldErrors } = useTextField(props)
</script>

2. Support Custom Field Properties ​

Extend the NormalizedField type for custom properties:

ts
const props = defineProps<{
  formId: symbol
  field: NormalizedField & {
    // Your custom properties
    max?: number
    min?: number
    toolbar?: any[][]
  }
}>()

3. Apply Wrapper Classes ​

Always bind field.wrapper to support responsive layouts:

vue
<template>
  <div v-bind="field.wrapper">
    <!-- Your component -->
  </div>
</template>

4. Display Errors Correctly ​

Use hasFieldErrors and hintText from the composable:

vue
<template>
  <span v-if="hasFieldErrors" class="error">
    {{ hintText }}
  </span>
</template>

5. Clean Up Resources ​

vue
<script setup lang="ts">
import { onBeforeUnmount } from 'vue'

let thirdPartyLib: any = null

onMounted(() => {
  thirdPartyLib = initializeLibrary()
})

onBeforeUnmount(() => {
  thirdPartyLib?.destroy()
})
</script>

6. Use Render Functions for Wrappers ​

For tight UI framework integration, use render functions:

vue
<script lang="ts">
import { VSwitch } from 'vuetify/components'
import { useTextField } from '@fancy-crud/vue'

export default defineComponent({
  props: {
    formId: { type: Symbol, required: true },
    field: { type: Object, required: true },
  },
  setup(props, { attrs, slots }) {
    const { vmodel, hintText } = useTextField(props)

    return () =>
      h(VSwitch, {
        ...attrs,
        ...props.field.wrapper,
        ...vmodel.value,
        label: props.field.label,
        hint: hintText.value,
      }, slots)
  },
})
</script>

Next Steps ​