custom_profile_fields: Add "required" parameter to the profile fields.

Fixes #28512.
This commit is contained in:
Vector73 2024-03-19 18:52:03 +05:30 committed by Tim Abbott
parent ac0673e0b5
commit f758ca596b
31 changed files with 281 additions and 9 deletions

View File

@ -20,6 +20,15 @@ format used by the Zulip server that they are interacting with.
## Changes in Zulip 9.0 ## Changes in Zulip 9.0
**Feature level 244**
* [`POST /register`](/api/register-queue), [`GET /events`](/api/get-events),
[`POST /realm/profile_fields`](/api/create-custom-profile-field),
[`GET /realm/profile_fields`](/api/get-custom-profile-fields): Added a new
parameter `required`, on custom profile field objects, indicating whether an
organization administrator has configured the field as something users should
be required to provide.
**Feature level 243** **Feature level 243**
* [`POST /register`](/api/register-queue), [`GET * [`POST /register`](/api/register-queue), [`GET

View File

@ -265,6 +265,7 @@ EXEMPT_FILES = make_set(
"web/src/url-template.d.ts", "web/src/url-template.d.ts",
"web/src/user_card_popover.js", "web/src/user_card_popover.js",
"web/src/user_deactivation_ui.ts", "web/src/user_deactivation_ui.ts",
"web/src/user_events.js",
"web/src/user_group_components.js", "web/src/user_group_components.js",
"web/src/user_group_create.js", "web/src/user_group_create.js",
"web/src/user_group_create_members.js", "web/src/user_group_create_members.js",

View File

@ -33,7 +33,7 @@ DESKTOP_WARNING_VERSION = "5.9.3"
# Changes should be accompanied by documentation explaining what the # Changes should be accompanied by documentation explaining what the
# new level means in api_docs/changelog.md, as well as "**Changes**" # new level means in api_docs/changelog.md, as well as "**Changes**"
# entries in the endpoint's documentation in `zulip.yaml`. # entries in the endpoint's documentation in `zulip.yaml`.
API_FEATURE_LEVEL = 243 API_FEATURE_LEVEL = 244
# Bump the minor PROVISION_VERSION to indicate that folks should provision # Bump the minor PROVISION_VERSION to indicate that folks should provision
# only when going from an old version of the code to a newer version. Bump # only when going from an old version of the code to a newer version. Bump

View File

@ -62,6 +62,7 @@ export function append_custom_profile_fields(element_id, user_id) {
is_select_field, is_select_field,
field_choices, field_choices,
for_manage_user_modal: element_id === "#edit-user-form .custom-profile-field-form", for_manage_user_modal: element_id === "#edit-user-form .custom-profile-field-form",
is_empty_required_field: field.required && !field_value.value,
}); });
$(element_id).append(html); $(element_id).append(html);
} }

View File

@ -5,6 +5,7 @@ import render_bankruptcy_alert_content from "../templates/navbar_alerts/bankrupt
import render_configure_email_alert_content from "../templates/navbar_alerts/configure_outgoing_email.hbs"; import render_configure_email_alert_content from "../templates/navbar_alerts/configure_outgoing_email.hbs";
import render_demo_organization_deadline_content from "../templates/navbar_alerts/demo_organization_deadline.hbs"; import render_demo_organization_deadline_content from "../templates/navbar_alerts/demo_organization_deadline.hbs";
import render_desktop_notifications_alert_content from "../templates/navbar_alerts/desktop_notifications.hbs"; import render_desktop_notifications_alert_content from "../templates/navbar_alerts/desktop_notifications.hbs";
import render_empty_required_profile_fields from "../templates/navbar_alerts/empty_required_profile_fields.hbs";
import render_insecure_desktop_app_alert_content from "../templates/navbar_alerts/insecure_desktop_app.hbs"; import render_insecure_desktop_app_alert_content from "../templates/navbar_alerts/insecure_desktop_app.hbs";
import render_navbar_alert_wrapper from "../templates/navbar_alerts/navbar_alert_wrapper.hbs"; import render_navbar_alert_wrapper from "../templates/navbar_alerts/navbar_alert_wrapper.hbs";
import render_profile_incomplete_alert_content from "../templates/navbar_alerts/profile_incomplete.hbs"; import render_profile_incomplete_alert_content from "../templates/navbar_alerts/profile_incomplete.hbs";
@ -14,6 +15,7 @@ import * as desktop_notifications from "./desktop_notifications";
import * as keydown_util from "./keydown_util"; import * as keydown_util from "./keydown_util";
import {localstorage} from "./localstorage"; import {localstorage} from "./localstorage";
import {page_params} from "./page_params"; import {page_params} from "./page_params";
import * as people from "./people";
import {current_user, realm} from "./state_data"; import {current_user, realm} from "./state_data";
import {should_display_profile_incomplete_alert} from "./timerender"; import {should_display_profile_incomplete_alert} from "./timerender";
import * as unread from "./unread"; import * as unread from "./unread";
@ -70,6 +72,30 @@ export function should_show_server_upgrade_notification(ls) {
return Date.now() > upgrade_nag_dismissal_duration; return Date.now() > upgrade_nag_dismissal_duration;
} }
export function maybe_show_empty_required_profile_fields_alert() {
const empty_required_profile_fields_exist = realm.custom_profile_fields
.map((f) => ({
...f,
value: people.my_custom_profile_data(f.id)?.value,
}))
.find((f) => f.required && !f.value);
if (!empty_required_profile_fields_exist) {
return;
}
const $navbar_alert = $("#navbar_alerts_wrapper").children(".alert").first();
if (
!$navbar_alert?.length ||
$navbar_alert.is("#empty-required-profile-fields-warning") ||
$navbar_alert.is(":hidden")
) {
open({
data_process: "profile-missing-required",
rendered_alert_content_html: render_empty_required_profile_fields(),
});
}
}
export function dismiss_upgrade_nag(ls) { export function dismiss_upgrade_nag(ls) {
$(".alert[data-process='server-needs-upgrade'").hide(); $(".alert[data-process='server-needs-upgrade'").hide();
if (localstorage.supported()) { if (localstorage.supported()) {
@ -171,6 +197,8 @@ export function initialize() {
data_process: "profile-incomplete", data_process: "profile-incomplete",
rendered_alert_content_html: render_profile_incomplete_alert_content(), rendered_alert_content_html: render_profile_incomplete_alert_content(),
}); });
} else {
maybe_show_empty_required_profile_fields_alert();
} }
// Configure click handlers. // Configure click handlers.
@ -208,6 +236,7 @@ export function initialize() {
show_step($process, 2); show_step($process, 2);
} else { } else {
$(this).closest(".alert").hide(); $(this).closest(".alert").hide();
maybe_show_empty_required_profile_fields_alert();
} }
$(window).trigger("resize"); $(window).trigger("resize");
}); });

