Pārlūkot izejas kodu

New react toolbar component

- Add unit tests
- Add ToolbarButton component
- Add ToolbarDivider component
- Add new icon
- Also adding lingering css change for the login that keep resurfaceing when compiling the styles.
Björn Alm 2 gadi atpakaļ
vecāks
revīzija
354850eb1c

+ 37 - 0
desktop/core/src/desktop/js/components/icons/DownloadIcon.tsx

@@ -0,0 +1,37 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you 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.
+
+import React, { SVGProps } from 'react';
+
+const DownloadIcon = (props: SVGProps<SVGSVGElement>): JSX.Element => (
+  <svg
+    viewBox="0 0 24 24"
+    fill="none"
+    width="1em"
+    height="1em"
+    className="cdp-icon-download"
+    {...props}
+  >
+    <path
+      fillRule="evenodd"
+      clipRule="evenodd"
+      d="M11 14V3h2v11l4.4-3.3 1.2 1.6-6.6 4.95-6.6-4.95 1.2-1.6L11 14zm8 5v-5h2v7H3v-7h2v5h14z"
+      fill="currentColor"
+    />
+  </svg>
+);
+
+export default DownloadIcon;

+ 25 - 0
desktop/core/src/desktop/js/reactComponents/Toolbar/Toolbar.scss

@@ -0,0 +1,25 @@
+@import './ToolbarShared.scss';
+
+.antd {
+  .hue-toolbar {
+    display: flex;
+    align-items: center;
+    background-color: $toolbar-background-color;
+
+    &--small {
+      height: $toolbar-height-small;
+    }
+
+    &--medium {
+      height: $toolbar-height-medium;
+    }
+
+    &--large {
+      height: $toolbar-height-large;
+    }
+  }
+
+  .hue-toolbar__spacer {
+    flex-grow: 2;
+  }
+}

+ 233 - 0
desktop/core/src/desktop/js/reactComponents/Toolbar/Toolbar.test.tsx

