import { Component, markup, xml } from "@odoo/owl"; import { registry } from "@web/core/registry"; import { patch } from "@web/core/utils/patch"; import { stepUtils } from "@web_tour/tour_utils"; import { appendArticleLink, embeddedViewPatchFunctions, endKnowledgeTour, openPowerbox, } from "../knowledge_tour_utils.js"; import { EmbeddedVideoComponent } from "@html_editor/others/embedded_components/backend/video/video"; import { VideoSelector } from "@html_editor/main/media/media_dialog/video_selector"; import { HtmlField } from "@html_editor/fields/html_field"; import { Editor } from "@html_editor/editor"; /** * This is a global knowledge tour testing commands and their usage. * * If you need to edit this tour, here are a few recommendations: * - Try to keep the steps light and test specifically your command * - Keep the commands modifying the main article at the top * - Ideally, those commands should NOT leave this article, and just modify its content * - Embed view commands are grouped together right after the other commands * - If you need to test something that leaves the article to test your command usage * (go into another menu, switch article, ...), it's better to put it after everything else, * in the "MISC" section. * Indeed, trying to input commands when rapidly switching from another view / article can create * some race conditions with the editor, leading to hard-to-debug issues * (e.g: "Component is destroyed"). */ //------------------------------------------------------------------------------ // UTILS //------------------------------------------------------------------------------ const embeddedViewPatchUtil = embeddedViewPatchFunctions(); const embedViewSelector = (embedViewName) => { return `[data-embedded="view"]:has( .o_last_breadcrumb_item:contains("${embedViewName}"))`; }; const commonKanbanSteps = (embedViewName) => { return [ { // scroll to the embedded view to load it trigger: embedViewSelector(embedViewName), run: function () { this.anchor.scrollIntoView(); }, }, { // wait for the kanban view to be mounted trigger: `${embedViewSelector(embedViewName)} .o_kanban_renderer`, }, ]; }; let articleId; let plugin; const unpatchHtmlField = patch(HtmlField.prototype, { setup() { super.setup(); articleId = this.props.record.resId; }, }); const unpatchEditor = patch(Editor.prototype, { preparePlugins() { super.preparePlugins(); plugin = this.plugins.find((p) => p.insertEmbeddedView); }, }); //------------------------------------------------------------------------------ // TOUR STEPS - KNOWLEDGE COMMANDS //------------------------------------------------------------------------------ // COMMAND: /article // WARNING: uses the legacy editor powerbox. const articleCommandSteps = [ { // open the command bar trigger: `[name="body"] .odoo-editor-editable > p:last-child`, run: function () { openPowerbox(this.anchor); }, }, { // click on the /article command trigger: '.o-we-powerbox .o-we-command-name:contains(Article)', run: 'click', }, { trigger: ".o_article_search_input input", run: "edit LinkedArticle", }, { trigger: `.o_article_search_item .o_article_search_name:contains(LinkedArticle)`, run: 'click', }, { trigger: `[name="body"] .odoo-editor-editable > p a:contains(LinkedArticle)`, }, ]; // COMMAND: /file const fileCommandSteps = [{ // open the command bar trigger: '.odoo-editor-editable > p', run: function () { openPowerbox(this.anchor); }, }, { // click on the /media command trigger: '.o-we-command-name:contains("Media")', run: 'click', }, { // Pick the "Documents" tab trigger: '.o_select_media_dialog .nav-tabs .nav-link:contains("Documents")', run: 'click', }, { // click on the first item of the modal trigger: '.o_existing_attachment_cell:contains(Onboarding) .o_button_area', run: 'click' }, { // wait for the block to appear in the editor trigger: "[data-embedded='file'] span.o_file_image a", run: 'click', }, { trigger: '.o-FileViewer-view:iframe body:contains(Content)', }, { trigger: '.o-FileViewer-headerButton[aria-label="Close"]', run: 'click', }, { trigger: ".o_file_name_container:contains(Onboarding)", run: function() { this.anchor.dispatchEvent(new Event("focus")); } }, { trigger: ".o_file_name_container + input", run: function() { this.anchor.value = "Renamed"; this.anchor.dispatchEvent(new Event("blur")); }, }, { trigger: "span.o_file_name", run: function () { // specifically test that there is no zeroWidthSpace character in the // name that would be added by the editor const currentName = this.anchor.textContent; if (currentName !== "Renamed") { throw new Error(`The new file name was expected to be: "Renamed", but the actual value is: "${currentName}"`); } } }]; // COMMAND: /index const indexCommandSteps = [{ // open the command bar trigger: '.odoo-editor-editable > p', run: function () { openPowerbox(this.anchor); }, }, { // click on the /index command trigger: '.o-we-command-name:contains("Index")', run: 'click', }, { // wait for the block to appear in the editor trigger: '[data-embedded="articleIndex"]', }, { // click on the refresh button trigger: '[data-embedded="articleIndex"] button[title="Update"]', run: 'click', }, { // click on the switch mode button trigger: '[data-embedded="articleIndex"] button[title="Switch Mode"]', run: 'click', }]; // COMMAND: /toc (table of contents) const tocCommandSteps = [{ // open the command bar trigger: '.odoo-editor-editable > p', run: function () { openPowerbox(this.anchor); }, }, { // click on the /toc command trigger: '.o-we-command-name:contains("Table Of Content")', run: 'click', }, { // wait for the block to appear in the editor trigger: '[data-embedded="tableOfContent"]', }, { // insert a few titles in the editor trigger: '.odoo-editor-editable > p', run: function () { const toCreate = [ ["h1", "Title 1"], ["h2", "Title 1.1"], ["h3", "Title 1.1.1"], ["h2", "Title 1.2"], ]; toCreate.forEach((el) => { const elem = document.createElement(el[0]); elem.textContent = el[1]; this.anchor.appendChild(elem); }) this.anchor.dispatchEvent(new Event("input", { bubbles: true })); }, }, { // click on the header to close the toc trigger: '[data-embedded="tableOfContent"] div:first', run: 'click', }, { // check that the toc content is not visible trigger: '[data-embedded="tableOfContent"] .o_embedded_toc_content:not(:visible)', }, { // click on the header again to open the toc trigger: '[data-embedded="tableOfContent"] div:first', run: 'click', }, { // click on the h1 anchor link generated by the toc trigger: '.o_embedded_toc_link_depth_0', run: 'click', }, { // open the options dropdown trigger: '.o_knowledge_header .dropdown-toggle', run: 'click', }, { // switch to locked (readonly) mode trigger: '.o_knowledge_options_dropdown .dropdown-item:contains("Lock")', run: 'click', }, { // check that we are in readonly mode trigger: '.o_field_html .o_readonly', }, { // check that the content of the toc is not duplicated trigger: '[data-embedded="tableOfContent"]', run: function () { if (this.anchor.querySelectorAll(".o_embedded_toc_content").length !== 1) { throw new Error('The table of content group of links should be present exactly once (not duplicated)'); } }, }, { // click on the h1 anchor link generated by the toc trigger: '.o_embedded_toc_link_depth_0', run: 'click', }, { // open the options dropdown trigger: '.o_knowledge_header .dropdown-toggle', run: 'click', }, { // unlock the article trigger: '.o_knowledge_options_dropdown .dropdown-item:contains("Unlock")', run: 'click', }, { // check that we are in edit mode trigger: '.o_field_html .odoo-editor-editable', }]; // COMMAND: /clipboard const clipboardCommandSteps = [{ // go to the custom article trigger: '.o_article .o_article_name:contains("EditorCommandsArticle")', run: "click", }, { // wait for article to be correctly loaded trigger: '.o_hierarchy_article_name input:value("EditorCommandsArticle")', }, { // open the command bar trigger: '.odoo-editor-editable > p', run: function () { openPowerbox(this.anchor); }, }, { // click on the /clipboard command trigger: '.o-we-command-name:contains("Clipboard")', run: 'click', }, { // wait for the block to appear in the editor trigger: "[data-embedded='clipboard']", }, { // enter text into the clipboard template trigger: "[data-embedded-editable='clipboardContent'] > p", run: "editor Hello world", }, { // verify that the text was correctly inserted trigger: "[data-embedded-editable='clipboardContent'] > p:contains(Hello world)", }]; // COMMAND: /video const YoutubeVideoId = "Rk1MYMPDx3s"; let unpatchVideoEmbed; let unpatchVideoSelector; class MockedVideoIframe extends Component { static template = xml`
`; static props = ["src"]; } const videoCommandSteps = [{ // patch the components trigger: "body", run: () => { unpatchVideoEmbed = patch(EmbeddedVideoComponent, { template: xml`
` }); unpatchVideoSelector = patch(VideoSelector.components, { ...VideoSelector.components, VideoIframe: MockedVideoIframe }); }, }, { // open the command bar trigger: ".odoo-editor-editable > p", run: function () { openPowerbox(this.anchor); }, }, { // click on the /media command trigger: '.o-we-command-name:contains("Media")', run: "click", }, { // open the Videos tab trigger: '.modal-body .nav-link:contains("Videos")', run: "click", }, { content: "Enter a video URL", trigger: ".modal-body #o_video_text", run: `edit https://www.youtube.com/watch?v=${YoutubeVideoId}`, }, { content: "Wait for preview to appear", trigger: `.o_video_iframe_src:contains("//www.youtube.com/embed/${YoutubeVideoId}?rel=0&autoplay=0")`, }, { content: "Confirm selection", trigger: '.modal-footer button:contains("Add")', run: "click", }, { trigger: `[data-embedded="video"] .o_video_iframe_src:contains("https://www.youtube.com/embed/${YoutubeVideoId}?rel=0&autoplay=0")`, }, { // wait for the block to appear in the editor trigger: '[data-embedded="video"]', run: function () { // ensure that the video element is not in the DOM, to avoid // rendering it in the readonly tour. this.anchor.remove(); }, }]; const unpatchSteps = [{ // unpatch the components trigger: "body", run: () => { unpatchVideoEmbed(); unpatchVideoSelector(); embeddedViewPatchUtil.after(); unpatchHtmlField(); unpatchEditor(); }, }]; //------------------------------------------------------------------------------ // TOUR STEPS - KNOWLEDGE EMBED VIEWS //------------------------------------------------------------------------------ const embeddedViewPatchSteps = [{ trigger: 'body', run: embeddedViewPatchUtil.before, }]; // EMBED VIEW: /list let embeddedProps; const embedListName = "List special chars *()!'<>~"; const listCommandSteps = [{ // open the command bar trigger: '.odoo-editor-editable > p', run: function () { openPowerbox(this.anchor); }, }, { // click on the /list command trigger: '.o-we-command-name:contains("Item List")', run: 'click', }, { // input a test name for the view trigger: '.modal-dialog #label', run: `edit ${embedListName}`, }, { // choose a name for the embedded view trigger: '.modal-footer button.btn-primary', run: 'click' }, { // scroll to the embedded view to load it trigger: embedViewSelector(embedListName), run: function () { this.anchor.scrollIntoView(); }, }, { // wait for the list view to be mounted trigger: `${embedViewSelector(embedListName)} .o_list_renderer`, }, { // verify that the view has the correct name and store data-embedded-props trigger: `${embedViewSelector(embedListName)} .o_control_panel .o_breadcrumb .active:contains("*()!'<>~")`, run: () => { const embeddedViewElement = document.querySelector('[data-embedded="view"]'); embeddedProps = JSON.parse(embeddedViewElement.dataset.embeddedProps); } }, { // click on rename button trigger: '.o_control_panel_breadcrumbs_actions .dropdown-toggle', run: 'click', }, { trigger: '.dropdown-item:contains(Edit)', run: "click", }, { // click to validate the modal trigger: '.modal-footer button.btn-primary', run: 'click' }, { // check that the name is the correct one and compare previous data-embedded-props and the new one (should be equivalent) trigger: `${embedViewSelector(embedListName)} .o_control_panel .o_breadcrumb .active:contains("*()!'<>~")`, run: () => { const embeddedViewElement = document.querySelector('[data-embedded="view"]'); const newEmbeddedProps = JSON.parse(embeddedViewElement.dataset.embeddedProps); if (newEmbeddedProps.display_name !== embeddedProps.display_name) { throw new Error('The name displayed should not have changed'); } if (JSON.stringify(newEmbeddedProps) !== JSON.stringify(embeddedProps)) { // check that knowledge.article render_embedded_view urllib.parse.quote did // produce an equivalent data-embedded-props as throw new Error('data-embedded-props should be semantically the same as before'); } } }, { // click on rename button trigger: '.o_control_panel_breadcrumbs_actions .dropdown-toggle', run: 'click', }, { trigger: '.dropdown-item:contains(Edit)', run: "click", }, { // rename the view trigger: '.modal-body input', run: "edit New Title", }, { // click to validate the modal trigger: '.modal-footer button.btn-primary', run: 'click', }, { // check that name has been updated trigger: '[data-embedded="view"] .o_control_panel .o_breadcrumb .active:contains("New Title")', }]; // EMBED VIEW: /kanban const embedKanbanName = "My Tasks Kanban"; const embedKanbanSteps = [{ // open the command bar trigger: '.odoo-editor-editable > p', run: function () { openPowerbox(this.anchor); }, }, { // click on the /kanban command trigger: '.o-we-command-name:contains("Item Kanban")', run: 'click', }, { // input a test name for the view trigger: '.modal-dialog #label', run: `edit ${embedKanbanName}`, }, { // choose a name for the embedded view trigger: `.modal-dialog:contains("Insert a Kanban View") .modal-footer button.btn-primary`, run: 'click', }, ...commonKanbanSteps(embedKanbanName), { // Check that the stages are well created trigger: `${embedViewSelector(embedKanbanName)} .o_kanban_renderer .o_kanban_group .o_kanban_header_title:contains("Ongoing")`, }, { // create an article item from Main New button trigger: `${embedViewSelector(embedKanbanName)} .o-kanban-button-new`, run: 'click', }, { // Type a Title for new article in the quick create form trigger: `${embedViewSelector(embedKanbanName)} .o_kanban_renderer .o_kanban_quick_create .o_input`, run: "edit New Quick Create Item", }, { // Add a random icon to the new article in the quick create form trigger: `${embedViewSelector(embedKanbanName)} .o_kanban_renderer .o_kanban_quick_create a[title="Add a random icon"]`, run: 'click', }, { // Click on the icon to open the emoji picker and select another icon in the quick create form trigger: `${embedViewSelector(embedKanbanName)} .o_kanban_renderer .o_kanban_quick_create .o_article_emoji`, run: 'click', }, { // Select an emoji for the new article trigger: '.o-Emoji[data-codepoints="🙃"]', run: 'click', }, { // Click on Add to create the article trigger: `${embedViewSelector(embedKanbanName)} .o_kanban_renderer .o_kanban_quick_create .o_kanban_add`, run: 'click' }, { trigger: `${embedViewSelector(embedKanbanName)} .o_kanban_renderer .o_article_emoji:contains("🙃")`, }, { // Verify that the article has been properly created trigger: `${embedViewSelector(embedKanbanName)} .o_kanban_renderer span:contains("New Quick Create Item")`, }, { // Click on the icon of the created article to open the emoji picker trigger: `${embedViewSelector(embedKanbanName)} .o_kanban_renderer .o_article_emoji`, run: 'click', }, { // Select another emoji for the created article trigger: '.o-Emoji[data-codepoints="🤩"]', run: 'click', }]; // EMBED VIEW: /cards (same as kanban, without the custom stages) const embedCardsKanbanName = "My Cards Kanban"; const embedCardsKanbanSteps = [{ // open the command bar trigger: '.odoo-editor-editable > p', run: function () { openPowerbox(this.anchor); }, }, { // click on the /kanban command trigger: '.o-we-command-name:contains("Item Cards")', run: 'click', }, { // input a test name for the view trigger: '.modal-dialog #label', run: `edit ${embedCardsKanbanName}`, }, { // choose a name for the embedded view trigger: `.modal-dialog:contains("Insert a Kanban View") .modal-footer button.btn-primary`, run: 'click', }, ...commonKanbanSteps(embedCardsKanbanName)]; /* * EMBED VIEW: /kanban - with custom act_window * Allows testing that we support a fully custom act.window definition to create embed views. */ const embedKanbanActWindowName = "Act Window Kanban"; const articleItemsKanbanAction = { domain: "[('parent_id', '=', active_id), ('is_article_item', '=', True)]", help: markup('

