From f98a2cdd6b1aca8abc45bb20ef53ca6f5ab6edfb Mon Sep 17 00:00:00 2001 From: Jesse Mazzella Date: Fri, 20 Jan 2023 10:27:09 -0800 Subject: [PATCH] feat: Recent Objects (#6103) * clicking recent objects selects that object * clicking target navigates to but does not select that object * max 20 recent objects --- e2e/tests/framework/appActions.e2e.spec.js | 12 +- .../functional/moveAndLinkObjects.e2e.spec.js | 144 +++++++++---- .../conditionSet/conditionSet.e2e.spec.js | 4 +- .../displayLayout/displayLayout.e2e.spec.js | 29 ++- .../functional/recentObjects.e2e.spec.js | 85 ++++++++ e2e/tests/functional/search.e2e.spec.js | 2 +- src/api/forms/components/controls/Locator.vue | 1 + src/api/objects/ObjectAPI.js | 50 ++++- src/plugins/duplicate/DuplicateAction.js | 25 ++- src/plugins/linkAction/LinkAction.js | 22 +- src/plugins/move/MoveAction.js | 21 ++ src/styles/vue-styles.scss | 1 + src/tools/url.js | 7 + src/tools/urlSpec.js | 2 +- src/ui/components/ObjectPath.vue | 25 ++- src/ui/inspector/location.scss | 1 + src/ui/layout/Layout.vue | 41 +++- src/ui/layout/RecentObjectsList.vue | 200 ++++++++++++++++++ src/ui/layout/RecentObjectsListItem.vue | 134 ++++++++++++ src/ui/layout/layout.scss | 2 +- src/ui/layout/mct-tree.scss | 4 + src/ui/layout/mct-tree.vue | 33 +-- src/ui/layout/pane.scss | 2 + src/ui/layout/pane.vue | 193 ++++++++++------- src/ui/layout/recent-objects.scss | 121 +++++++++++ .../layout/search/AnnotationSearchResult.vue | 8 +- src/ui/layout/search/GrandSearch.vue | 4 +- src/ui/layout/search/ObjectSearchResult.vue | 13 +- src/ui/layout/tree-item.vue | 17 +- src/ui/mixins/object-link.js | 4 +- src/ui/router/ApplicationRouter.js | 11 +- 31 files changed, 1023 insertions(+), 195 deletions(-) create mode 100644 e2e/tests/functional/recentObjects.e2e.spec.js create mode 100644 src/ui/layout/RecentObjectsList.vue create mode 100644 src/ui/layout/RecentObjectsListItem.vue create mode 100644 src/ui/layout/recent-objects.scss diff --git a/e2e/tests/framework/appActions.e2e.spec.js b/e2e/tests/framework/appActions.e2e.spec.js index b83f017bda..10ae0b11f8 100644 --- a/e2e/tests/framework/appActions.e2e.spec.js +++ b/e2e/tests/framework/appActions.e2e.spec.js @@ -49,11 +49,11 @@ test.describe('AppActions', () => { parent: e2eFolder.uuid }); - await page.goto(timer1.url, { waitUntil: 'networkidle' }); + await page.goto(timer1.url); await expect(page.locator('.l-browse-bar__object-name')).toHaveText(timer1.name); - await page.goto(timer2.url, { waitUntil: 'networkidle' }); + await page.goto(timer2.url); await expect(page.locator('.l-browse-bar__object-name')).toHaveText(timer2.name); - await page.goto(timer3.url, { waitUntil: 'networkidle' }); + await page.goto(timer3.url); await expect(page.locator('.l-browse-bar__object-name')).toHaveText(timer3.name); }); @@ -73,11 +73,11 @@ test.describe('AppActions', () => { name: 'Folder Baz', parent: folder2.uuid }); - await page.goto(folder1.url, { waitUntil: 'networkidle' }); + await page.goto(folder1.url); await expect(page.locator('.l-browse-bar__object-name')).toHaveText(folder1.name); - await page.goto(folder2.url, { waitUntil: 'networkidle' }); + await page.goto(folder2.url); await expect(page.locator('.l-browse-bar__object-name')).toHaveText(folder2.name); - await page.goto(folder3.url, { waitUntil: 'networkidle' }); + await page.goto(folder3.url); await expect(page.locator('.l-browse-bar__object-name')).toHaveText(folder3.name); expect(folder1.url).toBe(`${e2eFolder.url}/${folder1.uuid}`); diff --git a/e2e/tests/functional/moveAndLinkObjects.e2e.spec.js b/e2e/tests/functional/moveAndLinkObjects.e2e.spec.js index 6804032673..0a29dd9859 100644 --- a/e2e/tests/functional/moveAndLinkObjects.e2e.spec.js +++ b/e2e/tests/functional/moveAndLinkObjects.e2e.spec.js @@ -43,48 +43,76 @@ test.describe('Move & link item tests', () => { name: 'Child Folder', parent: parentFolder.uuid }); - await createDomainObjectWithDefaults(page, { + const grandchildFolder = await createDomainObjectWithDefaults(page, { type: 'Folder', name: 'Grandchild Folder', parent: childFolder.uuid }); // Attempt to move parent to its own grandparent - await page.locator(`text=Open MCT ${myItemsFolderName} >> span`).nth(3).click(); - await page.locator('.c-disclosure-triangle >> nth=0').click(); + await page.locator('button[title="Show selected item in tree"]').click(); - await page.locator(`a:has-text("Parent Folder") >> nth=0`).click({ + const treePane = page.locator('#tree-pane'); + await treePane.getByRole('treeitem', { + name: 'Parent Folder' + }).click({ button: 'right' }); - await page.locator('li.icon-move').click(); - await page.locator('form[name="mctForm"] >> .c-disclosure-triangle >> nth=0').click(); - await page.locator('form[name="mctForm"] >> text=Parent Folder').click(); + await page.getByRole('menuitem', { + name: /Move/ + }).click(); + + const locatorTree = page.locator('#locator-tree'); + const myItemsLocatorTreeItem = locatorTree.getByRole('treeitem', { + name: myItemsFolderName + }); + await myItemsLocatorTreeItem.locator('.c-disclosure-triangle').click(); + await myItemsLocatorTreeItem.click(); + + const parentFolderLocatorTreeItem = locatorTree.getByRole('treeitem', { + name: parentFolder.name + }); + await parentFolderLocatorTreeItem.locator('.c-disclosure-triangle').click(); + await parentFolderLocatorTreeItem.click(); await expect(page.locator('[aria-label="Save"]')).toBeDisabled(); - await page.locator('form[name="mctForm"] >> .c-disclosure-triangle >> nth=1').click(); - await page.locator('form[name="mctForm"] >> text=Child Folder').click(); + + const childFolderLocatorTreeItem = locatorTree.getByRole('treeitem', { + name: new RegExp(childFolder.name) + }); + await childFolderLocatorTreeItem.locator('.c-disclosure-triangle').click(); + await childFolderLocatorTreeItem.click(); await expect(page.locator('[aria-label="Save"]')).toBeDisabled(); - await page.locator('form[name="mctForm"] >> .c-disclosure-triangle >> nth=2').click(); - await page.locator('form[name="mctForm"] >> text=Grandchild Folder').click(); + + const grandchildFolderLocatorTreeItem = locatorTree.getByRole('treeitem', { + name: grandchildFolder.name + }); + await grandchildFolderLocatorTreeItem.locator('.c-disclosure-triangle').click(); + await grandchildFolderLocatorTreeItem.click(); await expect(page.locator('[aria-label="Save"]')).toBeDisabled(); - await page.locator('form[name="mctForm"] >> text=Parent Folder').click(); + + await parentFolderLocatorTreeItem.click(); await expect(page.locator('[aria-label="Save"]')).toBeDisabled(); await page.locator('[aria-label="Cancel"]').click(); // Move Child Folder from Parent Folder to My Items - await page.locator('.c-disclosure-triangle >> nth=0').click(); - await page.locator('.c-disclosure-triangle >> nth=1').click(); - - await page.locator(`a:has-text("Child Folder") >> nth=0`).click({ + await treePane.getByRole('treeitem', { + name: new RegExp(childFolder.name) + }).click({ button: 'right' }); - await page.locator('li.icon-move').click(); - await page.locator(`form[name="mctForm"] >> text=${myItemsFolderName}`).click(); + await page.getByRole('menuitem', { + name: /Move/ + }).click(); + await myItemsLocatorTreeItem.click(); - await page.locator('button:has-text("OK")').click(); + await page.locator('[aria-label="Save"]').click(); + const myItemsPaneTreeItem = treePane.getByRole('treeitem', { + name: myItemsFolderName + }); // Expect that Child Folder is in My Items, the root folder - expect(page.locator(`text=${myItemsFolderName} >> nth=0:has(text=Child Folder)`)).toBeTruthy(); + expect(myItemsPaneTreeItem.locator('nth=0:has(text=Child Folder)')).toBeTruthy(); }); test('Create a basic object and verify that it cannot be moved to telemetry object without Composition Provider', async ({ page, openmctConfig }) => { const { myItemsFolderName } = openmctConfig; @@ -114,7 +142,7 @@ test.describe('Move & link item tests', () => { // See if it's possible to put the folder in the Telemetry object during creation (Soft Assert) await page.locator(`form[name="mctForm"] >> text=${telemetryTable}`).click(); - let okButton = await page.locator('button.c-button.c-button--major:has-text("OK")'); + let okButton = page.locator('button.c-button.c-button--major:has-text("OK")'); let okButtonStateDisabled = await okButton.isDisabled(); expect.soft(okButtonStateDisabled).toBeTruthy(); @@ -138,7 +166,7 @@ test.describe('Move & link item tests', () => { // See if it's possible to put the folder in the Telemetry object after creation await page.locator(`text=Location Open MCT ${myItemsFolderName} >> span`).nth(3).click(); await page.locator(`form[name="mctForm"] >> text=${telemetryTable}`).click(); - let okButton2 = await page.locator('button.c-button.c-button--major:has-text("OK")'); + let okButton2 = page.locator('button.c-button.c-button--major:has-text("OK")'); let okButtonStateDisabled2 = await okButton2.isDisabled(); expect(okButtonStateDisabled2).toBeTruthy(); }); @@ -158,48 +186,76 @@ test.describe('Move & link item tests', () => { name: 'Child Folder', parent: parentFolder.uuid }); - await createDomainObjectWithDefaults(page, { + const grandchildFolder = await createDomainObjectWithDefaults(page, { type: 'Folder', name: 'Grandchild Folder', parent: childFolder.uuid }); - // Attempt to link parent to its own grandparent - await page.locator(`text=Open MCT ${myItemsFolderName} >> span`).nth(3).click(); - await page.locator('.c-disclosure-triangle >> nth=0').click(); + // Attempt to move parent to its own grandparent + await page.locator('button[title="Show selected item in tree"]').click(); - await page.locator(`a:has-text("Parent Folder") >> nth=0`).click({ + const treePane = page.locator('#tree-pane'); + await treePane.getByRole('treeitem', { + name: 'Parent Folder' + }).click({ button: 'right' }); - await page.locator('li.icon-link').click(); - await page.locator('form[name="mctForm"] >> .c-disclosure-triangle >> nth=0').click(); - await page.locator('form[name="mctForm"] >> text=Parent Folder').click(); + await page.getByRole('menuitem', { + name: /Move/ + }).click(); + + const locatorTree = page.locator('#locator-tree'); + const myItemsLocatorTreeItem = locatorTree.getByRole('treeitem', { + name: myItemsFolderName + }); + await myItemsLocatorTreeItem.locator('.c-disclosure-triangle').click(); + await myItemsLocatorTreeItem.click(); + + const parentFolderLocatorTreeItem = locatorTree.getByRole('treeitem', { + name: parentFolder.name + }); + await parentFolderLocatorTreeItem.locator('.c-disclosure-triangle').click(); + await parentFolderLocatorTreeItem.click(); await expect(page.locator('[aria-label="Save"]')).toBeDisabled(); - await page.locator('form[name="mctForm"] >> .c-disclosure-triangle >> nth=1').click(); - await page.locator('form[name="mctForm"] >> text=Child Folder').click(); + + const childFolderLocatorTreeItem = locatorTree.getByRole('treeitem', { + name: new RegExp(childFolder.name) + }); + await childFolderLocatorTreeItem.locator('.c-disclosure-triangle').click(); + await childFolderLocatorTreeItem.click(); await expect(page.locator('[aria-label="Save"]')).toBeDisabled(); - await page.locator('form[name="mctForm"] >> .c-disclosure-triangle >> nth=2').click(); - await page.locator('form[name="mctForm"] >> text=Grandchild Folder').click(); + + const grandchildFolderLocatorTreeItem = locatorTree.getByRole('treeitem', { + name: grandchildFolder.name + }); + await grandchildFolderLocatorTreeItem.locator('.c-disclosure-triangle').click(); + await grandchildFolderLocatorTreeItem.click(); await expect(page.locator('[aria-label="Save"]')).toBeDisabled(); - await page.locator('form[name="mctForm"] >> text=Parent Folder').click(); + + await parentFolderLocatorTreeItem.click(); await expect(page.locator('[aria-label="Save"]')).toBeDisabled(); await page.locator('[aria-label="Cancel"]').click(); - // Link Child Folder from Parent Folder to My Items - await page.locator('.c-disclosure-triangle >> nth=0').click(); - await page.locator('.c-disclosure-triangle >> nth=1').click(); - - await page.locator(`a:has-text("Child Folder") >> nth=0`).click({ + // Move Child Folder from Parent Folder to My Items + await treePane.getByRole('treeitem', { + name: new RegExp(childFolder.name) + }).click({ button: 'right' }); - await page.locator('li.icon-link').click(); - await page.locator(`form[name="mctForm"] >> text=${myItemsFolderName}`).click(); + await page.getByRole('menuitem', { + name: /Link/ + }).click(); + await myItemsLocatorTreeItem.click(); - await page.locator('button:has-text("OK")').click(); + await page.locator('[aria-label="Save"]').click(); + const myItemsPaneTreeItem = treePane.getByRole('treeitem', { + name: myItemsFolderName + }); // Expect that Child Folder is in My Items, the root folder - expect(page.locator(`text=${myItemsFolderName} >> nth=0:has(text=Child Folder)`)).toBeTruthy(); + expect(myItemsPaneTreeItem.locator('nth=0:has(text=Child Folder)')).toBeTruthy(); }); }); diff --git a/e2e/tests/functional/plugins/conditionSet/conditionSet.e2e.spec.js b/e2e/tests/functional/plugins/conditionSet/conditionSet.e2e.spec.js index 0cb927466d..af84850237 100644 --- a/e2e/tests/functional/plugins/conditionSet/conditionSet.e2e.spec.js +++ b/e2e/tests/functional/plugins/conditionSet/conditionSet.e2e.spec.js @@ -98,8 +98,8 @@ test.describe.serial('Condition Set CRUD Operations on @localStorage', () => { await page.locator('text=Conditions View Snapshot >> button').nth(3).click(); //Edit Condition Set Name from main view - await page.locator('text=Unnamed Condition Set').first().fill('Renamed Condition Set'); - await page.locator('text=Renamed Condition Set').first().press('Enter'); + await page.locator('.l-browse-bar__object-name').filter({ hasText: 'Unnamed Condition Set' }).first().fill('Renamed Condition Set'); + await page.locator('.l-browse-bar__object-name').filter({ hasText: 'Renamed Condition Set' }).first().press('Enter'); // Click Save Button await page.locator('text=Snapshot Save and Finish Editing Save and Continue Editing >> button').nth(1).click(); // Click Save and Finish Editing Option diff --git a/e2e/tests/functional/plugins/displayLayout/displayLayout.e2e.spec.js b/e2e/tests/functional/plugins/displayLayout/displayLayout.e2e.spec.js index a88d1a70a4..a3fcc5342e 100644 --- a/e2e/tests/functional/plugins/displayLayout/displayLayout.e2e.spec.js +++ b/e2e/tests/functional/plugins/displayLayout/displayLayout.e2e.spec.js @@ -24,6 +24,7 @@ const { test, expect } = require('../../../../pluginFixtures'); const { createDomainObjectWithDefaults, setStartOffset, setFixedTimeMode, setRealTimeMode } = require('../../../../appActions'); test.describe('Display Layout', () => { + /** @type {import('../../../../appActions').CreatedObjectInfo} */ let sineWaveObject; test.beforeEach(async ({ page }) => { await page.goto('./', { waitUntil: 'networkidle' }); @@ -47,7 +48,12 @@ test.describe('Display Layout', () => { // Expand the 'My Items' folder in the left tree await page.locator('.c-tree__item__view-control.c-disclosure-triangle').click(); // Add the Sine Wave Generator to the Display Layout and save changes - await page.dragAndDrop('text=Test Sine Wave Generator', '.l-layout__grid-holder'); + const treePane = page.locator('#tree-pane'); + const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', { + name: new RegExp(sineWaveObject.name) + }); + const layoutGridHolder = page.locator('.l-layout__grid-holder'); + await sineWaveGeneratorTreeItem.dragTo(layoutGridHolder); await page.locator('button[title="Save"]').click(); await page.locator('text=Save and Finish Editing').click(); @@ -74,7 +80,12 @@ test.describe('Display Layout', () => { // Expand the 'My Items' folder in the left tree await page.locator('.c-tree__item__view-control.c-disclosure-triangle').click(); // Add the Sine Wave Generator to the Display Layout and save changes - await page.dragAndDrop('text=Test Sine Wave Generator', '.l-layout__grid-holder'); + const treePane = page.locator('#tree-pane'); + const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', { + name: new RegExp(sineWaveObject.name) + }); + const layoutGridHolder = page.locator('.l-layout__grid-holder'); + await sineWaveGeneratorTreeItem.dragTo(layoutGridHolder); await page.locator('button[title="Save"]').click(); await page.locator('text=Save and Finish Editing').click(); @@ -105,7 +116,12 @@ test.describe('Display Layout', () => { // Expand the 'My Items' folder in the left tree await page.locator('.c-tree__item__view-control.c-disclosure-triangle').click(); // Add the Sine Wave Generator to the Display Layout and save changes - await page.dragAndDrop('text=Test Sine Wave Generator', '.l-layout__grid-holder'); + const treePane = page.locator('#tree-pane'); + const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', { + name: new RegExp(sineWaveObject.name) + }); + const layoutGridHolder = page.locator('.l-layout__grid-holder'); + await sineWaveGeneratorTreeItem.dragTo(layoutGridHolder); await page.locator('button[title="Save"]').click(); await page.locator('text=Save and Finish Editing').click(); @@ -139,7 +155,12 @@ test.describe('Display Layout', () => { // Expand the 'My Items' folder in the left tree await page.locator('.c-tree__item__view-control.c-disclosure-triangle').click(); // Add the Sine Wave Generator to the Display Layout and save changes - await page.dragAndDrop('text=Test Sine Wave Generator', '.l-layout__grid-holder'); + const treePane = page.locator('#tree-pane'); + const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', { + name: new RegExp(sineWaveObject.name) + }); + const layoutGridHolder = page.locator('.l-layout__grid-holder'); + await sineWaveGeneratorTreeItem.dragTo(layoutGridHolder); await page.locator('button[title="Save"]').click(); await page.locator('text=Save and Finish Editing').click(); diff --git a/e2e/tests/functional/recentObjects.e2e.spec.js b/e2e/tests/functional/recentObjects.e2e.spec.js new file mode 100644 index 0000000000..2fdeb94c1c --- /dev/null +++ b/e2e/tests/functional/recentObjects.e2e.spec.js @@ -0,0 +1,85 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2023, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + +const { test, expect } = require('../../pluginFixtures.js'); +const { createDomainObjectWithDefaults } = require('../../appActions.js'); + +test.describe('Recent Objects', () => { + test('Recent Objects CRUD operations', async ({ page }) => { + await page.goto('./', { waitUntil: 'networkidle' }); + + // Create a folder and nest a Clock within it + const recentObjectsList = page.locator('[aria-label="Recent Objects"]'); + const folderA = await createDomainObjectWithDefaults(page, { + type: 'Folder' + }); + const clock = await createDomainObjectWithDefaults(page, { + type: 'Clock', + parent: folderA.uuid + }); + + // Drag the Recent Objects panel up a bit + await page.locator('div:nth-child(2) > .l-pane__handle').hover(); + await page.mouse.down(); + await page.mouse.move(0, 100); + await page.mouse.up(); + + // Verify that both created objects appear in the list and are in the correct order + expect(recentObjectsList.getByRole('listitem', { name: clock.name })).toBeTruthy(); + expect(recentObjectsList.getByRole('listitem', { name: folderA.name })).toBeTruthy(); + expect(recentObjectsList.getByRole('listitem', { name: clock.name }).locator('a').getByText(folderA.name)).toBeTruthy(); + expect(recentObjectsList.getByRole('listitem').nth(0).getByText(clock.name)).toBeTruthy(); + expect(recentObjectsList.getByRole('listitem', { name: clock.name }).locator('a').getByText(folderA.name)).toBeTruthy(); + expect(recentObjectsList.getByRole('listitem').nth(1).getByText(folderA.name)).toBeTruthy(); + + // Navigate to the folder by clicking on the main object name in the recent objects list item + await recentObjectsList.getByRole('listitem', { name: folderA.name }).getByText(folderA.name).click(); + await page.waitForURL(`**/${folderA.uuid}?*`); + expect(recentObjectsList.getByRole('listitem').nth(0).getByText(folderA.name)).toBeTruthy(); + + // Rename + folderA.name = `${folderA.name}-NEW!`; + await page.locator('.l-browse-bar__object-name').fill(""); + await page.locator('.l-browse-bar__object-name').fill(folderA.name); + await page.keyboard.press('Enter'); + + // Verify rename has been applied in recent objects list item and objects paths + expect(page.getByRole('listitem', { name: clock.name }).locator('a').getByText(folderA.name)).toBeTruthy(); + expect(recentObjectsList.getByRole('listitem', { name: folderA.name })).toBeTruthy(); + + // Delete + await page.click('button[title="Show selected item in tree"]'); + // Delete the folder via the left tree pane treeitem context menu + await page.getByRole('treeitem', { name: new RegExp(folderA.name) }).locator('a').click({ + button: 'right' + }); + await page.getByRole('menuitem', { name: /Remove/ }).click(); + await page.getByRole('button', { name: 'OK' }).click(); + + // Verify that the folder and clock are no longer in the recent objects list + await expect(recentObjectsList.getByRole('listitem', { name: folderA.name })).toBeHidden(); + await expect(recentObjectsList.getByRole('listitem', { name: clock.name })).toBeHidden(); + }); + test.fixme("Clicking on the 'target button' scrolls the object into view in the tree and highlights it"); + test.fixme("Clicking on an object in the path of a recent object navigates to the object"); + test.fixme("Tests for context menu actions from recent objects"); +}); diff --git a/e2e/tests/functional/search.e2e.spec.js b/e2e/tests/functional/search.e2e.spec.js index 2aa4116563..906290e1a8 100644 --- a/e2e/tests/functional/search.e2e.spec.js +++ b/e2e/tests/functional/search.e2e.spec.js @@ -72,7 +72,7 @@ test.describe('Grand Search', () => { await page.locator('[aria-label="OpenMCT Search"] [aria-label="Search Input"]').fill('Cl'); await Promise.all([ page.waitForNavigation(), - page.locator('text=Clock A').click() + page.locator('[aria-label="Clock A clock result"] >> text=Clock A').click() ]); await expect(page.locator('.is-object-type-clock')).toBeVisible(); diff --git a/src/api/forms/components/controls/Locator.vue b/src/api/forms/components/controls/Locator.vue index 339912cec8..0e84911ba8 100644 --- a/src/api/forms/components/controls/Locator.vue +++ b/src/api/forms/components/controls/Locator.vue @@ -22,6 +22,7 @@