@@ -0,0 +1,233 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you 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.
+
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import '@testing-library/jest-dom';
+
+import ImportIcon from '../../components/icons/ImportIcon';
+
+import Toolbar, { ToolbarButton, ToolbarDivider } from './Toolbar';
+
+describe('Toolbar', () => {
+  test('renders any content', () => {
+    render(<Toolbar content={() => <div>custom content</div>} />);
+    expect(screen.getByText('custom content')).toBeVisible();
+  });
+
+  test('has default test-id "hue-toolbar"', () => {
+    render(<Toolbar content={() => <div></div>} />);
+    expect(screen.getByTestId('hue-toolbar')).toBeVisible();
+  });
+
+  test('offers custom test-id', () => {
+    render(<Toolbar testId="custom-test-id" content={() => <div></div>} />);
+    expect(screen.getByTestId('custom-test-id')).toBeVisible();
+  });
+
+  test('can have custom classname', () => {
+    render(<Toolbar className="test-class" content={() => <div></div>} />);
+    expect(screen.getByRole('list')).toHaveClass('test-class');
+  });
+
+  test('has default size of medium', () => {
+    render(<Toolbar content={() => <div></div>} />);
+    expect(screen.getByRole('list')).toHaveClass('hue-toolbar--medium');
+  });
+
+  test('can have be of size large', () => {
+    render(<Toolbar size="large" content={() => <div></div>} />);
+    expect(screen.getByRole('list')).toHaveClass('hue-toolbar--large');
+  });
+
+  test('can have be of size small', () => {
+    render(<Toolbar size="small" content={() => <div></div>} />);
+    expect(screen.getByRole('list')).toHaveClass('hue-toolbar--small');
+  });
+
+  describe('ToolbarButton', () => {
+    test('renders a button wrapped in a list element to match the parent menu tag', () => {
+      render(<Toolbar content={() => <ToolbarButton />} />);
+      expect(screen.getByRole('button').closest('li')).toBeVisible();
+    });
+
+    test('renders a button name', () => {
+      render(<Toolbar content={() => <ToolbarButton> testbutton </ToolbarButton>} />);
+      expect(screen.getByRole('button', { name: 'testbutton' })).toBeVisible();
+    });
+
+    test('has default test-id "hue-toolbar-button" on wrapping li element', () => {
+      render(<Toolbar content={() => <ToolbarButton />} />);
+      expect(screen.getByTestId('hue-toolbar-button')).toBeVisible();
+    });
+
+    test('offers custom test-id on wrapping li element', () => {
+      render(<Toolbar content={() => <ToolbarButton testId="button-test-id" />} />);
+      expect(screen.getByTestId('button-test-id')).toBeVisible();
+    });
+
+    test('default test-id on button element', () => {
+      render(<Toolbar content={() => <ToolbarButton />} />);
+      expect(screen.getByTestId('hue-toolbar-button-btn')).toBeVisible();
+    });
+
+    test('offers custom test-id on button element', () => {
+      render(<Toolbar content={() => <ToolbarButton testId="button-test-id" />} />);
+      expect(screen.getByTestId('button-test-id-btn')).toBeVisible();
+    });
+
+    test('can have custom classname on wrapping li element', () => {
+      render(<Toolbar content={() => <ToolbarButton className="test-button-class" />} />);
+      expect(screen.getByRole('listitem')).toHaveClass('test-button-class');
+    });
+
+    test('can render multiple buttons', () => {
+      render(
+        <Toolbar
+          content={() => (
+            <>
+              <ToolbarButton />
+              <ToolbarButton />
+              <ToolbarButton />
+            </>
+          )}
+        />
+      );
+      expect(screen.getAllByRole('button')).toHaveLength(3);
+    });
+
+    test('supports icon on the left', () => {
+      render(
+        <Toolbar
+          content={() => (
+            <ToolbarButton iconPosition="left" icon={<ImportIcon data-testid="test-icon" />}>
+              test-label
+            </ToolbarButton>
+          )}
+        />
+      );
+      const buttonContentDiv = screen.getByRole('button').firstElementChild;
+      const testIcon = screen.getByTestId('test-icon');
+      const leftElement = buttonContentDiv?.childNodes[0];
+      const rightElement = buttonContentDiv?.childNodes[1];
+      expect(leftElement).toEqual(testIcon);
+      expect(rightElement).toHaveTextContent('test-label');
+    });
+
+    test('supports icon on the right', () => {
+      render(
+        <Toolbar
+          content={() => (
+            <ToolbarButton iconPosition="right" icon={<ImportIcon data-testid="test-icon" />}>
+              test-label
+            </ToolbarButton>
+          )}
+        />
+      );
+      const buttonContentDiv = screen.getByRole('button').firstElementChild;
+      const testIcon = screen.getByTestId('test-icon');
+      const leftElement = buttonContentDiv?.childNodes[0];
+      const rightElement = buttonContentDiv?.childNodes[1];
+      expect(leftElement).toHaveTextContent('test-label');
+      expect(rightElement).toEqual(testIcon);
+    });
+
+    test('supports aria-label for icon only button', () => {
+      render(
+        <Toolbar
+          content={() => <ToolbarButton aria-label="test-aria-label" icon={<ImportIcon />} />}
+        />
+      );
+      const button = screen.getByRole('button', { name: 'test-aria-label' });
+      expect(button.getAttribute('aria-label')).toEqual('test-aria-label');
+    });
+
+    test('supports title for icon only button', () => {
+      render(
+        <Toolbar content={() => <ToolbarButton title="test-title" icon={<ImportIcon />} />} />
+      );
+
+      const button = screen.getByRole('button', { name: 'test-title' });
+      expect(button.getAttribute('title')).toEqual('test-title');
+    });
+
+    test('accepts JSX element as child', () => {
+      render(
+        <Toolbar
+          content={() => (
+            <ToolbarButton aria-label="test-aria-label">
+              <div>test-jsx-child</div>
+            </ToolbarButton>
+          )}
+        />
+      );
+
+      expect(screen.getByText('test-jsx-child')).toBeVisible();
+    });
+
+    test('can be disabled', () => {
+      const { rerender } = render(<Toolbar content={() => <ToolbarButton disabled />} />);
+      expect(screen.getByRole('button')).toBeDisabled();
+
+      rerender(<Toolbar content={() => <ToolbarButton />} />);
+      expect(screen.getByRole('button')).not.toBeDisabled();
+    });
+
+    test('can be turned into a href link with target', () => {
+      render(
+        <Toolbar
+          content={() => (
+            <ToolbarButton href="test-url" target="blank">
+              anchor-link-button
+            </ToolbarButton>
+          )}
+        />
+      );
+      const btn = screen.getByRole('link', { name: 'anchor-link-button' });
+      expect(btn.getAttribute('href')).toEqual('test-url');
+      expect(btn.getAttribute('target')).toEqual('blank');
+    });
+
+    test('calls provided onClick callback when clicked', async () => {
+      const user = userEvent.setup();
+      const callback = jest.fn();
+      render(<Toolbar content={() => <ToolbarButton onClick={callback} />} />);
+      const testButtton = screen.getByRole('button');
+      expect(callback).not.toHaveBeenCalled();
+      await user.click(testButtton);
+      expect(callback).toHaveBeenCalled();
+    });
+  });
+
+  describe('ToolbarDivider', () => {
+    test('renders a vertical divider wrapped in a li tag', () => {
+      render(<Toolbar content={() => <ToolbarDivider />} />);
+      expect(screen.getByRole('separator')).toHaveClass('ant-divider-vertical');
+      expect(screen.getByRole('separator').closest('li')).toBeDefined();
+    });
+
+    test('wrapping list item has default test id', () => {
+      render(<Toolbar content={() => <ToolbarDivider />} />);
+      expect(screen.getByTestId('hue-toolbar-divider')).toBeVisible();
+    });
+
+    test('wrapping list item supports custom test id', () => {
+      render(<Toolbar content={() => <ToolbarDivider testId="custom-test-id" />} />);
+      expect(screen.getByTestId('custom-test-id')).toBeVisible();
+    });
+  });
+});

