From 97d14a93b3fee85769cedf4c1e7abd770798088f Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Tue, 4 Jun 2019 05:40:48 +0200 Subject: [PATCH] Initial knowledge base support. --- .eslintrc | 3 +- Gemfile | 8 + Gemfile.lock | 14 + LICENSE-3RD-PARTY.txt | 25 + LICENSE-ICONS-3RD-PARTY.json | 129 +- .../_application_controller.coffee | 33 +- .../_application_controller_form.coffee | 53 +- .../_application_controller_generic.coffee | 2 + ...pplication_controller_reorder_modal.coffee | 51 + .../_application_controller_table.coffee | 3 +- .../controllers/_manage/knowledge_base.coffee | 130 + .../controllers/_profile/token_access.coffee | 3 +- .../_application_ui_element.coffee | 11 +- .../_ui_element/autocompletion_ajax.coffee | 6 +- .../app/controllers/_ui_element/color.coffee | 4 + .../_ui_element/icon_picker.coffee | 4 + .../_ui_element/iconset_picker.coffee | 4 + .../_ui_element/multi_locales.coffee | 34 + .../app/controllers/_ui_element/radio.coffee | 4 +- .../_ui_element/radio_graphic.coffee | 3 + .../controllers/_ui_element/richtext.coffee | 156 +- .../_rich_text_tool_button.coffee | 139 + .../_rich_text_tool_popup.coffee | 151 + .../link_answer_button.coffee | 15 + .../richtext_additions/link_button.coffee | 15 + .../richtext_additions/popup_answer.coffee | 43 + .../richtext_additions/popup_link.coffee | 52 + .../app/controllers/_ui_element/select.coffee | 10 + .../knowledge_base/add_form.coffee | 33 + .../knowledge_base/agent_controller.coffee | 385 + .../content_can_be_published_dialog.coffee | 68 + .../content_can_be_published_form.coffee | 133 + .../knowledge_base/content_controller.coffee | 161 + .../knowledge_base/delete_action.coffee | 71 + .../knowledge_base/editor_coordinator.coffee | 44 + .../knowledge_base/form_controller.coffee | 69 + .../knowledge_base_reader_pagination.coffee | 97 + .../knowledge_base/navigation.coffee | 173 + .../knowledge_base/public_menu_form.coffee | 17 + .../public_menu_form_item.coffee | 144 + .../knowledge_base/reader_controller.coffee | 126 + .../reader_list_container.coffee | 79 + .../reader_list_controller.coffee | 64 + .../knowledge_base/reader_list_item.coffee | 34 + .../controllers/knowledge_base/router.coffee | 35 + .../knowledge_base/scheduled_widget.coffee | 20 + .../knowledge_base/search_controller.coffee | 20 + .../knowledge_base/search_field_panel.coffee | 84 + .../knowledge_base/search_field_widget.coffee | 117 + .../knowledge_base/search_item.coffee | 23 + .../controllers/knowledge_base/sidebar.coffee | 63 + .../sidebar/_generic_list.coffee | 54 + .../knowledge_base/sidebar/actions.coffee | 14 + .../knowledge_base/sidebar/answers.coffee | 23 + .../knowledge_base/sidebar/attachments.coffee | 135 + .../knowledge_base/sidebar/categories.coffee | 45 + .../sidebar/linked_tickets.coffee | 68 + .../app/controllers/layout_ref.coffee | 133 + .../app/controllers/navigation.coffee | 12 + .../javascripts/app/controllers/search.coffee | 4 +- .../ticket_zoom/article_view.coffee | 5 +- .../ticket_zoom/sidebar_ticket.coffee | 25 + .../javascripts/app/controllers/users.coffee | 6 +- .../app/controllers/widget/answer_list.coffee | 93 + .../widget/button_with_dropdown.coffee | 31 + .../controllers/widget/link/kb_answer.coffee | 105 + .../javascripts/app/lib/app_init/track.coffee | 2 +- .../javascripts/app/lib/app_post/color.coffee | 140 + .../app/lib/app_post/icon_picker.coffee | 154 + .../app/lib/app_post/iconset_picker.coffee | 78 + .../app/lib/app_post/multi_locales.coffee | 95 + .../app/lib/app_post/multi_locales_row.coffee | 77 + .../kb_popover_provider.coffee | 5 + .../knowledge_base_answer_provider.coffee | 5 + .../knowledge_base_category_provider.coffee | 5 + .../knowledge_base_provider.coffee | 5 + .../app/lib/app_post/searchable_select.coffee | 9 +- .../javascripts/app/lib/app_post/utils.coffee | 31 + .../app_post/z_searchable_ajax_select.coffee | 99 +- .../javascripts/app/lib/base/html5Upload.js | 4 + .../app/lib/base/jquery.textmodule.js | 293 +- .../javascripts/app/lib/bootstrap/modal.js | 4 +- .../javascripts/app/lib/bootstrap/tooltip.js | 12 +- .../app/lib/core/jquery-ui-1.11.4.js | 1518 ++- .../lib/mixins/knowledge_base_actions.coffee | 27 + .../knowledge_base_can_be_published.coffee | 101 + .../mixins/knowledge_base_translatable.coffee | 110 + .../knowledge_base_translationable.coffee | 29 + .../app/models/knowledge_base.coffee | 230 + .../app/models/knowledge_base_answer.coffee | 94 + .../knowledge_base_answer_translation.coffee | 112 + ...ledge_base_answer_translation_state.coffee | 4 + .../app/models/knowledge_base_category.coffee | 168 + ...knowledge_base_category_translation.coffee | 8 + .../app/models/knowledge_base_layout.coffee | 4 + .../app/models/knowledge_base_locale.coffee | 36 + .../models/knowledge_base_menu_item.coffee | 7 + .../models/knowledge_base_translation.coffee | 11 + .../agent_ticket_view/navbar_vertical.jst.eco | 8 +- .../app/views/generic/attachments.jst.eco | 31 + .../app/views/generic/attribute.jst.eco | 11 +- .../app/views/generic/checkbox.jst.eco | 4 +- .../app/views/generic/color.jst.eco | 25 + .../app/views/generic/icon_picker.jst.eco | 18 + .../app/views/generic/iconset_picker.jst.eco | 15 + .../app/views/generic/multi_locales.jst.eco | 10 + .../views/generic/multi_locales_row.jst.eco | 14 + .../app/views/generic/radio.jst.eco | 2 +- .../app/views/generic/radio_graphic.jst.eco | 19 + .../app/views/generic/richtext.jst.eco | 14 +- .../app/views/generic/sla_times.jst.eco | 12 +- .../app/views/generic/table_row.jst.eco | 2 +- .../app/views/generic/tabs.jst.eco | 6 + .../app/views/generic/ticket_list.jst.eco | 20 +- .../app/views/getting_started/channel.jst.eco | 2 +- .../email_pre_configured.jst.eco | 4 +- .../_answer_attachments.jst.eco | 15 + .../_reader_answer_meta.jst.eco | 10 + .../_reader_linked_tickets.jst.eco | 9 + .../knowledge_base/_reader_list_item.jst.eco | 10 + .../knowledge_base/_reader_pagination.jst.eco | 13 + .../app/views/knowledge_base/agent.jst.eco | 12 + .../app/views/knowledge_base/base_form.coffee | 147 + .../app/views/knowledge_base/content.jst.eco | 29 + ...ent_can_be_published_header_suffix.jst.eco | 15 + .../app/views/knowledge_base/delete.coffee | 54 + .../views/knowledge_base/navigation.jst.eco | 59 + .../app/views/knowledge_base/new_modal.coffee | 54 + .../public_menu_form_item.jst.eco | 35 + .../public_menu_form_item_row.jst.eco | 21 + .../app/views/knowledge_base/reader.jst.eco | 22 + .../views/knowledge_base/reader_list.jst.eco | 4 + .../knowledge_base/scheduled_widget.jst.eco | 5 + .../app/views/knowledge_base/search.jst.eco | 3 + .../knowledge_base/search_field_panel.jst.eco | 13 + .../search_field_widget.jst.eco | 6 + .../views/knowledge_base/search_item.jst.eco | 15 + .../knowledge_base/server_snippet.coffee | 25 + .../knowledge_base/server_snippet.jst.eco | 15 + .../knowledge_base/sidebar/actions.jst.eco | 15 + .../sidebar/attachments.jst.eco | 50 + .../sidebar/generic_list.jst.eco | 40 + .../sidebar/linked_tickets.jst.eco | 9 + .../knowledge_base/vertical_form.jst.eco | 8 + .../app/views/layout_ref/index.jst.eco | 3 + .../layout_ref/kb_agent_reader_ref.jst.eco | 411 + .../kb_link_answer_to_answer_ref.jst.eco | 10 + .../kb_link_ticket_to_answer_ref.jst.eco | 210 + .../app/views/layout_ref/setup.jst.eco | 2 +- .../app/views/link/kb_answer.jst.eco | 16 + .../app/views/link/ticket/add.jst.eco | 48 +- .../javascripts/app/views/modal.jst.eco | 10 +- .../app/views/navigation/menu.jst.eco | 6 +- .../app/views/popover/kb_generic.jst.eco | 36 + .../profile/calendar_subscriptions.jst.eco | 6 +- .../app/views/profile/devices.jst.eco | 3 +- .../app/views/profile/token_access.jst.eco | 4 +- .../profile/token_access_created.jst.eco | 2 +- .../app/views/reorder_modal.jst.eco | 4 + .../views/ticket_zoom/article_view.jst.eco | 32 +- .../views/ticket_zoom/sidebar_ticket.jst.eco | 3 +- .../app/views/vertical_form.coffee | 23 + .../views/widget/button_with_dropdown.jst.eco | 15 + .../javascripts/app/views/widget/tag.jst.eco | 2 +- .../javascripts/knowledge_base_public.js | 7 + .../knowledge_base_public/dropdown.js | 28 + .../knowledge_base_public/language.js | 105 + .../knowledge_base_public/namespace.js | 3 + .../knowledge_base_public/search.js | 129 + .../javascripts/knowledge_base_public/util.js | 13 + .../knowledge_base_public_polyfills.js | 1 + .../element.prepend.js | 7 + .../knowledge_base_public_polyfills/fetch.js | 468 + .../promise.js | 272 + .../svgstore.js | 99 + app/assets/stylesheets/bootstrap.css | 31 +- app/assets/stylesheets/knowledge_base.scss | 1196 +++ app/assets/stylesheets/svg-dimensions.css | 8 + app/assets/stylesheets/zammad.scss | 1762 +++- app/controllers/attachments_controller.rb | 88 + app/controllers/concerns/has_publishing.rb | 25 + .../answer/attachments_controller.rb | 55 + .../knowledge_base/answers_controller.rb | 97 + .../knowledge_base/base_controller.rb | 41 + .../knowledge_base/categories_controller.rb | 59 + .../knowledge_base/layouts_controller.rb | 4 + .../knowledge_base/locales_controller.rb | 4 + .../knowledge_base/manage_controller.rb | 79 + .../public/answers_controller.rb | 28 + .../knowledge_base/public/base_controller.rb | 97 + .../public/categories_controller.rb | 65 + .../knowledge_base/search_controller.rb | 145 + app/controllers/knowledge_bases_controller.rb | 90 + app/controllers/links_controller.rb | 23 +- app/controllers/tickets_controller.rb | 24 +- app/helpers/knowledge_base_helper.rb | 221 + app/helpers/knowledge_base_icon_helper.rb | 26 + .../knowledge_base_visibility_class_helper.rb | 35 + app/helpers/translation_helper.rb | 7 + app/jobs/checks_kb_client_notification_job.rb | 57 + app/jobs/scheduled_touch_job.rb | 9 + app/models/application_model/can_assets.rb | 31 + .../application_model/can_cleanup_param.rb | 34 +- ...can_query_case_insensitive_where_or_sql.rb | 12 +- .../application_model/has_attachments.rb | 17 + app/models/concerns/can_be_published.rb | 152 + .../concerns/checks_kb_client_notification.rb | 52 + .../concerns/has_agent_allowed_params.rb | 33 + ...s_knowledge_base_attachment_permissions.rb | 29 + app/models/concerns/has_rich_text.rb | 161 + app/models/concerns/has_translations.rb | 56 + app/models/knowledge_base.rb | 185 + app/models/knowledge_base/answer.rb | 89 + .../knowledge_base/answer/translation.rb | 114 + .../answer/translation/content.rb | 58 + app/models/knowledge_base/category.rb | 134 + .../knowledge_base/category/translation.rb | 56 + app/models/knowledge_base/has_unique_title.rb | 23 + app/models/knowledge_base/locale.rb | 54 + app/models/knowledge_base/menu_item.rb | 24 + app/models/knowledge_base/search.rb | 58 + app/models/knowledge_base/translation.rb | 43 + app/models/link.rb | 32 + app/models/locale.rb | 3 + app/models/user/assets.rb | 1 + .../public/_breadcrumb.html.erb | 13 + .../public/_icon_from_set.html.erb | 1 + .../public/_icon_native.html.erb | 3 + .../public/_inline_stylesheet.html.erb | 23 + .../public/_top_banner.html.erb | 15 + .../public/_visibility_note.html.erb | 1 + .../public/answers/show.html.erb | 28 + .../public/categories/_answer.html.erb | 6 + .../public/categories/_category.html.erb | 9 + .../public/categories/index.html.erb | 41 + .../knowledge_base/public/not_found.html.erb | 11 + .../public/show_alternatives.html.erb | 15 + app/views/layouts/knowledge_base.html.erb | 76 + config/initializers/assets.rb | 1 + config/initializers/html_sanitizer.rb | 2 +- config/initializers/inflections.rb | 7 + config/routes/attachments.rb | 9 + config/routes/knowledge_base.rb | 73 + config/routes/ticket.rb | 1 + contrib/graphics.sketch | Bin 0 -> 15418 bytes ...0190531180304_initialize_knowledge_base.rb | 187 + db/seeds/permissions.rb | 35 + db/seeds/settings.rb | 41 + gulpfile.js | 2 + lib/can_be_published/state_machine.rb | 114 + lib/knowledge_base/menu_item_update_action.rb | 56 + lib/knowledge_base/server_snippet.rb | 28 + lib/knowledge_base/server_snippet_apache.rb | 31 + lib/knowledge_base/server_snippet_nginx.rb | 27 + lib/search_index_backend.rb | 20 +- lib/search_knowledge_base_backend.rb | 218 + public/assets/icon-fonts/FontAwesome.json | 8322 +++++++++++++++++ public/assets/icon-fonts/FontAwesome.woff | Bin 0 -> 110368 bytes .../assets/icon-fonts/Simple-Line-Icons.json | 1103 +++ .../assets/icon-fonts/Simple-Line-Icons.woff | Bin 0 -> 31340 bytes public/assets/icon-fonts/anticon.json | 1642 ++++ public/assets/icon-fonts/anticon.woff | Bin 0 -> 46488 bytes public/assets/icon-fonts/ionicons.json | 4402 +++++++++ public/assets/icon-fonts/ionicons.woff | Bin 0 -> 108780 bytes public/assets/icon-fonts/material.json | 4668 +++++++++ public/assets/icon-fonts/material.woff | Bin 0 -> 109948 bytes public/assets/images/colorcircle.gif | Bin 0 -> 97 bytes .../graphics/knowledge_base_accordion.svg | 25 + .../images/graphics/knowledge_base_grid.svg | 27 + .../images/graphics/knowledge_base_list.svg | 21 + public/assets/images/icons.svg | 54 +- public/assets/images/icons/chain.svg | 15 + public/assets/images/icons/clock.svg | 11 +- public/assets/images/icons/danger.svg | 9 + public/assets/images/icons/document.svg | 12 + public/assets/images/icons/external.svg | 10 + public/assets/images/icons/eye.svg | 12 + .../images/icons/knowledge-base-answer.svg | 9 + public/assets/images/icons/knowledge-base.svg | 12 + public/assets/images/icons/radio-checked.svg | 11 +- public/assets/images/icons/radio.svg | 11 +- public/assets/images/icons/rearange.svg | 10 + public/assets/tests/form.js | 30 +- spec/factories/knowledge_base.rb | 23 + spec/factories/knowledge_base/answer.rb | 5 + .../knowledge_base/answer/translation.rb | 15 + .../answer/translation/content.rb | 12 + spec/factories/knowledge_base/category.rb | 20 + .../knowledge_base/category/translation.rb | 14 + spec/factories/knowledge_base/locale.rb | 12 + spec/factories/knowledge_base/menu_item.rb | 14 + spec/factories/knowledge_base/translation.rb | 7 + .../checks_kb_client_notification_examples.rb | 41 + spec/models/contexts/factory_context.rb | 5 + .../answer/translation/content_spec.rb | 12 + .../knowledge_base/answer/translation_spec.rb | 16 + spec/models/knowledge_base/answer_spec.rb | 14 + .../category/translation_spec.rb | 14 + spec/models/knowledge_base/category_spec.rb | 97 + spec/models/knowledge_base/locale_spec.rb | 8 + spec/models/knowledge_base/menu_item_spec.rb | 44 + .../models/knowledge_base/translation_spec.rb | 14 + spec/models/knowledge_base_spec.rb | 66 + spec/models/role_spec.rb | 4 +- spec/rails_helper.rb | 7 + test/browser/admin_object_manager_test.rb | 2 + test/browser/preferences_token_access_test.rb | 2 +- test/unit/model_test.rb | 3 +- 308 files changed, 37506 insertions(+), 505 deletions(-) create mode 100644 app/assets/javascripts/app/controllers/_application_controller_reorder_modal.coffee create mode 100644 app/assets/javascripts/app/controllers/_manage/knowledge_base.coffee create mode 100644 app/assets/javascripts/app/controllers/_ui_element/color.coffee create mode 100644 app/assets/javascripts/app/controllers/_ui_element/icon_picker.coffee create mode 100644 app/assets/javascripts/app/controllers/_ui_element/iconset_picker.coffee create mode 100644 app/assets/javascripts/app/controllers/_ui_element/multi_locales.coffee create mode 100644 app/assets/javascripts/app/controllers/_ui_element/radio_graphic.coffee create mode 100644 app/assets/javascripts/app/controllers/_ui_element/richtext_additions/_rich_text_tool_button.coffee create mode 100644 app/assets/javascripts/app/controllers/_ui_element/richtext_additions/_rich_text_tool_popup.coffee create mode 100644 app/assets/javascripts/app/controllers/_ui_element/richtext_additions/link_answer_button.coffee create mode 100644 app/assets/javascripts/app/controllers/_ui_element/richtext_additions/link_button.coffee create mode 100644 app/assets/javascripts/app/controllers/_ui_element/richtext_additions/popup_answer.coffee create mode 100644 app/assets/javascripts/app/controllers/_ui_element/richtext_additions/popup_link.coffee create mode 100644 app/assets/javascripts/app/controllers/knowledge_base/add_form.coffee create mode 100644 app/assets/javascripts/app/controllers/knowledge_base/agent_controller.coffee create mode 100644 app/assets/javascripts/app/controllers/knowledge_base/content_can_be_published_dialog.coffee create mode 100644 app/assets/javascripts/app/controllers/knowledge_base/content_can_be_published_form.coffee create mode 100644 app/assets/javascripts/app/controllers/knowledge_base/content_controller.coffee create mode 100644 app/assets/javascripts/app/controllers/knowledge_base/delete_action.coffee create mode 100644 app/assets/javascripts/app/controllers/knowledge_base/editor_coordinator.coffee create mode 100644 app/assets/javascripts/app/controllers/knowledge_base/form_controller.coffee create mode 100644 app/assets/javascripts/app/controllers/knowledge_base/knowledge_base_reader_pagination.coffee create mode 100644 app/assets/javascripts/app/controllers/knowledge_base/navigation.coffee create mode 100644 app/assets/javascripts/app/controllers/knowledge_base/public_menu_form.coffee create mode 100644 app/assets/javascripts/app/controllers/knowledge_base/public_menu_form_item.coffee create mode 100644 app/assets/javascripts/app/controllers/knowledge_base/reader_controller.coffee create mode 100644 app/assets/javascripts/app/controllers/knowledge_base/reader_list_container.coffee create mode 100644 app/assets/javascripts/app/controllers/knowledge_base/reader_list_controller.coffee create mode 100644 app/assets/javascripts/app/controllers/knowledge_base/reader_list_item.coffee create mode 100644 app/assets/javascripts/app/controllers/knowledge_base/router.coffee create mode 100644 app/assets/javascripts/app/controllers/knowledge_base/scheduled_widget.coffee create mode 100644 app/assets/javascripts/app/controllers/knowledge_base/search_controller.coffee create mode 100644 app/assets/javascripts/app/controllers/knowledge_base/search_field_panel.coffee create mode 100644 app/assets/javascripts/app/controllers/knowledge_base/search_field_widget.coffee create mode 100644 app/assets/javascripts/app/controllers/knowledge_base/search_item.coffee create mode 100644 app/assets/javascripts/app/controllers/knowledge_base/sidebar.coffee create mode 100644 app/assets/javascripts/app/controllers/knowledge_base/sidebar/_generic_list.coffee create mode 100644 app/assets/javascripts/app/controllers/knowledge_base/sidebar/actions.coffee create mode 100644 app/assets/javascripts/app/controllers/knowledge_base/sidebar/answers.coffee create mode 100644 app/assets/javascripts/app/controllers/knowledge_base/sidebar/attachments.coffee create mode 100644 app/assets/javascripts/app/controllers/knowledge_base/sidebar/categories.coffee create mode 100644 app/assets/javascripts/app/controllers/knowledge_base/sidebar/linked_tickets.coffee create mode 100644 app/assets/javascripts/app/controllers/widget/answer_list.coffee create mode 100644 app/assets/javascripts/app/controllers/widget/button_with_dropdown.coffee create mode 100644 app/assets/javascripts/app/controllers/widget/link/kb_answer.coffee create mode 100644 app/assets/javascripts/app/lib/app_post/color.coffee create mode 100644 app/assets/javascripts/app/lib/app_post/icon_picker.coffee create mode 100644 app/assets/javascripts/app/lib/app_post/iconset_picker.coffee create mode 100644 app/assets/javascripts/app/lib/app_post/multi_locales.coffee create mode 100644 app/assets/javascripts/app/lib/app_post/multi_locales_row.coffee create mode 100644 app/assets/javascripts/app/lib/app_post/popover_provider/kb_popover_provider.coffee create mode 100644 app/assets/javascripts/app/lib/app_post/popover_provider/knowledge_base_answer_provider.coffee create mode 100644 app/assets/javascripts/app/lib/app_post/popover_provider/knowledge_base_category_provider.coffee create mode 100644 app/assets/javascripts/app/lib/app_post/popover_provider/knowledge_base_provider.coffee create mode 100644 app/assets/javascripts/app/lib/mixins/knowledge_base_actions.coffee create mode 100644 app/assets/javascripts/app/lib/mixins/knowledge_base_can_be_published.coffee create mode 100644 app/assets/javascripts/app/lib/mixins/knowledge_base_translatable.coffee create mode 100644 app/assets/javascripts/app/lib/mixins/knowledge_base_translationable.coffee create mode 100644 app/assets/javascripts/app/models/knowledge_base.coffee create mode 100644 app/assets/javascripts/app/models/knowledge_base_answer.coffee create mode 100644 app/assets/javascripts/app/models/knowledge_base_answer_translation.coffee create mode 100644 app/assets/javascripts/app/models/knowledge_base_answer_translation_state.coffee create mode 100644 app/assets/javascripts/app/models/knowledge_base_category.coffee create mode 100644 app/assets/javascripts/app/models/knowledge_base_category_translation.coffee create mode 100644 app/assets/javascripts/app/models/knowledge_base_layout.coffee create mode 100644 app/assets/javascripts/app/models/knowledge_base_locale.coffee create mode 100644 app/assets/javascripts/app/models/knowledge_base_menu_item.coffee create mode 100644 app/assets/javascripts/app/models/knowledge_base_translation.coffee create mode 100644 app/assets/javascripts/app/views/generic/attachments.jst.eco create mode 100644 app/assets/javascripts/app/views/generic/color.jst.eco create mode 100644 app/assets/javascripts/app/views/generic/icon_picker.jst.eco create mode 100644 app/assets/javascripts/app/views/generic/iconset_picker.jst.eco create mode 100644 app/assets/javascripts/app/views/generic/multi_locales.jst.eco create mode 100644 app/assets/javascripts/app/views/generic/multi_locales_row.jst.eco create mode 100644 app/assets/javascripts/app/views/generic/radio_graphic.jst.eco create mode 100644 app/assets/javascripts/app/views/knowledge_base/_answer_attachments.jst.eco create mode 100644 app/assets/javascripts/app/views/knowledge_base/_reader_answer_meta.jst.eco create mode 100644 app/assets/javascripts/app/views/knowledge_base/_reader_linked_tickets.jst.eco create mode 100644 app/assets/javascripts/app/views/knowledge_base/_reader_list_item.jst.eco create mode 100644 app/assets/javascripts/app/views/knowledge_base/_reader_pagination.jst.eco create mode 100644 app/assets/javascripts/app/views/knowledge_base/agent.jst.eco create mode 100644 app/assets/javascripts/app/views/knowledge_base/base_form.coffee create mode 100644 app/assets/javascripts/app/views/knowledge_base/content.jst.eco create mode 100644 app/assets/javascripts/app/views/knowledge_base/content_can_be_published_header_suffix.jst.eco create mode 100644 app/assets/javascripts/app/views/knowledge_base/delete.coffee create mode 100644 app/assets/javascripts/app/views/knowledge_base/navigation.jst.eco create mode 100644 app/assets/javascripts/app/views/knowledge_base/new_modal.coffee create mode 100644 app/assets/javascripts/app/views/knowledge_base/public_menu_form_item.jst.eco create mode 100644 app/assets/javascripts/app/views/knowledge_base/public_menu_form_item_row.jst.eco create mode 100644 app/assets/javascripts/app/views/knowledge_base/reader.jst.eco create mode 100644 app/assets/javascripts/app/views/knowledge_base/reader_list.jst.eco create mode 100644 app/assets/javascripts/app/views/knowledge_base/scheduled_widget.jst.eco create mode 100644 app/assets/javascripts/app/views/knowledge_base/search.jst.eco create mode 100644 app/assets/javascripts/app/views/knowledge_base/search_field_panel.jst.eco create mode 100644 app/assets/javascripts/app/views/knowledge_base/search_field_widget.jst.eco create mode 100644 app/assets/javascripts/app/views/knowledge_base/search_item.jst.eco create mode 100644 app/assets/javascripts/app/views/knowledge_base/server_snippet.coffee create mode 100644 app/assets/javascripts/app/views/knowledge_base/server_snippet.jst.eco create mode 100644 app/assets/javascripts/app/views/knowledge_base/sidebar/actions.jst.eco create mode 100644 app/assets/javascripts/app/views/knowledge_base/sidebar/attachments.jst.eco create mode 100644 app/assets/javascripts/app/views/knowledge_base/sidebar/generic_list.jst.eco create mode 100644 app/assets/javascripts/app/views/knowledge_base/sidebar/linked_tickets.jst.eco create mode 100644 app/assets/javascripts/app/views/knowledge_base/vertical_form.jst.eco create mode 100644 app/assets/javascripts/app/views/layout_ref/kb_agent_reader_ref.jst.eco create mode 100644 app/assets/javascripts/app/views/layout_ref/kb_link_answer_to_answer_ref.jst.eco create mode 100644 app/assets/javascripts/app/views/layout_ref/kb_link_ticket_to_answer_ref.jst.eco create mode 100644 app/assets/javascripts/app/views/link/kb_answer.jst.eco create mode 100644 app/assets/javascripts/app/views/popover/kb_generic.jst.eco create mode 100644 app/assets/javascripts/app/views/reorder_modal.jst.eco create mode 100644 app/assets/javascripts/app/views/vertical_form.coffee create mode 100644 app/assets/javascripts/app/views/widget/button_with_dropdown.jst.eco create mode 100644 app/assets/javascripts/knowledge_base_public.js create mode 100644 app/assets/javascripts/knowledge_base_public/dropdown.js create mode 100644 app/assets/javascripts/knowledge_base_public/language.js create mode 100644 app/assets/javascripts/knowledge_base_public/namespace.js create mode 100644 app/assets/javascripts/knowledge_base_public/search.js create mode 100644 app/assets/javascripts/knowledge_base_public/util.js create mode 100644 app/assets/javascripts/knowledge_base_public_polyfills.js create mode 100644 app/assets/javascripts/knowledge_base_public_polyfills/element.prepend.js create mode 100755 app/assets/javascripts/knowledge_base_public_polyfills/fetch.js create mode 100755 app/assets/javascripts/knowledge_base_public_polyfills/promise.js create mode 100644 app/assets/javascripts/knowledge_base_public_polyfills/svgstore.js create mode 100644 app/assets/stylesheets/knowledge_base.scss create mode 100644 app/controllers/attachments_controller.rb create mode 100644 app/controllers/concerns/has_publishing.rb create mode 100644 app/controllers/knowledge_base/answer/attachments_controller.rb create mode 100644 app/controllers/knowledge_base/answers_controller.rb create mode 100644 app/controllers/knowledge_base/base_controller.rb create mode 100644 app/controllers/knowledge_base/categories_controller.rb create mode 100644 app/controllers/knowledge_base/layouts_controller.rb create mode 100644 app/controllers/knowledge_base/locales_controller.rb create mode 100644 app/controllers/knowledge_base/manage_controller.rb create mode 100644 app/controllers/knowledge_base/public/answers_controller.rb create mode 100644 app/controllers/knowledge_base/public/base_controller.rb create mode 100644 app/controllers/knowledge_base/public/categories_controller.rb create mode 100644 app/controllers/knowledge_base/search_controller.rb create mode 100644 app/controllers/knowledge_bases_controller.rb create mode 100644 app/helpers/knowledge_base_helper.rb create mode 100644 app/helpers/knowledge_base_icon_helper.rb create mode 100644 app/helpers/knowledge_base_visibility_class_helper.rb create mode 100644 app/helpers/translation_helper.rb create mode 100644 app/jobs/checks_kb_client_notification_job.rb create mode 100644 app/jobs/scheduled_touch_job.rb create mode 100644 app/models/concerns/can_be_published.rb create mode 100644 app/models/concerns/checks_kb_client_notification.rb create mode 100644 app/models/concerns/has_agent_allowed_params.rb create mode 100644 app/models/concerns/has_knowledge_base_attachment_permissions.rb create mode 100644 app/models/concerns/has_rich_text.rb create mode 100644 app/models/concerns/has_translations.rb create mode 100644 app/models/knowledge_base.rb create mode 100644 app/models/knowledge_base/answer.rb create mode 100644 app/models/knowledge_base/answer/translation.rb create mode 100644 app/models/knowledge_base/answer/translation/content.rb create mode 100644 app/models/knowledge_base/category.rb create mode 100644 app/models/knowledge_base/category/translation.rb create mode 100644 app/models/knowledge_base/has_unique_title.rb create mode 100644 app/models/knowledge_base/locale.rb create mode 100644 app/models/knowledge_base/menu_item.rb create mode 100644 app/models/knowledge_base/search.rb create mode 100644 app/models/knowledge_base/translation.rb create mode 100644 app/views/knowledge_base/public/_breadcrumb.html.erb create mode 100644 app/views/knowledge_base/public/_icon_from_set.html.erb create mode 100644 app/views/knowledge_base/public/_icon_native.html.erb create mode 100644 app/views/knowledge_base/public/_inline_stylesheet.html.erb create mode 100644 app/views/knowledge_base/public/_top_banner.html.erb create mode 100644 app/views/knowledge_base/public/_visibility_note.html.erb create mode 100644 app/views/knowledge_base/public/answers/show.html.erb create mode 100644 app/views/knowledge_base/public/categories/_answer.html.erb create mode 100644 app/views/knowledge_base/public/categories/_category.html.erb create mode 100644 app/views/knowledge_base/public/categories/index.html.erb create mode 100644 app/views/knowledge_base/public/not_found.html.erb create mode 100644 app/views/knowledge_base/public/show_alternatives.html.erb create mode 100644 app/views/layouts/knowledge_base.html.erb create mode 100644 config/routes/attachments.rb create mode 100644 config/routes/knowledge_base.rb create mode 100644 contrib/graphics.sketch create mode 100644 db/migrate/20190531180304_initialize_knowledge_base.rb create mode 100644 lib/can_be_published/state_machine.rb create mode 100644 lib/knowledge_base/menu_item_update_action.rb create mode 100644 lib/knowledge_base/server_snippet.rb create mode 100644 lib/knowledge_base/server_snippet_apache.rb create mode 100644 lib/knowledge_base/server_snippet_nginx.rb create mode 100644 lib/search_knowledge_base_backend.rb create mode 100755 public/assets/icon-fonts/FontAwesome.json create mode 100644 public/assets/icon-fonts/FontAwesome.woff create mode 100755 public/assets/icon-fonts/Simple-Line-Icons.json create mode 100644 public/assets/icon-fonts/Simple-Line-Icons.woff create mode 100755 public/assets/icon-fonts/anticon.json create mode 100644 public/assets/icon-fonts/anticon.woff create mode 100755 public/assets/icon-fonts/ionicons.json create mode 100644 public/assets/icon-fonts/ionicons.woff create mode 100755 public/assets/icon-fonts/material.json create mode 100644 public/assets/icon-fonts/material.woff create mode 100644 public/assets/images/colorcircle.gif create mode 100644 public/assets/images/graphics/knowledge_base_accordion.svg create mode 100644 public/assets/images/graphics/knowledge_base_grid.svg create mode 100644 public/assets/images/graphics/knowledge_base_list.svg create mode 100644 public/assets/images/icons/chain.svg create mode 100644 public/assets/images/icons/danger.svg create mode 100644 public/assets/images/icons/document.svg create mode 100644 public/assets/images/icons/external.svg create mode 100644 public/assets/images/icons/eye.svg create mode 100644 public/assets/images/icons/knowledge-base-answer.svg create mode 100644 public/assets/images/icons/knowledge-base.svg create mode 100644 public/assets/images/icons/rearange.svg create mode 100644 spec/factories/knowledge_base.rb create mode 100644 spec/factories/knowledge_base/answer.rb create mode 100644 spec/factories/knowledge_base/answer/translation.rb create mode 100644 spec/factories/knowledge_base/answer/translation/content.rb create mode 100644 spec/factories/knowledge_base/category.rb create mode 100644 spec/factories/knowledge_base/category/translation.rb create mode 100644 spec/factories/knowledge_base/locale.rb create mode 100644 spec/factories/knowledge_base/menu_item.rb create mode 100644 spec/factories/knowledge_base/translation.rb create mode 100644 spec/models/concerns/checks_kb_client_notification_examples.rb create mode 100644 spec/models/contexts/factory_context.rb create mode 100644 spec/models/knowledge_base/answer/translation/content_spec.rb create mode 100644 spec/models/knowledge_base/answer/translation_spec.rb create mode 100644 spec/models/knowledge_base/answer_spec.rb create mode 100644 spec/models/knowledge_base/category/translation_spec.rb create mode 100644 spec/models/knowledge_base/category_spec.rb create mode 100644 spec/models/knowledge_base/locale_spec.rb create mode 100644 spec/models/knowledge_base/menu_item_spec.rb create mode 100644 spec/models/knowledge_base/translation_spec.rb create mode 100644 spec/models/knowledge_base_spec.rb diff --git a/.eslintrc b/.eslintrc index 9faa37508..78118f7f0 100644 --- a/.eslintrc +++ b/.eslintrc @@ -57,7 +57,7 @@ rules: no-case-declarations: 2 no-div-regex: 2 no-else-return: 0 - no-empty-label: 2 + no-labels: 2 no-empty-pattern: 2 no-eq-null: 2 no-eval: 2 @@ -69,7 +69,6 @@ rules: no-implied-eval: 2 no-invalid-this: 0 no-iterator: 2 - no-labels: 0 no-lone-blocks: 2 no-loop-func: 2 no-magic-number: 0 diff --git a/Gemfile b/Gemfile index 117c84af7..714fbff06 100644 --- a/Gemfile +++ b/Gemfile @@ -31,6 +31,9 @@ gem 'eventmachine' # core - password security gem 'argon2', '1.1.5' +# core - state machine +gem 'aasm' + # performance - Memcached gem 'dalli' @@ -105,6 +108,9 @@ gem 'telephone_number' # feature - SMS gem 'twilio-ruby' +# feature - ordering +gem 'acts_as_list' + # integrations gem 'clearbit' gem 'net-ldap' @@ -136,7 +142,9 @@ group :development, :test do gem 'pry-stack_explorer' # test frameworks + gem 'rails-controller-testing' gem 'rspec-rails' + gem 'shoulda-matchers' gem 'test-unit' # test DB diff --git a/Gemfile.lock b/Gemfile.lock index 743ef6e34..b15e457f9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -47,6 +47,8 @@ GIT GEM remote: https://rubygems.org/ specs: + aasm (5.0.0) + concurrent-ruby (~> 1.0) actioncable (5.1.7) actionpack (= 5.1.7) nio4r (~> 2.0) @@ -94,6 +96,8 @@ GEM i18n (>= 0.7, < 2) minitest (~> 5.1) tzinfo (~> 1.1) + acts_as_list (0.9.16) + activerecord (>= 3.0) addressable (2.5.2) public_suffix (>= 2.0.2, < 4.0) arel (8.0.0) @@ -374,6 +378,10 @@ GEM bundler (>= 1.3.0) railties (= 5.1.7) sprockets-rails (>= 2.0.0) + rails-controller-testing (1.0.4) + actionpack (>= 5.0.1.x) + actionview (>= 5.0.1.x) + activesupport (>= 5.0.1.x) rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) @@ -450,6 +458,8 @@ GEM childprocess (>= 0.5, < 2.0) rubyzip (~> 1.2, >= 1.2.2) shellany (0.0.1) + shoulda-matchers (4.0.1) + activesupport (>= 4.2.0) simple_oauth (0.3.1) simplecov (0.16.1) docile (~> 1.1) @@ -530,9 +540,11 @@ PLATFORMS ruby DEPENDENCIES + aasm activerecord-import activerecord-nulldb-adapter activerecord-session_store + acts_as_list argon2 (= 1.1.5) autodiscover! autoprefixer-rails @@ -592,6 +604,7 @@ DEPENDENCIES puma rack-livereload rails (= 5.1.7) + rails-controller-testing rails-observers rb-fsevent rchardet (>= 1.8.0) @@ -603,6 +616,7 @@ DEPENDENCIES rubyntlm! sassc-rails selenium-webdriver + shoulda-matchers simplecov simplecov-rcov slack-notifier diff --git a/LICENSE-3RD-PARTY.txt b/LICENSE-3RD-PARTY.txt index a2f6351b9..27261b6ee 100644 --- a/LICENSE-3RD-PARTY.txt +++ b/LICENSE-3RD-PARTY.txt @@ -163,3 +163,28 @@ Source: https://gist.github.com/sbrin/6801034 Copyright: 2015, sbrin - https://github.com/sbrin License: MIT license ----------------------------------------------------------------------------- +ant-design icon font +Source: https://github.com/ant-design/ant-design +Copyright: 2015-present Alipay.com, https://www.alipay.com/ +License: MIT license +----------------------------------------------------------------------------- +Font Awesome icon font +Source: http://fontawesome.io/ +Copyright: Font Awesome by Dave Gandy - http://fontawesome.io +License: SIL OFL 1.1 +----------------------------------------------------------------------------- +Simple line icons font +Source: https://github.com/thesabbir/simple-line-icons +Copyright: 2016 Sabbir Ahmed & All Contributors +License: MIT license +----------------------------------------------------------------------------- +Ionicons icon font +Source: https://github.com/ionic-team/ionicons +Copyright: 2016 Drifty (http://drifty.com/) +License: MIT license +----------------------------------------------------------------------------- +Material icon font +Source: https://github.com/google/material-design-icons +Copyright: Google +License: Apache License, Version 2.0 +----------------------------------------------------------------------------- diff --git a/LICENSE-ICONS-3RD-PARTY.json b/LICENSE-ICONS-3RD-PARTY.json index 3fab153f1..80b42fd76 100644 --- a/LICENSE-ICONS-3RD-PARTY.json +++ b/LICENSE-ICONS-3RD-PARTY.json @@ -29,8 +29,13 @@ "url": "", "license": "MIT" }, + "reply.svg": { + "author": "Felix Niklas", + "url": "", + "license": "MIT" + }, "weibo-button.svg": { - "author": "", + "author": "Weibo", "url": "", "license": "" }, @@ -159,11 +164,46 @@ "url": "", "license": "MIT" }, + "rearange.svg": { + "author": "Felix Niklas", + "url": "", + "license": "MIT" + }, + "external.svg": { + "author": "Felix Niklas", + "url": "", + "license": "MIT" + }, "mood-sad.svg": { + "author": "Felix Niklas", + "url": "", + "license": "MIT" + }, + "radio.svg": { "author": "Zammad", "url": "", "license": "MIT" }, + "radio-checked.svg": { + "author": "Zammad", + "url": "", + "license": "MIT" + }, + "knowledge-base.svg": { + "author": "Felix Niklas", + "url": "", + "license": "MIT" + }, + "eye.svg": { + "author": "Felix Niklas", + "url": "", + "license": "MIT" + }, + "document.svg": { + "author": "Felix Niklas", + "url": "", + "license": "MIT" + }, "low-priority.svg": { "author": "Felix Niklas", "url": "", @@ -180,9 +220,9 @@ "license": "MIT" }, "inactive-user.svg": { - "author": "R\u00e9my M\u00e9dard", - "url": "https:\/\/thenounproject.com\/search\/?q=user&i=10314", - "license": "CC 3.0 Attribution" + "author": "Felix Niklas", + "url": "", + "license": "MIT" }, "inactive-organization.svg": { "author": "Felix Niklas", @@ -194,6 +234,16 @@ "url": "", "license": "MIT" }, + "important.svg": { + "author": "Felix Niklas", + "url": "", + "license": "MIT" + }, + "reply-all.svg": { + "author": "Felix Niklas", + "url": "", + "license": "MIT" + }, "paperclip.svg": { "author": "Cheesefork", "url": "https:\/\/thenounproject.com\/search\/?q=attachment&i=197956", @@ -204,6 +254,21 @@ "url": "", "license": "MIT" }, + "lock.svg": { + "author": "Zammad", + "url": "", + "license": "MIT" + }, + "lock-open.svg": { + "author": "Zammad", + "url": "", + "license": "MIT" + }, + "forward.svg": { + "author": "Felix Niklas", + "url": "", + "license": "MIT" + }, "file-word.svg": { "author": "Felix Niklas", "url": "", @@ -220,9 +285,9 @@ "license": "MIT" }, "file-pdf.svg": { - "author": "Felix Niklas", + "author": "Adobe", "url": "", - "license": "MIT" + "license": "" }, "file-excel.svg": { "author": "Felix Niklas", @@ -244,28 +309,8 @@ "url": "", "license": "MIT" }, - "reply.svg": { - "author": "Felix Niklas", - "url": "", - "license": "MIT" - }, - "reply-all.svg": { - "author": "Felix Niklas", - "url": "", - "license": "MIT" - }, - "lock.svg": { - "author": "Zammad", - "url": "", - "license": "MIT" - }, - "forward.svg": { - "author": "Felix Niklas", - "url": "", - "license": "MIT" - }, "office365-button.svg": { - "author": "", + "author": "Office 365", "url": "", "license": "" }, @@ -589,6 +634,31 @@ "url": "", "license": "MIT" }, + "checkmark.svg": { + "author": "Zammad", + "url": "", + "license": "MIT" + }, + "chain.svg": { + "author": "Felix Niklas", + "url": "", + "license": "MIT" + }, + "bold.svg": { + "author": "Felix Niklas", + "url": "", + "license": "MIT" + }, + "checkbox.svg": { + "author": "Zammad", + "url": "", + "license": "MIT" + }, + "checkbox-indeterminate.svg": { + "author": "Felix Niklas", + "url": "", + "license": "MIT" + }, "checkbox-checked.svg": { "author": "Zammad", "url": "", @@ -614,11 +684,6 @@ "url": "", "license": "MIT" }, - "checkmark.svg": { - "author": "Zammad", - "url": "", - "license": "MIT" - }, "chat.svg": { "author": "Felix Niklas", "url": "", diff --git a/app/assets/javascripts/app/controllers/_application_controller.coffee b/app/assets/javascripts/app/controllers/_application_controller.coffee index acd02c1b9..b062cdf60 100644 --- a/app/assets/javascripts/app/controllers/_application_controller.coffee +++ b/app/assets/javascripts/app/controllers/_application_controller.coffee @@ -88,8 +88,10 @@ class App.Controller extends Spine.Controller for callId in idsToCancel App.Ajax.abort(callId) + # release Spine's event handling release: -> - # release custom bindings after it got removed from dom + @off() + @stopListening() # add @title methode to set title title: (name, translate = false) -> @@ -452,6 +454,7 @@ class App.ControllerModal extends App.Controller buttonCancel: false buttonCancelClass: 'btn--text btn--subtle' buttonSubmit: true + includeForm: true headPrefix: '' shown: true closeOnAnyClick: false @@ -516,6 +519,7 @@ class App.ControllerModal extends App.Controller buttonClass: @buttonClass centerButtons: @centerButtons leftButtons: @leftButtons + includeForm: @includeForm )) modal.find('.modal-body').html(content) if !@initRenderingDone @@ -554,18 +558,19 @@ class App.ControllerModal extends App.Controller if @small @el.addClass('modal--small') - @el.modal( - keyboard: @keyboard - show: true - backdrop: @backdrop - container: @container - ).on( - 'show.bs.modal': @localOnShow - 'shown.bs.modal': @localOnShown - 'hide.bs.modal': @localOnClose - 'hidden.bs.modal': @localOnClosed - 'dismiss.bs.modal': @localOnCancel - ) + @el + .on( + 'show.bs.modal': @localOnShow + 'shown.bs.modal': @localOnShown + 'hide.bs.modal': @localOnClose + 'hidden.bs.modal': @localOnClosed + 'dismiss.bs.modal': @localOnCancel + ).modal( + keyboard: @keyboard + show: true + backdrop: @backdrop + container: @container + ) if @closeOnAnyClick @el.on('click', => @@ -604,7 +609,7 @@ class App.ControllerModal extends App.Controller onShown: (e) => if @autoFocusOnFirstInput - @$('input:not([disabled]):not([type="hidden"]):not(".btn"), textarea').first().focus() + @$('input:not([disabled]):not([type="hidden"]):not(".btn"):not([type="radio"]:not(:checked)), textarea').first().focus() @initalFormParams = @formParams() localOnClose: (e) => diff --git a/app/assets/javascripts/app/controllers/_application_controller_form.coffee b/app/assets/javascripts/app/controllers/_application_controller_form.coffee index 7c5e1f5ba..863e413ff 100644 --- a/app/assets/javascripts/app/controllers/_application_controller_form.coffee +++ b/app/assets/javascripts/app/controllers/_application_controller_form.coffee @@ -1,4 +1,9 @@ class App.ControllerForm extends App.Controller + fullFormSubmitLabel: 'Submit' + fullFormSubmitAdditionalClasses: '' + fullFormButtonsContainerClass: '' + fullFormAdditionalButtons: [] # [{className: 'js-class', text: 'Label'}] + constructor: (params) -> super for key, value of params @@ -71,7 +76,9 @@ class App.ControllerForm extends App.Controller App.Log.debug 'ControllerForm', 'formGen', @model.configure_attributes # check if own fieldset should be generated - if @noFieldset + # forced when the form is a grid form because flex-wrap doesn't work on fieldsets + # source: https://github.com/philipwalton/flexbugs#9-some-html-elements-cant-be-flex-containers + if @noFieldset || @grid fieldset = @el else fieldset = $('
') @@ -127,7 +134,24 @@ class App.ControllerForm extends App.Controller if @fullForm if !@formClass @formClass = '' - fieldset = $('
').prepend(fieldset) + + fieldset = $("
").prepend(fieldset) + container = $("
") + + for buttonConfig in @fullFormAdditionalButtons + btn = $("") + .text(App.i18n.translateContent(@fullFormSubmitLabel)) + .appendTo(container) + + container.appendTo(fieldset) + + #fieldset = $("
").prepend(fieldset) + #fieldset = $("
").prepend(fieldset) # bind form events if @events @@ -258,11 +282,15 @@ class App.ControllerForm extends App.Controller # set params value if @params - # check if we have a references parts = attribute.name.split '::' - if parts[0] && parts[1] - if @params[ parts[0] ] && parts[1] of @params[ parts[0] ] - attribute.value = @params[ parts[0] ][ parts[1] ] + + if parts.length > 1 + deepValue = parts.reduce((memo, elem) -> + memo?[elem] + , @params) + + if deepValue isnt undefined + attribute.value = deepValue # set params value to default if attribute.name of @params @@ -426,11 +454,16 @@ class App.ControllerForm extends App.Controller ) # get all params of the form - @params: (form) -> + # set clearAccessories to true to remove inline image resizing handles + @params: (form, clearAccessories = false) -> param = {} lookupForm = @findForm(form) + if clearAccessories + # remove inline image resizing handles + lookupForm.find('.richtext.form-control').trigger('click') + # get contenteditable for element in lookupForm.find('[contenteditable]') name = $(element).data('name') @@ -656,6 +689,9 @@ class App.ControllerForm extends App.Controller # set forms to read only during communication with backend lookupForm.find('button, input, select, textarea').prop('readonly', true) + # disable radio and checbkox buttons + lookupForm.find('input[type=checkbox], input[type=radio]').prop('disabled', true) + # disable additionals submits lookupForm.find('button').prop('disabled', true) else @@ -678,6 +714,9 @@ class App.ControllerForm extends App.Controller # enable fields again lookupForm.find('button, input, select, textarea').prop('readonly', false) + # enable radio and checbkox buttons + lookupForm.find('input[type=checkbox], input[type=radio]').prop('disabled', false) + # enable submits again lookupForm.find('button').prop('disabled', false) else diff --git a/app/assets/javascripts/app/controllers/_application_controller_generic.coffee b/app/assets/javascripts/app/controllers/_application_controller_generic.coffee index f5c9a09a3..a9ceed9e1 100644 --- a/app/assets/javascripts/app/controllers/_application_controller_generic.coffee +++ b/app/assets/javascripts/app/controllers/_application_controller_generic.coffee @@ -403,6 +403,8 @@ class App.ControllerTabs extends App.Controller subHeader: @subHeader tabs: @tabs addTab: @addTab + headerSwitchName: @headerSwitchName + headerSwitchChecked: @headerSwitchChecked ) # insert content diff --git a/app/assets/javascripts/app/controllers/_application_controller_reorder_modal.coffee b/app/assets/javascripts/app/controllers/_application_controller_reorder_modal.coffee new file mode 100644 index 000000000..5116d4acd --- /dev/null +++ b/app/assets/javascripts/app/controllers/_application_controller_reorder_modal.coffee @@ -0,0 +1,51 @@ +class App.ControllerReorderModal extends App.ControllerModal + head: 'Drag to reorder' + content: -> + view = $(App.view('reorder_modal')()) + + table = new App.ControllerTable( + baseColWidth: null + dndCallback: -> + true + overview: ['title'] + attribute_list: [ + { name: 'title', display: 'Name' } + ] + objects: @items + ) + + view.find('.js-table-container').html(table.el) + + view + + onShown: -> + super + @$('.js-submit').focus() + + save: -> + ids = @$('tr.item').toArray().map (el) -> parseInt(el.dataset.id) + + @$('.alert').addClass('hidden') + + @formDisable(@el) + + @ajax( + id: 'reorder_save' + type: 'PATCH' + data: JSON.stringify({ordered_ids: ids}) + url: @url + processData: true + success: (data, status, xhr) => + App.Collection.loadAssets(data) + App.Event.trigger 'knowledge_base::sidebar::rerender' + @close() + error: (xhr) => + data = JSON.parse(xhr.responseText) + @$('.alert--danger').removeClass('hidden').text(data.error) + @formEnable(@el) + ) + + onSubmit: -> + super + @save() + diff --git a/app/assets/javascripts/app/controllers/_application_controller_table.coffee b/app/assets/javascripts/app/controllers/_application_controller_table.coffee index 1466eb4fa..0cde2584e 100644 --- a/app/assets/javascripts/app/controllers/_application_controller_table.coffee +++ b/app/assets/javascripts/app/controllers/_application_controller_table.coffee @@ -116,6 +116,7 @@ class App.ControllerTable extends App.Controller shownPage: 0 destroy: false + customActions: [] columnsLength: undefined headers: undefined @@ -544,7 +545,7 @@ class App.ControllerTable extends App.Controller # get header data @headers = [] - @actions = [] + @actions = [].concat @customActions availableWidth = @availableWidth for item in @overviewAttributes headerFound = false diff --git a/app/assets/javascripts/app/controllers/_manage/knowledge_base.coffee b/app/assets/javascripts/app/controllers/_manage/knowledge_base.coffee new file mode 100644 index 000000000..086b5ca35 --- /dev/null +++ b/app/assets/javascripts/app/controllers/_manage/knowledge_base.coffee @@ -0,0 +1,130 @@ +class App.ManageKnowledgeBase extends App.ControllerTabs + header: 'Knowledge Base' + headerSwitchName: 'kb-activate' + + events: + 'hidden.bs.tab li': 'didHideTab' + 'show.bs.tab li': 'willShowTab' + 'change .js-header-switch input': 'didChangeHeaderSwitch' + + elements: + '.js-header-switch input': 'headerSwitchInput' + + didHideTab: (e) -> + selector = $(e.relatedTarget).attr('href') + @$(selector).trigger('hidden.bs.tab') + + willShowTab: (e) -> + selector = $(e.target).attr('href') + @$(selector).trigger('show.bs.tab') + + tabs: [] + + constructor: -> + super + + @render() + @fetchAndRender() + + fetchAndRender: => + @startLoading() + + @ajax( + id: 'knowledge_bases_init_admin' + type: 'GET' + url: @apiPath + '/knowledge_bases/manage/init' + processData: true + success: (data, status, xhr) => + App.Collection.loadAssets(data) + + @knowledge_base_id = App.KnowledgeBase.first()?.id + @stopLoading() + @processLoaded() + error: (xhr) => + @knowledge_base_id = undefined + @stopLoading() + ) + + clear: -> + App.KnowledgeBase.find(@knowledge_base_id).remove(clear: true) + @fetchAndRender() + + processLoaded: -> + if @knowledge_base_id + @renderLoaded() + else + @renderNonExistant() + + renderNonExistant: -> + @renderScreenError(detail: 'No Knowledge Base. Please create first Knowledge Base', el: @$('.page-content')) + @headerSwitchInput.prop('checked', false) + + new App.KnowledgeBaseNewModal( + parentVC: @ + container: @el.closest('.main') + ) + + didChangeHeaderSwitch: -> + @headerSwitchInput.prop('disabled', true) + + upcomingState = @headerSwitchInput.prop('checked') + action = if upcomingState then 'activate' else 'deactivate' + kb = App.KnowledgeBase.find(@knowledge_base_id) + + @ajax( + id: 'knowledge_bases_init_admin' + type: 'PATCH' + url: kb.manageUrl(action) + processData: true + success: (data, status, xhr) => + App.Collection.loadAssets(data) + @processLoaded() + @headerSwitchInput.prop('disabled', false) + error: (xhr) => + @headerSwitchInput.prop('checked', !upcomingState) + @headerSwitchInput.prop('disabled', false) + ) + + renderLoaded: -> + params = { + knowledge_base_id: @knowledge_base_id + parentVC: @ + } + + @tabs = [ + { + name: 'Style' + target: 'style' + controller: App.KnowledgeBaseForm + params: _.extend({}, params, { screen: 'style', split: true }) + },{ + name: 'Languages' + target: 'languages' + controller: App.KnowledgeBaseForm + params: _.extend({}, params, { screen: 'languages' }) + },{ + name: 'Public Menu' + target: 'public_menu' + controller: App.KnowledgeBasePublicMenuForm + params: _.extend({}, params, { screen: 'public_menu' }) + },{ + name: 'Delete' + target: 'delete' + controller: App.KnowledgeBaseDelete + params: params + } + ] + + if !App.Config.get('system_online_service') + @tabs.splice(-1, 0, { + name: 'Custom Address' + target: 'custom_address' + controller: App.KnowledgeBaseCustomAddressForm, + params: _.extend({}, params, { screen: 'custom_address' }) + }) + + @render() + + @headerSwitchInput.prop('checked', App.KnowledgeBase.find(@knowledge_base_id).active) + +App.Config.set('KnowledgeBase', { prio: 10000, name: 'Knowledge Base', parent: '#manage', target: '#manage/knowledge_base', controller: App.ManageKnowledgeBase, permission: ['admin.knowledge_base'] }, 'NavBarAdmin') diff --git a/app/assets/javascripts/app/controllers/_profile/token_access.coffee b/app/assets/javascripts/app/controllers/_profile/token_access.coffee index 7bbd67800..0f72390c1 100644 --- a/app/assets/javascripts/app/controllers/_profile/token_access.coffee +++ b/app/assets/javascripts/app/controllers/_profile/token_access.coffee @@ -49,8 +49,9 @@ class Index extends App.ControllerSubContent delete: (e) => e.preventDefault() + id = $(e.currentTarget).data('token-id') + callback = => - id = $(e.target).closest('a').data('token-id') @ajax( id: 'user_access_token_delete' type: 'DELETE' diff --git a/app/assets/javascripts/app/controllers/_ui_element/_application_ui_element.coffee b/app/assets/javascripts/app/controllers/_ui_element/_application_ui_element.coffee index 674324397..26242bd4a 100644 --- a/app/assets/javascripts/app/controllers/_ui_element/_application_ui_element.coffee +++ b/app/assets/javascripts/app/controllers/_ui_element/_application_ui_element.coffee @@ -163,12 +163,21 @@ class App.UiElement.ApplicationUiElement if attribute.translate nameNew = App.i18n.translateInline(nameNew) - attribute.options.push + row = value: item.id, note: item.note, name: nameNew, title: if item.email then item.email else nameNew + if item.graphic + row.graphic = item.graphic + + # only used for graphics + if item.aspect_ratio + row.aspect_ratio = item.aspect_ratio + + attribute.options.push row + attribute.sortBy = null # execute filter diff --git a/app/assets/javascripts/app/controllers/_ui_element/autocompletion_ajax.coffee b/app/assets/javascripts/app/controllers/_ui_element/autocompletion_ajax.coffee index d6a15334d..62d3d40cb 100644 --- a/app/assets/javascripts/app/controllers/_ui_element/autocompletion_ajax.coffee +++ b/app/assets/javascripts/app/controllers/_ui_element/autocompletion_ajax.coffee @@ -1,18 +1,18 @@ # coffeelint: disable=camel_case_classes class App.UiElement.autocompletion_ajax - @render: (attribute, params = {}) -> - + @render: (attribute, params = {}, form) -> if params[attribute.name] || attribute.value object = App[attribute.relation].find(params[attribute.name] || attribute.value) valueName = object.displayName() # selectable search searchableAjaxSelectObject = new App.SearchableAjaxSelect( + delegate: form attribute: value: params[attribute.name] || attribute.value valueName: valueName name: attribute.name - id: params.organization_id || attribute.value + id: params.organization_id || attribute.id placeholder: App.i18n.translateInline('Search...') limit: 40 object: attribute.relation diff --git a/app/assets/javascripts/app/controllers/_ui_element/color.coffee b/app/assets/javascripts/app/controllers/_ui_element/color.coffee new file mode 100644 index 000000000..2641350ae --- /dev/null +++ b/app/assets/javascripts/app/controllers/_ui_element/color.coffee @@ -0,0 +1,4 @@ +# coffeelint: disable=camel_case_classes +class App.UiElement.color extends App.UiElement.ApplicationUiElement + @render: (attribute, params) -> + new App.Color(attribute: attribute).element() diff --git a/app/assets/javascripts/app/controllers/_ui_element/icon_picker.coffee b/app/assets/javascripts/app/controllers/_ui_element/icon_picker.coffee new file mode 100644 index 000000000..a78b343de --- /dev/null +++ b/app/assets/javascripts/app/controllers/_ui_element/icon_picker.coffee @@ -0,0 +1,4 @@ +# coffeelint: disable=camel_case_classes +class App.UiElement.icon_picker extends App.UiElement.ApplicationUiElement + @render: (attribute, params) -> + new App.IconPicker(attribute: attribute).element() diff --git a/app/assets/javascripts/app/controllers/_ui_element/iconset_picker.coffee b/app/assets/javascripts/app/controllers/_ui_element/iconset_picker.coffee new file mode 100644 index 000000000..126e14cf8 --- /dev/null +++ b/app/assets/javascripts/app/controllers/_ui_element/iconset_picker.coffee @@ -0,0 +1,4 @@ +# coffeelint: disable=camel_case_classes +class App.UiElement.iconset_picker extends App.UiElement.ApplicationUiElement + @render: (attribute, params) -> + new App.IconsetPicker(attribute: attribute).element() diff --git a/app/assets/javascripts/app/controllers/_ui_element/multi_locales.coffee b/app/assets/javascripts/app/controllers/_ui_element/multi_locales.coffee new file mode 100644 index 000000000..96969625e --- /dev/null +++ b/app/assets/javascripts/app/controllers/_ui_element/multi_locales.coffee @@ -0,0 +1,34 @@ +# coffeelint: disable=camel_case_classes +class App.UiElement.multi_locales extends App.UiElement.ApplicationUiElement + @render: (attribute, params, form) -> + new App.MultiLocales(attribute: attribute, object: form?.parentController?.object()).el + + @prepareParams: (attribute, dom, params) -> + if typeof params[attribute.name] == 'string' + params[attribute.name] = [params[attribute.name]] + + if !Array.isArray params[attribute.name] + return + + primary_system_locale_id = dom.find("[name=#{attribute.name}_primary_locale_id]:checked").val() + + params["#{attribute.name}_attributes"] = params[attribute.name] + .filter (elem) -> elem + .map (system_locale_id) -> + data = { + system_locale_id: system_locale_id + primary: system_locale_id == primary_system_locale_id + } + + domRow = dom.find(".js-primary input[value=#{system_locale_id}]").closest('tr') + + if domRow.hasClass('settings-list--deleted') + data['_destroy'] = '1' + + if (kb_locale_id = domRow.data('kbLocaleId')) + data['id'] = parseInt(kb_locale_id) + + data + + delete params["#{attribute.name}"] + delete params["#{attribute.name}_primary_locale_id"] diff --git a/app/assets/javascripts/app/controllers/_ui_element/radio.coffee b/app/assets/javascripts/app/controllers/_ui_element/radio.coffee index 00c5ec5b6..c84f7424d 100644 --- a/app/assets/javascripts/app/controllers/_ui_element/radio.coffee +++ b/app/assets/javascripts/app/controllers/_ui_element/radio.coffee @@ -1,5 +1,7 @@ # coffeelint: disable=camel_case_classes class App.UiElement.radio extends App.UiElement.ApplicationUiElement + @template_name: 'radio' + @render: (attribute, params) -> # build options list based on config @@ -23,4 +25,4 @@ class App.UiElement.radio extends App.UiElement.ApplicationUiElement # filter attributes @filterOption(attribute, params) - $( App.view('generic/radio')( attribute: attribute ) ) + $( App.view("generic/#{@template_name}")( attribute: attribute ) ) diff --git a/app/assets/javascripts/app/controllers/_ui_element/radio_graphic.coffee b/app/assets/javascripts/app/controllers/_ui_element/radio_graphic.coffee new file mode 100644 index 000000000..00c8938cf --- /dev/null +++ b/app/assets/javascripts/app/controllers/_ui_element/radio_graphic.coffee @@ -0,0 +1,3 @@ +# coffeelint: disable=camel_case_classes +class App.UiElement.radio_graphic extends App.UiElement.radio + @template_name: 'radio_graphic' diff --git a/app/assets/javascripts/app/controllers/_ui_element/richtext.coffee b/app/assets/javascripts/app/controllers/_ui_element/richtext.coffee index 45ed32c8a..c3cd13d6d 100644 --- a/app/assets/javascripts/app/controllers/_ui_element/richtext.coffee +++ b/app/assets/javascripts/app/controllers/_ui_element/richtext.coffee @@ -1,12 +1,19 @@ # coffeelint: disable=camel_case_classes class App.UiElement.richtext - @render: (attribute, params) -> - item = $( App.view('generic/richtext')(attribute: attribute) ) - item.find('[contenteditable]').ce( + @render: (attribute, params, form) -> + if _.isObject(attribute.value) + attribute.attachments = attribute.value.attachments + attribute.value = attribute.value.text + + item = $( App.view('generic/richtext')(attribute: attribute, toolButtons: @toolButtons) ) + @contenteditable = item.find('[contenteditable]').ce( mode: attribute.type maxlength: attribute.maxlength + buttons: attribute.buttons ) + item.find('a.btn--action[data-action]').click (event) => @toolButtonClicked(event, form) + if attribute.plugins for plugin in attribute.plugins params = plugin.params || {} @@ -25,6 +32,10 @@ class App.UiElement.richtext for file in params.attachments renderFile(file) + if attribute.attachments + for file in attribute.attachments + renderFile(file) + # remove items item.find('.attachments').on('click', '.js-delete', (e) => id = $(e.currentTarget).data('id') @@ -35,10 +46,12 @@ class App.UiElement.richtext item ) + form_id = item.closest('form').find('[name=form_id]').val() + # delete attachment from storage App.Ajax.request( type: 'DELETE' - url: "#{App.Config.get('api_path')}/upload_caches/#{@form_id}/items/#{id}" + url: "#{App.Config.get('api_path')}/upload_caches/#{form_id}/items/#{id}" processData: false ) @@ -56,58 +69,101 @@ class App.UiElement.richtext @attachmentsHolder = item.find('.attachments') @cancelContainer = item.find('.js-cancel') - upload_initialize_callback = => - form_id = item.closest('form').find('[name=form_id]').val() - html5Upload.initialize( - uploadUrl: "#{App.Config.get('api_path')}/upload_caches/#{form_id}" - dropContainer: item.closest('form').get(0) - cancelContainer: @cancelContainer - inputField: item.find('input').get(0) - maxSimultaneousUploads: 1, - key: 'File' - onFileAdded: (file) => + u = => html5Upload.initialize( + uploadUrl: "#{App.Config.get('api_path')}/attachments" + dropContainer: item.closest('form').get(0) + cancelContainer: @cancelContainer + inputField: item.find('input').get(0) + maxSimultaneousUploads: 1, + key: 'File' + data: + form_id: item.closest('form').find('[name=form_id]').val() + onFileAdded: (file) => - file.on( - onStart: => - @attachmentPlaceholder.addClass('hide') - @attachmentUpload.removeClass('hide') - @cancelContainer.removeClass('hide') - item.find('[contenteditable]').trigger('fileUploadStart') - App.Log.debug 'UiElement.richtext', 'upload start' + file.on( + onStart: => + @attachmentPlaceholder.addClass('hide') + @attachmentUpload.removeClass('hide') + @cancelContainer.removeClass('hide') + item.find('[contenteditable]').trigger('fileUploadStart') + App.Log.debug 'UiElement.richtext', 'upload start' - onAborted: => - @attachmentPlaceholder.removeClass('hide') - @attachmentUpload.addClass('hide') - item.find('input').val('') - item.find('[contenteditable]').trigger('fileUploadStop', ['aborted']) + onAborted: => + @attachmentPlaceholder.removeClass('hide') + @attachmentUpload.addClass('hide') + item.find('input').val('') + item.find('[contenteditable]').trigger('fileUploadStop', ['aborted']) - # Called after received response from the server - onCompleted: (response) => - response = JSON.parse(response) + # Called after received response from the server + onCompleted: (response) => + response = JSON.parse(response) - @attachmentPlaceholder.removeClass('hide') - @attachmentUpload.addClass('hide') + @attachmentPlaceholder.removeClass('hide') + @attachmentUpload.addClass('hide') - # reset progress bar - @progressBar.width(parseInt(0) + '%') - @progressText.text('') + # reset progress bar + @progressBar.width(parseInt(0) + '%') + @progressText.text('') - renderFile(response.data) - item.find('input').val('') - item.find('[contenteditable]').trigger('fileUploadStop', ['completed']) - App.Log.debug 'UiElement.richtext', 'upload complete', response.data + renderFile(response.data) + item.find('input').val('') + item.find('[contenteditable]').trigger('fileUploadStop', ['completed']) + App.Log.debug 'UiElement.richtext', 'upload complete', response.data - # Called during upload progress, first parameter - # is decimal value from 0 to 100. - onProgress: (progress, fileSize, uploadedBytes) => - @progressBar.width(parseInt(progress) + '%') - @progressText.text(parseInt(progress)) - # hide cancel on 90% - if parseInt(progress) >= 90 - @cancelContainer.addClass('hide') - App.Log.debug 'UiElement.richtext', 'uploadProgress ', parseInt(progress) - ) - ) - App.Delay.set(upload_initialize_callback, 100, undefined, 'form_upload') + # Called during upload progress, first parameter + # is decimal value from 0 to 100. + onProgress: (progress, fileSize, uploadedBytes) => + @progressBar.width(parseInt(progress) + '%') + @progressText.text(parseInt(progress)) + # hide cancel on 90% + if parseInt(progress) >= 90 + @cancelContainer.addClass('hide') + App.Log.debug 'UiElement.richtext', 'uploadProgress ', parseInt(progress) + + ) + ) + App.Delay.set(u, 100, undefined, 'form_upload') item + + @toolButtonClicked: (event, form) -> + action = $(event.currentTarget).data('action') + @toolButtons[action]?.onClick(event, form) + + @toolButtons = {} + @additions = {} + + # 1 next, -1 previous + # jQuery's helper doesn't work because it doesn't include non-element nodes + @allDirectionalSiblings: (elem, direction, to = null) -> + if !elem? + return [] + + output = [] + next = elem + + while sibling = App.UiElement.richtext.directionalSibling(next, direction) + next = sibling + if to? and sibling is to + break + + output.push sibling + + output + + # 1 next, -1 previous + @directionalSibling: (elem, direction) -> + if direction > 0 + elem.nextSibling + else + elem.previousSibling + + @buildParentsList: (elem, container) -> + $(elem) + .parentsUntil(container) + .toArray() + + @buildParentsListWithSelf: (elem, container) -> + output = App.UiElement.richtext.buildParentsList(elem, container) + output.unshift(elem) + output diff --git a/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/_rich_text_tool_button.coffee b/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/_rich_text_tool_button.coffee new file mode 100644 index 000000000..4e3bb9bdc --- /dev/null +++ b/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/_rich_text_tool_button.coffee @@ -0,0 +1,139 @@ +class App.UiElement.richtext.additions.RichTextToolButton + @icon: undefined # 'chain' + @text: undefined # 'Weblink' + + @klass: -> + # Needs implementation. Return constructor of RichTextToolPopup subclass. + + @initializeAttributes: {} + + @instantiateContent: (event, selection, delegate) -> + attrs = @initializeAttributes + + attrs['event'] = event + attrs['selection'] = selection + attrs['container'] = $(event.currentTarget).closest('.content') + attrs['delegate'] = delegate + + klassConstructor = @klass() + instance = new klassConstructor(attrs) + instance.el + + @popoverAttributes: (event, selection, delegate) -> + content = @instantiateContent(event, selection, delegate) + hash = + trigger: 'manual' + backdrop: true + html: true + animation: false + delay: 0 + placement: 'auto right' + theme: 'dark' + content: content + container: 'body' + template: '' + + hash + + @pickLinkInSingleContainer: (elem, containerToLookUpTo) -> + if elem.nodeName == 'A' + elem + else if innerLink = $(elem).find('a')[0] + innerLink + else if containerToLookUpTo and closestLink = $(elem).closest('a', containerToLookUpTo)[0] + closestLink + else + null + + @pickLinkAt: (elem, container, direction, boundary = null) -> + for parent in App.UiElement.richtext.buildParentsListWithSelf(elem, container) + if parent.nodeName is 'A' + return parent + + for elem in App.UiElement.richtext.allDirectionalSiblings(parent, direction, boundary) + if link = @pickLinkInSingleContainer(elem) + return link + + null + + @pickLink: (sel, textEditor) -> + range = sel.getRangeAt(0) + + if range.startContainer == range.endContainer + return @pickLinkInSingleContainer(range.startContainer, textEditor) + + if link = @pickLinkAt(range.startContainer, range.commonAncestorContainer, 1, range.endContainer) + return link + + if startParent = App.UiElement.richtext.buildParentsList(range.startContainer, range.commonAncestorContainer).pop() + for elem in App.UiElement.richtext.allDirectionalSiblings(startParent, 1, range.endContainer) + if link = @pickLinkInSingleContainer(elem) + return link + + if link = @pickLinkAt(range.endContainer, range.commonAncestorContainer, -1) + return link + + return null + + # close other buttons' popovers + @closeOtherPopovers: (event) -> + $(event.currentTarget) + .closest('.richtext-controls') + .find('.btn') + .toArray() + .filter (elem) -> $(elem).attr('aria-describedby') + .forEach (elem) -> $(elem).popover('hide') + + # normalize selection to parse later + @selectionSnapshot: (sel) -> + textEditor = $(event.currentTarget).closest('.richtext.form-control').find('[contenteditable]') + + if sel.isCollapsed and selectedLink = $(sel.anchorNode).closest('a')[0] + { + type: 'existing' + dom: $(selectedLink) + } + else if !sel.isCollapsed and selectedLink = @pickLink(sel, textEditor) + { + type: 'existing' + dom: $(selectedLink) + } + else if sel.type is 'Range' and $(sel.anchorNode).closest('[contenteditable]', textEditor)[0] + range = sel.getRangeAt(0) + + { + type: 'range' + range: sel.getRangeAt(0) + } + else if $(sel.anchorNode).closest('[contenteditable]', textEditor)[0] and !$(sel.anchorNode).is('[contenteditable]') + { + type: 'caret' + dom: $(sel.anchorNode) + offset: sel.anchorOffset + } + else + { + type: 'append' + dom: textEditor + } + + @onClick: (event, delegate) -> + event.stopPropagation() + event.preventDefault() + + # close popover if already open and stop + if $(event.currentTarget).attr('aria-describedby') + $(event.currentTarget).popover('hide') + return + + @closeOtherPopovers(event) + + textEditor = $(event.currentTarget).closest('.richtext.form-control').find('[contenteditable]') + + sel = document.getSelection() + selectionSnapshot = @selectionSnapshot(sel) + sel.removeAllRanges() + + $(event.currentTarget) + .popover(@popoverAttributes(event, selectionSnapshot, delegate)) + .popover('show') diff --git a/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/_rich_text_tool_popup.coffee b/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/_rich_text_tool_popup.coffee new file mode 100644 index 000000000..c7fb0fe3c --- /dev/null +++ b/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/_rich_text_tool_popup.coffee @@ -0,0 +1,151 @@ +class App.UiElement.richtext.additions.RichTextToolPopup extends App.ControllerForm + events: + 'submit form': 'onSubmit' + 'click .js-unlink': 'onUnlink' + + formParams: (params) -> + # needs implementation + + constructor: (params) -> + if params.selection.type is 'existing' + url = params.selection.dom.attr('href') + label = 'Update' + additional = [{ + className: 'btn btn--danger js-unlink' + text: 'Remove' + }] + else + label = 'Link' + + defaultParams = + params: @formParams(params) + fullForm: true + formClass: 'form--horizontal' + fullFormSubmitLabel: label + fullFormSubmitAdditionalClasses: 'btn--create' + fullFormAdditionalButtons: additional + autofocus: true + model: + configure_attributes: [] + + fullParams = $.extend(true, {}, defaultParams, params) + + super(fullParams) + + @didInitialize() + + $(@event.currentTarget).on('hidden.bs.popover', (e) => @willClose(e)) + + getAjaxAttributes: (field, attributes) -> + @delegate?.getAjaxAttributes?(field, attributes) + + onUnlink: (e) -> + e.preventDefault() + e.stopPropagation() + + switch @selection.type + when 'existing' + $(@selection.dom).contents().unwrap() + + $(@event.currentTarget).popover('hide') + + @wrapElement: (wrapper, selection) -> + topLevelOriginals = App.UiElement.richtext.buildParentsList(selection.range.startContainer, selection.range.commonAncestorContainer).reverse() + + if topLevelOriginalStart = topLevelOriginals.shift() + clonedStart = topLevelOriginalStart.cloneNode(false) + nextParent = clonedStart + + for orig in topLevelOriginals + clone = orig.cloneNode(false) + nextParent.append(clone) + + for elem in App.UiElement.richtext.allDirectionalSiblings(orig, 1) + nextParent.append(elem.cloneNode(true)) + + nextParent = clone + + startClone = selection.range.startContainer.cloneNode(true) + remaining = startClone.splitText(selection.range.startOffset) + nextParent.append(remaining) + + wrapper.append(clonedStart) + + for elem in App.UiElement.richtext.allDirectionalSiblings(selection.range.startContainer, 1) + nextParent.append(elem.cloneNode(true)) + else + topLevelOriginalStart = selection.range.startContainer + startClone = selection.range.startContainer.cloneNode(true) + remaining = startClone.splitText(selection.range.startOffset) + wrapper.append(remaining) + + for elem in App.UiElement.richtext.allDirectionalSiblings(topLevelOriginalStart, 1, selection.range.endContainer) + wrapper.append(elem.cloneNode(true)) + + topLevelOriginals = App.UiElement.richtext.buildParentsList(selection.range.endContainer, selection.range.commonAncestorContainer).reverse() + + if topLevelOriginalEnd = topLevelOriginals.shift() + clonedEnd = topLevelOriginalEnd.cloneNode(false) + nextParent = clonedEnd + + for orig in topLevelOriginals + clone = orig.cloneNode(false) + nextParent.append(clone) + + for elem in App.UiElement.richtext.allDirectionalSiblings(orig, -1) + nextParent.prepend(elem.cloneNode(true)) + + nextParent = clone + + endClone = selection.range.endContainer.cloneNode(true) + endClone.splitText(selection.range.endOffset) + nextParent.append(endClone) + + wrapper.append(clonedEnd) + else + endClone = selection.range.endContainer.cloneNode(true) + endClone.splitText(selection.range.endOffset) + wrapper.append(endClone) + + document.getSelection().removeAllRanges() + document.getSelection().addRange(selection.range) + document.getSelection().deleteFromDocument() + document.getSelection().removeAllRanges() + + wrapper.insertAfter(topLevelOriginalStart) + + wrapLink: -> + # needs implementation + + onSubmit: (e) -> + e.preventDefault() + + @wrapLink() + + $(@event.currentTarget).popover('destroy') + + didInitialize: -> + switch @selection.type + when 'existing' + @selection.dom.addClass('highlight-emulator') + when 'range' + span = $('').addClass('highlight-emulator') + + if @selection.range.startContainer == @selection.range.endContainer + @selection.range.startContainer.splitText(@selection.range.endOffset) + visibleText = @selection.range.startContainer.splitText(@selection.range.startOffset) + + $(visibleText).wrap(span) + else + @constructor.wrapElement(span, @selection) + + willClose: (e) -> + switch @selection.type + when 'existing' + @selection.dom.removeClass('highlight-emulator') + when 'range' + textEditor = $(@event.currentTarget).closest('.richtext.form-control').find('[contenteditable]') + textEditor.find('span.highlight-emulator').contents().unwrap() + + $(@event.currentTarget).off('hidden.bs.popover') + $(e.currentTarget).popover('destroy') diff --git a/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/link_answer_button.coffee b/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/link_answer_button.coffee new file mode 100644 index 000000000..9cd0b4b1a --- /dev/null +++ b/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/link_answer_button.coffee @@ -0,0 +1,15 @@ +# coffeelint: disable=camel_case_classes +class App.UiElement.richtext.toolButtons.link_answer extends App.UiElement.richtext.additions.RichTextToolButton + @icon: 'knowledge-base-answer' + @text: 'Link Answer' + @klass: -> App.UiElement.richtext.additions.RichTextToolPopupAnswer + @initializeAttributes: + model: + configure_attributes: [ + { + name: 'link' + display: 'Answer' + relation: 'KnowledgeBaseAnswerTranslation' + tag: 'autocompletion_ajax' + } + ] diff --git a/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/link_button.coffee b/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/link_button.coffee new file mode 100644 index 000000000..283ca3e5a --- /dev/null +++ b/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/link_button.coffee @@ -0,0 +1,15 @@ +# coffeelint: disable=camel_case_classes +class App.UiElement.richtext.toolButtons.link extends App.UiElement.richtext.additions.RichTextToolButton + @icon: 'chain' + @text: 'Weblink' + @klass: -> App.UiElement.richtext.additions.RichTextToolPopupLink + @initializeAttributes: + model: + configure_attributes: [ + { + name: 'link' + display: 'Link' + tag: 'input' + placeholder: 'http://' + } + ] diff --git a/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/popup_answer.coffee b/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/popup_answer.coffee new file mode 100644 index 000000000..776d4096b --- /dev/null +++ b/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/popup_answer.coffee @@ -0,0 +1,43 @@ +class App.UiElement.richtext.additions.RichTextToolPopupAnswer extends App.UiElement.richtext.additions.RichTextToolPopup + formParams: (params) -> + # coffeelint: disable=indentation + url = if params.selection.type is 'existing' && params.selection.dom.attr('data-target-type') is 'knowledge-base-answer' + params.selection.dom.attr('data-target-id') + # coffeelint: enable=indentation + + link: url + + applyOnto: (dom, object, text = null) -> + dom + .attr('href', object.uiUrl('edit')) + .attr('data-target-id', object.id) + .attr('data-target-type', 'knowledge-base-answer') + + if text? + dom.text(text) + + dom + + wrapLink: -> + id = @el.find('input').val() + object = App.KnowledgeBaseAnswerTranslation.find(id) + textEditor = $(@event.currentTarget).closest('.richtext.form-control').find('[contenteditable]') + + switch @selection.type + when 'existing' + @applyOnto(@selection.dom, object) + when 'append' + newElem = $('') + @applyOnto(newElem, object, object.title) + @selection.dom.append(newElem) + when 'caret' + newElem = $('') + @applyOnto(newElem, object, object.title) + @selection.dom[0].splitText(@selection.offset) + newElem.insertAfter(@selection.dom) + when 'range' + placeholder = textEditor.find('span.highlight-emulator') + newElem = $('') + @applyOnto(newElem, object) + placeholder.wrap(newElem) + placeholder.contents() diff --git a/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/popup_link.coffee b/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/popup_link.coffee new file mode 100644 index 000000000..0b6a59d2d --- /dev/null +++ b/app/assets/javascripts/app/controllers/_ui_element/richtext_additions/popup_link.coffee @@ -0,0 +1,52 @@ +class App.UiElement.richtext.additions.RichTextToolPopupLink extends App.UiElement.richtext.additions.RichTextToolPopup + formParams: (params) -> + # coffeelint: disable=indentation + url = if params.selection.type is 'existing' && !params.selection.dom.attr('data-target-type')? + params.selection.dom.attr('href') + # coffeelint: enable=indentation + + link: url + + applyOnto: (dom, url, text = null) -> + dom + .attr('href', url) + .removeAttr('data-target-id') + .removeAttr('data-target-type') + + if text? + dom.text(text) + + dom + + ensureProtocol: (input) -> + input = input.trim() + + if !input.match(/^\S+\:\/\//) and input[0] isnt '/' + 'http://' + input + else + input + + wrapLink: -> + input = @el.find('input').val() + url = @ensureProtocol(input) + + textEditor = $(@event.currentTarget).closest('.richtext.form-control').find('[contenteditable]') + + switch @selection.type + when 'existing' + @applyOnto(@selection.dom, url) + when 'append' + newElem = $('') + @applyOnto(newElem, url, input) + @selection.dom.append(newElem) + when 'caret' + newElem = $('') + @applyOnto(newElem, url, input) + @selection.dom[0].splitText?(@selection.offset) + newElem.insertAfter(@selection.dom) + when 'range' + placeholder = textEditor.find('span.highlight-emulator') + newElem = $('') + @applyOnto(newElem, url) + placeholder.wrap(newElem) + placeholder.contents() diff --git a/app/assets/javascripts/app/controllers/_ui_element/select.coffee b/app/assets/javascripts/app/controllers/_ui_element/select.coffee index b72158e59..9352e8885 100644 --- a/app/assets/javascripts/app/controllers/_ui_element/select.coffee +++ b/app/assets/javascripts/app/controllers/_ui_element/select.coffee @@ -51,6 +51,16 @@ class App.UiElement.select extends App.UiElement.ApplicationUiElement return if value of attribute.options return if value in (temp for own prop, temp of attribute.options) + if _.isArray(attribute.options) + # Array of Strings (value) + return if value of attribute.options + + # Array of Objects (for ordering purposes) + return if attribute.options.filter((elem) -> elem.value == value) isnt null + else + # regular Object + return if value in (temp for own prop, temp of attribute.options) + if attribute.historical_options && value of attribute.historical_options attribute.options[value] = attribute.historical_options[value] else diff --git a/app/assets/javascripts/app/controllers/knowledge_base/add_form.coffee b/app/assets/javascripts/app/controllers/knowledge_base/add_form.coffee new file mode 100644 index 000000000..3cb5bdc7c --- /dev/null +++ b/app/assets/javascripts/app/controllers/knowledge_base/add_form.coffee @@ -0,0 +1,33 @@ +class App.KnowledgeBaseAddForm extends App.ControllerModal + constructor: (params) -> + for key, value of params + @[key] = value + + @head = @object.objectActionName() + super(params) + + buttonSubmit: 'Create' + + content: -> + kb_locale = @parentController.kb_locale() + @formController = new App.KnowledgeBaseFormController(@object, kb_locale, 'agent_create', $('
')) + @form = @formController.form # used for disabling inputs during saving + @formController.el + + submit: (e) -> + @preventDefaultAndStopPropagation(e) + + if !@formController.validateAndShowErrors() + return + + params = @formController.paramsForSaving() + params.translations_attributes[0].content_attributes = { body: '' } + + @parentController.coordinator.saveChanges(@object, params, @) + + showAlert: (text) -> + @formController?.showAlert(text) + + didSaveCallback: (data) -> + url = @object.constructor.find(data.id).uiUrl(@parentController.kb_locale(), 'edit') + @parentController.navigate(url) diff --git a/app/assets/javascripts/app/controllers/knowledge_base/agent_controller.coffee b/app/assets/javascripts/app/controllers/knowledge_base/agent_controller.coffee new file mode 100644 index 000000000..f42538f82 --- /dev/null +++ b/app/assets/javascripts/app/controllers/knowledge_base/agent_controller.coffee @@ -0,0 +1,385 @@ +class App.KnowledgeBaseAgentController extends App.Controller + className: 'knowledge-base vertical' + name: 'Knowledge Base' + + elements: + '.js-body': 'body' + '.js-navigation': 'navigation' + '.js-sidebar': 'sidebar' + + constructor: (params) -> + super + @bind 'config_update_local', (data) => @configUpdated(data) + + if @permissionCheck('knowledge_base.*') and App.Config.get('kb_active') + @updateNavMenu() + else if App.Config.get('kb_active_publicly') + @loadInitial( + {}, + success: (data, status, xhr) => + @updateNavMenu() + ) + + configUpdated: (data) -> + if data.name isnt 'kb_active' and data.name isnt 'kb_active_publicly' + return + + @updateNavMenu() + + firstRunIfNeeded: -> + if @firstRunDone + return + + @firstRunDone = true + + @coordinator = new App.KnowledgeBaseEditorCoordinator(parentController: @) + + @fetchAndRender() + + @bind('ui:rerender', + => + @render(true) + @contentController?.url = null + @lastParams.selectedSystemLocale = App.KnowledgeBaseLocale.detect(@getKnowledgeBase()).systemLocale() + @show(@lastParams) + ) + + @bind 'kb_data_changed', (pushed_data) => + key = "kb_pull_#{pushed_data.class}_#{pushed_data.id}" + + App.Delay.set( => + @loadChange(pushed_data) + , 1000, key, 'kb_data_changed_loading') + + @listenTo App.KnowledgeBase, 'kb_data_change_loaded', => + return if !@displayingError + + object = @constructor.pickObjectUsing(@lastParams, @) + + if !@objectVisibleInternally(object) + return + + @renderControllers(@lastParams) + + @checkForUpdates() + + loadChange: (pushed_data) => + url = pushed_data.url + '?full=true' + + if pushed_data.class is 'KnowledgeBase::Answer' + object = App.KnowledgeBaseAnswer.find pushed_data.id + + # coffeelint: disable=indentation + loaded_ids = object + ?.translations() + .map (elem) -> elem.content()?.id + .filter (elem) -> elem isnt undefined + # coffeelint: enable=indentation + + if loaded_ids and loaded_ids.length isnt 0 + url += '&include_contents=' + loaded_ids.join(',') + + @ajax( + id: "kb_pull_#{pushed_data.class}_#{pushed_data.id}" + type: 'GET' + url: url + processData: true + success: (data, status, xhr) => + App.Collection.loadAssets(data.assets) + + @notifyChangeLoaded() + error: (xhr) => + if xhr.status != 404 + return + + klassName = pushed_data.class.replace(/::/g,'') + + if object = App[klassName]?.find(pushed_data.id) + object.remove(clear: true) + @notifyChangeLoaded() + ) + + objectVisibleInternally: (object) -> + if !object + return false + else if object instanceof App.KnowledgeBaseAnswer and !object.exists() + return false + else if object instanceof App.KnowledgeBaseCategory and !object.visibleInternally(@kb_locale()) + return false + + true + + notifyChangeLoaded: -> + App.KnowledgeBase.trigger('kb_data_change_loaded') + + active: (state) -> + return @shown if state is undefined + @shown = state + + featureActive: -> + (@permissionCheck('knowledge_base.*') and App.Config.get('kb_active')) or (App.Config.get('kb_active_publicly') and App.KnowledgeBase.first()?) + + activeLocaleSuffix: -> + @kb_locale().urlSuffix() + + requiredPermissionSuffix: (params) -> + if params.action is 'edit' + 'editor' + else + '*' + + show: (params) => + @firstRunIfNeeded() + @navupdate '#knowledge_base' + + @bodyModal?.close() + @bodyModal = null + + if !@permissionCheckRedirect("knowledge_base.#{@requiredPermissionSuffix(params)}") + return + + if @loaded && @rendered && @lastParams && !params.knowledge_base_id && @contentController && @kb_locale()? + @navigate @lastParams.match[0] , true + return + + if @contentController && @contentController.url is params.match[0] + @title @lastTitle + @contentController.restoreVisibility?() + return + + @rendered = true + + @lastParams = params + + if @loaded and params.selectedSystemLocale is null and params.selectedSystemLocalePresent + @renderError() + return + + @displayingError = false + + if @loaded + if params.knowledge_base_id + @renderControllers(params) + else + if (kb = App.KnowledgeBase.all()[0]) + @navigate kb.uiUrl(App.KnowledgeBaseLocale.detect(kb)), true + else + @renderScreenErrorInContent('No Knowledge Base created') + else + @pendingParams = params + + renderScreenErrorInContent: (text) -> + @contentController = undefined + @renderScreenError(detail: text, el: @$('.page-content')) + @displayingError = true + + renderControllers: (params) -> + object = @constructor.pickObjectUsing(params, @) + + if !object || (!@isEditor() && !object.visibleInternally(@kb_locale())) + @renderNotFound() + return + + titleSuffix = if !(object instanceof App.KnowledgeBase) + object.guaranteedTitle(@kb_locale().id) + else if params.action is 'search' + App.i18n.translateInline('Search') + else + '' + + @updateTitle(titleSuffix) + + klass = @contentControllerClass(params) + @contentController = @buildUsing(klass, params, object) + @navigationController?.show(object, params.action) + @sidebarController?.show(object, params.action) + + updateTitle: (titleSuffix) -> + newTitle = @getKnowledgeBase()?.guaranteedTitle(@kb_locale()?.id) || '' + + if titleSuffix != '' + if newTitle + newTitle += ' - ' + + newTitle += titleSuffix + + @title newTitle + @lastTitle = newTitle + + contentControllerClass: (params) -> + if params.action is 'search' + return App.KnowledgeBaseSearchController + + if params.action is 'edit' + return App.KnowledgeBaseContentController + + if params.answer_id + App.KnowledgeBaseReaderController + else + App.KnowledgeBaseReaderListController + + edit: false + + renderNotFound: -> + title = App.i18n.translateInline('Not Found') + @updateTitle(title) + @navigationController?.show(undefined, title) + @renderScreenErrorInContent('The page was not found') + @sidebarController?.hide() + + renderNotAvailableAnymore: -> + @updateTitle(App.i18n.translateInline('Not Available')) + @renderScreenErrorInContent('The page is not available anymore') + + renderError: -> + @bodyModal?.close() + + url = App.Utils.joinUrlComponents @lastParams.effectivePath, @getKnowledgeBase().primaryKbLocale().urlSuffix() + + @bodyModal = new App.ControllerModal( + head: 'Locale not found' + contentInline: "Open in primary locale" + buttonClose: false + buttonSubmit: false + backdrop: 'static' + keyboard: false + container: @el + ) + + kb_locale: -> + kb = @getKnowledgeBase() + return if !kb + + if @lastParams.selectedSystemLocale + kb.kb_locales().filter((elem) => elem.system_locale_id == @lastParams.selectedSystemLocale.id)[0] + + getKnowledgeBase: -> + App.KnowledgeBase.find(@lastParams.knowledge_base_id) + + fetchAndRender: => + @fetch(true, true) + + fetch: (showLoader, processLoaded) -> + if showLoader + @startLoading() + + loaded_content_ids = App.KnowledgeBaseAnswerTranslationContent.all().map (elem) -> elem.id + + params = { + answer_translation_content_ids: loaded_content_ids + } + + @loadInitial( + params, + success: (data, status, xhr) => + if showLoader + @stopLoading() + + if processLoaded + @processLoaded() + , + error: (xhr) => + if showLoader + @stopLoading() + ) + + loadInitial: (params, options = {}) => + @ajax( + id: 'knowledge_bases_init' + type: 'POST' + url: @apiPath + '/knowledge_bases/init' + data: JSON.stringify(params) + processData: true + success: (data, status, xhr) => + @loaded = true + @loadKbData(data) + + options.success?(data, status, xhr) + error: (xhr) -> + options.error?(xhr) + ) + + loadKbData: (data) -> + App.Collection.loadAssets(data) + + for elem in @calculateIdsToDelete(data) + for id in elem.ids + App[elem.modelName].find(id)?.remove(clear: true) + + calculateIdsToDelete: (data) -> + Object + .keys(data) + .filter (elem) -> elem.match(/^KnowledgeBase/) + .map (model) -> + newIds = Object.keys data[model] + oldIds = App[model].all().map (elem) -> elem.id + diff = oldIds.filter (elem) -> !newIds.includes(String(elem)) + + {modelName: model, ids: diff} + , {} + + processLoaded: -> + @render(true) + + if @pendingParams + @show(@pendingParams) + @pendingParams = undefined + + render: (force = false) => + @html App.view('knowledge_base/agent')() + + @navigationController = new App.KnowledgeBaseNavigation( + el: @$('.js-navigation') + parentController: @ + ) + + @sidebarController = new App.KnowledgeBaseSidebar( + el: @$('.js-sidebar') + parentController: @ + ) + + isEditor: -> + App.User.current().permission('knowledge_base.editor') + + checkForUpdates: -> + @interval(@checkUpdatesAction, 10 * 60 * 1000, 'kb_interval_check') + + checkUpdatesAction: => + if !@loaded + return + + @fetch(false, false) + + buildUsing: (klass, params, object) -> + new klass( + el: @$('.page-content') + object: object + parentController: @ + selectedSystemLocale: params.selectedSystemLocale + url: params.match[0] + ) + + onclick: -> + !(@permissionCheck('knowledge_base.*') and App.Config.get('kb_active')) and (App.Config.get('kb_active_publicly') and App.KnowledgeBase.first()?) + + accessoryIcon: -> + return if !@onclick() + + 'external' + + clicked: -> + window.open(App.KnowledgeBase.first().publicBaseUrl(), '_blank') + + @pickObjectUsing: (params, parentController) -> + kb = parentController.getKnowledgeBase() + return if !kb + + if answer_id = params['answer_id'] + App.KnowledgeBaseAnswer.find(answer_id) + else if category_id = params['category_id'] + App.KnowledgeBaseCategory.find(category_id) + else if knowledge_base_id = params['knowledge_base_id'] + kb + +App.Config.set('KnowledgeBase', { controller: 'KnowledgeBaseAgentController' }, 'permanentTask') +App.Config.set('KnowledgeBase', { prio: 1150, parent: '', name: 'Knowledge Base', target: '#knowledge_base', key: 'KnowledgeBase', class: 'knowledge-base', shown: false}, 'NavBar') diff --git a/app/assets/javascripts/app/controllers/knowledge_base/content_can_be_published_dialog.coffee b/app/assets/javascripts/app/controllers/knowledge_base/content_can_be_published_dialog.coffee new file mode 100644 index 000000000..46ae121cb --- /dev/null +++ b/app/assets/javascripts/app/controllers/knowledge_base/content_can_be_published_dialog.coffee @@ -0,0 +1,68 @@ +class App.KnowledgeBaseContentCanBePublishedDialog extends App.ControllerModal + events: + 'click .scheduled-widget-delete': 'clickedCancelTimer' + 'submit form': 'submitTiming' + + head: 'Visibility' + includeForm: false + buttonSubmit: false + + constructor: (params) -> + super + + content: => + @formController = new App.KnowledgeBaseContentCanBePublishedForm( + object: @object + ) + + @formController.form + + saveUpdate: (params, successCallback = null) => + @clearAlerts() + @formController.toggleDisabled(true) + + @ajax( + id: 'knowledge_base_can_be_published' + type: 'POST' + data: JSON.stringify(params) + url: @object.generateURL('has_publishing_update') + processData: true + success: (data, status, xhr) => + App.Collection.load(type: 'KnowledgeBaseAnswer', data: [data]) + successCallback?() + @formController.toggleDisabled(false) + error: (xhr) => + @formController.toggleDisabled(false) + @showAlert(xhr.responseJSON?.error || 'Unable to save changes') + ) + + clickedCancelTimer: (e) -> + widget = $(e.currentTarget).closest('.scheduled-widget') + state = widget.data('state') + params = { "#{state}_at": null } + + @saveUpdate params, -> + widget.remove() + + submitTiming: (e) => + @preventDefaultAndStopPropagation(e) + + data = @formParams() + + params = + "#{data.visibility}_at": if data.timing is 'scheduled' then data.scheduled else '--now--' + + newVisibilityIndex = @formController.states.indexOf(data.visibility) + oldVisibilityIndex = @formController.states.indexOf(@formController.params.visibility) + + if newVisibilityIndex < oldVisibilityIndex + for index in [(newVisibilityIndex+1)..oldVisibilityIndex] + params["#{@formController.states[index]}_at"] = null + + @saveUpdate params, => + if data.timing is 'now' + @close() + return + + @update() + @initalFormParams = @formParams() diff --git a/app/assets/javascripts/app/controllers/knowledge_base/content_can_be_published_form.coffee b/app/assets/javascripts/app/controllers/knowledge_base/content_can_be_published_form.coffee new file mode 100644 index 000000000..ac20e4d11 --- /dev/null +++ b/app/assets/javascripts/app/controllers/knowledge_base/content_can_be_published_form.coffee @@ -0,0 +1,133 @@ +class App.KnowledgeBaseContentCanBePublishedForm extends App.ControllerForm + elements: + '.js-datepicker': 'datePicker' + '[name=visibility]': 'visibilityRadios' + '[value=now]': 'timingNow' + '[value=scheduled]': 'timingScheduled' + + constructor: (params) -> + @prepare(params) + super + @postRendering() + @visibilityRadios.trigger('change') + + prepare: (params) -> + @handlers = [@timingHandler, @visibilityHandler, @scheduledHandler] + + @params = + visibility: params.object.can_be_published_state() + + scheduledHandler: (params, attribute, attributes, classname, form, ui) => + if attribute.name isnt 'scheduled' + return + + if !params.scheduled + return + + @timingScheduled.prop('checked', true) + + visibilityHandler: (params, attribute, attributes, classname, form, ui) => + if attribute.name isnt 'visibility' + return + + @toggleDisabled(false) + + scheduledWidget = @form.find(".scheduled-widget[data-state=#{params.visibility}]") + + if scheduledWidget.length > 0 and !@form.find('.controls--datetime input[data-item=date]').val() + date = scheduledWidget.data('date') + @datePicker.datepicker('setDate', date) + else + @datePicker.datepicker('clearDates') + @timingNow.prop('checked', true) + + timingHandler: (params, attribute, attributes, classname, form, ui) => + if attribute.name isnt 'timing' + return + + if params.timing isnt 'now' + return + + if !params.scheduled + return + + @datePicker.datepicker('clearDates') + + postRendering: => + # simulate elements + for key, value of @elements + @[value] = @form.find(key) + + # move date picker to inside of timing radio + @timingScheduled.parent().addClass('additional-radio-controls').append(@form.find('[data-name="scheduled"]')) + @form.find('[data-attribute-name="scheduled"]').remove() + @datePicker.datepicker('setStartDate', new Date()) + + # add scheduled tiemr widgets + now = new Date() + + for state in @states + if @object["#{state}_at"] && new Date(@object["#{state}_at"]) > now + label = @form.find("input[value=#{state}]").closest('label') + timer = new App.KnowledgeBaseScheduledWidget(object: @object, state: state) + label.after timer.el + + toggleDisabled: (state) -> + selectedState = @visibilityRadios.filter(':checked').val() + timingDisabled = @params.visibility is selectedState + isRollback = @states.indexOf(@params.visibility) > @states.indexOf(selectedState) + + @form.find('[value=now], [type=submit]') + .attr('disabled', state or timingDisabled) + + @form.find('[value=scheduled], .controls--datetime input') + .attr('disabled', state or timingDisabled or isRollback) + + @visibilityRadios.attr('disabled', state) + + fullForm: true + fullFormSubmitLabel: 'Update' + fullFormSubmitAdditionalClasses: 'btn--primary' + states: ['draft', 'internal', 'published', 'archived'] + + model: + configure_attributes: [ + name: 'visibility' + display: 'Visibility' + tag: 'radio' + default: false + options: [ + value: 'draft' + name: 'Draft' + note: 'Only visible to editors' + , + value: 'internal' + name: 'Internal' + note: 'Only visible to agents & editors' + , + value: 'published' + name: 'Public' + note: 'Visible to everyone' + , + value: 'archived' + name: 'Archived' + ] + , + name: 'timing' + display: 'Timing' + tag: 'radio' + default: 'now' + options: [ + value: 'now' + name: 'Now' + , + value: 'scheduled' + name: 'Schedule for' + ] + , + name: 'scheduled' + display: 'Date' + tag: 'datetime' + class: 'form-control--small' + null: true + ] diff --git a/app/assets/javascripts/app/controllers/knowledge_base/content_controller.coffee b/app/assets/javascripts/app/controllers/knowledge_base/content_controller.coffee new file mode 100644 index 000000000..9977e77ae --- /dev/null +++ b/app/assets/javascripts/app/controllers/knowledge_base/content_controller.coffee @@ -0,0 +1,161 @@ +class App.KnowledgeBaseContentController extends App.Controller + elements: + '.js-form': 'form' + '.js-discard': 'discardButton' + '.js-submitContainer': 'submitContainer' + + events: + 'click .js-submit': 'submit' + 'click .js-discard': 'discardChanges' + 'submit .js-form': 'submit' + 'input .js-form': 'showDiscardButton' + 'click .js-submit-action': 'submit' + + constructor: -> + super + + translation = @object.translation(@parentController.kb_locale().id) + + if translation and !translation.fullyLoaded() + @html App.view('knowledge_base/content')(@) + @startLoading() + + translation.loadFull (isSuccess) => + @stopLoading() + + if !isSuccess + return + + @initialize() + + return + + @initialize() + + initialize: -> + @render() + + @listenTo App.KnowledgeBase, 'kb_data_change_loaded', => + @objectRefreshed() + true + + # update availability display whenever object is touched + @listenTo @object, 'refresh', => + @renderAvailabilityWidgets() + + render: -> + @html App.view('knowledge_base/content')(@) + @renderAvailabilityWidgets() + + @formController = @buildFormController(@form) + @startingParams = App.ControllerForm.params(@formController.el) + + buildFormController: (dom = undefined) -> + new App.KnowledgeBaseFormController(@object, @parentController.kb_locale(), 'agent_edit', dom) + + remoteDidntChangeSinceStart: -> + remoteParams = @buildFormController().rawParams() + App.KnowledgeBaseFormController.compareParams(remoteParams, @startingParams) + + objectRefreshed: -> + @renderAvailabilityWidgets() + + if @remoteDidntChangeSinceStart() + @pendingRerender = false + return + + if !@parentController.shown + @pendingRerender = true + return + + @rerenderIfConfirmed() + + rerenderIfConfirmed: -> + text = App.i18n.translatePlain('Changes were made. Do you want to reload? You\'ll loose your changes') + if confirm(text) + @render() + + renderAvailabilityWidgets: -> + if !@object.constructor.canBePublished?() + return + + new App.WidgetButtonWithDropdown( + el: @submitContainer + mainActionLabel: 'Update' + actions: @quickActions() + ) + + html = App.view('knowledge_base/content_can_be_published_header_suffix')(object: @object) + @el.find('.js-published-header-suffix').replaceWith(html) + + submit: (e) -> + @preventDefaultAndStopPropagation(e) + + if !@formController.validateAndShowErrors() + return + + paramsForSaving = @formController.paramsForSaving() + + additional_action = $(e.currentTarget).data('id') + + if @remoteDidntChangeSinceStart() + @parentController.coordinator.saveChanges(@object, paramsForSaving, @, additional_action) + return + + new App.ControllerConfirm( + head: 'Content was changed since loading' + message: 'Your changes may override someone else\'s changes. Are you sure to save?' + callback: => + @parentController.coordinator.saveChanges(@object, paramsForSaving, @) + ) + + missingTranslation: -> + @object.translation(@parentController.kb_locale().id) is undefined && !@object.isNew() + + showDiscardButton: -> + @delay => + noChanges = App.KnowledgeBaseFormController.compareParams(@formController.rawParams(), @startingParams) + @discardButton.toggleClass('hide', noChanges) + , 500, 'check_unsaved_changes' + + quickActions: -> + prefix = App.i18n.translatePlain('Update') + ' & ' + actions = @object.can_be_published_quick_actions() + + [ + { + id: 'internal' + name: prefix + App.i18n.translatePlain('Internal') + disabled: !_.includes(actions, 'internal') + },{ + id: 'publish' + name: prefix + App.i18n.translatePlain('Publish') + disabled: !_.includes(actions, 'publish') + },{ + id: 'archive' + name: prefix + App.i18n.translatePlain('Archive') + disabled: !_.includes(actions, 'archive') + } + ] + + discardChanges: -> + @render() + + showAlert: (text) -> + @formController?.showAlert(text) + + didSaveCallback: (data) -> + @render() + + App.Event.trigger 'knowledge_base::sidebar::rerender' + App.Event.trigger 'knowledge_base::navigation::rerender' + + # this method is called when user comes back to already instantiated view + restoreVisibility: -> + if !@pendingRerender + return + + @pendingRerender = false + + # add delay to give it time to rerender before showing prompt + App.Delay.set => @rerenderIfConfirmed() diff --git a/app/assets/javascripts/app/controllers/knowledge_base/delete_action.coffee b/app/assets/javascripts/app/controllers/knowledge_base/delete_action.coffee new file mode 100644 index 000000000..417bc6211 --- /dev/null +++ b/app/assets/javascripts/app/controllers/knowledge_base/delete_action.coffee @@ -0,0 +1,71 @@ +class App.KnowledgeBaseDeleteAction + constructor: (params) -> + for key, value of params + @[key] = value + + if @object instanceof App.KnowledgeBaseCategory and !@object.isEmpty() + @showCannotDelete( + 'Cannot delete category', + 'Please delete all children categories and answers first.' + ) + + return + + @showConfirm() + + showConfirm: -> + kb_locale = @parentController.kb_locale() + translation = @object.guaranteedTranslation(kb_locale.id) + + @dialog = new App.ControllerConfirm( + head: 'Delete' + message: "Are you sure to delete \"#{translation?.title}\"?" + callback: @doDelete + container: @parentController.el + onSubmit: -> + @formDisable(@el) + @callback(@) + @dialog = null + ) + + showCannotDelete: (title, message) -> + modal = new App.ControllerModal( + head: title + contentInline: message + container: @parentController.el + buttonClose: true + buttonSubmit: 'Ok' + onSubmit: (e) => + modal.close() + @dialog = null + ) + + @dialog = modal + + doDelete: (modal) => + App.Ajax.request( + type: 'DELETE' + url: @object.generateURL() + '?full=true' + success: => + @deleteOk(modal) + error: (xhr) => + @deleteFailure(modal, xhr) + ) + + deleteOk: (modal) => + futureObject = @object.parent?() || @object.category?() || @object.knowledge_base() + + @parentController.contentController.stopListening() + @object.removeIncludingTranslations(clear: true) + + modal.close() + + @parentController.navigate futureObject.uiUrl(@parentController.kb_locale(), 'edit') + + deleteFailure: (modal, xhr) -> + modal.formEnable(modal.el) + modal.showAlert xhr.responseJSON?.error || 'Unable to delete.' + + # simulate modal's close function + close: -> + @dialog?.close() diff --git a/app/assets/javascripts/app/controllers/knowledge_base/editor_coordinator.coffee b/app/assets/javascripts/app/controllers/knowledge_base/editor_coordinator.coffee new file mode 100644 index 000000000..27299274f --- /dev/null +++ b/app/assets/javascripts/app/controllers/knowledge_base/editor_coordinator.coffee @@ -0,0 +1,44 @@ +class App.KnowledgeBaseEditorCoordinator + constructor: (params) -> + for key, value of params + @[key] = value + + clickedCanBePublished: (object) -> + new App.KnowledgeBaseContentCanBePublishedDialog( + object: object + container: @parentController.el + ) + + clickedDelete: (object) -> + new App.KnowledgeBaseDeleteAction( + object: object + parentController: @parentController + ) + + # built-in Spine's function doesn't work when object has no ID set and includes "undefined" in URL + urlFor: (object) -> + if object.id + object.generateURL() + else + object.url() + + saveChanges: (object, data, formController, action) -> + App.ControllerForm.disable(formController.form) + + url = @urlFor(object) + '?full=true' + + if action + url += "&additional_action=#{action}" + + App.Ajax.request( + type: object.writeMethod() + data: JSON.stringify(data) + url: url + success: (data) -> + App.Collection.loadAssets(data.assets) + formController.didSaveCallback(data) + error: (xhr) -> + data = JSON.parse(xhr.responseText) + App.ControllerForm.enable(formController.form) + formController.showAlert(data.error || 'Unable to save changes.') + ) diff --git a/app/assets/javascripts/app/controllers/knowledge_base/form_controller.coffee b/app/assets/javascripts/app/controllers/knowledge_base/form_controller.coffee new file mode 100644 index 000000000..cc09b2b3b --- /dev/null +++ b/app/assets/javascripts/app/controllers/knowledge_base/form_controller.coffee @@ -0,0 +1,69 @@ +class App.KnowledgeBaseFormController extends App.ControllerForm + # set screen to agent_edit or agent_create + constructor: (object, kb_locale, screen, dom) -> + @object = object + @kb_locale = kb_locale + + objectParams = @currentParams() + objectParams['form_id'] = App.ControllerForm.formId() + + super( + params: objectParams + autofocus: dom isnt null + grid: true + el: dom || $('
') + screen: screen + model: { configure_attributes: @getAttrs() } + ) + + getAjaxAttributes: (field, attributes) -> + @apiPath = App.Config.get('api_path') + + attributes.type = 'POST' + attributes.url = "#{@apiPath}/knowledge_bases/search" + + attributes.data.flavor = 'agent' + attributes.data.knowledge_base_id = @object.knowledge_base().id + attributes.data.exclude_ids = [@object.translation(@kb_locale.id)?.id] + attributes.data.index = 'KnowledgeBase::Answer::Translation' + attributes.data.locale = @kb_locale.systemLocale().locale + attributes.data.highlight_enabled = false + + attributes.data = JSON.stringify(attributes.data) + + attributes + + currentParams: -> + @object.attributesIncludingTranslation(@kb_locale.id) + + rawParams: -> + App.ControllerForm.params(@el) + + paramsForSaving: -> + @object.prepareNestedParams(@rawParams(), @kb_locale.id) + + validateAndShowErrors: -> + errors = @validate(@rawParams()) + + @constructor.validate( + errors: errors + form: @.el + ) + + !errors + + getAttrs: -> + attrs = @object.configure_attributes?(@kb_locale) || @object.constructor.configure_attributes + + attrs.push { + name: 'form_id' + tag: 'input' + type: 'hidden' + } + + attrs + + @compareParams: (a, b) -> + for params in [a, b] + delete params.form_id + _.isEqual(a, b) diff --git a/app/assets/javascripts/app/controllers/knowledge_base/knowledge_base_reader_pagination.coffee b/app/assets/javascripts/app/controllers/knowledge_base/knowledge_base_reader_pagination.coffee new file mode 100644 index 000000000..e3986d635 --- /dev/null +++ b/app/assets/javascripts/app/controllers/knowledge_base/knowledge_base_reader_pagination.coffee @@ -0,0 +1,97 @@ +class App.KnowledgeBaseReaderPagination extends App.Controller + constructor: -> + super + @render() + + className: 'knowledge-base-article-nav' + + render: -> + @stopListening() + + previousAnswer = @calculatePreviousAnswer() + nextAnswer = @calculateNextAnswer() + + @html App.view('knowledge_base/_reader_pagination')( + previousAnswer: previousAnswer?.attributesForRendering(@kb_locale) + nextAnswer: nextAnswer?.attributesForRendering(@kb_locale) + ) + + for object in [@object, previousAnswer, nextAnswer, @object.category()] + if object + @listenTo object, 'refresh', (e) => + @render() + + calculatePreviousAnswer: -> + @calculateSiblingAnswer(-1) + + calculateNextAnswer: -> + @calculateSiblingAnswer(+1) + + calculateSiblingAnswer: (direction) -> + if sibling = @calculateSibling(@object.category().answers(), @object, direction) + return sibling + + if direction < 0 and cat_answer = @findlastAnswer(@object.category()) + return cat_answer + + scope = @object + + while scope + parent = scope.category?() || scope.parent?() + + list = if parent + parent.children() + else + scope.knowledge_base().rootCategories() + + if siblingAtScope = @findAnswerInSiblingCategory(scope, list, direction) + return siblingAtScope + + scope = parent + + null + + calculateSibling: (list, current, direction) -> + list[@getIndexOf(list, current) + direction] + + getIndexOf: (list, current) -> + matching = list.filter((elem) -> elem.id == current.id)[0] + list.indexOf(matching) + + findlastAnswer: (category, include_direct_answers = false) -> + if include_direct_answers and last_direct = category.answers().slice(-1)[0] + return last_direct + + for category in category.children().reverse() + if answer = @findlastAnswer(category, true) + return answer + + return null + + findFirstAnswer: (category) -> + for category in category.children() + if answer = @findFirstAnswer(category) + return answer + + category.answers()[0] + + findAnswerInSiblingCategory: (category, list, direction) -> + currentCategoryIndex = @getIndexOf(list, category) + + categories = if direction < 0 + list.slice(0, currentCategoryIndex).reverse() + else + list.slice(currentCategoryIndex + 1) + + for category in categories + # coffeelint: disable=indentation + found = if direction < 0 + @findlastAnswer(category, true) + else + @findFirstAnswer(category) + # coffeelint: enable=indentation + + if found + return found + + null diff --git a/app/assets/javascripts/app/controllers/knowledge_base/navigation.coffee b/app/assets/javascripts/app/controllers/knowledge_base/navigation.coffee new file mode 100644 index 000000000..ae995dd44 --- /dev/null +++ b/app/assets/javascripts/app/controllers/knowledge_base/navigation.coffee @@ -0,0 +1,173 @@ +class App.KnowledgeBaseNavigation extends App.Controller + @extend(Spine.Events) + + events: + 'click .js-search': 'clickedToggleSearch' + + elements: + '.js-edit': 'editButton' + + constructor: -> + super + @render() + + @bind 'knowledge_base::navigation::rerender', => @needsUpdate() + + @listenTo App.KnowledgeBase, 'kb_data_change_loaded', => + @needsUpdate() + + buildCrumbsForRendering: (array, kb_locale, action) -> + if action is 'search' + action = null + + if !kb_locale + return [] + + array + .filter (elem) -> + elem != undefined and elem != null + .map (elem) => + if typeof elem is 'string' + return { title: elem } + + @listenToChangesOn(elem) + elem.attributesForRendering(kb_locale, action: action) + + listenToChangesOn: (object) -> + locale = @parentController.kb_locale() + + if !locale + return + + @stopListening object, 'refresh' + @listenToOnce object.translationBindlableObject(locale.id), 'refresh', (obj) => + @needsUpdate() + + show: (object, action) -> + @savedAction = action + + if @dontRenderFor(object) + return + + # coffeelint: disable=indentation + crumbs = if title = @calculateTitle(object, action) + [@parentController.getKnowledgeBase(), title] + else + @breadcrumbTo(object).reverse() + # coffeelint: enable=indentation + + crumbsForRendering = @buildCrumbsForRendering(crumbs, @parentController.kb_locale(), action) + + @render(crumbsForRendering, object, action) + @savedParams = object + + calculateTitle: (object, action) -> + if action is 'search' + App.i18n.translateInline 'Search' + else if !object + App.i18n.translateInline 'Not found' + + dontRenderFor: (object) -> + if object instanceof App.Model + object.isNew() && !object.isFresh + else + false + + needsUpdate: -> + @show(@savedParams, @savedAction) + + selectedLocaleDisplay: -> + @parentController.kb_locale()?.systemLocale().alias || '-' + + render: (crumbs = [], object = null, action = null) -> + kb_locale = @parentController.kb_locale() + return if !kb_locale + + @html App.view('knowledge_base/navigation')( + crumbs: crumbs + kbLocales: @kbLocaleOptions(object, kb_locale, action) + search: @searchOptions(object, kb_locale, action) + edit: @editOptions(object, kb_locale, action) + externalUrl: @externalUrl(object, kb_locale, action) + iconset: @parentController.getKnowledgeBase().iconset + ) + + kbLocaleOptions: (object, kb_locale, action) -> + { + selected: kb_locale + collection: @kb_locales() + } + + searchOptions: (object, kb_locale, action) -> + enabled = action is 'search' + + url = if enabled == true + @toggleSearchSource || @parentController.getKnowledgeBase()?.uiUrl(kb_locale) + else + @parentController.getKnowledgeBase()?.uiUrl(kb_locale, 'search') + + { + enabled: enabled + url: url + } + + editOptions: (object, kb_locale, action) -> + enabled = action is 'edit' + + { + url: object?.uiUrl(kb_locale, if !enabled then 'edit') + enabled: enabled + available: @parentController.isEditor() + } + + + externalUrl: (object, kb_locale, action) -> + if action and action != 'edit' + return + + if !(object?.visiblePublicly?(kb_locale) or (object?.translation?(kb_locale?.id)? and @parentController.isEditor())) + return + + object.publicBaseUrl(kb_locale) + + kb_locales: -> + path = '#' + @parentController.lastParams.match.input + + @parentController + .getKnowledgeBase() + .kb_locales() + .map (elem) -> elem.attributesForRendering(path) + + toggleSearchSource: undefined + + clickedToggleSearch: -> + if @savedAction is 'search' + return + + @toggleSearchSource = location.hash + + breadcrumbTo: (object) -> + if !object + return [] + + output = switch object.constructor + when App.KnowledgeBaseAnswer + @breadcrumbToAnswer(object) + when App.KnowledgeBaseCategory + @breadcrumbToCategory(object) + when App.KnowledgeBase + @breadcrumbToKb(object) + + breadcrumbToAnswer: (answer) -> + [answer].concat @breadcrumbToCategory(answer.category()) + + breadcrumbToCategory: (category) -> + array = [category] + + while parent = (parent || category).parent() + array = array.concat parent + + array.concat @breadcrumbToKb(category.knowledge_base()) + + breadcrumbToKb: (kb) -> + [kb] diff --git a/app/assets/javascripts/app/controllers/knowledge_base/public_menu_form.coffee b/app/assets/javascripts/app/controllers/knowledge_base/public_menu_form.coffee new file mode 100644 index 000000000..f30ad420a --- /dev/null +++ b/app/assets/javascripts/app/controllers/knowledge_base/public_menu_form.coffee @@ -0,0 +1,17 @@ +class App.KnowledgeBasePublicMenuForm extends App.Controller + events: + 'show.bs.tab': 'willShow' + + willShow: -> + @el.empty() + + for kb_locale in App.KnowledgeBase.find(@knowledge_base_id).kb_locales() + menu_items = App.KnowledgeBaseMenuItem.using_kb_locale(kb_locale) + + form_item = new App.KnowledgeBasePublicMenuFormItem( + knowledge_base_id: @knowledge_base_id, + kb_locale: kb_locale, + menu_items: menu_items + ) + + @el.append form_item.el diff --git a/app/assets/javascripts/app/controllers/knowledge_base/public_menu_form_item.coffee b/app/assets/javascripts/app/controllers/knowledge_base/public_menu_form_item.coffee new file mode 100644 index 000000000..1c403cabf --- /dev/null +++ b/app/assets/javascripts/app/controllers/knowledge_base/public_menu_form_item.coffee @@ -0,0 +1,144 @@ +class App.KnowledgeBasePublicMenuFormItem extends App.Controller + events: + 'click .js-add': 'add' + 'click .js-remove': 'remove' + 'input input': 'input' + 'submit form': 'submit' + + elements: + '.js-alert': 'alert' + + constructor: -> + super + @render() + + render: -> + @html App.view('knowledge_base/public_menu_form_item')( + kb_locale_id: @kb_locale.id + rows: @menu_items + title: @kb_locale.systemLocale().name + ) + + @applySortable() + + applySortable: -> + dndOptions = + tolerance: 'pointer' + distance: 15 + opacity: 0.6 + items: 'tr.sortable' + start: (e, ui) -> + ui.placeholder.height( ui.item.height() ) + helper: (e, tr) -> + originals = tr.children() + helper = tr + helper.children().each (index, el) -> + # Set helper cell sizes to match the original sizes + $(@).width( originals.eq(index).width() ) + return helper + update: @dndCallback + stop: (e, ui) -> + ui.item.children().each (index, element) -> + element.style.width = '' + + @el.find('tbody').sortable(dndOptions) + + toggleUserInteraction: (enabled) -> + if enabled + App.ControllerForm.enable(@el) + else + App.ControllerForm.disable(@el) + + @$('.js-remove, .js-add').attr('disabled', !enabled) + @el.find('tbody').sortable(disabled: !enabled) + + buildData: -> + items = @$('tr.sortable') + .toArray() + .map (elem) -> $(elem) + .map (elem) -> + { + id: elem.data('id') + title: elem.find('input[data-name=title]').val() + url: elem.find('input[data-name=url]').val() + new_tab: elem.find('input[data-name=new_tab]').prop('checked') + _destroy: elem.hasClass('js-deleted') + } + + { + kb_locale_id: @$('form').data('kb-locale-id'), + menu_items: items + } + + input: -> + if @validateForm(false) + @hideAlert() + + add: -> + el = App.view('knowledge_base/public_menu_form_item_row')() + $(el).insertBefore(@$('tr:has(.js-add)')) + + remove: (e) -> + row = $(e.currentTarget).closest('tr') + + if row.data('id') + row.toggleClass('settings-list--deleted js-deleted') + row.find('.js-remove input').prop('checked', row.hasClass('settings-list--deleted')) + row.find('.js-new-tab input').attr('disabled', row.hasClass('js-deleted')) + else + row.remove() + + showAlert: (message) -> + translated = App.i18n.translatePlain(message) + + @alert + .text(translated) + .removeClass('hidden') + + hideAlert: -> + @alert.addClass('hidden') + + emptyFields: -> + @$('tr.sortable:not(.js-deleted)') + .find('input[data-name]') + .toArray() + .filter (elem) -> $(elem).val().length == 0 + + validateForm: (showAlert = true) -> + if @emptyFields().length == 0 + return true + + if showAlert + @showAlert('Please fill in all fields') + + false + + submit: (e) -> + @preventDefaultAndStopPropagation(e) + + if !@validateForm() + return + + @hideAlert() + @toggleUserInteraction(false) + + kb = App.KnowledgeBase.find(@knowledge_base_id) + + @ajax( + id: 'update_menu_items' + type: 'PATCH' + url: kb.manageUrl('update_menu_items') + data: JSON.stringify(@buildData()) + processData: true + success: (data, status, xhr) => + for menu_item in App.KnowledgeBaseMenuItem.using_kb_locale(@kb_locale) + menu_item.remove(clear: true) + + App.Collection.loadAssets(data.assets) + + @menu_items = App.KnowledgeBaseMenuItem.using_kb_locale(@kb_locale) + @render() + error: (xhr) => + @showAlert(xhr.responseJSON?.error_human || 'Couldn\'t save changes') + @toggleUserInteraction(true) + ) diff --git a/app/assets/javascripts/app/controllers/knowledge_base/reader_controller.coffee b/app/assets/javascripts/app/controllers/knowledge_base/reader_controller.coffee new file mode 100644 index 000000000..8ee553b16 --- /dev/null +++ b/app/assets/javascripts/app/controllers/knowledge_base/reader_controller.coffee @@ -0,0 +1,126 @@ +class App.KnowledgeBaseReaderController extends App.Controller + @extend App.PopoverProvidable + @registerPopovers 'Ticket' + + elements: + '.js-answer-title': 'answerTitle' + '.js-answer-body': 'answerBody' + '.js-answer-pagination': 'answerPagination' + '.js-answer-attachments': 'answerAttachments' + '.js-answer-linked-tickets': 'answerLinkedTickets' + '.js-answer-meta': 'answerMeta' + + constructor: -> + super + + translation = @object.translation(@parentController.kb_locale().id) + + @html App.view('knowledge_base/reader')( + search_return_url: @buildSearchReturnUrl() + ) + + if translation and !translation.fullyLoaded() + @startLoading(@answerBody) + + translation.loadFull (isSuccess) => + @stopLoading() + + if !isSuccess + return + + @initialize() + + return + + @initialize() + + initialize: -> + @render() + + render: -> + @stopListening() + + kb_locale = @parentController.kb_locale() + + @renderAnswer(@object, kb_locale) + + if !@object + return + + @listenTo App.KnowledgeBase, 'kb_data_change_loaded', => + @renderAnswer(@object, kb_locale) + + renderAnswer: (answer, kb_locale) -> + if !answer + @parentController.renderNotFound() + return + + if !answer.exists() + @parentController.renderNotAvailableAnymore() + return + + @renderAttachments(answer.attachments) + @renderLinkedTickets(answer.translation(kb_locale.id)?.linked_tickets()) + + paginator = new App.KnowledgeBaseReaderPagination(object: @object, kb_locale: kb_locale) + @answerPagination.html paginator.el + + answer_translation = answer.translation(kb_locale.id) + + if !answer_translation + @renderTranslationMissing(answer) + return + + @answerTitle.text(answer_translation.title) + + @renderBody(answer_translation) + + @answerMeta.html App.view('knowledge_base/_reader_answer_meta')( + answer: answer + ) + + @renderPopovers() + + renderBody: (translation) -> + body = $($.parseHTML(translation.content().body)) + + for linkDom in body.find('a').andSelf('a').toArray() + switch $(linkDom).attr('data-target-type') + when 'knowledge-base-answer' + if object = App.KnowledgeBaseAnswerTranslation.find $(linkDom).attr('data-target-id') + $(linkDom).attr 'href', object.uiUrl() + else + $(linkDom).attr 'href', '#' + + @answerBody.html(body) + + renderAttachments: (attachments) -> + @answerAttachments.html App.view('generic/attachments')( + attachments: attachments + ) + + renderLinkedTickets: (linked_tickets) -> + @answerLinkedTickets.html App.view('knowledge_base/_reader_linked_tickets')( + tickets: linked_tickets + ) + + renderTranslationMissing: (answer) -> + if !@parentController.isEditor() + @parentController.renderNotFound() + return + + @renderScreenPlaceholder( + icon: App.Utils.icon('mood-ok') + detail: 'Not available in selected language' + el: @answerBody + action: 'Create a translation' + actionCallback: => + url = answer.uiUrl(@parentController.kb_locale(), 'edit') + @navigate url + ) + + buildSearchReturnUrl: -> + if @parentController.lastParams.action != 'search-return' + return + + decodeURIComponent @parentController.lastParams.arguments diff --git a/app/assets/javascripts/app/controllers/knowledge_base/reader_list_container.coffee b/app/assets/javascripts/app/controllers/knowledge_base/reader_list_container.coffee new file mode 100644 index 000000000..a3eb47263 --- /dev/null +++ b/app/assets/javascripts/app/controllers/knowledge_base/reader_list_container.coffee @@ -0,0 +1,79 @@ +class App.KnowledgeBaseReaderListContainer extends App.Controller + constructor: -> + super + @render() + + @listenTo App.KnowledgeBase, 'kb_data_change_loaded', => + @parentRefreshed() + + tag: 'ul' + className: 'sections' + + parentRefreshed: -> + newIds = @children().map (elem) -> elem.id + oldIds = @el.children().toArray().map (elem) -> parseInt(elem.dataset.id) + + if _.isEqual(newIds, oldIds) + return + + App.Delay.set(=> + @render() + , 200, "#{@constructor.className}_#{@parent.constructor.className}:#{@parent.id}", 'kb_category_refresh') + + render: -> + @el.empty() + + for child in @children() + @el.append new App.KnowledgeBaseReaderListItem( + item: child + isEditor: @isEditor + iconset: @parent.knowledge_base().iconset + kb_locale: @kb_locale + parentController: @ + ).el + +class App.KnowledgeBaseReaderListContainer.Answers extends App.KnowledgeBaseReaderListContainer + children: -> + if !(@parent instanceof App.KnowledgeBaseCategory) + return [] + + answers = @parent.answers() + + if !@isEditor + answers = answers.filter (elem) => elem.is_internally_published(@kb_locale) + + answers + +class App.KnowledgeBaseReaderListContainer.Categories extends App.KnowledgeBaseReaderListContainer + render: -> + super + + @el.addClass "sections--#{@layout()}" + @el[0].dataset['size'] = @size() + + children: -> + # coffeelint: disable=indentation + items = if @parent instanceof App.KnowledgeBase + @parent.rootCategories() + else if @parent instanceof App.KnowledgeBaseCategory + @parent.children() + else + [] + # coffeelint: enable=indentation + + if !@isEditor + items = items.filter (elem) => elem.visibleInternally(@kb_locale) + + items + + layout: -> + if @parent instanceof App.KnowledgeBase + @parent.knowledge_base().homepage_layout + else + @parent.knowledge_base().category_layout + + size: -> + if @parent instanceof App.KnowledgeBase + 'large' + else + 'medium' diff --git a/app/assets/javascripts/app/controllers/knowledge_base/reader_list_controller.coffee b/app/assets/javascripts/app/controllers/knowledge_base/reader_list_controller.coffee new file mode 100644 index 000000000..ce6661b4d --- /dev/null +++ b/app/assets/javascripts/app/controllers/knowledge_base/reader_list_controller.coffee @@ -0,0 +1,64 @@ +class App.KnowledgeBaseReaderListController extends App.Controller + constructor: -> + super + @render() + + @listenTo App.KnowledgeBase, 'kb_data_change_loaded', => + if !@objectVisibleInternally() + @parentController.renderNotAvailableAnymore() + + elements: + '.js-readerListContainer': 'container' + + objectVisibleInternally: -> + @object.visibleInternally(@parentController.kb_locale()) + + render: -> + if !@parentController.isEditor() && (!@object || !@object.exists() || !@objectVisibleInternally()) + @parentController.renderNotFound() + return + + if @object.isEmpty() + @renderScreenPlaceholder( + icon: App.Utils.icon('mood-ok') + detail: 'This category is empty' + action: 'Start Editing' + actionCallback: => + url = @object.uiUrl(@parentController.kb_locale(), 'edit') + @navigate url + ) + return + + @html App.view('knowledge_base/reader_list')() + + @searchFieldPanel = new App.KnowledgeBaseSearchFieldPanel( + el: @$('.js-searchFieldContainer') + + context: @object + kb_locale: @parentController.kb_locale() + return_path: @object.uiUrl(@parentController.kb_locale(), 'search-inline') + + willStart: @searchPanelWillStart + didEnd: @searchPanelDidEnd + ) + + if @parentController.lastParams.action is 'search-inline' + @searchFieldPanel.widget.startSearch(@parentController.lastParams.arguments) + + isEditor = @parentController.isEditor() + kb_locale = @parentController.kb_locale() + + setTimeout => + for kind in ['Categories', 'Answers'] + @container.append new App.KnowledgeBaseReaderListContainer[kind]( + parent: @object + isEditor: isEditor + kb_locale: kb_locale + ).el + , 100 + + searchPanelWillStart: => + @container.addClass('hide') + + searchPanelDidEnd: => + @container.removeClass('hide') diff --git a/app/assets/javascripts/app/controllers/knowledge_base/reader_list_item.coffee b/app/assets/javascripts/app/controllers/knowledge_base/reader_list_item.coffee new file mode 100644 index 000000000..595ccd1a8 --- /dev/null +++ b/app/assets/javascripts/app/controllers/knowledge_base/reader_list_item.coffee @@ -0,0 +1,34 @@ +class App.KnowledgeBaseReaderListItem extends App.Controller + constructor: -> + super + @render() + @el[0].dataset.id = @item.id + + @listenTo App.KnowledgeBase, 'kb_data_change_loaded', => + @render() + + tag: 'li' + className: 'section' + + render: -> + if @sort_order != null && @sort_order != @item.position + App.Delay.set(=> + @parentController.parentRefreshed() + , 1000, 'kb_reader_list_resort') + + @sort_order = @item.position + + attrs = @item.attributesForRendering(@kb_locale, isEditor: @isEditor) + + @el + .prop('className') + .split(' ') + .filter (elem) -> elem.match 'kb-item--' + .forEach (elem) -> @el.removeClass(elem) + + @el.addClass attrs.className + + @html App.view('knowledge_base/_reader_list_item')( + item: attrs + iconset: @iconset + ) diff --git a/app/assets/javascripts/app/controllers/knowledge_base/router.coffee b/app/assets/javascripts/app/controllers/knowledge_base/router.coffee new file mode 100644 index 000000000..75578b844 --- /dev/null +++ b/app/assets/javascripts/app/controllers/knowledge_base/router.coffee @@ -0,0 +1,35 @@ +class Router extends App.ControllerPermanent + requiredPermission: 'knowledge_base.*' + + constructor: (params) -> + super + + if params['locale'] + params.selectedSystemLocale = App.Locale.findByAttribute('locale', params['locale']) + params.selectedSystemLocalePresent = true + + # check authentication + @authenticateCheckRedirect() + + App.TaskManager.execute( + key: 'KnowledgeBase' + controller: 'KnowledgeBaseAgentController' + params: params + show: true + persistent: true + ) + +[ + '/category/:category_id' + '/answer/:answer_id' + '' +] + .reduce((memo, elem) -> + memo.concat [elem, elem + '/:action', elem + '/:action/:arguments'] + , []) + .forEach (elem) -> + url = "knowledge_base/:knowledge_base_id/locale/:locale#{elem}" # App.Utils not yet available, thus not using App.Utils.joinUrlComponents + App.Config.set(url, Router, 'Routes') + +App.Config.set('knowledge_base', Router, 'Routes') + diff --git a/app/assets/javascripts/app/controllers/knowledge_base/scheduled_widget.coffee b/app/assets/javascripts/app/controllers/knowledge_base/scheduled_widget.coffee new file mode 100644 index 000000000..08f1b01e7 --- /dev/null +++ b/app/assets/javascripts/app/controllers/knowledge_base/scheduled_widget.coffee @@ -0,0 +1,20 @@ +class App.KnowledgeBaseScheduledWidget extends App.Controller + className: 'scheduled-widget' + + constructor: -> + super + + @el.attr('data-state', @state) + @el.data('date', @getDate()) + + @render() + + getDate: -> + if string = @object["#{@state}_at"] + new Date(string) + + render: -> + @html App.view('knowledge_base/scheduled_widget')( + timestamp: App.i18n.translateTimestamp(@getDate()) + state: @state + ) diff --git a/app/assets/javascripts/app/controllers/knowledge_base/search_controller.coffee b/app/assets/javascripts/app/controllers/knowledge_base/search_controller.coffee new file mode 100644 index 000000000..1ce434f62 --- /dev/null +++ b/app/assets/javascripts/app/controllers/knowledge_base/search_controller.coffee @@ -0,0 +1,20 @@ +class App.KnowledgeBaseSearchController extends App.Controller + constructor: -> + super + @html App.view('knowledge_base/search')( + knowledge_base: @parentController.getKnowledgeBase() + kb_locale: @parentController.kb_locale() + ) + + @searchFieldPanel = new App.KnowledgeBaseSearchFieldPanel( + el: @$('.js-searchFieldContainer') + + context: @parentController.getKnowledgeBase() + kb_locale: @parentController.kb_locale() + return_path: @parentController.getKnowledgeBase().uiUrl(@parentController.kb_locale(), 'search') + ) + + if query = @parentController.lastParams.arguments + @searchFieldPanel.widget.startSearch(query) + + @searchFieldPanel.widget.focus() diff --git a/app/assets/javascripts/app/controllers/knowledge_base/search_field_panel.coffee b/app/assets/javascripts/app/controllers/knowledge_base/search_field_panel.coffee new file mode 100644 index 000000000..8ca213f1e --- /dev/null +++ b/app/assets/javascripts/app/controllers/knowledge_base/search_field_panel.coffee @@ -0,0 +1,84 @@ +class App.KnowledgeBaseSearchFieldPanel extends App.Controller + elements: + '.js-placeholderEmpty': 'emptyPlaceholder' + '.js-placeholderError': 'errorPlaceholder' + '.js-results': 'resultsContainer' + + context: undefined + kb_locale: null + + #callbacks + willStart: null + didEnd: null + + constructor: -> + super + @html App.view('knowledge_base/search_field_panel')() + + @widget = new App.KnowledgeBaseSearchFieldWidget( + el: @$('.searchfield') + kb_locale: @kb_locale + context: @context + + willStart: @widgetWillStart + didEnd: @widgetDidEnd + + willStartLoading: @widgetWillStartLoading + + renderError: @renderError + renderResults: @renderResults + ) + + clear: => + @resultsContainer.empty() + @errorPlaceholder.addClass('hide') + @emptyPlaceholder.addClass('hide') + + widgetWillStart: => + @willStart?() + + widgetDidEnd: => + @clear() + @didEnd?() + + widgetWillStartLoading: => + @clear() + + renderError: (text) => + @errorPlaceholder + .removeClass('hide') + .find('.help-block--inner') + .text(App.i18n.translateInline(text)) + + renderResults: (results, originalQuery) => + @clear() + + if results.result.length == 0 + @emptyPlaceholder.removeClass('hide') + return + + suffix = @buildReturnSuffix(originalQuery) + return_path = App.Utils.joinUrlComponents(@return_path, originalQuery) + + views = results + .result + .map (elem, index) -> + details = results.details[index] + klass_name = elem.type.replace /::/g, '' + + object = App[klass_name].find(elem.id) + + new App.KnowledgeBaseSearchItem( + object: object + meta: elem + details: details + pathSuffix: suffix + return_path: return_path + ) + + .map (elem) -> elem.el + + @resultsContainer.append views + + buildReturnSuffix: (query) -> + encodeURIComponent App.Utils.joinUrlComponents(@return_path, query) diff --git a/app/assets/javascripts/app/controllers/knowledge_base/search_field_widget.coffee b/app/assets/javascripts/app/controllers/knowledge_base/search_field_widget.coffee new file mode 100644 index 000000000..0234fabcb --- /dev/null +++ b/app/assets/javascripts/app/controllers/knowledge_base/search_field_widget.coffee @@ -0,0 +1,117 @@ +class App.KnowledgeBaseSearchFieldWidget extends App.Controller + className: 'searchfield' + + elements: + '.js-searchField': 'searchField' + '.js-emptySearchButton': 'emptySearchButton' + + events: + 'input .js-searchField': 'input' + 'click .js-emptySearchButton': 'clear' + + isActive: false + + context: undefined + kb_locale: null + + # callbacks + renderError: null + renderResults: null + willStartLoading: null + willStart: null + didEnd: null + + constructor: -> + super + + @cache = {} + + @html App.view('knowledge_base/search_field_widget')( + placeholder_suffix: @context?.guaranteedTitle(@kb_locale.id) + ) + + clear: -> + @searchField.val('') + @emptySearchButton.addClass 'hide' + + @isActive = false + @didEnd?() + + input: -> + query = @searchField.val() + + @emptySearchButton.toggleClass 'hide', query.length == 0 + + if query == '' + @abortAjaxCalls() + @isActive = false + @didEnd?() + return + + if !@isActive + @isActive = true + @willStart?() + + @willStartLoading?() + + @searchField.addClass('loading') + + @delay( => + @makeRequest(query) + , 100, 'makeRequest') + + data: (query) -> + attrs = { + query: query, + flavor: 'agent', + knowledge_base_id: @context.knowledge_base().id + locale: @kb_locale.systemLocale().locale + } + + if @context instanceof App.KnowledgeBaseCategory + attrs['scope_id'] = @context.id + + attrs + + url: -> + App.Utils.joinUrlComponents(App.KnowledgeBase.url, 'search') + + makeRequest: (query) -> + if (cachedResult = @cache[query]) + @onSuccess(cachedResult) + return + + @ajax( + id: 'kb_search_loading' + type: 'POST' + url: @url() + data: JSON.stringify(@data(query)) + success: (data, status, xhr) => + @cache[query] = data + @onSuccess(data, query) + error: @onError + ) + + onError: (xhr) => + if xhr.status == 0 + if @ajaxCalls.length == 0 + @searchField.removeClass('loading') + return + + @searchField.removeClass('loading') + + text = xhr.responseJSON?.error_human || xhr.responseJSON?.errorr || 'Unable to load' + @renderError(text) + + onSuccess: (data, originalQuery) => + @searchField.removeClass('loading') + App.Collection.loadAssets(data.assets) + @renderResults?(data, originalQuery) + + focus: -> + @searchField.focus() + + startSearch: (query) -> + @searchField + .val(query) + .trigger('input') diff --git a/app/assets/javascripts/app/controllers/knowledge_base/search_item.coffee b/app/assets/javascripts/app/controllers/knowledge_base/search_item.coffee new file mode 100644 index 000000000..b554b7378 --- /dev/null +++ b/app/assets/javascripts/app/controllers/knowledge_base/search_item.coffee @@ -0,0 +1,23 @@ +class App.KnowledgeBaseSearchItem extends App.Controller + tag: 'li' + className: 'section' + + events: + 'click a': 'searchLinkClicked' + + constructor: -> + super + + @render() + + data: -> + output = @details || {} + output['url'] = @object?.uiUrl("search-return/#{@pathSuffix}") || '#' + output + + render: -> + @html App.view('knowledge_base/search_item')(data: @data()) + + searchLinkClicked: -> # setup history and let it continue, no need to prevent default action or bubbling + if window.history? and @return_path? + window.history.replaceState(null, null, @return_path) diff --git a/app/assets/javascripts/app/controllers/knowledge_base/sidebar.coffee b/app/assets/javascripts/app/controllers/knowledge_base/sidebar.coffee new file mode 100644 index 000000000..222af68a3 --- /dev/null +++ b/app/assets/javascripts/app/controllers/knowledge_base/sidebar.coffee @@ -0,0 +1,63 @@ +class App.KnowledgeBaseSidebar extends App.Controller + @extend(Spine.Events) + + events: + 'click .js-content-actions-container a': 'contentActionClicked' + + constructor: -> + super + @show() + + @bind 'knowledge_base::sidebar::rerender', => @rerender() + + @listenTo App.KnowledgeBase, 'kb_data_change_loaded', => + @rerender() + true + + rerender: -> + @show(@savedParams, @savedAction) + + contentActionClicked: (e) -> + # coffeelint: disable=indentation + actionName = switch e.target.dataset.action + when 'delete' then 'clickedDelete' + when 'visibility' then 'clickedCanBePublished' + # coffeelint: enable=indentation + + @parentController.bodyModal = @parentController.coordinator[actionName]?(@savedParams) + + show: (object, action) -> + isEdit = action is 'edit' + + @el.toggleClass('hidden', !isEdit) + @savedParams = object + @savedAction = action + @el.empty() + + if !isEdit + return + + for widget in @widgets(object) + @el.append new widget( + object: object + kb_locale: @parentController.kb_locale() + parentController: @parentController + ).el + + hide: -> + @el.addClass('hidden') + + widgets: (object) -> + output = [App.KnowledgeBaseSidebarActions] + + if object instanceof App.KnowledgeBase || object instanceof App.KnowledgeBaseCategory + output.push App.KnowledgeBaseSidebarCategories + + if object instanceof App.KnowledgeBaseCategory + output.push App.KnowledgeBaseSidebarAnswers + + if object instanceof App.KnowledgeBaseAnswer + output.push App.KnowledgeBaseSidebarLinkedTickets + output.push App.KnowledgeBaseSidebarAttachments + + output diff --git a/app/assets/javascripts/app/controllers/knowledge_base/sidebar/_generic_list.coffee b/app/assets/javascripts/app/controllers/knowledge_base/sidebar/_generic_list.coffee new file mode 100644 index 000000000..06271fd7e --- /dev/null +++ b/app/assets/javascripts/app/controllers/knowledge_base/sidebar/_generic_list.coffee @@ -0,0 +1,54 @@ +class App.KnowledgeBaseSidebarGenericList extends App.Controller + className: 'sidebar-block' + + events: + 'click .js-reorder': 'openReorder' + 'click .js-add': 'openAdd' + + constructor: -> + super + + @html App.view('knowledge_base/sidebar/generic_list')(@templateOptions()) + + templateOptions: -> + iconset: @object.knowledge_base().iconset + items: @items() + urlNew: @urlNew() + enabled: true + title: @title + emptyNote: @emptyNote + + openReorder: (e) -> + e.preventDefault() + e.stopPropagation() + + @parentController.bodyModal = new App.ControllerReorderModal( + container: @parentController.body + items: @items() + url: @reorderSaveUrl() + ) + + openAdd: (e) -> + e.preventDefault() + e.stopPropagation() + + newObject = @newObject() + newObject.isFresh = true + + @parentController.bodyModal = new App.KnowledgeBaseAddForm( + object: newObject + container: @parentController.body + parentController: @parentController + ) + + newObject: -> + #has to be overridden + + reorderSaveUrl: -> + #has to be overridden + + items: -> + #has to be overridden + + urlNew: -> + #has to be overridden diff --git a/app/assets/javascripts/app/controllers/knowledge_base/sidebar/actions.coffee b/app/assets/javascripts/app/controllers/knowledge_base/sidebar/actions.coffee new file mode 100644 index 000000000..373a6f9cd --- /dev/null +++ b/app/assets/javascripts/app/controllers/knowledge_base/sidebar/actions.coffee @@ -0,0 +1,14 @@ +class App.KnowledgeBaseSidebarActions extends App.Controller + className: 'sidebar-block' + + constructor: -> + super + + actions = @object?.contentSidebarActions(@kb_locale) + + html = if actions + App.view('knowledge_base/sidebar/actions')(actions: actions) + else + '' + + @html html diff --git a/app/assets/javascripts/app/controllers/knowledge_base/sidebar/answers.coffee b/app/assets/javascripts/app/controllers/knowledge_base/sidebar/answers.coffee new file mode 100644 index 000000000..4e96ed2a9 --- /dev/null +++ b/app/assets/javascripts/app/controllers/knowledge_base/sidebar/answers.coffee @@ -0,0 +1,23 @@ +class App.KnowledgeBaseSidebarAnswers extends App.KnowledgeBaseSidebarGenericList + templateName: 'answers' + title: 'Answers' + emptyNote: 'No answers' + + urlNew: -> + "#knowledge_base/#{@object.knowledge_base().id}/category/#{@object.id}/answers/new" + + answers: -> + @object.answers() + + items: -> + @answers() + .sort (a, b) -> + a.position - b.position + .map (elem) => + elem.attributesForRendering(@kb_locale, action: 'edit', isEditor: true) + + reorderSaveUrl: -> + @object.generateURL('reorder_answers') + + newObject: -> + new App.KnowledgeBaseAnswer(category_id: @object.id) diff --git a/app/assets/javascripts/app/controllers/knowledge_base/sidebar/attachments.coffee b/app/assets/javascripts/app/controllers/knowledge_base/sidebar/attachments.coffee new file mode 100644 index 000000000..40a6cf290 --- /dev/null +++ b/app/assets/javascripts/app/controllers/knowledge_base/sidebar/attachments.coffee @@ -0,0 +1,135 @@ +class App.KnowledgeBaseSidebarAttachments extends App.Controller + className: 'sidebar-block' + + events: + 'click .js-delete': 'delete' + 'html5Upload.dropZone.show': 'showDropZone' + 'html5Upload.dropZone.hide': 'hideDropZone' + + elements: + '.attachmentUpload-progressBar': 'progressBar' + '.js-percentage': 'progressText' + '.attachmentPlaceholder': 'attachmentPlaceholder' + '.attachmentUpload': 'attachmentUpload' + '.js-cancel': 'cancelContainer' + 'input': 'input' + '.dropContainer': 'dropContainer' + + constructor: -> + super + + @render() + @listenTo @object, 'refresh', @needsUpdate + + needsUpdate: => + @render() + + render: -> + @html App.view('knowledge_base/sidebar/attachments')( + attachments: @object.attachments + ) + + html5Upload.initialize( + uploadUrl: @object.generateURL('attachments') + dropContainer: @el.get(0) + cancelContainer: @cancelContainer + inputField: @input.get(0) + maxSimultaneousUploads: 1, + key: 'file' + onFileAdded: @onFileAdded + ) + + delete: (e) => + e.preventDefault() + id = parseInt($(e.currentTarget).attr('data-object-id')) + attachment = @object.attachments.filter((elem) -> elem.id == id)[0] + + new DeleteConfirm( + container: @container + answer: @object + attachment: attachment + parentController: @ + ) + + fetch: => + @ajax( + id: "attachments_#{@object.id}_knowledge_base_answer" + type: 'GET' + url: @object.generateURL() + '?full=true' + processData: true + success: (data, status, xhr) => + App.Collection.loadAssets(data.assets) + @render() + ) + + onFileAdded: (file) => + file.on( + onStart: @onStart + onAborted: @onAborted + onCompleted: @onCompleted + onProgress: @onProgress + ) + + onStart: => + @attachmentPlaceholder.addClass('hide') + @attachmentUpload.removeClass('hide') + @cancelContainer.removeClass('hide') + + onAborted: => + @attachmentPlaceholder.removeClass('hide') + @attachmentUpload.addClass('hide') + @input.val('') + + onCompleted: (response) => + @attachmentPlaceholder.removeClass('hide') + @attachmentUpload.addClass('hide') + + @progressBar.width(parseInt(0) + '%') + @progressText.text('') + + @input.val('') + + data = JSON.parse(response) + App.Collection.loadAssets(data) + + onProgress: (progress, fileSize, uploadedBytes) => + @progressBar.width(parseInt(progress) + '%') + @progressText.text(parseInt(progress)) + # hide cancel on 90% + if parseInt(progress) >= 90 + @cancelContainer.addClass('hide') + + showDropZone: -> + if @dropContainer.hasClass('is-dropTarget') + return + + @dropContainer.addClass('is-dropTarget') + + hideDropZone: -> + @dropContainer.removeClass('is-dropTarget') + +class DeleteConfirm extends App.ControllerConfirm + content: -> + sentence = App.i18n.translateContent('Are you sure to delete') + "#{sentence} #{@attachment.filename}?" + buttonSubmit: 'delete' + onSubmit: -> + @formDisable(@el) + + @ajax( + id: 'attachment_delete' + type: 'DELETE' + url: @answer.generateURL("attachments/#{@attachment.id}") + processData: true + success: @success + error: @error + ) + + success: (data, status, xhr) => + @close() + App.Collection.loadAssets(data) + @parentController.render() + + error: (xhr) => + @formEnable(@el) + @showAlert(xhr.responseJSON?.error || 'Unable to save changes') diff --git a/app/assets/javascripts/app/controllers/knowledge_base/sidebar/categories.coffee b/app/assets/javascripts/app/controllers/knowledge_base/sidebar/categories.coffee new file mode 100644 index 000000000..c662742ef --- /dev/null +++ b/app/assets/javascripts/app/controllers/knowledge_base/sidebar/categories.coffee @@ -0,0 +1,45 @@ +class App.KnowledgeBaseSidebarCategories extends App.KnowledgeBaseSidebarGenericList + templateName: 'categories' + title: 'Categories' + emptyNote: 'No categories' + + constructor: -> + super + + templateOptions: -> + attrs = super + attrs.isRoot = @object instanceof App.KnowledgeBase + attrs + + urlNew: -> + prefix = "#knowledge_base/#{@object.knowledge_base().id}/category/" + + if @object instanceof App.KnowledgeBaseCategory + prefix + "#{@object.id}/new" + else if @object instanceof App.KnowledgeBase + prefix + 'category/new' + + categories: -> + if @object instanceof App.KnowledgeBaseCategory + @object.children() + else if @object instanceof App.KnowledgeBase + @object.rootCategories() + else + [] + + items: -> + @categories() + .sort (a, b) -> + a.position - b.position + .map (elem) => + elem.attributesForRendering(@kb_locale, action: 'edit', isEditor: true) + + reorderSaveUrl: -> + if @object instanceof App.KnowledgeBaseCategory + @object.generateURL('reorder_categories') + else + @object.url() + '/categories/reorder_root_categories' + + newObject: -> + parent = if @object instanceof App.KnowledgeBaseCategory then @object + new App.KnowledgeBaseCategory(parent_id: parent?.id, knowledge_base_id: @object.knowledge_base().id) diff --git a/app/assets/javascripts/app/controllers/knowledge_base/sidebar/linked_tickets.coffee b/app/assets/javascripts/app/controllers/knowledge_base/sidebar/linked_tickets.coffee new file mode 100644 index 000000000..86386ef0a --- /dev/null +++ b/app/assets/javascripts/app/controllers/knowledge_base/sidebar/linked_tickets.coffee @@ -0,0 +1,68 @@ +class App.KnowledgeBaseSidebarLinkedTickets extends App.Controller + @extend App.PopoverProvidable + @registerPopovers 'Ticket' + + className: 'sidebar-block' + + events: + 'click .js-add': 'clickedAdd' + 'click .js-delete': 'delete' + + constructor: -> + super + + @render() + @listenTo @object, 'refresh', @needsUpdate + + needsUpdate: => + @render() + + render: -> + @html App.view('knowledge_base/sidebar/linked_tickets')( + tickets: @object.translation(@kb_locale.id)?.linked_tickets() || [] + ) + + @renderPopovers() + + fetch: => + @ajax( + id: "links_#{@object.id}_knowledge_base_answer" + type: 'GET' + url: @object.generateURL() + '?full=true' + processData: true + success: (data, status, xhr) => + App.Collection.loadAssets(data.assets) + @render() + ) + + clickedAdd: (e) => + e.preventDefault() + + new App.TicketLinkAdd( + link_object: 'KnowledgeBase::Answer::Translation' + link_object_id: @object.translation(@kb_locale.id)?.id + link_types: [['normal', 'Normal']] + object: @object.translation(@kb_locale.id) + parent: @ + container: @el.closest('.content') + ) + + delete: (e) => + e.preventDefault() + + data = + link_type: $(e.currentTarget).data('link-type') + link_object_source: $(e.currentTarget).data('object') + link_object_source_value: $(e.currentTarget).data('object-id') + link_object_target: 'KnowledgeBase::Answer::Translation' + link_object_target_value: @object.translation(@kb_locale.id)?.id + + # get data + @ajax( + id: "links_remove_#{@object.id}_#{@object_type}" + type: 'GET' + url: "#{@apiPath}/links/remove" + data: data + processData: true + success: @fetch + ) diff --git a/app/assets/javascripts/app/controllers/layout_ref.coffee b/app/assets/javascripts/app/controllers/layout_ref.coffee index 5cc35222e..484a223ca 100644 --- a/app/assets/javascripts/app/controllers/layout_ref.coffee +++ b/app/assets/javascripts/app/controllers/layout_ref.coffee @@ -2239,4 +2239,137 @@ class ChatToTicketRef extends App.ControllerContent y2: y1 + @attachments.outerHeight() App.Config.set('layout_ref/chat_to_ticket', ChatToTicketRef, 'Routes') + +class KnowledgeBaseAgentReaderRef extends App.ControllerContent + className: 'flex knowledge-base vertical' + + elements: + '.js-search': 'searchInput' + + events: + 'click [data-target]': 'onTargetClicked' + 'click .js-open-search': 'toggleSearch' + + constructor: -> + super + App.Utils.loadIconFont('anticon') + @render() + @level(1) + + render: -> + @html App.view('layout_ref/kb_agent_reader_ref')() + + toggleSearch: (event) -> + active = $(event.currentTarget).toggleClass('btn--primary') + if $(event.currentTarget).is('.btn--primary') + @el.find('.main[data-level]').addClass('hidden') + @el.find('[data-level~="search"]').removeClass('hidden') + @searchInput.focus() + else + @el.find("[data-level~=\"#{@currentLevel}\"]").removeClass('hidden') + @el.find('[data-level~="search"]').addClass('hidden') + + onTargetClicked: (event) -> + event.preventDefault() + @level(event.currentTarget.dataset.target) + + level: (level) -> + @currentLevel = level + @el.find('[data-level]').addClass('hidden') + @el.find("[data-level~=\"#{@currentLevel}\"]").removeClass('hidden') + +App.Config.set('layout_ref/kb_agent_reader', KnowledgeBaseAgentReaderRef, 'Routes') + +class KnowledgeBaseLinkTicketToAnswerRef extends App.ControllerContent + constructor: -> + super + App.Utils.loadIconFont('anticon') + @render() + + render: => + new App.ControllerModal + head: 'Link Answer' + buttonSubmit: false + container: @el + content: App.view('layout_ref/kb_link_ticket_to_answer_ref') + +App.Config.set('layout_ref/kb_link_ticket_to_answer', KnowledgeBaseLinkTicketToAnswerRef, 'Routes') + +class KnowledgeBaseLinkAnswerToAnswerRef extends App.ControllerContent + elements: + '.js-form': 'form' + + constructor: -> + super + @render() + + render: -> + @html App.view('layout_ref/kb_link_answer_to_answer_ref')() + + new App.ControllerForm( + grid: true + params: + category_id: 2 + translation_ids: [ + 1 + 2 + ] + archived_at: null + internal_at: null + published_at: '2018-10-22T13:58:08.730Z' + attachments: [] + id: 1 + translation: + title: 'Lithium en-us' + content: + body: + text: 'Lithium (from Greek: λίθος, translit. lithos, lit. "stone") is a chemical element with symbol Li and atomic number 3. It is a soft, silvery-white alkali metal. Under standard conditions, it is the lightest metal and the lightest solid element. Like all alkali metals, lithium is highly reactive and flammable, and is stored in mineral oil.' + attachments: [] + id: 1 + answer_id: 1 + id: 1 + screen: 'agent' + autofocus: true + el: @form + model: + configure_attributes: [ + { + name: 'translation::title' + model: 'translation' + display: 'Title' + tag: 'input' + grid_width: '1/2' + } + { + name: 'category_id' + model: 'answer' + display: 'Category' + tag: 'select' + null: true + options: [ + { + value: 1 + name: 'Metal' + } + { + value: 2 + name: 'Alkali metal' + } + ] + grid_width: '1/2' + } + { + name: 'translation::content::body' + model: 'translation' + display: 'Content' + tag: 'richtext' + buttons: [ + 'link' + 'link_answer' + ] + } + ] + ) + +App.Config.set('layout_ref/kb_link_answer_to_answer', KnowledgeBaseLinkAnswerToAnswerRef, 'Routes') App.Config.set('LayoutRef', { prio: 1600, parent: '#current_user', name: 'Layout Reference', translate: true, target: '#layout_ref', permission: [ 'admin' ] }, 'NavBarRight') diff --git a/app/assets/javascripts/app/controllers/navigation.coffee b/app/assets/javascripts/app/controllers/navigation.coffee index 512b58fd4..3f3c05ab3 100644 --- a/app/assets/javascripts/app/controllers/navigation.coffee +++ b/app/assets/javascripts/app/controllers/navigation.coffee @@ -21,6 +21,7 @@ class App.Navigation extends App.ControllerWidgetPermanent 'click .js-global-search-result': 'emptyAndCloseDelayed' 'click .js-details-link': 'openExtendedSearch' 'change .js-menu .js-switch input': 'switch' + 'click .js-onclick': 'click' constructor: -> super @@ -97,6 +98,10 @@ class App.Navigation extends App.ControllerWidgetPermanent item.switch = worker.switch() if worker.active && worker.active() activeTab[item.target] = true + if worker.onclick + item.onclick = worker.onclick() + if worker.accessoryIcon + item.accessoryIcon = worker.accessoryIcon() if worker.featureActive if worker.featureActive() shown = true @@ -120,6 +125,13 @@ class App.Navigation extends App.ControllerWidgetPermanent activeTab: activeTab ) + click: (e) -> + @preventDefaultAndStopPropagation(e) + + key = $(e.currentTarget).data('key') + worker = App.TaskManager.worker(key) + worker.clicked(e) + # on switch changes and execute it on controller switch: (e) -> val = $(e.target).prop('checked') diff --git a/app/assets/javascripts/app/controllers/search.coffee b/app/assets/javascripts/app/controllers/search.coffee index 5f718dc19..f396805d1 100644 --- a/app/assets/javascripts/app/controllers/search.coffee +++ b/app/assets/javascripts/app/controllers/search.coffee @@ -85,9 +85,9 @@ class App.Search extends App.Controller @tabs = [] for model in App.Config.get('models_searchable') - model = model.replace(/::/, '') + model = model.replace(/::/g, '') tab = - name: model + name: App[model]?.display_name || model model: model count: 0 active: false diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/article_view.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/article_view.coffee index b13437200..dd464b904 100644 --- a/app/assets/javascripts/app/controllers/ticket_zoom/article_view.coffee +++ b/app/assets/javascripts/app/controllers/ticket_zoom/article_view.coffee @@ -131,6 +131,9 @@ class ArticleViewItem extends App.ObserverController attachments = App.TicketArticle.contentAttachments(article) if article.attachments for attachment in article.attachments + attachment.url = "#{App.Config.get('api_path')}/ticket_attachment/#{article.ticket_id}/#{article.id}/#{attachment.id}?disposition=attachment" + attachment.preview_url = "#{App.Config.get('api_path')}/ticket_attachment/#{article.ticket_id}/#{article.id}/#{attachment.id}?view=preview" + if attachment && attachment.preferences && attachment.preferences['original-format'] is true link = url: "#{App.Config.get('api_path')}/ticket_attachment/#{article.ticket_id}/#{article.id}/#{attachment.id}?disposition=attachment" @@ -192,7 +195,7 @@ class ArticleViewItem extends App.ObserverController @html App.view('ticket_zoom/article_view')( ticket: @ticket article: article - attachments: attachments + attachments: App.view('generic/attachments')(attachments: attachments) links: links ) diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_ticket.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_ticket.coffee index 097ef8e3f..cc7c8f57b 100644 --- a/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_ticket.coffee +++ b/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_ticket.coffee @@ -54,6 +54,19 @@ class Edit extends App.ObserverController ) class SidebarTicket extends App.Controller + constructor: -> + super + @bind 'config_update_local', (data) => @configUpdated(data) + + configUpdated: (data) -> + if data.name != 'kb_active' + return + + if data.value + return + + @editTicket(@el) + sidebarItem: => @item = { name: 'ticket' @@ -96,6 +109,9 @@ class SidebarTicket extends App.Controller if @linkWidget && args.links @linkWidget.reload(args.links) + if @linkKbAnswerWidget && args.links + @linkKbAnswerWidget.reload(args.links) + editTicket: (el) => @el = el localEl = $(App.view('ticket_zoom/sidebar_ticket')()) @@ -121,6 +137,15 @@ class SidebarTicket extends App.Controller object: @ticket links: @links ) + + if @permissionCheck('knowledge_base.*') and App.Config.get('kb_active') + @linkKbAnswerWidget = new App.WidgetLinkKbAnswer( + el: localEl.filter('.link_kb_answers') + object_type: 'Ticket' + object: @ticket + links: @links + ) + @timeUnitWidget = new App.TicketZoomTimeUnit( el: localEl.filter('.js-timeUnit') object_id: @ticket.id diff --git a/app/assets/javascripts/app/controllers/users.coffee b/app/assets/javascripts/app/controllers/users.coffee index 418732f7e..60a4f28ef 100644 --- a/app/assets/javascripts/app/controllers/users.coffee +++ b/app/assets/javascripts/app/controllers/users.coffee @@ -61,8 +61,8 @@ class Index extends App.ControllerSubContent display: 'Action' className: 'actionCell' translation: true - width: '200px' - displayWidth: 200 + width: '250px' + displayWidth: 250 unresizable: true header.push attribute header @@ -70,7 +70,7 @@ class Index extends App.ControllerSubContent callbackAttributes = (value, object, attribute, header) -> text = App.i18n.translateInline('View from user\'s perspective') value = ' ' - attribute.raw = ' ' + App.Utils.icon('switchView') + text + '' + attribute.raw = ' ' + App.Utils.icon('switchView') + '' + text + '' attribute.class = '' attribute.parentClass = 'actionCell no-padding' attribute.link = '' diff --git a/app/assets/javascripts/app/controllers/widget/answer_list.coffee b/app/assets/javascripts/app/controllers/widget/answer_list.coffee new file mode 100644 index 000000000..dcefd33aa --- /dev/null +++ b/app/assets/javascripts/app/controllers/widget/answer_list.coffee @@ -0,0 +1,93 @@ +class App.AnswerList extends App.Controller + @extend App.PopoverProvidable + @registerPopovers 'Organization', 'User' + + constructor: -> + super + + @render() + + render: => + + openTicket = (id,e) => + ticket = App.Ticket.findNative(id) + @navigate ticket.uiUrl() + callbackTicketTitleAdd = (value, object, attribute, attributes) -> + attribute.title = object.title + value + '1111' + callbackLinkToTicket = (value, object, attribute, attributes) -> + attribute.link = object.uiUrl() + value + '22222' + callbackUserPopover = (value, object, attribute, attributes) -> + return value if !object + refObjectId = undefined + if attribute.name is 'customer_id' + refObjectId = object.customer_id + if attribute.name is 'owner_id' + refObjectId = object.owner_id + return value if !refObjectId + attribute.class = 'user-popover' + attribute.data = + id: refObjectId + value + callbackOrganizationPopover = (value, object, attribute, attributes) -> + return value if !object + return value if !object.organization_id + attribute.class = 'organization-popover' + attribute.data = + id: object.organization_id + value + + callbackIconHeader = (headers) -> + attribute = + name: 'icon' + display: '' + translation: false + width: '28px' + displayWidth:28 + unresizable: true + headers.unshift(0) + headers[0] = attribute + headers + + callbackIcon = (value, object, attribute, header) -> + value = ' ' + attribute.class = object.iconClass() + attribute.link = '' + attribute.title = object.iconTitle() + value + + list = [] + for ticket_id in @ticket_ids + ticketItem = App.KnowledgeBaseAnswer.fullLocal(ticket_id) + list.push ticketItem + @el.html('') + new App.ControllerTable( + tableId: @tableId + el: @el + overview: @columns || [ 'id', 'translation::title', 'customer', 'group', 'created_at' ] + model: App.KnowledgeBaseAnswer + objects: list + #bindRow: + # events: + # 'click': openTicket + callbackHeader: [ callbackIconHeader ] + callbackAttributes: + #icon: + #[ callbackIcon ] + #customer_id: + #[ callbackUserPopover ] + organization_id: + [ callbackOrganizationPopover ] + owner_id: + [ callbackUserPopover ] + title: + [ callbackLinkToTicket, callbackTicketTitleAdd ] + number: + [ callbackLinkToTicket, callbackTicketTitleAdd ] + radio: @radio + ) + + @renderPopovers() diff --git a/app/assets/javascripts/app/controllers/widget/button_with_dropdown.coffee b/app/assets/javascripts/app/controllers/widget/button_with_dropdown.coffee new file mode 100644 index 000000000..dfbbfc8ba --- /dev/null +++ b/app/assets/javascripts/app/controllers/widget/button_with_dropdown.coffee @@ -0,0 +1,31 @@ +class App.WidgetButtonWithDropdown extends App.Controller + elements: + '.dropdown-menu-accessories': 'accessoriesContainer' + + events: + 'click li': 'clickedOption' + + constructor: -> + super + @render() + + mainActionLabel: 'Submit' + mainActionIdentifier: 'js-submit' + accessoryActionsIdentifier: 'js-submit-action' + + render: -> + @el.addClass 'buttonDropdown dropdown dropup' + + @html App.view('widget/button_with_dropdown')( + mainActionIdentifier: @mainActionIdentifier + accessoryActionsIdentifier: @accessoryActionsIdentifier + mainActionLabel: @mainActionLabel + actions: @actions || [] + ) + + clickedOption: (e) -> + if e.currentTarget.hasAttribute('disabled') + @preventDefaultAndStopPropagation(e) + return + + @accessoriesContainer.blur() diff --git a/app/assets/javascripts/app/controllers/widget/link/kb_answer.coffee b/app/assets/javascripts/app/controllers/widget/link/kb_answer.coffee new file mode 100644 index 000000000..edeb6688c --- /dev/null +++ b/app/assets/javascripts/app/controllers/widget/link/kb_answer.coffee @@ -0,0 +1,105 @@ +class App.WidgetLinkKbAnswer extends App.WidgetLink + @registerPopovers 'KnowledgeBaseAnswer' + + elements: + '.js-add': 'addButton' + '.searchableSelect': 'searchableSelect' + '.js-shadow': 'shadowField' + '.js-input': 'inputField' + + events: + 'change .js-shadow': 'didSubmit' + 'blur .js-input': 'didBlur' + + getAjaxAttributes: (field, attributes) -> + @apiPath = App.Config.get('api_path') + + attributes.type = 'POST' + attributes.url = "#{@apiPath}/knowledge_bases/search" + attributes.data.flavor = 'agent' + attributes.data.include_locale = true + attributes.data.index = 'KnowledgeBase::Answer::Translation' + attributes.data.highlight_enabled = false + + attributes.data = JSON.stringify(attributes.data) + + attributes + + linksForRendering: -> + @localLinks + .map (elem) -> + switch elem.link_object + when 'KnowledgeBase::Answer::Translation' + if translation = App.KnowledgeBaseAnswerTranslation.fullLocal( elem.link_object_value ) + title: translation.title + id: translation.id + url: translation.uiUrl() + .filter (elem) -> + elem? + + render: -> + @html App.view('link/kb_answer')( + list: @linksForRendering() + ) + + @renderPopovers() + + @el.append(new App.SearchableAjaxSelect( + delegate: @ + useAjaxDetails: true + attribute: + id: 'link_kb_answer' + name: 'input' + placeholder: App.i18n.translateInline('Search...') + limit: 40 + object: 'KnowledgeBaseAnswerTranslation' + ajax: true + ).element()) + + @refreshElements() + @searchableSelect.addClass('hidden') + + didSubmit: => + @clearDelay('hideField') + @inputField.attr('disabled', true) + @saveToServer(@shadowField.val()) + + didBlur: (e) => + @delay( => + @setInputVisible(false) + , 200, 'hideField') + + add: -> + @shadowField.val('') + @inputField.attr('disabled', false).val('') + + @setInputVisible(true) + @inputField.focus() + + setInputVisible: (setInputVisible) -> + @searchableSelect.toggleClass('hidden', !setInputVisible) + @addButton.toggleClass('hidden', setInputVisible) + + saveToServer: (id) -> + @ajax( + id: "links_add_#{@object.id}_#{@object_type}" + type: 'GET' + url: "#{@apiPath}/links/add" + data: + link_type: 'normal' + link_object_target: 'Ticket' + link_object_target_value: @object.id + link_object_source: 'KnowledgeBase::Answer::Translation' + link_object_source_number: id + processData: true + success: (data, status, xhr) => + @fetch() + @setInputVisible(false) + error: (xhr, statusText, error) => + @setInputVisible(false) + @notify( + type: 'error' + msg: App.i18n.translateContent(xhr.responseJSON?.error || "Couldn't save changes") + removeAll: true + ) + ) diff --git a/app/assets/javascripts/app/lib/app_init/track.coffee b/app/assets/javascripts/app/lib/app_init/track.coffee index 217257356..e4434e9a9 100644 --- a/app/assets/javascripts/app/lib/app_init/track.coffee +++ b/app/assets/javascripts/app/lib/app_init/track.coffee @@ -28,7 +28,7 @@ class App.Track class _trackSingleton constructor: -> @trackId = "track-#{new Date().getTime()}-#{Math.floor(Math.random() * 99999)}" - @browser = App.Browser.detection() + @browser = App.Browser.detection() if App.Browser @data = [] # @url = 'http://localhost:3005/api/v1/ui' @url = 'https://log.zammad.com/api/v1/ui' diff --git a/app/assets/javascripts/app/lib/app_post/color.coffee b/app/assets/javascripts/app/lib/app_post/color.coffee new file mode 100644 index 000000000..b7d9e0e7c --- /dev/null +++ b/app/assets/javascripts/app/lib/app_post/color.coffee @@ -0,0 +1,140 @@ +# coffeelint: disable=camel_case_classes +class App.Color extends Spine.Controller + hsl: undefined + + elements: + '.js-input': 'input' + '.js-shadow': 'shadow' + '.js-swatch': 'swatch' + '.js-colorpicker-hue-saturation': 'hueSaturation' + '.js-colorpicker-lightness-plane': 'lightnessPlane' + '.js-colorpicker-saturation-gradient': 'saturationGradient' + '.js-colorpicker-circle': 'circle' + '.js-colorpicker-lightness': 'lightness' + '.js-colorpicker-hue-plane': 'huePlane' + '.js-colorpicker-slider': 'slider' + + events: + 'input .js-input': 'onInput' + 'mousedown .js-colorpicker-hue-saturation': 'onHueSaturationMousedown' + 'mousedown .js-colorpicker-lightness': 'onLightnessMousedown' + 'click .js-dropdown': 'stopPropagation' + + stopPropagation: (event) -> + event.stopPropagation() + + constructor: -> + super + @render() + + element: => + @el + + render: -> + @hsl = @rgbToHsl(@parseColor(@attribute.value)) + @html App.view('generic/color') + attribute: @attribute + hsl: @hsl + + onInput: -> + @update @input.val() + @output() + + update: (color) -> + @updateSwatch(color) + @hsl = @rgbToHsl(@parseColor(color)) + @renderPicker() + + updateSwatch: (color) -> + @swatch.css 'background-color', '' + @swatch.css 'background-color', color + + output: -> + hslString = @hslString(@hsl) + @input.val hslString + @updateSwatch hslString + @shadow.val @rgbToHex(@parseColor(hslString)) + + componentToHex: (c) -> + hex = c.toString(16) + if hex.length == 1 then '0' + hex else hex + + rgbToHex: (rgba) -> + '#' + @componentToHex(rgba[0]) + @componentToHex(rgba[1]) + @componentToHex(rgba[2]) + + parseColor: (color) -> + canvas = document.createElement('canvas') + canvas.width = canvas.height = 1 + ctx = canvas.getContext('2d') + ctx.clearRect(0, 0, 1, 1) + ctx.fillStyle = color + ctx.fillRect(0, 0, 1, 1) + ctx.getImageData(0, 0, 1, 1).data + + rgbToHsl: (rgb) -> + return [0, 0, 0] if !rgb + + r = rgb[0] / 255 + g = rgb[1] / 255 + b = rgb[2] / 255 + + max = Math.max(r, g, b) + min = Math.min(r, g, b) + l = (max + min) / 2 + + if (max == min) + h = s = 0 # achromatic + else + d = max - min + s = if l > 0.5 then d / (2 - max - min) else d / (max + min) + + h = switch + when r is max then (g - b) / d + (g < b ? 6 : 0) + when g is max then (b - r) / d + 2 + when b is max then (r - g) / d + 4 + + h /= 6 + + [h, s, l] + + hslString: -> + "hsl(#{Math.round(360 * @hsl[0])},#{Math.round(100 * @hsl[1])}%,#{Math.round(100 * @hsl[2])}%)" + + onHueSaturationMousedown: (event) -> + @offset = @hueSaturation.offset() + $(document).on 'mousemove.colorpicker', @onHueSaturationMousemove + $(document).on 'mouseup.colorpicker', @onMouseup + @onHueSaturationMousemove(event) + + onHueSaturationMousemove: (event) => + @hsl[0] = Math.max(0, Math.min(1, (event.pageX - @offset.left)/@hueSaturation.width())) + @hsl[1] = Math.max(0, Math.min(1, 1-(event.pageY - @offset.top)/@hueSaturation.height())) + @renderPicker() + @output() + + onLightnessMousedown: (event) -> + @offset = @lightness.offset() + $(document).on 'mousemove.colorpicker', @onLightnessMousemove + $(document).on 'mouseup.colorpicker', @onMouseup + @onLightnessMousemove(event) + + onLightnessMousemove: (event) => + @hsl[2] = Math.max(0, Math.min(1, 1-(event.pageY - @offset.top)/@lightness.height())) + @renderPicker() + @output() + + onMouseup: -> + $(document).off 'mousemove.colorpicker' + $(document).off 'mouseup.colorpicker' + + renderPicker: -> + @lightnessPlane.css 'background-color': "hsla(0,0%,#{if @hsl[2] > 0.5 then 100 else 0}%,#{2*Math.abs(@hsl[2]-0.5)})" + @saturationGradient.css 'background-image': "linear-gradient(transparent, hsl(0, 0%, #{@hsl[2]*100}%))" + @circle.css + left: @hsl[0]*100 +'%' + top: 100 - @hsl[1]*100 +'%' + borderColor: if @hsl[2] > 0.5 then 'black' else 'white' + @huePlane.css 'background-color': "hsl(#{@hsl[0]*360}, 100%, 50%)" + @slider.css top: 100 - @hsl[2]*100 +'%' + + diff --git a/app/assets/javascripts/app/lib/app_post/icon_picker.coffee b/app/assets/javascripts/app/lib/app_post/icon_picker.coffee new file mode 100644 index 000000000..20791ff84 --- /dev/null +++ b/app/assets/javascripts/app/lib/app_post/icon_picker.coffee @@ -0,0 +1,154 @@ +# coffeelint: disable=camel_case_classes +class App.IconPicker extends Spine.Controller + library: null + empty: false + columns: 8 + currentItem: null + + events: + 'focus .js-input': 'onFocus' + 'input .js-filter-icons': 'filterIcons' + 'click .js-filter-icons': 'stopPropagation' + 'click .js-pick': 'onIconClick' + 'mouseenter .js-pick': 'highlightItem' + 'shown.bs.dropdown': 'onPickerOpen' + 'hidden.bs.dropdown': 'onPickerClose' + 'focus .js-shadow': 'onShadowFocus' + + elements: + '.js-iconGrid': 'iconGrid' + '.js-noMatch': 'noMatch' + '.js-shadow': 'shadow' + '.js-input': 'input' + '.js-filter-icons': 'filter' + '.js-pick': 'icons' + + stopPropagation: (event) -> + event.stopPropagation() + + constructor: -> + super + @throttledRenderIcons = _.throttle(@renderIcons, 300) + @render() + App.Utils.loadIconFont(@attribute.iconset) + App.Utils.loadIconFontInfo @attribute.iconset, (icons) => + @library = icons + @renderIcons() + + element: => + @el + + render: -> + attributeValue = @attribute.value + @html App.view('generic/icon_picker') + attribute: @attribute + value: @attribute.value + + renderIcons: (filter) => + fragment = document.createDocumentFragment() + regex = new RegExp(filter, 'i') if filter + count = 0 + + _.each @library, (icon) => + if !filter || filter && (regex.test(icon.name) || icon.filter && _.some(icon.filter, (w) -> regex.test(w))) + count++ + fragment.appendChild $("
  • #{String.fromCharCode('0x'+ icon.unicode)}
  • ").get(0) + + if count + @iconGrid.html fragment + @empty = false + @refreshElements() + else + if not @empty + # show a random placeholder + next = Math.floor(Math.random() * @noMatch.length) + if next == @noMatch.filter('.is-active').index() + next = (next + 1) % @noMatch.length + @noMatch.removeClass('is-active').eq(next).addClass('is-active') + @empty = true + @iconGrid.empty() + + filterIcons: (event) => + @throttledRenderIcons event.currentTarget.value + + onIconClick: (event) -> + @pick event.currentTarget.getAttribute('data-unicode') + + pick: (unicode) -> + @shadow.val unicode + @input.text String.fromCharCode("0x#{unicode}") + @el.closest('form').trigger('input') + + # propergate focus to our visible input + onShadowFocus: -> + @input.focus() + + onPickerOpen: -> + @filter.focus() + @isOpen = true + + onPickerClose: -> + @isOpen = false + @filter.val '' + @renderIcons() + $(document).off 'keydown.icon_picker' + + onFocus: -> + $(document).on 'keydown.icon_picker', @navigate + + navigate: (event) => + switch event.keyCode + when 40 then @nudge event, 0, 1 # down + when 38 then @nudge event, 0, -1 # up + when 39 then @nudge event, 1 # right + when 37 then @nudge event, -1 # left + when 13 then @onEnter event + when 27 then @onEscape() + + onEscape: -> + @currentItem = null + @toggle() if @isOpen + + onEnter: (event) -> + if !@isOpen + return @toggle() + if @currentItem + @pick @currentItem.attr('data-unicode') + @toggle() + + toggle: -> + @$('[data-toggle="dropdown"]').dropdown('toggle') + + nudge: (event, x, y) -> + event.preventDefault() + if !@currentItem + selectedIndex = 0 + else + selectedIndex = @currentItem.index() + + distance = switch + when x > 0 then 1 + when x < 0 then -1 + when y > 0 then @columns + when y < 0 then -@columns + + if selectedIndex + distance >= @icons.length or selectedIndex + distance < 0 + # out of boundary + return + + selectedIndex += distance + @unhighlightCurrentItem() + + @currentItem = @icons.eq(selectedIndex) + @currentItem.addClass('is-active').get(0).scrollIntoView(behavior: 'instant') + + highlightItem: (event) => + @unhighlightCurrentItem() + @currentItem = $(event.currentTarget) + @currentItem.addClass('is-active') + + unhighlightCurrentItem: -> + return if !@currentItem + @currentItem.removeClass('is-active') + @currentItem = null + diff --git a/app/assets/javascripts/app/lib/app_post/iconset_picker.coffee b/app/assets/javascripts/app/lib/app_post/iconset_picker.coffee new file mode 100644 index 000000000..53a4e6563 --- /dev/null +++ b/app/assets/javascripts/app/lib/app_post/iconset_picker.coffee @@ -0,0 +1,78 @@ +# coffeelint: disable=camel_case_classes +class App.IconsetPicker extends Spine.Controller + sets: + FontAwesome: + name: 'Font Awesome' + version: '4.7' + website: 'https://fontawesome.com/v4.7.0/' + anticon: + name: 'Anticon' + version: '2.10' + website: 'https://2x.ant.design/components/icon/' + material: + name: 'Material' + version: '2.2.0' + website: 'https://material.io/icons/' + ionicons: + name: 'Ionicons' + version: '2.0.1' + website: 'https://ionicons.com/v2/' + 'Simple-Line-Icons': + name: 'Simple Line Icons' + version: '0.0.1' + website: 'https://simplelineicons.github.io/' + + elements: + '.js-set': 'setElements' + 'input': 'input' + + events: + 'click .js-set': 'pick' + # 'mouseenter .icon': 'flip' + + constructor: -> + super + @render() + + element: => + @el + + render: -> + @html App.view('generic/iconset_picker') + attribute: @attribute + sets: @sets + + for family, set of @sets + App.Utils.loadIconFont(family) + App.Utils.loadIconFontInfo family, @initializePreview.bind(@, family) + + initializePreview: (family, icons) -> + @sets[family].icons = icons + @renderPreview(family, icons) + + renderPreview: (family) -> + fragment = document.createDocumentFragment() + icons = _.shuffle(@sets[family].icons) + + for i in [0..(11*5-1)] + fragment.appendChild $("#{String.fromCharCode('0x'+ icons[i].unicode)}").get(0) + + @el.find("[data-family=\"#{family}\"] .js-preview").html fragment + + pick: (event) -> + family = $(event.currentTarget).attr('data-family') + @input.val family + @setElements.removeClass('is-active') + event.currentTarget.classList.add('is-active') + + flip: (event) -> + $icon = $(event.currentTarget) + family = $icon.closest('.js-set').attr('data-family') + + if $icon.hasClass('do-flash') + $icon.removeClass('do-flash') + # force redraw + $icon.get(0).offsetWidth + + $icon.text String.fromCharCode('0x'+ _.sample(@sets[family].icons).unicode) + $icon.addClass('do-flash') \ No newline at end of file diff --git a/app/assets/javascripts/app/lib/app_post/multi_locales.coffee b/app/assets/javascripts/app/lib/app_post/multi_locales.coffee new file mode 100644 index 000000000..d4360af56 --- /dev/null +++ b/app/assets/javascripts/app/lib/app_post/multi_locales.coffee @@ -0,0 +1,95 @@ +class App.MultiLocales extends App.Controller + events: + 'click .js-remove': 'remove' + 'click .js-primary': 'primary' + 'change .js-shadow': 'changeOnRow' + + constructor: -> + super + + @multiple_rows_supported = App.Config.get('kb_multi_lingual_support') + @rows = [] + @render() + + if @object + @listenTo @object, 'refresh', @parentObjectUpdated + + parentObjectUpdated: => + App.Delay.set => + @attribute.value = @object.attributes()[@attribute.name] + @render() + + render: -> + @html App.view('generic/multi_locales')(attribute: @attribute, vc: @) + + if Array.isArray(@attribute.value) + for locale in @attribute.value + @appendRow @renderRow(locale, @attribute.value.length == 1) + + if @multiple_rows_supported || !Array.isArray(@attribute.value) || @attribute.value.length == 0 + @appendRow @renderRow() + + renderRow: (kb_locale_attributes, solo = false) -> + kb_locale = App.KnowledgeBaseLocale.find kb_locale_attributes?.id + + new App.MultiLocalesRow( + attribute: @attribute + kb_locale: kb_locale + available_locales: @selectableLocales(kb_locale?.systemLocale()?.id) + solo: solo + ) + + selectableLocales: (self_value) -> + takenCodes = @$('.js-shadow') + .toArray() + .map (elem) -> $(elem).val() + .filter (elem) -> elem && elem != self_value + + App.Locale.all().filter (elem) -> + !takenCodes.includes(String(elem.id)) + + remove: (e) -> + domRow = $(e.currentTarget).closest('tr')[0] + row = _.find @rows, (elem) -> elem.el[0] == domRow + + if row?.primaryCheckbox.prop('checked') + return + else if row?.kb_locale?.id + row.toggleDelete() + else + row.el.remove() + @rows.splice @rows.indexOf(row), 1 + + @changeOnRow() + + primary: (e) -> + input = $(e.currentTarget).find('input') + + if input.attr('disabled') + return + + input.prop('checked', true) + + @changeOnRow() + + changeOnRow: (e) -> + if !@hasEmptyRow() && @multiple_rows_supported + @appendRow @renderRow() + + nonempty_rows = @rows.filter (row) -> row.selector.shadowInput.val() + + if nonempty_rows.length == 1 + nonempty_rows[0].updateButtons(true, true) + else + for row in nonempty_rows + row.updateButtons(false) + + for row in @rows + row.updateOptions( @selectableLocales(row.selector.shadowInput.val()) ) + + hasEmptyRow: -> + @$('.js-shadow').is (i, elem) -> !$(elem).val() + + appendRow: (row) -> + @rows.push row + @$('tbody').append row.el diff --git a/app/assets/javascripts/app/lib/app_post/multi_locales_row.coffee b/app/assets/javascripts/app/lib/app_post/multi_locales_row.coffee new file mode 100644 index 000000000..0191aefda --- /dev/null +++ b/app/assets/javascripts/app/lib/app_post/multi_locales_row.coffee @@ -0,0 +1,77 @@ +class App.MultiLocalesRow extends App.Controller + tag: 'tr' + + elements: + '.js-primary input': 'primaryCheckbox' + '.js-remove input': 'removeButton' + '.js-selectorContainer': 'selectorContainer' + + events: + 'change .js-shadow': 'change' + + constructor: -> + super + @el.data('kbLocaleId', @kb_locale?.id) + @render() + + render: -> + @html App.view('generic/multi_locales_row')( + attribute: @attribute + kb_locale: @kb_locale + ) + + value = @kb_locale?.systemLocale()?.id + + @_updateButtons(value, @solo , @kb_locale?.primary) + + @selector = @localesSelectBuild(@attribute.name, value, @selectorContainer) + @updateOptions(@available_locales) + + localesSelectBuild: (name, value, el) -> + new App.SearchableSelect( + el: el + attribute: + name: name + value: value + null: false + placeholder: 'Select locale:' + options: [] #formattedLocales + class: 'form-control--small' + ) + + updateOptions: (options) -> + value = @selector.shadowInput.val() # @selector.attribute.value + + formattedLocales = options + .map (elem) -> + { + name: elem.name + value: elem.id + selected: (elem.id + '') == value + } + + formattedLocales.sort (a, b) -> a.name.localeCompare(b.name) + + @selector.attribute.options = formattedLocales + @selector.render() + + updateButtons: (is_solo, is_primary = undefined) -> + if is_primary == undefined + is_primary = @primaryCheckbox[0].checked + + @_updateButtons(@selector.shadowInput.val(), is_solo, is_primary) + + _updateButtons: (value, is_solo, is_primary) -> + is_deleted = @el.hasClass('settings-list--deleted') + + @removeButton.attr('disabled', is_solo || !value || is_primary) + @primaryCheckbox.attr('disabled', is_solo || !value || is_deleted) + @primaryCheckbox.prop('checked' , is_primary) + + change: -> + @primaryCheckbox.attr 'value', @selector.shadowInput.val() + + toggleDelete: -> + @el.toggleClass('settings-list--deleted') + @removeButton.prop('checked', @el.hasClass('settings-list--deleted')) + @selector.el.toggleClass('u-unclickable') diff --git a/app/assets/javascripts/app/lib/app_post/popover_provider/kb_popover_provider.coffee b/app/assets/javascripts/app/lib/app_post/popover_provider/kb_popover_provider.coffee new file mode 100644 index 000000000..98667820d --- /dev/null +++ b/app/assets/javascripts/app/lib/app_post/popover_provider/kb_popover_provider.coffee @@ -0,0 +1,5 @@ +class App.KbPopoverProvider extends App.SingleObjectPopoverProvider + @templateName = 'kb_generic' + @includeData = false + displayTitleUsing: (object) -> + object.title diff --git a/app/assets/javascripts/app/lib/app_post/popover_provider/knowledge_base_answer_provider.coffee b/app/assets/javascripts/app/lib/app_post/popover_provider/knowledge_base_answer_provider.coffee new file mode 100644 index 000000000..804a902f8 --- /dev/null +++ b/app/assets/javascripts/app/lib/app_post/popover_provider/knowledge_base_answer_provider.coffee @@ -0,0 +1,5 @@ +class KnowledgeBaseAnswer extends App.KbPopoverProvider + @klass = App.KnowledgeBaseAnswerTranslation + @selectorCssClassPrefix = 'kb-answer' + +App.PopoverProvider.registerProvider('KnowledgeBaseAnswer', KnowledgeBaseAnswer) diff --git a/app/assets/javascripts/app/lib/app_post/popover_provider/knowledge_base_category_provider.coffee b/app/assets/javascripts/app/lib/app_post/popover_provider/knowledge_base_category_provider.coffee new file mode 100644 index 000000000..715f9921c --- /dev/null +++ b/app/assets/javascripts/app/lib/app_post/popover_provider/knowledge_base_category_provider.coffee @@ -0,0 +1,5 @@ +class KnowledgeBaseCategory extends App.KbPopoverProvider + @klass = App.KnowledgeBaseCategoryTranslation + @selectorCssClassPrefix = 'kb-category' + +App.PopoverProvider.registerProvider('KnowledgeBaseCategory', KnowledgeBaseCategory) diff --git a/app/assets/javascripts/app/lib/app_post/popover_provider/knowledge_base_provider.coffee b/app/assets/javascripts/app/lib/app_post/popover_provider/knowledge_base_provider.coffee new file mode 100644 index 000000000..88ab3781d --- /dev/null +++ b/app/assets/javascripts/app/lib/app_post/popover_provider/knowledge_base_provider.coffee @@ -0,0 +1,5 @@ +class KnowledgeBase extends App.KbPopoverProvider + @klass = App.KnowledgeBaseTranslation + @selectorCssClassPrefix = 'kb' + +App.PopoverProvider.registerProvider('KnowledgeBase', KnowledgeBase) diff --git a/app/assets/javascripts/app/lib/app_post/searchable_select.coffee b/app/assets/javascripts/app/lib/app_post/searchable_select.coffee index ab89960ee..0c4bb9669 100644 --- a/app/assets/javascripts/app/lib/app_post/searchable_select.coffee +++ b/app/assets/javascripts/app/lib/app_post/searchable_select.coffee @@ -115,7 +115,6 @@ class App.SearchableSelect extends Spine.Controller onDropdownShown: => @input.on 'click', @stopPropagation @highlightFirst() - $(document).on 'keydown.searchable_select', @navigate if @level > 0 @showSubmenu(@currentMenu) @isOpen = true @@ -123,7 +122,6 @@ class App.SearchableSelect extends Spine.Controller onDropdownHidden: => @input.off 'click', @stopPropagation @unhighlightCurrentItem() - $(document).off 'keydown.searchable_select' @isOpen = false if !@input.val() @@ -359,8 +357,10 @@ class App.SearchableSelect extends Spine.Controller onBlur: -> @clearAutocomplete() + @input.off 'keydown.searchable_select' onFocus: -> + @input.on 'keydown.searchable_select', @navigate textEnd = @input.val().length @input.prop('selectionStart', textEnd) @input.prop('selectionEnd', textEnd) @@ -372,8 +372,9 @@ class App.SearchableSelect extends Spine.Controller onShadowChange: -> value = @shadowInput.val() - for option in @attribute.options - option.selected = (option.value + '') == value # makes sure option value is always a string + if Array.isArray(@attribute.options) + for option in @attribute.options + option.selected = (option.value + '') == value # makes sure option value is always a string onInput: (event) => @toggle() if not @isOpen diff --git a/app/assets/javascripts/app/lib/app_post/utils.coffee b/app/assets/javascripts/app/lib/app_post/utils.coffee index 89b87584f..5610be987 100644 --- a/app/assets/javascripts/app/lib/app_post/utils.coffee +++ b/app/assets/javascripts/app/lib/app_post/utils.coffee @@ -1213,6 +1213,37 @@ class App.Utils ctx.drawImage(img, 0, 0) canvas.toDataURL('image/png') + # works asynchronously to make sure images are loaded before converting to base64 + # output is passed to callback + @htmlImage2DataUrlAsync: (html, callback) -> + output = @_checkTypeOf("
    #{html}
    ") + + # coffeelint: disable=indentation + elems = output + .find('img') + .toArray() + .filter (elem) -> !elem.src.match(/^(data|cid):/i) + # coffeelint: enable=indentation + + cacheOrDone = -> + if (nextElem = elems.pop()) + App.Utils._htmlImage2DataUrlAsync(nextElem, (data) -> + $(nextElem).attr('src', data) + cacheOrDone() + ) + else + callback(output[0].innerHTML) + + cacheOrDone() + + @_htmlImage2DataUrlAsync: (originalImage, callback) -> + imageCache = new Image() + imageCache.onload = -> + data = App.Utils._htmlImage2DataUrl(originalImage) + callback(data) + + imageCache.src = originalImage.src + @baseUrl: -> fqdn = App.Config.get('fqdn') http_type = App.Config.get('http_type') diff --git a/app/assets/javascripts/app/lib/app_post/z_searchable_ajax_select.coffee b/app/assets/javascripts/app/lib/app_post/z_searchable_ajax_select.coffee index 1176c69cb..117dc6694 100644 --- a/app/assets/javascripts/app/lib/app_post/z_searchable_ajax_select.coffee +++ b/app/assets/javascripts/app/lib/app_post/z_searchable_ajax_select.coffee @@ -1,4 +1,9 @@ class App.SearchableAjaxSelect extends App.SearchableSelect + constructor: -> + super + + # create cache + @searchResultCache = {} onInput: (event) => super @@ -7,67 +12,62 @@ class App.SearchableAjaxSelect extends App.SearchableSelect # e.g. Ticket to ticket or AnotherObject to another_object objectString = underscored(@options.attribute.object) - # create common accessors - @apiPath = App.Config.get('api_path') + query = @input.val() - # create cache and cache key - @searchResultCache = @searchResultCache || {} - - cacheKey = "#{objectString}+#{@query}" + # create cache key + cacheKey = "#{objectString}+#{query}" # use cache for search result if @searchResultCache[cacheKey] - return @renderResponse( @searchResultCache[cacheKey] ) + App.Ajax.abort @options.attribute.id + @renderResponse @searchResultCache[cacheKey], query + return # add timeout for loader icon - clearTimeout @loaderTimeoutId - @loaderTimeoutId = setTimeout @showLoader, 1000 + if !@loaderTimeoutId + @loaderTimeoutId = setTimeout @showLoader, 1000 - # start search request and update options - App.Ajax.request( + attributes = id: @options.attribute.id type: 'GET' - url: "#{@apiPath}/search/#{objectString}" + url: "#{App.Config.get('api_path')}/search/#{objectString}" data: - query: @query + query: query limit: @options.attribute.limit processData: true success: (data, status, xhr) => # cache search result @searchResultCache[cacheKey] = data - @renderResponse(data) - ) + @renderResponse(data, query) - renderResponse: (data) => + # if delegate is given and provides getAjaxAttributes method, try to extend ajax call + # this is needed for autocompletion field in KB answer-to-answer linking to submit search context + if @delegate?.getAjaxAttributes + attributes = @delegate?.getAjaxAttributes?(@, attributes) + + # start search request and update options + App.Ajax.request(attributes) + + renderResponse: (data, originalQuery) => # clear timout and remove loader icon clearTimeout @loaderTimeoutId + @loaderTimeoutId = undefined @el.removeClass('is-loading') # load assets App.Collection.loadAssets(data.assets) # get options from search result - options = [] - for object in data.result - if object.type is 'Ticket' - ticket = App.Ticket.find(object.id) - data = - name: "##{ticket.number} - #{ticket.title}" - value: ticket.id - options.push data - else if object.type is 'User' - user = App.User.find( object.id ) - data = - name: "#{user.displayName()}" - value: user.id - options.push data - else if object.type is 'Organization' - organization = App.Organization.find(object.id) - data = - name: "#{organization.displayName()}" - value: organization.id - options.push data + options = data + .result + .map (elem) => + # use search results directly to avoid loading KB assets in Ticket view + if @useAjaxDetails + @renderResponseItemAjax(elem, data) + else + @renderResponseItem(elem) + .filter (elem) -> elem? # fill template with gathered options @optionsList.html @renderOptions options @@ -76,7 +76,32 @@ class App.SearchableAjaxSelect extends App.SearchableSelect @refreshElements() # execute filter - @filterByQuery @query + @filterByQuery originalQuery + + renderResponseItemAjax: (elem, data) -> + result = _.find(data.details, (detailElem) -> detailElem.type == elem.type and detailElem.id == elem.id) + + if result + { + name: result.title + value: elem.id + } + + renderResponseItem: (elem) -> + object = App[elem.type.replace(/::/g, '')]?.find(elem.id) + + if !object + return + + name = if object instanceof App.Ticket + "##{object.number} - #{object.title}" + else + object.displayName() + + { + name: name + value: object.id + } showLoader: => @el.addClass('is-loading') diff --git a/app/assets/javascripts/app/lib/base/html5Upload.js b/app/assets/javascripts/app/lib/base/html5Upload.js index 9841e9672..b2eabd33f 100644 --- a/app/assets/javascripts/app/lib/base/html5Upload.js +++ b/app/assets/javascripts/app/lib/base/html5Upload.js @@ -97,11 +97,15 @@ } }; showDropZone = function(dropContainer) { + $(dropContainer).trigger('html5Upload.dropZone.show') + if ( !$(dropContainer).find('.article-content, .richtext').hasClass('is-dropTarget') ) { $(dropContainer).find('.article-content, .richtext').addClass('is-dropTarget') } } hideDropZone = function(dropContainer) { + $(dropContainer).trigger('html5Upload.dropZone.hide') + if ( $(dropContainer).find('.article-content, .richtext').hasClass('is-dropTarget') ) { $(dropContainer).find('.article-content, .richtext').removeClass('is-dropTarget') } diff --git a/app/assets/javascripts/app/lib/base/jquery.textmodule.js b/app/assets/javascripts/app/lib/base/jquery.textmodule.js index a7503b891..0fa0db19b 100644 --- a/app/assets/javascripts/app/lib/base/jquery.textmodule.js +++ b/app/assets/javascripts/app/lib/base/jquery.textmodule.js @@ -68,18 +68,18 @@ if (e.keyCode === 13) { e.preventDefault() e.stopPropagation() - var id = this.$widget.find('.dropdown-menu li.is-active').data('id') + var elem = this.$widget.find('.dropdown-menu li.is-active')[0] // as fallback use hovered element - if (!id) { - id = this.$widget.find('.dropdown-menu li:hover').data('id') + if (!elem) { + elem = this.$widget.find('.dropdown-menu li:hover')[0] } // as fallback first element - if (!id) { - id = this.$widget.find('.dropdown-menu li:first-child').data('id') + if (!elem) { + elem = this.$widget.find('.dropdown-menu li:first-child')[0] } - this.take(id) + this.take(elem) return } @@ -132,8 +132,9 @@ // backspace if (e.keyCode === 8 && this.buffer) { + var trigger = this.findTrigger(this.buffer) // backspace + buffer === :: -> close textmodule - if (this.buffer === '::') { + if (trigger && trigger.trigger === this.buffer) { this.close(true) e.preventDefault() return @@ -143,7 +144,7 @@ var length = this.buffer.length this.buffer = this.buffer.substr(0, length-1) this.log('BS backspace', this.buffer) - this.result(this.buffer.substr(2, length-1)) + this.result(trigger) } } @@ -159,41 +160,53 @@ // arrow keys if (e.keyCode === 37 || e.keyCode === 38 || e.keyCode === 39 || e.keyCode === 40) return - // observer other second key - if (this.buffer === ':' && String.fromCharCode(e.which) !== ':') { - this.buffer = '' - } + var newChar = String.fromCharCode(e.which) - // oberserve second : - if (this.buffer === ':' && String.fromCharCode(e.which) === ':') { - this.buffer = this.buffer + ':' + // observe other keys + if (this.hasAvailableTriggers(this.buffer)) { + if(this.hasAvailableTriggers(this.buffer + newChar)) { + this.buffer = this.buffer + newChar + } else if (!this.findTrigger(this.buffer)) { + this.buffer = '' + } } // oberserve first : - if (!this.buffer && String.fromCharCode(e.which) === ':') { - this.buffer = this.buffer + ':' + if (!this.buffer && this.hasAvailableTriggers(newChar)) { + this.buffer = this.buffer + newChar } - if (this.buffer && this.buffer.substr(0,2) === '::') { - - var sign = String.fromCharCode(e.which) - if ( sign && sign !== ':' && e.which != 8 ) { // 8 == backspace - this.buffer = this.buffer + sign - //this.log('BUFF ADD', sign, this.buffer, sign.length, e.which) - } + var trigger = this.findTrigger(this.buffer) + if (trigger) { this.log('BUFF HINT', this.buffer, this.buffer.length, e.which, String.fromCharCode(e.which)) if (!this.isActive()) { this.open() } - this.result(this.buffer.substr(2, this.buffer.length)) + this.result(trigger) } } + // check if at least one trigger is available with the given prefix + Plugin.prototype.hasAvailableTriggers = function(prefix) { + var result = _.find(this.helpers, function(helper) { + var trigger = helper.trigger + return trigger.substr(0, prefix.length) == prefix.substr(0, trigger.length) + }) + + return result != undefined + } + + // find a matching trigger + Plugin.prototype.findTrigger = function(string) { + return _.find(this.helpers, function(helper) { + return helper.trigger == string.substr(0, helper.trigger.length) + }) + } + // create base template Plugin.prototype.renderBase = function() { - this.untouched = true this.$element.after('') this.$widget = this.$element.next() this.$widget.on('mousedown', 'li', $.proxy(this.onEntryClick, this)) @@ -344,27 +357,27 @@ Plugin.prototype.onEntryClick = function(event) { event.preventDefault() - var id = $(event.currentTarget).data('id') - this.take(id) + this.take(event.currentTarget) } // select text module and insert into text - Plugin.prototype.take = function(id) { - if (!id) { + Plugin.prototype.take = function(elem) { + if (!elem) { this.close(true) return } - for (var i = 0; i < this.collection.length; i++) { - var item = this.collection[i] - if (item.id == id) { - var content = item.content - this.cutInput() - this.paste(content) - this.close(true) - return - } + + var trigger = this.findTrigger(this.buffer) + + if (trigger) { + var _this = this; + + trigger.renderValue(this, elem, function(text) { + _this.cutInput() + _this.paste(text) + _this.close(true) + }) } - return } Plugin.prototype.getFirstRange = function() { @@ -381,47 +394,33 @@ } // render result - Plugin.prototype.result = function(term) { - var _this = this - var result = _.filter(this.collection, function(item) { - var reg = new RegExp(term, 'i') - if (item.name && item.name.match(reg)) { - return item - } - if (item.keywords && item.keywords.match(reg)) { - return item - } - return - }) + Plugin.prototype.result = function(trigger) { + if (!trigger) return - result.reverse() + var term = this.buffer.substr(trigger.trigger.length, this.buffer.length) + trigger.renderResults(this, term) + } - this.$widget.find('ul').html('') - this.log('result', term, result) + Plugin.prototype.emptyResultsContainer = function() { + this.$widget.find('ul').empty() + } - var elements = $() - - for (var i = 0; i < result.length; i++) { - var item = result[i] - var element = $('
  • ') - element.attr('data-id', item.id) - element.text(item.name) - element.addClass('u-clickable u-textTruncate') - if (i == result.length-1) { - element.addClass('is-active') - } - if (item.keywords) { - element.append($('').text(item.keywords)) - } - elements = elements.add(element) - } - - this.$widget.find('ul').append(elements).scrollTop(9999) + Plugin.prototype.appendResults = function(collection) { + this.$widget.find('ul').append(collection).scrollTop(9999) + this.afterResultRendering() + } + Plugin.prototype.afterResultRendering = function() { // keep the width of the dropdown the same even when longer items got filtered out - if(this._fixedWidth && this.untouched){ - this.$widget.find('ul').css('width', this.$widget.find('ul').width()); - this.untouched = false; + if(this._fixedWidth){ + var elem = this.$widget.find('ul') + + var currentMinWidth = parseInt(elem.css('min-width')) + var realWidth = elem.width() + + if(!currentMinWidth || realWidth > currentMinWidth) { + elem.css('min-width', realWidth + 'px') + } } this.movePosition() @@ -445,4 +444,144 @@ }); } -}(jQuery, window)); \ No newline at end of file + function Collection() {} + + Collection.renderValue = function(textmodule, elem, callback) { + var id = $(elem).data('id') + var item = _.find(textmodule.collection, function(elem) { return elem.id == id }) + + if (!item) return + + callback(item.content) + } + + Collection.renderResults = function(textmodule, term) { + var reg = new RegExp(term, 'i') + var result = textmodule.collection.filter(function(item) { + return (item.name && item.name.match(reg)) || (item.keywords && item.keywords.match(reg)) + }) + result.reverse() + + textmodule.emptyResultsContainer() + + var elements = result.map(function(elem, index, array){ + var element = $('
  • ') + .attr('data-id', elem.id) + .text(elem.name) + .addClass('u-clickable u-textTruncate') + + if (index == array.length-1) { + element.addClass('is-active') + } + + if (elem.keywords) { + element.append($('').text(elem.keywords)) + } + + return element + }) + + textmodule.appendResults(elements) + } + + Collection.trigger = '::' + + function KbAnswer() {} + + KbAnswer.renderValue = function(textmodule, elem, callback) { + textmodule.emptyResultsContainer() + + var element = $('
  • ').text(App.i18n.translateInline('Please wait...')) + textmodule.appendResults(element) + + App.Ajax.request({ + id: 'textmoduleKbAnswer', + type: 'GET', + url: $(elem).data('url'), + success: function(data, status, xhr) { + App.Collection.loadAssets(data.assets) + + var translation = App.KnowledgeBaseAnswerTranslation.find($(elem).data('id')) + + var body = translation.content().bodyWithPublicURLs() + + App.Utils.htmlImage2DataUrlAsync(body, function(output){ + callback(output) + }) + }, + error: function(xhr) { + callback('') + } + }) + } + + KbAnswer.renderResults = function(textmodule, term) { + textmodule.emptyResultsContainer() + + if(!term) { + var element = $('
  • ').text(App.i18n.translateInline('Start typing to search in Knowledge Base...')) + textmodule.appendResults(element) + + return + } + + var element = $('
  • ').text(App.i18n.translateInline('Loading...')) + textmodule.appendResults(element) + + App.Delay.set(function() { + App.Ajax.request({ + id: 'textmoduleKbAnswer', + type: 'POST', + url: App.Config.get('api_path') + '/knowledge_bases/search', + data: JSON.stringify({ + 'query': term, + 'flavor': 'agent', + 'index': 'KnowledgeBase::Answer::Translation', + 'url_type': 'agent', + 'highlight_enabled': false + }), + processData: true, + success: function(data, status, xhr) { + textmodule.emptyResultsContainer() + + var items = data + .result + .map(function(elem){ + if(result = _.find(data.details, function(detailElem) { return detailElem.type == elem.type && detailElem.id == elem.id })) { + return { + 'name': result.title, + 'value': elem.id, + 'url': result.url + } + } + }) + .filter(function(elem){ return elem != undefined }) + .map(function(elem, index, array) { + var element = $('
  • ') + .attr('data-id', elem.value) + .attr('data-url', elem.url) + .text(elem.name) + .addClass('u-clickable u-textTruncate') + + if (index == array.length-1) { + element.addClass('is-active') + } + + return element + }) + + if(items.length == 0) { + items.push($('
  • ').text(App.i18n.translateInline('No results found'))) + } + + textmodule.appendResults(items) + } + }) + }, 200, 'textmoduleKbAnswerDelay', 'textmodule') + } + + KbAnswer.trigger = '??' + + Plugin.prototype.helpers = [Collection, KbAnswer] + +}(jQuery, window)); diff --git a/app/assets/javascripts/app/lib/bootstrap/modal.js b/app/assets/javascripts/app/lib/bootstrap/modal.js index 8a05b8585..db78f289b 100644 --- a/app/assets/javascripts/app/lib/bootstrap/modal.js +++ b/app/assets/javascripts/app/lib/bootstrap/modal.js @@ -83,8 +83,8 @@ .show() .scrollTop(0) - if (that.options.backdrop) that.adjustBackdrop() that.adjustDialog() + if (that.options.backdrop) that.adjustBackdrop() if (transition) { that.$element[0].offsetWidth // force reflow @@ -241,8 +241,8 @@ // these following methods are used to handle overflowing modals Modal.prototype.handleUpdate = function () { - if (this.options.backdrop) this.adjustBackdrop() this.adjustDialog() + if (this.options.backdrop) this.adjustBackdrop() } Modal.prototype.adjustBackdrop = function () { diff --git a/app/assets/javascripts/app/lib/bootstrap/tooltip.js b/app/assets/javascripts/app/lib/bootstrap/tooltip.js index cda147d9c..062f5cf7c 100644 --- a/app/assets/javascripts/app/lib/bootstrap/tooltip.js +++ b/app/assets/javascripts/app/lib/bootstrap/tooltip.js @@ -33,12 +33,14 @@ animation: true, placement: 'top', selector: false, + backdrop: false, template: '', trigger: 'hover focus', title: '', delay: 0, html: false, container: false, + theme: 'light', viewport: { selector: 'body', padding: 0 @@ -161,9 +163,12 @@ var tipId = this.getUID(this.type) this.setContent() - $tip.attr('id', tipId) + $tip.attr('id', tipId).attr('data-theme', this.options.theme) this.$element.attr('aria-describedby', tipId) + if(this.options.backdrop) + this.$tip.on('click.bs.tooltip.stopPropagation', function(event){ event.stopPropagation() }) + if (this.options.animation) $tip.addClass('fade') var placement = typeof this.options.placement == 'function' ? @@ -210,6 +215,8 @@ var prevHoverState = that.hoverState that.$element.trigger('shown.bs.' + that.type) that.hoverState = null + if(that.options.backdrop) + $(document).one('click.bs.tooltip', function(){ that.hide() }) if (prevHoverState == 'out') that.leave(that) } @@ -436,6 +443,9 @@ clearTimeout(this.timeout) this.hide(function () { that.$element.off('.' + that.type).removeData('bs.' + that.type) + + if(that.options.backdrop) + that.$tip.off('click.bs.tooltip.stopPropagation') }) } diff --git a/app/assets/javascripts/app/lib/core/jquery-ui-1.11.4.js b/app/assets/javascripts/app/lib/core/jquery-ui-1.11.4.js index 7623214af..e53e8877a 100644 --- a/app/assets/javascripts/app/lib/core/jquery-ui-1.11.4.js +++ b/app/assets/javascripts/app/lib/core/jquery-ui-1.11.4.js @@ -1,6 +1,6 @@ -/*! jQuery UI - v1.11.4 - 2016-03-12 +/*! jQuery UI - v1.11.4 - 2017-10-12 * http://jqueryui.com -* Includes: core.js, widget.js, mouse.js, position.js, sortable.js, accordion.js, autocomplete.js, menu.js +* Includes: core.js, widget.js, mouse.js, position.js, draggable.js, droppable.js, sortable.js, accordion.js, autocomplete.js, menu.js * Copyright jQuery Foundation and other contributors; Licensed MIT */ (function( factory ) { @@ -1549,6 +1549,1520 @@ $.ui.position = { var position = $.ui.position; +/*! + * jQuery UI Draggable 1.11.4 + * http://jqueryui.com + * + * Copyright jQuery Foundation and other contributors + * Released under the MIT license. + * http://jquery.org/license + * + * http://api.jqueryui.com/draggable/ + */ + + +$.widget("ui.draggable", $.ui.mouse, { + version: "1.11.4", + widgetEventPrefix: "drag", + options: { + addClasses: true, + appendTo: "parent", + axis: false, + connectToSortable: false, + containment: false, + cursor: "auto", + cursorAt: false, + grid: false, + handle: false, + helper: "original", + iframeFix: false, + opacity: false, + refreshPositions: false, + revert: false, + revertDuration: 500, + scope: "default", + scroll: true, + scrollSensitivity: 20, + scrollSpeed: 20, + snap: false, + snapMode: "both", + snapTolerance: 20, + stack: false, + zIndex: false, + + // callbacks + drag: null, + start: null, + stop: null + }, + _create: function() { + + if ( this.options.helper === "original" ) { + this._setPositionRelative(); + } + if (this.options.addClasses){ + this.element.addClass("ui-draggable"); + } + if (this.options.disabled){ + this.element.addClass("ui-draggable-disabled"); + } + this._setHandleClassName(); + + this._mouseInit(); + }, + + _setOption: function( key, value ) { + this._super( key, value ); + if ( key === "handle" ) { + this._removeHandleClassName(); + this._setHandleClassName(); + } + }, + + _destroy: function() { + if ( ( this.helper || this.element ).is( ".ui-draggable-dragging" ) ) { + this.destroyOnClear = true; + return; + } + this.element.removeClass( "ui-draggable ui-draggable-dragging ui-draggable-disabled" ); + this._removeHandleClassName(); + this._mouseDestroy(); + }, + + _mouseCapture: function(event) { + var o = this.options; + + this._blurActiveElement( event ); + + // among others, prevent a drag on a resizable-handle + if (this.helper || o.disabled || $(event.target).closest(".ui-resizable-handle").length > 0) { + return false; + } + + //Quit if we're not on a valid handle + this.handle = this._getHandle(event); + if (!this.handle) { + return false; + } + + this._blockFrames( o.iframeFix === true ? "iframe" : o.iframeFix ); + + return true; + + }, + + _blockFrames: function( selector ) { + this.iframeBlocks = this.document.find( selector ).map(function() { + var iframe = $( this ); + + return $( "
    " ) + .css( "position", "absolute" ) + .appendTo( iframe.parent() ) + .outerWidth( iframe.outerWidth() ) + .outerHeight( iframe.outerHeight() ) + .offset( iframe.offset() )[ 0 ]; + }); + }, + + _unblockFrames: function() { + if ( this.iframeBlocks ) { + this.iframeBlocks.remove(); + delete this.iframeBlocks; + } + }, + + _blurActiveElement: function( event ) { + var document = this.document[ 0 ]; + + // Only need to blur if the event occurred on the draggable itself, see #10527 + if ( !this.handleElement.is( event.target ) ) { + return; + } + + // support: IE9 + // IE9 throws an "Unspecified error" accessing document.activeElement from an