Skip to content

Latest commit

 

History

History
1190 lines (1003 loc) · 43.8 KB

File metadata and controls

1190 lines (1003 loc) · 43.8 KB

import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem';

Forms

A form lets your app ask users to input and submit data. Forms can be defined with a simple form object that takes a list of fields, and return user responses directly as promises.

A form dialog

Using forms

Promise-based forms:

import { showForm } from '@devvit/web/client';

// Show form and get user response directly
const result = await showForm({
  form: {
    fields: [
      {
        type: 'string',
        name: 'name',
        label: 'Name',
      },
    ],
  },
  data: { name: 'Default value' } // Optional initial data
});

// Handle form submission result immediately
if (result) {
  const { name } = result;
  
  // Process the data directly
  console.log(`User entered: ${name}`);
  
  // Chain additional actions
  await fetch('/api/save-name', {
    method: 'POST',
    body: JSON.stringify({ name })
  });
  
  // Or show another form in sequence
  const step2 = await showForm({
    form: {
      fields: [
        {
          type: 'string',
          name: 'food',
          label: 'Favorite food?',
        },
      ],
    }
  });
  
  if (step2) {
    console.log(`Multi-step complete: ${name}, ${step2.food}`);
  }
} else {
  console.log('User cancelled the form');
}

Parameters

showForm(options) → Returns Promise

  • form (Form): The form specification object
  • data (FormValues, optional): Initial form field values
  • Returns: Promise<FormValues | null> - Resolves with form data or null if cancelled

Menu response forms

For forms that open from a menu item, you can use menu responses. This is useful since you do not have access to the @devvit/web/client library from a menu item endpoint.

Configure forms in devvit.json:

{
  "forms": {
    "nameForm": "/internal/form/name-submit",
    "reviewForm": "/internal/form/review-submit"
  }
}

Server endpoint that shows form via menu response:

<Tabs variant="pill" groupId="http-server-framework" defaultValue="hono" values={[ { label: 'Hono', value: 'hono' }, { label: 'Express', value: 'express' }, ]}>

import type { MenuItemRequest, UiResponse } from '@devvit/web/shared';

type NameFormRequest = { name: string };
type ReviewFormRequest = { review: string };

// Menu action that triggers menu response form
app.post('/internal/menu/start-workflow', async (c) => {
  const _input = await c.req.json<MenuItemRequest>();
  // Server processing before showing form
  const userData = await fetchUserData();
  
  return c.json<UiResponse>({
    showForm: {
      name: 'nameForm',
      form: {
        fields: [
          {
            type: 'string',
            name: 'name',
            label: 'Name',
          },
        ],
      },
      data: { name: userData.name } // Pre-populate from server
    }
  });
});

// Form submission handler that can chain to another form
app.post('/internal/form/name-submit', async (c) => {
  const { name } = await c.req.json<NameFormRequest>();
  
  // Server processing
  await saveUserName(name);
  
  // Show next form in workflow
  return c.json<UiResponse>({
    showForm: {
      name: 'reviewForm',
      form: {
        fields: [
          {
            type: 'paragraph',
            name: 'review',
            label: 'How was your experience?',
          },
        ],
      }
    }
  });
});

app.post('/internal/form/review-submit', async (c) => {
  const { review } = await c.req.json<ReviewFormRequest>();
  
  await saveReview(review);
  
  return c.json<UiResponse>({
    showToast: 'Thank you for your feedback!'
  });
});
import type { MenuItemRequest, UiResponse } from '@devvit/web/shared';

type NameFormRequest = { name: string };
type ReviewFormRequest = { review: string };

// Menu action that triggers menu response form
router.post<string, never, UiResponse, MenuItemRequest>("/internal/menu/start-workflow", async (_req, res) => {
  // Server processing before showing form
  const userData = await fetchUserData();
  
  res.json({
    showForm: {
      name: 'nameForm',
      form: {
        fields: [
          {
            type: 'string',
            name: 'name',
            label: 'Name',
          },
        ],
      },
      data: { name: userData.name } // Pre-populate from server
    }
  });
});