View File

@ -104,6 +104,7 @@ export function dispatch_normal_event(event) {
realm.custom_profile_fields = event.fields; realm.custom_profile_fields = event.fields;
settings_profile_fields.populate_profile_fields(realm.custom_profile_fields); settings_profile_fields.populate_profile_fields(realm.custom_profile_fields);
settings_account.add_custom_profile_fields_to_settings(); settings_account.add_custom_profile_fields_to_settings();
navbar_alerts.maybe_show_empty_required_profile_fields_alert();
break; break;
case "default_streams": case "default_streams":

View File

@ -269,6 +269,7 @@ function open_custom_profile_field_form_modal() {
display_in_profile_summary: $("#profile_field_display_in_profile_summary").is( display_in_profile_summary: $("#profile_field_display_in_profile_summary").is(
":checked", ":checked",
), ),
required: $("#profile-field-required").is(":checked"),
}; };
const url = "/json/realm/profile_fields"; const url = "/json/realm/profile_fields";
const opts = { const opts = {
@ -454,6 +455,7 @@ function open_edit_form_modal(e) {
hint: field.hint, hint: field.hint,
choices, choices,
display_in_profile_summary: field.display_in_profile_summary === true, display_in_profile_summary: field.display_in_profile_summary === true,
required: field.required === true,
is_select_field: field.type === field_types.SELECT.id, is_select_field: field.type === field_types.SELECT.id,
is_external_account_field: field.type === field_types.EXTERNAL_ACCOUNT.id, is_external_account_field: field.type === field_types.EXTERNAL_ACCOUNT.id,
valid_to_display_in_summary: is_valid_to_display_in_summary(field.type), valid_to_display_in_summary: is_valid_to_display_in_summary(field.type),
@ -510,6 +512,7 @@ function open_edit_form_modal(e) {
data.display_in_profile_summary = $profile_field_form data.display_in_profile_summary = $profile_field_form
.find("input[name=display_in_profile_summary]") .find("input[name=display_in_profile_summary]")
.is(":checked"); .is(":checked");
data.required = $profile_field_form.find("input[name=required]").is(":checked");
const new_field_data = read_field_data_from_form( const new_field_data = read_field_data_from_form(
Number.parseInt(field.type, 10), Number.parseInt(field.type, 10),
@ -588,6 +591,7 @@ function toggle_display_in_profile_summary_profile_field(e) {
hint: field.hint, hint: field.hint,
field_data, field_data,
display_in_profile_summary: !field.display_in_profile_summary, display_in_profile_summary: !field.display_in_profile_summary,
required: field.required,
}; };
const $profile_field_status = $("#admin-profile-field-status").expectOne(); const $profile_field_status = $("#admin-profile-field-status").expectOne();
@ -599,6 +603,31 @@ function toggle_display_in_profile_summary_profile_field(e) {
); );
} }
function toggle_required(e) {
const field_id = Number.parseInt($(e.currentTarget).attr("data-profile-field-id"), 10);
const field = get_profile_field(field_id);
let field_data;
if (field.field_data) {
field_data = field.field_data;
}
const data = {
name: field.name,
hint: field.hint,
field_data,
display_in_profile_summary: field.display_in_profile_summary,
required: !field.required,
};
const $profile_field_status = $("#admin-profile-field-status").expectOne();
settings_ui.do_settings_change(
channel.patch,
"/json/realm/profile_fields/" + field_id,
data,
$profile_field_status,
);
}
export function reset() { export function reset() {
meta.loaded = false; meta.loaded = false;
} }
@ -648,6 +677,7 @@ export function do_populate_profile_fields(profile_fields_data) {
} }
const display_in_profile_summary = profile_field.display_in_profile_summary === true; const display_in_profile_summary = profile_field.display_in_profile_summary === true;
const required = profile_field.required === true;
$profile_fields_table.append( $profile_fields_table.append(
render_admin_profile_field_list({ render_admin_profile_field_list({
profile_field: { profile_field: {
@ -661,6 +691,7 @@ export function do_populate_profile_fields(profile_fields_data) {
profile_field.type === field_types.EXTERNAL_ACCOUNT.id, profile_field.type === field_types.EXTERNAL_ACCOUNT.id,
display_in_profile_summary, display_in_profile_summary,
valid_to_display_in_summary: is_valid_to_display_in_summary(profile_field.type), valid_to_display_in_summary: is_valid_to_display_in_summary(profile_field.type),
required,
}, },
can_modify: current_user.is_admin, can_modify: current_user.is_admin,
realm_default_external_accounts: realm.realm_default_external_accounts, realm_default_external_accounts: realm.realm_default_external_accounts,
@ -767,4 +798,5 @@ export function build_page() {
".display_in_profile_summary", ".display_in_profile_summary",
toggle_display_in_profile_summary_profile_field, toggle_display_in_profile_summary_profile_field,
); );
$("#admin_profile_fields_table").on("click", ".required-field-toggle", toggle_required);
} }

View File

@ -89,6 +89,7 @@ export const realm_schema = z.object({
id: z.number(), id: z.number(),
name: z.string(), name: z.string(),
order: z.number(), order: z.number(),
required: z.boolean(),
type: z.number(), type: z.number(),
}), }),
), ),

View File

@ -592,4 +592,15 @@ export function initialize(): void {
); );
}, },
}); });
delegate("body", {
target: ".custom-user-field-label-wrapper",
content: $t({
defaultMessage: "This profile field is required.",
}),
appendTo: () => document.body,
onHidden(instance) {
instance.destroy();
},
});
} }

