stream_settings: Add 'Default stream' option in edit stream UI.

This commit adds a 'Default stream for new users' checkbox in
the stream editing UI to allow admins to easily add or remove
a stream as the default stream for new users. Previously, this
functionality required navigating to separate menu.

Fixes a part of #24048.
This commit is contained in:
Hemant Umre 2023-07-22 15:54:55 +05:30 committed by Tim Abbott
parent d346b9bd1c
commit a81715786c
14 changed files with 211 additions and 12 deletions

View File

@ -20,6 +20,12 @@ format used by the Zulip server that they are interacting with.
## Changes in Zulip 8.0 ## Changes in Zulip 8.0
**Feature level 200**
* [`PATCH /streams/{stream_id}`](/api/update-stream): Added
`is_default_stream` parameter to add or remove the stream as a default
stream for new users.
**Feature level 199** **Feature level 199**
* [`POST /register`](/api/register-queue), [`GET /events`][/api/get-events], * [`POST /register`](/api/register-queue), [`GET /events`][/api/get-events],

View File

@ -102,6 +102,7 @@ export function dispatch_normal_event(event) {
case "default_streams": case "default_streams":
stream_data.set_realm_default_streams(event.default_streams); stream_data.set_realm_default_streams(event.default_streams);
settings_streams.update_default_streams_table(); settings_streams.update_default_streams_table();
stream_settings_ui.update_is_default_stream();
break; break;
case "delete_message": { case "delete_message": {

View File

@ -168,6 +168,9 @@ function get_property_value(property_name, for_realm_default_settings, sub) {
if (property_name === "stream_privacy") { if (property_name === "stream_privacy") {
return stream_data.get_stream_privacy_policy(sub.stream_id); return stream_data.get_stream_privacy_policy(sub.stream_id);
} }
if (property_name === "is_default_stream") {
return stream_data.is_default_stream_id(sub.stream_id);
}
return sub[property_name]; return sub[property_name];
} }

View File

@ -247,6 +247,7 @@ export function show_settings_for(node) {
stream_post_policy_values: stream_data.stream_post_policy_values, stream_post_policy_values: stream_data.stream_post_policy_values,
stream_privacy_policy_values: stream_data.stream_privacy_policy_values, stream_privacy_policy_values: stream_data.stream_privacy_policy_values,
stream_privacy_policy: stream_data.get_stream_privacy_policy(stream_id), stream_privacy_policy: stream_data.get_stream_privacy_policy(stream_id),
check_default_stream: stream_data.is_default_stream_id(stream_id),
zulip_plan_is_not_limited: page_params.zulip_plan_is_not_limited, zulip_plan_is_not_limited: page_params.zulip_plan_is_not_limited,
upgrade_text_for_wide_organization_logo: upgrade_text_for_wide_organization_logo:
page_params.upgrade_text_for_wide_organization_logo, page_params.upgrade_text_for_wide_organization_logo,
@ -679,6 +680,9 @@ export function initialize() {
const sub = sub_store.get(stream_id); const sub = sub_store.get(stream_id);
const $subsection = $(e.target).closest(".settings-subsection-parent"); const $subsection = $(e.target).closest(".settings-subsection-parent");
settings_org.save_discard_widget_status_handler($subsection, false, sub); settings_org.save_discard_widget_status_handler($subsection, false, sub);
if (sub) {
stream_ui_updates.update_default_stream_and_stream_privacy_state($subsection);
}
return true; return true;
}); });
@ -714,6 +718,7 @@ export function initialize() {
for (const elem of settings_org.get_subsection_property_elements($subsection)) { for (const elem of settings_org.get_subsection_property_elements($subsection)) {
settings_org.discard_property_element_changes(elem, false, sub); settings_org.discard_property_element_changes(elem, false, sub);
} }
stream_ui_updates.update_default_stream_and_stream_privacy_state($subsection);
const $save_btn_controls = $(e.target).closest(".save-button-controls"); const $save_btn_controls = $(e.target).closest(".save-button-controls");
settings_org.change_save_button_state($save_btn_controls, "discarded"); settings_org.change_save_button_state($save_btn_controls, "discarded");
}, },

