From b31024be47a4bede541c0576100978b5d91e5381 Mon Sep 17 00:00:00 2001 From: Vector73 Date: Thu, 13 Mar 2025 10:03:41 +0000 Subject: [PATCH] saved_snippets: Add support for editing saved snippets. Fixes #33708. --- api_docs/changelog.md | 7 ++ api_docs/include/rest-endpoints.md | 1 + version.py | 2 +- web/src/saved_snippets.ts | 3 +- web/src/saved_snippets_ui.ts | 76 ++++++++++++++- web/src/server_events_dispatch.js | 6 +- web/src/tippyjs.ts | 10 ++ web/styles/compose.css | 3 +- web/styles/zulip.css | 15 ++- web/templates/dropdown_list.hbs | 3 + web/templates/edit_saved_snippet_modal.hbs | 10 ++ web/tests/dispatch.test.cjs | 12 ++- web/tests/lib/events.cjs | 11 +++ web/tests/saved_snippets.test.cjs | 7 +- zerver/actions/saved_snippets.py | 29 ++++++ zerver/lib/event_schema.py | 2 + zerver/lib/event_types.py | 6 ++ zerver/lib/events.py | 5 + zerver/openapi/python_examples.py | 33 +++++-- zerver/openapi/zulip.yaml | 107 +++++++++++++++++++++ zerver/tests/test_events.py | 11 ++- zerver/tests/test_saved_snippets.py | 30 ++++++ zerver/views/saved_snippets.py | 31 +++++- zproject/urls.py | 7 +- 24 files changed, 405 insertions(+), 22 deletions(-) create mode 100644 web/templates/edit_saved_snippet_modal.hbs diff --git a/api_docs/changelog.md b/api_docs/changelog.md index c627ad355a..b5fc3ef257 100644 --- a/api_docs/changelog.md +++ b/api_docs/changelog.md @@ -20,6 +20,13 @@ format used by the Zulip server that they are interacting with. ## Changes in Zulip 10.0 +**Feature level 368** + +* [`GET /events`](/api/get-events): An event with `type: "saved_snippet"` + and `op: "update"` is sent to the current user when a saved snippet is edited. +* [`PATCH /saved_snippets/{saved_snippet_id}`](/api/edit-saved-snippet): + Added a new endpoint for editing a saved snippet. + **Feature level 367** * [`POST /register`](/api/register-queue), [`POST /events`](/api/get-events): diff --git a/api_docs/include/rest-endpoints.md b/api_docs/include/rest-endpoints.md index 85516b39dd..035f4de19b 100644 --- a/api_docs/include/rest-endpoints.md +++ b/api_docs/include/rest-endpoints.md @@ -34,6 +34,7 @@ * [Delete a draft](/api/delete-draft) * [Get all saved snippets](/api/get-saved-snippets) * [Create a saved snippet](/api/create-saved-snippet) +* [Edit a saved snippet](/api/edit-saved-snippet) * [Delete a saved snippet](/api/delete-saved-snippet) #### Channels diff --git a/version.py b/version.py index 3e0d9df7ac..c0666fef1a 100644 --- a/version.py +++ b/version.py @@ -34,7 +34,7 @@ DESKTOP_WARNING_VERSION = "5.9.3" # new level means in api_docs/changelog.md, as well as "**Changes**" # entries in the endpoint's documentation in `zulip.yaml`. -API_FEATURE_LEVEL = 367 +API_FEATURE_LEVEL = 368 # 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 diff --git a/web/src/saved_snippets.ts b/web/src/saved_snippets.ts index e3a3d2bb27..5bae6e5956 100644 --- a/web/src/saved_snippets.ts +++ b/web/src/saved_snippets.ts @@ -21,7 +21,7 @@ export function get_saved_snippet_by_id(saved_snippet_id: number): SavedSnippet return saved_snippet; } -export function add_saved_snippet(saved_snippet: SavedSnippet): void { +export function update_saved_snippet_dict(saved_snippet: SavedSnippet): void { saved_snippets_dict.set(saved_snippet.id, saved_snippet); } @@ -39,6 +39,7 @@ export function get_options_for_dropdown_widget(): Option[] { description: saved_snippet.content, bold_current_selection: true, has_delete_icon: true, + has_edit_icon: true, })); return options; diff --git a/web/src/saved_snippets_ui.ts b/web/src/saved_snippets_ui.ts index 8b770e751b..0b62b989d9 100644 --- a/web/src/saved_snippets_ui.ts +++ b/web/src/saved_snippets_ui.ts @@ -4,6 +4,7 @@ import type * as tippy from "tippy.js"; import render_add_saved_snippet_modal from "../templates/add_saved_snippet_modal.hbs"; import render_confirm_delete_saved_snippet from "../templates/confirm_dialog/confirm_delete_saved_snippet.hbs"; +import render_edit_saved_snippet_modal from "../templates/edit_saved_snippet_modal.hbs"; import * as channel from "./channel.ts"; import * as compose_ui from "./compose_ui.ts"; @@ -25,9 +26,26 @@ function submit_create_saved_snippet_form(): void { const content = $("#add-new-saved-snippet-modal .saved-snippet-content") .val() ?.trim(); - if (title && content) { - dialog_widget.submit_api_request(channel.post, "/json/saved_snippets", {title, content}); - } + + assert(title && content); + + dialog_widget.submit_api_request(channel.post, "/json/saved_snippets", {title, content}); +} + +function submit_edit_saved_snippet_form(saved_snippet_id: number): void { + const title = $("#edit-saved-snippet-modal .saved-snippet-title") + .val() + ?.trim(); + const content = $("#edit-saved-snippet-modal .saved-snippet-content") + .val() + ?.trim(); + + assert(title && content); + + dialog_widget.submit_api_request(channel.patch, `/json/saved_snippets/${saved_snippet_id}`, { + title, + content, + }); } function update_submit_button_state(): void { @@ -49,6 +67,26 @@ function saved_snippet_modal_post_render(): void { $("#add-new-saved-snippet-modal").on("input", "input,textarea", update_submit_button_state); } +function saved_snippet_edit_modal_post_render(saved_snippet: saved_snippets.SavedSnippet): void { + $("#edit-saved-snippet-modal").on("input", "input,textarea", () => { + const title = $("#edit-saved-snippet-modal .saved-snippet-title") + .val() + ?.trim(); + const content = $("#edit-saved-snippet-modal .saved-snippet-content") + .val() + ?.trim(); + const $submit_button = $("#edit-saved-snippet-modal .dialog_submit_button"); + + $submit_button.prop("disabled", true); + if (title === saved_snippet.title && content === saved_snippet.content) { + return; + } + if (title && content) { + $submit_button.prop("disabled", false); + } + }); +} + export function rerender_dropdown_widget(): void { if (saved_snippets_widget && saved_snippets_dropdown) { const options = saved_snippets.get_options_for_dropdown_widget(); @@ -88,6 +126,38 @@ function item_click_callback( return; } + if ( + $(event.target).closest(".saved_snippets-dropdown-list-container .dropdown-list-edit") + .length > 0 + ) { + const saved_snippet_id = $(event.currentTarget).attr("data-unique-id"); + assert(saved_snippet_id !== undefined); + + const saved_snippet = saved_snippets.get_saved_snippet_by_id( + Number.parseInt(saved_snippet_id, 10), + ); + assert(saved_snippet !== undefined); + dialog_widget.launch({ + html_heading: $t_html({defaultMessage: "Edit saved snippet"}), + html_body: render_edit_saved_snippet_modal({ + title: saved_snippet.title, + content: saved_snippet.content, + }), + html_submit_button: $t_html({defaultMessage: "Save"}), + id: "edit-saved-snippet-modal", + form_id: "edit-saved-snippet-form", + update_submit_disabled_state_on_change: true, + on_click() { + submit_edit_saved_snippet_form(saved_snippet.id); + }, + on_shown: () => $("#edit-saved-snippet-title").trigger("focus"), + post_render() { + saved_snippet_edit_modal_post_render(saved_snippet); + }, + }); + return; + } + dropdown.hide(); // Get target textarea where the "Add saved snippet" button is clicked. const $target_element = $(dropdown.reference); diff --git a/web/src/server_events_dispatch.js b/web/src/server_events_dispatch.js index e18c0d155d..fb86316c42 100644 --- a/web/src/server_events_dispatch.js +++ b/web/src/server_events_dispatch.js @@ -577,13 +577,17 @@ export function dispatch_normal_event(event) { case "saved_snippets": switch (event.op) { case "add": - saved_snippets.add_saved_snippet(event.saved_snippet); + saved_snippets.update_saved_snippet_dict(event.saved_snippet); saved_snippets_ui.rerender_dropdown_widget(); break; case "remove": saved_snippets.remove_saved_snippet(event.saved_snippet_id); saved_snippets_ui.rerender_dropdown_widget(); break; + case "update": + saved_snippets.update_saved_snippet_dict(event.saved_snippet); + saved_snippets_ui.rerender_dropdown_widget(); + break; } break; case "scheduled_messages": diff --git a/web/src/tippyjs.ts b/web/src/tippyjs.ts index 0cd29f72a9..a4ee20dde1 100644 --- a/web/src/tippyjs.ts +++ b/web/src/tippyjs.ts @@ -687,6 +687,16 @@ export function initialize(): void { }, }); + tippy.delegate("body", { + target: ".saved_snippets-dropdown-list-container .dropdown-list-edit", + content: $t({defaultMessage: "Edit snippet"}), + delay: LONG_HOVER_DELAY, + appendTo: () => document.body, + onHidden(instance) { + instance.destroy(); + }, + }); + tippy.delegate("body", { target: ".generate-channel-email-button-container.disabled_setting_tooltip", content: $t({defaultMessage: "You do not have permission to post in this channel."}), diff --git a/web/styles/compose.css b/web/styles/compose.css index d0cf491558..73b5182143 100644 --- a/web/styles/compose.css +++ b/web/styles/compose.css @@ -1706,7 +1706,8 @@ textarea.new_message_textarea { } } -#add-new-saved-snippet-modal { +#add-new-saved-snippet-modal, +#edit-saved-snippet-modal { & .saved-snippet-title { width: 97%; margin-bottom: 20px; diff --git a/web/styles/zulip.css b/web/styles/zulip.css index b18061ae87..8b73e4e683 100644 --- a/web/styles/zulip.css +++ b/web/styles/zulip.css @@ -2194,13 +2194,23 @@ body:not(.spectator-view) { top: 0.2419em; } - .dropdown-list-delete { + .dropdown-list-delete, + .dropdown-list-edit { position: absolute; top: 0; right: 5px; visibility: hidden; } + .dropdown-list-edit { + color: var(--color-text-neutral-icon-button); + right: 30px; + + &:hover { + color: var(--color-text-neutral-icon-button-hover); + } + } + &:focus, &:hover { color: var(--color-dropdown-item); @@ -2208,7 +2218,8 @@ body:not(.spectator-view) { background-color: var(--background-color-active-dropdown-item); outline: none; - .dropdown-list-delete { + .dropdown-list-delete, + .dropdown-list-edit { visibility: visible; } } diff --git a/web/templates/dropdown_list.hbs b/web/templates/dropdown_list.hbs index 75e502efb2..7d31399fed 100644 --- a/web/templates/dropdown_list.hbs +++ b/web/templates/dropdown_list.hbs @@ -5,6 +5,9 @@ {{#if bold_current_selection}} {{name}} + {{#if has_edit_icon}} + {{> components/icon_button custom_classes="dropdown-list-edit" intent="brand" icon="edit" aria-label=(t "Edit snippet") }} + {{/if}} {{#if has_delete_icon}} {{> components/icon_button custom_classes="dropdown-list-delete" intent="danger" icon="trash" aria-label=(t "Delete snippet") }} {{/if}} diff --git a/web/templates/edit_saved_snippet_modal.hbs b/web/templates/edit_saved_snippet_modal.hbs new file mode 100644 index 0000000000..0b1ec413e4 --- /dev/null +++ b/web/templates/edit_saved_snippet_modal.hbs @@ -0,0 +1,10 @@ +
+
+ + +
{{t "Content" }}
+ +
+
diff --git a/web/tests/dispatch.test.cjs b/web/tests/dispatch.test.cjs index f8847fdcf4..ca16448def 100644 --- a/web/tests/dispatch.test.cjs +++ b/web/tests/dispatch.test.cjs @@ -194,7 +194,7 @@ run_test("saved_snippets", ({override}) => { override(saved_snippets_ui, "rerender_dropdown_widget", noop); { const stub = make_stub(); - override(saved_snippets, "add_saved_snippet", stub.f); + override(saved_snippets, "update_saved_snippet_dict", stub.f); dispatch(add_event); assert.equal(stub.num_calls, 1); @@ -210,6 +210,16 @@ run_test("saved_snippets", ({override}) => { assert.equal(stub.num_calls, 1); assert_same(stub.get_args("event").event, remove_event.saved_snippet_id); } + + const update_event = event_fixtures.saved_snippets__update; + { + const stub = make_stub(); + override(saved_snippets, "update_saved_snippet_dict", stub.f); + + dispatch(update_event); + assert.equal(stub.num_calls, 1); + assert_same(stub.get_args("event").event, update_event.saved_snippet); + } }); run_test("attachments", ({override}) => { diff --git a/web/tests/lib/events.cjs b/web/tests/lib/events.cjs index 1bde587e64..38847b8658 100644 --- a/web/tests/lib/events.cjs +++ b/web/tests/lib/events.cjs @@ -666,6 +666,17 @@ exports.fixtures = { saved_snippet_id: 1, }, + saved_snippets__update: { + type: "saved_snippets", + op: "update", + saved_snippet: { + id: 1, + title: "Example 2", + content: "Welcome to the organization.", + date_created: 1681662420, + }, + }, + scheduled_messages__add: { type: "scheduled_messages", op: "add", diff --git a/web/tests/saved_snippets.test.cjs b/web/tests/saved_snippets.test.cjs index 3ff56bd8ba..aef6f88ec2 100644 --- a/web/tests/saved_snippets.test.cjs +++ b/web/tests/saved_snippets.test.cjs @@ -41,7 +41,7 @@ run_test("add_saved_snippet", () => { content: "Test content", date_created: 128374878, }; - saved_snippets.add_saved_snippet(saved_snippet); + saved_snippets.update_saved_snippet_dict(saved_snippet); const my_saved_snippet = saved_snippets.get_saved_snippet_by_id(2); assert.equal(my_saved_snippet, saved_snippet); @@ -54,7 +54,7 @@ run_test("options for dropdown widget", () => { content: "Test content", date_created: 128374876, }; - saved_snippets.add_saved_snippet(saved_snippet); + saved_snippets.update_saved_snippet_dict(saved_snippet); assert.deepEqual(saved_snippets.get_options_for_dropdown_widget(), [ { @@ -63,6 +63,7 @@ run_test("options for dropdown widget", () => { description: "Test content", bold_current_selection: true, has_delete_icon: true, + has_edit_icon: true, }, { unique_id: 2, @@ -70,6 +71,7 @@ run_test("options for dropdown widget", () => { description: "Test content", bold_current_selection: true, has_delete_icon: true, + has_edit_icon: true, }, { unique_id: 1, @@ -77,6 +79,7 @@ run_test("options for dropdown widget", () => { description: "Test content", bold_current_selection: true, has_delete_icon: true, + has_edit_icon: true, }, ]); }); diff --git a/zerver/actions/saved_snippets.py b/zerver/actions/saved_snippets.py index 59ca88467f..522ac9d169 100644 --- a/zerver/actions/saved_snippets.py +++ b/zerver/actions/saved_snippets.py @@ -42,6 +42,35 @@ def do_create_saved_snippet( return saved_snippet +def do_edit_saved_snippet( + saved_snippet_id: int, + title: str | None, + content: str | None, + user_profile: UserProfile, +) -> SavedSnippet: + try: + saved_snippet = SavedSnippet.objects.get(id=saved_snippet_id, user_profile=user_profile) + except SavedSnippet.DoesNotExist: + raise ResourceNotFoundError(_("Saved snippet does not exist.")) + + if title is not None: + saved_snippet.title = title + if content is not None: + saved_snippet.content = content + + with transaction.atomic(durable=True): + saved_snippet.save() + + event = { + "type": "saved_snippets", + "op": "update", + "saved_snippet": saved_snippet.to_api_dict(), + } + send_event_on_commit(user_profile.realm, event, [user_profile.id]) + + return saved_snippet + + def do_get_saved_snippets(user_profile: UserProfile) -> list[dict[str, Any]]: saved_snippets = SavedSnippet.objects.filter(user_profile=user_profile) diff --git a/zerver/lib/event_schema.py b/zerver/lib/event_schema.py index 0ca9359eea..cf067c4683 100644 --- a/zerver/lib/event_schema.py +++ b/zerver/lib/event_schema.py @@ -63,6 +63,7 @@ from zerver.lib.event_types import ( EventRestart, EventSavedSnippetsAdd, EventSavedSnippetsRemove, + EventSavedSnippetsUpdate, EventScheduledMessagesAdd, EventScheduledMessagesRemove, EventScheduledMessagesUpdate, @@ -189,6 +190,7 @@ check_realm_user_remove = make_checker(EventRealmUserRemove) check_restart = make_checker(EventRestart) check_saved_snippets_add = make_checker(EventSavedSnippetsAdd) check_saved_snippets_remove = make_checker(EventSavedSnippetsRemove) +check_saved_snippets_update = make_checker(EventSavedSnippetsUpdate) check_scheduled_message_add = make_checker(EventScheduledMessagesAdd) check_scheduled_message_remove = make_checker(EventScheduledMessagesRemove) check_scheduled_message_update = make_checker(EventScheduledMessagesUpdate) diff --git a/zerver/lib/event_types.py b/zerver/lib/event_types.py index e8282c0a48..9efb546107 100644 --- a/zerver/lib/event_types.py +++ b/zerver/lib/event_types.py @@ -710,6 +710,12 @@ class EventSavedSnippetsAdd(BaseEvent): saved_snippet: SavedSnippetFields +class EventSavedSnippetsUpdate(BaseEvent): + type: Literal["saved_snippets"] + op: Literal["update"] + saved_snippet: SavedSnippetFields + + class EventSavedSnippetsRemove(BaseEvent): type: Literal["saved_snippets"] op: Literal["remove"] diff --git a/zerver/lib/events.py b/zerver/lib/events.py index 41971d0ed1..ed8f78d3b5 100644 --- a/zerver/lib/events.py +++ b/zerver/lib/events.py @@ -969,6 +969,11 @@ def apply_event( if saved_snippet["id"] == event["saved_snippet_id"]: del state["saved_snippets"][idx] break + elif event["op"] == "update": + for idx, saved_snippet in enumerate(state["saved_snippets"]): + if saved_snippet["id"] == event["saved_snippet"]["id"]: + state["saved_snippets"][idx] = event["saved_snippet"] + break elif event["type"] == "drafts": if event["op"] == "add": diff --git a/zerver/openapi/python_examples.py b/zerver/openapi/python_examples.py index 9b020e7f25..078053be18 100644 --- a/zerver/openapi/python_examples.py +++ b/zerver/openapi/python_examples.py @@ -1152,7 +1152,7 @@ def create_saved_snippet(client: Client) -> None: @openapi_test_function("/saved_snippets:get") -def get_saved_snippets(client: Client) -> None: +def get_saved_snippets(client: Client) -> int: # {code_example|start} # Get all the saved snippets. result = client.call_endpoint( @@ -1163,12 +1163,26 @@ def get_saved_snippets(client: Client) -> None: assert_success_response(result) validate_against_openapi_schema(result, "/saved_snippets", "get", "200") + return result["saved_snippets"][0]["id"] + + +@openapi_test_function("/saved_snippets/{saved_snippet_id}:patch") +def edit_saved_snippet(client: Client, saved_snippet_id: int) -> None: + # {code_example|start} + # Edit a saved snippet. + request = {"title": "New welcome message", "content": "Welcome to Zulip!"} + result = client.call_endpoint( + request=request, + url=f"/saved_snippets/{saved_snippet_id}", + method="PATCH", + ) + # {code_example|end} + assert_success_response(result) + validate_against_openapi_schema(result, "/saved_snippets/{saved_snippet_id}", "patch", "200") + @openapi_test_function("/saved_snippets/{saved_snippet_id}:delete") -def delete_saved_snippet(client: Client) -> None: - saved_snippet_id = client.call_endpoint(url="/saved_snippets", method="GET")["saved_snippets"][ - 0 - ]["id"] +def delete_saved_snippet(client: Client, saved_snippet_id: int) -> None: # {code_example|start} # Delete a saved snippet. result = client.call_endpoint( @@ -1840,8 +1854,13 @@ def test_users(client: Client, owner_client: Client) -> None: get_alert_words(client) add_alert_words(client) create_saved_snippet(client) - get_saved_snippets(client) - delete_saved_snippet(client) + # Calling this again to pass the curl examples tests as the + # `delete-saved-snippet` endpoint is called before `edit-saved-snippet` + # causing "Saved snippet does not exist." error. + create_saved_snippet(client) + saved_snippet_id = get_saved_snippets(client) + edit_saved_snippet(client, saved_snippet_id) + delete_saved_snippet(client, saved_snippet_id) remove_alert_words(client) add_apns_token(client) remove_apns_token(client) diff --git a/zerver/openapi/zulip.yaml b/zerver/openapi/zulip.yaml index 692ecef739..f17da7d413 100644 --- a/zerver/openapi/zulip.yaml +++ b/zerver/openapi/zulip.yaml @@ -5507,6 +5507,41 @@ paths: "date_created": 1681662420, }, } + - type: object + additionalProperties: false + description: | + Event containing details of the edited saved snippet. + + Clients should update the existing saved snippet with the + ID provided in the `saved_snippet` object. + + **Changes**: New in Zulip 10.0 (feature level 368). + properties: + id: + $ref: "#/components/schemas/EventIdSchema" + type: + allOf: + - $ref: "#/components/schemas/EventTypeSchema" + - enum: + - saved_snippets + op: + type: string + enum: + - update + saved_snippet: + $ref: "#/components/schemas/SavedSnippet" + example: + { + "type": "saved_snippets", + "op": "update", + "saved_snippet": + { + "id": 1, + "title": "Example", + "content": "Welcome to the organization.", + "date_created": 1681662420, + }, + } - type: object additionalProperties: false description: | @@ -6398,6 +6433,78 @@ paths: A typical failed JSON response for when either title or content is empty: /saved_snippets/{saved_snippet_id}: + patch: + operationId: edit-saved-snippet + tags: ["drafts"] + summary: Edit a saved snippet + description: | + Edit a saved snippet for the current user. + + **Changes**: New in Zulip 10.0 (feature level 368). + parameters: + - name: saved_snippet_id + in: path + schema: + type: integer + description: | + The ID of the saved snippet to edit. + required: true + example: 3 + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + title: + type: string + description: | + The title of the saved snippet. + example: Welcome message + content: + type: string + description: | + The content of the saved snippet in text/markdown format. + + Clients should insert this content into a message when using + a saved snippet. + example: Welcome to the organization. + responses: + "200": + $ref: "#/components/responses/SimpleSuccess" + "400": + description: Bad request. + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CodedError" + - example: + { + "code": "BAD_REQUEST", + "msg": "No new data is supplied", + "result": "error", + } + description: | + A typical failed JSON response for when neither title nor content is + provided in the request: + "404": + description: Not Found. + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CodedError" + - description: | + A typical failed JSON response for when no saved snippet exists + with the provided ID: + example: + { + "code": "BAD_REQUEST", + "result": "error", + "msg": "Saved snippet does not exist.", + } delete: operationId: delete-saved-snippet tags: ["drafts"] diff --git a/zerver/tests/test_events.py b/zerver/tests/test_events.py index b42720aa9b..baaae159ae 100644 --- a/zerver/tests/test_events.py +++ b/zerver/tests/test_events.py @@ -92,7 +92,11 @@ from zerver.actions.realm_settings import ( do_set_realm_user_default_setting, do_set_realm_zulip_update_announcements_stream, ) -from zerver.actions.saved_snippets import do_create_saved_snippet, do_delete_saved_snippet +from zerver.actions.saved_snippets import ( + do_create_saved_snippet, + do_delete_saved_snippet, + do_edit_saved_snippet, +) from zerver.actions.scheduled_messages import ( check_schedule_message, delete_scheduled_message, @@ -186,6 +190,7 @@ from zerver.lib.event_schema import ( check_realm_user_update, check_saved_snippets_add, check_saved_snippets_remove, + check_saved_snippets_update, check_scheduled_message_add, check_scheduled_message_remove, check_scheduled_message_update, @@ -1767,6 +1772,10 @@ class NormalActionsTest(BaseAction): saved_snippet_id = ( SavedSnippet.objects.filter(user_profile=self.user_profile).order_by("id")[0].id ) + with self.verify_action() as events: + do_edit_saved_snippet(saved_snippet_id, "Example", None, self.user_profile) + check_saved_snippets_update("events[0]", events[0]) + with self.verify_action() as events: do_delete_saved_snippet(saved_snippet_id, self.user_profile) check_saved_snippets_remove("events[0]", events[0]) diff --git a/zerver/tests/test_saved_snippets.py b/zerver/tests/test_saved_snippets.py index 1d9d741fab..fafbb202a4 100644 --- a/zerver/tests/test_saved_snippets.py +++ b/zerver/tests/test_saved_snippets.py @@ -49,6 +49,36 @@ class SavedSnippetTests(ZulipTestCase): msg=f"title is too long (limit: {SavedSnippet.MAX_TITLE_LENGTH} characters)", ) + def test_edit_saved_snippet(self) -> None: + """Tests updation of saved snippets.""" + + user = self.example_user("hamlet") + self.login_user(user) + saved_snippet_id = self.create_example_saved_snippet(user) + + result = self.client_patch( + f"/json/saved_snippets/{saved_snippet_id}", + {"title": "New title"}, + ) + self.assert_json_success(result) + + result = self.client_patch( + f"/json/saved_snippets/{saved_snippet_id}", {"content": "New content"} + ) + self.assert_json_success(result) + + result = self.client_patch( + f"/json/saved_snippets/{saved_snippet_id}", + ) + self.assert_json_error(result, "No new data is supplied", status_code=400) + + # Tests if error is thrown when the provided ID does not exist. + result = self.client_patch( + "/json/saved_snippets/10", + {"content": "New content"}, + ) + self.assert_json_error(result, "Saved snippet does not exist.", status_code=404) + def test_delete_saved_snippet(self) -> None: """Tests deletion of saved snippets.""" diff --git a/zerver/views/saved_snippets.py b/zerver/views/saved_snippets.py index e103c7a16a..877df46bb9 100644 --- a/zerver/views/saved_snippets.py +++ b/zerver/views/saved_snippets.py @@ -2,15 +2,18 @@ from typing import Annotated from django.conf import settings from django.http import HttpRequest, HttpResponse +from django.utils.translation import gettext as _ from pydantic import StringConstraints from zerver.actions.saved_snippets import ( do_create_saved_snippet, do_delete_saved_snippet, + do_edit_saved_snippet, do_get_saved_snippets, ) +from zerver.lib.exceptions import JsonableError from zerver.lib.response import json_success -from zerver.lib.typed_endpoint import typed_endpoint +from zerver.lib.typed_endpoint import PathOnly, typed_endpoint from zerver.models import SavedSnippet, UserProfile @@ -45,6 +48,32 @@ def create_saved_snippet( return json_success(request, data={"saved_snippet_id": saved_snippet.id}) +@typed_endpoint +def edit_saved_snippet( + request: HttpRequest, + user_profile: UserProfile, + *, + saved_snippet_id: PathOnly[int], + title: Annotated[ + str | None, + StringConstraints( + min_length=1, max_length=SavedSnippet.MAX_TITLE_LENGTH, strip_whitespace=True + ), + ] = None, + content: Annotated[ + str | None, + StringConstraints( + min_length=1, max_length=settings.MAX_MESSAGE_LENGTH, strip_whitespace=True + ), + ] = None, +) -> HttpResponse: + if title is None and content is None: + raise JsonableError(_("No new data is supplied")) + + do_edit_saved_snippet(saved_snippet_id, title, content, user_profile) + return json_success(request) + + def delete_saved_snippet( request: HttpRequest, user_profile: UserProfile, diff --git a/zproject/urls.py b/zproject/urls.py index 768927fded..a4f4a1fca3 100644 --- a/zproject/urls.py +++ b/zproject/urls.py @@ -153,6 +153,7 @@ from zerver.views.report import report_csp_violations from zerver.views.saved_snippets import ( create_saved_snippet, delete_saved_snippet, + edit_saved_snippet, get_saved_snippets, ) from zerver.views.scheduled_messages import ( @@ -346,7 +347,11 @@ v1_api_and_json_patterns = [ rest_path("drafts/", PATCH=edit_draft, DELETE=delete_draft), # saved_snippets -> zerver.views.saved_snippets rest_path("saved_snippets", GET=get_saved_snippets, POST=create_saved_snippet), - rest_path("saved_snippets/", DELETE=delete_saved_snippet), + rest_path( + "saved_snippets/", + DELETE=delete_saved_snippet, + PATCH=edit_saved_snippet, + ), # New scheduled messages are created via send_message_backend. rest_path( "scheduled_messages", GET=fetch_scheduled_messages, POST=create_scheduled_message_backend