FeedbackForm

Collect details for a rating. For down the form offers reason chips (Inaccurate, Not helpful, Too slow, Unsafe, Other by default) and a comment, and shows a thank-you note once onSubmit resolves; up renders only the note. MessageActions opens it after a thumbs down.

Getting Started
$pnpm add @sinups/ai-kit @mantine/core @mantine/hooks @tabler/icons-react
Examples
Thumbs down and up
Source

The component source as it ships in the package. Useful as a reference, or as a starting point if you want to fork a piece.

message-actions/FeedbackForm/FeedbackForm.tsx
import React, { memo, useState } from 'react';
import { Alert, Button, Chip, Group, Stack, Text, Textarea } from '@mantine/core';
import { IconAlertCircle, IconHeart } from '@tabler/icons-react';
import type { FeedbackDetails, FeedbackReason, MessageFeedbackValue } from '../types';
import { useAsyncAction } from '../use-async-action';

export interface FeedbackFormLabels {
  title: string;
  commentLabel: string;
  commentPlaceholder: string;
  submit: string;
  skip: string;
  thanks: string;
  error: string;
}

export const DEFAULT_FEEDBACK_REASONS: FeedbackReason[] = [
  { value: 'inaccurate', label: 'Inaccurate' },
  { value: 'not-helpful', label: 'Not helpful' },
  { value: 'too-slow', label: 'Too slow' },
  { value: 'unsafe', label: 'Unsafe' },
  { value: 'other', label: 'Other' },
];

export const DEFAULT_FEEDBACK_FORM_LABELS: FeedbackFormLabels = {
  title: 'What went wrong?',
  commentLabel: 'Details',
  commentPlaceholder: 'Tell us more (optional)',
  submit: 'Send feedback',
  skip: 'Skip',
  thanks: 'Thanks for your feedback',
  error: 'Could not send feedback',
};

export interface FeedbackFormProps {
  /** Rating the form belongs to: `up` renders only the thank-you note, `down` renders reasons and a comment */
  value: MessageFeedbackValue;
  /** Sends the negative feedback details; the form shows the thank-you note once the promise resolves */
  onSubmit?: (details: FeedbackDetails) => void | Promise<void>;
  /** Adds a skip button that sends the rating without details */
  onSkip?: () => void | Promise<void>;
  /** Reasons rendered as chips, `Inaccurate`, `Not helpful`, `Too slow`, `Unsafe`, `Other` by default */
  reasons?: FeedbackReason[];
  /** Renders the thank-you note, for feedback that was already sent */
  submitted?: boolean;
  /** Overrides of the default English labels */
  labels?: Partial<FeedbackFormLabels>;
  /** Class name added to the root element */
  className?: string;
  /** Inline styles added to the root element */
  style?: React.CSSProperties;
}

/** Negative feedback form with reason chips and a comment, or a short thank-you note for positive ratings */
export const FeedbackForm = memo(function FeedbackForm({
  value,
  onSubmit,
  onSkip,
  reasons = DEFAULT_FEEDBACK_REASONS,
  submitted = false,
  labels: labelsProp,
  className,
  style,
}: FeedbackFormProps) {
  const labels = { ...DEFAULT_FEEDBACK_FORM_LABELS, ...labelsProp };
  const [selected, setSelected] = useState<string[]>([]);
  const [comment, setComment] = useState('');
  const [sent, setSent] = useState(false);
  const { pendingKey, error, run } = useAsyncAction(labels.error);

  if (value === 'up' || submitted || sent) {
    return (
      <Group gap="xs" wrap="nowrap" className={className} style={style} role="status">
        <IconHeart size={16} aria-hidden />
        <Text size="sm">{labels.thanks}</Text>
      </Group>
    );
  }

  const trimmed = comment.trim();
  const canSubmit = selected.length > 0 || trimmed.length > 0;

  const submit = async () => {
    if (!canSubmit) {
      return;
    }
    const ok = await run('submit', () => onSubmit?.({ reasons: selected, comment: trimmed }));
    if (ok) {
      setSent(true);
    }
  };

  const skip = async () => {
    const ok = await run('skip', () => onSkip?.());
    if (ok) {
      setSent(true);
    }
  };

  return (
    <Stack gap="sm" className={className} style={style}>
      <Text size="sm" fw={500}>
        {labels.title}
      </Text>
      <Chip.Group multiple value={selected} onChange={setSelected}>
        <Group gap={6}>
          {reasons.map((reason) => (
            <Chip key={reason.value} value={reason.value} size="xs" variant="outline">
              {reason.label}
            </Chip>
          ))}
        </Group>
      </Chip.Group>
      <Textarea
        aria-label={labels.commentLabel}
        placeholder={labels.commentPlaceholder}
        autosize
        minRows={2}
        maxRows={6}
        value={comment}
        onChange={(event) => setComment(event.currentTarget.value)}
        onKeyDown={(event) => {
          if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
            event.preventDefault();
            submit();
          }
        }}
      />
      {error && (
        <Alert color="red" variant="light" p="xs" icon={<IconAlertCircle size={16} />}>
          {error}
        </Alert>
      )}
      <Group gap="xs" justify="flex-end" wrap="nowrap">
        {onSkip && (
          <Button
            size="xs"
            variant="subtle"
            color="gray"
            onClick={skip}
            loading={pendingKey === 'skip'}
            disabled={pendingKey === 'submit'}
          >
            {labels.skip}
          </Button>
        )}
        <Button
          size="xs"
          onClick={submit}
          disabled={!canSubmit || pendingKey === 'skip'}
          loading={pendingKey === 'submit'}
        >
          {labels.submit}
        </Button>
      </Group>
    </Stack>
  );
});

FeedbackForm.displayName = 'FeedbackForm';
API reference
Prop
Type
Required
value
MessageFeedbackValue
Yes
Rating the form belongs to: `up` renders only the thank-you note, `down` renders reasons and a comment
onSubmit
(details: FeedbackDetails) => void | Promise<void>
No
Sends the negative feedback details; the form shows the thank-you note once the promise resolves
onSkip
() => void | Promise<void>
No
Adds a skip button that sends the rating without details
reasons
FeedbackReason[]
No
Reasons rendered as chips, `Inaccurate`, `Not helpful`, `Too slow`, `Unsafe`, `Other` by default
submitted
boolean
No
Renders the thank-you note, for feedback that was already sent
labels
Partial<FeedbackFormLabels>
No
Overrides of the default English labels
className
string
No
Class name added to the root element
style
React.CSSProperties
No
Inline styles added to the root element