Skip to content

Conversation

@PedroSimas4
Copy link
Collaborator

@PedroSimas4 PedroSimas4 commented Dec 11, 2025

This PR adds support for a new custom profile field type: SELECT_MULTIPLE (ID 9). This allows administrators to define a list of fixed options (similar to the standard Select field), but enables users to select multiple answers via checkboxes.

Key Changes:

Backend: Added SELECT_MULTIPLE to models.py, added validation logic in validator.py (ensuring values are valid lists of option keys), and updated users.py to handle storage.
Frontend: Updated settings UI to allow creating these fields and the Profile UI to display selected values as a comma-separated list.
Storage: Data is stored in the database as a JSON-encoded list of strings (e.g., ["1", "2"]).

Fixes: #17495

How changes were tested:

Automated Tests:

Added backend tests in zerver/tests/test_users.py to verify creation, validation (invalid choices, malformed JSON), and storage logic.
Added frontend unit tests in web/tests/settings_profile_fields.test.cjs to verify the UI configuration logic.
Ran ./tools/test-all locally and all tests passed.

Manual Verification:

Verified the end-to-end flow:
Created a "Programming Languages" multi-select field in Organization Settings.
Edited a user profile to select multiple options (e.g., Python, Rust).
Verified that the profile displays "Python, Rust" correctly.
Verified that deselecting all options hides the field from the profile display.

Screenshots and screen captures:

{0D6E12F4-B2CA-437D-9B79-9FD29ECF099A} {D52474B0-DF8F-4AA0-ADB9-D85EF72EEB93} {AA6C4E92-4109-457C-B8D8-BD2E3670A905} {036A4A28-9DF5-4B11-A73F-E732BC99C2C8} {A5EF9FD2-7A60-4FDE-9E23-25EC489261AE}

Self-review checklist

[x] [Self-reviewed]

Communicate decisions, questions, and potential concerns.

[x] Explains differences from previous plans (e.g., issue description).
[x] Highlights technical choices and bugs encountered.
[x] Calls out remaining decisions and concerns.
[x] Automated tests verify logic where appropriate.

Individual commits are ready for review.

[x] Each commit is a coherent idea.
[x] Commit message(s) explain reasoning and motivation for changes.

Completed manual review and testing of the following:

[x] Visual appearance of the changes.
[x] Responsiveness and internationalization.
[x] Strings and tooltips.
[x] End-to-end functionality of buttons, interactions and flows.
[x] Corner cases, error conditions, and easily imagined bugs.

@zulipbot zulipbot added size: XL area: settings (admin/org) new feature A proposed new feature for the product labels Dec 11, 2025
@zulipbot
Copy link
Member

Hello @zulip/server-settings members, this pull request was labeled with the "area: settings (admin/org)" label, so you may want to check it out!

@PedroSimas4 PedroSimas4 force-pushed the feature-checkbox-profile-field branch 3 times, most recently from fa04c95 to 56f7c05 Compare December 11, 2025 16:34
(SELECT, gettext_lazy("List of options"), validate_select_field, str, "SELECT"),
(
SELECT_MULTIPLE,
gettext_lazy("List of options (multiple)"),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if "multiple" or something like "multiselect" would feel clearer to end users for this.

try:
field_type_id = CustomProfileField.SELECT_MULTIPLE
except AttributeError:
field_type_id = 9
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This exception should never happen in a test; how did you end up writing this test code?

(I think you only use this in one place, so it should be inlined?)

try:
val_to_validate = orjson.loads(value)
except orjson.JSONDecodeError:
except orjson.JSONDecodeError: # nocoverage
Copy link
Member

@timabbott timabbott Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These comments should be in the previous commit that adds the code with the linter issue. Or better, just add coverage in that commit.

(Similarly for all the other backend changes in this second commit).

readable_values.push(option.text);
}
}
const final_text = readable_values.join(", ");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you want to use one of the helpers from util.ts that use Intl.ListFormat to do this in an internationalized fashion. (Japanese doesn't use , as the separator, for example).

return undefined;
}
profile_field.value = final_text;
} catch {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the situation when this catch clause would fire?

Generally it's quite risky to write code that catches ANY exception, since if the code above grew a syntax error such that it always failed, it'd just use this fallback code here.

);
} catch {
choices_data = [];
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly, why did you add this try/catch?

selected_values.add(item);
}
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a lot of parsing code; can you add comments explaining what possible failures you're worried about here?

