Compare commits

...

3 Commits

Author SHA1 Message Date
Dhruwang
c9b116847a chore: retrigger CI checks
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 15:02:37 +05:30
Dhruwang
b3ba11b611 fix: address review feedback for choice rotation (#6908)
- Fix inconsistent falsy check: use `if (lastElement !== undefined)` in
  getShuffledChoicesIds to match getShuffledRowIndices pattern
- Add reverse transition in deleteChoice: reverseOrderExceptLast -> reverseOrder
  and exceptLast -> all when removing the last special choice
- Add proper translations for all 13 non-English locales

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 13:00:51 +05:30
Dhruwang
36112d89d1 feat: add reverseOrder and reverseOrderExceptLast shuffle options (#6908)
Implements "Choice Rotation" feature for Likert scales and survey choices.
Adds two new shuffle/randomization options that give a 50% chance to reverse
the displayed order, helping reduce response-order bias.

- Extend ZShuffleOption enum in both types packages with the two new values
- Handle new options in getShuffledRowIndices and getShuffledChoicesIds using
  getSecureRandom() for the coin flip
- Add new options to shuffleOptionsTypes in multiple-choice, ranking, and
  matrix editor forms (reverseOrder hidden when "other"/"none" choice present,
  consistent with the existing "all" option)
- Auto-downgrade reverseOrder → reverseOrderExceptLast when a special choice
  is added (mirrors the existing all → exceptLast behavior)
- Update ShuffleOptionsTypes interface in ShuffleOptionSelect component
- Add i18n keys for all 14 supported locales (English text as fallback for
  non-English locales pending translation)
- Add comprehensive unit tests for both new options in utils.test.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 12:56:31 +05:30
22 changed files with 206 additions and 2 deletions

View File

@@ -1674,6 +1674,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "Antwortlimit muss die Anzahl der erhaltenen Antworten ({responseCount}) überschreiten.",
"response_limits_redirections_and_more": "Antwort Limits, Weiterleitungen und mehr.",
"response_options": "Antwortoptionen",
"reverse_order_occasionally": "Reihenfolge gelegentlich umkehren",
"reverse_order_occasionally_except_last": "Reihenfolge gelegentlich umkehren, außer letzter",
"roundness": "Rundheit",
"roundness_description": "Steuert, wie abgerundet die Ecken sind.",
"row_used_in_logic_error": "Diese Zeile wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne sie zuerst aus der Logik.",

View File

@@ -1674,6 +1674,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "Response limit needs to exceed number of received responses ({responseCount}).",
"response_limits_redirections_and_more": "Response limits, redirections and more.",
"response_options": "Response Options",
"reverse_order_occasionally": "Reverse order occasionally",
"reverse_order_occasionally_except_last": "Reverse order occasionally except last",
"roundness": "Roundness",
"roundness_description": "Controls how rounded corners are.",
"row_used_in_logic_error": "This row is used in logic of question {questionIndex}. Please remove it from logic first.",

View File

@@ -1674,6 +1674,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "El límite de respuestas debe superar el número de respuestas recibidas ({responseCount}).",
"response_limits_redirections_and_more": "Límites de respuestas, redirecciones y más.",
"response_options": "Opciones de respuesta",
"reverse_order_occasionally": "Invertir orden ocasionalmente",
"reverse_order_occasionally_except_last": "Invertir orden ocasionalmente excepto el último",
"roundness": "Redondez",
"roundness_description": "Controla qué tan redondeadas están las esquinas.",
"row_used_in_logic_error": "Esta fila se utiliza en la lógica de la pregunta {questionIndex}. Por favor, elimínala de la lógica primero.",

View File

@@ -1674,6 +1674,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "La limite de réponses doit dépasser le nombre de réponses reçues ({responseCount}).",
"response_limits_redirections_and_more": "Limites de réponse, redirections et plus.",
"response_options": "Options de réponse",
"reverse_order_occasionally": "Inverser l'ordre occasionnellement",
"reverse_order_occasionally_except_last": "Inverser l'ordre occasionnellement sauf le dernier",
"roundness": "Rondeur",
"roundness_description": "Contrôle l'arrondi des coins.",
"row_used_in_logic_error": "Cette ligne est utilisée dans la logique de la question {questionIndex}. Veuillez d'abord la supprimer de la logique.",

View File

@@ -1674,6 +1674,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "A válaszkorlátnak meg kell haladnia a kapott válaszok számát ({responseCount}).",
"response_limits_redirections_and_more": "Válaszkorlátok, átirányítások és egyebek.",
"response_options": "Válasz beállításai",
"reverse_order_occasionally": "Sorrend alkalmi megfordítása",
"reverse_order_occasionally_except_last": "Sorrend alkalmi megfordítása az utolsó kivételével",
"roundness": "Kerekesség",
"roundness_description": "Annak vezérlése, hogy a sarkok mennyire legyenek lekerekítve.",
"row_used_in_logic_error": "Ez a sor használatban van a(z) {questionIndex}. kérdés logikájában. Először távolítsa el a logikából.",

View File

@@ -1674,6 +1674,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "回答数の上限は、受信済みの回答数 ({responseCount}) を超える必要があります。",
"response_limits_redirections_and_more": "回答数の上限、リダイレクトなど。",
"response_options": "回答オプション",
"reverse_order_occasionally": "順序をランダムに逆転",
"reverse_order_occasionally_except_last": "最後以外の順序をランダムに逆転",
"roundness": "丸み",
"roundness_description": "角の丸みを調整します。",
"row_used_in_logic_error": "この行は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",

View File

@@ -1674,6 +1674,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "De responslimiet moet groter zijn dan het aantal ontvangen reacties ({responseCount}).",
"response_limits_redirections_and_more": "Reactielimieten, omleidingen en meer.",
"response_options": "Reactieopties",
"reverse_order_occasionally": "Volgorde af en toe omkeren",
"reverse_order_occasionally_except_last": "Volgorde af en toe omkeren behalve laatste",
"roundness": "Rondheid",
"roundness_description": "Bepaalt hoe afgerond de hoeken zijn.",
"row_used_in_logic_error": "Deze rij wordt gebruikt in de logica van vraag {questionIndex}. Verwijder het eerst uit de logica.",

View File

@@ -1674,6 +1674,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "O limite de respostas precisa exceder o número de respostas recebidas ({responseCount}).",
"response_limits_redirections_and_more": "Limites de resposta, redirecionamentos e mais.",
"response_options": "Opções de Resposta",
"reverse_order_occasionally": "Inverter ordem ocasionalmente",
"reverse_order_occasionally_except_last": "Inverter ordem ocasionalmente exceto o último",
"roundness": "Circularidade",
"roundness_description": "Controla o arredondamento dos cantos.",
"row_used_in_logic_error": "Esta linha é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",

View File

@@ -1674,6 +1674,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "O limite de respostas precisa exceder o número de respostas recebidas ({responseCount}).",
"response_limits_redirections_and_more": "Limites de resposta, redirecionamentos e mais.",
"response_options": "Opções de Resposta",
"reverse_order_occasionally": "Inverter ordem ocasionalmente",
"reverse_order_occasionally_except_last": "Inverter ordem ocasionalmente exceto o último",
"roundness": "Arredondamento",
"roundness_description": "Controla o arredondamento dos cantos.",
"row_used_in_logic_error": "Esta linha é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",

View File

@@ -1674,6 +1674,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "Limita răspunsurilor trebuie să depășească numărul de răspunsuri primite ({responseCount}).",
"response_limits_redirections_and_more": "Limite de răspunsuri, redirecționări și altele.",
"response_options": "Opțiuni răspuns",
"reverse_order_occasionally": "Inversare ordine ocazional",
"reverse_order_occasionally_except_last": "Inversare ordine ocazional cu excepția ultimului",
"roundness": "Rotunjire",
"roundness_description": "Controlează cât de rotunjite sunt colțurile.",
"row_used_in_logic_error": "Această linie este folosită în logica întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",

View File

@@ -1674,6 +1674,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "Лимит ответов должен превышать количество полученных ответов ({responseCount}).",
"response_limits_redirections_and_more": "Лимиты ответов, перенаправления и другое.",
"response_options": "Параметры ответа",
"reverse_order_occasionally": "Иногда обращать порядок",
"reverse_order_occasionally_except_last": "Иногда обращать порядок кроме последнего",
"roundness": "Скругление",
"roundness_description": "Определяет степень скругления углов.",
"row_used_in_logic_error": "Эта строка используется в логике вопроса {questionIndex}. Пожалуйста, сначала удалите её из логики.",

View File

@@ -1674,6 +1674,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "Svarsgränsen måste överstiga antalet mottagna svar ({responseCount}).",
"response_limits_redirections_and_more": "Svarsgränser, omdirigeringar och mer.",
"response_options": "Svarsalternativ",
"reverse_order_occasionally": "Vänd ordning ibland",
"reverse_order_occasionally_except_last": "Vänd ordning ibland utom sista",
"roundness": "Rundhet",
"roundness_description": "Styr hur rundade hörnen är.",
"row_used_in_logic_error": "Denna rad används i logiken för fråga {questionIndex}. Vänligen ta bort den från logiken först.",

View File

@@ -1674,6 +1674,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "限制 响应 需要 超过 收到 的 响应 数量 {responseCount})。",
"response_limits_redirections_and_more": "响应 限制 、 重定向 和 更多 。",
"response_options": "响应 选项",
"reverse_order_occasionally": "偶尔反转顺序",
"reverse_order_occasionally_except_last": "偶尔反转顺序(最后一项除外)",
"roundness": "圆度",
"roundness_description": "控制圆角的弧度。",
"row_used_in_logic_error": "\"这个 行 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",

View File

@@ -1674,6 +1674,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "回應限制必須超過收到的回應數 ('{'responseCount'}')。",
"response_limits_redirections_and_more": "回應限制、重新導向等。",
"response_options": "回應選項",
"reverse_order_occasionally": "偶爾反轉順序",
"reverse_order_occasionally_except_last": "偶爾反轉順序(最後一項除外)",
"roundness": "圓角",
"roundness_description": "調整邊角的圓潤程度。",
"row_used_in_logic_error": "此 row 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",

View File

@@ -188,6 +188,16 @@ export const MatrixElementForm = ({
label: t("environments.surveys.edit.randomize_all_except_last"),
show: true,
},
reverseOrder: {
id: "reverseOrder",
label: t("environments.surveys.edit.reverse_order_occasionally"),
show: true,
},
reverseOrderExceptLast: {
id: "reverseOrderExceptLast",
label: t("environments.surveys.edit.reverse_order_occasionally_except_last"),
show: true,
},
};
const [parent] = useAutoAnimate();

View File

@@ -79,6 +79,16 @@ export const MultipleChoiceElementForm = ({
label: t("environments.surveys.edit.randomize_all_except_last"),
show: true,
},
reverseOrder: {
id: "reverseOrder",
label: t("environments.surveys.edit.reverse_order_occasionally"),
show: element.choices.every((c) => c.id !== "other" && c.id !== "none"),
},
reverseOrderExceptLast: {
id: "reverseOrderExceptLast",
label: t("environments.surveys.edit.reverse_order_occasionally_except_last"),
show: true,
},
};
const multipleChoiceOptionDisplayTypeOptions = [
@@ -169,6 +179,9 @@ export const MultipleChoiceElementForm = ({
...(element.shuffleOption === shuffleOptionsTypes.all.id && {
shuffleOption: shuffleOptionsTypes.exceptLast.id as TShuffleOption,
}),
...(element.shuffleOption === shuffleOptionsTypes.reverseOrder.id && {
shuffleOption: shuffleOptionsTypes.reverseOrderExceptLast.id as TShuffleOption,
}),
});
};
@@ -193,8 +206,18 @@ export const MultipleChoiceElementForm = ({
setisInvalidValue(null);
}
const hasRemainingSpecialChoices = newChoices.some((c) => c.id === "other" || c.id === "none");
updateElement(elementIdx, {
choices: newChoices,
...(!hasRemainingSpecialChoices &&
element.shuffleOption === "reverseOrderExceptLast" && {
shuffleOption: "reverseOrder" as TShuffleOption,
}),
...(!hasRemainingSpecialChoices &&
element.shuffleOption === "exceptLast" && {
shuffleOption: "all" as TShuffleOption,
}),
});
};

View File

@@ -115,6 +115,21 @@ export const RankingElementForm = ({
label: t("environments.surveys.edit.randomize_all"),
show: element.choices.length > 0,
},
exceptLast: {
id: "exceptLast",
label: t("environments.surveys.edit.randomize_all_except_last"),
show: true,
},
reverseOrder: {
id: "reverseOrder",
label: t("environments.surveys.edit.reverse_order_occasionally"),
show: true,
},
reverseOrderExceptLast: {
id: "reverseOrderExceptLast",
label: t("environments.surveys.edit.reverse_order_occasionally_except_last"),
show: true,
},
};
useEffect(() => {

View File

@@ -25,6 +25,8 @@ interface ShuffleOptionsTypes {
none?: ShuffleOptionType;
all?: ShuffleOptionType;
exceptLast?: ShuffleOptionType;
reverseOrder?: ShuffleOptionType;
reverseOrderExceptLast?: ShuffleOptionType;
}
interface ShuffleOptionSelectProps {

View File

@@ -135,6 +135,52 @@ describe("getShuffledRowIndices", () => {
expect(getShuffledRowIndices(1, "all")).toEqual([0]);
expect(getShuffledRowIndices(1, "exceptLast")).toEqual([0]);
});
test('should reverse all for "reverseOrder" when random < 0.5', () => {
// getSecureRandom returns < 0.5, so the array is reversed
setNextRandomNormalizedValue(0.3);
expect(getShuffledRowIndices(4, "reverseOrder")).toEqual([3, 2, 1, 0]);
});
test('should keep original order for "reverseOrder" when random >= 0.5', () => {
// getSecureRandom returns >= 0.5, so the array is NOT reversed
setNextRandomNormalizedValue(0.7);
expect(getShuffledRowIndices(4, "reverseOrder")).toEqual([0, 1, 2, 3]);
});
test('should preserve all elements with "reverseOrder"', () => {
setNextRandomNormalizedValue(0.3);
const result = getShuffledRowIndices(5, "reverseOrder");
expect(result).toHaveLength(5);
expect(result.sort((a, b) => a - b)).toEqual([0, 1, 2, 3, 4]);
});
test('should reverse all except last for "reverseOrderExceptLast" when random < 0.5', () => {
// getSecureRandom returns < 0.5, so the array (minus last) is reversed
setNextRandomNormalizedValue(0.3);
expect(getShuffledRowIndices(4, "reverseOrderExceptLast")).toEqual([2, 1, 0, 3]);
});
test('should keep original order for "reverseOrderExceptLast" when random >= 0.5', () => {
// getSecureRandom returns >= 0.5, so the array is NOT reversed
setNextRandomNormalizedValue(0.7);
expect(getShuffledRowIndices(4, "reverseOrderExceptLast")).toEqual([0, 1, 2, 3]);
});
test('should always keep last element in place for "reverseOrderExceptLast"', () => {
setNextRandomNormalizedValue(0.3);
const result = getShuffledRowIndices(5, "reverseOrderExceptLast");
expect(result[result.length - 1]).toBe(4);
expect(result).toHaveLength(5);
expect(result.sort((a, b) => a - b)).toEqual([0, 1, 2, 3, 4]);
});
test('should handle n=1 for "reverseOrder" and "reverseOrderExceptLast"', () => {
setNextRandomNormalizedValue(0.3);
expect(getShuffledRowIndices(1, "reverseOrder")).toEqual([0]);
setNextRandomNormalizedValue(0.3);
expect(getShuffledRowIndices(1, "reverseOrderExceptLast")).toEqual([0]);
});
});
describe("getShuffledChoicesIds", () => {
@@ -182,6 +228,56 @@ describe("getShuffledChoicesIds", () => {
expect(getShuffledChoicesIds(singleChoice, "all")).toEqual(["s1"]);
expect(getShuffledChoicesIds(singleChoice, "exceptLast")).toEqual(["s1"]);
});
test('should reverse all for "reverseOrder" when random < 0.5', () => {
setNextRandomNormalizedValue(0.3);
expect(getShuffledChoicesIds(choicesBase, "reverseOrder")).toEqual(["c3", "c2", "c1"]);
});
test('should keep original order for "reverseOrder" when random >= 0.5', () => {
setNextRandomNormalizedValue(0.7);
expect(getShuffledChoicesIds(choicesBase, "reverseOrder")).toEqual(["c1", "c2", "c3"]);
});
test('should preserve "other" at end with "reverseOrder" when reversed', () => {
setNextRandomNormalizedValue(0.3);
expect(getShuffledChoicesIds(choicesWithOther, "reverseOrder")).toEqual(["c3", "c2", "c1", "other"]);
});
test('should preserve all elements with "reverseOrder"', () => {
setNextRandomNormalizedValue(0.3);
const result = getShuffledChoicesIds(choicesBase, "reverseOrder");
expect(result).toHaveLength(3);
expect([...result].sort()).toEqual(["c1", "c2", "c3"]);
});
test('should reverse all except last for "reverseOrderExceptLast" when random < 0.5', () => {
setNextRandomNormalizedValue(0.3);
expect(getShuffledChoicesIds(choicesBase, "reverseOrderExceptLast")).toEqual(["c2", "c1", "c3"]);
});
test('should keep original order for "reverseOrderExceptLast" when random >= 0.5', () => {
setNextRandomNormalizedValue(0.7);
expect(getShuffledChoicesIds(choicesBase, "reverseOrderExceptLast")).toEqual(["c1", "c2", "c3"]);
});
test('should keep last regular choice in place with "reverseOrderExceptLast", "other" appended after', () => {
setNextRandomNormalizedValue(0.3);
expect(getShuffledChoicesIds(choicesWithOther, "reverseOrderExceptLast")).toEqual([
"c2",
"c1",
"c3",
"other",
]);
});
test('should always keep last regular element in place for "reverseOrderExceptLast"', () => {
setNextRandomNormalizedValue(0.3);
const result = getShuffledChoicesIds(choicesBase, "reverseOrderExceptLast");
expect(result[result.length - 1]).toBe("c3");
expect(result).toHaveLength(3);
expect([...result].sort()).toEqual(["c1", "c2", "c3"]);
});
});
describe("getQuestionsFromSurvey", () => {
test("should return elements from blocks", () => {

View File

@@ -40,6 +40,20 @@ export const getShuffledRowIndices = (n: number, shuffleOption: TShuffleOption):
shuffle(array);
array.push(lastElement);
}
} else if (shuffleOption === "reverseOrder") {
// 50% chance to reverse the entire array
if (getSecureRandom() < 0.5) {
array.reverse();
}
} else if (shuffleOption === "reverseOrderExceptLast") {
// 50% chance to reverse all except the last element
const lastElement = array.pop();
if (lastElement !== undefined) {
if (getSecureRandom() < 0.5) {
array.reverse();
}
array.push(lastElement);
}
}
return array;
};
@@ -67,6 +81,22 @@ export const getShuffledChoicesIds = (
shuffledChoices.push(lastElement);
}
}
if (shuffleOption === "reverseOrder") {
// 50% chance to reverse the entire list
if (getSecureRandom() < 0.5) {
shuffledChoices.reverse();
}
}
if (shuffleOption === "reverseOrderExceptLast") {
// 50% chance to reverse all except the last element
const lastElement = shuffledChoices.pop();
if (lastElement !== undefined) {
if (getSecureRandom() < 0.5) {
shuffledChoices.reverse();
}
shuffledChoices.push(lastElement);
}
}
if (otherOption) {
shuffledChoices.push(otherOption);

View File

@@ -136,7 +136,7 @@ export const ZSurveyElementChoice = z.object({
export type TSurveyElementChoice = z.infer<typeof ZSurveyElementChoice>;
export const ZShuffleOption = z.enum(["none", "all", "exceptLast"]);
export const ZShuffleOption = z.enum(["none", "all", "exceptLast", "reverseOrder", "reverseOrderExceptLast"]);
export type TShuffleOption = z.infer<typeof ZShuffleOption>;
export const ZMultipleChoiceOptionDisplayType = z.enum(["list", "dropdown"]);

View File

@@ -488,7 +488,7 @@ export const ZSurveyConsentQuestion = ZSurveyQuestionBase.extend({
*/
export type TSurveyConsentQuestion = z.infer<typeof ZSurveyConsentQuestion>;
export const ZShuffleOption = z.enum(["none", "all", "exceptLast"]);
export const ZShuffleOption = z.enum(["none", "all", "exceptLast", "reverseOrder", "reverseOrderExceptLast"]);
export type TShuffleOption = z.infer<typeof ZShuffleOption>;