Browse Source

refactor: replace old modals with new dialog component

Nicolas Meienberger 2 năm trước cách đây
mục cha
commit
e9590f8806

+ 1 - 1
.eslintrc.js

@@ -1,5 +1,5 @@
 module.exports = {
-  plugins: ['@typescript-eslint', 'import', 'react', 'jest', 'jsdoc', 'import'],
+  plugins: ['@typescript-eslint', 'import', 'react', 'jest', 'jsdoc'],
   extends: [
     'plugin:@typescript-eslint/recommended',
     'next/core-web-vitals',

+ 2 - 0
package.json

@@ -31,6 +31,8 @@
   "dependencies": {
     "@hookform/resolvers": "^2.9.10",
     "@prisma/client": "^4.11.0",
+    "@radix-ui/react-dialog": "^1.0.3",
+    "@radix-ui/react-switch": "^1.0.2",
     "@radix-ui/react-tabs": "^1.0.3",
     "@runtipi/postgres-migrations": "^5.3.0",
     "@tabler/core": "1.0.0-beta17",

+ 236 - 0
pnpm-lock.yaml

@@ -7,6 +7,12 @@ dependencies:
   '@prisma/client':
     specifier: ^4.11.0
     version: 4.11.0(prisma@4.11.0)
+  '@radix-ui/react-dialog':
+    specifier: ^1.0.3
+    version: 1.0.3(@types/react@18.0.28)(react-dom@18.2.0)(react@18.2.0)
+  '@radix-ui/react-switch':
+    specifier: ^1.0.2
+    version: 1.0.2(react-dom@18.2.0)(react@18.2.0)
   '@radix-ui/react-tabs':
     specifier: ^1.0.3
     version: 1.0.3(react-dom@18.2.0)(react@18.2.0)
@@ -1558,6 +1564,33 @@ packages:
       react: 18.2.0
     dev: false
 
+  /@radix-ui/react-dialog@1.0.3(@types/react@18.0.28)(react-dom@18.2.0)(react@18.2.0):
+    resolution: {integrity: sha512-owNhq36kNPqC2/a+zJRioPg6HHnTn5B/sh/NjTY8r4W9g1L5VJlrzZIVcBr7R9Mg8iLjVmh6MGgMlfoVf/WO/A==}
+    peerDependencies:
+      react: ^16.8 || ^17.0 || ^18.0
+      react-dom: ^16.8 || ^17.0 || ^18.0
+    dependencies:
+      '@babel/runtime': 7.20.13
+      '@radix-ui/primitive': 1.0.0
+      '@radix-ui/react-compose-refs': 1.0.0(react@18.2.0)
+      '@radix-ui/react-context': 1.0.0(react@18.2.0)
+      '@radix-ui/react-dismissable-layer': 1.0.3(react-dom@18.2.0)(react@18.2.0)
+      '@radix-ui/react-focus-guards': 1.0.0(react@18.2.0)
+      '@radix-ui/react-focus-scope': 1.0.2(react-dom@18.2.0)(react@18.2.0)
+      '@radix-ui/react-id': 1.0.0(react@18.2.0)
+      '@radix-ui/react-portal': 1.0.2(react-dom@18.2.0)(react@18.2.0)
+      '@radix-ui/react-presence': 1.0.0(react-dom@18.2.0)(react@18.2.0)
+      '@radix-ui/react-primitive': 1.0.2(react-dom@18.2.0)(react@18.2.0)
+      '@radix-ui/react-slot': 1.0.1(react@18.2.0)
+      '@radix-ui/react-use-controllable-state': 1.0.0(react@18.2.0)
+      aria-hidden: 1.2.3
+      react: 18.2.0
+      react-dom: 18.2.0(react@18.2.0)
+      react-remove-scroll: 2.5.5(@types/react@18.0.28)(react@18.2.0)
+    transitivePeerDependencies:
+      - '@types/react'
+    dev: false
+
   /@radix-ui/react-direction@1.0.0(react@18.2.0):
     resolution: {integrity: sha512-2HV05lGUgYcA6xgLQ4BKPDmtL+QbIZYH5fCOTAOOcJ5O0QbWS3i9lKaurLzliYUDhORI2Qr3pyjhJh44lKA3rQ==}
     peerDependencies:
@@ -1567,6 +1600,45 @@ packages:
       react: 18.2.0
     dev: false
 
+  /@radix-ui/react-dismissable-layer@1.0.3(react-dom@18.2.0)(react@18.2.0):
+    resolution: {integrity: sha512-nXZOvFjOuHS1ovumntGV7NNoLaEp9JEvTht3MBjP44NSW5hUKj/8OnfN3+8WmB+CEhN44XaGhpHoSsUIEl5P7Q==}
+    peerDependencies:
+      react: ^16.8 || ^17.0 || ^18.0
+      react-dom: ^16.8 || ^17.0 || ^18.0
+    dependencies:
+      '@babel/runtime': 7.20.13
+      '@radix-ui/primitive': 1.0.0
+      '@radix-ui/react-compose-refs': 1.0.0(react@18.2.0)
+      '@radix-ui/react-primitive': 1.0.2(react-dom@18.2.0)(react@18.2.0)
+      '@radix-ui/react-use-callback-ref': 1.0.0(react@18.2.0)
+      '@radix-ui/react-use-escape-keydown': 1.0.2(react@18.2.0)
+      react: 18.2.0
+      react-dom: 18.2.0(react@18.2.0)
+    dev: false
+
+  /@radix-ui/react-focus-guards@1.0.0(react@18.2.0):
+    resolution: {integrity: sha512-UagjDk4ijOAnGu4WMUPj9ahi7/zJJqNZ9ZAiGPp7waUWJO0O1aWXi/udPphI0IUjvrhBsZJGSN66dR2dsueLWQ==}
+    peerDependencies:
+      react: ^16.8 || ^17.0 || ^18.0
+    dependencies:
+      '@babel/runtime': 7.20.13
+      react: 18.2.0
+    dev: false
+
+  /@radix-ui/react-focus-scope@1.0.2(react-dom@18.2.0)(react@18.2.0):
+    resolution: {integrity: sha512-spwXlNTfeIprt+kaEWE/qYuYT3ZAqJiAGjN/JgdvgVDTu8yc+HuX+WOWXrKliKnLnwck0F6JDkqIERncnih+4A==}
+    peerDependencies:
+      react: ^16.8 || ^17.0 || ^18.0
+      react-dom: ^16.8 || ^17.0 || ^18.0
+    dependencies:
+      '@babel/runtime': 7.20.13
+      '@radix-ui/react-compose-refs': 1.0.0(react@18.2.0)
+      '@radix-ui/react-primitive': 1.0.2(react-dom@18.2.0)(react@18.2.0)
+      '@radix-ui/react-use-callback-ref': 1.0.0(react@18.2.0)
+      react: 18.2.0
+      react-dom: 18.2.0(react@18.2.0)
+    dev: false
+
   /@radix-ui/react-id@1.0.0(react@18.2.0):
     resolution: {integrity: sha512-Q6iAB/U7Tq3NTolBBQbHTgclPmGWE3OlktGGqrClPozSw4vkQ1DfQAOtzgRPecKsMdJINE05iaoDUG8tRzCBjw==}
     peerDependencies:
@@ -1577,6 +1649,18 @@ packages:
       react: 18.2.0
     dev: false
 
+  /@radix-ui/react-portal@1.0.2(react-dom@18.2.0)(react@18.2.0):
+    resolution: {integrity: sha512-swu32idoCW7KA2VEiUZGBSu9nB6qwGdV6k6HYhUoOo3M1FFpD+VgLzUqtt3mwL1ssz7r2x8MggpLSQach2Xy/Q==}
+    peerDependencies:
+      react: ^16.8 || ^17.0 || ^18.0
+      react-dom: ^16.8 || ^17.0 || ^18.0
+    dependencies:
+      '@babel/runtime': 7.20.13
+      '@radix-ui/react-primitive': 1.0.2(react-dom@18.2.0)(react@18.2.0)
+      react: 18.2.0
+      react-dom: 18.2.0(react@18.2.0)
+    dev: false
+
   /@radix-ui/react-presence@1.0.0(react-dom@18.2.0)(react@18.2.0):
     resolution: {integrity: sha512-A+6XEvN01NfVWiKu38ybawfHsBjWum42MRPnEuqPsBZ4eV7e/7K321B5VgYMPv3Xx5An6o1/l9ZuDBgmcmWK3w==}
     peerDependencies:
@@ -1632,6 +1716,24 @@ packages:
       react: 18.2.0
     dev: false
 
+  /@radix-ui/react-switch@1.0.2(react-dom@18.2.0)(react@18.2.0):
+    resolution: {integrity: sha512-BcG/LKehxt36NXG0wPnoCitIfSMtU9Xo7BmythYA1PAMLtsMvW7kALfBzmduQoHTWcKr0AVcFyh0gChBUp9TiQ==}
+    peerDependencies:
+      react: ^16.8 || ^17.0 || ^18.0
+      react-dom: ^16.8 || ^17.0 || ^18.0
+    dependencies:
+      '@babel/runtime': 7.20.13
+      '@radix-ui/primitive': 1.0.0
+      '@radix-ui/react-compose-refs': 1.0.0(react@18.2.0)
+      '@radix-ui/react-context': 1.0.0(react@18.2.0)
+      '@radix-ui/react-primitive': 1.0.2(react-dom@18.2.0)(react@18.2.0)
+      '@radix-ui/react-use-controllable-state': 1.0.0(react@18.2.0)
+      '@radix-ui/react-use-previous': 1.0.0(react@18.2.0)
+      '@radix-ui/react-use-size': 1.0.0(react@18.2.0)
+      react: 18.2.0
+      react-dom: 18.2.0(react@18.2.0)
+    dev: false
+
   /@radix-ui/react-tabs@1.0.3(react-dom@18.2.0)(react@18.2.0):
     resolution: {integrity: sha512-4CkF/Rx1GcrusI/JZ1Rvyx4okGUs6wEenWA0RG/N+CwkRhTy7t54y7BLsWUXrAz/GRbBfHQg/Odfs/RoW0CiRA==}
     peerDependencies:
@@ -1670,6 +1772,16 @@ packages:
       react: 18.2.0
     dev: false
 
+  /@radix-ui/react-use-escape-keydown@1.0.2(react@18.2.0):
+    resolution: {integrity: sha512-DXGim3x74WgUv+iMNCF+cAo8xUHHeqvjx8zs7trKf+FkQKPQXLk2sX7Gx1ysH7Q76xCpZuxIJE7HLPxRE+Q+GA==}
+    peerDependencies:
+      react: ^16.8 || ^17.0 || ^18.0
+    dependencies:
+      '@babel/runtime': 7.20.13
+      '@radix-ui/react-use-callback-ref': 1.0.0(react@18.2.0)
+      react: 18.2.0
+    dev: false
+
   /@radix-ui/react-use-layout-effect@1.0.0(react@18.2.0):
     resolution: {integrity: sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ==}
     peerDependencies:
@@ -1679,6 +1791,25 @@ packages:
       react: 18.2.0
     dev: false
 
+  /@radix-ui/react-use-previous@1.0.0(react@18.2.0):
+    resolution: {integrity: sha512-RG2K8z/K7InnOKpq6YLDmT49HGjNmrK+fr82UCVKT2sW0GYfVnYp4wZWBooT/EYfQ5faA9uIjvsuMMhH61rheg==}
+    peerDependencies:
+      react: ^16.8 || ^17.0 || ^18.0
+    dependencies:
+      '@babel/runtime': 7.20.13
+      react: 18.2.0
+    dev: false
+
+  /@radix-ui/react-use-size@1.0.0(react@18.2.0):
+    resolution: {integrity: sha512-imZ3aYcoYCKhhgNpkNDh/aTiU05qw9hX+HHI1QDBTyIlcFjgeFlKKySNGMwTp7nYFLQg/j0VA2FmCY4WPDDHMg==}
+    peerDependencies:
+      react: ^16.8 || ^17.0 || ^18.0
+    dependencies:
+      '@babel/runtime': 7.20.13
+      '@radix-ui/react-use-layout-effect': 1.0.0(react@18.2.0)
+      react: 18.2.0
+    dev: false
+
   /@redis/bloom@1.2.0(@redis/client@1.5.6):
     resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==}
     peerDependencies:
@@ -2616,6 +2747,13 @@ packages:
     resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
     dev: true
 
+  /aria-hidden@1.2.3:
+    resolution: {integrity: sha512-xcLxITLe2HYa1cnYnwCjkOO1PqUHQpozB8x9AR0OgWN2woOBi5kSDVxKfd0b7sb1hw5qFeJhXm9H1nu3xSfLeQ==}
+    engines: {node: '>=10'}
+    dependencies:
+      tslib: 2.5.0
+    dev: false
+
   /aria-query@5.1.3:
     resolution: {integrity: sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==}
     dependencies:
@@ -3370,6 +3508,10 @@ packages:
     engines: {node: '>=8'}
     dev: true
 
+  /detect-node-es@1.1.0:
+    resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
+    dev: false
+
   /diff-sequences@29.4.3:
     resolution: {integrity: sha512-ofrBgwpPhCD85kMKtE9RYFFq6OC1A89oW2vvgWZNCwxrUpRUILopY7lsYyMDSjc8g6U6aiO0Qubg6r4Wgt5ZnA==}
     engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -4401,6 +4543,11 @@ packages:
       has: 1.0.3
       has-symbols: 1.0.3
 
+  /get-nonce@1.0.1:
+    resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
+    engines: {node: '>=6'}
+    dev: false
+
   /get-package-type@0.1.0:
     resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==}
     engines: {node: '>=8.0.0'}
@@ -4735,6 +4882,12 @@ packages:
       side-channel: 1.0.4
     dev: true
 
+  /invariant@2.2.4:
+    resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==}
+    dependencies:
+      loose-envify: 1.4.0
+    dev: false
+
   /ipaddr.js@1.9.1:
     resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
     engines: {node: '>= 0.10'}
