Repository: CitizenLabDotCo/citizenlab
Pull request #11143: TAN-4658 Ranking field
Author: login: luucvanderzee
Created: 2025-05-20T22:21:16Z | Merged: 2025-05-23T21:42:12Z

# Changelog
## Added
- Ranking field to custom fields form


Diff:
diff --git a/front/app/components/CustomFieldsForm/CustomFields.tsx b/front/app/components/CustomFieldsForm/CustomFields.tsx
index 33e9fa5128b0..9fce285f64b7 100644
--- a/front/app/components/CustomFieldsForm/CustomFields.tsx
+++ b/front/app/components/CustomFieldsForm/CustomFields.tsx
@@ -24,6 +24,7 @@ import MultiSelectField from './Fields/MultiSelectField';
 import SingleSelectField from './Fields/SingleSelectField';
 import messages from './messages';
 import { getInstructionMessage } from './util';
+import RankingField from './Fields/RankingField';
 
 const renderField = ({
   question,
@@ -85,6 +86,8 @@ const renderField = ({
       return <Topics name={question.key} projectId={projectId} />;
     case 'linear_scale':
       return <LinearScaleField question={question} />;
+    case 'ranking':
+      return <RankingField question={question} />;
     default:
       return null;
   }
diff --git a/front/app/components/CustomFieldsForm/Fields/RankingField/Ranking.tsx b/front/app/components/CustomFieldsForm/Fields/RankingField/Ranking.tsx
new file mode 100644
index 000000000000..9945a3b5914e
--- /dev/null
+++ b/front/app/components/CustomFieldsForm/Fields/RankingField/Ranking.tsx
@@ -0,0 +1,133 @@
+import React from 'react';
+
+import { Box, Button, IOption, Text } from '@citizenlab/cl2-component-library';
+import styled, { useTheme } from 'styled-components';
+
+import { IFlatCustomField } from 'api/custom_fields/types';
+
+import useLocalize from 'hooks/useLocalize';
+
+import messages from 'components/Form/Components/Controls/messages';
+import {
+  DragAndDrop,
+  Drop,
+} from 'components/FormBuilder/components/DragAndDrop';
+import { DragAndDropResult } from 'components/FormBuilder/edit/utils';
+
+import { useIntl } from 'utils/cl-intl';
+
+import RankingOption from './RankingOption';
+
+const Ul = styled.ul`
+  padding: 0;
+  margin-top: 4px;
+  list-style-type: none;
+`;
+
+interface Props {
+  value?: string[];
+  question: IFlatCustomField;
+  onChange: (value?: string[]) => void;
+}
+
+const getOptionsFromData = (data: string[], options: IOption[]): IOption[] => {
+  return data
+    .map((optionKey: string) => {
+      return options.find((option) => option.value === optionKey);
+    })
+    .filter((value): value is IOption => !!value);
+};
+
+const Ranking = ({ value: data, question, onChange }: Props) => {
+  const { formatMessage } = useIntl();
+  const localize = useLocalize();
+  const theme = useTheme();
+
+  if (!question.options) return null;
+
+  // If form data present, get options in that ranking order.
+  // Otherwise, get option order from the question.
+  const questionOptions: IOption[] = question.options.map((option) => ({
+    value: option.key,
+    label: localize(option.title_multiloc),
+  }));
+
+  const options = data
+    ? getOptionsFromData(data, questionOptions)
+    : questionOptions;
+
+  // updateData: Function to update the form data with a specific option order.
+  const updateData = (newOptionOrder?: IOption[]) => {
+    onChange(newOptionOrder?.map((option: IOption) => option.value));
+  };
+
+  // moveOptionInArray: Function to move an option in the array to a new index & update the form data.
+  const moveOptionInArray = (sourceIndex: number, destinationIndex: number) => {
+    const updatedOptions = [...options];
+    const [removed] = updatedOptions.splice(sourceIndex, 1);
+    updatedOptions.splice(destinationIndex, 0, removed);
+    updateData(updatedOptions);
+  };
+
+  // reorderFieldsAfterDrag: Function to reorder and save the options after a drag and drop.
+  const reorderFieldsAfterDrag = (result: DragAndDropResult) => {
+    const { source, destination } = result;
+    if (!destination) return;
+
+    const sourceIndex = source.index;
+    const destinationIndex = destination.index;
+
+    moveOptionInArray(sourceIndex, destinationIndex);
+  };
+
+  return (
+    <Box
+      display="flex"
+      flexDirection="row"
+      flexGrow={1}
+      id="e2e-ranking-control"
+    >
+      <Box flexGrow={1}>
+        <DragAndDrop
+          onDragEnd={(result: DragAndDropResult) => {
+            reorderFieldsAfterDrag(result);
+          }}
+        >
+          <Drop id="droppable" type="rankOptions">
+            <Text m="0px" aria-hidden color="tenantPrimary">
+              {formatMessage(messages.rankingInstructions)}
+            </Text>
+            <Ul aria-labelledby={`ranking-question-label-${question.key}`}>
+              {options.map((option: IOption, index: number) => (
+                <RankingOption
+                  key={option.value}
+                  data={data}
+                  option={option}
+                  options={options}
+                  index={index}
+                  moveOptionInArray={moveOptionInArray}
+                />
+              ))}
+            </Ul>
+          </Drop>
+        </DragAndDrop>
+        {data !== undefined && (
+          <Box display="flex">
+            <Button
+              p="0px"
+              buttonStyle="text"
+              textColor={theme.colors.tenantPrimary}
+              textDecoration="underline"
+              text={formatMessage(messages.clearAll)}
+              onClick={() => {
+                updateData(undefined);
+              }}
+            />
+          </Box>
+        )}
+      </Box>
+    </Box>
+  );
+};
+
+export default Ranking;
diff --git a/front/app/components/CustomFieldsForm/Fields/RankingField/RankingOption.tsx b/front/app/components/CustomFieldsForm/Fields/RankingField/RankingOption.tsx
new file mode 100644
index 000000000000..8a53576c4f4a
--- /dev/null
+++ b/front/app/components/CustomFieldsForm/Fields/RankingField/RankingOption.tsx
@@ -0,0 +1,137 @@
+import React from 'react';
+
+import {
+  Box,
+  Icon,
+  IOption,
+  Label,
+  Text,
+  Select,
+} from '@citizenlab/cl2-component-library';
+
+import { Drag } from 'components/FormBuilder/components/DragAndDrop';
+import styled, { useTheme } from 'styled-components';
+import { useIntl } from 'utils/cl-intl';
+import { ScreenReaderOnly } from 'utils/a11y';
+import messages from 'components/Form/Components/Controls/messages';
+
+const StyledSelect = styled(Select)`
+  min-width: 52px;
+  margin-right: 12px;
+  margin-top: auto;
+  margin-bottom: auto;
+
+  select {
+    padding: 4px;
+    border: solid 1px ${(props) => props.theme.colors.tenantPrimary};
+    color: ${(props) => props.theme.colors.tenantPrimary};
+  }
+
+  svg {
+    fill: ${(props) => props.theme.colors.tenantPrimary} !important;
+    width: 16px;
+  }
+`;
+
+interface Props {
+  data?: string[];
+  option: IOption;
+  options: IOption[];
+  index: number;
+  moveOptionInArray: (sourceIndex: number, destinationIndex: number) => void;
+}
+
+const RankingOption = ({
+  data,
+  option,
+  options,
+  index,
+  moveOptionInArray,
+}: Props) => {
+  const { formatMessage } = useIntl();
+  const theme = useTheme();
+
+  // getRankOfOption: Function to get the rank of a specific option in the current options array.
+  const getRankOfOption = (currentOption: IOption) => {
+    return data
+      ? options.findIndex(
+          (option: IOption) => option.value === currentOption.value
+        ) + 1
+      : '';
+  };
+
+  // rankDropdownOptions: For the select dropdown, generate the rank number options.
+  const rankDropdownOptions = options.map(
+    (_option: IOption, index: number) => ({
+      value: index + 1,
+      label: `${index + 1}`,
+    })
+  );
+
+  return (
+    <li aria-roledescription="sortable">
+      <Drag index={index} useBorder={false} id={`ranking-item-${option.value}`}>
+        <Box
+          style={{ cursor: 'grab' }}
+          mb="12px"
+          background={theme.colors.tenantPrimaryLighten95}
+          borderRadius={theme.borderRadius}
+          border={`1px solid ${theme.colors.tenantPrimary}`}
+        >
+          <Box
+            padding="18px 20px 18px 20px"
+            display="flex"
+            justifyContent="space-between"
+          >
+            <Box display="flex">
+              <ScreenReaderOnly>
+                <Label>
+                  {`${option.label}. ${
+                    getRankOfOption(option)
+                      ? formatMessage(messages.currentRank)
+                      : formatMessage(messages.noRankSelected)
+                  }`}
+                </Label>
+              </ScreenReaderOnly>
+
+              <StyledSelect
+                options={rankDropdownOptions}
+                value={getRankOfOption(option)}
+                onChange={(selectedOption) => {
+                  moveOptionInArray(index, selectedOption.value - 1);
+
+                  // For a11y, focus the list item again after reordering.
+                  (
+                    document.querySelector(
+                      `[data-rbd-drag-handle-draggable-id="ranking-item-${option.value}"]`
+                    ) as HTMLElement
+                  ).focus();
+                }}
+              />
+              <Text
+                maxWidth="80%"
+                my="auto"
+                color="tenantPrimary"
+                p="0px"
+                m="0px"
+                aria-hidden
+              >
+                {option.label}
+              </Text>
+            </Box>
+
+            <Box flexShrink={0} my="auto" pl="8px">
+              <Icon
+                height="18px"
+                name="drag-handle"
+                fill={theme.colors.tenantPrimary}
+              />
+            </Box>
+          </Box>
+        </Box>
+      </Drag>
+    </li>
+  );
+};
+
+export default RankingOption;
diff --git a/front/app/components/CustomFieldsForm/Fields/RankingField/index.tsx b/front/app/components/CustomFieldsForm/Fields/RankingField/index.tsx
new file mode 100644
index 000000000000..ee33501313bc
--- /dev/null
+++ b/front/app/components/CustomFieldsForm/Fields/RankingField/index.tsx
@@ -0,0 +1,57 @@
+import React from 'react';
+
+import { Controller, useFormContext } from 'react-hook-form';
+
+import { IFlatCustomField } from 'api/custom_fields/types';
+
+import Ranking from './Ranking';
+import Error, { TFieldName } from 'components/UI/Error';
+import { CLError, RHFErrors } from 'typings';
+
+interface Props {
+  question: IFlatCustomField;
+}
+
+const RankingField = ({ question }: Props) => {
+  const {
+    control,
+    formState: { errors: formContextErrors },
+  } = useFormContext();
+
+  const name = question.key;
+
+  const errors = formContextErrors[name] as RHFErrors;
+  const validationError = errors?.message;
+  const apiError = errors?.error && ([errors] as CLError[]);
+
+  return (
+    <>
+      <Controller
+        name={name}
+        control={control}
+        render={({ field: { ref: _ref, ...field } }) => {
+          return <Ranking question={question} {...field} />;
+        }}
+      />
+      {validationError && (
+        <Error
+          marginTop="8px"
+          marginBottom="8px"
+          text={validationError}
+          scrollIntoView={false}
+        />
+      )}
+      {apiError && (
+        <Error
+          fieldName={name as TFieldName}
+          apiErrors={apiError}
+          marginTop="8px"
+          marginBottom="8px"
+          scrollIntoView={false}
+        />
+      )}
+    </>
+  );
+};
+
+export default RankingField;
diff --git a/front/app/components/CustomFieldsForm/generateYupSchema.ts b/front/app/components/CustomFieldsForm/generateYupSchema.ts
index fc6f4631922e..5dd14349ee3e 100644
--- a/front/app/components/CustomFieldsForm/generateYupSchema.ts
+++ b/front/app/components/CustomFieldsForm/generateYupSchema.ts
@@ -30,6 +30,8 @@ const generateYupValidationSchema = ({
       title_multiloc,
     } = question;
 
+    const title = localize(title_multiloc);
+
     switch (input_type) {
       case 'text_multiloc': {
         if (key === 'title_multiloc') {
@@ -83,7 +85,7 @@ const generateYupValidationSchema = ({
           schema[key] = required
             ? string().required(
                 formatMessage(messages.fieldRequired, {
-                  fieldName: localize(title_multiloc),
+                  fieldName: title,
                 })
               )
             : string();
@@ -98,7 +100,7 @@ const generateYupValidationSchema = ({
           schema[key] = required
             ? number().required(
                 formatMessage(messages.fieldRequired, {
-                  fieldName: localize(title_multiloc),
+                  fieldName: title,
                 })
               )
             : number();
@@ -110,7 +112,7 @@ const generateYupValidationSchema = ({
         schema[key] = required
           ? string().required(
               formatMessage(messages.fieldRequired, {
-                fieldName: localize(title_multiloc),
+                fieldName: title,
               })
             )
           : string();
@@ -134,13 +136,13 @@ const generateYupValidationSchema = ({
           fieldSchema = fieldSchema
             .required(
               formatMessage(messages.fieldRequired, {
-                fieldName: localize(title_multiloc),
+                fieldName: title,
               })
             )
             .min(
               1,
               formatMessage(messages.fieldRequired, {
-                fieldName: localize(title_multiloc),
+                fieldName: title,
               })
             );
         }
@@ -150,7 +152,7 @@ const generateYupValidationSchema = ({
             minimum_select_count,
             formatMessage(messages.fieldMinimum, {
               minSelections: minimum_select_count,
-              fieldName: localize(title_multiloc),
+              fieldName: title,
             })
           );
         }
@@ -159,7 +161,7 @@ const generateYupValidationSchema = ({
             maximum_select_count,
             formatMessage(messages.fieldMaximum, {
               maxSelections: maximum_select_count,
-              fieldName: localize(title_multiloc),
+              fieldName: title,
             })
           );
         }
@@ -212,12 +214,31 @@ const generateYupValidationSchema = ({
         schema[key] = required
           ? number().required(
               formatMessage(messages.fieldRequired, {
-                fieldName: localize(title_multiloc),
+                fieldName: title,
               })
             )
           : number();
         break;
       }
+
+      case 'ranking': {
+        const numberOfOptions = question.options?.length;
+
+        const fieldRequired = formatMessage(messages.fieldRequired, {
+          fieldName: title,
+        });
+
+        // type guard, numberOfOptions should always be defined
+        schema[key] =
+          required && numberOfOptions !== undefined
+            ? array()
+                .of(string())
+                .min(numberOfOptions, fieldRequired)
+                .max(numberOfOptions, fieldRequired)
+                .required(fieldRequired)
+            : array().nullable();
+        break;
+      }
     }
   });
 


Reviews:
user: login: IvaKop
body: Approving with some minor questions / comments. Most important is to test the error handling and make sure the messages make sense.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T07:14:38Z
user: login: luucvanderzee
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T21:35:36Z
user: login: luucvanderzee
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T21:38:26Z
user: login: luucvanderzee
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T21:39:56Z

Review comments:
diff_hunk: @@ -218,6 +218,21 @@ const generateYupValidationSchema = ({
           : number();
         break;
       }
+
+      case 'ranking': {
+        const numberOfOptions = question.options?.length;
+
+        // type guard, numberOfOptions should always be defined
+        schema[key] =
+          required && numberOfOptions !== undefined
+            ? array()
+                .of(string())
+                .min(numberOfOptions, formatMessage(messages.fieldRequired))
path: front/app/components/CustomFieldsForm/generateYupSchema.ts
user: login: IvaKop
body: Should we have separate messages for these scenarios?
created_at: 2025-05-22T07:11:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,57 @@
+import React from 'react';
+
+import { Controller, useFormContext } from 'react-hook-form';
+
+import { IFlatCustomField } from 'api/custom_fields/types';
+
+import Ranking from './Ranking';
+import Error, { TFieldName } from 'components/UI/Error';
+import { CLError, RHFErrors } from 'typings';
+
+interface Props {
+  question: IFlatCustomField;
+}
+
+const RankingField = ({ question }: Props) => {
+  const {
+    control,
+    formState: { errors: formContextErrors },
+  } = useFormContext();
+
+  const name = question.key;
+
+  const errors = formContextErrors[name] as RHFErrors;
path: front/app/components/CustomFieldsForm/Fields/RankingField/index.tsx
user: login: IvaKop
body: Did we test this is working? With more complex question types sometimes we need to change the error handling but nit sure if it's the case here.
created_at: 2025-05-22T07:12:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,134 @@
+import React from 'react';
+
+import { Box, Button, IOption, Text } from '@citizenlab/cl2-component-library';
+
+import {
+  DragAndDrop,
+  Drop,
+} from 'components/FormBuilder/components/DragAndDrop';
+
+import { IFlatCustomField } from 'api/custom_fields/types';
+import { useIntl } from 'utils/cl-intl';
+import styled, { useTheme } from 'styled-components';
+
+import messages from 'components/Form/Components/Controls/messages';
+import RankingOption from './RankingOption';
+import useLocalize from 'hooks/useLocalize';
+import { DragAndDropResult } from 'components/FormBuilder/edit/utils';
+
+const Ul = styled.ul`
+  padding: 0;
+  margin-top: 4px;
+  list-style-type: none;
+`;
+
+interface Props {
+  value?: string[];
+  question: IFlatCustomField;
+  onChange: (value?: string[]) => void;
+}
+
+const getOptionsFromData = (
+  data: string[],
+  optionsFromSchema: IOption[]
path: front/app/components/CustomFieldsForm/Fields/RankingField/Ranking.tsx
user: login: IvaKop
body: Let's not use schema in the naming? It confuses me to think it's part of the old setup. We don't have a schema anymore.
created_at: 2025-05-22T07:13:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -218,6 +218,21 @@ const generateYupValidationSchema = ({
           : number();
         break;
       }
+
+      case 'ranking': {
+        const numberOfOptions = question.options?.length;
+
+        // type guard, numberOfOptions should always be defined
+        schema[key] =
+          required && numberOfOptions !== undefined
+            ? array()
+                .of(string())
+                .min(numberOfOptions, formatMessage(messages.fieldRequired))
path: front/app/components/CustomFieldsForm/generateYupSchema.ts
user: login: luucvanderzee
body: In this case, I don't think it's worth it, because in the interface is it actually almost impossible to achieve a state where you have a ranking that more than 0 but less than `numberOfOptions` selected options. The only case I can think of is when someone filled out the form, and then later the admin added new options, and you then go back to edit your response. In this case, the validation will just say that the thing is required instead of being more specific.
created_at: 2025-05-23T21:35:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,57 @@
+import React from 'react';
+
+import { Controller, useFormContext } from 'react-hook-form';
+
+import { IFlatCustomField } from 'api/custom_fields/types';
+
+import Ranking from './Ranking';
+import Error, { TFieldName } from 'components/UI/Error';
+import { CLError, RHFErrors } from 'typings';
+
+interface Props {
+  question: IFlatCustomField;
+}
+
+const RankingField = ({ question }: Props) => {
+  const {
+    control,
+    formState: { errors: formContextErrors },
+  } = useFormContext();
+
+  const name = question.key;
+
+  const errors = formContextErrors[name] as RHFErrors;
path: front/app/components/CustomFieldsForm/Fields/RankingField/index.tsx
user: login: luucvanderzee
body: Yeah it works, but I just noticed I messed up the formatting of the message. Just fixed that :+1:
created_at: 2025-05-23T21:38:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,134 @@
+import React from 'react';
+
+import { Box, Button, IOption, Text } from '@citizenlab/cl2-component-library';
+
+import {
+  DragAndDrop,
+  Drop,
+} from 'components/FormBuilder/components/DragAndDrop';
+
+import { IFlatCustomField } from 'api/custom_fields/types';
+import { useIntl } from 'utils/cl-intl';
+import styled, { useTheme } from 'styled-components';
+
+import messages from 'components/Form/Components/Controls/messages';
+import RankingOption from './RankingOption';
+import useLocalize from 'hooks/useLocalize';
+import { DragAndDropResult } from 'components/FormBuilder/edit/utils';
+
+const Ul = styled.ul`
+  padding: 0;
+  margin-top: 4px;
+  list-style-type: none;
+`;
+
+interface Props {
+  value?: string[];
+  question: IFlatCustomField;
+  onChange: (value?: string[]) => void;
+}
+
+const getOptionsFromData = (
+  data: string[],
+  optionsFromSchema: IOption[]
path: front/app/components/CustomFieldsForm/Fields/RankingField/Ranking.tsx
user: login: luucvanderzee
body: Haha the word 'schema' is triggering. I will change it
created_at: 2025-05-23T21:39:56Z
author_association: CONTRIBUTOR

PR comments:
user: login: notion-workspace[bot]
created_at: 2025-05-20T22:21:22Z
body: <a href="https://www.notion.so/Add-ranking-field-1f99663b7b2680a381b3c7b6861015e0?pvs=8&n=github_linkback">Add ranking field</a>
author_association: NONE
user: login: cl-dev-bot
created_at: 2025-05-20T22:31:44Z
body: 
<!--
  0 failure: 
  0 warning: 
  4 messages
  
  DangerID: danger-id-Danger;
-->



<table>
  <thead>
    <tr>
      <th width="50"></th>
      <th width="100%" data-danger-table="true">Messages</th>
    </tr>
  </thead>
  <tbody><tr>
      <td>:book:</td>
      <td>Changelog provided 🎉</td>
    </tr>
  
<tr>
      <td>:book:</td>
      <td>Notion issue: <a href="https://notion.so/TAN-4658">TAN-4658</a></td>
    </tr>
  
<tr>
      <td>:book:</td>
      <td>

  <a href="https://epic.citizenlab.co/ci-e2e?citizenlab_branch=TAN-4658-ranking-field">Run the e2e tests</a>
  </td>
    </tr>
  
<tr>
      <td>:book:</td>
      <td><a href="https://epic.citizenlab.co/translations/TAN-4658-ranking-field">Check translation progress</a></td>
    </tr>
  </tbody>
</table>


<p align="right">
  Generated by :no_entry_sign: <a href="https://danger.systems/js">dangerJS</a> against a62fa7960b6f9eb426a99540b32953bc6f63a5f1
</p>

author_association: COLLABORATOR