@alya
Copy link
Contributor

alya commented Dec 11, 2025

On the UI side:

  • A screenshot of the dropdown that includes this type of field is missing.
  • The spacing between options is incorrect -- there needs to be more space on the left of the checkbox than the right, if we actually want to put them on the same line. I'd also like to see a screenshot that includes surrounding UI context, and has some options with long names. How is line-wrapping handled?

@timabbott
Copy link
Member

This looks like a great first PR @PedroSimas4! FYI #api design > custom profile fields @ 💬 will need to be resolved before we can finalize the server part of the implementation.

.

@PedroSimas4 PedroSimas4 force-pushed the feature-checkbox-profile-field branch 3 times, most recently from 5c1315b to f98686f Compare December 14, 2025 21:21
@PedroSimas4
Copy link
Collaborator Author

@timabbott @alya Thank you for the detailed feedback! I have updated the PR addressing all the points:

  1. UI/UX & CSS:

Changed the field label to "Multi-select" as suggested.

Moved the inline styles to web/styles/settings.css and cleaned up the HBS template.

Fixed the flexbox wrapping and spacing for long values.

(Added screenshots of the Admin UI and Profile View).

  1. Frontend Logic:

Switched to util.format_array_as_list for proper internationalization.

Replaced generic try/catch blocks with specific z.safeParse checks to handle data integrity safely without swallowing logic errors.

  1. Backend & Tests:

Removed # nocoverage tags.

Added a regression test case in zerver/tests/test_users.py covering both invalid JSON input and corrupted field definitions.

Refactored the test flow to avoid the unnecessary try/except block for the field type constant.

Ready for another look!

@alya
Copy link
Contributor

alya commented Dec 16, 2025

Hm, I still don't see a screenshot of the dropdown?

@alya
Copy link
Contributor

alya commented Dec 16, 2025

"Multi-select" is more technical-sounding than I like. Let's just call it "Checkboxes" (following, e.g., Google Forms and SurveyMonkey). Flagged for discussion in #design > custom profile field types, in case there's feedback.

@timabbott timabbott added the maintainer review PR is ready for review by Zulip maintainers. label Dec 17, 2025
@timabbott
Copy link
Member

Tagging @sahil839 for maintainer review once the renaming feedback above has been completed.

@PedroSimas4 PedroSimas4 force-pushed the feature-checkbox-profile-field branch from f98686f to 2e1f1b6 Compare December 17, 2025 20:47
This commit introduces a new custom profile field type, SELECT_MULTIPLE
(ID 9). This field allows administrators to define a fixed set of
options, similar to the existing SELECT type, but enables users to
choose multiple answers.

Key implementation details:

* Storage: Selected values are stored in the database as a JSON-encoded
  list of strings (e.g., '["1", "2"]').

* Validation: Added logic to `validator.py` to ensure that saved
  values are valid lists and that every selected item exists in the
  field's defined options.

* Tests: Added backend tests in `test_users.py` to verify creation,
  validation, and storage logic.
This updates the web application to support the display and editing
of the new 'Select multiple' custom profile field type.

Changes include:

* Settings UI: Admins can now create and edit multi-select fields in
  the organization settings.

* Profile Display: The user profile now renders these fields as a
  comma-separated list of selected values. If no value is selected,
  the field is hidden to maintain a clean UI.