@@ -7059,6 +7212,41 @@ packages:
       - supports-color
     dev: false
 
+  /react-remove-scroll-bar@2.3.4(@types/react@18.0.28)(react@18.2.0):
+    resolution: {integrity: sha512-63C4YQBUt0m6ALadE9XV56hV8BgJWDmmTPY758iIJjfQKt2nYwoUrPk0LXRXcB/yIj82T1/Ixfdpdk68LwIB0A==}
+    engines: {node: '>=10'}
+    peerDependencies:
+      '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0
+      react: ^16.8.0 || ^17.0.0 || ^18.0.0
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+    dependencies:
+      '@types/react': 18.0.28
+      react: 18.2.0
+      react-style-singleton: 2.2.1(@types/react@18.0.28)(react@18.2.0)
+      tslib: 2.5.0
+    dev: false
+
+  /react-remove-scroll@2.5.5(@types/react@18.0.28)(react@18.2.0):
+    resolution: {integrity: sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==}
+    engines: {node: '>=10'}
+    peerDependencies:
+      '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0
+      react: ^16.8.0 || ^17.0.0 || ^18.0.0
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+    dependencies:
+      '@types/react': 18.0.28
+      react: 18.2.0
+      react-remove-scroll-bar: 2.3.4(@types/react@18.0.28)(react@18.2.0)
+      react-style-singleton: 2.2.1(@types/react@18.0.28)(react@18.2.0)
+      tslib: 2.5.0
+      use-callback-ref: 1.3.0(@types/react@18.0.28)(react@18.2.0)
+      use-sidecar: 1.1.2(@types/react@18.0.28)(react@18.2.0)
+    dev: false
+
   /react-select@5.7.0(@types/react@18.0.28)(react-dom@18.2.0)(react@18.2.0):
     resolution: {integrity: sha512-lJGiMxCa3cqnUr2Jjtg9YHsaytiZqeNOKeibv6WF5zbK/fPegZ1hg3y/9P1RZVLhqBTs0PfqQLKuAACednYGhQ==}
     peerDependencies:
