diff --git a/frappe/commands/test_commands.py b/frappe/commands/test_commands.py index bd9697d599..f3d9f3d0d9 100644 --- a/frappe/commands/test_commands.py +++ b/frappe/commands/test_commands.py @@ -1116,6 +1116,7 @@ class TestGunicornWorker(IntegrationTestCase): time.sleep(2) execute_in_shell("pgrep gunicorn | xargs -L1 kill -9") + @unittest.skip("Flaky test") def test_gunicorn_ping_sync(self): self.spawn_gunicorn() path = f"http://{self.TEST_SITE}:{self.port}/api/method/ping" @@ -1126,6 +1127,7 @@ class TestGunicornWorker(IntegrationTestCase): path = f"http://{self.TEST_SITE}:{self.port}/api/method/ping" self.assertEqual(requests.get(path).status_code, 200) + @unittest.skip("Flaky test") def test_gunicorn_idle_cpu_usage(self): def get_total_usage(): process = psutil.Process(self.handle.pid) diff --git a/frappe/core/doctype/communication/communication.py b/frappe/core/doctype/communication/communication.py index 59aefc53bf..16dae174ec 100644 --- a/frappe/core/doctype/communication/communication.py +++ b/frappe/core/doctype/communication/communication.py @@ -419,7 +419,7 @@ class Communication(Document, CommunicationEmailMixin): # Skip timeline links if a "Sent" communication already exists # else will create duplicate timeline entries if self.sent_or_received == "Received" and self.find_one_by_filters( - message_id=self.message_id, sent_or_received="Sent" + message_id=self.message_id, email_account=self.email_account, sent_or_received="Sent" ): return diff --git a/frappe/core/doctype/file/file.js b/frappe/core/doctype/file/file.js index f3f1380855..2dd4bb48a2 100644 --- a/frappe/core/doctype/file/file.js +++ b/frappe/core/doctype/file/file.js @@ -43,25 +43,34 @@ frappe.ui.form.on("File", { if (!frappe.utils.can_upload_public_files() && frm.doc.is_private) { frm.set_df_property("is_private", "read_only", 1); } + + if (frm.doc.attached_to_name) { + const field = frm.get_field("attached_to_name"); + field.$input_wrapper + .find(".control-value") + .html(`${frappe.utils.get_form_link(frm.doctype, frm.docname, true)}`); + } }, preview_file: function (frm) { let $preview = ""; let file_extension = frm.doc.file_type.toLowerCase(); + const full_file_url = frm.doc.file_url + "?fid=" + frm.doc.name; + const src_url = frappe.utils.escape_html(full_file_url); - if (frappe.utils.is_image_file(frm.doc.file_url)) { + if (frappe.utils.is_image_file(full_file_url)) { $preview = $(`
`); - } else if (frappe.utils.is_video_file(frm.doc.file_url)) { + } else if (frappe.utils.is_video_file(full_file_url)) { $preview = $(`
`); @@ -72,14 +81,14 @@ frappe.ui.form.on("File", { style="background:#323639;" width="100%" height="1190" - src="${frappe.utils.escape_html(frm.doc.file_url)}" type="application/pdf" + src="${src_url}" type="application/pdf" > `); } else if (file_extension === "mp3") { $preview = $(`
`); diff --git a/frappe/desk/page/desktop/desktop.css b/frappe/desk/page/desktop/desktop.css index 27a128b60c..ef5e4e6062 100644 --- a/frappe/desk/page/desktop/desktop.css +++ b/frappe/desk/page/desktop/desktop.css @@ -2,7 +2,7 @@ --desktop-blur: blur(10.2px); --desktop-modal-width: 590px; --desktop-modal-height: 450px; - --folder-thumbnail-icon-height: 12px; + --folder-thumbnail-icon-height: 16px; --desktop-icon-dimension: 54px; --folder-icon-background-color: var(--surface-gray-1); --desktop-modal-radius: 30px; @@ -91,7 +91,7 @@ padding:0px; margin: 0px; height: 100%; - overflow: auto; + overflow: hidden; } .icons{ gap: 16px; @@ -109,6 +109,10 @@ gap: 12px; padding: 13px 16px 12px 16px; position: relative; + border-radius: 20px; + border-width: 1px; + border-style: dashed; + border-color: transparent; } .desktop-icon.desktop-edit-mode .hide-button { display: flex; @@ -129,6 +133,7 @@ .icon-container{ padding: 10px; border-radius: 16px; + overflow: hidden; display: flex; align-items: center; justify-content: center; @@ -254,13 +259,13 @@ position: absolute; } -.folder-icon{ - border-radius: 10px; - background-color: var(--folder-icon-background-color) !important; +.folder-icon { + border-radius: 16px; + background-color: var(--folder-icon-background-color) !important; box-shadow: 0px 0px 1px 0px rgba(0, 0, 0, 0.14); padding: 7px; align-items: normal; - box-shadow: 0 0 1px 0 rgba(0, 0, 0, 0.14), 0 1px 3px 0 rgba(0, 0, 0, 0.14); + /* box-shadow: 0 0 1px 0 rgba(0, 0, 0, 0.14), 0 1px 3px 0 rgba(0, 0, 0, 0.14); */ & .icons{ gap: 2.1px; margin-top: 0px; @@ -343,8 +348,7 @@ } .desktop-edit-mode{ - border: 1px dashed var(--outline-gray-2); - border-radius: 20px; + border-color: var(--outline-gray-2); } .edit-mode-buttons{ display: none; @@ -364,7 +368,7 @@ :root { --desktop-icon-dimension: 50px; --desktop-icon-container: 117px; - --folder-thumbnail-icon-height:17px; + --folder-thumbnail-icon-height:15px; } .desktop-container { @@ -443,6 +447,12 @@ } } +.icons-container { + > .icons-container { + padding: 0px; + } +} + .desktop-edit{ width: 36px; height: 36px; diff --git a/frappe/desk/page/desktop/desktop.js b/frappe/desk/page/desktop/desktop.js index 2690d5dd74..514af59374 100644 --- a/frappe/desk/page/desktop/desktop.js +++ b/frappe/desk/page/desktop/desktop.js @@ -286,7 +286,6 @@ class DesktopPage { this.setup_navbar(); this.setup_awesomebar(); this.handle_route_change(); - this.setup_edit_button(); } setup_edit_button() { if (this.edit_mode || frappe.is_mobile()) return; @@ -1087,11 +1086,6 @@ class DesktopIcon { this.folder_grid = new DesktopIconGrid({ wrapper: this.folder_wrapper, icons_data: this.child_icons, - row_size: 3, - page_size: { - row: 3, - col: 3, - }, in_folder: true, in_modal: false, no_dragging: true, diff --git a/frappe/email/doctype/email_account/test_email_account.py b/frappe/email/doctype/email_account/test_email_account.py index b50dd69b34..810e87a424 100644 --- a/frappe/email/doctype/email_account/test_email_account.py +++ b/frappe/email/doctype/email_account/test_email_account.py @@ -521,12 +521,14 @@ class TestInboundMail(IntegrationTestCase): def test_mail_exist_validation(self): """Do not create communication record if the mail is already downloaded into the system.""" + email_account = frappe.get_doc("Email Account", "_Test Email Account 1") mail_content = self.get_test_mail(fname="incoming-1.raw") message_id = Email(mail_content).message_id # Create new communication record in DB - communication = self.new_communication(message_id=message_id, sent_or_received="Received") + communication = self.new_communication( + message_id=message_id, email_account=email_account.name, sent_or_received="Received" + ) - email_account = frappe.get_doc("Email Account", "_Test Email Account 1") inbound_mail = InboundMail(mail_content, email_account, 12345, 1) new_communication = inbound_mail.process() diff --git a/frappe/email/receive.py b/frappe/email/receive.py index 81e539d706..45d587a69a 100644 --- a/frappe/email/receive.py +++ b/frappe/email/receive.py @@ -751,7 +751,10 @@ class InboundMail(Email): return return Communication.find_one_by_filters( - message_id=self.message_id, sent_or_received="Received", order_by="creation DESC" + message_id=self.message_id, + email_account=self.email_account.name, + sent_or_received="Received", + order_by="creation DESC", ) def is_sender_same_as_receiver(self): diff --git a/frappe/model/delete_doc.py b/frappe/model/delete_doc.py index 60df4c279a..858e5212d0 100644 --- a/frappe/model/delete_doc.py +++ b/frappe/model/delete_doc.py @@ -100,9 +100,6 @@ def delete_doc( else: return False - # delete passwords - delete_all_passwords_for(doctype, name) - doc = None if doctype == "DocType": if for_reload: @@ -200,6 +197,9 @@ def delete_doc( enqueue_after_commit=True, ) + # delete passwords + delete_all_passwords_for(doctype, name) + # clear cache for Document doc.clear_cache() # delete global search entry diff --git a/frappe/model/meta.py b/frappe/model/meta.py index 9e5624252d..add084d38e 100644 --- a/frappe/model/meta.py +++ b/frappe/model/meta.py @@ -793,11 +793,21 @@ class Meta(Document): group.get("items").append(doctype) link.added = True + # Add fieldname to transaction group for external links + if not link.is_child_table: + if "fieldnames" not in group: + group["fieldnames"] = {} + group["fieldnames"][link.link_doctype] = link.link_fieldname + if not link.added: # group not found, make a new group - data.transactions.append( - dict(label=link.group, items=[link.parent_doctype or link.link_doctype]) - ) + new_group = dict(label=link.group, items=[link.parent_doctype or link.link_doctype]) + + # Add fieldname to new transaction group for external links + if not link.is_child_table: + new_group["fieldnames"] = {link.link_doctype: link.link_fieldname} + + data.transactions.append(new_group) if not data.fieldname and link.link_fieldname: data.fieldname = link.link_fieldname diff --git a/frappe/public/js/billing.bundle.js b/frappe/public/js/billing.bundle.js index 00fd226e1a..79e0609c58 100644 --- a/frappe/public/js/billing.bundle.js +++ b/frappe/public/js/billing.bundle.js @@ -27,6 +27,14 @@ $(document).ready(function () { dismiss_key: `${frappe.boot.site_info.name}_trial_card_time`, dismiss_it_for: "day", }; + let visiblity_condition = + frappe.boot.is_fc_site && + !!frappe.boot.setup_complete && + !frappe.is_mobile() && + frappe.user.has_role("System Manager"); + if (visiblity_condition && isFCUser) { + addChatBubble(); + } if (isFCUser) { $.extend(card_args, { primary_action_label: "Upgrade", @@ -42,12 +50,7 @@ $(document).ready(function () { }); } $(document).on("desktop_screen", function (event, data) { - if ( - frappe.boot.is_fc_site && - !!frappe.boot.setup_complete && - !frappe.is_mobile() && - frappe.user.has_role("System Manager") - ) { + if (visiblity_condition) { if (site_info.trial_end_date && trial_end_date > new Date()) { card_args.parent = $(".icons-container").first(); let banner_card = new frappe.ui.SidebarCard(card_args); @@ -84,3 +87,25 @@ function openFrappeCloudDashboard() { "_blank" ); } + +function addChatBubble() { + const all_apps = frappe.utils.get_installed_apps(); + const desk_apps = ["erpnext", "hrms"]; + + const apps_allowed = frappe.utils.is_sub_array(all_apps, desk_apps); + if (checkBusinessHours && apps_allowed) { + let chat_banner = document.createElement("script"); + chat_banner.innerHTML = + '(function(d,t){var BASE_URL="https://chat.frappe.cloud";var g=d.createElement(t),s=d.getElementsByTagName(t)[0];g.src=BASE_URL+"/packs/js/sdk.js";g.async=true;s.parentNode.insertBefore(g,s);g.onload=function(){window.chatwootSDK.run({websiteToken:"LdmfJzftdJGEcFjoTqk8CrSq",baseUrl:BASE_URL})}})(document,"script");'; + document.body.append(chat_banner); + const root = document.documentElement; + root.style.setProperty("--s-700", "var(--gray-500)"); + } +} + +function checkBusinessHours() { + let currentTime = new Date(); + const istTime = new Date(currentTime.toLocaleString("en-US", { timeZone: "Asia/Kolkata" })); + + return istTime.getHours() >= 11 && istTime.getHours() < 18; +} diff --git a/frappe/public/js/bootstrap-4-web.bundle.js b/frappe/public/js/bootstrap-4-web.bundle.js index 3845a7b185..0d37b745a7 100644 --- a/frappe/public/js/bootstrap-4-web.bundle.js +++ b/frappe/public/js/bootstrap-4-web.bundle.js @@ -25,7 +25,7 @@ frappe.get_modal = function (title, content) { {% for (let j=0; j < transactions[i].items.length; j++) { %} {% let doctype = transactions[i].items[j]; %} + {% let fieldname = (transactions[i].fieldnames && transactions[i].fieldnames[doctype]) || transactions[i].fieldname; %} diff --git a/frappe/public/js/frappe/ui/user_onboarding/user_onboarding.bundle.js b/frappe/public/js/frappe/ui/user_onboarding/user_onboarding.bundle.js index c75170d28b..3ed2215733 100644 --- a/frappe/public/js/frappe/ui/user_onboarding/user_onboarding.bundle.js +++ b/frappe/public/js/frappe/ui/user_onboarding/user_onboarding.bundle.js @@ -67,7 +67,7 @@ function addStyles() { position: fixed; right: 24px; bottom: 24px; - width: 380px; + width: 310px; max-height: 80vh; background: #fff; border-radius: 16px; diff --git a/frappe/public/js/frappe/utils/utils.js b/frappe/public/js/frappe/utils/utils.js index c4e4d0718f..674ff8c8c6 100644 --- a/frappe/public/js/frappe/utils/utils.js +++ b/frappe/public/js/frappe/utils/utils.js @@ -2252,4 +2252,16 @@ Object.assign(frappe.utils, { } return value; }, + get_installed_apps() { + return frappe.boot.app_data.map((app) => { + return app.app_name; + }); + }, + is_sub_array(big, small) { + let i = 0; + for (let num of big) { + if (num === small[i]) i++; + } + return i === small.length; + }, }); diff --git a/frappe/public/js/frappe/views/communication.js b/frappe/public/js/frappe/views/communication.js index bfa1706f11..528b5f81a3 100755 --- a/frappe/public/js/frappe/views/communication.js +++ b/frappe/public/js/frappe/views/communication.js @@ -62,6 +62,7 @@ frappe.views.CommunicationComposer = class { { fieldtype: "Button", label: frappe.utils.icon("down", "xs"), + title: __("More Options"), fieldname: "option_toggle_button", click: () => { this.toggle_more_options(); @@ -496,7 +497,11 @@ frappe.views.CommunicationComposer = class { }, ]; - frappe.utils.add_select_group_button(clear_and_add_template, email_template_actions); + frappe.utils.add_select_group_button( + clear_and_add_template, + email_template_actions, + "btn-default" + ); $(fields.use_html.wrapper).addClass("mt-2 text-center").appendTo(clear_and_add_template); } diff --git a/frappe/public/js/frappe/views/workspace/workspace.js b/frappe/public/js/frappe/views/workspace/workspace.js index 76e6691336..8ecde380be 100644 --- a/frappe/public/js/frappe/views/workspace/workspace.js +++ b/frappe/public/js/frappe/views/workspace/workspace.js @@ -493,6 +493,7 @@ frappe.views.Workspace = class Workspace { let blocks = [ { type: "header", + data: { text: values.title }, }, ]; @@ -666,7 +667,6 @@ frappe.views.Workspace = class Workspace { spacer: this.blocks["spacer"], HeaderSize: frappe.workspace_block.tunes["header_size"], }; - this.editor = new EditorJS({ data: { blocks: blocks || [], @@ -676,6 +676,26 @@ frappe.views.Workspace = class Workspace { readOnly: true, logLevel: "ERROR", }); + if (blocks.length == 0) { + let message = __("Welcome to the {0} workspace", [this.page.title]); + let default_block = [ + { + type: "header", + data: { text: message }, + }, + ]; + if (this.has_access) { + default_block.push({ + type: "paragraph", + data: { + text: __("Click on {0} to edit", [frappe.utils.icon("ellipsis")]), + }, + }); + } + this.editor.isReady.then(() => { + this.editor.render({ blocks: default_block }); + }); + } } save_page(page) { diff --git a/frappe/public/scss/common/modal.scss b/frappe/public/scss/common/modal.scss index 3cb388a3fd..9cd60935c2 100644 --- a/frappe/public/scss/common/modal.scss +++ b/frappe/public/scss/common/modal.scss @@ -243,10 +243,6 @@ body.modal-open[style^="padding-right"] { } .frappe-control:last-child { margin-left: 10px; - button { - // same as form-control input - height: calc(1.5em + 0.7rem); - } } } } @@ -268,7 +264,19 @@ body.modal-open[style^="padding-right"] { } .frappe-control:last-child { - margin-top: -14px; + margin-top: 10px; + } + } +} + +.modal .frappe-control[data-fieldname="option_toggle_button"] { + margin-top: 10px; + .form-group { + margin-bottom: 0; + + button { + width: 28px; + height: 28px; } } } @@ -299,6 +307,9 @@ body.modal-open[style^="padding-right"] { } .assignee { flex: 1; + display: flex; + gap: 8px; + align-items: center; } &:hover { .btn-group { @@ -306,9 +317,6 @@ body.modal-open[style^="padding-right"] { transition: opacity 0.1s ease-in-out; } } - .avatar { - margin-right: var(--margin-md); - } } // Stack minimized modals diff --git a/frappe/public/scss/desk/avatar.scss b/frappe/public/scss/desk/avatar.scss index 7fd2a321e2..059518c414 100644 --- a/frappe/public/scss/desk/avatar.scss +++ b/frappe/public/scss/desk/avatar.scss @@ -98,6 +98,16 @@ } } +.avatar-smaller { + width: 22px; + height: 22px; + text-align: center; + + .standard-image { + @include get_textstyle("xs", "regular"); + } +} + .avatar-medium { width: 28px; height: 28px; diff --git a/frappe/public/scss/desk/form_sidebar.scss b/frappe/public/scss/desk/form_sidebar.scss index fceffe52d7..02c7411027 100644 --- a/frappe/public/scss/desk/form_sidebar.scss +++ b/frappe/public/scss/desk/form_sidebar.scss @@ -20,6 +20,10 @@ flex-wrap: wrap; color: var(--text-light); + .icon { + stroke: var(--text-light); + } + .icon-btn { height: unset; } @@ -38,6 +42,82 @@ } } + .user-actions { + display: flex; + flex-direction: column; + gap: 8px; + padding: var(--padding-md); + + .user-actions-list { + display: flex; + flex-direction: column; + gap: 6px; + } + + .user-action-row { + margin: 0; + } + + .user-action-link { + display: flex; + align-items: center; + text-decoration: underline; + justify-content: space-between; + gap: var(--margin-sm); + width: 100%; + padding: 4px 8px; + margin-left: -6px; + margin-right: -8px; + border-radius: var(--border-radius-md); + transition: background-color 120ms ease; + + &:hover, + &:focus-visible { + background: var(--subtle-fg); + } + + &:focus-visible { + outline: none; + } + + .user-action-external-icon { + display: none; + line-height: 0; + + .icon { + margin: 0; + --icon-stroke: var(--text-muted); + } + } + + &[target="_blank"] .user-action-external-icon { + display: inline-flex; + align-items: center; + opacity: 0; + transform: translateX(-2px); + transition: opacity 120ms ease, transform 120ms ease; + } + + &[target="_blank"]:hover .user-action-external-icon, + &[target="_blank"]:focus-visible .user-action-external-icon { + opacity: 1; + transform: translateX(0); + } + } + + .user-action-label { + display: block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .sidebar-section.user-actions.border-bottom { + padding-bottom: 15px; + } + .form-tags { .tag-area { margin-top: -3px; @@ -141,8 +221,13 @@ } } + .form-title-text { + // to match the actions button height for center alignment + line-height: 28px; + } + .form-stats-likes { - gap: 8px; + gap: 2px; .form-print { button:hover { background: var(--btn-default-hover-bg); @@ -318,30 +403,56 @@ body[data-route^="Form"] { .attachment-row, .form-tag-row { - margin: var(--margin-xs) 0; - max-width: 100%; + margin: 4px 0; + .data-pill { @include get_textstyle("sm", "regular"); justify-content: space-between; box-shadow: none; + display: flex; + align-items: center; + height: 24px; + padding: 0px 6px !important; + + .pill-label { + color: inherit !important; + } + + .icon { + stroke: currentColor; + } } } .attachment-row { + margin-left: -6px; + margin-right: 0px; + .data-pill { + display: flex; + align-items: center; + height: 28px; + border-radius: var(--border-radius-md); + padding: 0px 6px !important; background-color: unset; box-shadow: none; - padding-left: 0px !important; width: 100%; + &:hover, + &:focus-within { + background-color: var(--subtle-fg); + } + &:active { background-color: transparent !important; box-shadow: none !important; } + > div { + gap: 8px; + } + .attachment-file-label { display: block; - margin-left: var(--margin-xs); - padding-right: var(--padding-xs); text-align: left; } .attachment-icon { @@ -377,13 +488,58 @@ body[data-route^="Form"] { .form-attachments, .form-tags, .form-shared { - padding: 8px; + padding: var(--padding-sm) var(--padding-md); } + +.form-attachments { + // to add gap between attachment section label and attachments + // without affecting empty state + .attachments-actions + .attachment-row { + margin-top: 8px; + } +} + +.form-tags { + // to add gap between tag section label and tags + // without affecting empty state + :not(.form-tag-row) + .form-tag-row { + margin-top: 8px; + } +} + .form-assignments, .form-shared { .assignments, .shares { - margin: var(--margin-xs) 0px; + margin-top: 8px; + + .dialog-assignment-row { + display: flex; + align-items: center; + height: 28px; + border-radius: var(--border-radius-md); + padding: 0px 6px; + margin-left: -8px; + margin-right: 0px; + + &:hover, + &:focus-within { + background-color: var(--subtle-fg); + } + + &:not(:last-child) { + margin-bottom: 4px; + } + + .btn-group { + margin-right: -4px; + } + } + + .view-all-assignment { + display: block; + margin-top: var(--padding-xs); + } } } .add-assignment-btn, @@ -415,17 +571,43 @@ body[data-route^="Form"] { } } +.liked-by { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; +} + .liked-by-popover { + max-width: 240px; + border-radius: var(--border-radius-md); + box-shadow: var(--shadow-md); + overflow: hidden; + .popover-body { - min-height: 30px; padding: 0px; + .liked-by-popover-summary { + padding: 4px 10px; + margin: 0; + color: var(--text-muted); + border-bottom: 1px solid var(--subtle-accent); + @include get_textstyle("sm", "regular"); + } + ul.list-unstyled { margin-bottom: 0px; + padding: 4px; li { - padding: var(--padding-xs) var(--padding-sm); - margin: 2px; + display: flex; + align-items: center; + gap: var(--padding-xs); + padding: var(--padding-xs); + margin: 0; + border-radius: var(--border-radius-sm); + cursor: pointer; &:hover { background-color: var(--fg-hover-color); diff --git a/frappe/public/scss/desk/sidebar.scss b/frappe/public/scss/desk/sidebar.scss index 739e2c3b16..5f8c479268 100644 --- a/frappe/public/scss/desk/sidebar.scss +++ b/frappe/public/scss/desk/sidebar.scss @@ -127,7 +127,15 @@ } } - .onboarding-sidebar { + .promotional-banners { + display: flex; + flex-direction: column; + gap: 4px; + margin: var(--margin-sm) 0; + } + + .onboarding-sidebar, + .promotional-banner { text-decoration: none; font-size: var(--text-sm); display: flex; @@ -287,9 +295,8 @@ width: auto; } } - .collapse-sidebar-link { - display: none; - } + .promotional-banners, + .collapse-sidebar-link, .dropdown-navbar-user { display: none; } diff --git a/frappe/public/scss/desk/timeline.scss b/frappe/public/scss/desk/timeline.scss index e5de39caf7..20d6f98e13 100644 --- a/frappe/public/scss/desk/timeline.scss +++ b/frappe/public/scss/desk/timeline.scss @@ -96,6 +96,11 @@ $threshold: 34; max-width: var(--timeline-content-max-width); padding: var(--padding-sm); margin-left: var(--margin-md); + + > .ql-editor { + display: inline-flex; + } + &.frappe-card { color: var(--text-neutral); background-color: var(--bg-color); diff --git a/frappe/website/js/bootstrap-4.js b/frappe/website/js/bootstrap-4.js index e525d423b6..b95d42ac6a 100644 --- a/frappe/website/js/bootstrap-4.js +++ b/frappe/website/js/bootstrap-4.js @@ -27,7 +27,7 @@ frappe.get_modal = function (title, content) {