View File

@ -10,6 +10,7 @@ import {buddy_list} from "./buddy_list";
import * as compose_state from "./compose_state"; import * as compose_state from "./compose_state";
import * as message_live_update from "./message_live_update"; import * as message_live_update from "./message_live_update";
import * as narrow_state from "./narrow_state"; import * as narrow_state from "./narrow_state";
import * as navbar_alerts from "./navbar_alerts";
import * as people from "./people"; import * as people from "./people";
import * as pm_list from "./pm_list"; import * as pm_list from "./pm_list";
import * as settings from "./settings"; import * as settings from "./settings";
@ -21,7 +22,7 @@ import * as settings_profile_fields from "./settings_profile_fields";
import * as settings_realm_user_settings_defaults from "./settings_realm_user_settings_defaults"; import * as settings_realm_user_settings_defaults from "./settings_realm_user_settings_defaults";
import * as settings_streams from "./settings_streams"; import * as settings_streams from "./settings_streams";
import * as settings_users from "./settings_users"; import * as settings_users from "./settings_users";
import {current_user} from "./state_data"; import {current_user, realm} from "./state_data";
import * as stream_events from "./stream_events"; import * as stream_events from "./stream_events";
export const update_person = function update(person) { export const update_person = function update(person) {
@ -128,6 +129,33 @@ export const update_person = function update(person) {
if (Object.hasOwn(person, "custom_profile_field")) { if (Object.hasOwn(person, "custom_profile_field")) {
people.set_custom_profile_field_data(person.user_id, person.custom_profile_field); people.set_custom_profile_field_data(person.user_id, person.custom_profile_field);
if (person.user_id === people.my_current_user_id()) {
navbar_alerts.maybe_show_empty_required_profile_fields_alert();
const field_id = person.custom_profile_field.id;
const field_value = people.get_custom_profile_data(person.user_id, field_id)?.value;
const is_field_required = realm.custom_profile_fields?.find(
(f) => field_id === f.id,
)?.required;
if (is_field_required) {
const $custom_user_field = $(
`.profile-settings-form .custom_user_field[data-field-id="${CSS.escape(field_id)}"]`,
);
const $field = $custom_user_field.find(".field");
const $required_symbol = $custom_user_field.find(".required-symbol");
if (!field_value) {
if (!$field.hasClass("empty-required-field")) {
$field.addClass("empty-required-field");
$required_symbol.removeClass("hidden");
}
} else {
if ($field.hasClass("empty-required-field")) {
$field.removeClass("empty-required-field");
$required_symbol.addClass("hidden");
}
}
}
}
} }
if (Object.hasOwn(person, "timezone")) { if (Object.hasOwn(person, "timezone")) {

View File

@ -287,6 +287,7 @@ export function get_custom_profile_field_data(user, field, field_types) {
profile_field.is_external_account = field_type === field_types.EXTERNAL_ACCOUNT.id; profile_field.is_external_account = field_type === field_types.EXTERNAL_ACCOUNT.id;
profile_field.type = field_type; profile_field.type = field_type;
profile_field.display_in_profile_summary = field.display_in_profile_summary; profile_field.display_in_profile_summary = field.display_in_profile_summary;
profile_field.required = field.required;
switch (field_type) { switch (field_type) {
case field_types.DATE.id: case field_types.DATE.id:

View File

@ -482,6 +482,10 @@
border-width: 1px; border-width: 1px;
} }
.field {
border-color: hsl(3deg 73% 74%);
}
.deactivated-pill { .deactivated-pill {
background-color: hsl(0deg 86% 14%) !important; background-color: hsl(0deg 86% 14%) !important;
} }

View File

@ -131,7 +131,9 @@ h3,
.admin_profile_fields_table { .admin_profile_fields_table {
& th.display, & th.display,
td.display_in_profile_summary_cell { & th.required,
td.display_in_profile_summary_cell,
td.required-cell {
text-align: center; text-align: center;
} }
} }
@ -672,6 +674,15 @@ input[type="checkbox"] {
} }
} }
.required-symbol {
color: hsl(0deg 66% 60%);
}
.settings-profile-user-field {
margin-top: 5px;
max-width: fit-content;
}
.control-label-disabled { .control-label-disabled {
color: hsl(0deg 0% 82%); color: hsl(0deg 0% 82%);
@ -1513,7 +1524,6 @@ $option_title_width: 180px;
#edit-user-form { #edit-user-form {
.person_picker { .person_picker {
min-width: 206px; min-width: 206px;
margin-top: 5px;
} }
& textarea { & textarea {
@ -1536,7 +1546,6 @@ $option_title_width: 180px;
& select { & select {
/* Override undesired Bootstrap default. */ /* Override undesired Bootstrap default. */
margin-bottom: 0; margin-bottom: 0;
margin-top: 5px;
} }
} }
@ -1906,6 +1915,22 @@ $option_title_width: 180px;
} }
} }
.empty-required-field {
border: 1px solid hsl(3deg 57% 33%);
border-radius: 5px;
.settings_url_input,
.settings_text_input,
.settings_textarea,
.pill-container {
border-color: hsl(0deg 0% 100%);
&:focus {
border-color: hsl(206deg 80% 62% / 80%);
}
}
}
#generate-integration-url-modal { #generate-integration-url-modal {
.inline { .inline {
display: inline; display: inline;

View File

@ -0,0 +1,6 @@
<div data-step="1">
<span>
{{t 'Your profile is missing required fields.'}}&nbsp;
<a class="alert-link" href="#settings/profile">{{t 'Edit your profile'}}</a>
</span>
</div>

View File

@ -43,5 +43,12 @@
{{t 'Display on user card' }} {{t 'Display on user card' }}
</label> </label>
</div> </div>
<div class="input-group">
<label class="checkbox" for="profile-field-required">
<input type="checkbox" id="profile-field-required" name="required"/>
<span></span>
{{t 'Required field' }}
</label>
</div>
</div> </div>
</form> </form>

View File

@ -23,6 +23,14 @@
</span> </span>
{{/if}} {{/if}}
</td> </td>
<td class="required-cell">
<span class="profile-field-required">
<label class="checkbox" for="profile-field-required-{{id}}">
<input class="required-field-toggle required-checkbox-{{required}}" type="checkbox" id="profile-field-required-{{id}}" {{#if required}} checked="checked" {{/if}} data-profile-field-id="{{id}}"/>
<span></span>
</label>
</span>
</td>
{{#if ../can_modify}} {{#if ../can_modify}}
<td class="actions"> <td class="actions">
<button class="button rounded small btn-warning open-edit-form-modal tippy-zulip-delayed-tooltip" data-tippy-content="{{t 'Edit custom profile field' }}" data-profile-field-id="{{id}}"> <button class="button rounded small btn-warning open-edit-form-modal tippy-zulip-delayed-tooltip" data-tippy-content="{{t 'Edit custom profile field' }}" data-profile-field-id="{{id}}">

View File

@ -1,8 +1,11 @@
<div class="custom_user_field" name="{{ field.name }}" data-field-id="{{ field.id }}"> <div class="custom_user_field" name="{{ field.name }}" data-field-id="{{ field.id }}">
<label class="inline-block" for="{{ field.name }}" class="title">{{ field.name }}</label> <span class="custom-user-field-label-wrapper">
<label class="inline-block {{#if field.required}}required-field{{/if}}" for="{{ field.name }}" class="title">{{ field.name }}</label>
<span class="required-symbol {{#unless is_empty_required_field}}hidden{{/unless}}"> *</span>
</span>
<div class="alert-notification custom-field-status"></div> <div class="alert-notification custom-field-status"></div>
<div class="settings-profile-field-user-hint">{{ field.hint }}</div> <div class="settings-profile-field-user-hint">{{ field.hint }}</div>
<div class="settings-profile-user-field"> <div class="settings-profile-user-field {{#if is_empty_required_field}}empty-required-field{{/if}}">
{{#if is_long_text_field}} {{#if is_long_text_field}}
<textarea maxlength="500" class="custom_user_field_value settings_textarea">{{ field_value.value }}</textarea> <textarea maxlength="500" class="custom_user_field_value settings_textarea">{{ field_value.value }}</textarea>
{{else if is_select_field}} {{else if is_select_field}}

View File

@ -45,5 +45,12 @@
</label> </label>
</div> </div>
{{/if}} {{/if}}
<div class="input-group">
<label class="checkbox" for="edit-required-{{id}}">
<input class="edit-required" type="checkbox" id="edit-required-{{id}}" name="required" {{#if required}} checked="checked" {{/if}}/>
<span></span>
{{t 'Required field' }}
</label>
</div>
</form> </form>
{{/with}} {{/with}}

View File

@ -15,6 +15,7 @@
<th>{{t "Type" }}</th> <th>{{t "Type" }}</th>
{{#if is_admin}} {{#if is_admin}}
<th class="display">{{t "Card"}}</th> <th class="display">{{t "Card"}}</th>
<th class="required">{{t "Required"}}</th>
<th class="actions">{{t "Actions" }}</th> <th class="actions">{{t "Actions" }}</th>
{{/if}} {{/if}}
</tr> </tr>

View File

@ -40,6 +40,7 @@ const message_lists = mock_esm("../src/message_lists");
const user_topics_ui = mock_esm("../src/user_topics_ui"); const user_topics_ui = mock_esm("../src/user_topics_ui");
const muted_users_ui = mock_esm("../src/muted_users_ui"); const muted_users_ui = mock_esm("../src/muted_users_ui");
const narrow_title = mock_esm("../src/narrow_title"); const narrow_title = mock_esm("../src/narrow_title");
const navbar_alerts = mock_esm("../src/navbar_alerts");
const pm_list = mock_esm("../src/pm_list"); const pm_list = mock_esm("../src/pm_list");
const reactions = mock_esm("../src/reactions"); const reactions = mock_esm("../src/reactions");
const realm_icon = mock_esm("../src/realm_icon"); const realm_icon = mock_esm("../src/realm_icon");
@ -115,7 +116,6 @@ message_lists.home = {
message_lists.all_rendered_message_lists = () => [message_lists.home, message_lists.current]; message_lists.all_rendered_message_lists = () => [message_lists.home, message_lists.current];
// page_params is highly coupled to dispatching now // page_params is highly coupled to dispatching now
page_params.test_suite = false; page_params.test_suite = false;
current_user.is_admin = true; current_user.is_admin = true;
realm.realm_description = "already set description"; realm.realm_description = "already set description";
@ -134,8 +134,17 @@ function dispatch(ev) {
server_events_dispatch.dispatch_normal_event(ev); server_events_dispatch.dispatch_normal_event(ev);
} }
const me = {
email: "me@example.com",
user_id: 20,
full_name: "Me Myself",
timezone: "America/Los_Angeles",
};
people.init(); people.init();
people.add_active_user(me);
people.add_active_user(test_user); people.add_active_user(test_user);
people.initialize_current_user(me.user_id);
message_helper.process_new_message(test_message); message_helper.process_new_message(test_message);
@ -295,6 +304,7 @@ run_test("custom profile fields", ({override}) => {
const event = event_fixtures.custom_profile_fields; const event = event_fixtures.custom_profile_fields;
override(settings_profile_fields, "populate_profile_fields", noop); override(settings_profile_fields, "populate_profile_fields", noop);
override(settings_account, "add_custom_profile_fields_to_settings", noop); override(settings_account, "add_custom_profile_fields_to_settings", noop);
override(navbar_alerts, "maybe_show_empty_required_profile_fields_alert", noop);
dispatch(event); dispatch(event);
assert_same(realm.custom_profile_fields, event.fields); assert_same(realm.custom_profile_fields, event.fields);
}); });
@ -432,6 +442,8 @@ run_test("realm settings", ({override}) => {
override(sidebar_ui, "update_invite_user_option", noop); override(sidebar_ui, "update_invite_user_option", noop);
override(gear_menu, "rerender", noop); override(gear_menu, "rerender", noop);
override(narrow_title, "redraw_title", noop); override(narrow_title, "redraw_title", noop);
override(navbar_alerts, "check_profile_incomplete", noop);
override(navbar_alerts, "show_profile_incomplete", noop);
function test_electron_dispatch(event, fake_send_event) { function test_electron_dispatch(event, fake_send_event) {
with_overrides(({override}) => { with_overrides(({override}) => {

View File

@ -138,6 +138,7 @@ exports.fixtures = {
field_data: "", field_data: "",
order: 1, order: 1,
display_in_profile_summary: false, display_in_profile_summary: false,
required: false,
}, },
{ {
id: 2, id: 2,
@ -147,6 +148,7 @@ exports.fixtures = {
field_data: "", field_data: "",
order: 2, order: 2,
display_in_profile_summary: false, display_in_profile_summary: false,
required: false,
}, },
], ],
}, },

View File

@ -99,6 +99,7 @@ run_test("populate_profile_fields", ({mock_template}) => {
field_data: "", field_data: "",
display_in_profile_summary: false, display_in_profile_summary: false,
valid_to_display_in_summary: true, valid_to_display_in_summary: true,
required: false,
}, },
{ {
type: SELECT_ID, type: SELECT_ID,
@ -117,6 +118,7 @@ run_test("populate_profile_fields", ({mock_template}) => {
]), ]),
display_in_profile_summary: false, display_in_profile_summary: false,
valid_to_display_in_summary: true, valid_to_display_in_summary: true,
required: false,
}, },
{ {
type: EXTERNAL_ACCOUNT_ID, type: EXTERNAL_ACCOUNT_ID,
@ -128,6 +130,7 @@ run_test("populate_profile_fields", ({mock_template}) => {
}), }),
display_in_profile_summary: true, display_in_profile_summary: true,
valid_to_display_in_summary: true, valid_to_display_in_summary: true,
required: false,
}, },
{ {
type: EXTERNAL_ACCOUNT_ID, type: EXTERNAL_ACCOUNT_ID,
@ -140,6 +143,7 @@ run_test("populate_profile_fields", ({mock_template}) => {
}), }),
display_in_profile_summary: true, display_in_profile_summary: true,
valid_to_display_in_summary: true, valid_to_display_in_summary: true,
required: false,
}, },
]; ];
const expected_template_data = [ const expected_template_data = [
@ -154,6 +158,7 @@ run_test("populate_profile_fields", ({mock_template}) => {
is_external_account_field: false, is_external_account_field: false,
display_in_profile_summary: false, display_in_profile_summary: false,
valid_to_display_in_summary: true, valid_to_display_in_summary: true,
required: false,
}, },
can_modify: true, can_modify: true,
realm_default_external_accounts: realm.realm_default_external_accounts, realm_default_external_accounts: realm.realm_default_external_accounts,
@ -172,6 +177,7 @@ run_test("populate_profile_fields", ({mock_template}) => {
is_external_account_field: false, is_external_account_field: false,
display_in_profile_summary: false, display_in_profile_summary: false,
valid_to_display_in_summary: true, valid_to_display_in_summary: true,
required: false,
}, },
can_modify: true, can_modify: true,
realm_default_external_accounts: realm.realm_default_external_accounts, realm_default_external_accounts: realm.realm_default_external_accounts,
@ -187,6 +193,7 @@ run_test("populate_profile_fields", ({mock_template}) => {
is_external_account_field: true, is_external_account_field: true,
display_in_profile_summary: true, display_in_profile_summary: true,
valid_to_display_in_summary: true, valid_to_display_in_summary: true,
required: false,
}, },
can_modify: true, can_modify: true,
realm_default_external_accounts: realm.realm_default_external_accounts, realm_default_external_accounts: realm.realm_default_external_accounts,
@ -202,6 +209,7 @@ run_test("populate_profile_fields", ({mock_template}) => {
is_external_account_field: true, is_external_account_field: true,
display_in_profile_summary: true, display_in_profile_summary: true,
valid_to_display_in_summary: true, valid_to_display_in_summary: true,
required: false,
}, },
can_modify: true, can_modify: true,
realm_default_external_accounts: realm.realm_default_external_accounts, realm_default_external_accounts: realm.realm_default_external_accounts,

View File

@ -9,6 +9,7 @@ const $ = require("./lib/zjquery");
const {current_user} = require("./lib/zpage_params"); const {current_user} = require("./lib/zpage_params");
const message_live_update = mock_esm("../src/message_live_update"); const message_live_update = mock_esm("../src/message_live_update");
const navbar_alerts = mock_esm("../src/navbar_alerts");
const settings_account = mock_esm("../src/settings_account", { const settings_account = mock_esm("../src/settings_account", {
maybe_update_deactivate_account_button() {}, maybe_update_deactivate_account_button() {},
update_email() {}, update_email() {},
@ -68,7 +69,7 @@ function initialize() {
initialize(); initialize();
run_test("updates", () => { run_test("updates", ({override}) => {
let person; let person;
const isaac = { const isaac = {
@ -79,6 +80,7 @@ run_test("updates", () => {
}; };
people.add_active_user(isaac); people.add_active_user(isaac);
override(navbar_alerts, "maybe_show_empty_required_profile_fields_alert", noop);
user_events.update_person({ user_events.update_person({
user_id: isaac.user_id, user_id: isaac.user_id,
role: settings_config.user_role_values.guest.code, role: settings_config.user_role_values.guest.code,

View File

@ -25,6 +25,7 @@ def try_add_realm_default_custom_profile_field(
realm: Realm, realm: Realm,
field_subtype: str, field_subtype: str,
display_in_profile_summary: bool = False, display_in_profile_summary: bool = False,
required: bool = False,
) -> CustomProfileField: ) -> CustomProfileField:
field_data = DEFAULT_EXTERNAL_ACCOUNTS[field_subtype] field_data = DEFAULT_EXTERNAL_ACCOUNTS[field_subtype]
custom_profile_field = CustomProfileField( custom_profile_field = CustomProfileField(
@ -34,6 +35,7 @@ def try_add_realm_default_custom_profile_field(
hint=field_data.hint, hint=field_data.hint,
field_data=orjson.dumps(dict(subtype=field_subtype)).decode(), field_data=orjson.dumps(dict(subtype=field_subtype)).decode(),
display_in_profile_summary=display_in_profile_summary, display_in_profile_summary=display_in_profile_summary,
required=required,
) )
custom_profile_field.save() custom_profile_field.save()
custom_profile_field.order = custom_profile_field.id custom_profile_field.order = custom_profile_field.id
@ -49,12 +51,14 @@ def try_add_realm_custom_profile_field(
hint: str = "", hint: str = "",
field_data: Optional[ProfileFieldData] = None, field_data: Optional[ProfileFieldData] = None,
display_in_profile_summary: bool = False, display_in_profile_summary: bool = False,
required: bool = False,
) -> CustomProfileField: ) -> CustomProfileField:
custom_profile_field = CustomProfileField( custom_profile_field = CustomProfileField(
realm=realm, realm=realm,
name=name, name=name,
field_type=field_type, field_type=field_type,
display_in_profile_summary=display_in_profile_summary, display_in_profile_summary=display_in_profile_summary,
required=required,
) )
custom_profile_field.hint = hint custom_profile_field.hint = hint
if custom_profile_field.field_type in ( if custom_profile_field.field_type in (
@ -101,10 +105,12 @@ def try_update_realm_custom_profile_field(
hint: str = "", hint: str = "",
field_data: Optional[ProfileFieldData] = None, field_data: Optional[ProfileFieldData] = None,
display_in_profile_summary: bool = False, display_in_profile_summary: bool = False,
required: bool = False,
) -> None: ) -> None:
field.name = name field.name = name
field.hint = hint field.hint = hint
field.display_in_profile_summary = display_in_profile_summary field.display_in_profile_summary = display_in_profile_summary
field.required = required
if field.field_type in (CustomProfileField.SELECT, CustomProfileField.EXTERNAL_ACCOUNT): if field.field_type in (CustomProfileField.SELECT, CustomProfileField.EXTERNAL_ACCOUNT):
if field.field_type == CustomProfileField.SELECT: if field.field_type == CustomProfileField.SELECT:
assert field_data is not None assert field_data is not None

View File

@ -169,6 +169,7 @@ custom_profile_field_type = DictType(
("hint", str), ("hint", str),
("field_data", str), ("field_data", str),
("order", int), ("order", int),
("required", bool),
], ],
optional_keys=[ optional_keys=[
("display_in_profile_summary", bool), ("display_in_profile_summary", bool),

View File

@ -22,6 +22,7 @@ class ProfileDataElementBase(TypedDict, total=False):
type: int type: int
hint: str hint: str
display_in_profile_summary: bool display_in_profile_summary: bool
required: bool
field_data: str field_data: str
order: int order: int

View File

@ -0,0 +1,17 @@
# Generated by Django 4.2.10 on 2024-03-01 09:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("zerver", "0503_realm_zulip_update_announcements_level"),
]
operations = [
migrations.AddField(
model_name="customprofilefield",
name="required",
field=models.BooleanField(default=False),
),
]

View File

@ -77,6 +77,7 @@ class CustomProfileField(models.Model):
# Whether the field should be displayed in smaller summary # Whether the field should be displayed in smaller summary
# sections of a page displaying custom profile fields. # sections of a page displaying custom profile fields.
display_in_profile_summary = models.BooleanField(default=False) display_in_profile_summary = models.BooleanField(default=False)
required = models.BooleanField(default=False)
SHORT_TEXT = 1 SHORT_TEXT = 1
LONG_TEXT = 2 LONG_TEXT = 2
@ -165,6 +166,7 @@ class CustomProfileField(models.Model):
"hint": self.hint, "hint": self.hint,
"field_data": self.field_data, "field_data": self.field_data,
"order": self.order, "order": self.order,
"required": self.required,
} }
if self.display_in_profile_summary: if self.display_in_profile_summary:
data_as_dict["display_in_profile_summary"] = True data_as_dict["display_in_profile_summary"] = True

View File

@ -1913,6 +1913,7 @@ paths:
"hint": "Enter your GitHub username", "hint": "Enter your GitHub username",
"field_data": '{"subtype":"github"}', "field_data": '{"subtype":"github"}',
"order": 8, "order": 8,
"required": true,
}, },
], ],
"id": 0, "id": 0,
@ -10298,6 +10299,7 @@ paths:
"hint": "Enter your GitHub username", "hint": "Enter your GitHub username",
"field_data": '{"subtype":"github"}', "field_data": '{"subtype":"github"}',
"order": 8, "order": 8,
"required": true,
}, },
{ {
"id": 9, "id": 9,
@ -10422,6 +10424,18 @@ paths:
**Changes**: New in Zulip 6.0 (feature level 146). **Changes**: New in Zulip 6.0 (feature level 146).
type: boolean type: boolean
example: true example: true
required:
description: |
Whether an organization administrator has configured this profile field as
required.
Because the required property is mutable, clients cannot assume that a required
custom profile field has a value. The Zulip web application displays a prominent
banner to any user who has not set a value for a required field.
**Changes**: New in Zulip 9.0 (feature level 244).
type: boolean
example: true
required: required:
- field_type - field_type
encoding: encoding:
@ -10431,6 +10445,8 @@ paths:
contentType: application/json contentType: application/json
display_in_profile_summary: display_in_profile_summary:
contentType: application/json contentType: application/json
required:
contentType: application/json
responses: responses:
"200": "200":
description: Success. description: Success.
@ -18946,6 +18962,17 @@ components:
[profile field types](/help/custom-profile-fields#profile-field-types). [profile field types](/help/custom-profile-fields#profile-field-types).
**Changes**: New in Zulip 6.0 (feature level 146). **Changes**: New in Zulip 6.0 (feature level 146).
required:
type: boolean
description: |
Whether an organization administrator has configured this profile field as
required.
Because the required property is mutable, clients cannot assume that a required
custom profile field has a value. The Zulip web application displays a prominent
banner to any user who has not set a value for a required field.
**Changes**: New in Zulip 9.0 (feature level 244).
OnboardingStep: OnboardingStep:
type: object type: object
additionalProperties: false additionalProperties: false

View File

@ -482,6 +482,7 @@ class UpdateCustomProfileFieldTest(CustomProfileFieldTestCase):
self.assertEqual(field.name, "New phone number") self.assertEqual(field.name, "New phone number")
self.assertIs(field.hint, "") self.assertIs(field.hint, "")
self.assertEqual(field.field_type, CustomProfileField.SHORT_TEXT) self.assertEqual(field.field_type, CustomProfileField.SHORT_TEXT)
self.assertEqual(field.required, False)
result = self.client_patch( result = self.client_patch(
f"/json/realm/profile_fields/{field.id}", f"/json/realm/profile_fields/{field.id}",
@ -511,12 +512,24 @@ class UpdateCustomProfileFieldTest(CustomProfileFieldTestCase):
msg = 'Argument "display_in_profile_summary" is not valid JSON.' msg = 'Argument "display_in_profile_summary" is not valid JSON.'
self.assert_json_error(result, msg) self.assert_json_error(result, msg)
result = self.client_patch(
f"/json/realm/profile_fields/{field.id}",
info={
"name": "New phone number",
"hint": "New contact number",
"required": "invalid value",
},
)
msg = 'Argument "required" is not valid JSON.'
self.assert_json_error(result, msg)
result = self.client_patch( result = self.client_patch(
f"/json/realm/profile_fields/{field.id}", f"/json/realm/profile_fields/{field.id}",
info={ info={
"name": "New phone number", "name": "New phone number",
"hint": "New contact number", "hint": "New contact number",
"display_in_profile_summary": "true", "display_in_profile_summary": "true",
"required": "true",
}, },
) )
self.assert_json_success(result) self.assert_json_success(result)
@ -527,6 +540,7 @@ class UpdateCustomProfileFieldTest(CustomProfileFieldTestCase):
self.assertEqual(field.hint, "New contact number") self.assertEqual(field.hint, "New contact number")
self.assertEqual(field.field_type, CustomProfileField.SHORT_TEXT) self.assertEqual(field.field_type, CustomProfileField.SHORT_TEXT)
self.assertEqual(field.display_in_profile_summary, True) self.assertEqual(field.display_in_profile_summary, True)
self.assertEqual(field.required, True)
result = self.client_patch( result = self.client_patch(
f"/json/realm/profile_fields/{field.id}", f"/json/realm/profile_fields/{field.id}",

View File

@ -155,6 +155,7 @@ def create_realm_custom_profile_field(
field_data: ProfileFieldData = REQ(default={}, json_validator=check_profile_field_data), field_data: ProfileFieldData = REQ(default={}, json_validator=check_profile_field_data),
field_type: int = REQ(json_validator=check_int), field_type: int = REQ(json_validator=check_int),
display_in_profile_summary: bool = REQ(default=False, json_validator=check_bool), display_in_profile_summary: bool = REQ(default=False, json_validator=check_bool),
required: bool = REQ(default=False, json_validator=check_bool),
) -> HttpResponse: ) -> HttpResponse:
if display_in_profile_summary and display_in_profile_summary_limit_reached(user_profile.realm): if display_in_profile_summary and display_in_profile_summary_limit_reached(user_profile.realm):
raise JsonableError( raise JsonableError(
@ -170,6 +171,7 @@ def create_realm_custom_profile_field(
realm=user_profile.realm, realm=user_profile.realm,
field_subtype=field_subtype, field_subtype=field_subtype,
display_in_profile_summary=display_in_profile_summary, display_in_profile_summary=display_in_profile_summary,
required=required,
) )
return json_success(request, data={"id": field.id}) return json_success(request, data={"id": field.id})
else: else:
@ -180,6 +182,7 @@ def create_realm_custom_profile_field(
field_type=field_type, field_type=field_type,
hint=hint, hint=hint,
display_in_profile_summary=display_in_profile_summary, display_in_profile_summary=display_in_profile_summary,
required=required,
) )
return json_success(request, data={"id": field.id}) return json_success(request, data={"id": field.id})
except IntegrityError: except IntegrityError:
@ -209,6 +212,7 @@ def update_realm_custom_profile_field(
hint: str = REQ(default=""), hint: str = REQ(default=""),
field_data: ProfileFieldData = REQ(default={}, json_validator=check_profile_field_data), field_data: ProfileFieldData = REQ(default={}, json_validator=check_profile_field_data),
display_in_profile_summary: bool = REQ(default=False, json_validator=check_bool), display_in_profile_summary: bool = REQ(default=False, json_validator=check_bool),
required: bool = REQ(default=False, json_validator=check_bool),
) -> HttpResponse: ) -> HttpResponse:
realm = user_profile.realm realm = user_profile.realm
try: try:
@ -246,6 +250,7 @@ def update_realm_custom_profile_field(
hint=hint, hint=hint,
field_data=field_data, field_data=field_data,
display_in_profile_summary=display_in_profile_summary, display_in_profile_summary=display_in_profile_summary,
required=required,
) )
except IntegrityError: except IntegrityError:
raise JsonableError(_("A field with that label already exists.")) raise JsonableError(_("A field with that label already exists."))