@@ -7088,6 +7276,23 @@ packages:
       react: 18.2.0
     dev: false
 
+  /react-style-singleton@2.2.1(@types/react@18.0.28)(react@18.2.0):
+    resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==}
+    engines: {node: '>=10'}
+    peerDependencies:
+      '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0
+      react: ^16.8.0 || ^17.0.0 || ^18.0.0
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+    dependencies:
+      '@types/react': 18.0.28
+      get-nonce: 1.0.1
+      invariant: 2.2.4
+      react: 18.2.0
+      tslib: 2.5.0
+    dev: false
+
   /react-tooltip@4.5.1(react-dom@18.2.0)(react@18.2.0):
     resolution: {integrity: sha512-Zo+CSFUGXar1uV+bgXFFDe7VeS2iByeIp5rTgTcc2HqtuOS5D76QapejNNfx320MCY91TlhTQat36KGFTqgcvw==}
     engines: {npm: '>=6.13'}
@@ -8107,6 +8312,21 @@ packages:
       requires-port: 1.0.0
     dev: true
 
+  /use-callback-ref@1.3.0(@types/react@18.0.28)(react@18.2.0):
+    resolution: {integrity: sha512-3FT9PRuRdbB9HfXhEq35u4oZkvpJ5kuYbpqhCfmiZyReuRgpnhDlbr2ZEnnuS0RrJAPn6l23xjFg9kpDM+Ms7w==}
+    engines: {node: '>=10'}
+    peerDependencies:
+      '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0
+      react: ^16.8.0 || ^17.0.0 || ^18.0.0
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+    dependencies:
+      '@types/react': 18.0.28
+      react: 18.2.0
+      tslib: 2.5.0
+    dev: false
+
   /use-isomorphic-layout-effect@1.1.2(@types/react@18.0.28)(react@18.2.0):
     resolution: {integrity: sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==}
     peerDependencies:
@@ -8120,6 +8340,22 @@ packages:
       react: 18.2.0
     dev: false
 
+  /use-sidecar@1.1.2(@types/react@18.0.28)(react@18.2.0):
+    resolution: {integrity: sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==}
+    engines: {node: '>=10'}
+    peerDependencies:
+      '@types/react': ^16.9.0 || ^17.0.0 || ^18.0.0
+      react: ^16.8.0 || ^17.0.0 || ^18.0.0
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+    dependencies:
+      '@types/react': 18.0.28
+      detect-node-es: 1.1.0
+      react: 18.2.0
+      tslib: 2.5.0
+    dev: false
+
   /use-sync-external-store@1.2.0(react@18.2.0):
     resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==}
     peerDependencies:

+ 40 - 19
src/client/modules/Apps/components/AppActions/AppActions.test.tsx

@@ -13,70 +13,91 @@ describe('Test: AppActions', () => {
     exposable: [],
   } as unknown as AppInfo;
 
-  it('should render the correct buttons when app status is stopped', () => {
-    // Arrange
+  it('should call the callbacks when buttons are clicked', () => {
+    // arrange
     const onStart = jest.fn();
     const onRemove = jest.fn();
     // @ts-expect-error
-    const { getByText } = render(<AppActions status="stopped" info={app} onStart={onStart} onUninstall={onRemove} />);
+    render(<AppActions status="stopped" info={app} onStart={onStart} onUninstall={onRemove} />);
 
-    // Act
-    fireEvent.click(getByText('Start'));
-    fireEvent.click(getByText('Remove'));
+    // act
+    const startButton = screen.getByRole('button', { name: 'Start' });
+    fireEvent.click(startButton);
+    const removeButton = screen.getByText('Remove');
+    fireEvent.click(removeButton);
 
-    // Assert
-    expect(getByText('Start')).toBeInTheDocument();
-    expect(getByText('Remove')).toBeInTheDocument();
+    // assert
     expect(onStart).toHaveBeenCalled();
     expect(onRemove).toHaveBeenCalled();
   });
 
   it('should render the correct buttons when app status is running', () => {
+    // arrange
     // @ts-expect-error
-    const { getByText } = render(<AppActions status="running" info={app} />);
-    expect(getByText('Stop')).toBeInTheDocument();
-    expect(getByText('Open')).toBeInTheDocument();
-    expect(getByText('Settings')).toBeInTheDocument();
+    render(<AppActions status="running" info={app} />);
+
+    // assert
+    expect(screen.getByRole('button', { name: 'Stop' })).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: 'Open' })).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument();
   });
 
   it('should render the correct buttons when app status is starting', () => {
+    // arrange
     // @ts-expect-error
     render(<AppActions status="starting" info={app} />);
-    expect(screen.getByText('Cancel')).toBeInTheDocument();
+
+    // assert
+    expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
     expect(screen.getByTestId('action-button-loading')).toBeInTheDocument();
   });
 
   it('should render the correct buttons when app status is stopping', () => {
+    // arrange
     // @ts-expect-error
     render(<AppActions status="stopping" info={app} />);
-    expect(screen.getByText('Cancel')).toBeInTheDocument();
+
+    // assert
+    expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
     expect(screen.getByTestId('action-button-loading')).toBeInTheDocument();
   });
 
   it('should render the correct buttons when app status is removing', () => {
+    // arrange
     // @ts-expect-error
     render(<AppActions status="uninstalling" info={app} />);
-    expect(screen.getByText('Cancel')).toBeInTheDocument();
+
+    // assert
+    expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
     expect(screen.getByTestId('action-button-loading')).toBeInTheDocument();
   });
 
   it('should render the correct buttons when app status is installing', () => {
+    // arrange
     // @ts-ignore
     render(<AppActions status="installing" info={app} />);
-    expect(screen.getByText('Cancel')).toBeInTheDocument();
+
+    // assert
+    expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
     expect(screen.getByTestId('action-button-loading')).toBeInTheDocument();
   });
 
   it('should render the correct buttons when app status is updating', () => {
+    // arrange
     // @ts-expect-error
     render(<AppActions status="updating" info={app} />);
-    expect(screen.getByText('Cancel')).toBeInTheDocument();
+
+    // assert
+    expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
     expect(screen.getByTestId('action-button-loading')).toBeInTheDocument();
   });
 
   it('should render the correct buttons when app status is missing', () => {
+    // arrange
     // @ts-expect-error
     render(<AppActions status="missing" info={app} />);
-    expect(screen.getByText('Install')).toBeInTheDocument();
+
+    // assert
+    expect(screen.getByRole('button', { name: 'Install' })).toBeInTheDocument();
   });
 });

