@squiz/formatted-text-editor 1.21.1-alpha.32 → 1.21.1-alpha.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -33,8 +33,10 @@ const react_image_size_1 = require("react-image-size");
33
33
  const Button_1 = __importDefault(require("../../../../ui/Button/Button"));
34
34
  const LinkOff_1 = __importDefault(require("@mui/icons-material/LinkOff"));
35
35
  const InsertLinkRounded_1 = __importDefault(require("@mui/icons-material/InsertLinkRounded"));
36
+ const clsx_1 = __importDefault(require("clsx"));
37
+ const regexDataURI = /^data:([a-z]+\/[a-z0-9-+.]+(;[a-z-]+=[a-z0-9-]+)?)?(;base64)?,([a-z0-9!$&',()*+;=\-._~:@/?%\s]*)$/i;
36
38
  const ImageForm = ({ data, onSubmit }) => {
37
- const { register, handleSubmit, setValue } = (0, react_hook_form_1.useForm)({
39
+ const { register, handleSubmit, setValue, formState: { errors }, } = (0, react_hook_form_1.useForm)({
38
40
  defaultValues: data,
39
41
  });
40
42
  const [aspectRatioFromWidth, setAspectRatioFromWidth] = (0, react_1.useState)(9 / 16);
@@ -48,9 +50,16 @@ const ImageForm = ({ data, onSubmit }) => {
48
50
  setAspectRatioFromWidth(height / width);
49
51
  setAspectRatioFromHeight(width / height);
50
52
  })
51
- .catch(() => {
52
- // TODO: we will use this when we add validation in a follow-up ticket
53
- });
53
+ .catch();
54
+ };
55
+ const validateIsNotImage = async (src) => {
56
+ try {
57
+ await (0, react_image_size_1.getImageSize)(src);
58
+ return false;
59
+ }
60
+ catch (error) {
61
+ return true;
62
+ }
54
63
  };
55
64
  const calculateDimensions = () => {
56
65
  if (aspectRatioLocked) {
@@ -68,15 +77,48 @@ const ImageForm = ({ data, onSubmit }) => {
68
77
  };
69
78
  return (react_1.default.createElement("form", { className: "squiz-fte-form", onSubmit: handleSubmit(onSubmit) },
70
79
  react_1.default.createElement("div", { className: "squiz-fte-form-group mb-2" },
71
- react_1.default.createElement(Input_1.Input, { label: "Source", ...register('src', { onChange: setDimensionsFromURL }) })),
80
+ react_1.default.createElement(Input_1.Input, { label: "Source", required: true, error: errors?.src?.message, ...register('src', {
81
+ onChange: setDimensionsFromURL,
82
+ required: 'Source is required',
83
+ validate: {
84
+ isValidImage: async (value) => {
85
+ if (value && regexDataURI.test(value)) {
86
+ return 'Must not be a data URI';
87
+ }
88
+ if (value && (await validateIsNotImage(value))) {
89
+ return 'Must be a valid image URL';
90
+ }
91
+ },
92
+ },
93
+ }) })),
72
94
  react_1.default.createElement("div", { className: "squiz-fte-form-group mb-2" },
73
- react_1.default.createElement(Input_1.Input, { label: "Alternative description", ...register('alt') })),
74
- react_1.default.createElement("div", { className: "flex flex-row items-end" },
95
+ react_1.default.createElement(Input_1.Input, { label: "Alternative description", required: true, error: errors?.alt?.message, ...register('alt', { required: 'Alternative description is required' }) })),
96
+ react_1.default.createElement("div", { className: "flex flex-row" },
75
97
  react_1.default.createElement("div", { className: "squiz-fte-form-group mb-2" },
76
- react_1.default.createElement(Input_1.Input, { label: "Width", ...register('width'), type: "number", name: "width", onChange: calculateDimensions })),
98
+ react_1.default.createElement(Input_1.Input, { label: "Width", type: "number", required: true, error: errors?.width?.message, ...register('width', {
99
+ onChange: calculateDimensions,
100
+ required: 'Width is required',
101
+ validate: {
102
+ isValidWidth: (value) => {
103
+ if (value && !(value > 0)) {
104
+ return 'Must be higher than 0';
105
+ }
106
+ },
107
+ },
108
+ }) })),
77
109
  react_1.default.createElement("div", { className: "flex mx-1 mb-2" },
78
- react_1.default.createElement(Button_1.default, { handleOnClick: toggleAspectRatio, isActive: false, icon: aspectRatioLocked ? react_1.default.createElement(InsertLinkRounded_1.default, null) : react_1.default.createElement(LinkOff_1.default, null), label: "Constrain properties", isDisabled: false })),
110
+ react_1.default.createElement(Button_1.default, { handleOnClick: toggleAspectRatio, isActive: false, icon: aspectRatioLocked ? react_1.default.createElement(InsertLinkRounded_1.default, null) : react_1.default.createElement(LinkOff_1.default, null), label: "Constrain properties", isDisabled: false, className: (0, clsx_1.default)('my-auto', !errors?.height && !errors?.width && 'mb-0') })),
79
111
  react_1.default.createElement("div", { className: "squiz-fte-form-group mb-2" },
80
- react_1.default.createElement(Input_1.Input, { label: "Height", ...register('height'), type: "number", name: "height", onChange: calculateDimensions })))));
112
+ react_1.default.createElement(Input_1.Input, { label: "Height", type: "number", required: true, error: errors?.height?.message, ...register('height', {
113
+ onChange: calculateDimensions,
114
+ required: 'Height is required',
115
+ validate: {
116
+ isValidHeight: (value) => {
117
+ if (value && !(value > 0)) {
118
+ return 'Must be higher than 0';
119
+ }
120
+ },
121
+ },
122
+ }) })))));
81
123
  };