// Form submission handler that can chain to another form
router.post<string, never, UiResponse, NameFormRequest>("/internal/form/name-submit", async (req, res) => {
  const { name } = req.body;
  
  // Server processing
  await saveUserName(name);
  
  // Show next form in workflow
  res.json({
    showForm: {
      name: 'reviewForm',
      form: {
        fields: [
          {
            type: 'paragraph',
            name: 'review',
            label: 'How was your experience?',
          },
        ],
      }
    }
  });
});

router.post<string, never, UiResponse, ReviewFormRequest>("/internal/form/review-submit", async (req, res) => {
  const { review } = req.body;
  
  await saveReview(review);
  
  res.json({
    showToast: 'Thank you for your feedback!'
  });
});

Form object

The form object enables you to customize the form container and the list of form fields included.

Usage

const myForm = {
  title: 'My form',
  description: 'This is my form. There are many like it, but this one is mine.',
  fields: [
    {
      type: 'string',
      name: 'food',
      label: 'What is your favorite food?',
    },
    {
      type: 'string',
      name: 'drink',
      label: 'What is your favorite drink?',
    },
  ],
  acceptLabel: 'Submit',
  cancelLabel: 'Cancel',
};

Supported properties

Property Supported types Description
title string undefined An optional title for the form
description string undefined An optional description for the form
fields FormField[] The fields that will be displayed in the form
acceptLabel string undefined An optional label for the submit button
cancelLabel string undefined An optional label for the cancel button

Supported fields types

The following field types are supported: String, Select, Paragraph, Number, Boolean, Image, and Group.

String

A single-line text input.

String input

Usage

const stringField = {
  type: 'string',
  name: 'title',
  label: 'Tournament title',
};

Properties

Property Supported types Description
type string The desired field type.
name string The name of the field. This will be used as the key in the values object when the form is submitted.
label string The label of the field. This will be displayed to the user.
helpText string undefined An optional help text that will be displayed below the field.
required boolean undefined If true the field will be required and the user will not be able to submit the form without filling it in. Defaults to false.
disabled boolean undefined If true the field will be disabled. Defaults to false.
defaultValue ValueType undefined The default value of the field.
scope SettingScopeType undefined This indicates whether the field (setting) is an app level or install level setting. App setting values can be used by any installation. undefined by default.
placeholder string undefined Placeholder text for display before a value is present.
isSecret boolean undefined Makes the form field secret.

Select

A dropdown menu with predefined options.

Select input

Usage

const selectField = {
  type: 'select',
  name: 'interval',
  label: 'Update the leaderboard',
  options: [
    { label: 'Hourly', value: 'hourly' },
    { label: 'Daily', value: 'daily' },
    { label: 'Weekly', value: 'weekly' },
    { label: 'Monthly', value: 'monthly' },
    { label: 'Yearly', value: 'yearly' },
  ],
};

Properties

Property Supported types Description
type string The desired field type.
name string The name of the field. This will be used as the key in the values object when the form is submitted.
label string The label of the field. This will be displayed to the user.
options FieldConfig_Selection_Item[] The list of options available.
helpText string undefined An optional help text that will be displayed below the field.
required boolean undefined If true the field will be required and the user will not be able to submit the form without filling it in. Defaults to false.
disabled boolean undefined If true the field will be disabled. Defaults to false.
defaultValue string[] undefined The default value of the field. Note that the default value is wrapped in an array to support multiple selected values.
scope SettingScopeType undefined This indicates whether the field (setting) is an app level or install level setting. App setting values can be used by any installation. undefined by default.
multiSelect boolean undefined Enables users to select more than 1 item from the set.

Paragraph

A multi-line text input for longer responses.

Paragraph input

Usage

const paragraphField = {
  type: 'paragraph',
  name: 'description',
  label: 'Description',
};

Properties

Property Supported types Description
type string The desired field type.
name string The name of the field. This will be used as the key in the values object when the form is submitted.
label string The label of the field. This will be displayed to the user.
helpText string undefined An optional help text that will be displayed below the field.
required boolean undefined If true the field will be required and the user will not be able to submit the form without filling it in. Defaults to false.
disabled boolean undefined If true the field will be disabled. Defaults to false.
defaultValue ValueType undefined The default value of the field.
scope SettingScopeType undefined This indicates whether the field (setting) is an app level or install level setting. App setting values can be used by any installation. undefined by default.
placeholder string undefined Placeholder text for display before a value is present.
lineHeight number undefined Sets the field height by number of lines.