No data to display

'), name: embedKanbanActWindowName, res_model: 'knowledge.article', type: 'ir.actions.act_window', views: [[false, 'kanban']], view_mode: 'kanban', }; const articleItemsKanbanActionContext = () => { return { active_id: articleId, default_parent_id: articleId, default_is_article_item: true, }; }; const embedKanbanActWindowSteps = [{ // manually insert view from act_window object trigger: '.odoo-editor-editable > p', run: function () { const context = articleItemsKanbanActionContext(); const selection = document.getSelection(); selection.setBaseAndExtent(this.anchor, 0, this.anchor, 0); plugin.insertEmbeddedView( articleItemsKanbanAction, articleItemsKanbanAction.name, "kanban", { context } ); }, }, ...commonKanbanSteps(embedKanbanActWindowName)]; //------------------------------------------------------------------------------ // TOUR STEPS - MISC //------------------------------------------------------------------------------ /* * MISC: Verifying view filtering mechanics. * When you enable a filter on an embed view, it it saved and restored if you go back to that view. * See: 'knowledgeEmbedViewsFilters' for more details */ const embedViewFiltersSteps = [{ // Check that we have 2 elements in the embedded view trigger: 'tbody tr.o_data_row:nth-child(2)', }, { // add a simple filter trigger: '.o_searchview_input_container input', run: "edit 1", }, { trigger: '.o_searchview_autocomplete [role="menuitem"]:contains(Title)', run: "click", }, { // Check that the filter is effective trigger: 'tbody:not(tr.o_data_row:nth-child(2))', }, { // Open the filtered article trigger: 'tbody > tr > td[name="display_name"]', run: "click", }, { // Wait for the article to be open trigger: '.o_hierarchy_article_name input:value("Child 1")', }, { // Re-open the main article trigger: '.o_article .o_article_name:contains("EditorCommandsArticle")', run: "click", }, { // Check that there is the filter in the searchBar trigger: '.o_searchview_input_container', }, { // Check that the filter is effective trigger: 'tbody:not(tr.o_data_row:nth-child(2))', }]; // MISC: Test opening an article item through the kanban view const embedKanbanEditArticleSteps = [{ // Create a new article using quick create in OnGoing Column trigger: `${embedViewSelector(embedKanbanName)} .o_kanban_renderer .o_kanban_group .o_kanban_header_title:contains("Ongoing") .o_kanban_quick_add`, run: 'click' }, { // Type a Title for new article in the quick create form trigger: `${embedViewSelector(embedKanbanName)} .o_kanban_renderer .o_kanban_group:has(.o_kanban_header_title:contains("Ongoing")) .o_kanban_quick_create .o_input`, run: "edit Quick Create Ongoing Item", }, { // Click on Edit to open the article in edition in his own form view trigger: `${embedViewSelector(embedKanbanName)} .o_kanban_renderer .o_kanban_quick_create .o_kanban_edit`, run: 'click' }, { // verify that the view switched to the article item trigger: '.o_knowledge_header .o_hierarchy_article_name input:value("Quick Create Ongoing Item")', }, { // Re-open the main article trigger: '.o_article .o_article_name:contains("EditorCommandsArticle")', run: "click", }, { // Wait for the article to be properly loaded trigger: '.odoo-editor-editable:contains("EditorCommandsArticle Content")', }]; /* * MISC: Verifying /article command inside the mail composer. * We add specific code to make the /article command work inside the composer, notably in relation * to the "to inline" process. * See '_toInline' knowledge override in html_field.js */ const composeBody = ".modal-dialog:contains(Compose Email) [name='body']"; const articleCommandComposerSteps = [{ // open the chatter trigger: '.btn-chatter', run: "click", }, { // open the message editor trigger: '.o-mail-Chatter-sendMessage:not([disabled=""])', run: "click", }, { // open the full composer trigger: "button[title='Open Full Composer']", run: "click", }, { trigger: `${composeBody} .odoo-editor-editable > div.o-paragraph`, }, ...appendArticleLink(`${composeBody}`, 'EditorCommandsArticle'), { // wait for the block to appear in the editor trigger: `${composeBody} .o_knowledge_article_link:contains("EditorCommandsArticle")`, }, ...appendArticleLink(`${composeBody}`, 'LinkedArticle', `.o_knowledge_article_link:contains("EditorCommandsArticle")`), { // wait for the block to appear in the editor, after the previous one trigger: `${composeBody} .odoo-editor-editable a:nth-child(2).o_knowledge_article_link:contains("LinkedArticle")[contenteditable="false"]`, }, { // verify that the first block is still there and contenteditable=false trigger: `${composeBody} .odoo-editor-editable a:nth-child(1).o_knowledge_article_link:contains("EditorCommandsArticle")[contenteditable="false"]`, }, { // send the message trigger: '.o_mail_send', run: "click", }, { trigger: '.o_widget_knowledge_chatter_panel .o-mail-Thread .o-mail-Message-body a:nth-child(1).o_knowledge_article_link:contains("EditorCommandsArticle")', }, { trigger: '.o_widget_knowledge_chatter_panel .o-mail-Thread .o-mail-Message-body a:nth-child(2).o_knowledge_article_link:contains("LinkedArticle")', }, { // close the chatter trigger: '.btn-chatter', run: 'click', }]; // MISC: Article command usage const articleCommandUsageSteps = [{ // wait for the block to appear in the editor trigger: '.o_knowledge_article_link:contains("LinkedArticle")', run: 'click', }, { // check that the view switched to the corresponding article trigger: '.o_knowledge_header:has(.o_hierarchy_article_name input:value("LinkedArticle"))', run: "click", }, { // Re-open the main article trigger: '.o_article .o_article_name:contains("EditorCommandsArticle")', run: "click", }, { // Wait for the article to be properly loaded trigger: '.odoo-editor-editable:contains("EditorCommandsArticle Content")', }]; /** MISC: Clipboard usage on a contact * * Has to stay last for 2 reasons: * - It's important to be executed in an article that has embed views inside it, to make sure that * the breadcrumbs from embed views don't interfere with the macro system ; * - It actually leaves the main article, meaning any steps after this one would have to manually * re-enter the article from the Knowledge app (could have side effects, see file introduction). */ const clipboardUsageSteps = [{ // open the chatter trigger: '.btn-chatter', run: 'click', }, { trigger: '.o-mail-Thread', }, { // open the follower list of the article trigger: '.o-mail-Followers-button', run: 'click', }, { // open the contact record of the follower trigger: '.o-mail-Follower-details:contains(HelloWorldPartner)', run: 'click', }, { // verify that the partner form view is fully loaded trigger: '.o_breadcrumb .o_last_breadcrumb_item.active:contains(HelloWorldPartner)', }, { // return to the knowledge article by going back from the breadcrumbs trigger: '.o_breadcrumb a:contains(EditorCommandsArticle)', run: 'click', }, { trigger: "[data-embedded='clipboard'] button:contains(Copy)", }, { // open the chatter again trigger: '.btn-chatter', run: 'click', }, { trigger: '.o-mail-Thread', }, { // open the follower list of the article trigger: '.o-mail-Followers-button', run: 'click', }, { // open the contact record of the follower trigger: '.o-mail-Follower-details:contains(HelloWorldPartner)', run: 'click', }, { // verify that the partner form view is fully loaded trigger: '.o_breadcrumb .o_last_breadcrumb_item.active:contains(HelloWorldPartner)', }, { // search an article to open it from the contact record trigger: 'button[title="Search Knowledge Articles"]', run: 'click', }, { // open the article trigger: '.o_command_default:contains(EditorCommandsArticle)', run: 'click', }, { // wait for article to be correctly loaded trigger: '.o_hierarchy_article_name input:value("EditorCommandsArticle")', }, { // use the template as description for the contact record trigger: "[data-embedded='clipboard'] button:contains(Use as)", run: 'click', }, { // check that the content of the template was inserted as description trigger: '.o_form_sheet .o_field_html .odoo-editor-editable p:contains("Hello world")', }]; registry.category("web_tour.tours").add('knowledge_article_commands_tour', { url: '/odoo', steps: () => [stepUtils.showAppsMenuItem(), { // open the Knowledge App trigger: '.o_app[data-menu-xmlid="knowledge.knowledge_menu_root"]', run: "click", }, // Regular commands ...articleCommandSteps, ...fileCommandSteps, ...indexCommandSteps, ...tocCommandSteps, ...videoCommandSteps, ...clipboardCommandSteps, // Embed view commands ...embeddedViewPatchSteps, ...listCommandSteps, ...embedKanbanSteps, ...embedKanbanActWindowSteps, ...embedCardsKanbanSteps, // Misc ...embedViewFiltersSteps, ...embedKanbanEditArticleSteps, ...articleCommandUsageSteps, ...articleCommandComposerSteps, ...clipboardUsageSteps, // has to stay last, see steps docstring ...unpatchSteps, ...endKnowledgeTour() ]});