Repository: CitizenLabDotCo/citizenlab
Pull request #11154: TAN-4665 Matrix field
Author: login: luucvanderzee
Created: 2025-05-21T22:21:15Z | Merged: 2025-05-23T21:46:56Z

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



Diff:
diff --git a/front/app/api/custom_field_statements/useCustomFieldStatements.ts b/front/app/api/custom_field_statements/useCustomFieldStatements.ts
index 0b71b967bcad..e3eaedb24f69 100644
--- a/front/app/api/custom_field_statements/useCustomFieldStatements.ts
+++ b/front/app/api/custom_field_statements/useCustomFieldStatements.ts
@@ -1,13 +1,10 @@
 import { UseQueryOptions, useQueries } from '@tanstack/react-query';
 
-import { ICustomFields } from 'api/custom_fields/types';
-
 import fetcher from 'utils/cl-react-query/fetcher';
 
 import customFieldStatementKeys from './keys';
 import {
   ICustomFieldStatementParameters,
-  IdeaCustomFields,
   IFormCustomFieldStatement,
 } from './types';
 
@@ -27,18 +24,10 @@ const fetchCustomFieldStatement = ({
   });
 };
 
-type CustomFieldStatements = Omit<ICustomFieldStatementParameters, 'id'> & {
-  customFields?: ICustomFields | IdeaCustomFields;
-};
-
 type CustomFieldsStatementsReturnType =
   UseQueryOptions<IFormCustomFieldStatement>[];
 