82
124
  exports.default = ImageForm;
package/lib/index.css CHANGED
@@ -388,6 +388,10 @@
388
388
  margin-top: 1.5rem !important;
389
389
  margin-bottom: 1.5rem !important;
390
390
  }
391
+ .squiz-fte-scope .my-auto {
392
+ margin-top: auto !important;
393
+ margin-bottom: auto !important;
394
+ }
391
395
  .squiz-fte-scope .-mr-3 {
392
396
  margin-right: -0.75rem !important;
393
397
  }
@@ -444,9 +448,6 @@
444
448
  .squiz-fte-scope .flex-row {
445
449
  flex-direction: row !important;
446
450
  }
447
- .squiz-fte-scope .items-end {
448
- align-items: flex-end !important;
449
- }
450
451
  .squiz-fte-scope .items-center {
451
452
  align-items: center !important;
452
453
  }
@@ -666,6 +667,7 @@
666
667
  --tw-border-opacity: 1;
667
668
  border-color: rgb(215 35 33 / var(--tw-border-opacity));
668
669
  background-repeat: no-repeat;
670
+ padding-right: 2rem;
669
671
  background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjQiIHdpZHRoPSIyNCI+PHJlY3Qgd2lkdGg9IjEwMCUiIGhlaWdodD0iMTAwJSIgZmlsbD0ibm9uZSIvPjxnIGNsYXNzPSJjdXJyZW50TGF5ZXIiPjxwYXRoIGQ9Ik00LjQ3IDIxaDE1LjA2YzEuNTQgMCAyLjUtMS42NyAxLjczLTNMMTMuNzMgNC45OWMtLjc3LTEuMzMtMi42OS0xLjMzLTMuNDYgMEwyLjc0IDE4Yy0uNzcgMS4zMy4xOSAzIDEuNzMgM3pNMTIgMTRjLS41NSAwLTEtLjQ1LTEtMXYtMmMwLS41NS40NS0xIDEtMXMxIC40NSAxIDF2MmMwIC41NS0uNDUgMS0xIDF6bTEgNGgtMnYtMmgydjJ6IiBjbGFzcz0ic2VsZWN0ZWQiIGZpbGw9IiNkNzIzMjEiLz48L2c+PC9zdmc+);
670
672
  background-position: top 0.25rem right 0.25rem;