+ 3 - 1
src/client/modules/Apps/components/AppActions/AppActions.tsx

@@ -32,8 +32,10 @@ interface BtnProps {
 const ActionButton: React.FC<BtnProps> = (props) => {
   const { IconComponent, onClick, title, loading, color, width = 140 } = props;
 
+  const testId = loading ? 'action-button-loading' : undefined;
+
   return (
-    <Button loading={loading} data-testid={`action-button-${title?.toLowerCase()}`} onClick={onClick} width={width} className={clsx('me-2 px-4 mt-2', [`btn-${color}`])}>
+    <Button loading={loading} data-testid={testId} onClick={onClick} width={width} className={clsx('me-2 px-4 mt-2', [`btn-${color}`])}>
       {title}
       {IconComponent && <IconComponent className="ms-1" size={14} />}
     </Button>

+ 1 - 3
src/client/modules/Apps/components/InstallForm/InstallForm.tsx

@@ -83,10 +83,8 @@ export const InstallForm: React.FC<IProps> = ({ formFields, onSubmit, initalValu
     }
   };
 
-  const name = initalValues ? 'update' : 'install';
-
   return (
-    <form data-testid={`${name}-form`} className="flex flex-col" onSubmit={handleSubmit(validate)}>
+    <form className="flex flex-col" onSubmit={handleSubmit(validate)}>
       {formFields.filter(typeFilter).map(renderField)}
       {exposable && renderExposeForm()}
       <Button loading={loading} type="submit" className="btn-success">

+ 11 - 9
src/client/modules/Apps/components/InstallModal/InstallModal.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
+import { Dialog, DialogContent, DialogDescription, DialogHeader } from '@/components/ui/Dialog';
 import { InstallForm } from '../InstallForm';
-import { Modal, ModalBody, ModalHeader } from '../../../../components/ui/Modal';
 import { AppInfo } from '../../../../core/types';
 import { FormValues } from '../InstallForm/InstallForm';
 
@@ -12,12 +12,14 @@ interface IProps {
 }
 
 export const InstallModal: React.FC<IProps> = ({ info, isOpen, onClose, onSubmit }) => (
-  <Modal onClose={onClose} isOpen={isOpen}>
-    <ModalHeader>
-      <h5 className="modal-title">Install {info.name}</h5>
-    </ModalHeader>
-    <ModalBody>
-      <InstallForm onSubmit={onSubmit} formFields={info.form_fields} exposable={info.exposable} />
-    </ModalBody>
-  </Modal>
+  <Dialog open={isOpen} onOpenChange={onClose}>
+    <DialogContent>
+      <DialogHeader>
+        <h5 className="modal-title">Install {info.name}</h5>
+      </DialogHeader>
+      <DialogDescription>
+        <InstallForm onSubmit={onSubmit} formFields={info.form_fields} exposable={info.exposable} />
+      </DialogDescription>
+    </DialogContent>
+  </Dialog>
 );

+ 16 - 14
src/client/modules/Apps/components/StopModal.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
 import { Button } from '../../../components/ui/Button';
-import { Modal, ModalBody, ModalFooter, ModalHeader } from '../../../components/ui/Modal';
 import { AppInfo } from '../../../core/types';
 
 interface IProps {
@@ -11,17 +11,19 @@ interface IProps {
 }
 
 export const StopModal: React.FC<IProps> = ({ info, isOpen, onClose, onConfirm }) => (
-  <Modal size="sm" onClose={onClose} isOpen={isOpen}>
-    <ModalHeader>
-      <h5 className="modal-title">Stop {info.name} ?</h5>
-    </ModalHeader>
-    <ModalBody>
-      <div className="text-muted">All data will be retained</div>
-    </ModalBody>
-    <ModalFooter>
-      <Button data-testid="modal-stop-button" onClick={onConfirm} className="btn-danger">
-        Stop
-      </Button>
-    </ModalFooter>
-  </Modal>
+  <Dialog open={isOpen} onOpenChange={onClose}>
+    <DialogContent size="sm">
+      <DialogHeader>
+        <h5 className="modal-title">Stop {info.name} ?</h5>
+      </DialogHeader>
+      <DialogDescription>
+        <div className="text-muted">All data will be retained</div>
+      </DialogDescription>
+      <DialogFooter>
+        <Button onClick={onConfirm} className="btn-danger">
+          Stop
+        </Button>
+      </DialogFooter>
+    </DialogContent>
+  </Dialog>
 );

+ 18 - 16
src/client/modules/Apps/components/UninstallModal.tsx

@@ -1,7 +1,7 @@
 import { IconAlertTriangle } from '@tabler/icons-react';
 import React from 'react';
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
 import { Button } from '../../../components/ui/Button';
-import { Modal, ModalBody, ModalFooter, ModalHeader } from '../../../components/ui/Modal';
 import { AppInfo } from '../../../core/types';
 
 interface IProps {
@@ -12,19 +12,21 @@ interface IProps {
 }
 
 export const UninstallModal: React.FC<IProps> = ({ info, isOpen, onClose, onConfirm }) => (
-  <Modal size="sm" type="danger" onClose={onClose} isOpen={isOpen}>
-    <ModalHeader>
-      <h5 className="modal-title">Uninstall {info.name} ?</h5>
-    </ModalHeader>
-    <ModalBody className="text-center py-4">
-      <IconAlertTriangle className="icon mb-2 text-danger icon-lg" />
-      <h3>Are you sure?</h3>
-      <div className="text-muted">All data for this app will be lost.</div>
-    </ModalBody>
-    <ModalFooter>
-      <Button onClick={onConfirm} className="btn-danger">
-        Uninstall
-      </Button>
-    </ModalFooter>
-  </Modal>
+  <Dialog open={isOpen} onOpenChange={onClose}>
+    <DialogContent type="danger" size="sm">
+      <DialogHeader>
+        <h5 className="modal-title">Uninstall {info.name} ?</h5>
+      </DialogHeader>
+      <DialogDescription className="text-center py-4">
+        <IconAlertTriangle className="icon mb-2 text-danger icon-lg" />
+        <h3>Are you sure?</h3>
+        <div className="text-muted">All data for this app will be lost.</div>
+      </DialogDescription>
+      <DialogFooter>
+        <Button onClick={onConfirm} className="btn-danger">
+          Uninstall
+        </Button>
+      </DialogFooter>
+    </DialogContent>
+  </Dialog>
 );

+ 9 - 9
src/client/modules/Apps/components/UpdateModal/UpdateModal.test.tsx

@@ -7,40 +7,40 @@ describe('UpdateModal', () => {
   const newVersion = '1.2.3';
 
   it('renders with the correct title and version number', () => {
-    // Arrange
+    // arrange
     render(<UpdateModal info={app} newVersion={newVersion} isOpen onClose={jest.fn()} onConfirm={jest.fn()} />);
 
-    // Assert
+    // assert
     expect(screen.getByText(`Update ${app.name} ?`)).toBeInTheDocument();
     expect(screen.getByText(`${newVersion}`)).toBeInTheDocument();
   });
 
   it('should not render when isOpen is false', () => {
-    // Arrange
+    // arrange
     render(<UpdateModal info={app} newVersion={newVersion} isOpen={false} onClose={jest.fn()} onConfirm={jest.fn()} />);
     const modal = screen.queryByTestId('modal');
 
-    // Assert (modal should have style display: none)
-    expect(modal).toHaveStyle('display: none');
+    // assert
+    expect(modal).not.toBeInTheDocument();
   });
 
   it('calls onClose when the close button is clicked', () => {
-    // Arrange
+    // arrange
     const onClose = jest.fn();
     render(<UpdateModal info={app} newVersion={newVersion} isOpen onClose={onClose} onConfirm={jest.fn()} />);
 
-    // Act
+    // act
     const closeButton = screen.getByTestId('modal-close-button');
     fireEvent.click(closeButton);
     expect(onClose).toHaveBeenCalled();
   });
 
   it('calls onConfirm when the update button is clicked', () => {
-    // Arrange
+    // arrange
     const onConfirm = jest.fn();
     render(<UpdateModal info={app} newVersion={newVersion} isOpen onClose={jest.fn()} onConfirm={onConfirm} />);
 
-    // Act
+    // act
     const updateButton = screen.getByText('Update');
     fireEvent.click(updateButton);
     expect(onConfirm).toHaveBeenCalled();

+ 19 - 17
src/client/modules/Apps/components/UpdateModal/UpdateModal.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
 import { Button } from '../../../../components/ui/Button';
-import { Modal, ModalBody, ModalFooter, ModalHeader } from '../../../../components/ui/Modal';
 import { AppInfo } from '../../../../core/types';
 
 interface IProps {
@@ -12,20 +12,22 @@ interface IProps {
 }
 
 export const UpdateModal: React.FC<IProps> = ({ info, newVersion, isOpen, onClose, onConfirm }) => (
-  <Modal size="sm" onClose={onClose} isOpen={isOpen}>
-    <ModalHeader>
-      <h5 className="modal-title">Update {info.name} ?</h5>
-    </ModalHeader>
-    <ModalBody>
-      <div className="text-muted">
-        Update app to latest verion : <b>{newVersion}</b> ?<br />
-        This will reset your custom configuration (e.g. changes in docker-compose.yml)
-      </div>
-    </ModalBody>
-    <ModalFooter>
-      <Button data-testid="modal-update-button" onClick={onConfirm} className="btn-success">
-        Update
-      </Button>
-    </ModalFooter>
-  </Modal>
+  <Dialog open={isOpen} onOpenChange={onClose}>
+    <DialogContent size="sm">
+      <DialogHeader>
+        <h5 className="modal-title">Update {info.name} ?</h5>
+      </DialogHeader>
+      <DialogDescription>
+        <div className="text-muted">
+          Update app to latest verion : <b>{newVersion}</b> ?<br />
+          This will reset your custom configuration (e.g. changes in docker-compose.yml)
+        </div>
+      </DialogDescription>
+      <DialogFooter>
+        <Button onClick={onConfirm} className="btn-success">
+          Update
+        </Button>
+      </DialogFooter>
+    </DialogContent>
+  </Dialog>
 );

+ 11 - 9
src/client/modules/Apps/components/UpdateSettingsModal.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
+import { Dialog, DialogContent, DialogDescription, DialogHeader } from '@/components/ui/Dialog';
 import { InstallForm } from './InstallForm';
-import { Modal, ModalBody, ModalHeader } from '../../../components/ui/Modal';
 import { AppInfo } from '../../../core/types';
 import { FormValues } from './InstallForm/InstallForm';
 
@@ -15,12 +15,14 @@ interface IProps {
 }
 
 export const UpdateSettingsModal: React.FC<IProps> = ({ info, config, isOpen, onClose, onSubmit, exposed, domain }) => (
-  <Modal onClose={onClose} isOpen={isOpen}>
-    <ModalHeader>
-      <h5 className="modal-title">Update {info.name} config</h5>
-    </ModalHeader>
-    <ModalBody>
-      <InstallForm onSubmit={onSubmit} formFields={info.form_fields} exposable={info.exposable} initalValues={{ ...config, exposed, domain }} />
-    </ModalBody>
-  </Modal>
+  <Dialog open={isOpen} onOpenChange={onClose}>
+    <DialogContent>
+      <DialogHeader>
+        <h5 className="modal-title">Update {info.name} config</h5>
+      </DialogHeader>
+      <DialogDescription>
+        <InstallForm onSubmit={onSubmit} formFields={info.form_fields} exposable={info.exposable} initalValues={{ ...config, exposed, domain }} />
+      </DialogDescription>
+    </DialogContent>
+  </Dialog>
 );

+ 45 - 138
src/client/modules/Apps/containers/AppDetailsContainer/AppDetailsContainer.test.tsx

@@ -23,7 +23,7 @@ describe('Test: AppDetailsContainer', () => {
       render(<AppDetailsContainer app={app} />);
 
       // Assert
-      expect(screen.getByTestId('action-button-update')).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: 'Update' })).toBeInTheDocument();
     });
 
     it('should display install button when app is not installed', async () => {
@@ -33,7 +33,7 @@ describe('Test: AppDetailsContainer', () => {
       render(<AppDetailsContainer app={app} />);
 
       // Assert
-      expect(screen.getByTestId('action-button-install')).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: 'Install' })).toBeInTheDocument();
     });
 
     it('should display uninstall and start button when app is stopped', async () => {
@@ -43,8 +43,8 @@ describe('Test: AppDetailsContainer', () => {
       render(<AppDetailsContainer app={app} />);
 
       // Assert
-      expect(screen.getByTestId('action-button-remove')).toBeInTheDocument();
-      expect(screen.getByTestId('action-button-start')).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: 'Start' })).toBeInTheDocument();
     });
 
     it('should display stop, open and settings buttons when app is running', async () => {
@@ -53,9 +53,9 @@ describe('Test: AppDetailsContainer', () => {
       render(<AppDetailsContainer app={app} />);
 
       // Assert
-      expect(screen.getByTestId('action-button-stop')).toBeInTheDocument();
-      expect(screen.getByTestId('action-button-open')).toBeInTheDocument();
-      expect(screen.getByTestId('action-button-settings')).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: 'Stop' })).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: 'Open' })).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument();
     });
 
     it('should not display update button when update is not available', async () => {
@@ -64,7 +64,7 @@ describe('Test: AppDetailsContainer', () => {
       render(<AppDetailsContainer app={app} />);
 
       // Assert
-      expect(screen.queryByTestId('action-button-update')).not.toBeInTheDocument();
+      expect(screen.queryByRole('button', { name: 'Update' })).not.toBeInTheDocument();
     });
 
     it('should not display open button when app has no_gui set to true', async () => {
@@ -73,7 +73,7 @@ describe('Test: AppDetailsContainer', () => {
       render(<AppDetailsContainer app={app} />);
 
       // Assert
-      expect(screen.queryByTestId('action-button-open')).not.toBeInTheDocument();
+      expect(screen.queryByRole('button', { name: 'Open' })).not.toBeInTheDocument();
     });
   });
 
@@ -85,7 +85,7 @@ describe('Test: AppDetailsContainer', () => {
       render(<AppDetailsContainer app={app} />);
 
       // Act
-      const openButton = screen.getByTestId('action-button-open');
+      const openButton = screen.getByRole('button', { name: 'Open' });
       openButton.click();
 
       // Assert
@@ -99,7 +99,7 @@ describe('Test: AppDetailsContainer', () => {
       render(<AppDetailsContainer app={app} />);
 
       // Act
-      const openButton = screen.getByTestId('action-button-open');
+      const openButton = screen.getByRole('button', { name: 'Open' });
       openButton.click();
 
       // Assert
@@ -114,10 +114,12 @@ describe('Test: AppDetailsContainer', () => {
       server.use(getTRPCMock({ path: ['app', 'installApp'], type: 'mutation', response: app }));
       const { result } = renderHook(() => useToastStore());
       render(<AppDetailsContainer app={app} />);
+      const openModalButton = screen.getByRole('button', { name: 'Install' });
+      fireEvent.click(openModalButton);
 
       // Act
-      const installForm = screen.getByTestId('install-form');
-      fireEvent.submit(installForm);
+      const installButton = screen.getByRole('button', { name: 'Install' });
+      fireEvent.click(installButton);
 
       await waitFor(() => {
         expect(result.current.toasts).toHaveLength(1);
@@ -139,10 +141,12 @@ describe('Test: AppDetailsContainer', () => {
 
       const app = createAppEntity({ overrides: { status: 'missing' } });
       render(<AppDetailsContainer app={app} />);
+      const openModalButton = screen.getByRole('button', { name: 'Install' });
+      fireEvent.click(openModalButton);
 
       // Act
-      const installForm = screen.getByTestId('install-form');
-      fireEvent.submit(installForm);
+      const installButton = screen.getByRole('button', { name: 'Install' });
+      fireEvent.click(installButton);
 
       await waitFor(() => {
         expect(result.current.toasts).toHaveLength(1);
@@ -150,24 +154,6 @@ describe('Test: AppDetailsContainer', () => {
         expect(result.current.toasts[0].status).toEqual('error');
       });
     });
-
-    // Skipping because trpc.useContext is not working in tests
-    it.skip('should put the app in installing state when install mutation is called', async () => {
-      // Arrange
-      const { result } = renderHook(() => useToastStore());
-      const app = createAppEntity({ overrides: { status: 'missing' } });
-      server.use(getTRPCMock({ path: ['app', 'installApp'], type: 'mutation', response: app, delay: 100 }));
-      render(<AppDetailsContainer app={app} />);
-
-      // Act
-      const installForm = screen.getByTestId('install-form');
-      fireEvent.submit(installForm);
-
-      await waitFor(() => {
-        expect(screen.getByText('installing')).toBeInTheDocument();
-        expect(result.current.toasts).toHaveLength(1);
-      });
-    });
   });
 
   describe('Test: Update app', () => {
@@ -177,11 +163,11 @@ describe('Test: AppDetailsContainer', () => {
       server.use(getTRPCMock({ path: ['app', 'updateApp'], type: 'mutation', response: app }));
       const { result } = renderHook(() => useToastStore());
       render(<AppDetailsContainer app={app} />);
+      const openModalButton = screen.getByRole('button', { name: 'Update' });
+      fireEvent.click(openModalButton);
 
       // Act
-      const updateButton = screen.getByTestId('action-button-update');
-      updateButton.click();
-      const modalUpdateButton = screen.getByTestId('modal-update-button');
+      const modalUpdateButton = screen.getByRole('button', { name: 'Update' });
       modalUpdateButton.click();
 
       await waitFor(() => {
@@ -197,11 +183,11 @@ describe('Test: AppDetailsContainer', () => {
       server.use(getTRPCMockError({ path: ['app', 'updateApp'], type: 'mutation', message: 'my big error' }));
       const app = createAppEntity({ overrides: { version: 2, latestVersion: 3 }, overridesInfo: { tipi_version: 3 } });
       render(<AppDetailsContainer app={app} />);
+      const openModalButton = screen.getByRole('button', { name: 'Update' });
+      fireEvent.click(openModalButton);
 
       // Act
-      const updateButton = screen.getByTestId('action-button-update');
-      updateButton.click();
-      const modalUpdateButton = screen.getByTestId('modal-update-button');
+      const modalUpdateButton = screen.getByRole('button', { name: 'Update' });
       modalUpdateButton.click();
 
       // Assert
@@ -211,26 +197,6 @@ describe('Test: AppDetailsContainer', () => {
         expect(result.current.toasts[0].status).toEqual('error');
       });
     });
-
-    // Skipping because trpc.useContext is not working in tests
-    it.skip('should put the app in updating state when update mutation is called', async () => {
-      // Arrange
-      const { result } = renderHook(() => useToastStore());
-      const app = createAppEntity({ overrides: { version: 2 }, overridesInfo: { tipi_version: 3 } });
-      server.use(getTRPCMock({ path: ['app', 'updateApp'], type: 'mutation', response: app, delay: 100 }));
-      render(<AppDetailsContainer app={app} />);
-
-      // Act
-      const updateButton = screen.getByTestId('action-button-update');
-      updateButton.click();
-      const modalUpdateButton = screen.getByTestId('modal-update-button');
-      modalUpdateButton.click();
-
-      await waitFor(() => {
-        expect(screen.getByText('updating')).toBeInTheDocument();
-        expect(result.current.toasts).toHaveLength(1);
-      });
-    });
   });
 
   describe('Test: Uninstall app', () => {
@@ -240,11 +206,11 @@ describe('Test: AppDetailsContainer', () => {
       server.use(getTRPCMock({ path: ['app', 'uninstallApp'], type: 'mutation', response: { id: app.id, config: {}, status: 'missing' } }));
       const { result } = renderHook(() => useToastStore());
       render(<AppDetailsContainer app={app} />);
+      const openModalButton = screen.getByRole('button', { name: 'Remove' });
+      fireEvent.click(openModalButton);
 
       // Act
-      const uninstallButton = screen.getByTestId('action-button-remove');
-      uninstallButton.click();
-      const modalUninstallButton = screen.getByText('Uninstall');
+      const modalUninstallButton = screen.getByRole('button', { name: 'Uninstall' });
       modalUninstallButton.click();
 
       // Assert
@@ -261,11 +227,11 @@ describe('Test: AppDetailsContainer', () => {
       server.use(getTRPCMockError({ path: ['app', 'uninstallApp'], type: 'mutation', message: 'my big error' }));
       const app = createAppEntity({ status: 'stopped' });
       render(<AppDetailsContainer app={app} />);
+      const openModalButton = screen.getByRole('button', { name: 'Remove' });
+      fireEvent.click(openModalButton);
 
       // Act
-      const uninstallButton = screen.getByTestId('action-button-remove');
-      uninstallButton.click();
-      const modalUninstallButton = screen.getByText('Uninstall');
+      const modalUninstallButton = screen.getByRole('button', { name: 'Uninstall' });
       modalUninstallButton.click();
 
       // Assert
@@ -275,27 +241,6 @@ describe('Test: AppDetailsContainer', () => {
         expect(result.current.toasts[0].status).toEqual('error');
       });
     });
-
-    // Skipping because trpc.useContext is not working in tests
-    it.skip('should put the app in uninstalling state when uninstall mutation is called', async () => {
-      // Arrange
-      const { result } = renderHook(() => useToastStore());
-      const app = createAppEntity({ status: 'stopped' });
-      server.use(getTRPCMock({ path: ['app', 'uninstallApp'], type: 'mutation', response: { id: app.id, config: {}, status: 'missing' }, delay: 100 }));
-      render(<AppDetailsContainer app={app} />);
-
-      // Act
-      const uninstallButton = screen.getByTestId('action-button-remove');
-      uninstallButton.click();
-      const modalUninstallButton = screen.getByText('Uninstall');
-      modalUninstallButton.click();
-
-      await waitFor(() => {
-        expect(screen.getByText('uninstalling')).toBeInTheDocument();
-        expect(screen.queryByText('installing')).not.toBeInTheDocument();
-        expect(result.current.toasts).toHaveLength(1);
-      });
-    });
   });
 
   describe('Test: Start app', () => {
@@ -307,7 +252,7 @@ describe('Test: AppDetailsContainer', () => {
       render(<AppDetailsContainer app={app} />);
 
       // Act
-      const startButton = screen.getByTestId('action-button-start');
+      const startButton = screen.getByRole('button', { name: 'Start' });
       startButton.click();
 
       // Assert
@@ -326,7 +271,7 @@ describe('Test: AppDetailsContainer', () => {
       render(<AppDetailsContainer app={app} />);
 
       // Act
-      const startButton = screen.getByTestId('action-button-start');
+      const startButton = screen.getByRole('button', { name: 'Start' });
       startButton.click();
 
       // Assert
@@ -336,24 +281,6 @@ describe('Test: AppDetailsContainer', () => {
         expect(result.current.toasts[0].status).toEqual('error');
       });
     });
-
-    // Skipping because trpc.useContext is not working in tests
-    it.skip('should put the app in starting state when start mutation is called', async () => {
-      // Arrange
-      const { result } = renderHook(() => useToastStore());
-      const app = createAppEntity({ status: 'stopped' });
-      server.use(getTRPCMock({ path: ['app', 'startApp'], type: 'mutation', response: app, delay: 100 }));
-      render(<AppDetailsContainer app={app} />);
-
-      // Act
-      const startButton = screen.getByTestId('action-button-start');
-      startButton.click();
-
-      await waitFor(() => {
-        expect(screen.getByText('starting')).toBeInTheDocument();
-        expect(result.current.toasts).toHaveLength(1);
-      });
-    });
   });
 
   describe('Test: Stop app', () => {
@@ -363,11 +290,11 @@ describe('Test: AppDetailsContainer', () => {
       server.use(getTRPCMock({ path: ['app', 'stopApp'], type: 'mutation', response: app }));
       const { result } = renderHook(() => useToastStore());
       render(<AppDetailsContainer app={app} />);
+      const openModalButton = screen.getByRole('button', { name: 'Stop' });
+      fireEvent.click(openModalButton);
 
       // Act
-      const stopButton = screen.getByTestId('action-button-stop');
-      stopButton.click();
-      const modalStopButton = screen.getByTestId('modal-stop-button');
+      const modalStopButton = screen.getByRole('button', { name: 'Stop' });
       modalStopButton.click();
 
       // Assert
@@ -384,11 +311,11 @@ describe('Test: AppDetailsContainer', () => {
       server.use(getTRPCMockError({ path: ['app', 'stopApp'], type: 'mutation', message: 'my big error' }));
       const app = createAppEntity({ status: 'running' });
       render(<AppDetailsContainer app={app} />);
+      const openModalButton = screen.getByRole('button', { name: 'Stop' });
+      fireEvent.click(openModalButton);
 
       // Act
-      const stopButton = screen.getByTestId('action-button-stop');
-      stopButton.click();
-      const modalStopButton = screen.getByTestId('modal-stop-button');
+      const modalStopButton = screen.getByRole('button', { name: 'Stop' });
       modalStopButton.click();
 
       // Assert
@@ -398,26 +325,6 @@ describe('Test: AppDetailsContainer', () => {
         expect(result.current.toasts[0].status).toEqual('error');
       });
     });
-
-    // Skipping because trpc.useContext is not working in tests
-    it.skip('should put the app in stopping state when stop mutation is called', async () => {
-      // Arrange
-      const { result } = renderHook(() => useToastStore());
-      const app = createAppEntity({ status: 'running' });
-      server.use(getTRPCMock({ path: ['app', 'stopApp'], type: 'mutation', response: app }));
-      render(<AppDetailsContainer app={app} />);
-
-      // Act
-      const stopButton = screen.getByTestId('action-button-stop');
-      stopButton.click();
-      const modalStopButton = screen.getByTestId('modal-stop-button');
-      modalStopButton.click();
-
-      await waitFor(() => {
-        expect(screen.getByText('stopping')).toBeInTheDocument();
-        expect(result.current.toasts).toHaveLength(1);
-      });
-    });
   });
 
   describe('Test: Update app config', () => {
@@ -427,12 +334,12 @@ describe('Test: AppDetailsContainer', () => {
       server.use(getTRPCMock({ path: ['app', 'updateAppConfig'], type: 'mutation', response: app }));
       const { result } = renderHook(() => useToastStore());
       render(<AppDetailsContainer app={app} />);
+      const openModalButton = screen.getByRole('button', { name: 'Settings' });
+      fireEvent.click(openModalButton);
 
       // Act
-      const configButton = screen.getByTestId('action-button-settings');
+      const configButton = screen.getByRole('button', { name: 'Update' });
       configButton.click();
-      const modalConfigButton = screen.getAllByText('Update');
-      modalConfigButton[1]?.click();
 
       // Assert
       await waitFor(() => {
@@ -448,12 +355,12 @@ describe('Test: AppDetailsContainer', () => {
       server.use(getTRPCMockError({ path: ['app', 'updateAppConfig'], type: 'mutation', message: 'my big error' }));
       const app = createAppEntity({ status: 'running', overridesInfo: { exposable: true } });
       render(<AppDetailsContainer app={app} />);
+      const openModalButton = screen.getByRole('button', { name: 'Settings' });
+      fireEvent.click(openModalButton);
 
       // Act
-      const configButton = screen.getByTestId('action-button-settings');
+      const configButton = screen.getByRole('button', { name: 'Update' });
       configButton.click();
-      const modalConfigButton = screen.getAllByText('Update');
-      modalConfigButton[1]?.click();
 
       // Assert
       await waitFor(() => {

+ 16 - 14
src/client/modules/Settings/components/RestartModal/RestartModal.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
 import { Button } from '../../../../components/ui/Button';
-import { Modal, ModalBody, ModalFooter, ModalHeader } from '../../../../components/ui/Modal';
 
 interface IProps {
   isOpen: boolean;
@@ -10,17 +10,19 @@ interface IProps {
 }
 
 export const RestartModal: React.FC<IProps> = ({ isOpen, onClose, onConfirm, loading }) => (
-  <Modal size="sm" onClose={onClose} isOpen={isOpen}>
-    <ModalHeader>
-      <h5 className="modal-title">Restart Tipi</h5>
-    </ModalHeader>
-    <ModalBody>
-      <div className="text-muted">Would you like to restart your Tipi server?</div>
-    </ModalBody>
-    <ModalFooter>
-      <Button data-testid="settings-modal-restart-button" onClick={onConfirm} className="btn-danger" loading={loading}>
-        Restart
-      </Button>
-    </ModalFooter>
-  </Modal>
+  <Dialog open={isOpen} onOpenChange={onClose}>
+    <DialogContent type="danger" size="sm">
+      <DialogHeader>
+        <h5 className="modal-title">Restart Tipi</h5>
+      </DialogHeader>
+      <DialogDescription>
+        <div className="text-muted">Would you like to restart your Tipi server?</div>
+      </DialogDescription>
+      <DialogFooter>
+        <Button onClick={onConfirm} className="btn-danger" loading={loading}>
+          Restart
+        </Button>
+      </DialogFooter>
+    </DialogContent>
+  </Dialog>
 );

+ 1 - 1
src/client/modules/Settings/components/SettingsForm/SettingsForm.tsx

@@ -83,7 +83,7 @@ export const SettingsForm = (props: IProps) => {
   };
 
   return (
-    <form data-testid="settings-form" className="flex flex-col" onSubmit={handleSubmit(validate)}>
+    <form className="flex flex-col" onSubmit={handleSubmit(validate)}>
       <h2 className="text-2xl font-bold">General settings</h2>
       <p className="mb-4">This will update your settings.json file. Make sure you know what you are doing before updating these values.</p>
       <div className="mb-3">

+ 16 - 14
src/client/modules/Settings/components/UpdateModal/UpdateModal.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
 import { Button } from '../../../../components/ui/Button';
-import { Modal, ModalBody, ModalFooter, ModalHeader } from '../../../../components/ui/Modal';
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '../../../../components/ui/Dialog';
 
 interface IProps {
   isOpen: boolean;
@@ -10,17 +10,19 @@ interface IProps {
 }
 
 export const UpdateModal: React.FC<IProps> = ({ isOpen, onClose, onConfirm, loading }) => (
-  <Modal size="sm" onClose={onClose} isOpen={isOpen}>
-    <ModalHeader>
-      <h5 className="modal-title">Update Tipi</h5>
-    </ModalHeader>
-    <ModalBody>
-      <div className="text-muted">Would you like to update Tipi to the latest version?</div>
-    </ModalBody>
-    <ModalFooter>
-      <Button onClick={onConfirm} className="btn-success" loading={loading}>
-        Update
-      </Button>
-    </ModalFooter>
-  </Modal>
+  <Dialog open={isOpen} onOpenChange={onClose}>
+    <DialogContent size="sm">
+      <DialogHeader>
+        <h5 className="modal-title">Update Tipi</h5>
+      </DialogHeader>
+      <DialogDescription>
+        <div className="text-muted">Would you like to update Tipi to the latest version?</div>
+      </DialogDescription>
+      <DialogFooter>
+        <Button onClick={onConfirm} className="btn-success" loading={loading}>
+          Update
+        </Button>
+      </DialogFooter>
+    </DialogContent>
+  </Dialog>
 );

+ 16 - 10
src/client/modules/Settings/containers/GeneralActions/GeneralActions.test.tsx

@@ -9,7 +9,7 @@ describe('Test: GeneralActions', () => {
   it('should render without error', () => {
     render(<GeneralActions />);
 
-    expect(screen.getByText('Update')).toBeInTheDocument();
+    expect(screen.getByText('Actions')).toBeInTheDocument();
   });
 
   it('should show toast if update mutation fails', async () => {
@@ -21,10 +21,12 @@ describe('Test: GeneralActions', () => {
     await waitFor(() => {
       expect(screen.getByText('Update to 2.0.0')).toBeInTheDocument();
     });
-    const updateButton = screen.getByText('Update');
+    const updateButton = screen.getByRole('button', { name: /Update/i });
+    fireEvent.click(updateButton);
 
     // act
-    fireEvent.click(updateButton);
+    const updateButtonModal = screen.getByRole('button', { name: /Update/i });
+    fireEvent.click(updateButtonModal);
 
     // assert
     await waitFor(() => {
@@ -42,12 +44,14 @@ describe('Test: GeneralActions', () => {
     server.use(getTRPCMock({ path: ['system', 'update'], response: true }));
     render(<GeneralActions />);
     await waitFor(() => {
-      expect(screen.getByText('Update to 2.0.0')).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: 'Update to 2.0.0' })).toBeInTheDocument();
     });
-    const updateButton = screen.getByText('Update');
+    const updateButton = screen.getByRole('button', { name: /Update to 2.0.0/i });
+    fireEvent.click(updateButton);
 
     // act
-    fireEvent.click(updateButton);
+    const updateButtonModal = screen.getByRole('button', { name: /Update/i });
+    fireEvent.click(updateButtonModal);
 
     // assert
     await waitFor(() => {
@@ -60,12 +64,12 @@ describe('Test: GeneralActions', () => {
     const { result } = renderHook(() => useToastStore());
     server.use(getTRPCMockError({ path: ['system', 'restart'], type: 'mutation', status: 500, message: 'Something went wrong' }));
     render(<GeneralActions />);
-
-    // Find button near the top of the page
-    const restartButton = screen.getByTestId('settings-modal-restart-button');
+    const restartButton = screen.getByRole('button', { name: /Restart/i });
 
     // act
     fireEvent.click(restartButton);
+    const restartButtonModal = screen.getByRole('button', { name: /Restart/i });
+    fireEvent.click(restartButtonModal);
 
     // assert
     await waitFor(() => {
@@ -83,10 +87,12 @@ describe('Test: GeneralActions', () => {
     render(<GeneralActions />);
 
     // Find button near the top of the page
-    const restartButton = screen.getByTestId('settings-modal-restart-button');
+    const restartButton = screen.getByRole('button', { name: /Restart/i });
 
     // act
     fireEvent.click(restartButton);
+    const restartButtonModal = screen.getByRole('button', { name: /Restart/i });
+    fireEvent.click(restartButtonModal);
 
     // assert
     await waitFor(() => {

+ 2 - 2
src/client/modules/Settings/containers/GeneralActions/GeneralActions.tsx

@@ -68,7 +68,7 @@ export const GeneralActions = () => {
     );
   };
   return (
-    <div className="col d-flex flex-column">
+    <>
       <div className="card-body">
         <h2 className="mb-4">Actions</h2>
         <h3 className="card-title mt-4">Version {versionQuery.data?.current}</h3>
@@ -82,6 +82,6 @@ export const GeneralActions = () => {
       </div>
       <RestartModal isOpen={restartDisclosure.isOpen} onClose={restartDisclosure.close} onConfirm={() => restart.mutate()} loading={loading} />
       <UpdateModal isOpen={updateDisclosure.isOpen} onClose={updateDisclosure.close} onConfirm={() => update.mutate()} loading={loading} />
-    </div>
+    </>
   );
 };

+ 7 - 0
tests/client/jest.setup.tsx

@@ -15,6 +15,12 @@ jest.mock('remark-gfm', () => () => ({}));
 
 console.error = jest.fn();
 
+class ResizeObserver {
+  observe() {}
+
+  unobserve() {}
+}
+
 // Mock localStorage
 const localStorageMock = (() => {
   let store: Record<string, string> = {};
@@ -35,6 +41,7 @@ const localStorageMock = (() => {
 })();
 
 Object.defineProperty(window, 'localStorage', { value: localStorageMock });
+Object.defineProperty(window, 'ResizeObserver', { value: ResizeObserver });
 
 beforeAll(() => {
   // Enable the mocking in tests.