Number

An input for numerical values.

Number input

Usage

const numberField = {
  type: 'number',
  name: 'tokens',
  label: 'Token balance',
};

Properties

Property Supported types Description
type string The desired field type.
name string The name of the field. This will be used as the key in the values object when the form is submitted.
label string The label of the field. This will be displayed to the user.
helpText string undefined An optional help text that will be displayed below the field.
required boolean undefined If true the field will be required and the user will not be able to submit the form without filling it in. Defaults to false.
disabled boolean undefined If true the field will be disabled. Defaults to false.
defaultValue ValueType undefined The default value of the field.
scope SettingScopeType undefined This indicates whether the field (setting) is an app level or install level setting. App setting values can be used by any installation. undefined by default.

Boolean

A yes/no or true/false type input.

Boolean input

Usage

const booleanField = {
  type: 'boolean',
  name: 'enable',
  label: 'Enable the event',
};

Properties

Property Supported types Description
type string The desired field type.
name string The name of the field. This will be used as the key in the values object when the form is submitted.
label string The label of the field. This will be displayed to the user.
helpText string undefined An optional help text that will be displayed below the field.
disabled boolean undefined If true the field will be disabled. Defaults to false.
defaultValue ValueType undefined The default value of the field.
scope SettingScopeType undefined This indicates whether the field (setting) is an app level or install level setting. App setting values can be used by any installation. undefined by default.

Image

An image upload field.

Image input

Usage

const imageField = {
  type: 'image', // This tells the form to expect an image
  name: 'myImage',
  label: 'Image goes here',
  required: true,
};

Properties

Property Supported types Description
type string The desired field type.
name string The name of the field. This will be used as the key in the values object when the form is submitted.
label string The label of the field. This will be displayed to the user.
helpText string undefined An optional help text that will be displayed below the field.
required boolean undefined If true the field will be required and the user will not be able to submit the form without filling it in. Defaults to false.
disabled boolean undefined If true the field will be disabled. Defaults to false.
scope SettingScopeType undefined This indicates whether the field (setting) is an app level or install level setting. App setting values can be used by any installation. undefined by default.
placeholder string undefined Placeholder text for display before a value is present.
isSecret boolean undefined Makes the form field secret.

Notes

  • The formats supported are PNG, JPEG, WEBP, and GIF.
  • The maximum file size allowed is 20 MB.
  • When uploading a WEBP image, it will be converted to JPEG. As such, the Reddit URL returned points to a JPEG image.

Group

A collection of related fields that allows for better readability.

Usage

const groupField = {
  type: 'group',
  label: 'This is a group of input fields',
  fields: [
    {
      type: 'paragraph',
      name: 'description',
      label: 'How would you describe what happened?',
    },
    {
      type: 'number',
      name: 'score',
      label: 'How would you rate your meal on a scale from 1 to 10?',
    },
  ],
};

Properties

Property Supported types Description
type string The desired field type.
label string The label of the group that will be displayed to the user.
fields FormField[] The fields that will be displayed in the group.
helpText string undefined An optional help text that will be displayed below the group.

Examples

Below is a collection of common use cases and patterns.

Dynamic forms

Client-side approach:

import { showForm } from '@devvit/web/client';

// Get user data and show form with dynamic default values
const user = await reddit.getCurrentUser();

const result = await showForm({
  form: {
    fields: [
      {
        type: 'string',
        name: 'username',
        label: 'Username',
      },
    ],
  },
  data: {
    username: user?.username || ''
  }
});

if (result) {
  // Handle the form result
  console.log(`Hello ${result.username}`);
}

Server-side approach:

{
  "forms": {
    "dynamicForm": "/internal/form/dynamic-submit"
  }
}

<Tabs variant="pill" groupId="http-server-framework" defaultValue="hono" values={[ { label: 'Hono', value: 'hono' }, { label: 'Express', value: 'express' }, ]}>

import type { MenuItemRequest, UiResponse } from '@devvit/web/shared';

type DynamicFormRequest = { username: string };