-const useCustomFieldStatements = ({
-  projectId,
-  phaseId,
-  customFields,
-}: CustomFieldStatements) => {
+const useCustomFieldStatements = ({ projectId, phaseId, customFields }) => {
   const customFieldsStatementIds =
     customFields?.data.flatMap((customField) =>
       customField.relationships.matrix_statements?.data.map(
diff --git a/front/app/api/custom_fields/types.ts b/front/app/api/custom_fields/types.ts
index 7a86790a3258..3d4d98b95a69 100644
--- a/front/app/api/custom_fields/types.ts
+++ b/front/app/api/custom_fields/types.ts
@@ -51,9 +51,9 @@ export type IOptionsType = {
 };
 
 export type IMatrixStatementsType = {
-  id?: string;
+  id: string;
+  key: string;
   title_multiloc: Multiloc;
-  temp_id?: string;
 };
 
 export type QuestionRuleType = { if: string | number; goto_page_id: string };
@@ -157,35 +157,44 @@ export type IFlatCustomFieldWithIndex = IFlatCustomField & {
 
 type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;
 
-export type IFlatCreateCustomField = Optional<
-  IFlatCustomField,
-  | 'description_multiloc'
-  | 'type'
-  | 'key'
-  | 'options'
-  | 'matrix_statements'
-  | 'ordering'
-  | 'created_at'
-  | 'updated_at'
-  | 'linear_scale_label_1_multiloc'
-  | 'linear_scale_label_2_multiloc'
-  | 'linear_scale_label_3_multiloc'
-  | 'linear_scale_label_4_multiloc'
-  | 'linear_scale_label_5_multiloc'
-  | 'linear_scale_label_6_multiloc'
-  | 'linear_scale_label_7_multiloc'
-  | 'linear_scale_label_8_multiloc'
-  | 'linear_scale_label_9_multiloc'
-  | 'linear_scale_label_10_multiloc'
-  | 'linear_scale_label_11_multiloc'
-  | 'maximum'
-  | 'random_option_ordering'
-  | 'dropdown_layout'
-  | 'question_category'
-  | 'ask_follow_up'
-  | 'include_in_printed_form'
+export type ICreateMatrixStatementsType = {
+  id?: string;
+  title_multiloc: Multiloc;
+  temp_id?: string;
+};
+
+export type IFlatCreateCustomField = Omit<
+  Optional<
+    IFlatCustomField,
+    | 'description_multiloc'
+    | 'type'
+    | 'key'
+    | 'options'
+    | 'ordering'
+    | 'created_at'
+    | 'updated_at'
+    | 'linear_scale_label_1_multiloc'
+    | 'linear_scale_label_2_multiloc'
+    | 'linear_scale_label_3_multiloc'
+    | 'linear_scale_label_4_multiloc'
+    | 'linear_scale_label_5_multiloc'
+    | 'linear_scale_label_6_multiloc'
+    | 'linear_scale_label_7_multiloc'
+    | 'linear_scale_label_8_multiloc'
+    | 'linear_scale_label_9_multiloc'
+    | 'linear_scale_label_10_multiloc'
+    | 'linear_scale_label_11_multiloc'
+    | 'maximum'
+    | 'random_option_ordering'
+    | 'dropdown_layout'
+    | 'question_category'
+    | 'ask_follow_up'
+    | 'include_in_printed_form'
+  >,
+  'matrix_statements'
 > & {
   isLocalOnly: boolean;
+  matrix_statements?: ICreateMatrixStatementsType[];
 };
 
 export interface ICustomFields {
diff --git a/front/app/api/custom_fields/useCustomFields.ts b/front/app/api/custom_fields/useCustomFields.ts
index 7eaf6b235b2e..689b51021560 100644
--- a/front/app/api/custom_fields/useCustomFields.ts
+++ b/front/app/api/custom_fields/useCustomFields.ts
@@ -3,6 +3,8 @@ import useCustomFieldStatements from 'api/custom_field_statements/useCustomField
 
 import { ICustomFieldsParameters, IFlatCustomField } from './types';
 import useRawCustomFields from './useRawCustomFields';
+import { IFormCustomFieldStatementData } from 'api/custom_field_statements/types';
+import { IMatrixStatementsType } from 'api/custom_fields/types';
 
 const useCustomFields = ({
   projectId,
@@ -22,6 +24,29 @@ const useCustomFields = ({
     customFields: result.data,
   });
 
+  const statementsById = statements.reduce((acc, statement) => {
+    if (!statement.data) return acc;
+
+    const id = statement.data.data.id;
+    acc[id] = statement.data.data;
+    return acc;
+  }, {} as Record<string, IFormCustomFieldStatementData>);
+
+  const statementsByCustomFieldId = result.data?.data.reduce(
+    (acc, customField) => {
+      if (!customField.relationships.matrix_statements) return acc;
+
+      const statementIds =
+        customField.relationships.matrix_statements?.data.map(
+          (statement) => statement.id
+        );
+
+      acc[customField.id] = statementIds.map((id) => statementsById[id]);
+      return acc;
+    },
+    {} as Record<string, IFormCustomFieldStatementData[]>
+  );
+
   const data: IFlatCustomField[] | undefined = result.data?.data.map(
     (customField) => {
       const optionsForCustomField = options.filter((option) => {
@@ -36,17 +61,15 @@ const useCustomFields = ({
         );
       });
 
-      const statementsForCustomField = statements.filter((statement) => {
-        const relationshipStatementIds =
-          customField.relationships.matrix_statements?.data.map(
-            (statement) => statement.id
-          );
-
-        return (
-          statement.data?.data.id &&
-          relationshipStatementIds?.includes(statement.data.data.id)
-        );
-      });
+      const statementsForCustomField =
+        statementsByCustomFieldId?.[customField.id] ?? [];
+      const convertedStatements: IMatrixStatementsType[] =
+        statementsForCustomField.map((statement) => ({
+          id: statement.id,
+          key: statement.attributes.key,
+          title_multiloc: statement.attributes.title_multiloc,
+          temp_id: statement.attributes.temp_id,
+        }));
 
       return {
         ...customField,
@@ -57,15 +80,7 @@ const useCustomFields = ({
         // TODO: Fix this the next time the file is edited.
         // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
         map_config_id: customField.relationships?.map_config?.data?.id,
-        matrix_statements:
-          statementsForCustomField.length > 0
-            ? statementsForCustomField.map((statement) => ({
-                id: statement.data?.data.id,
-                title_multiloc:
-                  statement.data?.data.attributes.title_multiloc || {},
-                temp_id: statement.data?.data.attributes.temp_id,
-              }))
-            : [],
+        matrix_statements: convertedStatements,
         options:
           optionsForCustomField.length > 0
             ? optionsForCustomField.map((option) => ({
diff --git a/front/app/components/CustomFieldsForm/CustomFields.tsx b/front/app/components/CustomFieldsForm/CustomFields.tsx
index 2caa1168b48e..1b6d85d22f84 100644
--- a/front/app/components/CustomFieldsForm/CustomFields.tsx
+++ b/front/app/components/CustomFieldsForm/CustomFields.tsx
@@ -26,6 +26,7 @@ import messages from './messages';
 import { getInstructionMessage } from './util';
 import RankingField from './Fields/RankingField';
 import RatingField from './Fields/RatingField';
+import MatrixField from './Fields/MatrixField';
 
 const renderField = ({
   question,
@@ -91,6 +92,8 @@ const renderField = ({
       return <RankingField question={question} />;
     case 'rating':
       return <RatingField question={question} />;
+    case 'matrix_linear_scale':
+      return <MatrixField question={question} />;
     default:
       return null;
   }
diff --git a/front/app/components/CustomFieldsForm/Fields/LinearScale/Labels.tsx b/front/app/components/CustomFieldsForm/Fields/LinearScale/Labels.tsx
index b1bb390d280b..154859723fcb 100644
--- a/front/app/components/CustomFieldsForm/Fields/LinearScale/Labels.tsx
+++ b/front/app/components/CustomFieldsForm/Fields/LinearScale/Labels.tsx
@@ -3,7 +3,7 @@ import React from 'react';
 import { Box, Text, useBreakpoint } from '@citizenlab/cl2-component-library';
 import { IFlatCustomField } from 'api/custom_fields/types';
 import useLocalize from 'hooks/useLocalize';
-import { Multiloc } from 'typings';
+import { getLinearScaleLabel } from './utils';
 
 interface Props {
   question: IFlatCustomField;
@@ -16,8 +16,7 @@ const Labels = ({ question, maximum }: Props) => {
 
   // Put all labels in an array so we can easily access them
   const labelsFromSchema = Array.from({ length: maximum }, (_, index) => {
-    const label: Multiloc | undefined =
-      question[`linear_scale_label_${index + 1}_multiloc`];
+    const label = getLinearScaleLabel(question, index + 1);
     return label ? localize(label) : undefined;
   });
 
diff --git a/front/app/components/CustomFieldsForm/Fields/LinearScale/LinearScale.tsx b/front/app/components/CustomFieldsForm/Fields/LinearScale/LinearScale.tsx
index 7ef0d6b92496..821bf38b4d62 100644
--- a/front/app/components/CustomFieldsForm/Fields/LinearScale/LinearScale.tsx
+++ b/front/app/components/CustomFieldsForm/Fields/LinearScale/LinearScale.tsx
@@ -8,9 +8,9 @@ import { useIntl } from 'utils/cl-intl';
 import LinearScaleButton from './LinearScaleButton';
 import Labels from './Labels';
 import useLocalize from 'hooks/useLocalize';
-import { Multiloc } from 'typings';
 
 import messages from 'components/Form/Components/Controls/messages';
+import { getLinearScaleLabel } from './utils';
 
 interface Props {
   value?: number;
@@ -31,8 +31,7 @@ const LinearScale = ({ value: data, question, onChange }: Props) => {
 
   const getAriaValueText = (value: number, total: number) => {
     // If the value has a label, read it out
-    const label: Multiloc | undefined =
-      question[`linear_scale_label_${value}_multiloc`];
+    const label = getLinearScaleLabel(question, value);
 
     if (label) {
       return formatMessage(messages.valueOutOfTotalWithLabel, {
@@ -43,8 +42,7 @@ const LinearScale = ({ value: data, question, onChange }: Props) => {
     }
 
     // If we don't have a label but we do have a maximum, read out the current value & maximum label
-    const maxLabel: Multiloc | undefined =
-      question[`linear_scale_label_${maximum}_multiloc`];
+    const maxLabel = getLinearScaleLabel(question, maximum);
 
     if (maxLabel) {
       return formatMessage(messages.valueOutOfTotalWithMaxExplanation, {
diff --git a/front/app/components/CustomFieldsForm/Fields/LinearScale/utils.ts b/front/app/components/CustomFieldsForm/Fields/LinearScale/utils.ts
new file mode 100644
index 000000000000..14d580b7c6e5
--- /dev/null
+++ b/front/app/components/CustomFieldsForm/Fields/LinearScale/utils.ts
@@ -0,0 +1,17 @@
+import { IFlatCustomField } from 'api/custom_fields/types';
+import { get } from 'utils/helperUtils';
+
+type ValidLabelNumbers = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11;
+
+const isValidLabelNumber = (
+  labelNumber: number
+): labelNumber is ValidLabelNumbers => labelNumber >= 1 && labelNumber <= 11;
+
+export const getLinearScaleLabel = (
+  question: IFlatCustomField,
+  labelNumber: number
+) => {
+  if (!isValidLabelNumber(labelNumber)) return undefined;
+  const label = get(question, `linear_scale_label_${labelNumber}_multiloc`);
+  return label;
+};
diff --git a/front/app/components/CustomFieldsForm/Fields/MatrixField/Matrix.tsx b/front/app/components/CustomFieldsForm/Fields/MatrixField/Matrix.tsx
new file mode 100644
index 000000000000..d0613ec3c8f8
--- /dev/null
+++ b/front/app/components/CustomFieldsForm/Fields/MatrixField/Matrix.tsx
@@ -0,0 +1,260 @@
+import React, { useRef, useEffect, useCallback } from 'react';
+
+import {
+  Box,
+  Button,
+  Radio,
+  Table,
+  Tbody,
+  Td,
+  Text,
+  Th,
+  Thead,
+  Tr,
+} from '@citizenlab/cl2-component-library';
+import { media, RGBAtoRGB } from 'component-library/utils/styleUtils';
+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 { ScreenReaderOnly } from 'utils/a11y';
+import { useIntl } from 'utils/cl-intl';
+
+import { getLinearScaleLabel } from '../LinearScale/utils';
+
+const StickyTh = styled(Th)`
+  background: ${(props) =>
+    RGBAtoRGB(props.theme.colors.tenantPrimaryLighten95, 0.05)};
+
+  position: sticky;
+  inset-inline-start: 0px;
+  z-index: 1;
+  flex-grow: 1;
+
+  ${media.phone`
+    min-width: 120px;
+    `}
+
+  ${media.tablet`
+    min-width: 180px;
+    `}
+
+  ${media.desktop`
+      min-width: 200px;
+    `}
+`;
+
+const StyledTd = styled(Td)`
+  background: ${(props) =>
+    RGBAtoRGB(props.theme.colors.tenantPrimaryLighten95, 0.05)};
+  max-width: 100px;
+
+  .circle {
+    margin-right: 0px;
+    border: 1px solid ${(props) => props.theme.colors.tenantPrimary};
+  }
+`;
+
+interface Props {
+  value?: Record<string, number>;
+  question: IFlatCustomField;
+  onChange: (value?: Record<string, number>) => void;
+}
+
+const Matrix = ({ value: data, question, onChange }: Props) => {
+  const theme = useTheme();
+  const { formatMessage } = useIntl();
+  const localize = useLocalize();
+
+  const statements = question.matrix_statements;
+  const tableDivRef = useRef<HTMLDivElement>(null); // Used to apply border styling on scroll
+
+  const id = question.key;
+
+  // Determine maximum number of columns in the table
+  const maxColumns = question.maximum ?? 11;
+
+  // Put all linear scale labels from the UI Schema in an array so we can easily use them
+  const columnsFromSchema = Array.from({ length: maxColumns }, (_, index) => {
+    // Use number value (index + 1) if no text label is set
+    const number = index + 1;
+    const labelMultiloc = getLinearScaleLabel(question, number);
+    if (!labelMultiloc) return number.toString();
+
+    return localize(labelMultiloc);
+  }).filter((label) => label !== '');
+
+  // Add scroll event to check whether the table should have a dashed
+  // border which indicates it can be horizontally scrolled
+  useEffect(() => {
+    const checkApplyBorder = () => {
+      const tableElement = tableDivRef.current;
+      if (tableElement) {
+        if (
+          tableElement.scrollLeft + 4 >= // 4 is used as a small offset to make sure it "catches" correctly
+          tableElement.scrollWidth - tableElement.clientWidth
+        ) {
+          tableElement.style.borderRight = 'none';
+        } else {
+          tableElement.style.borderRight = `1px dashed ${theme.colors.tenantPrimaryLighten75}`;
+        }
+      }
+    };
+
+    checkApplyBorder();
+
+    tableDivRef.current?.addEventListener('scroll', () => {
+      checkApplyBorder();
+    });
+  }, [theme.colors.tenantPrimaryLighten75]);
+
+  const getAriaValueText = useCallback(
+    (value: number, total: number) => {
+      // If the value has a label, read it out
+      const label = getLinearScaleLabel(question, value);
+
+      if (label) {
+        return formatMessage(messages.valueOutOfTotalWithLabel, {
+          value,
+          total,
+          label: localize(label),
+        });
+      }
+
+      // If we don't have a label but we do have a maximum, read out the current value & maximum label
+      const maxLabel = getLinearScaleLabel(question, maxColumns);
+      if (maxLabel) {
+        return formatMessage(messages.valueOutOfTotalWithMaxExplanation, {
+          value,
+          total,
+          maxValue: maxColumns,
+          maxLabel: localize(maxLabel),
+        });
+      }
+      // Otherwise, just read out the value and the maximum value
+      return formatMessage(messages.valueOutOfTotal, { value, total });
+    },
+    [maxColumns, question, formatMessage, localize]
+  );
+
+  if (!statements) return null;
+
+  return (
+    <>
+      <Box overflowX="auto" ref={tableDivRef} id="e2e-matrix-control">
+        <Table
+          width={'100%'}
+          style={{ borderCollapse: 'separate', borderSpacing: '0px 8px' }}
+          aria-labelledby={`matrix-question-label-${id}`}
+        >
+          <Thead>
+            <Td minWidth="84px" pt="0px" />
+            {columnsFromSchema.map((column, index) => {
+              return (
+                <Th minWidth="84px" key={index} scope="col" pt="0px">
+                  <Box title={column} display="flex" justifyContent="center">
+                    <Text
+                      textAlign="center"
+                      m="0px"
+                      p="0px"
+                      mx="auto"
+                      color={'tenantPrimary'}
+                    >
+                      {column}
+                    </Text>
+                  </Box>
+                </Th>
+              );
+            })}
+          </Thead>
+          <Tbody>
+            {statements.map((statement, index) => {
+              return (
+                <Tr key={index}>
+                  <StickyTh scope="row">
+                    <Text m="4px" color="tenantPrimary">
+                      {localize(statement.title_multiloc)}
+                    </Text>
+                  </StickyTh>
+
+                  {columnsFromSchema.map((_, columnIndex) => {
+                    return (
+                      <StyledTd key={`radio-${columnIndex}-${index}`}>
+                        <Box
+                          min-width="84px"
+                          display="flex"
+                          justifyContent="center"
+                        >
+                          <Radio
+                            mx="auto"
+                            onKeyDown={(e) => {
+                              if (e.key === 'Enter') {
+                                e.preventDefault();
+                                onChange({
+                                  ...data,
+                                  [statement.key]: columnIndex + 1,
+                                });
+                              }
+                            }}
+                            currentValue={
+                              data ? data[statement.key] - 1 : undefined
+                            }
+                            value={columnIndex}
+                            label={
+                              <ScreenReaderOnly>
+                                {getAriaValueText(
+                                  columnIndex + 1,
+                                  columnsFromSchema.length
+                                )}
+                                {localize(statements[index].title_multiloc)}
+                              </ScreenReaderOnly>
+                            }
+                            name={`radio-group-${statement.key}-${id}`}
+                            id={`${id}-${index}-${columnIndex}-radio`}
+                            onChange={(value) => {
+                              onChange({
+                                ...data,
+                                [statement.key]: value + 1,
+                              });
+                            }}
+                          />
+                        </Box>
+                      </StyledTd>
+                    );
+                  })}
+                </Tr>
+              );
+            })}
+          </Tbody>
+        </Table>
+      </Box>
+      {data !== undefined && (
+        <Box display="flex">
+          <Button
+            p="0px"
+            buttonStyle="text"
+            textColor={theme.colors.tenantPrimary}
+            textDecoration="underline"
+            text={
+              <>
+                <ScreenReaderOnly>
+                  {formatMessage(messages.clearAllScreenreader)}
+                </ScreenReaderOnly>
+                <Box aria-hidden>{formatMessage(messages.clearAll)}</Box>
+              </>
+            }
+            onClick={() => {
+              onChange(undefined);
+            }}
+          />
+        </Box>
+      )}
+    </>
+  );
+};
+
+export default Matrix;
diff --git a/front/app/components/CustomFieldsForm/Fields/MatrixField/index.tsx b/front/app/components/CustomFieldsForm/Fields/MatrixField/index.tsx
new file mode 100644
index 000000000000..b030479ab08b
--- /dev/null
+++ b/front/app/components/CustomFieldsForm/Fields/MatrixField/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 Matrix from './Matrix';
+import Error, { TFieldName } from 'components/UI/Error';
+import { CLError, RHFErrors } from 'typings';
+
+interface Props {
+  question: IFlatCustomField;
+}
+
+const MatrixField = ({ 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 <Matrix 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 MatrixField;
diff --git a/front/app/components/CustomFieldsForm/generateYupSchema.ts b/front/app/components/CustomFieldsForm/generateYupSchema.ts
index 23fd92f4919e..35f34d0580d6 100644
--- a/front/app/components/CustomFieldsForm/generateYupSchema.ts
+++ b/front/app/components/CustomFieldsForm/generateYupSchema.ts
@@ -5,6 +5,8 @@ import { IFlatCustomField } from 'api/custom_fields/types';
 
 import { Localize } from 'hooks/useLocalize';
 
+import legacyMessages from 'components/Form/Components/Controls/messages';
+
 import validateAtLeastOneLocale from 'utils/yup/validateAtLeastOneLocale';
 
 import messages from './messages';
@@ -222,6 +224,34 @@ const generateYupValidationSchema = ({
             : number();
         break;
       }
+
+      case 'matrix_linear_scale': {
+        // type guards, numberOfStatements and numberOfColumns should always be defined
+        const numberOfStatements = question.matrix_statements?.length ?? 0;
+        const numberOfColumns = question.maximum ?? 11;
+
+        schema[key] = required
+          ? object().test(
+              formatMessage(legacyMessages.allStatementsError),
+              (object) => {
+                const keys = Object.keys(object);
+                const values = Object.values(object);
+                const isValid =
+                  keys.length === numberOfStatements &&
+                  values.every((value) => {
+                    return (
+                      typeof value === 'number' &&
+                      value >= 1 &&
+                      value <= numberOfColumns
+                    );
+                  });
+
+                return isValid;
+              }
+            )
+          : object().nullable();
+        break;
+      }
     }
   });
 
diff --git a/front/app/components/FormBuilder/components/FormFields/FormField/index.tsx b/front/app/components/FormBuilder/components/FormFields/FormField/index.tsx
index fe7e20e3220b..c49feed65d19 100644
--- a/front/app/components/FormBuilder/components/FormFields/FormField/index.tsx
+++ b/front/app/components/FormBuilder/components/FormFields/FormField/index.tsx
@@ -15,10 +15,10 @@ import { useFormContext, useFieldArray } from 'react-hook-form';
 import styled from 'styled-components';
 
 import {
+  ICreateMatrixStatementsType,
   ICustomFieldSettingsTab,
   IFlatCustomField,
   IFlatCustomFieldWithIndex,
-  IMatrixStatementsType,
   IOptionsType,
 } from 'api/custom_fields/types';
 import useDuplicateMapConfig from 'api/map_config/useDuplicateMapConfig';
@@ -157,10 +157,10 @@ export const FormField = ({
       );
     }
 
-    let duplicatedStatements: IMatrixStatementsType[] = [];
+    let duplicatedStatements: ICreateMatrixStatementsType[] = [];
     if (matrix_statements) {
       duplicatedStatements = matrix_statements.map(
-        ({ id: _statementId, temp_id: _optionalTempId, ...rest }) => ({
+        ({ id: _statementId, ...rest }) => ({
           temp_id: generateTempId(),
           ...rest,
         })
diff --git a/front/app/containers/Admin/projects/project/nativeSurvey/utils.test.ts b/front/app/containers/Admin/projects/project/nativeSurvey/utils.test.ts
index 1ca94452fcd0..a506345cd66e 100644
--- a/front/app/containers/Admin/projects/project/nativeSurvey/utils.test.ts
+++ b/front/app/containers/Admin/projects/project/nativeSurvey/utils.test.ts
@@ -47,12 +47,14 @@ describe('clearOptionIds', () => {
       matrix_statements: [
         {
           id: '96e275e5b8194c7b85ac615665930d22',
+          key: 'key1',
           title_multiloc: {
             en: 'Statement 1',
           },
         },
         {
           id: '1f911f35352d4e2082b30b8afb92d081',
+          key: 'key2',
           title_multiloc: {
             en: 'Statement 2',
           },
diff --git a/front/app/containers/Admin/projects/project/nativeSurvey/utils.tsx b/front/app/containers/Admin/projects/project/nativeSurvey/utils.tsx
index 815c1d2f1bbc..c5218e72fc80 100644
--- a/front/app/containers/Admin/projects/project/nativeSurvey/utils.tsx
+++ b/front/app/containers/Admin/projects/project/nativeSurvey/utils.tsx
@@ -2,7 +2,11 @@ import React from 'react';
 
 import { Box } from '@citizenlab/cl2-component-library';
 
-import { IFlatCustomField, IOptionsType } from 'api/custom_fields/types';
+import {
+  ICreateMatrixStatementsType,
+  IFlatCustomField,
+  IOptionsType,
+} from 'api/custom_fields/types';
 
 import UserFieldsInFormNotice from 'containers/Admin/projects/project/nativeSurvey/UserFieldsInFormNotice';
 
@@ -85,7 +89,7 @@ export const clearOptionAndStatementIds = (
   customFields: IFlatCustomField[]
 ) => {
   return customFields.map((field: IFlatCustomField) => {
-    const newField = { ...field };
+    const newField: IFlatCustomField = { ...field };
 
     if (field.options && field.options.length > 0) {
       newField.options = field.options.map((option: IOptionsType) => {
@@ -95,10 +99,15 @@ export const clearOptionAndStatementIds = (
     }
 
     if (field.matrix_statements && field.matrix_statements.length > 0) {
-      newField.matrix_statements = field.matrix_statements.map((statement) => {
-        const { id: _id, ...statementWithoutId } = statement;
-        return { ...statementWithoutId };
-      });
+      const clearedStatements: ICreateMatrixStatementsType[] =
+        field.matrix_statements.map((statement) => {
+          const { id: _id, ...statementWithoutId } = statement;
+          return { ...statementWithoutId };
+        });
+
+      // Luuc: This type situation is a total mess. We should
+      // refactor this at some point.
+      (newField as any).matrix_statements = clearedStatements;
     }
 
     return newField;


Reviews:
user: login: luucvanderzee
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T20:59:33Z
user: login: luucvanderzee
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T21:02:34Z
user: login: IvaKop
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T08:07:52Z
user: login: IvaKop
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T08:08:34Z

Review comments:
diff_hunk: @@ -157,35 +157,44 @@ export type IFlatCustomFieldWithIndex = IFlatCustomField & {
 
 type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;
 
-export type IFlatCreateCustomField = Optional<
-  IFlatCustomField,
-  | 'description_multiloc'
-  | 'type'
-  | 'key'
-  | 'options'
-  | 'matrix_statements'
-  | 'ordering'
-  | 'created_at'
-  | 'updated_at'
-  | 'linear_scale_label_1_multiloc'
-  | 'linear_scale_label_2_multiloc'
-  | 'linear_scale_label_3_multiloc'
-  | 'linear_scale_label_4_multiloc'
-  | 'linear_scale_label_5_multiloc'
-  | 'linear_scale_label_6_multiloc'
-  | 'linear_scale_label_7_multiloc'
-  | 'linear_scale_label_8_multiloc'
-  | 'linear_scale_label_9_multiloc'
-  | 'linear_scale_label_10_multiloc'
-  | 'linear_scale_label_11_multiloc'
-  | 'maximum'
-  | 'random_option_ordering'
-  | 'dropdown_layout'
-  | 'question_category'
-  | 'ask_follow_up'
-  | 'include_in_printed_form'
+export type ICreateMatrixStatementsType = {
path: front/app/api/custom_fields/types.ts
user: login: luucvanderzee
body: Throughout the code base, there are two types of "flat custom field": the ones that are coming from the `useCustomFields` hook (and thus indirectly from our DB), and the ones that exist only temporarily in the memory of the browser. They have quite some differences and should be modeled as two different types. And they kind of are: besides the `IFlatCustomField`(coming from our DB) there is the `IFlatCreateCustomField` type (temporarily in memory). But the latter type is only used in like one or two place, while it should be used in much more places because this in-memory version is also used all over the place. 

Instead, in these places, to shut typescript up, the `IFlatCustomField` was just given tons of properties which are optional. This makes it a huge pain in the ass to work with.

I have tried to improve the situation a bit, at least for the matrix statements, but this rabbit hole goes way to deep for now
created_at: 2025-05-22T20:59:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -36,17 +61,15 @@ const useCustomFields = ({
         );
       });
 
-      const statementsForCustomField = statements.filter((statement) => {
-        const relationshipStatementIds =
-          customField.relationships.matrix_statements?.data.map(
-            (statement) => statement.id
-          );
-
-        return (
-          statement.data?.data.id &&
-          relationshipStatementIds?.includes(statement.data.data.id)
-        );
-      });
+      const statementsForCustomField =
+        statementsByCustomFieldId?.[customField.id] ?? [];
+      const convertedStatements: IMatrixStatementsType[] =
+        statementsForCustomField.map((statement) => ({
+          id: statement.id,
+          key: statement.attributes.key,
+          title_multiloc: statement.attributes.title_multiloc,
+          temp_id: statement.attributes.temp_id,
+        }));
path: front/app/api/custom_fields/useCustomFields.ts
user: login: luucvanderzee
body: Refactored this to make it more understandable and to have proper type information
created_at: 2025-05-22T21:02:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -157,35 +157,44 @@ export type IFlatCustomFieldWithIndex = IFlatCustomField & {
 
 type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;
 
-export type IFlatCreateCustomField = Optional<
-  IFlatCustomField,
-  | 'description_multiloc'
-  | 'type'
-  | 'key'
-  | 'options'
-  | 'matrix_statements'
-  | 'ordering'
-  | 'created_at'
-  | 'updated_at'
-  | 'linear_scale_label_1_multiloc'
-  | 'linear_scale_label_2_multiloc'
-  | 'linear_scale_label_3_multiloc'
-  | 'linear_scale_label_4_multiloc'
-  | 'linear_scale_label_5_multiloc'
-  | 'linear_scale_label_6_multiloc'
-  | 'linear_scale_label_7_multiloc'
-  | 'linear_scale_label_8_multiloc'
-  | 'linear_scale_label_9_multiloc'
-  | 'linear_scale_label_10_multiloc'
-  | 'linear_scale_label_11_multiloc'
-  | 'maximum'
-  | 'random_option_ordering'
-  | 'dropdown_layout'
-  | 'question_category'
-  | 'ask_follow_up'
-  | 'include_in_printed_form'
+export type ICreateMatrixStatementsType = {
path: front/app/api/custom_fields/types.ts
user: login: IvaKop
body: This does seem a bit messy. Not super aware of the details of how this came to be but it does look a bit better. As long as it works with the new setup, we can refactor down the line so best to not go too deep with it 👍 Agree with your approach.
created_at: 2025-05-23T08:07:52Z
author_association: CONTRIBUTOR

PR comments:
user: login: notion-workspace[bot]
created_at: 2025-05-21T22:21:19Z
body: <a href="https://www.notion.so/Add-matrix-field-1fa9663b7b26801d9b24d34e64d445d7?pvs=8&n=github_linkback">Add matrix field</a>
author_association: NONE
user: login: cl-dev-bot
created_at: 2025-05-22T20:56:45Z
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-4665">TAN-4665</a></td>
    </tr>
  
<tr>
      <td>:book:</td>
      <td>

  <a href="https://epic.citizenlab.co/ci-e2e?citizenlab_branch=TAN-4665-matrix-field">Run the e2e tests</a>
  </td>
    </tr>
  
<tr>
      <td>:book:</td>
      <td><a href="https://epic.citizenlab.co/translations/TAN-4665-matrix-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 067d5fbc2fb678ed99a3bb074e6b34cf81242180
</p>

author_association: COLLABORATOR