diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index 03b6298aec..a86ab98a26 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -786,6 +786,8 @@ "groupVariableDescription": "Co představují štítky podotázek (např. rok, země atd.).", "categoryPickerDescription": "Název parametru, který se liší mezi podotázkami, jako je \"Možnost\", \"Rok\" nebo \"Země\"", "subquestionLabel": "Štítek podotázky", + "optionColor": "Barva", + "defaultColor": "Výchozí barva", "subquestionLabelDescription": "Štítek nebo parametr, který identifikuje tuto podotázku, jako je \"Možnost 1\", \"2033\" nebo \"Francie\"", "conditionalPairDescription": "Podmíněný pár je speciální typ skupiny otázek, který vyvolává podmíněné pravděpodobnosti. Každý podmíněný pár je mezi nadřazenou otázkou a podřazenou otázkou. Nadřazená otázka musí být binární, zatímco podřazená může být buď binární, číselná nebo datová otázka.", "isDoneError": "Nelze upravit uzavřené, vyřešené nebo odmítnuté otázky", diff --git a/front_end/messages/en.json b/front_end/messages/en.json index dd202b11f2..254c4f5400 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -1181,6 +1181,8 @@ "groupVariableDescription": "What the subquestion labels represent (e.g. year, country, etc).", "categoryPickerDescription": "A name for the parameter which varies between subquestions, like \"Option\", \"Year\" or \"Country\"", "subquestionLabel": "Subquestion label", + "optionColor": "Color", + "defaultColor": "Default color", "subquestionLabelDescription": "The label or parameter which identifies this subquestion, like \"Option 1\", \"2033\" or \"France\"", "conditionalPairDescription": "A Conditional Pair is a special type of Question Group that elicits conditional probabilities. Each Conditional Pair sits between a Parent Question and a Child Question. The Parent must be a binary question, while the child may be a single binary, numeric, or date question.", "parentQuestion": "Parent Question", diff --git a/front_end/messages/es.json b/front_end/messages/es.json index a733e47336..146905902b 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -788,6 +788,8 @@ "groupVariableDescription": "Lo que representan las etiquetas de subpreguntas (por ejemplo, año, país, etc.).", "categoryPickerDescription": "Un nombre para el parámetro que varía entre subpreguntas, como \"Opción\", \"Año\" o \"País\"", "subquestionLabel": "Etiqueta de subpregunta", + "optionColor": "Color", + "defaultColor": "Color predeterminado", "subquestionLabelDescription": "La etiqueta o parámetro que identifica esta subpregunta, como \"Opción 1\", \"2033\" o \"Francia\"", "conditionalPairDescription": "Un Par Condicional es un tipo especial de Grupo de Preguntas que solicita probabilidades condicionales. Cada Par Condicional se encuentra entre una Pregunta Principal y una Pregunta Secundaria. La Principal debe ser una pregunta binaria, mientras que la secundaria puede ser una pregunta binaria, numérica o de fecha.", "isDoneError": "No se pueden editar preguntas cerradas, resueltas o rechazadas", diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index 8e9138b1cb..b70f18de32 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -879,6 +879,8 @@ "groupVariableDescription": "O que os rótulos das subperguntas representam (por exemplo, ano, país, etc.).", "categoryPickerDescription": "Um nome para o parâmetro que varia entre as subperguntas, como \"Opção\", \"Ano\" ou \"País\"", "subquestionLabel": "Rótulo da Subpergunta", + "optionColor": "Cor", + "defaultColor": "Cor padrão", "subquestionLabelDescription": "O rótulo ou parâmetro que identifica esta subpergunta, como \"Opção 1\", \"2033\" ou \"França\"", "conditionalPairDescription": "Um Par Condicional é um tipo especial de Grupo de Perguntas que elicia probabilidades condicionais. Cada Par Condicional está entre uma Pergunta Pai e uma Pergunta Filha. O Pai deve ser uma pergunta binária, enquanto a filha pode ser uma única pergunta binária, numérica ou de data.", "isDoneError": "Não é possível editar perguntas fechadas, resolvidas ou rejeitadas", diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index da8623179d..7256b765e7 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -941,6 +941,8 @@ "groupVariableDescription": "子問題標籤代表什麼 (例如年份、國家等)。", "categoryPickerDescription": "在子問題之間變化的參數名稱,例如“選項”、“年份”或“國家”", "subquestionLabel": "子問題標籤", + "optionColor": "顏色", + "defaultColor": "預設顏色", "subquestionLabelDescription": "標籤或參數,用於識別此子問題,例如“選項 1”、“2033”或“法國”", "conditionalPairDescription": "條件對是一種特殊類型的問題組,用於引出條件概率。每個條件對位於父問題與子問題之間。父問題必須是二元問題,而子問題可以是單個二元、數字或日期問題。", "parentQuestion": "父問題", diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index dd1be60787..9783078cc1 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -777,6 +777,8 @@ "groupVariableDescription": "子問題標籤所代表的內容(例如年份、國家等)。", "categoryPickerDescription": "子問題之間變化的參數名稱,如「選項」、「年份」或「國家」", "subquestionLabel": "子問題標籤", + "optionColor": "颜色", + "defaultColor": "默认颜色", "subquestionLabelDescription": "識別此子問題的���籤或參數,如「選項1」、「2033」或「法國」", "conditionalPairDescription": "條件對是一種特殊類型的問題組,用於獲取條件概率。每個條件對位於父問題和子問題之間。父問題必須是二元問題,而子問題可以是單一二元、數值或日期問題。", "isDoneError": "無法編輯已關閉、已解決或被拒絕的問題", diff --git a/front_end/src/app/(main)/questions/components/color_picker.tsx b/front_end/src/app/(main)/questions/components/color_picker.tsx new file mode 100644 index 0000000000..aa7bc699d7 --- /dev/null +++ b/front_end/src/app/(main)/questions/components/color_picker.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react"; +import { useTranslations } from "next-intl"; +import { FC } from "react"; + +import { + getMcOptionColor, + MC_OPTION_PALETTE_KEYS, + METAC_COLORS, +} from "@/constants/colors"; +import useAppTheme from "@/hooks/use_app_theme"; +import { ThemeColor } from "@/types/theme"; +import cn from "@/utils/core/cn"; + +type Props = { + value: string | null | undefined; + onChange: (key: string | null) => void; + fallback?: ThemeColor; +}; + +const ColorPicker: FC = ({ value, onChange, fallback }) => { + const t = useTranslations(); + const { getThemeColor } = useAppTheme(); + + const currentColor = + getMcOptionColor(value) ?? fallback ?? METAC_COLORS.gray["400"]; + + return ( + + + + {({ close }) => ( + <> + {MC_OPTION_PALETTE_KEYS.map((key) => { + const color = getMcOptionColor(key); + if (!color) return null; + + return ( + + + )} + + + ); +}; + +export default ColorPicker; diff --git a/front_end/src/app/(main)/questions/components/group_form.tsx b/front_end/src/app/(main)/questions/components/group_form.tsx index fd498afb67..c5b0fad62c 100644 --- a/front_end/src/app/(main)/questions/components/group_form.tsx +++ b/front_end/src/app/(main)/questions/components/group_form.tsx @@ -32,7 +32,9 @@ import { InputContainer } from "@/components/ui/input_container"; import LoadingIndicator from "@/components/ui/loading_indicator"; import { MarkdownText } from "@/components/ui/markdown_text"; import Select from "@/components/ui/select"; +import { MULTIPLE_CHOICE_COLOR_SCALE } from "@/constants/colors"; import { ContinuousQuestionTypes } from "@/constants/questions"; +import { useAuth } from "@/contexts/auth_context"; import { useDebouncedCallback } from "@/hooks/use_debounce"; import { GroupOfQuestionsGraphType, @@ -66,6 +68,7 @@ import { sortGroupPredictionOptions } from "@/utils/questions/groupOrdering"; import BacktoCreate from "./back_to_create"; import CategoryPicker from "./category_picker"; +import ColorPicker from "./color_picker"; import NumericQuestionInput from "./numeric_question_input"; import { createQuestionPost, updatePost } from "../actions"; @@ -73,6 +76,7 @@ type SubQuestionDraftBase = { id?: number; clientId: string; label: string; + label_color?: string | null; scheduled_close_time?: string | undefined; scheduled_resolve_time?: string | undefined; open_time?: string | undefined; @@ -96,6 +100,7 @@ type CreateSubQuestionPayload = { type: SimpleQuestionType; title: string; label: string; + label_color?: string | null; scheduled_close_time: string | undefined; scheduled_resolve_time?: string | undefined; open_time: string | undefined; @@ -209,6 +214,7 @@ const GroupForm: React.FC = ({ }) => { const router = useRouter(); const t = useTranslations(); + const { user } = useAuth(); const isDuplicate = mode === "create" && !!post; const isDraftMounted = useRef(false); const draftKey = `group_${subtype}`; @@ -259,6 +265,7 @@ const GroupForm: React.FC = ({ type: sqType, title: `${data["title"]} (${x.label})`, label: x.label, + label_color: x.label_color ?? null, scheduled_close_time: x.scheduled_close_time, scheduled_resolve_time: x.scheduled_resolve_time, open_time: x.open_time, @@ -395,6 +402,7 @@ const GroupForm: React.FC = ({ open_time: isDuplicate ? undefined : x.open_time, cp_reveal_time: isDuplicate ? undefined : x.cp_reveal_time, label: x.label, + label_color: x.label_color, unit: x.unit, scaling: x.scaling, open_lower_bound: x.open_lower_bound, @@ -789,20 +797,38 @@ const GroupForm: React.FC = ({ : undefined } > - { - setSubQuestions( - subQuestions.map((subQuestion, iter_index) => { - if (index === iter_index) { - subQuestion["label"] = e.target.value; - } - return subQuestion; - }) - ); - }} - className="rounded border border-gray-500 px-3 py-2 text-base dark:border-gray-500-dark dark:bg-blue-50-dark" - value={subQuestion?.label} - /> +
+ { + setSubQuestions( + subQuestions.map((subQuestion, iter_index) => { + if (index === iter_index) { + subQuestion["label"] = e.target.value; + } + return subQuestion; + }) + ); + }} + className="w-full rounded border border-gray-500 px-3 py-2 text-base dark:border-gray-500-dark dark:bg-blue-50-dark" + value={subQuestion?.label} + /> + {user?.is_superuser && ( + { + setSubQuestions( + subQuestions.map((subQuestion, iter_index) => { + if (index === iter_index) { + subQuestion["label_color"] = key; + } + return subQuestion; + }) + ); + }} + /> + )} +
{collapsedSubQuestions[index] && (
diff --git a/front_end/src/app/(main)/questions/components/question_form.tsx b/front_end/src/app/(main)/questions/components/question_form.tsx index 2b88d06368..7d11b90bb2 100644 --- a/front_end/src/app/(main)/questions/components/question_form.tsx +++ b/front_end/src/app/(main)/questions/components/question_form.tsx @@ -31,7 +31,9 @@ import LoadingIndicator from "@/components/ui/loading_indicator"; import { MarkdownText } from "@/components/ui/markdown_text"; import SectionToggle from "@/components/ui/section_toggle"; import Select from "@/components/ui/select"; +import { MULTIPLE_CHOICE_COLOR_SCALE } from "@/constants/colors"; import { ContinuousQuestionTypes } from "@/constants/questions"; +import { useAuth } from "@/contexts/auth_context"; import { useDebouncedCallback } from "@/hooks/use_debounce"; import { ErrorResponse } from "@/types/fetch"; import { Post, PostStatus, PostWithForecasts } from "@/types/post"; @@ -61,6 +63,7 @@ import { getQuestionStatus } from "@/utils/questions/helpers"; import { createQuestionPost, updatePost } from "../actions"; import BacktoCreate from "./back_to_create"; import CategoryPicker from "./category_picker"; +import ColorPicker from "./color_picker"; import NumericQuestionInput from "./numeric_question_input"; // Extended interface to include additional fields being used @@ -277,6 +280,7 @@ const createQuestionSchemas = ( }) ), options_order: z.nativeEnum(MultipleChoiceOptionsOrder).optional(), + options_colors: z.record(z.string()).optional(), }) ); @@ -316,6 +320,7 @@ const QuestionForm: FC = ({ }) => { const router = useRouter(); const t = useTranslations(); + const { user } = useAuth(); const { isDone, hasForecasts } = getQuestionStatus(post); const optionsLocked = hasForecasts && mode !== "create"; const [isLoading, setIsLoading] = useState(); @@ -377,6 +382,11 @@ const QuestionForm: FC = ({ ...(data as MultipleChoiceQuestionType), type: QuestionType.MultipleChoice, options: optionsList.map((o) => o.trim()), + options_colors: Object.fromEntries( + optionsList + .map((o, i) => [o.trim(), optionColors[i]] as const) + .filter(([, c]) => !!c) + ), } as MultipleChoiceQuestionType) : ({ ...data, @@ -425,6 +435,13 @@ const QuestionForm: FC = ({ ? post.question.options : Array(MIN_OPTIONS_AMOUNT).fill("") ); + const [optionColors, setOptionColors] = useState<(string | null)[]>( + post?.question?.options + ? post.question.options.map( + (o) => post?.question?.options_colors?.[o] ?? null + ) + : Array(MIN_OPTIONS_AMOUNT).fill(null) + ); const [categoriesList, setCategoriesList] = useState( post?.projects?.category ?? ([] as Category[]) @@ -501,11 +518,23 @@ const QuestionForm: FC = ({ saveQuestionDraft(questionType, { ...formData, options: optionsList, + options_colors: Object.fromEntries( + optionsList + .map((o, i) => [o, optionColors[i]] as const) + .filter(([, c]) => !!c) + ), categories: categoriesList, type: formData.type as unknown as QuestionType, } as Partial); } - }, [form, shouldUseDraftValue, questionType, optionsList, categoriesList]); + }, [ + form, + shouldUseDraftValue, + questionType, + optionsList, + optionColors, + categoriesList, + ]); const debouncedHandleFormChange = useDebouncedCallback( handleFormChange, @@ -606,6 +635,11 @@ const QuestionForm: FC = ({ const draft = getQuestionDraft(questionType); if (draft) { setOptionsList(draft.options ?? Array(MIN_OPTIONS_AMOUNT).fill("")); // MC questions + setOptionColors( + draft.options + ? draft.options.map((o) => draft.options_colors?.[o] ?? null) + : Array(MIN_OPTIONS_AMOUNT).fill(null) + ); setCategoriesList(draft.categories ?? []); setCurrentProject( !isNil(draft.default_project) && @@ -636,6 +670,13 @@ const QuestionForm: FC = ({ return () => subscription.unsubscribe(); }, [form, shouldUseDraftValue, debouncedHandleFormChange]); + // update draft when option colors change + useEffect(() => { + if (shouldUseDraftValue && isDraftMounted.current) { + debouncedHandleFormChange(); + } + }, [shouldUseDraftValue, debouncedHandleFormChange, optionColors]); + return (
= ({ } />
+ {user?.is_superuser && ( +
+ { + setOptionColors((prev) => + prev.map((c, index) => + index === opt_index ? key : c + ) + ); + }} + /> +
+ )} {opt_index >= MIN_OPTIONS_AMOUNT && !optionsLocked && ( diff --git a/front_end/src/constants/colors.ts b/front_end/src/constants/colors.ts index 18c67297ef..f4926f142c 100644 --- a/front_end/src/constants/colors.ts +++ b/front_end/src/constants/colors.ts @@ -1,3 +1,5 @@ +import { ThemeColor } from "@/types/theme"; + export const METAC_COLORS = { gray: { 0: { DEFAULT: "#ffffff", dark: "#262f38" }, @@ -202,3 +204,10 @@ export const METAC_COLORS = { export const MULTIPLE_CHOICE_COLOR_SCALE = Object.values( METAC_COLORS["mc-option"] ).map((value) => value); + +export const MC_OPTION_PALETTE_KEYS = Object.keys(METAC_COLORS["mc-option"]); + +export function getMcOptionColor(key?: string | null): ThemeColor | undefined { + if (!key) return undefined; + return (METAC_COLORS["mc-option"] as Record)[key]; +} diff --git a/front_end/src/types/question.ts b/front_end/src/types/question.ts index 3a6b798ee3..b6b6afe7a2 100644 --- a/front_end/src/types/question.ts +++ b/front_end/src/types/question.ts @@ -288,6 +288,7 @@ export type Question = { options?: string[]; options_history?: [string, string[]][]; options_order?: MultipleChoiceOptionsOrder; + options_colors?: Record | null; group_variable?: string; group_rank?: number; // Continuous only @@ -305,6 +306,7 @@ export type Question = { fine_print: string | null; resolution_criteria: string | null; label: string; + label_color?: string | null; unit: string; author_username: string; post_id: number; @@ -333,6 +335,7 @@ export type EditableQuestionFields = Pick< | "description" | "options" | "options_order" + | "options_colors" | "group_variable" | "group_rank" | "scaling" @@ -342,6 +345,7 @@ export type EditableQuestionFields = Pick< | "fine_print" | "resolution_criteria" | "label" + | "label_color" | "unit" | "post_id" | "display_divergences" diff --git a/front_end/src/utils/questions/choices.ts b/front_end/src/utils/questions/choices.ts index 6268835863..f5d6d48381 100644 --- a/front_end/src/utils/questions/choices.ts +++ b/front_end/src/utils/questions/choices.ts @@ -1,7 +1,11 @@ import { isNil, uniq } from "lodash"; import { useTranslations } from "next-intl"; -import { METAC_COLORS, MULTIPLE_CHOICE_COLOR_SCALE } from "@/constants/colors"; +import { + getMcOptionColor, + METAC_COLORS, + MULTIPLE_CHOICE_COLOR_SCALE, +} from "@/constants/colors"; import { getEffectiveVisibleCount } from "@/constants/questions"; import { ChoiceItem } from "@/types/choices"; import { PostGroupOfQuestions, QuestionStatus } from "@/types/post"; @@ -173,7 +177,10 @@ export function generateChoiceItemsFromMultipleChoiceForecast( ? choice + " (" + t("Upcoming") + ")" : choice, isDeleted, - color: MULTIPLE_CHOICE_COLOR_SCALE[index] ?? METAC_COLORS.gray["400"], + color: + getMcOptionColor(question.options_colors?.[choice]) ?? + MULTIPLE_CHOICE_COLOR_SCALE[index] ?? + METAC_COLORS.gray["400"], highlighted: false, active: true, resolution: @@ -373,7 +380,10 @@ export function generateChoiceItemsFromGroupQuestions( return { id: question.id, choice: label, - color: MULTIPLE_CHOICE_COLOR_SCALE[index] ?? METAC_COLORS.gray["400"], + color: + getMcOptionColor(question.label_color) ?? + MULTIPLE_CHOICE_COLOR_SCALE[index] ?? + METAC_COLORS.gray["400"], highlighted: false, active: true, resolution: question.resolution, diff --git a/questions/migrations/0038_question_label_color_question_options_colors.py b/questions/migrations/0038_question_label_color_question_options_colors.py new file mode 100644 index 0000000000..a356ae46d9 --- /dev/null +++ b/questions/migrations/0038_question_label_color_question_options_colors.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.14 on 2026-07-13 13:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('questions', '0037_question_options_order'), + ] + + operations = [ + migrations.AddField( + model_name='question', + name='label_color', + field=models.CharField(blank=True, help_text="Admin-set frontend palette key ('1'..'18') overriding the index-based subquestion color.", max_length=20, null=True), + ), + migrations.AddField( + model_name='question', + name='options_colors', + field=models.JSONField(blank=True, help_text="For Multiple Choice only.\n Dict of option label -> frontend palette key ('1'..'18').\n Overrides the default index-based option color.", null=True), + ), + ] diff --git a/questions/models.py b/questions/models.py index e167a8be94..e710a0a44f 100644 --- a/questions/models.py +++ b/questions/models.py @@ -243,6 +243,14 @@ class MultipleChoiceOptionsOrder(models.TextChoices): CP_DESC: all views sort options by descending community prediction.""", ) + options_colors = models.JSONField( + null=True, + blank=True, + help_text="""For Multiple Choice only. + Dict of option label -> frontend palette key ('1'..'18'). + Overrides the default index-based option color.""", + ) + # Legacy field that will be removed possibilities = models.JSONField(null=True, blank=True) @@ -261,6 +269,13 @@ class MultipleChoiceOptionsOrder(models.TextChoices): # Group group_variable = models.CharField(blank=True, null=False) label = models.TextField(blank=True, null=False) + label_color = models.CharField( + max_length=20, + blank=True, + null=True, + help_text="Admin-set frontend palette key ('1'..'18') " + "overriding the index-based subquestion color.", + ) group: "GroupOfQuestions" = models.ForeignKey( "GroupOfQuestions", null=True, diff --git a/questions/serializers/common.py b/questions/serializers/common.py index ef7fa59e1d..ea0560baaa 100644 --- a/questions/serializers/common.py +++ b/questions/serializers/common.py @@ -61,6 +61,7 @@ class Meta: "all_options_ever", "options_history", "options_order", + "options_colors", "group_variable", # Used for Group Of Questions to determine # whether question is eligible for forecasting @@ -72,6 +73,7 @@ class Meta: "default_score_type", "default_aggregation_method", "label", + "label_color", "unit", "open_upper_bound", "open_lower_bound", @@ -167,8 +169,10 @@ class Meta: "inbound_outcome_count", "options", "options_order", + "options_colors", "group_variable", "label", + "label_color", "resolution_criteria", "open_time", "cp_reveal_time", @@ -179,6 +183,17 @@ class Meta: "group_rank", ) + def validate_options_colors(self, value): + if value is None: + return value + if not isinstance(value, dict) or not all( + isinstance(k, str) and isinstance(v, str) for k, v in value.items() + ): + raise serializers.ValidationError( + "options_colors must be a mapping of option label to palette key" + ) + return value + def validate(self, data: dict): errors = [] diff --git a/questions/services/multiple_choice_handlers.py b/questions/services/multiple_choice_handlers.py index a8461712e7..96e0999d12 100644 --- a/questions/services/multiple_choice_handlers.py +++ b/questions/services/multiple_choice_handlers.py @@ -131,6 +131,8 @@ def multiple_choice_rename_option( timestr, [new_option if opt == old_option else opt for opt in options], ) + if question.options_colors and old_option in question.options_colors: + question.options_colors[new_option] = question.options_colors.pop(old_option) return question