* Tests: Updated `settings_profile_fields.test.cjs` and mocks to
  support the new field type ID.

Fixes zulip#17495.
@PedroSimas4 PedroSimas4 force-pushed the feature-checkbox-profile-field branch from 2e1f1b6 to 9735e8b Compare December 17, 2025 20:53
@PedroSimas4
Copy link
Collaborator Author

Renamed the field to 'Checkboxes' as suggested

Copy link
Collaborator

@sahil839 sahil839 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have only done review for the first commit currently. Will do the complete review tommorrow.

for item in items:
if item not in field_data_dict:
msg = _("'{value}' is not a valid choice for '{field_name}'.")
raise ValidationError(msg.format(value=item, field_name=var_name))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think some code is missing like validating the data when creating a SELECT_MULTIPLE type custom field. This is for validating the value set by user for the custom field but there is no validation when an admin creates a SELECT_MULTIPLE type custom profile field.

val_to_validate = orjson.loads(value)
except orjson.JSONDecodeError:
raise ValidationError(_("Invalid value (not valid JSON)."))
multiselect_validator = cast(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't why casting to a type is needed here. I think this is because SELECT_FIELD_VALIDATORS dict is being used to store validators for both SELECT and SELECT_MULTIPLE type fields when they have different return types.

I think we can store them differently if that is the case instead of using cast here.

raise ValidationError(_("{var_name} is not a string or integer").format(var_name=var_name))


def validate_select_multiple_field(var_name: str, field_data: str, value: object) -> Any:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can use an explicit type here instead of Any.

)

field.field_data = "invalid_json_definition"
field.save()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure if it is required to test such a case where DB needs to be updated manually to set invalid data and it is not possible to do so using API.

Copy link
Collaborator

@sahil839 sahil839 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted some comments in frontend code as well. I feel the try-catch blocks are not needed since we can expect valid JSON value from server where we already validate the data. Or would be good if you could highlight cases where JSON value can be invalid except for modifying the DB value manually.

$container.find("input:checked").each(function (this: HTMLElement) {
const val = $(this).val();
if (val !== undefined) {
values.push(String(val));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of using String here, above call to val() can be modified as const val = $<HTMLInputELement>(this).val()!.

);
} catch {
field_choice_dict = {};
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need this try-catch block. We expect server to validate field_data when creating and updating the field so we can expect field.field_data to always be a valid json.

// the expected schema (array of strings).
try {
parsed = JSON.parse(current_val);
} catch {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly for the field value as well, the server would validate the value when setting or updating it.

const result = z.array(z.string()).safeParse(parsed);
if (result.success) {
for (const item of result.data) {
selected_values.add(item);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This breaks the behavior for single select type field where the selected value is not shown in UI. I guess would be good to just have separate if-blocks for single and multiple select fields, i.e. have is_select_field false for multiple select fields and instead have a different variable like is_multi_select_field.

// We wrap this in a try/catch so that if the field data is malformed
// (e.g. invalid JSON), the edit form still opens (showing empty choices)
// rather than crashing. This allows the admin to recover by re-entering choices.
try {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any case that you can reproduce with invalid JSON without manually modifying the DB? If not we can remove this as well.

profile_field.field_data &&
(profile_field.type === field_types.SELECT.id ||
profile_field.type === field_types.SELECT_MULTIPLE.id)
) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is choices field computed here used in the below render_admin_profile_field_list? I don't think so because we do not show choices in the list.

@zulipbot
Copy link
Member

zulipbot commented Jan 6, 2026

Heads up @PedroSimas4, we just merged some commits that conflict with the changes you made in this pull request! You can review this repository's recent commits to see where the conflicts occur. Please rebase your feature branch against the upstream/main branch and resolve your pull request's merge conflicts accordingly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: settings (admin/org) has conflicts maintainer review PR is ready for review by Zulip maintainers. new feature A proposed new feature for the product size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add support for checkbox-style custom profile fields

5 participants