671
673
  background-size: 1.5rem;
@@ -769,6 +771,7 @@
769
771
  .squiz-fte-scope .editor-toolbar .squiz-fte-btn,
770
772
  .squiz-fte-scope .squiz-fte-scope__floating-popover .squiz-fte-btn {
771
773
  padding: 0.25rem;
774
+ font-weight: 700;
772
775
  }
773
776
  .squiz-fte-scope .editor-toolbar .squiz-fte-btn ~ .squiz-fte-btn,
774
777
  .squiz-fte-scope .squiz-fte-scope__floating-popover .squiz-fte-btn ~ .squiz-fte-btn {
@@ -798,7 +801,7 @@
798
801
  border-radius: 4px;
799
802
  --tw-bg-opacity: 1;
800
803
  background-color: rgb(255 255 255 / var(--tw-bg-opacity));
801
- font-weight: 400;
804
+ font-weight: 700;
802
805
  --tw-text-opacity: 1;
803
806
  color: rgb(112 112 112 / var(--tw-text-opacity));
804
807
  transition-property: all;
@@ -961,6 +964,7 @@
961
964
  .squiz-fte-scope .editor-toolbar .squiz-fte-modal-footer__button,
962
965
  .squiz-fte-scope .squiz-fte-scope__floating-popover .squiz-fte-modal-footer__button {
963
966
  padding: 0.25rem;
967
+ font-weight: 700;
964
968
  }
965
969
  .squiz-fte-scope .editor-toolbar .squiz-fte-modal-footer__button ~ .squiz-fte-btn,
966
970
  .squiz-fte-scope .squiz-fte-scope__floating-popover .squiz-fte-modal-footer__button ~ .squiz-fte-btn {
@@ -970,7 +974,7 @@
970
974
  border-radius: 4px;
971
975
  --tw-bg-opacity: 1;
972
976
  background-color: rgb(255 255 255 / var(--tw-bg-opacity));
973
- font-weight: 400;
977
+ font-weight: 700;
974
978
  --tw-text-opacity: 1;
975
979
  color: rgb(112 112 112 / var(--tw-text-opacity));
976
980
  transition-property: all;
@@ -6,6 +6,7 @@ type ButtonProps = {
6
6
  label: string;
7
7
  text?: string;
8
8
  icon?: ReactElement;
9
+ className?: string;
9
10
  };
10
- declare const Button: ({ handleOnClick, isDisabled, isActive, label, text, icon }: ButtonProps) => JSX.Element;
11
+ declare const Button: ({ handleOnClick, isDisabled, isActive, label, text, icon, className }: ButtonProps) => JSX.Element;
11
12
  export default Button;
@@ -5,8 +5,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const react_1 = __importDefault(require("react"));
7
7
  const clsx_1 = __importDefault(require("clsx"));
8
- const Button = ({ handleOnClick, isDisabled, isActive, label, text, icon }) => {
9
- return (react_1.default.createElement("button", { "aria-label": label, title: label, type: "button", onClick: handleOnClick, disabled: isDisabled, className: (0, clsx_1.default)('squiz-fte-btn', isActive && 'squiz-fte-btn--is-active', icon && ' squiz-fte-btn--is-icon') },
8
+ const Button = ({ handleOnClick, isDisabled, isActive, label, text, icon, className }) => {
9
+ return (react_1.default.createElement("button", { "aria-label": label, title: label, type: "button", onClick: handleOnClick, disabled: isDisabled, className: (0, clsx_1.default)('squiz-fte-btn', isActive && 'squiz-fte-btn--is-active', icon && ' squiz-fte-btn--is-icon', className) },
10
10
  text && react_1.default.createElement("span", null, text),
11
11
  icon && icon));
12
12
  };
@@ -29,9 +29,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
29
29
  exports.Input = void 0;
30
30
  const react_1 = __importStar(require("react"));
31
31
  const clsx_1 = __importDefault(require("clsx"));
32
- const InputInternal = ({ name, label, type = 'text', error, ...rest }, ref) => {
32
+ const InputInternal = ({ name, label, type = 'text', error, required, ...rest }, ref) => {
33
33
  return (react_1.default.createElement("div", { className: (0, clsx_1.default)(error && 'squiz-fte-invalid-form-field') },
34
34
  label && (react_1.default.createElement("label", { htmlFor: name, className: "squiz-fte-form-label" }, label)),
35
+ required && (react_1.default.createElement("span", { className: "text-gray-600", "aria-label": "Required field" }, "*")),
35
36
  react_1.default.createElement("input", { ref: ref, id: name, name: name, type: type, "aria-invalid": !!error, className: "squiz-fte-form-control", ...rest }),
36
37
  error && react_1.default.createElement("div", { className: "squiz-fte-form-error" }, error)));
37
38
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@squiz/formatted-text-editor",
3
- "version": "1.21.1-alpha.32",
3
+ "version": "1.21.1-alpha.33",
4
4
  "main": "lib/index.js",
5
5
  "types": "lib/index.d.ts",
6
6
  "scripts": {
@@ -74,5 +74,5 @@
74
74
  "volta": {
75
75
  "node": "18.15.0"
76
76
  },
77
- "gitHead": "7aae392e4aaf3fee925010d78d60cb22d474cb46"
77
+ "gitHead": "1618acf485927bf853d04d9000eda160c03e2567"
78
78
  }
@@ -6,6 +6,7 @@ import { ImageAttributes } from '@remirror/extension-image/dist-types/image-exte
6
6
  import Button from '../../../../ui/Button/Button';
7
7
  import LinkOffIcon from '@mui/icons-material/LinkOff';
8
8
  import InsertLinkRoundedIcon from '@mui/icons-material/InsertLinkRounded';
9
+ import clsx from 'clsx';
9
10
 
10
11
  export type UpdateImageOptions = ImageAttributes & {
11
12
  src: string;
@@ -21,25 +22,41 @@ export type FormProps = {
21
22
  };
22
23
  export type Dimensions = 'width' | 'height';
23
24
 
25
+ const regexDataURI =
26
+ /^data:([a-z]+\/[a-z0-9-+.]+(;[a-z-]+=[a-z0-9-]+)?)?(;base64)?,([a-z0-9!$&',()*+;=\-._~:@/?%\s]*)$/i;
27
+
24
28
  const ImageForm = ({ data, onSubmit }: FormProps): ReactElement => {
25
- const { register, handleSubmit, setValue } = useForm<ImageFormData>({
29
+ const {
30
+ register,
31
+ handleSubmit,
32
+ setValue,
33
+ formState: { errors },
34
+ } = useForm<ImageFormData>({
26
35
  defaultValues: data,
27
36
  });
37
+
28
38
  const [aspectRatioFromWidth, setAspectRatioFromWidth] = useState(9 / 16);
29
39
  const [aspectRatioFromHeight, setAspectRatioFromHeight] = useState(16 / 9);
30
40
  const [aspectRatioLocked, setAspectRatioLocked] = useState(true);
31
41
 
32
- const setDimensionsFromURL = (e: { target: { value: string } }) => {
42
+ const setDimensionsFromURL = (e: React.ChangeEvent<HTMLInputElement>) => {
33
43
  getImageSize(e.target.value)
34
- .then(({ width, height }) => {
44
+ .then(({ width, height }: { width: number; height: number }) => {
35
45
  setValue('width', width);
36
46
  setValue('height', height);
37
47
  setAspectRatioFromWidth(height / width);
38
48
  setAspectRatioFromHeight(width / height);
39
49
  })
40
- .catch(() => {
41
- // TODO: we will use this when we add validation in a follow-up ticket
42
- });
50
+ .catch();
51
+ };
52
+
53
+ const validateIsNotImage = async (src: string) => {
54
+ try {
55
+ await getImageSize(src);
56
+ return false;
57
+ } catch (error) {
58
+ return true;
59
+ }
43
60
  };
44
61
 
45
62
  const calculateDimensions = () => {
@@ -61,14 +78,53 @@ const ImageForm = ({ data, onSubmit }: FormProps): ReactElement => {
61
78
  return (
62
79
  <form className="squiz-fte-form" onSubmit={handleSubmit(onSubmit)}>
63
80
  <div className="squiz-fte-form-group mb-2">
64
- <Input label="Source" {...register('src', { onChange: setDimensionsFromURL })} />
81
+ <Input
82
+ label="Source"
83
+ required
84
+ error={errors?.src?.message}
85
+ {...register('src', {
86
+ onChange: setDimensionsFromURL,
87
+ required: 'Source is required',
88
+ validate: {
89
+ isValidImage: async (value: string | undefined) => {
90
+ if (value && regexDataURI.test(value)) {
91
+ return 'Must not be a data URI';
92
+ }
93
+ if (value && (await validateIsNotImage(value))) {
94
+ return 'Must be a valid image URL';
95
+ }
96
+ },
97
+ },
98
+ })}
99
+ />
65
100
  </div>
66
101
  <div className="squiz-fte-form-group mb-2">
67
- <Input label="Alternative description" {...register('alt')} />
102
+ <Input
103
+ label="Alternative description"
104
+ required
105
+ error={errors?.alt?.message}
106
+ {...register('alt', { required: 'Alternative description is required' })}
107
+ />
68
108
  </div>
69
- <div className="flex flex-row items-end">
109
+ <div className="flex flex-row">
70
110
  <div className="squiz-fte-form-group mb-2">
71
- <Input label="Width" {...register('width')} type="number" name="width" onChange={calculateDimensions} />
111
+ <Input
112
+ label="Width"
113
+ type="number"
114
+ required
115
+ error={errors?.width?.message}
116
+ {...register('width', {
117
+ onChange: calculateDimensions,
118
+ required: 'Width is required',
119
+ validate: {
120
+ isValidWidth: (value: number | undefined) => {
121
+ if (value && !(value > 0)) {
122
+ return 'Must be higher than 0';
123
+ }
124
+ },
125
+ },
126
+ })}
127
+ />
72
128
  </div>
73
129
  <div className="flex mx-1 mb-2">
74
130
  <Button
@@ -77,10 +133,27 @@ const ImageForm = ({ data, onSubmit }: FormProps): ReactElement => {
77
133
  icon={aspectRatioLocked ? <InsertLinkRoundedIcon /> : <LinkOffIcon />}
78
134
  label="Constrain properties"
79
135
  isDisabled={false}
136
+ className={clsx('my-auto', !errors?.height && !errors?.width && 'mb-0')}
80
137
  />
81
138
  </div>
82
139
  <div className="squiz-fte-form-group mb-2">
83
- <Input label="Height" {...register('height')} type="number" name="height" onChange={calculateDimensions} />
140
+ <Input
141
+ label="Height"
142
+ type="number"
143
+ required
144
+ error={errors?.height?.message}
145
+ {...register('height', {
146
+ onChange: calculateDimensions,
147
+ required: 'Height is required',
148
+ validate: {
149
+ isValidHeight: (value: number | undefined) => {
150
+ if (value && !(value > 0)) {
151
+ return 'Must be higher than 0';
152
+ }
153
+ },
154
+ },
155
+ })}
156
+ />
84
157
  </div>
85
158
  </div>
86
159
  </form>
@@ -4,6 +4,12 @@ import { NodeSelection } from 'prosemirror-state';
4
4
  import React from 'react';
5
5
  import { renderWithEditor } from '../../../../tests';
6
6
  import ImageButton from './ImageButton';
7
+ import { getImageSize } from 'react-image-size';
8
+
9
+ jest.mock('react-image-size');
10
+ beforeEach(() => {
11
+ (getImageSize as jest.Mock).mockResolvedValue({ width: 2, height: 2 });
12
+ });
7
13
 
8
14
  describe('ImageButton', () => {
9
15
  const openModal = async () => {
@@ -132,4 +138,36 @@ describe('ImageButton', () => {
132
138
  fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
133
139
  expect(modalHeading).not.toBeInTheDocument();
134
140
  });
141
+ it('Adds a new image with Source field', async () => {
142
+ await renderWithEditor(<ImageButton />, { content: 'Some nonsense content here' });
143
+
144
+ // open the modal and add an image.
145
+ await openModal();
146
+ fireEvent.change(screen.getByLabelText('Source'), { target: { value: '' } });
147
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
148
+ expect(await screen.findByText('Source is required')).toBeInTheDocument();
149
+ });
150
+
151
+ it('Adds a new image with no alt text', async () => {
152
+ await renderWithEditor(<ImageButton />, { content: 'Some tacos here' });
153
+
154
+ // open the modal and add an image.
155
+ await openModal();
156
+ fireEvent.change(screen.getByLabelText('Source'), { target: { value: 'https://httpcats.com/529.jpg' } });
157
+ fireEvent.change(screen.getByLabelText('Alternative description'), { target: { value: '' } });
158
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
159
+ expect(await screen.findByText('Alternative description is required')).toBeInTheDocument();
160
+ });
161
+
162
+ it('Adds a new image with no width or height text', async () => {
163
+ await renderWithEditor(<ImageButton />, { content: 'Some beautiful content here' });
164
+
165
+ // open the modal and add an image.
166
+ await openModal();
167
+ fireEvent.change(screen.getByLabelText('Width'), { target: { value: '' } });
168
+ fireEvent.change(screen.getByLabelText('Height'), { target: { value: '' } });
169
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
170
+ expect(await screen.findByText('Width is required')).toBeInTheDocument();
171
+ expect(await screen.findByText('Height is required')).toBeInTheDocument();
172
+ });
135
173
  });
@@ -1,9 +1,14 @@
1
1
  import '@testing-library/jest-dom';
2
- import { screen, fireEvent } from '@testing-library/react';
2
+ import { screen, fireEvent, waitFor } from '@testing-library/react';
3
3
  import React from 'react';
4
4
  import { renderWithEditor } from '../../../../tests';
5
5
  import ImageModal from './ImageModal';
6
+ import { getImageSize } from 'react-image-size';
6
7
 
8
+ jest.mock('react-image-size');
9
+ beforeEach(() => {
10
+ (getImageSize as jest.Mock).mockResolvedValue({ width: 0, height: 0 });
11
+ });
7
12
  const mockSubmitFunction = jest.fn();
8
13
  const mockCancelFunction = jest.fn();
9
14
  const setup = () => {
@@ -31,28 +36,38 @@ describe('ImageModal', () => {
31
36
  it('Populates the source field when a source is supplied', async () => {
32
37
  const { sourceInput } = setup();
33
38
  fireEvent.change(sourceInput, { target: { value: 'https://httpcats.com/302.jpg' } });
34
- expect(sourceInput.value).toBe('https://httpcats.com/302.jpg');
39
+ await waitFor(() => {
40
+ expect(sourceInput.value).toBe('https://httpcats.com/302.jpg');
41
+ });
35
42
  });
36
43
 
37
44
  it('Renders empty width and height fields if the image source is empty', async () => {
38
45
  const { sourceInput, widthInput, heightInput } = setup();
39
46
  fireEvent.change(sourceInput, { target: { value: '' } });
40
- expect(widthInput.value).toBe('');
41
- expect(heightInput.value).toBe('');
47
+ await waitFor(() => {
48
+ expect(widthInput.value).toBe('');
49
+ expect(heightInput.value).toBe('');
50
+ });
42
51
  });
43
52
 
44
53
  it('Updates the height field with aspect ratio based value from width', async () => {
45
54
  const { widthInput, heightInput } = setup();
46
55
  fireEvent.change(widthInput, { target: { value: '300' } });
47
- expect(widthInput.value).toBe('300');
48
- expect(heightInput.value).toBe('168.75');
56
+ await waitFor(() => {
57
+ expect(widthInput.value).toBe('300');
58
+ expect(heightInput.value).toBe('168.75');
59
+ });
49
60
  });
61
+
50
62
  it('Updates the width field with aspect ratio based value from height', async () => {
51
63
  const { widthInput, heightInput } = setup();
52
64
  fireEvent.change(heightInput, { target: { value: '100' } });
53
- expect(heightInput.value).toBe('100');
54
- expect(widthInput.value).toBe('177.78');
65
+ await waitFor(() => {
66
+ expect(heightInput.value).toBe('100');
67
+ expect(widthInput.value).toBe('177.78');
68
+ });
55
69
  });
70
+
56
71
  it('Does not change the width when height is changed and aspect ratio link is off', () => {
57
72
  const { widthInput, heightInput } = setup();
58
73
  fireEvent.change(heightInput, { target: { value: '100' } });
@@ -63,6 +78,7 @@ describe('ImageModal', () => {
63
78
  expect(heightInput.value).toBe('200');
64
79
  expect(widthInput.value).toBe('177.78');
65
80
  });
81
+
66
82
  it('Does not change the height when width is changed and aspect ratio link is off', () => {
67
83
  const { widthInput, heightInput } = setup();
68
84
  fireEvent.change(widthInput, { target: { value: '450' } });
@@ -73,6 +89,7 @@ describe('ImageModal', () => {
73
89
  expect(widthInput.value).toBe('600');
74
90
  expect(heightInput.value).toBe('253.13');
75
91
  });
92
+
76
93
  it('Changes the icon when aspect ratio button is toggled', () => {
77
94
  renderWithEditor(<ImageModal onCancel={mockCancelFunction} onSubmit={mockSubmitFunction} />);
78
95
  expect(screen.getByTestId('InsertLinkRoundedIcon')).toBeInTheDocument();
@@ -80,4 +97,26 @@ describe('ImageModal', () => {
80
97
  expect(screen.queryByTestId('InsertLinkRoundedIcon')).not.toBeInTheDocument();
81
98
  expect(screen.getByTestId('LinkOffIcon')).toBeInTheDocument();
82
99
  });
100
+
101
+ it('Returns relevant error message if the width is not higher than 0', async () => {
102
+ const { widthInput } = setup();
103
+
104
+ fireEvent.change(widthInput, { target: { value: '0' } });
105
+ fireEvent.click(screen.getByRole('button', { name: /Apply/i }));
106
+ await waitFor(() => {
107
+ expect(widthInput.value).toBe('0');
108
+ });
109
+ expect(await screen.findByText('Must be higher than 0')).toBeInTheDocument();
110
+ });
111
+
112
+ it('Returns relevant error message if the height is not higher than 0', async () => {
113
+ const { heightInput } = setup();
114
+
115
+ fireEvent.change(heightInput, { target: { value: '0' } });
116
+ fireEvent.click(screen.getByRole('button', { name: /Apply/i }));
117
+ await waitFor(() => {
118
+ expect(heightInput.value).toBe('0');
119
+ });
120
+ expect(await screen.findByText('Must be higher than 0')).toBeInTheDocument();
121
+ });
83
122
  });
@@ -14,7 +14,7 @@
14
14
  height: auto;
15
15
  }
16
16
  .squiz-fte-btn {
17
- @apply p-1;
17
+ @apply p-1 font-bold;
18
18
  ~ .squiz-fte-btn {
19
19
  margin-left: 2px;
20
20
  }
@@ -8,9 +8,10 @@ type ButtonProps = {
8
8
  label: string;
9
9
  text?: string;
10
10
  icon?: ReactElement;
11
+ className?: string;
11
12
  };
12
13
 
13
- const Button = ({ handleOnClick, isDisabled, isActive, label, text, icon }: ButtonProps) => {
14
+ const Button = ({ handleOnClick, isDisabled, isActive, label, text, icon, className }: ButtonProps) => {
14
15
  return (
15
16
  <button
16
17
  aria-label={label}
@@ -18,7 +19,12 @@ const Button = ({ handleOnClick, isDisabled, isActive, label, text, icon }: Butt
18
19
  type="button"
19
20
  onClick={handleOnClick}
20
21
  disabled={isDisabled}
21
- className={clsx('squiz-fte-btn', isActive && 'squiz-fte-btn--is-active', icon && ' squiz-fte-btn--is-icon')}
22
+ className={clsx(
23
+ 'squiz-fte-btn',
24
+ isActive && 'squiz-fte-btn--is-active',
25
+ icon && ' squiz-fte-btn--is-icon',
26
+ className,
27
+ )}
22
28
  >
23
29
  {text && <span>{text}</span>}
24
30
  {icon && icon}
@@ -1,5 +1,5 @@
1
1
  .squiz-fte-btn {
2
- @apply font-normal rounded ease-linear transition-all bg-white text-gray-600 duration-150;
2
+ @apply font-bold rounded ease-linear transition-all bg-white text-gray-600 duration-150;
3
3
  display: flex;
4
4
  align-items: center;
5
5
  text-align: center;
@@ -7,7 +7,7 @@ describe('Input', () => {
7
7
  const mockOnChange = jest.fn();
8
8
 
9
9
  const InputComponent = () => {
10
- return <Input name="text-input" defaultValue="Water" label="Text input" onChange={mockOnChange} />;
10
+ return <Input name="text-input" defaultValue="Water" label="Text input" onChange={mockOnChange} required />;
11
11
  };
12
12
 
13
13
  it('Renders the label', () => {
@@ -40,4 +40,10 @@ describe('Input', () => {
40
40
  fireEvent.change(input, { target: { value: 'Wine' } });
41
41
  expect(mockOnChange).toHaveBeenCalled();
42
42
  });
43
+
44
+ it('If the field is declared required, it is marked as such', () => {
45
+ render(<InputComponent />);
46
+ const requiredMarker = screen.getByLabelText('Required field');
47
+ expect(requiredMarker).toBeInTheDocument();
48
+ });
43
49
  });
@@ -7,7 +7,7 @@ type InputProps = InputHTMLAttributes<HTMLInputElement> & {
7
7
  };
8
8
 
9
9
  const InputInternal = (
10
- { name, label, type = 'text', error, ...rest }: InputProps,
10
+ { name, label, type = 'text', error, required, ...rest }: InputProps,
11
11
  ref: ForwardedRef<HTMLInputElement>,
12
12
  ) => {
13
13
  return (
@@ -17,6 +17,11 @@ const InputInternal = (
17
17
  {label}
18
18
  </label>
19
19
  )}
20
+ {required && (
21
+ <span className="text-gray-600" aria-label="Required field">
22
+ *
23
+ </span>
24
+ )}
20
25
  <input
21
26
  ref={ref}
22
27
  id={name}
@@ -15,7 +15,7 @@
15
15
  }
16
16
  &-invalid-form-field {
17
17
  .squiz-fte-form-control {
18
- @apply border-red-300 bg-no-repeat;
18
+ @apply border-red-300 bg-no-repeat pr-8;
19
19
  background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjQiIHdpZHRoPSIyNCI+PHJlY3Qgd2lkdGg9IjEwMCUiIGhlaWdodD0iMTAwJSIgZmlsbD0ibm9uZSIvPjxnIGNsYXNzPSJjdXJyZW50TGF5ZXIiPjxwYXRoIGQ9Ik00LjQ3IDIxaDE1LjA2YzEuNTQgMCAyLjUtMS42NyAxLjczLTNMMTMuNzMgNC45OWMtLjc3LTEuMzMtMi42OS0xLjMzLTMuNDYgMEwyLjc0IDE4Yy0uNzcgMS4zMy4xOSAzIDEuNzMgM3pNMTIgMTRjLS41NSAwLTEtLjQ1LTEtMXYtMmMwLS41NS40NS0xIDEtMXMxIC40NSAxIDF2MmMwIC41NS0uNDUgMS0xIDF6bTEgNGgtMnYtMmgydjJ6IiBjbGFzcz0ic2VsZWN0ZWQiIGZpbGw9IiNkNzIzMjEiLz48L2c+PC9zdmc+');
20
20
  background-position: top 0.25rem right 0.25rem;
21
21
  background-size: 1.5rem;