Skip to content

Storage UI: Delete Drive (in proposal; Remove the configuration for this device)#1934

Merged
mvidner merged 8 commits intostorage-config-uifrom
delete-proposed-drive
Jan 30, 2025
Merged

Storage UI: Delete Drive (in proposal; Remove the configuration for this device)#1934
mvidner merged 8 commits intostorage-config-uifrom
delete-proposed-drive

Conversation

@mvidner
Copy link
Contributor

@mvidner mvidner commented Jan 22, 2025

Problem

https://trello.com/c/JQ3blpRm

remove

Solution

The implementation was easier because the UI model already had a removeDrive method...
but testing was fun again, because imitating the tests that worked for deleting a partition was not enough this time 😅

Testing

  • Added a new unit test
  • Tested manually

Screenshots

In action (used Peek for recording, thanks Ancor for the tip)

  1. Try to delete nvme01 but not possible because it contains /
  2. Delete / from it (Storage UI: Delete Partition (in proposal; trash-can icon) #1915)... (and we have an issue box, that's new but not from me 😄 )
  3. Now can delete nvme01
    agama-delete-proposed-partition-and-drive

@mvidner mvidner force-pushed the delete-proposed-drive branch from 9dd9190 to ad15af6 Compare January 23, 2025 21:18
@mvidner
Copy link
Contributor Author

mvidner commented Jan 24, 2025

I will need help with this:
After I select the menu item, a React backtrace appears, saying "_useDrive4 is undefined "
IIUC, it happens after the drive has been deleted and findDrive doesn't find it (correct) so useDrive returns undefined.
This looks like a workflow problem that is too advanced for me now.

@dgdavid
Copy link
Contributor

dgdavid commented Jan 24, 2025

I will need help with this: After I select the menu item, a React backtrace appears, saying "_useDrive4 is undefined " IIUC, it happens after the drive has been deleted and findDrive doesn't find it (correct) so useDrive returns undefined. This looks like a workflow problem that is too advanced for me now.

Yes, it is a workflow problem because the combination of re-renders, hooks, and the mentioned undefined. The quick fix could be the one proposed at 8a9477d, but actually Agama should stop re-rendering components that are going to be unmounted.

Let's go ahead and commit the right fix later, when all the storage interface pieces are more polished.

@mvidner
Copy link
Contributor Author

mvidner commented Jan 29, 2025

When trying to code the unit test, I am most confused by the disconnected names.
Let us take the EmailInput component and its simplest test as example.

export default function EmailInput({ onValidate = noop, value, ...props }: EmailInputProps) {

it("renders an email input", () => {

The test uses the component like:

<EmailInput id="email" name="email" aria-label="User email" value="test@test.com" />

and IIUC the component returns this

<InputGroup>
  <TextInput type="email" value="test@test.com" validated="default" />
</InputGroup>

the test code then goes

const inputField = screen.getByRole("textbox", { name: "User email" });

and this is where I lose my cool:

  1. Get by ROLE? I did not specify any role
  2. TEXTBOX? I see no textbox in the code so far
  3. "User email" is a NAME? Hell no, I wrote name="email", "User email" is an aria label, not a name.

@mvidner
Copy link
Contributor Author

mvidner commented Jan 29, 2025

Thanks @dgdavid for the pointer:

  1. and 2. role=textbox is implied on <input type=email> according to
    https://www.w3.org/TR/html-aria/#docconformance

But 3. still looks wrong. Let's focus on <LoginPage> now (because EmailInput is actually unused in Agama now)
Same situation like with EmailInput, the test says the property is name, but the tsx code, and the actual DOM, use aria-label and there is no such name.

const form = screen.getByRole("form", { name: "Login form" });

<Form id="login" onSubmit={login} aria-label={_("Login form")}>

login-form-aria-label

@mvidner
Copy link
Contributor Author

mvidner commented Jan 29, 2025

Problem 3. resolved: aria-label indeed counts as a name

  • on a live page, open the developer tools (Firefox: Ctrl-Shift-I), Accessibility tab, there is an expandable accessibility tree with Role and Name columns, clicking an item will also open a Properties pane where "role" and "name" are repeated along with more details
  • https://testing-library.com/docs/queries/about/#priority mentions an "accessibility tree" and accessible name which see for a slightly scary algorithm which will convert aria-label to name

@dgdavid
Copy link
Contributor

dgdavid commented Jan 29, 2025

  1. "User email" is a NAME? Hell no, I wrote name="email", "User email" is an aria label, not a name.

For the record, apart of the things already discussed, I forget mentioning in our IRC talk that this is the name you are giving to the param sent on form submission. As said in IRC, something for the developer, not the name from user POV.

@dgdavid
Copy link
Contributor

dgdavid commented Jan 30, 2025

Hi @mvidner,

Apologies for the delayed response to the doubts you mentioned in the description of commit 0b15433f0dbb18ae8deb6979d88bfdf3964af138. To be honest, I got a bit distracted by your attempt to use the startTransition feature proposed by React.


TL;DR: from a testing perspective, the root of the problem was the missing mock for useAvailableDevices, which is used by the internal SearchSelectorMultipleOptions component.


Of course, we have a lot of room for improvement at the code level, especially regarding the complex storage interface. As mentioned yesterday, that's another reason I prefer not to postpone adding tests: they not only help us ensure that the component behaves as intended and prevent breaking it with future additions, but they also help us spot such complexities and give us the opportunity to simplify them.

That said, the error you included in the commit was just the final lines of the failing test output. However, if you scroll to the top, you'll find an interesting error at the beginning.

❯ npm run test src/components/storage/DriveEditor.test.tsx -- --no-coverage --bail   

> test
> jest src/components/storage/DriveEditor.test.tsx --no-coverage --bail

  console.error
    The above error occurred in the <SearchSelectorMultipleOptions> component:
    
        at SearchSelectorMultipleOptions (/home/dgdavid/development/suse/agama/web/src/components/storage/DriveEditor.tsx:286:42)
        at SearchSelectorOptions (/home/dgdavid/development/suse/agama/web/src/components/storage/DriveEditor.tsx:362:34)
        at section
        at MenuGroupBase (/home/dgdavid/development/suse/agama/web/node_modules/@patternfly/react-core/src/components/Menu/MenuGroup.tsx:21:3)
        at MenuGroup
        at SearchSelector (/home/dgdavid/development/suse/agama/web/src/components/storage/DriveEditor.tsx:383:27)
        at ul
        at MenuList (/home/dgdavid/development/suse/agama/web/node_modules/@patternfly/react-core/src/components/Menu/MenuList.tsx:20:3)
        at div
        at /home/dgdavid/development/suse/agama/web/node_modules/@patternfly/react-core/src/components/Menu/MenuContent.tsx:22:11
        at div
        at MenuBase (/home/dgdavid/development/suse/agama/web/node_modules/@patternfly/react-core/src/components/Menu/Menu.tsx:91:5)
        at Menu
        at Popper (/home/dgdavid/development/suse/agama/web/node_modules/@patternfly/react-core/src/helpers/Popper/Popper.tsx:180:3)
        at MenuContainer (/home/dgdavid/development/suse/agama/web/node_modules/@patternfly/react-core/src/components/Menu/MenuContainer.tsx:55:3)
        at DriveSelector (/home/dgdavid/development/suse/agama/web/src/components/storage/DriveEditor.tsx:417:26)
        at h4
        at DriveHeader (/home/dgdavid/development/suse/agama/web/src/components/storage/DriveEditor.tsx:468:24)
        at div
        at div
        at CardTitle (/home/dgdavid/development/suse/agama/web/node_modules/@patternfly/react-core/src/components/Card/CardTitle.tsx:16:3)
        at div
        at CardHeaderMain (/home/dgdavid/development/suse/agama/web/node_modules/@patternfly/react-core/src/components/Card/CardHeaderMain.tsx:13:3)
        at div
        at CardHeader (/home/dgdavid/development/suse/agama/web/node_modules/@patternfly/react-core/src/components/Card/CardHeader.tsx:81:3)
        at div
        at Card (/home/dgdavid/development/suse/agama/web/node_modules/@patternfly/react-core/src/components/Card/Card.tsx:68:3)
        at DriveEditor (/home/dgdavid/development/suse/agama/web/src/components/storage/DriveEditor.tsx:683:39)
        at QueryClientProvider (/home/dgdavid/development/suse/agama/web/node_modules/@tanstack/react-query/src/QueryClientProvider.tsx:30:3)
        at Wrapper (/home/dgdavid/development/suse/agama/web/src/test-utils.tsx:149:22)
    
    Consider adding an error boundary to your tree to customize error handling behavior.
    Visit https://reactjs.org/link/error-boundaries to learn more about error boundaries.

      105 |
      106 |     const driveButton = screen.getByRole("button", { name: "Drive" });
    > 107 |     await user.click(driveButton);
          |     ^
      108 |     const drivesMenu = await screen.findByRole("menu");
      109 |     const deleteDriveButton = within(drivesMenu).getByRole("menuitem", {
      110 |       name: "Do not use",

      at logCapturedError (node_modules/react-dom/cjs/react-dom.development.js:18704:23)
      at update.callback (node_modules/react-dom/cjs/react-dom.development.js:18737:5)
      at callCallback (node_modules/react-dom/cjs/react-dom.development.js:15036:12)
      at commitUpdateQueue (node_modules/react-dom/cjs/react-dom.development.js:15057:9)
      at commitLayoutEffectOnFiber (node_modules/react-dom/cjs/react-dom.development.js:23430:13)
      at commitLayoutMountEffects_complete (node_modules/react-dom/cjs/react-dom.development.js:24727:9)
      at commitLayoutEffects_begin (node_modules/react-dom/cjs/react-dom.development.js:24713:7)
      at commitLayoutEffects (node_modules/react-dom/cjs/react-dom.development.js:24651:3)
      at commitRootImpl (node_modules/react-dom/cjs/react-dom.development.js:26862:5)
      at commitRoot (node_modules/react-dom/cjs/react-dom.development.js:26721:5)
      at performSyncWorkOnRoot (node_modules/react-dom/cjs/react-dom.development.js:26156:3)
      at flushSyncCallbacks (node_modules/react-dom/cjs/react-dom.development.js:12042:22)
      at flushActQueue (node_modules/react/cjs/react.development.js:2667:24)
      at act (node_modules/react/cjs/react.development.js:2582:11)
      at node_modules/@testing-library/react/dist/act-compat.js:47:25
      at Object.eventWrapper (node_modules/@testing-library/react/dist/pure.js:107:28)
      at Object.wrapEvent (node_modules/@testing-library/user-event/dist/cjs/event/wrapEvent.js:6:28)
      at Object.dispatchEvent (node_modules/@testing-library/user-event/dist/cjs/event/dispatchEvent.js:45:22)
      at Object.dispatchUIEvent (node_modules/@testing-library/user-event/dist/cjs/event/dispatchEvent.js:22:26)
      at Mouse.up (node_modules/@testing-library/user-event/dist/cjs/system/pointer/mouse.js:100:30)
      at PointerHost.release (node_modules/@testing-library/user-event/dist/cjs/system/pointer/index.js:84:28)
      at pointerAction (node_modules/@testing-library/user-event/dist/cjs/pointer/index.js:59:47)
      at Object.pointer (node_modules/@testing-library/user-event/dist/cjs/pointer/index.js:32:9)
      at Object.asyncWrapper (node_modules/@testing-library/react/dist/pure.js:88:22)
      at Object.<anonymous> (src/components/storage/DriveEditor.test.tsx:107:5)

 FAIL  src/components/storage/DriveEditor.test.tsx (10.011 s)
  PartitionMenuItem
    ○ skipped allows users to delete the partition
  RemoveDriveOption
    ✕ allows users to delete the drive (238 ms)

  ● RemoveDriveOption › allows users to delete the drive

    A component suspended while responding to synchronous input. This will cause the UI to be replaced with a loading indicator. To fix, updates that suspend should be wrapped with startTransition.

      105 |
      106 |     const driveButton = screen.getByRole("button", { name: "Drive" });
    > 107 |     await user.click(driveButton);
    
  ....

As you can see in the console.error, the issue actually started at <SearchSelectorMultipleOptions> component.

If you take a look at the mentioned internal component, you'll notice it uses two hooks: useNavigate and useAvailableDevices. The first one isn't a problem since it's already mocked for the entire test suite 😉 . However, the second one is where the problem lies. I won’t go into the details here, but if you look at it, you’ll quickly realize that mocking it for the test is necessary.

 jest.mock("~/queries/storage", () => ({
   ...jest.requireActual("~/queries/storage"),
   useAvailableDevices: () => [sda],
 }));

It's a bit messy, but that's where we are for now. We've talked before about the need for a global mocking mechanism, similar to what yast-storage-ng uses. Ideally, we'd like to provide a full scenario to the test suite and let the original code handle things, rather than mocking everything. However, we're not there yet.

After mocking useAvailableDevices, the test proceeds further but fails when trying to find the "Do not use" menu item. The issue is that the name of the menu item is actually a combination of its text and its description.

      --------------------------------------------------
      menuitem:

      Name "Do not use Remove the configuration for this device":
      <button
        class="pf-v6-c-menu__item"
        role="menuitem"
        tabindex="-1"
        type="button"
      />

      --------------------------------------------------

Why? I don't know, but it’s worth having a look at https://github.com/patternfly/patternfly-react/blob/main/packages/react-core/src/components/Menu/MenuItem.tsx to see if it can be improved—perhaps making the description prop the target of an aria-describedby. But again, that's outside the scope of our current goals.

In the meantime, the test can be solved in two ways:

  • Adding an explicit aria-label to the menu item.
  • Not looking for the exact "Do not use" text, but for /Do not use/.

Either option is fine with me at this point, but be careful if you choose the first one and make sure we still provide useful information to the user, rather than just a vague "Do not use".

Click to show/hide a proposed diff
diff --git a/web/src/components/storage/DriveEditor.test.tsx b/web/src/components/storage/DriveEditor.test.tsx
index fddf7a1dd..c82d3516f 100644
--- a/web/src/components/storage/DriveEditor.test.tsx
+++ b/web/src/components/storage/DriveEditor.test.tsx
@@ -66,7 +66,11 @@ const mockDrive: ConfigModel.Drive = {
 const mockDeleteDrive = jest.fn();
 const mockDeletePartition = jest.fn();
 
-// TODO: why does "~/queries/storage" work elsewhere??
+jest.mock("~/queries/storage", () => ({
+  ...jest.requireActual("~/queries/storage"),
+  useAvailableDevices: () => [sda],
+}));
+
 jest.mock("~/queries/storage/config-model", () => ({
   ...jest.requireActual("~/queries/storage/config-model"),
   useConfigModel: () => ({ drives: [mockDrive] }),
@@ -102,7 +106,7 @@ describe("RemoveDriveOption", () => {
     await user.click(driveButton);
     const drivesMenu = screen.getByRole("menu");
     const deleteDriveButton = within(drivesMenu).getByRole("menuitem", {
-      name: "Do not use",
+      name: /Do not use/,
     });
     await user.click(deleteDriveButton);
     expect(mockDeleteDrive).toHaveBeenCalled();
diff --git a/web/src/components/storage/DriveEditor.tsx b/web/src/components/storage/DriveEditor.tsx
index 6b5122e4e..ee7b3393a 100644
--- a/web/src/components/storage/DriveEditor.tsx
+++ b/web/src/components/storage/DriveEditor.tsx
@@ -1,5 +1,5 @@
 /*
- * Copyright (c) [2024] SUSE LLC
+ * Copyright (c) [2024-2025] SUSE LLC
  *
  * All Rights Reserved.
  *
@@ -20,7 +20,7 @@
  * find current contact information at www.suse.com.
  */
 
-import React, { useId, useRef, useState, startTransition } from "react";
+import React, { useId, useRef, useState } from "react";
 import { useNavigate, generatePath } from "react-router-dom";
 import { _, formatList } from "~/i18n";
 import { sprintf } from "sprintf-js";
@@ -424,12 +424,7 @@ const DriveSelector = ({ drive, selected, toggleAriaLabel }) => {
     driveHandler.switch(newDriveName);
     setIsOpen(false);
   };
-  // const onToggle = () => setIsOpen(!isOpen);
-  const onToggle = () => {
-    console.log("onToggle BEFORE");
-    startTransition(() => setIsOpen(!isOpen));
-    console.log("onToggle AFTER");
-  };
+  const onToggle = () => setIsOpen(!isOpen);
 
   return (
     <MenuContainer

@mvidner
Copy link
Contributor Author

mvidner commented Jan 30, 2025

I have simply committed David's proposed change.

This message...

The above error occurred in the <SearchSelectorMultipleOptions> component:

It is very helpful, I actually wanted to know WHICH component suspended, but I chose to ask an AI and it gave me generic advice about console.log debugging.

But I do not see the message with (cd web; npm test)... oh, it is there but I would totally miss it, because (like our CI!) I run the test with npm test -- --silent to avoid seeing tons of

  1. React Router Future Flag Warnings
  2. our own ws.ts logging

I will make a card to remove --silent. IMHO it is nearly as important as having the test pass. Now we have a passing happy path, but when there are non-passing tests, it makes debugging unnecessarily harder.

@mvidner mvidner marked this pull request as ready for review January 30, 2025 12:08
mvidner and others added 7 commits January 30, 2025 13:54
(the menu item only appears when I first delete the root partition from it)

except it crashes!? and I am pretty sure useDrive has worked before

> Unexpected Application Error!
> _useDrive4 is undefined
>
> RemoveDriveOption@https://localhost:10443/index.js:42094:22
> renderWithHooks@https://localhost:10443/index.js:84540:27
> updateFunctionComponent@https://localhost:10443/index.js:88666:20
> beginWork@https://localhost:10443/index.js:90689:16
> beginWork$1@https://localhost:10443/index.js:96514:14
> performUnitOfWork@https://localhost:10443/index.js:95645:12
> ...
> 💿 Hey developer 👋
> You can provide a way better UX than this when your app throws errors by providing your own ErrorBoundary or errorElement prop on your route.
The interface was crashing when deleting a drive due to the
`RemoveDriveOption` component attempting to destructure an `undefined`
value after the delete action was performed.

Most likely, after the delete action, a parent component in the tree is
re-rendered, which causes `RemoveDriveOption` to re-render as well. As a
quick fix, this commit postpones the destructuring until the value
returned by the `useDrive` hook is known, returning early if it is
`undefined`. A more refined solution is welcome, but will be addressed
once the overall storage UI is more polished.
Imitated the partition test, but new fun!

test fails with:
    A component suspended while responding to synchronous input. This will cause the UI to be replaced with a loading indicator. To fix, updates that suspend should be wrapped with startTransition.
- mock useAvailableDevices
- startTransition was a misleading advice given when th test errored with a suspension.
- actually the name ends up as
 "Do not use Remove the configuration for this device" so match partially
@mvidner mvidner force-pushed the delete-proposed-drive branch from b573dad to 89caba2 Compare January 30, 2025 12:57
Copy link
Contributor

@ancorgs ancorgs left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@mvidner mvidner merged commit 7aaed71 into storage-config-ui Jan 30, 2025
2 checks passed
@mvidner mvidner deleted the delete-proposed-drive branch January 30, 2025 16:13
@imobachgs imobachgs mentioned this pull request Feb 26, 2025
imobachgs added a commit that referenced this pull request Feb 26, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants