@squiz/formatted-text-editor 1.33.1-alpha.3 → 1.33.1-alpha.4

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.
Files changed (57) hide show
  1. package/demo/App.tsx +4 -24
  2. package/demo/AppContext.tsx +28 -0
  3. package/demo/index.scss +0 -2
  4. package/demo/main.tsx +2 -0
  5. package/demo/resources.json +28 -0
  6. package/demo/sources.json +23 -0
  7. package/lib/Editor/EditorContext.d.ts +0 -7
  8. package/lib/Editor/EditorContext.js +0 -2
  9. package/lib/EditorToolbar/Tools/Image/Form/ImageForm.js +8 -19
  10. package/lib/EditorToolbar/Tools/Link/Form/LinkForm.js +6 -38
  11. package/lib/EditorToolbar/Tools/Link/RemoveLinkButton.js +1 -1
  12. package/lib/Extensions/Extensions.js +0 -2
  13. package/lib/Extensions/ImageExtension/AssetImageExtension.d.ts +0 -1
  14. package/lib/Extensions/ImageExtension/AssetImageExtension.js +1 -2
  15. package/lib/Extensions/LinkExtension/AssetLinkExtension.d.ts +0 -1
  16. package/lib/Extensions/LinkExtension/AssetLinkExtension.js +1 -2
  17. package/lib/index.css +7 -2
  18. package/lib/types.d.ts +3 -3
  19. package/lib/ui/Fields/Checkbox/Checkbox.js +1 -1
  20. package/lib/ui/Fields/Input/Input.d.ts +2 -4
  21. package/lib/ui/Fields/Input/Input.js +3 -9
  22. package/lib/ui/Fields/InputContainer/InputContainer.d.ts +9 -0
  23. package/lib/ui/Fields/InputContainer/InputContainer.js +16 -0
  24. package/lib/ui/Fields/MatrixAsset/MatrixAsset.d.ts +17 -0
  25. package/lib/ui/Fields/MatrixAsset/MatrixAsset.js +29 -0
  26. package/lib/utils/validation.d.ts +2 -1
  27. package/lib/utils/validation.js +8 -2
  28. package/package.json +4 -3
  29. package/src/Editor/Editor.spec.tsx +1 -1
  30. package/src/Editor/EditorContext.spec.tsx +11 -13
  31. package/src/Editor/EditorContext.ts +0 -11
  32. package/src/EditorToolbar/Tools/Image/Form/ImageForm.spec.tsx +27 -10
  33. package/src/EditorToolbar/Tools/Image/Form/ImageForm.tsx +25 -29
  34. package/src/EditorToolbar/Tools/Image/ImageButton.spec.tsx +69 -43
  35. package/src/EditorToolbar/Tools/Link/Form/LinkForm.spec.tsx +15 -5
  36. package/src/EditorToolbar/Tools/Link/Form/LinkForm.tsx +14 -20
  37. package/src/EditorToolbar/Tools/Link/LinkButton.spec.tsx +45 -29
  38. package/src/EditorToolbar/Tools/Link/RemoveLinkButton.spec.tsx +47 -4
  39. package/src/EditorToolbar/Tools/Link/RemoveLinkButton.tsx +3 -2
  40. package/src/Extensions/Extensions.ts +0 -2
  41. package/src/Extensions/ImageExtension/AssetImageExtension.ts +1 -3
  42. package/src/Extensions/LinkExtension/AssetLinkExtension.ts +1 -3
  43. package/src/types.ts +7 -5
  44. package/src/ui/Fields/Checkbox/Checkbox.tsx +1 -1
  45. package/src/ui/Fields/Input/Input.tsx +4 -18
  46. package/src/ui/Fields/InputContainer/InputContainer.spec.tsx +18 -0
  47. package/src/ui/Fields/InputContainer/InputContainer.tsx +29 -0
  48. package/src/ui/Fields/MatrixAsset/MatrixAsset.spec.tsx +103 -0
  49. package/src/ui/Fields/MatrixAsset/MatrixAsset.tsx +55 -0
  50. package/src/ui/_forms.scss +4 -2
  51. package/src/utils/validation.spec.ts +22 -0
  52. package/src/utils/validation.ts +9 -1
  53. package/tests/index.ts +2 -0
  54. package/tests/mockResourceBrowserContext.tsx +63 -0
  55. package/tests/renderWithContext.tsx +18 -0
  56. package/tests/renderWithEditor.tsx +18 -21
  57. package/vite.config.ts +8 -0
@@ -7,7 +7,7 @@ import RemoveLinkButton from './RemoveLinkButton';
7
7
  describe('RemoveLinkButton', () => {
8
8
  it('Removes a link', async () => {
9
9
  const { editor, getJsonContent } = await renderWithEditor(<RemoveLinkButton />, {
10
- context: { matrix: { matrixDomain: 'my-matrix.squiz.net' } },
10
+ context: { editor: { matrix: { matrixDomain: 'my-matrix.squiz.net' } } },
11
11
  content: {
12
12
  type: 'doc',
13
13
  content: [
@@ -17,7 +17,12 @@ describe('RemoveLinkButton', () => {
17
17
  {
18
18
  type: 'text',
19
19
  text: 'Sample link',
20
- marks: [{ type: 'assetLink', attrs: { matrixAssetId: '123', target: '_blank' } }],
20
+ marks: [
21
+ {
22
+ type: 'assetLink',
23
+ attrs: { matrixAssetId: '123', matrixIdentifier: 'matrix-identifier', target: '_blank' },
24
+ },
25
+ ],
21
26
  },
22
27
  { type: 'text', text: ' with ' },
23
28
  {
@@ -47,7 +52,7 @@ describe('RemoveLinkButton', () => {
47
52
 
48
53
  it('Removes the link when clicking the keyboard shortcut', async () => {
49
54
  const { elements, editor, getJsonContent } = await renderWithEditor(<RemoveLinkButton />, {
50
- context: { matrix: { matrixDomain: 'my-matrix.squiz.net' } },
55
+ context: { editor: { matrix: { matrixDomain: 'my-matrix.squiz.net' } } },
51
56
  content: {
52
57
  type: 'doc',
53
58
  content: [
@@ -57,7 +62,12 @@ describe('RemoveLinkButton', () => {
57
62
  {
58
63
  type: 'text',
59
64
  text: 'Sample link',
60
- marks: [{ type: 'assetLink', attrs: { matrixAssetId: '123', target: '_blank' } }],
65
+ marks: [
66
+ {
67
+ type: 'assetLink',
68
+ attrs: { matrixAssetId: '123', matrixIdentifier: 'matrix-identifier', target: '_blank' },
69
+ },
70
+ ],
61
71
  },
62
72
  { type: 'text', text: ' with ' },
63
73
  {
@@ -97,4 +107,37 @@ describe('RemoveLinkButton', () => {
97
107
  // expect remove button to be enabled
98
108
  expect(screen.getByRole('button', { name: 'Remove link (shift+cmd+K)' })).not.toBeDisabled();
99
109
  });
110
+
111
+ it('Enables the Remove link button when asset link text is selected', async () => {
112
+ const { editor } = await renderWithEditor(<RemoveLinkButton />, {
113
+ context: { editor: { matrix: { matrixDomain: 'my-matrix.squiz.net' } } },
114
+ content: {
115
+ type: 'doc',
116
+ content: [
117
+ {
118
+ type: 'paragraph',
119
+ content: [
120
+ {
121
+ type: 'text',
122
+ text: 'Sample link',
123
+ marks: [
124
+ {
125
+ type: 'assetLink',
126
+ attrs: { matrixAssetId: '123', matrixIdentifier: 'matrix-identifier', target: '_blank' },
127
+ },
128
+ ],
129
+ },
130
+ ],
131
+ },
132
+ ],
133
+ },
134
+ });
135
+
136
+ // expect remove button to be disabled
137
+ expect(screen.getByRole('button', { name: 'Remove link (shift+cmd+K)' })).toBeDisabled();
138
+ // jump to the middle of the link.
139
+ await act(() => editor.selectText(3));
140
+ // expect remove button to be enabled
141
+ expect(screen.getByRole('button', { name: 'Remove link (shift+cmd+K)' })).not.toBeDisabled();
142
+ });
100
143
  });
@@ -2,13 +2,14 @@ import React, { useCallback } from 'react';
2
2
  import { useChainedCommands, useActive, useKeymap } from '@remirror/react';
3
3
  import Button from '../../../ui/Button/Button';
4
4
  import LinkOffIcon from '@mui/icons-material/LinkOff';
5
+ import { AssetLinkExtension } from '../../../Extensions/LinkExtension/AssetLinkExtension';
5
6
  import { LinkExtension } from '../../../Extensions/LinkExtension/LinkExtension';
6
7
  import { LinkButtonProps } from './LinkButton';
7
8
 
8
9
  const RemoveLinkButton = ({ inPopover = false }: LinkButtonProps) => {
9
10
  const chain = useChainedCommands();
10
- const active = useActive<LinkExtension>();
11
- const disabled = !active.link();
11
+ const active = useActive<LinkExtension | AssetLinkExtension>();
12
+ const disabled = !active.link() && !active.assetLink();
12
13
 
13
14
  const handleClick = () => {
14
15
  chain.removeLink().removeAssetLink().focus().run();
@@ -42,12 +42,10 @@ export const createExtensions = (context: EditorContextOptions) => {
42
42
  new ImageExtension(),
43
43
  new ImageExtension({ preferPastedTextContent: false }),
44
44
  new AssetImageExtension({
45
- matrixIdentifier: context.matrix.matrixIdentifier,
46
45
  matrixDomain: context.matrix.matrixDomain,
47
46
  }),
48
47
  new LinkExtension(),
49
48
  new AssetLinkExtension({
50
- matrixIdentifier: context.matrix.matrixIdentifier,
51
49
  matrixDomain: context.matrix.matrixDomain,
52
50
  }),
53
51
  ];
@@ -16,7 +16,6 @@ import { resolveMatrixAssetUrl } from '../../utils/resolveMatrixAssetUrl';
16
16
  import { NodeName } from '../Extensions';
17
17
 
18
18
  export type AssetImageOptions = {
19
- matrixIdentifier?: string;
20
19
  matrixDomain?: string;
21
20
  };
22
21
 
@@ -28,7 +27,6 @@ export type AssetImageAttributes = {
28
27
 
29
28
  @extension<AssetImageOptions>({
30
29
  defaultOptions: {
31
- matrixIdentifier: '',
32
30
  matrixDomain: '',
33
31
  },
34
32
  defaultPriority: ExtensionPriority.High,
@@ -51,7 +49,7 @@ export class AssetImageExtension extends NodeExtension<AssetImageOptions> {
51
49
  attrs: {
52
50
  ...extra.defaults(),
53
51
  matrixAssetId: {},
54
- matrixIdentifier: { default: this.options.matrixIdentifier },
52
+ matrixIdentifier: {},
55
53
  matrixDomain: { default: this.options.matrixDomain },
56
54
  },
57
55
  parseDOM: [
@@ -20,7 +20,6 @@ export type AssetLinkAttributes = {
20
20
  };
21
21
 
22
22
  export type AssetLinkOptions = {
23
- matrixIdentifier?: string;
24
23
  matrixDomain?: string;
25
24
  defaultTarget?: LinkTarget;
26
25
  supportedTargets?: LinkTarget[];
@@ -34,7 +33,6 @@ export type UpdateAssetLinkProps = {
34
33
 
35
34
  @extension<AssetLinkOptions>({
36
35
  defaultOptions: {
37
- matrixIdentifier: '',
38
36
  matrixDomain: '',
39
37
  defaultTarget: LinkTarget.Self,
40
38
  supportedTargets: [LinkTarget.Self, LinkTarget.Blank],
@@ -54,7 +52,7 @@ export class AssetLinkExtension extends MarkExtension<AssetLinkOptions> {
54
52
  attrs: {
55
53
  ...extra.defaults(),
56
54
  matrixAssetId: {},
57
- matrixIdentifier: { default: this.options.matrixIdentifier },
55
+ matrixIdentifier: {},
58
56
  matrixDomain: { default: this.options.matrixDomain },
59
57
  target: { default: this.options.defaultTarget },
60
58
  },
package/src/types.ts CHANGED
@@ -1,5 +1,7 @@
1
- export type DeepPartial<T> = T extends Record<string, unknown>
2
- ? {
3
- [P in keyof T]?: DeepPartial<T[P]>;
4
- }
5
- : T;
1
+ export type DeepPartial<T> = {
2
+ [P in keyof T]?: T[P] extends Array<infer U>
3
+ ? Array<DeepPartial<U>>
4
+ : T[P] extends ReadonlyArray<infer U>
5
+ ? ReadonlyArray<DeepPartial<U>>
6
+ : DeepPartial<T[P]>;
7
+ };
@@ -41,7 +41,7 @@ export const Checkbox = <TChecked, TUnchecked>({
41
41
  {toggled && <CheckRoundedIcon />}
42
42
  </button>
43
43
  {/* Checkbox label as a button, acts as a secondary way to toggle */}
44
- <button type="button" className="label" onClick={toggleCheckbox}>
44
+ <button type="button" className="label" onClick={toggleCheckbox} tabIndex={-1}>
45
45
  {label}
46
46
  </button>
47
47
  </div>
@@ -1,27 +1,14 @@
1
1
  import React, { ForwardedRef, forwardRef, InputHTMLAttributes } from 'react';
2
- import clsx from 'clsx';
2
+ import { InputContainer, InputContainerProps } from '../InputContainer/InputContainer';
3
3
 
4
- type InputProps = InputHTMLAttributes<HTMLInputElement> & {
5
- label?: string;
6
- error?: string;
7
- };
4
+ type InputProps = InputHTMLAttributes<HTMLInputElement> & Omit<InputContainerProps, 'children'>;
8
5
 
9
6
  const InputInternal = (
10
7
  { name, label, type = 'text', error, required, ...rest }: InputProps,
11
8
  ref: ForwardedRef<HTMLInputElement>,
12
9
  ) => {
13
10
  return (
14
- <div className={clsx(error && 'squiz-fte-invalid-form-field')}>
15
- {label && (
16
- <label htmlFor={name} className="squiz-fte-form-label">
17
- {label}
18
- </label>
19
- )}
20
- {required && (
21
- <span className="text-gray-600" aria-label="Required field">
22
- *
23
- </span>
24
- )}
11
+ <InputContainer name={name} label={label} error={error} required={required}>
25
12
  <input
26
13
  ref={ref}
27
14
  id={name}
@@ -31,8 +18,7 @@ const InputInternal = (
31
18
  className="squiz-fte-form-control"
32
19
  {...rest}
33
20
  />
34
- {error && <div className="squiz-fte-form-error">{error}</div>}
35
- </div>
21
+ </InputContainer>
36
22
  );
37
23
  };
38
24
 
@@ -0,0 +1,18 @@
1
+ import '@testing-library/jest-dom';
2
+ import React from 'react';
3
+ import { render, screen } from '@testing-library/react';
4
+ import { InputContainer } from './InputContainer';
5
+
6
+ describe('InputContainer', () => {
7
+ it('Renders with expected content', () => {
8
+ render(
9
+ <InputContainer name="my-input" label="My input" error="Input is invalid" required={true}>
10
+ input element
11
+ </InputContainer>,
12
+ );
13
+
14
+ expect(screen.getByText('My input')).toHaveClass('squiz-fte-form-label');
15
+ expect(screen.getByText('input element')).toBeInTheDocument();
16
+ expect(screen.getByText('Input is invalid')).toHaveClass('squiz-fte-form-error');
17
+ });
18
+ });
@@ -0,0 +1,29 @@
1
+ import React, { ReactNode } from 'react';
2
+ import clsx from 'clsx';
3
+
4
+ export type InputContainerProps = {
5
+ name?: string;
6
+ label?: string;
7
+ error?: string;
8
+ required?: boolean;
9
+ children: ReactNode;
10
+ };
11
+
12
+ export const InputContainer = ({ name, label, error, required, children }: InputContainerProps) => {
13
+ return (
14
+ <div className={clsx(error && 'squiz-fte-invalid-form-field')}>
15
+ {label && (
16
+ <label htmlFor={name} className="squiz-fte-form-label">
17
+ {label}
18
+ </label>
19
+ )}
20
+ {label && required && (
21
+ <span className="text-gray-600" aria-label="Required field">
22
+ *
23
+ </span>
24
+ )}
25
+ {children}
26
+ {error && <div className="squiz-fte-form-error">{error}</div>}
27
+ </div>
28
+ );
29
+ };
@@ -0,0 +1,103 @@
1
+ import '@testing-library/jest-dom';
2
+ import React from 'react';
3
+ import { fireEvent, render, screen } from '@testing-library/react';
4
+ import { MatrixAsset } from './MatrixAsset';
5
+ import { mockResourceBrowserContext } from '../../../../tests';
6
+
7
+ describe('MatrixAsset', () => {
8
+ it('Renders empty state when no value is provided', () => {
9
+ render(<MatrixAsset modalTitle="Insert asset" onChange={jest.fn()} />);
10
+
11
+ expect(screen.getByRole('button', { name: 'Choose asset' })).toBeInTheDocument();
12
+ });
13
+
14
+ it('Renders a selected state when a value is provided', () => {
15
+ const { MockResourceBrowserContext } = mockResourceBrowserContext({
16
+ sources: [{ id: 'my-source-id' }],
17
+ resources: [{ id: 'my-resource-id', name: 'My resource' }],
18
+ });
19
+
20
+ render(
21
+ <MockResourceBrowserContext>
22
+ <MatrixAsset
23
+ modalTitle="Insert asset"
24
+ value={{
25
+ matrixIdentifier: 'my-source-id',
26
+ matrixAssetId: 'my-resource-id',
27
+ addional: 'addditional data',
28
+ }}
29
+ onChange={jest.fn()}
30
+ />
31
+ </MockResourceBrowserContext>,
32
+ );
33
+
34
+ expect(screen.getByText('My resource')).toBeInTheDocument();
35
+ });
36
+
37
+ it('Calls onChange with expected value when resources is selected', async () => {
38
+ const handleChange = jest.fn();
39
+ const { MockResourceBrowserContext, selectResource } = mockResourceBrowserContext({
40
+ sources: [{ id: 'my-source-id' }],
41
+ resources: [{ id: 'my-resource-id', name: 'My resource' }],
42
+ });
43
+
44
+ render(
45
+ <MockResourceBrowserContext>
46
+ <MatrixAsset
47
+ modalTitle="Insert asset"
48
+ value={{ matrixIdentifier: undefined, matrixAssetId: undefined, additional: 'additional data' }}
49
+ onChange={handleChange}
50
+ />
51
+ </MockResourceBrowserContext>,
52
+ );
53
+
54
+ await selectResource(screen.getByRole('button', { name: 'Choose asset' }), 'My resource');
55
+
56
+ expect(handleChange).toHaveBeenCalledWith({
57
+ target: {
58
+ value: {
59
+ additional: 'additional data',
60
+ matrixAssetId: 'my-resource-id',
61
+ matrixIdentifier: 'my-source-id',
62
+ },
63
+ },
64
+ });
65
+ });
66
+
67
+ it('Calls onChange with expected value when resources is cleared', async () => {
68
+ const handleChange = jest.fn();
69
+ const { MockResourceBrowserContext } = mockResourceBrowserContext({
70
+ sources: [{ id: 'my-source-id' }],
71
+ resources: [
72
+ { id: 'my-resource-id', name: 'My resource' },
73
+ { id: 'updated-resource-id', name: 'Updated resource' },
74
+ ],
75
+ });
76
+
77
+ render(
78
+ <MockResourceBrowserContext>
79
+ <MatrixAsset
80
+ modalTitle="Insert asset"
81
+ value={{
82
+ matrixIdentifier: 'my-source-id',
83
+ matrixAssetId: 'my-resource-id',
84
+ additional: 'additional data',
85
+ }}
86
+ onChange={handleChange}
87
+ />
88
+ </MockResourceBrowserContext>,
89
+ );
90
+
91
+ fireEvent.click(screen.getByRole('button', { name: 'Remove selection' }));
92
+
93
+ expect(handleChange).toHaveBeenCalledWith({
94
+ target: {
95
+ value: {
96
+ additional: 'additional data',
97
+ matrixAssetId: undefined,
98
+ matrixIdentifier: undefined,
99
+ },
100
+ },
101
+ });
102
+ });
103
+ });
@@ -0,0 +1,55 @@
1
+ import React from 'react';
2
+ import { HydratedResourceReference, ResourceBrowserInput } from '@squiz/resource-browser';
3
+ import { InputContainer, InputContainerProps } from '../InputContainer/InputContainer';
4
+
5
+ type MatrixAssetValue = {
6
+ matrixIdentifier?: string;
7
+ matrixAssetId?: string;
8
+ };
9
+
10
+ export type MatrixAssetProps<T extends MatrixAssetValue> = Omit<InputContainerProps, 'children'> & {
11
+ modalTitle: string;
12
+ allowedTypes?: string[];
13
+ value?: T | null;
14
+ // LinkForm contains a "target" property.
15
+ // react-hook-form treats the presence of this property as an "Event" object being passed through, see:
16
+ // https://github.com/react-hook-form/react-hook-form/blob/master/src/logic/getEventValue.ts
17
+ // Nest the value under a "target" object to work around the behaviour.
18
+ onChange: (value: { target: { value: T } }) => void;
19
+ };
20
+
21
+ export const MatrixAsset = <T extends MatrixAssetValue>({
22
+ modalTitle,
23
+ allowedTypes,
24
+ value,
25
+ onChange,
26
+ ...props
27
+ }: MatrixAssetProps<T>) => {
28
+ return (
29
+ <InputContainer {...props}>
30
+ <ResourceBrowserInput
31
+ modalTitle={modalTitle}
32
+ allowedTypes={allowedTypes}
33
+ value={
34
+ value && value.matrixIdentifier && value.matrixAssetId
35
+ ? {
36
+ source: value.matrixIdentifier,
37
+ resource: value.matrixAssetId,
38
+ }
39
+ : null
40
+ }
41
+ onChange={(reference: HydratedResourceReference | null) => {
42
+ onChange({
43
+ target: {
44
+ value: {
45
+ ...value,
46
+ matrixIdentifier: reference?.source?.id,
47
+ matrixAssetId: reference?.resource?.id,
48
+ } as T,
49
+ },
50
+ });
51
+ }}
52
+ />
53
+ </InputContainer>
54
+ );
55
+ };
@@ -6,6 +6,7 @@
6
6
  @apply mb-1 text-md font-semibold text-gray-600;
7
7
  }
8
8
  &-form-control {
9
+ height: 36px;
9
10
  padding: 6px 12px;
10
11
  @apply placeholder-slate-300 text-gray-800 relative bg-white rounded text-md border-2 border-gray-300 outline-0 focus:outline-0 focus:border-blue-300 w-full;
11
12
  &:focus,
@@ -14,10 +15,11 @@
14
15
  }
15
16
  }
16
17
  &-invalid-form-field {
17
- .squiz-fte-form-control {
18
+ .squiz-fte-form-control,
19
+ .resource-picker {
18
20
  @apply border-red-300 bg-no-repeat pr-8;
19
21
  background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjQiIHdpZHRoPSIyNCI+PHJlY3Qgd2lkdGg9IjEwMCUiIGhlaWdodD0iMTAwJSIgZmlsbD0ibm9uZSIvPjxnIGNsYXNzPSJjdXJyZW50TGF5ZXIiPjxwYXRoIGQ9Ik00LjQ3IDIxaDE1LjA2YzEuNTQgMCAyLjUtMS42NyAxLjczLTNMMTMuNzMgNC45OWMtLjc3LTEuMzMtMi42OS0xLjMzLTMuNDYgMEwyLjc0IDE4Yy0uNzcgMS4zMy4xOSAzIDEuNzMgM3pNMTIgMTRjLS41NSAwLTEtLjQ1LTEtMXYtMmMwLS41NS40NS0xIDEtMXMxIC40NSAxIDF2MmMwIC41NS0uNDUgMS0xIDF6bTEgNGgtMnYtMmgydjJ6IiBjbGFzcz0ic2VsZWN0ZWQiIGZpbGw9IiNkNzIzMjEiLz48L2c+PC9zdmc+');
20
- background-position: top 0.25rem right 0.25rem;
22
+ background-position: center right 0.25rem;
21
23
  background-size: 1.5rem;
22
24
  }
23
25
  }
@@ -0,0 +1,22 @@
1
+ import { hasProperties, noEmptySpacesValidation } from './validation';
2
+
3
+ describe('validation', () => {
4
+ it.each([
5
+ ['String with only spaces', ' ', 'Empty space is not allowed'],
6
+ ['Empty string', '', undefined],
7
+ ['Non-empty string', 'test value', undefined],
8
+ ['String with trailing/leading spaces', ' test value ', undefined],
9
+ ])('noEmptySpacesValidation - %s', (description: string, value: string, expected: string | undefined) => {
10
+ expect(noEmptySpacesValidation(value)).toBe(expected);
11
+ });
12
+
13
+ it.each([
14
+ ['Value is null', null, ['prop1'], 'Field is invalid'],
15
+ ['Value is undefined', undefined, ['prop1'], 'Field is invalid'],
16
+ ['Value has none of the required props', { prop2: 'Not what we want' }, ['prop1'], 'Field is invalid'],
17
+ ['Value has all of the required props', { prop1: 'Valid' }, ['prop1'], undefined],
18
+ ['Value has all of the required props + extra', { prop1: 'Valid', prop2: 'Another prop' }, ['prop1'], undefined],
19
+ ])('hasProperties - %s', (description: string, value: any, properties: string[], expected: string | undefined) => {
20
+ expect(hasProperties('Field is invalid', properties)(value)).toBe(expected);
21
+ });
22
+ });
@@ -1,8 +1,16 @@
1
- export const noEmptySpacesValidation = async (value: string | undefined) => {
1
+ export const noEmptySpacesValidation = (value: string | undefined) => {
2
2
  if (value && !(value.trim().length > 0)) {
3
3
  return 'Empty space is not allowed';
4
4
  }
5
5
  };
6
6
 
7
+ export const hasProperties =
8
+ <T>(message: string, properties: (keyof T)[]) =>
9
+ (value: T) => {
10
+ if (!value || properties.filter((property) => value[property]).length !== properties.length) {
11
+ return message;
12
+ }
13
+ };
14
+
7
15
  export const regexDataURI =
8
16
  /^data:([a-z]+\/[a-z0-9-+.]+(;[a-z-]+=[a-z0-9-]+)?)?(;base64)?,([a-z0-9!$&',()*+;=\-._~:@/?%\s]*)$/i;
package/tests/index.ts CHANGED
@@ -1,2 +1,4 @@
1
1
  export * from './renderWithEditor';
2
+ export * from './renderWithContext';
3
+ export * from './mockResourceBrowserContext';
2
4
  export * from './select';
@@ -0,0 +1,63 @@
1
+ import React, { ReactNode } from 'react';
2
+ import { ResourceBrowserContext, Source, Resource, ResourceReference } from '@squiz/resource-browser';
3
+ import { fireEvent, screen } from '@testing-library/react';
4
+ import { DeepPartial } from '../src/types';
5
+
6
+ export type MockResourceBrowserContextOptions = DeepPartial<{
7
+ sources: Source[];
8
+ resources: Resource[];
9
+ }>;
10
+
11
+ const mockResource = (resource: DeepPartial<Resource> = {}): Resource =>
12
+ ({
13
+ id: 'default-resource',
14
+ name: 'Default resource',
15
+ url: 'https://default-resource/',
16
+ urls: [],
17
+ childCount: 0,
18
+ type: {
19
+ code: 'unspecified',
20
+ name: 'Unspecified',
21
+ },
22
+ status: {
23
+ code: 'live',
24
+ name: 'Live',
25
+ },
26
+ ...resource,
27
+ } as Resource);
28
+
29
+ const mockSource = (source: DeepPartial<Source>): Source => ({
30
+ id: 'default-source',
31
+ name: 'Default source',
32
+ ...source,
33
+ nodes: (source.nodes || [mockResource()]).map((resource) => mockResource(resource)),
34
+ });
35
+
36
+ export const mockResourceBrowserContext = ({ sources, resources }: MockResourceBrowserContextOptions) => {
37
+ sources = (sources || []).map((source) => mockSource(source));
38
+ resources = (resources || []).map((resource) => mockResource(resource));
39
+
40
+ const onRequestSources = jest.fn().mockResolvedValue(sources);
41
+ const onRequestChildren = jest.fn().mockResolvedValue(resources);
42
+ const onRequestResource = jest
43
+ .fn()
44
+ .mockImplementation(
45
+ (reference: ResourceReference) => resources?.find((resource) => resource.id === reference.resource) || null,
46
+ );
47
+
48
+ return {
49
+ MockResourceBrowserContext: ({ children }: { children: ReactNode }) => (
50
+ <ResourceBrowserContext.Provider value={{ onRequestSources, onRequestChildren, onRequestResource }}>
51
+ {children}
52
+ </ResourceBrowserContext.Provider>
53
+ ),
54
+ selectResource: async (opener: HTMLElement, resourceName: string) => {
55
+ const sourceLabel = `Drill down to ${sources?.[0]?.nodes?.[0]?.name} children`;
56
+
57
+ fireEvent.click(opener);
58
+ fireEvent.click(await screen.findByRole('button', { name: sourceLabel }));
59
+ fireEvent.click(await screen.findByTitle(resourceName));
60
+ fireEvent.click(await screen.findByRole('button', { name: 'Select' }));
61
+ },
62
+ };
63
+ };
@@ -0,0 +1,18 @@
1
+ import React, { ReactElement } from 'react';
2
+ import { render, RenderOptions, RenderResult } from '@testing-library/react';
3
+ import merge from 'deepmerge';
4
+ import { EditorContext } from '../src';
5
+ import { defaultEditorContext, EditorContextOptions } from '../src/Editor/EditorContext';
6
+ import { DeepPartial } from '../src/types';
7
+
8
+ export type ContextRenderOptions = RenderOptions & {
9
+ context?: DeepPartial<{
10
+ editor: EditorContextOptions;
11
+ }>;
12
+ };
13
+
14
+ export const renderWithContext = (ui: ReactElement | null, options?: ContextRenderOptions): RenderResult => {
15
+ const editorContext = merge(defaultEditorContext, options?.context?.editor || {}) as EditorContextOptions;
16
+
17
+ return render(<EditorContext.Provider value={editorContext}>{ui}</EditorContext.Provider>);
18
+ };
@@ -1,22 +1,18 @@
1
1
  import React, { ReactElement, useContext, useEffect } from 'react';
2
- import { render, RenderOptions } from '@testing-library/react';
3
2
  import { Extension, RemirrorContentType, RemirrorManager } from '@remirror/core';
4
3
  import { CorePreset } from '@remirror/preset-core';
5
4
  import { BuiltinPreset } from 'remirror';
6
5
  import { EditorComponent, Remirror, useRemirror } from '@remirror/react';
7
6
  import { RemirrorTestChain } from 'jest-remirror';
8
- import merge from 'deepmerge';
9
7
  import { createExtensions } from '../src/Extensions/Extensions';
10
- import { EditorContext } from '../src/Editor/EditorContext';
11
- import { FloatingToolbar } from '../src/EditorToolbar/FloatingToolbar';
12
- import { defaultEditorContext, EditorContextOptions } from '../src/Editor/EditorContext';
13
- import { DeepPartial } from '../src/types';
8
+ import { EditorContext } from '../src';
9
+ import { FloatingToolbar } from '../src/EditorToolbar';
10
+ import { renderWithContext, ContextRenderOptions } from './renderWithContext';
14
11
 
15
- export type EditorRenderOptions = RenderOptions & {
12
+ export type EditorRenderOptions = ContextRenderOptions & {
16
13
  content?: RemirrorContentType;
17
14
  editable?: boolean;
18
15
  extensions?: Extension[];
19
- context?: DeepPartial<EditorContextOptions>;
20
16
  };
21
17
 
22
18
  type TestEditorProps = EditorRenderOptions & {
@@ -41,6 +37,9 @@ const TestEditor = ({ children, extensions, content, onReady, editable }: TestEd
41
37
  content: content,
42
38
  selection: 'start',
43
39
  stringHandler: 'html',
40
+ onError: ({ error }) => {
41
+ throw error;
42
+ },
44
43
  });
45
44
 
46
45
  useEffect(() => {
@@ -89,7 +88,6 @@ export const renderWithEditor = async (
89
88
  ui: ReactElement | null,
90
89
  options?: EditorRenderOptions,
91
90
  ): Promise<EditorRenderResult> => {
92
- const context = merge(defaultEditorContext, options?.context || {}) as EditorContextOptions;
93
91
  const result: Partial<EditorRenderResult> = {
94
92
  getHtmlContent: () => document.querySelector('.remirror-editor')?.innerHTML,
95
93
  getJsonContent: () => result.editor?.state.doc.content.child(0).toJSON(),
@@ -97,18 +95,17 @@ export const renderWithEditor = async (
97
95
  };
98
96
  let isReady = false;
99
97
 
100
- const { container } = render(
101
- <EditorContext.Provider value={context}>
102
- <TestEditor
103
- onReady={(manager) => {
104
- result.editor = RemirrorTestChain.create(manager);
105
- isReady = true;
106
- }}
107
- {...options}
108
- >
109
- {ui}
110
- </TestEditor>
111
- </EditorContext.Provider>,
98
+ const { container } = renderWithContext(
99
+ <TestEditor
100
+ onReady={(manager) => {
101
+ result.editor = RemirrorTestChain.create(manager);
102
+ isReady = true;
103
+ }}
104
+ {...options}
105
+ >
106
+ {ui}
107
+ </TestEditor>,
108
+ options,
112
109
  );
113
110
 
114
111
  if (!isReady) {