Browse Source

feat(ui-components): Add radio group rating - (#39145)

Priscila Oliveira 2 years ago
parent
commit
19d4e12474

+ 39 - 0
docs-ui/stories/components/form-fields.stories.js

@@ -18,6 +18,7 @@ import TextareaField from 'sentry/components/forms/textareaField';
 import TextCopyInput from 'sentry/components/forms/textCopyInput';
 import TextField from 'sentry/components/forms/textField';
 import {Panel} from 'sentry/components/panels';
+import {RadioGroupRating} from 'sentry/components/radioGroupRating';
 import Switch from 'sentry/components/switchButton';
 
 export default {
@@ -460,6 +461,44 @@ NonInlineField.parameters = {
   },
 };
 
+export const _RadioGroupRating = () => (
+  <RadioGroupRating
+    name="feelingIfFeatureNotAvailableRating"
+    options={{
+      0: {
+        label: 'Very Dissatisfied',
+        description: "Not disappointed (It isn't really useful)",
+      },
+      1: {
+        label: 'Dissatisfied',
+      },
+      2: {
+        label: 'Neutral',
+      },
+      3: {
+        label: 'Satisfied',
+      },
+      4: {
+        description: "Very disappointed (It's a deal breaker)",
+        label: 'Very Satisfied',
+      },
+    }}
+    label="How satisfied are you with this feature?"
+    inline={false}
+    stacked
+  />
+);
+
+_RadioGroupRating.storyName = 'Radio Group Rating';
+
+_RadioGroupRating.parameters = {
+  docs: {
+    description: {
+      story: 'Used to provide insights regarding opinions and experiences',
+    },
+  },
+};
+
 export const _RangeSlider = () => (
   <div style={{backgroundColor: '#fff', padding: 20}}>
     <RangeSlider

+ 24 - 17
static/app/components/button.tsx

@@ -366,6 +366,29 @@ const getSizeStyles = ({size = 'md', translucentBorder, theme}: StyledButtonProp
   };
 };
 
+export const getButtonStyles = ({theme, ...props}: StyledButtonProps) => {
+  return css`
+    display: inline-block;
+    border-radius: ${theme.button.borderRadius};
+    text-transform: none;
+    font-weight: 600;
+    ${getColors({...props, theme})};
+    ${getSizeStyles({...props, theme})};
+    ${getBoxShadow({...props, theme})};
+    cursor: ${props.disabled ? 'not-allowed' : 'pointer'};
+    opacity: ${(props.busy || props.disabled) && '0.65'};
+    transition: background 0.1s, border 0.1s, box-shadow 0.1s;
+
+    ${props.priority === 'link' &&
+    `font-size: inherit; font-weight: inherit; padding: 0;`}
+    ${props.size === 'zero' && `height: auto; min-height: auto; padding: ${space(0.25)};`}
+
+  &:focus {
+      outline: none;
+    }
+  `;
+};
+
 const StyledButton = styled(
   reactForwardRef<any, ButtonProps>(
     (
@@ -407,23 +430,7 @@ const StyledButton = styled(
       (typeof prop === 'string' && isPropValid(prop)),
   }
 )<ButtonProps>`
-  display: inline-block;
-  border-radius: ${p => p.theme.button.borderRadius};
-  text-transform: none;
-  font-weight: 600;
-  ${getColors};
-  ${getSizeStyles}
-  ${getBoxShadow};
-  cursor: ${p => (p.disabled ? 'not-allowed' : 'pointer')};
-  opacity: ${p => (p.busy || p.disabled) && '0.65'};
-  transition: background 0.1s, border 0.1s, box-shadow 0.1s;
-
-  ${p => p.priority === 'link' && `font-size: inherit; font-weight: inherit; padding: 0;`}
-  ${p => p.size === 'zero' && `height: auto; min-height: auto; padding: ${space(0.25)};`}
-
-  &:focus {
-    outline: none;
-  }
+  ${getButtonStyles};
 `;
 
 const buttonLabelPropKeys = ['size', 'borderless', 'align'];

+ 60 - 0
static/app/components/radioGroupRating.spec.tsx

@@ -0,0 +1,60 @@
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+
+import {RadioGroupRating, RadioGroupRatingProps} from './radioGroupRating';
+
+const options: RadioGroupRatingProps['options'] = {
+  0: {
+    label: 'Very Dissatisfied',
+    description: "Not disappointed (It isn't really useful)",
+  },
+  1: {
+    label: 'Dissatisfied',
+  },
+  2: {
+    label: 'Neutral',
+  },
+  3: {
+    label: 'Satisfied',
+  },
+  4: {
+    description: "Very disappointed (It's a deal breaker)",
+    label: 'Very Satisfied',
+  },
+};
+
+describe('RadioGroupRating', function () {
+  it('render numerical labels', function () {
+    const handleChange = jest.fn();
+
+    render(
+      <RadioGroupRating
+        name="feelingIfFeatureNotAvailableRating"
+        options={options}
+        onChange={handleChange}
+        label="How satisfied are you with this feature?"
+      />
+    );
+
+    expect(
+      screen.getByText('How satisfied are you with this feature?')
+    ).toBeInTheDocument();
+
+    expect(screen.getAllByRole('radio')).toHaveLength(Object.keys(options).length);
+
+    Object.keys(options).forEach((key, index) => {
+      expect(screen.getByText(index + 1)).toBeInTheDocument();
+      expect(
+        screen.getByLabelText(`Select option ${options[key].label}`)
+      ).toBeInTheDocument();
+
+      const description = options[key].description;
+      if (description) {
+        expect(screen.getByText(description)).toBeInTheDocument();
+      }
+    });
+
+    // Click on the first option
+    userEvent.click(screen.getByLabelText(`Select option ${options[0].label}`));
+    expect(handleChange).toHaveBeenCalledWith('0');
+  });
+});

+ 105 - 0
static/app/components/radioGroupRating.tsx

@@ -0,0 +1,105 @@
+import {useEffect, useState} from 'react';
+import {css} from '@emotion/react';
+import styled from '@emotion/styled';
+
+import {getButtonStyles} from 'sentry/components/button';
+import Field, {FieldProps} from 'sentry/components/forms/field';
+import {t} from 'sentry/locale';
+import space from 'sentry/styles/space';
+
+type Option = {label: string; description?: string};
+
+export type RadioGroupRatingProps = Omit<FieldProps, 'children'> & {
+  /**
+   * Field name, used in all radio group elements
+   */
+  name: string;
+  /**
+   * An object of options, where the label is used for the aria-label,
+   * the key is used for the selection and
+   * the optional description to provide more context
+   */
+  options: Record<string, Option>;
+  /**
+   * The key of the option that should be selected by default
+   */
+  defaultValue?: string;
+  /**
+   * Callback function that is called when the selection changes.
+   * its value is the key of the selected option
+   */
+  onChange?: (value: string) => void;
+};
+
+// Used to provide insights regarding opinions and experiences.
+// Currently limited to numeric options only, but can be updated to meet other needs.
+export function RadioGroupRating({
+  options,
+  name,
+  onChange,
+  defaultValue,
+  ...fieldProps
+}: RadioGroupRatingProps) {
+  const [selectedOption, setSelectedOption] = useState(defaultValue);
+
+  useEffect(() => {
+    if (!selectedOption) {
+      return;
+    }
+    onChange?.(selectedOption);
+  }, [selectedOption, onChange]);
+
+  return (
+    <Field {...fieldProps}>
+      <Wrapper totalOptions={Object.keys(options).length}>
+        {Object.entries(options).map(([key, option], index) => (
+          <OptionWrapper key={key}>
+            <Label
+              selected={key === selectedOption}
+              htmlFor={key}
+              onClick={() => setSelectedOption(key)}
+              aria-label={t('Select option %s', option.label)}
+            >
+              {index + 1}
+            </Label>
+            <HiddenInput type="radio" id={key} name={name} value={option.label} />
+            <Description>{option.description}</Description>
+          </OptionWrapper>
+        ))}
+      </Wrapper>
+    </Field>
+  );
+}
+
+const HiddenInput = styled('input')`
+  display: none;
+`;
+
+const Wrapper = styled('div')<{totalOptions: number}>`
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(50px, 1fr));
+  margin-top: ${space(0.5)};
+  gap: ${space(1)};
+`;
+
+const OptionWrapper = styled('div')`
+  display: flex;
+  align-items: center;
+  flex-direction: column;
+`;
+
+const Label = styled('label')<{'aria-label': string; selected: boolean}>`
+  cursor: pointer;
+  ${p => css`
+    ${getButtonStyles({
+      theme: p.theme,
+      size: 'md',
+      'aria-label': p['aria-label'],
+      priority: p.selected ? 'primary' : 'default',
+    })}
+  `}
+`;
+
+const Description = styled('div')`
+  text-align: center;
+`;