+ 64 - 0
desktop/core/src/desktop/js/reactComponents/Toolbar/Toolbar.tsx

@@ -0,0 +1,64 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you 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.
+
+import React, { FunctionComponent } from 'react';
+import classNames from 'classnames';
+
+import ToolbarButtonExport from './ToolbarButton/ToolbarButton';
+import ToolbarDividerExport from './ToolbarDivider/ToolbarDivider';
+
+import './Toolbar.scss';
+export const ToolbarButton = ToolbarButtonExport;
+export const ToolbarDivider = ToolbarDividerExport;
+
+enum Size {
+  small = 'small',
+  medium = 'medium',
+  large = 'large'
+}
+
+/**
+ * Toolbar
+ * A toolbar component based on the menu tag that uses the content view prop to display
+ * ToolbarButtons, ToolbarDividers or any custom JSX elements that wrap its content in
+ * an li element.
+ */
+export interface ToolbarProps {
+  className?: string;
+  /** One or more ToolbarButtons and ToolbarSeparators. For multiple elements use a wrapping fragment. */
+  content: () => JSX.Element;
+  /** Defaults to medium with a height of 40px with ToolbarButton icons & font-size matching cuix button medium size */
+  size?: keyof typeof Size;
+  testId?: string;
+}
+
+const Toolbar: FunctionComponent<ToolbarProps> = ({
+  className,
+  content,
+  size = Size.medium,
+  testId = 'hue-toolbar'
+}) => (
+  <React.StrictMode>
+    <menu
+      className={classNames('hue-toolbar', `hue-toolbar--${size}`, className)}
+      data-testid={testId}
+    >
+      {content()}
+    </menu>
+  </React.StrictMode>
+);
+
+export default Toolbar;