// Endpoint that shows form with dynamic data
app.post('/internal/menu/show-dynamic-form', async (c) => {
  const _input = await c.req.json<MenuItemRequest>();
  const user = await reddit.getCurrentUser();
  
  return c.json<UiResponse>({
    showForm: {
      name: 'dynamicForm',
      form: {
        fields: [
          {
            type: 'string',
            name: 'username',
            label: 'Username',
          },
        ],
      },
      data: {
        username: user?.username || ''
      }
    }
  });
});

// Form submission handler
app.post('/internal/form/dynamic-submit', async (c) => {
  const { username } = await c.req.json<DynamicFormRequest>();
  
  return c.json<UiResponse>({
    showToast: `Hello ${username}`
  });
});
import type { MenuItemRequest, UiResponse } from '@devvit/web/shared';

type DynamicFormRequest = { username: string };

// Endpoint that shows form with dynamic data
router.post<string, never, UiResponse, MenuItemRequest>("/internal/menu/show-dynamic-form", async (_req, res) => {
  const user = await reddit.getCurrentUser();
  
  res.json({
    showForm: {
      name: 'dynamicForm',
      form: {
        fields: [
          {
            type: 'string',
            name: 'username',
            label: 'Username',
          },
        ],
      },
      data: {
        username: user?.username || ''
      }
    }
  });
});

// Form submission handler
router.post<string, never, UiResponse, DynamicFormRequest>("/internal/form/dynamic-submit", async (req, res) => {
  const { username } = req.body;
  
  res.json({
    showToast: `Hello ${username}`
  });
});

Multi-step forms

Client-side approach (Promise chaining):

import { showForm } from '@devvit/web/client';

async function multiStepForm() {
  // Step 1: Get name
  const step1Result = await showForm({
    form: {
      fields: [
        {
          type: 'string',
          name: 'name',
          label: "What's your name?",
          required: true,
        },
      ],
    }
  });

  if (!step1Result) return; // User cancelled

  // Step 2: Get food preference  
  const step2Result = await showForm({
    form: {
      fields: [
        {
          type: 'string',
          name: 'food',
          label: "What's your favorite food?",
          required: true,
        },
      ],
    },
    data: { name: step1Result.name } // Pass data from previous step
  });

  if (!step2Result) return; // User cancelled

  // Step 3: Get drink preference
  const step3Result = await showForm({
    form: {
      fields: [
        {
          type: 'string',
          name: 'drink',
          label: "What's your favorite drink?",
          required: true,
        },
      ],
    },
    data: { 
      name: step1Result.name,
      food: step2Result.food
    }
  });

  if (step3Result) {
    // All steps completed - save or process data
    const finalData = {
      ...step1Result,
      ...step2Result, 
      ...step3Result
    };
    
    console.log(`Thanks ${finalData.name}! You like ${finalData.food} and ${finalData.drink}.`);
  }
}

Server-side approach (Separate endpoints):

{
  "forms": {
    "step1Form": "/internal/form/step1-submit",
    "step2Form": "/internal/form/step2-submit",
    "step3Form": "/internal/form/step3-submit"
  }
}

<Tabs variant="pill" groupId="http-server-framework" defaultValue="hono" values={[ { label: 'Hono', value: 'hono' }, { label: 'Express', value: 'express' }, ]}>

import type { UiResponse } from '@devvit/web/shared';

type Step1FormRequest = { name: string };
type Step2FormRequest = { name: string; food: string };
type Step3FormRequest = { name: string; food: string; drink: string };

// Step 1: Name form
app.post('/internal/form/step1-submit', async (c) => {
  const { name } = await c.req.json<Step1FormRequest>();
  
  return c.json<UiResponse>({
    showForm: {
      name: 'step2Form',
      form: {
        fields: [
          {
            type: 'string',
            name: 'food',
            label: "What's your favorite food?",
            required: true,
          },
        ],
      },
      data: { name } // Pass data to next step
    }
  });
});

// Step 2: Food form
app.post('/internal/form/step2-submit', async (c) => {
  const { name, food } = await c.req.json<Step2FormRequest>();
  
  return c.json<UiResponse>({
    showForm: {
      name: 'step3Form',
      form: {
        fields: [
          {
            type: 'string',
            name: 'drink',
            label: "What's your favorite drink?",
            required: true,
          },
        ],
      },
      data: { name, food } // Pass accumulated data
    }
  });
});