View File

@ -250,6 +250,14 @@ export function update_can_remove_subscribers_group_id(sub, new_value) {
stream_edit_subscribers.rerender_subscribers_list(sub); stream_edit_subscribers.rerender_subscribers_list(sub);
} }
export function update_is_default_stream() {
const active_stream_id = get_active_data().id;
if (active_stream_id) {
const sub = sub_store.get(active_stream_id);
stream_ui_updates.update_setting_element(sub, "is_default_stream");
}
}
export function set_color(stream_id, color) { export function set_color(stream_id, color) {
const sub = sub_store.get(stream_id); const sub = sub_store.get(stream_id);
stream_edit.set_stream_property(sub, "color", color); stream_edit.set_stream_property(sub, "color", color);
@ -1156,7 +1164,7 @@ export function update_public_stream_privacy_option_state($container) {
$public_stream_elem.prop("disabled", !settings_data.user_can_create_public_streams()); $public_stream_elem.prop("disabled", !settings_data.user_can_create_public_streams());
} }
export function update_private_stream_privacy_option_state($container) { export function update_private_stream_privacy_option_state($container, is_default_stream = false) {
// Disable both "Private, shared history" and "Private, protected history" options. // Disable both "Private, shared history" and "Private, protected history" options.
const $private_stream_elem = $container.find( const $private_stream_elem = $container.find(
`input[value='${CSS.escape(stream_data.stream_privacy_policy_values.private.code)}']`, `input[value='${CSS.escape(stream_data.stream_privacy_policy_values.private.code)}']`,
@ -1167,11 +1175,18 @@ export function update_private_stream_privacy_option_state($container) {
)}']`, )}']`,
); );
$private_stream_elem.prop("disabled", !settings_data.user_can_create_private_streams()); const disable_private_stream_options =
$private_with_public_history_elem.prop( is_default_stream || !settings_data.user_can_create_private_streams();
"disabled",
!settings_data.user_can_create_private_streams(), $private_stream_elem.prop("disabled", disable_private_stream_options);
); $private_with_public_history_elem.prop("disabled", disable_private_stream_options);
$private_stream_elem
.closest("div")
.toggleClass("default_stream_private_tooltip", is_default_stream);
$private_with_public_history_elem
.closest("div")
.toggleClass("default_stream_private_tooltip", is_default_stream);
} }
export function hide_or_disable_stream_privacy_options_if_required($container) { export function hide_or_disable_stream_privacy_options_if_required($container) {

View File

@ -113,6 +113,24 @@ export function update_regular_sub_settings(sub) {
} }
} }
export function update_default_stream_and_stream_privacy_state($container) {
const $default_stream = $container.find(".default-stream");
const privacy_type = $container.find("input[type=radio][name=privacy]:checked").val();
const is_invite_only =
privacy_type === "invite-only" || privacy_type === "invite-only-public-history";
// If a private stream option is selected, the default stream option is disabled.
$default_stream.find("input").prop("disabled", is_invite_only);
$default_stream.toggleClass(
"control-label-disabled default_stream_private_tooltip",
is_invite_only,
);
// If the default stream option is checked, the private stream options are disabled.
const is_default_stream = $default_stream.find("input").prop("checked");
stream_settings_ui.update_private_stream_privacy_option_state($container, is_default_stream);
}
export function enable_or_disable_permission_settings_in_edit_panel(sub) { export function enable_or_disable_permission_settings_in_edit_panel(sub) {
if (!hash_util.is_editing_stream(sub.stream_id)) { if (!hash_util.is_editing_stream(sub.stream_id)) {
return; return;
@ -129,6 +147,8 @@ export function enable_or_disable_permission_settings_in_edit_panel(sub) {
return; return;
} }
update_default_stream_and_stream_privacy_state($stream_settings);
const disable_message_retention_setting = const disable_message_retention_setting =
!page_params.zulip_plan_is_not_limited || !page_params.is_owner; !page_params.zulip_plan_is_not_limited || !page_params.is_owner;
$stream_settings $stream_settings

View File

@ -292,6 +292,28 @@ export function initialize() {
}, },
}); });
delegate("body", {
target: [".settings-radio-input-parent.default_stream_private_tooltip"],
content: $t({
defaultMessage: "Default streams for new users cannot be made private.",
}),
appendTo: () => document.body,
onHidden(instance) {
instance.destroy();
},
});
delegate("body", {
target: [".default-stream.default_stream_private_tooltip"],
content: $t({
defaultMessage: "Private streams cannot be default streams for new users.",
}),
appendTo: () => document.body,
onHidden(instance) {
instance.destroy();
},
});
delegate("body", { delegate("body", {
target: ["#generate_multiuse_invite_radio_container.disabled_setting_tooltip"], target: ["#generate_multiuse_invite_radio_container.disabled_setting_tooltip"],
content: $t({ content: $t({

View File

@ -1006,6 +1006,14 @@ div.settings-radio-input-parent {
cursor: not-allowed; cursor: not-allowed;
} }
} }
&.default_stream_private_tooltip {
cursor: not-allowed;
& label {
pointer-events: none;
}
}
} }
.stream-permissions, .stream-permissions,
@ -1039,6 +1047,15 @@ div.settings-radio-input-parent {
max-width: 100%; max-width: 100%;
height: 30px; height: 30px;
} }
.default-stream {
margin: 25px 0;
width: fit-content;
.inline {
display: inline;
}
}
} }
#change_user_group_description, #change_user_group_description,

View File

@ -56,6 +56,7 @@
stream_post_policy_values=../stream_post_policy_values stream_post_policy_values=../stream_post_policy_values
stream_privacy_policy_values=../stream_privacy_policy_values stream_privacy_policy_values=../stream_privacy_policy_values
stream_privacy_policy=../stream_privacy_policy stream_privacy_policy=../stream_privacy_policy
check_default_stream=../check_default_stream
zulip_plan_is_not_limited=../zulip_plan_is_not_limited zulip_plan_is_not_limited=../zulip_plan_is_not_limited
upgrade_text_for_wide_organization_logo=../upgrade_text_for_wide_organization_logo upgrade_text_for_wide_organization_logo=../upgrade_text_for_wide_organization_logo
is_business_type_org=../is_business_type_org is_business_type_org=../is_business_type_org

View File

@ -16,6 +16,18 @@
</div> </div>
</div> </div>
{{#if is_stream_edit}}
<div class="default-stream">
{{> ../settings/settings_checkbox
prefix="id_"
setting_name="is_default_stream"
is_checked=check_default_stream
label="Default stream for new users"
help_link="/help/set-default-streams-for-new-users"
}}
</div>
{{/if}}
<div class="input-group"> <div class="input-group">
<label class="dropdown-title">{{t 'Who can post to the stream?'}} <label class="dropdown-title">{{t 'Who can post to the stream?'}}
{{> ../help_link_widget link="/help/stream-sending-policy" }} {{> ../help_link_widget link="/help/stream-sending-policy" }}

View File

@ -293,6 +293,7 @@ run_test("custom profile fields", ({override}) => {
run_test("default_streams", ({override}) => { run_test("default_streams", ({override}) => {
const event = event_fixtures.default_streams; const event = event_fixtures.default_streams;
override(settings_streams, "update_default_streams_table", noop); override(settings_streams, "update_default_streams_table", noop);
override(stream_settings_ui, "update_is_default_stream", noop);
const stub = make_stub(); const stub = make_stub();
override(stream_data, "set_realm_default_streams", stub.f); override(stream_data, "set_realm_default_streams", stub.f);
dispatch(event); dispatch(event);

View File

@ -15978,6 +15978,20 @@ paths:
type: boolean type: boolean
example: false example: false
required: false required: false
- name: is_default_stream
in: query
description: |
Add or remove the stream as a [default stream][default-stream]
for new users joining the organization.
[default-stream]: /help/set-default-streams-for-new-users
**Changes**: New in Zulip 8.0 (feature level 200). Previously, default stream status
could only be changed using the [dedicated API endpoint](/api/add-default-stream).
schema:
type: boolean
example: false
required: false
- $ref: "#/components/parameters/StreamPostPolicy" - $ref: "#/components/parameters/StreamPostPolicy"
- $ref: "#/components/parameters/MessageRetentionDays" - $ref: "#/components/parameters/MessageRetentionDays"
- $ref: "#/components/parameters/CanRemoveSubscribersGroupId" - $ref: "#/components/parameters/CanRemoveSubscribersGroupId"

View File

@ -714,7 +714,7 @@ class StreamAdminTest(ZulipTestCase):
"is_private": orjson.dumps(True).decode(), "is_private": orjson.dumps(True).decode(),
} }
result = self.client_patch(f"/json/streams/{default_stream.id}", params) result = self.client_patch(f"/json/streams/{default_stream.id}", params)
self.assert_json_error(result, "Default streams cannot be made private.") self.assert_json_error(result, "A default stream cannot be private.")
self.assertFalse(default_stream.invite_only) self.assertFalse(default_stream.invite_only)
do_change_user_role(user_profile, UserProfile.ROLE_MEMBER, acting_user=None) do_change_user_role(user_profile, UserProfile.ROLE_MEMBER, acting_user=None)
@ -1051,6 +1051,76 @@ class StreamAdminTest(ZulipTestCase):
).decode() ).decode()
self.assertEqual(realm_audit_log.extra_data, expected_extra_data) self.assertEqual(realm_audit_log.extra_data, expected_extra_data)
def test_add_and_remove_stream_as_default(self) -> None:
user_profile = self.example_user("hamlet")
self.login_user(user_profile)
realm = user_profile.realm
stream = self.make_stream("stream", realm=realm)
stream_id = self.subscribe(user_profile, "stream").id
params = {
"is_default_stream": orjson.dumps(True).decode(),
}
result = self.client_patch(f"/json/streams/{stream_id}", params)
self.assert_json_error(result, "Must be an organization administrator")
self.assertFalse(stream_id in get_default_stream_ids_for_realm(realm.id))
do_change_user_role(user_profile, UserProfile.ROLE_REALM_ADMINISTRATOR, acting_user=None)
result = self.client_patch(f"/json/streams/{stream_id}", params)
self.assert_json_success(result)
self.assertTrue(stream_id in get_default_stream_ids_for_realm(realm.id))
params = {
"is_private": orjson.dumps(True).decode(),
}
result = self.client_patch(f"/json/streams/{stream_id}", params)
self.assert_json_error(result, "A default stream cannot be private.")
stream.refresh_from_db()
self.assertFalse(stream.invite_only)
params = {
"is_private": orjson.dumps(True).decode(),
"is_default_stream": orjson.dumps(False).decode(),
}
result = self.client_patch(f"/json/streams/{stream_id}", params)
self.assert_json_success(result)
stream.refresh_from_db()
self.assertTrue(stream.invite_only)
self.assertFalse(stream_id in get_default_stream_ids_for_realm(realm.id))
stream_2 = self.make_stream("stream_2", realm=realm)
stream_2_id = self.subscribe(user_profile, "stream_2").id
bad_params = {
"is_default_stream": orjson.dumps(True).decode(),
"is_private": orjson.dumps(True).decode(),
}
result = self.client_patch(f"/json/streams/{stream_2_id}", bad_params)
self.assert_json_error(result, "A default stream cannot be private.")
stream.refresh_from_db()
self.assertFalse(stream_2.invite_only)
self.assertFalse(stream_2_id in get_default_stream_ids_for_realm(realm.id))
private_stream = self.make_stream("private_stream", realm=realm, invite_only=True)
private_stream_id = self.subscribe(user_profile, "private_stream").id
params = {
"is_default_stream": orjson.dumps(True).decode(),
}
result = self.client_patch(f"/json/streams/{private_stream_id}", params)
self.assert_json_error(result, "A default stream cannot be private.")
self.assertFalse(private_stream_id in get_default_stream_ids_for_realm(realm.id))
params = {
"is_private": orjson.dumps(False).decode(),
"is_default_stream": orjson.dumps(True).decode(),
}
result = self.client_patch(f"/json/streams/{private_stream_id}", params)
self.assert_json_success(result)
private_stream.refresh_from_db()
self.assertFalse(private_stream.invite_only)
self.assertTrue(private_stream_id in get_default_stream_ids_for_realm(realm.id))
def test_stream_permission_changes_updates_updates_attachments(self) -> None: def test_stream_permission_changes_updates_updates_attachments(self) -> None:
self.login("desdemona") self.login("desdemona")
fp = StringIO("zulip!") fp = StringIO("zulip!")

View File

@ -262,6 +262,7 @@ def update_stream_backend(
), ),
is_private: Optional[bool] = REQ(json_validator=check_bool, default=None), is_private: Optional[bool] = REQ(json_validator=check_bool, default=None),
is_announcement_only: Optional[bool] = REQ(json_validator=check_bool, default=None), is_announcement_only: Optional[bool] = REQ(json_validator=check_bool, default=None),
is_default_stream: Optional[bool] = REQ(json_validator=check_bool, default=None),
stream_post_policy: Optional[int] = REQ( stream_post_policy: Optional[int] = REQ(
json_validator=check_int_in(Stream.STREAM_POST_POLICY_TYPES), default=None json_validator=check_int_in(Stream.STREAM_POST_POLICY_TYPES), default=None
), ),
@ -290,6 +291,12 @@ def update_stream_backend(
else: else:
proposed_is_web_public = stream.is_web_public proposed_is_web_public = stream.is_web_public
if is_default_stream is not None:
proposed_is_default_stream = is_default_stream
else:
default_stream_ids = get_default_stream_ids_for_realm(stream.realm_id)
proposed_is_default_stream = stream.id in default_stream_ids
if stream.realm.is_zephyr_mirror_realm: if stream.realm.is_zephyr_mirror_realm:
# In the Zephyr mirroring model, history is unconditionally # In the Zephyr mirroring model, history is unconditionally
# not public to subscribers, even for public streams. # not public to subscribers, even for public streams.
@ -319,12 +326,11 @@ def update_stream_backend(
else: else:
raise JsonableError(_("Invalid parameters")) raise JsonableError(_("Invalid parameters"))
if is_private is not None: # Ensure that a stream cannot be both a default stream for new users and private
# Default streams cannot be made private. if proposed_is_private and proposed_is_default_stream:
default_stream_ids = get_default_stream_ids_for_realm(stream.realm_id) raise JsonableError(_("A default stream cannot be private."))
if is_private and stream.id in default_stream_ids:
raise JsonableError(_("Default streams cannot be made private."))
if is_private is not None:
# We require even realm administrators to be actually # We require even realm administrators to be actually
# subscribed to make a private stream public, via this # subscribed to make a private stream public, via this
# stricted access_stream check. # stricted access_stream check.
@ -352,6 +358,12 @@ def update_stream_backend(
acting_user=user_profile, acting_user=user_profile,
) )
if is_default_stream is not None:
if is_default_stream:
do_add_default_stream(stream)
else:
do_remove_default_stream(stream)
if message_retention_days is not None: if message_retention_days is not None:
if not user_profile.is_realm_owner: if not user_profile.is_realm_owner:
raise OrganizationOwnerRequiredError raise OrganizationOwnerRequiredError