+ 76 - 0
desktop/core/src/desktop/js/reactComponents/Toolbar/ToolbarButton/ToolbarButton.scss

@@ -0,0 +1,76 @@
+@use "sass:color";
+
+@import '../ToolbarShared.scss';
+
+$antd-btn-padding-horizontal: 12px;
+$focus-border-width: 3px;
+$focus-border-color: $fluid-blue-200;
+
+@mixin tab-focus-apparence() {
+  // Prevent antd transition when modifying padding to allow
+  // for the focus-visible border.
+  transition: 0s padding ease-out;
+
+  &:focus-visible {
+    border-color: $focus-border-color;
+    border-radius: $fluid-border-radius;
+    transition: none;
+  }
+}
+
+@mixin set-sizes($font-size, $icon-size, $toolbar-height) {
+  height: $toolbar-height;
+  font-size: $font-size;
+
+  svg {
+    height: $icon-size;
+    width: $icon-size;
+  }
+}
+
+.antd {
+  .hue-toolbar {
+    .hue-toolbar-button__wrapper {
+      list-style-type: none;
+      height: 100%;
+      display: flex;
+      align-items: center;
+    }
+
+    .hue-toolbar-button {
+      @include tab-focus-apparence;
+
+      padding: 0 $antd-btn-padding-horizontal;
+      border-width: $focus-border-width;
+      border-color: transparent;
+      border-radius: 0;
+
+      &:hover {
+        background-color: color.adjust($toolbar-background-color, $lightness: -3%);
+      }
+
+      span {
+        display: flex;
+        align-items: center;
+        gap: $fluid-spacing-xxs;
+      }
+    }
+
+    &--small .hue-toolbar-button {
+      @include set-sizes(12px, 14px, $toolbar-height-small);
+    }
+
+    &--medium .hue-toolbar-button {
+      @include set-sizes(14px, 16px, $toolbar-height-medium);
+    }
+
+    &--large .hue-toolbar-button {
+      @include set-sizes(16px, 20px, $toolbar-height-large);
+    }
+  }
+}
+
+// Fix to cancel out hardcoded padding by antd for anchor based buttons
+a.hue-toolbar-button.ant-btn {
+  display: flex;
+}

+ 95 - 0
desktop/core/src/desktop/js/reactComponents/Toolbar/ToolbarButton/ToolbarButton.tsx

@@ -0,0 +1,95 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you 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.
+
+import React, { FunctionComponent } from 'react';
+import { Button } from 'antd';
+import classNames from 'classnames';
+
+import './ToolbarButton.scss';
+
+enum IconPosition {
+  left = 'left',
+  right = 'right'
+}
+
+export interface ToolbarButtonProps {
+  /** Make sure to use aria-label for icon only buttons */
+  'aria-label'?: string;
+  /** The contents of the button, normally the label but can be any JSX */
+  children?: JSX.Element | string;
+  /** String appended to the classname of the button wrapper */
+  className?: string;
+  /** Enable state of the button */
+  disabled?: boolean;
+  /** Setting the href will render the button using the anchor tag */
+  href?: string;
+  /** The icon to be displayed by the button, expects a react based icon like used in cuix */
+  icon?: React.ReactNode;
+  /** The position of the icon, defaults to left */
+  iconPosition?: keyof typeof IconPosition;
+  /** Callback for buttons, including anchor based ones using href */
+  onClick?: () => void;
+  /** Target when using href */
+  target?: string;
+  /** Test id */
+  testId?: string;
+  /** Optional title when using icon only or when additional context is needed */
+  title?: string;
+}
+
+/**
+ * ToolbarButton
+ * A button for use in the Toolbar component.
+ */
+const ToolbarButton: FunctionComponent<ToolbarButtonProps> = ({
+  'aria-label': ariaLabel,
+  className,
+  children,
+  disabled,
+  href,
+  icon,
+  iconPosition = IconPosition.left,
+  onClick,
+  target,
+  testId = 'hue-toolbar-button',
+  title
+}) => {
+  return (
+    <React.StrictMode>
+      <li data-testid={testId} className={classNames(className, 'hue-toolbar-button__wrapper')}>
+        <Button
+          aria-label={ariaLabel}
+          className={'hue-toolbar-button'}
+          data-testid={`${testId}-btn`}
+          disabled={disabled}
+          href={href}
+          onClick={onClick}
+          target={target}
+          type="link"
+          title={title}
+        >
+          <>
+            {iconPosition === IconPosition.left && icon}
+            {children}
+            {iconPosition === IconPosition.right && icon}
+          </>
+        </Button>
+      </li>
+    </React.StrictMode>
+  );
+};
+
+export default ToolbarButton;