// Step 3: Final form
app.post('/internal/form/step3-submit', async (c) => {
  const { name, food, drink } = await c.req.json<Step3FormRequest>();
  
  return c.json<UiResponse>({
    showToast: `Thanks ${name}! You like ${food} and ${drink}.`
  });
});
import type { UiResponse } from '@devvit/web/shared';

type Step1FormRequest = { name: string };
type Step2FormRequest = { name: string; food: string };
type Step3FormRequest = { name: string; food: string; drink: string };

// Step 1: Name form
router.post<string, never, UiResponse, Step1FormRequest>("/internal/form/step1-submit", async (req, res) => {
  const { name } = req.body;
  
  res.json({
    showForm: {
      name: 'step2Form',
      form: {
        fields: [
          {
            type: 'string',
            name: 'food',
            label: "What's your favorite food?",
            required: true,
          },
        ],
      },
      data: { name } // Pass data to next step
    }
  });
});

// Step 2: Food form
router.post<string, never, UiResponse, Step2FormRequest>("/internal/form/step2-submit", async (req, res) => {
  const { name, food } = req.body;
  
  res.json({
    showForm: {
      name: 'step3Form',
      form: {
        fields: [
          {
            type: 'string',
            name: 'drink',
            label: "What's your favorite drink?",
            required: true,
          },
        ],
      },
      data: { name, food } // Pass accumulated data
    }
  });
});

// Step 3: Final form
router.post<string, never, UiResponse, Step3FormRequest>("/internal/form/step3-submit", async (req, res) => {
  const { name, food, drink } = req.body;
  
  res.json({
    showToast: `Thanks ${name}! You like ${food} and ${drink}.`
  });
});

One of everything

This example includes one of each of the supported field types.

Client-side approach:

import { showForm } from '@devvit/web/client';

const result = await showForm({
  form: {
    title: 'My favorites',
    description: 'Tell us about your favorite food!',
    fields: [
      {
        type: 'string',
        name: 'food',
        label: 'What is your favorite food?',
        helpText: 'Must be edible',
        required: true,
      },
      {
        label: 'About that food',
        type: 'group',
        fields: [
          {
            type: 'number',
            name: 'times',
            label: 'How many times a week do you eat it?',
            defaultValue: 1,
          },
          {
            type: 'paragraph',
            name: 'what',
            label: 'What makes it your favorite?',
          },
          {
            type: 'select',
            name: 'healthy',
            label: 'Is it healthy?',
            options: [
              { label: 'Yes', value: 'yes' },
              { label: 'No', value: 'no' },
              { label: 'Maybe', value: 'maybe' },
            ],
            defaultValue: ['maybe'],
          },
        ],
      },
      {
        type: 'boolean',
        name: 'again',
        label: 'Can we ask again?',
      },
    ],
    acceptLabel: 'Submit',
    cancelLabel: 'Cancel',
  }
});

if (result) {
  console.log('Form values:', result);
  // Handle form submission
}

Server-side approach:

{
  "forms": {
    "everythingForm": "/internal/form/everything-submit"
  }
}

<Tabs variant="pill" groupId="http-server-framework" defaultValue="hono" values={[ { label: 'Hono', value: 'hono' }, { label: 'Express', value: 'express' }, ]}>

import type { MenuItemRequest, UiResponse } from '@devvit/web/shared';

type EverythingFormRequest = {
  food: string;
  times?: number;
  what?: string;
  healthy?: string[];
  again?: boolean;
};

app.post('/internal/form/everything-submit', async (c) => {
  const formValues = await c.req.json<EverythingFormRequest>();
  console.log('Form values:', formValues);
  
  return c.json<UiResponse>({
    showToast: 'Thanks!'
  });
});

// Example showing the form
app.post('/internal/menu/show-everything-form', async (c) => {
  const _input = await c.req.json<MenuItemRequest>();
  return c.json<UiResponse>({
    showForm: {
      name: 'everythingForm',
      form: {
        title: 'My favorites',
        description: 'Tell us about your favorite food!',
        fields: [
          {
            type: 'string',
            name: 'food',
            label: 'What is your favorite food?',
            helpText: 'Must be edible',
            required: true,
          },
          {
            label: 'About that food',
            type: 'group',
            fields: [
              {
                type: 'number',
                name: 'times',
                label: 'How many times a week do you eat it?',
                defaultValue: 1,
              },
              {
                type: 'paragraph',
                name: 'what',
                label: 'What makes it your favorite?',
              },
              {
                type: 'select',
                name: 'healthy',
                label: 'Is it healthy?',
                options: [
                  { label: 'Yes', value: 'yes' },
                  { label: 'No', value: 'no' },
                  { label: 'Maybe', value: 'maybe' },
                ],
                defaultValue: ['maybe'],
              },
            ],
          },
          {
            type: 'boolean',
            name: 'again',
            label: 'Can we ask again?',
          },
        ],
        acceptLabel: 'Submit',
        cancelLabel: 'Cancel',
      }
    }
  });
});
import type { MenuItemRequest, UiResponse } from '@devvit/web/shared';

type EverythingFormRequest = {
  food: string;
  times?: number;
  what?: string;
  healthy?: string[];
  again?: boolean;
};

router.post<string, never, UiResponse, EverythingFormRequest>("/internal/form/everything-submit", async (req, res) => {
  console.log('Form values:', req.body);
  
  res.json({
    showToast: 'Thanks!'
  });
});

// Example showing the form
router.post<string, never, UiResponse, MenuItemRequest>("/internal/menu/show-everything-form", async (_req, res) => {
  res.json({
    showForm: {
      name: 'everythingForm',
      form: {
        title: 'My favorites',
        description: 'Tell us about your favorite food!',
        fields: [
          {
            type: 'string',
            name: 'food',
            label: 'What is your favorite food?',
            helpText: 'Must be edible',
            required: true,
          },
          {
            label: 'About that food',
            type: 'group',
            fields: [
              {
                type: 'number',
                name: 'times',
                label: 'How many times a week do you eat it?',
                defaultValue: 1,
              },
              {
                type: 'paragraph',
                name: 'what',
                label: 'What makes it your favorite?',
              },
              {
                type: 'select',
                name: 'healthy',
                label: 'Is it healthy?',
                options: [
                  { label: 'Yes', value: 'yes' },
                  { label: 'No', value: 'no' },
                  { label: 'Maybe', value: 'maybe' },
                ],
                defaultValue: ['maybe'],
              },
            ],
          },
          {
            type: 'boolean',
            name: 'again',
            label: 'Can we ask again?',
          },
        ],
        acceptLabel: 'Submit',
        cancelLabel: 'Cancel',
      }
    }
  });
});

Image uploads

Client-side approach:

import { showForm } from '@devvit/web/client';

const result = await showForm({
  form: {
    title: 'Upload an image!',
    fields: [
      {
        name: 'myImage',
        type: 'image', // This tells the form to expect an image
        label: 'Image goes here',
        required: true,
      },
    ],
  }
});

if (result) {
  const { myImage } = result;
  // returns an i.redd.it URL
  console.log('Image uploaded:', myImage);
  
  // Process the image further
  await fetch('/api/process-image', {
    method: 'POST',
    body: JSON.stringify({ imageUrl: myImage })
  });
}

Server-side approach:

{
  "forms": {
    "imageForm": "/internal/form/image-submit"
  }
}

<Tabs variant="pill" groupId="http-server-framework" defaultValue="hono" values={[ { label: 'Hono', value: 'hono' }, { label: 'Express', value: 'express' }, ]}>

import type { UiResponse } from '@devvit/web/shared';

type ImageFormRequest = { myImage: string };

app.post('/internal/form/image-submit', async (c) => {
  const { myImage } = await c.req.json<ImageFormRequest>();
  // Store the mediaUrl in Redis and render it via an <img> tag on the client, or send to external service to modify
  console.log('Image uploaded:', myImage);
  
  return c.json<UiResponse>({
    showToast: 'Image uploaded successfully!'
  });
});
import type { UiResponse } from '@devvit/web/shared';

type ImageFormRequest = { myImage: string };

router.post<string, never, UiResponse, ImageFormRequest>("/internal/form/image-submit", async (req, res) => {
  const { myImage } = req.body;
  // Store the mediaUrl in Redis and render it via an <img> tag on the client, or send to external service to modify
  console.log('Image uploaded:', myImage);
  
  res.json({
    showToast: 'Image uploaded successfully!'
  });
});