+ 38 - 0
desktop/core/src/desktop/js/reactComponents/Toolbar/ToolbarDivider/ToolbarDivider.scss

@@ -0,0 +1,38 @@
+@import '../ToolbarShared.scss';
+
+$vertical-margin: 3px;
+
+@mixin set-sizes($toolbar-height) {
+  .hue-toolbar-divider {
+    height: $toolbar-height;
+
+    .ant-divider-vertical {
+      height: calc(#{$toolbar-height} - calc(#{$vertical-margin} * 2));
+    }
+  }
+}
+
+.antd {
+  .hue-toolbar {
+    .hue-toolbar-divider {
+      list-style-type: none;
+
+      .ant-divider-vertical {
+        border-left-color: $toolbar-border-color;
+        top: $vertical-margin;
+      }
+    }
+
+    &--small {
+      @include set-sizes($toolbar-height-small);
+    }
+
+    &--medium {
+      @include set-sizes($toolbar-height-medium);
+    }
+
+    &--large {
+      @include set-sizes($toolbar-height-large);
+    }
+  }
+}

+ 34 - 0
desktop/core/src/desktop/js/reactComponents/Toolbar/ToolbarDivider/ToolbarDivider.tsx

@@ -0,0 +1,34 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you 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.
+
+import { Divider } from 'antd';
+import React, { FunctionComponent } from 'react';
+
+import './ToolbarDivider.scss';
+
+export interface ToolbarDividerProps {
+  testId?: string;
+}
+
+const ToolbarDivider: FunctionComponent<ToolbarDividerProps> = ({
+  testId = 'hue-toolbar-divider'
+}: ToolbarDividerProps) => (
+  <li className="hue-toolbar-divider" data-testid={testId}>
+    <Divider type="vertical" />
+  </li>
+);
+
+export default ToolbarDivider;

+ 7 - 0
desktop/core/src/desktop/js/reactComponents/Toolbar/ToolbarShared.scss

@@ -0,0 +1,7 @@
+@import '../../components/styles/variables';
+
+$toolbar-border-color: $fluid-gray-300;
+$toolbar-background-color: $fluid-gray-050;
+$toolbar-height-small: 32px;
+$toolbar-height-medium: 40px;
+$toolbar-height-large: 48px;

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
desktop/core/src/desktop/static/desktop/css/hue.css


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
desktop/core/src/desktop/static/desktop/css/login.css


+ 3 - 1
desktop/core/src/desktop/static/desktop/less/root-wrapped-antd.less

@@ -21,7 +21,9 @@
   @import 'node_modules/antd/es/skeleton/style/index.less';
   @import 'node_modules/antd/es/menu/style/index.less';
   @import 'node_modules/antd/es/spin/style/index.less';
-
+  @import 'node_modules/antd/es/segmented/style/index.less';
+  @import 'node_modules/antd/es/select/style/index.less';
+  @import 'node_modules/antd/es/divider/style/index.less';
   /* stylelint-enable no-invalid-position-at-import-rule */
 
   // Required global overrides:

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels