@tinacms/app 2.5.9 → 2.5.11

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tinacms/app",
3
- "version": "2.5.9",
3
+ "version": "2.5.11",
4
4
  "main": "src/main.tsx",
5
5
  "files": [
6
6
  "src",
@@ -11,9 +11,11 @@
11
11
  ],
12
12
  "license": "Apache-2.0",
13
13
  "devDependencies": {
14
+ "@testing-library/react": "^16.2.0",
14
15
  "@types/react": "^18.3.18",
15
16
  "@types/react-dom": "^18.3.5",
16
17
  "happy-dom": "15.10.2",
18
+ "monaco-editor": "0.31.0",
17
19
  "typescript": "^5.7.3",
18
20
  "vite": "^5.4.14",
19
21
  "vitest": "^2.1.9"
@@ -24,20 +26,18 @@
24
26
  },
25
27
  "dependencies": {
26
28
  "@graphiql/toolkit": "0.8.4",
27
- "@headlessui/react": "2.1.8",
28
- "@heroicons/react": "^1.0.6",
29
- "@monaco-editor/react": "4.7.0-rc.0",
29
+ "@monaco-editor/loader": "^1.7.0",
30
+ "@radix-ui/react-popover": "^1.1.18",
30
31
  "final-form": "4.20.10",
31
32
  "graphiql": "3.0.0-alpha.1",
32
33
  "graphql": "15.8.0",
33
- "monaco-editor": "0.31.0",
34
+ "lucide-react": "^0.424.0",
34
35
  "react": "^18.3.1",
35
36
  "react-dom": "^18.3.1",
36
37
  "react-router-dom": "^6.30.3",
37
- "typescript": "^5.7.3",
38
38
  "zod": "^3.24.2",
39
- "@tinacms/mdx": "2.1.10",
40
- "tinacms": "3.10.1"
39
+ "tinacms": "^3.12.0",
40
+ "@tinacms/mdx": "^2.2.0"
41
41
  },
42
42
  "repository": {
43
43
  "url": "https://github.com/tinacms/tinacms.git",
package/src/App.tsx CHANGED
@@ -2,6 +2,7 @@ import React, { Suspense } from 'react';
2
2
  import TinaCMS, { TinaAdmin, useCMS, MdxFieldPluginExtendible } from 'tinacms';
3
3
  import { Preview } from './preview';
4
4
  import Playground from './Playground';
5
+ import { RawEditorErrorBoundary } from './fields/rich-text/error-boundary';
5
6
 
6
7
  // TODO: Resolve this to local file in tsconfig.json
7
8
  // @ts-expect-error
@@ -21,9 +22,11 @@ const Editor = (props) => {
21
22
  setRawMode={setRawMode}
22
23
  {...props}
23
24
  rawEditor={
24
- <Suspense fallback={<div>Loading raw editor...</div>}>
25
- <RawEditor {...props} setRawMode={setRawMode} rawMode={rawMode} />
26
- </Suspense>
25
+ <RawEditorErrorBoundary onDismiss={() => setRawMode(false)}>
26
+ <Suspense fallback={<div>Loading raw editor...</div>}>
27
+ <RawEditor {...props} setRawMode={setRawMode} rawMode={rawMode} />
28
+ </Suspense>
29
+ </RawEditorErrorBoundary>
27
30
  }
28
31
  />
29
32
  );
@@ -1,8 +1,8 @@
1
1
  import { createGraphiQLFetcher } from '@graphiql/toolkit';
2
- import { FolderIcon } from '@heroicons/react/outline';
3
2
  import { queries } from 'CLIENT_IMPORT';
4
3
  import { GraphiQL } from 'graphiql';
5
4
  import { parse, print } from 'graphql';
5
+ import { Folder } from 'lucide-react';
6
6
  import React from 'react';
7
7
  import { useCMS } from 'tinacms';
8
8
 
@@ -56,8 +56,9 @@ const Playground = () => {
56
56
 
57
57
  const ref = React.useRef();
58
58
 
59
- const getToken = () => {
60
- return JSON.parse(localStorage.getItem('tinacms-auth') || '{}')?.id_token;
59
+ const getToken = async () => {
60
+ const token = await cms.api.tina.authProvider.getToken();
61
+ return token?.id_token ?? token?.access_token;
61
62
  };
62
63
 
63
64
  if (!autoQueries) {
@@ -133,7 +134,7 @@ const Playground = () => {
133
134
  fetcher={async (params, options) => {
134
135
  const fetcher = createGraphiQLFetcher({
135
136
  url: cms.api.tina.contentApiUrl || __API_URL__,
136
- headers: { Authorization: `Bearer ${getToken()}` },
137
+ headers: { Authorization: `Bearer ${await getToken()}` },
137
138
  });
138
139
  return fetcher(params, options);
139
140
  }}
@@ -144,7 +145,7 @@ const Playground = () => {
144
145
  plugins={[
145
146
  {
146
147
  title: 'Queries',
147
- icon: () => <FolderIcon />,
148
+ icon: () => <Folder />,
148
149
  content: (values) => <Plugin />,
149
150
  },
150
151
  ]}
@@ -0,0 +1,58 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import React from 'react';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { RawEditorErrorBoundary } from './error-boundary';
5
+
6
+ const Throws = ({ message }: { message: string }) => {
7
+ throw new Error(message);
8
+ };
9
+
10
+ const FAILED_IMPORT = 'Failed to fetch dynamically imported module';
11
+ const FAILED_SERIALIZE = 'Marks inside inline code are not supported';
12
+
13
+ const renderWithThrow = (message: string, onDismiss = () => {}) => {
14
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
15
+ render(
16
+ <RawEditorErrorBoundary onDismiss={onDismiss}>
17
+ <Throws message={message} />
18
+ </RawEditorErrorBoundary>
19
+ );
20
+ consoleError.mockRestore();
21
+ };
22
+
23
+ describe('RawEditorErrorBoundary', () => {
24
+ it('renders its children when nothing throws', () => {
25
+ render(
26
+ <RawEditorErrorBoundary onDismiss={() => {}}>
27
+ <div>raw editor</div>
28
+ </RawEditorErrorBoundary>
29
+ );
30
+ expect(screen.getByText('raw editor')).toBeTruthy();
31
+ });
32
+
33
+ it('keeps a failed lazy import inside the field', () => {
34
+ renderWithThrow(FAILED_IMPORT);
35
+ expect(
36
+ screen.getByText(/Couldn't open this field in raw markdown/)
37
+ ).toBeTruthy();
38
+ });
39
+
40
+ it('keeps a failure to serialize the field inside the field', () => {
41
+ renderWithThrow(FAILED_SERIALIZE);
42
+ expect(
43
+ screen.getByText(/Couldn't open this field in raw markdown/)
44
+ ).toBeTruthy();
45
+ });
46
+
47
+ it('shows the underlying message so the editor knows what to undo', () => {
48
+ renderWithThrow(FAILED_SERIALIZE);
49
+ expect(screen.getByText(FAILED_SERIALIZE)).toBeTruthy();
50
+ });
51
+
52
+ it('offers a way back to the rich-text editor', () => {
53
+ const onDismiss = vi.fn();
54
+ renderWithThrow(FAILED_IMPORT, onDismiss);
55
+ fireEvent.click(screen.getByRole('button'));
56
+ expect(onDismiss).toHaveBeenCalledOnce();
57
+ });
58
+ });
@@ -0,0 +1,62 @@
1
+ import React from 'react';
2
+
3
+ interface Props {
4
+ children: React.ReactNode;
5
+ onDismiss: () => void;
6
+ }
7
+
8
+ /**
9
+ * The raw editor fails in more than one way: its chunk is loaded lazily, so the
10
+ * import can reject, and it serializes the field on render, so content it can't
11
+ * represent throws. Either one unmounts the whole admin without a boundary,
12
+ * leaving a blank page.
13
+ *
14
+ * The heading names the outcome rather than a cause, since the boundary cannot
15
+ * tell them apart. The message underneath is the one thing that lets an editor
16
+ * act — "Marks inside inline code are not supported" says which formatting to
17
+ * undo — and it is what a bug report needs verbatim.
18
+ */
19
+ export class RawEditorErrorBoundary extends React.Component<
20
+ Props,
21
+ { message: string | null }
22
+ > {
23
+ state: { message: string | null } = { message: null };
24
+
25
+ static getDerivedStateFromError(error: Error) {
26
+ return { message: error?.message ?? '' };
27
+ }
28
+
29
+ componentDidCatch(error: Error) {
30
+ console.error('Raw markdown editor failed to open:', error);
31
+ }
32
+
33
+ render() {
34
+ if (this.state.message === null) {
35
+ return this.props.children;
36
+ }
37
+ // `whitespace-normal` because the field wrapper sets `nowrap`, which this
38
+ // would otherwise inherit and clip the message.
39
+ return (
40
+ <div className='p-4 text-sm whitespace-normal text-gray-700'>
41
+ <p className='mb-2'>
42
+ Couldn't open this field in raw markdown. Your content hasn't changed.
43
+ </p>
44
+ {this.state.message ? (
45
+ <p className='mb-3 font-mono text-xs break-words text-gray-500'>
46
+ {this.state.message}
47
+ </p>
48
+ ) : null}
49
+ <button
50
+ type='button'
51
+ className='rounded border border-gray-200 bg-white px-2 py-1 font-medium shadow hover:bg-blue-500 hover:text-white'
52
+ onClick={() => {
53
+ this.setState({ message: null });
54
+ this.props.onDismiss();
55
+ }}
56
+ >
57
+ Back to rich-text editor
58
+ </button>
59
+ </div>
60
+ );
61
+ }
62
+ }
@@ -1,17 +1,11 @@
1
- import {
2
- Popover,
3
- PopoverButton,
4
- PopoverPanel,
5
- Transition,
6
- } from '@headlessui/react';
7
- import { XCircleIcon } from '@heroicons/react/solid';
1
+ import { CircleX } from 'lucide-react';
2
+ import * as PopoverPrimitive from '@radix-ui/react-popover';
8
3
  /**
9
4
 
10
5
 
11
6
 
12
7
  */
13
8
  import React from 'react';
14
- import { Fragment } from 'react';
15
9
  // import { InvalidMarkdownElement } from '@tinacms/mdx/src/parse/plate'
16
10
  export type EmptyTextElement = { type: 'text'; text: '' };
17
11
  export type PositionItem = {
@@ -72,47 +66,38 @@ export function ErrorMessage({ error }: { error: InvalidMarkdownElement }) {
72
66
  const message = buildErrorMessage(error);
73
67
 
74
68
  return (
75
- <Popover className='relative'>
76
- {() => (
77
- <>
78
- <PopoverButton
79
- className={`p-2 shaodw-lg border ${
80
- error ? '' : ' opacity-0 hidden '
81
- }`}
82
- >
83
- <span className='sr-only'>Errors</span>
84
- <XCircleIcon className='h-5 w-5 text-red-400' aria-hidden='true' />
85
- </PopoverButton>
86
- <Transition
87
- enter='transition ease-out duration-200'
88
- enterFrom='opacity-0 translate-y-1'
89
- enterTo='opacity-100 translate-y-0'
90
- leave='transition ease-in duration-150'
91
- leaveFrom='opacity-100 translate-y-0'
92
- leaveTo='opacity-0 translate-y-1'
93
- >
94
- <PopoverPanel className='absolute top-8 w-[300px] -right-3 z-10 mt-3 px-4 sm:px-0'>
95
- <div className='overflow-hidden rounded-lg shadow-lg ring-1 ring-black ring-opacity-5'>
96
- <div className='rounded bg-red-50 p-4 overflow-scroll'>
97
- <div className='flex'>
98
- <div className='flex-shrink-0'>
99
- <XCircleIcon
100
- className='h-5 w-5 text-red-400'
101
- aria-hidden='true'
102
- />
103
- </div>
104
- <div className='ml-3'>
105
- <h3 className='text-sm font-medium text-red-800 whitespace-pre-wrap'>
106
- {message}
107
- </h3>
108
- </div>
109
- </div>
69
+ <PopoverPrimitive.Root>
70
+ <PopoverPrimitive.Trigger
71
+ className={`p-2 shaodw-lg border ${error ? '' : ' opacity-0 hidden '}`}
72
+ >
73
+ <span className='sr-only'>Errors</span>
74
+ <CircleX className='h-5 w-5 text-red-400' aria-hidden='true' />
75
+ </PopoverPrimitive.Trigger>
76
+ <PopoverPrimitive.Portal>
77
+ <PopoverPrimitive.Content
78
+ align='end'
79
+ sideOffset={12}
80
+ className='z-50 w-[300px] outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0'
81
+ >
82
+ <div className='overflow-hidden rounded-lg shadow-lg ring-1 ring-black ring-opacity-5'>
83
+ <div className='rounded bg-red-50 p-4 overflow-scroll'>
84
+ <div className='flex'>
85
+ <div className='flex-shrink-0'>
86
+ <CircleX
87
+ className='h-5 w-5 text-red-400'
88
+ aria-hidden='true'
89
+ />
90
+ </div>
91
+ <div className='ml-3'>
92
+ <h3 className='text-sm font-medium text-red-800 whitespace-pre-wrap'>
93
+ {message}
94
+ </h3>
110
95
  </div>
111
96
  </div>
112
- </PopoverPanel>
113
- </Transition>
114
- </>
115
- )}
116
- </Popover>
97
+ </div>
98
+ </div>
99
+ </PopoverPrimitive.Content>
100
+ </PopoverPrimitive.Portal>
101
+ </PopoverPrimitive.Root>
117
102
  );
118
103
  }
@@ -1,5 +1,4 @@
1
- import React from 'react';
2
- import MonacoEditor, { useMonaco, loader } from '@monaco-editor/react';
1
+ import loader from '@monaco-editor/loader';
3
2
  /**
4
3
  * MDX is built directly to the app because of how we load dependencies.
5
4
  * Since we drop the package.json in to the end users folder, we can't
@@ -8,14 +7,15 @@ import MonacoEditor, { useMonaco, loader } from '@monaco-editor/react';
8
7
  */
9
8
  import { parseMDX, serializeMDX } from '@tinacms/mdx';
10
9
  import type * as monaco from 'monaco-editor';
10
+ import React from 'react';
11
11
  import { RichTextType } from 'tinacms';
12
+ import { RichTextEditorSwitchedEvent, captureEvent } from 'tinacms';
12
13
  import {
13
14
  ErrorMessage,
14
15
  InvalidMarkdownElement,
15
16
  buildError,
16
17
  } from './error-message';
17
18
  import { useDebounce } from './use-debounce';
18
- import { RichTextEditorSwitchedEvent, captureEvent } from 'tinacms';
19
19
 
20
20
  export const uuid = () => {
21
21
  // @ts-ignore
@@ -29,6 +29,76 @@ export const uuid = () => {
29
29
 
30
30
  type Monaco = typeof monaco;
31
31
 
32
+ /**
33
+ * The editor itself is fetched from a CDN at runtime by @monaco-editor/loader;
34
+ * the `monaco-editor` package is a devDependency used only for its types.
35
+ */
36
+ const useMonaco = (): Monaco | null => {
37
+ const [instance, setInstance] = React.useState<Monaco | null>(null);
38
+ React.useEffect(() => {
39
+ let cancelled = false;
40
+ loader
41
+ .init()
42
+ .then((m) => {
43
+ if (!cancelled) {
44
+ setInstance(m as Monaco);
45
+ }
46
+ })
47
+ .catch((error) => {
48
+ console.error('Monaco initialization: error:', error);
49
+ });
50
+ return () => {
51
+ cancelled = true;
52
+ };
53
+ }, []);
54
+ return instance;
55
+ };
56
+
57
+ const MonacoEditor = (props: {
58
+ monaco: Monaco;
59
+ path: string;
60
+ value: string;
61
+ language: string;
62
+ options: monaco.editor.IStandaloneEditorConstructionOptions;
63
+ onMount: (
64
+ editor: monaco.editor.IStandaloneCodeEditor,
65
+ monaco: Monaco
66
+ ) => void;
67
+ onChange: (value: string) => void;
68
+ }) => {
69
+ const containerRef = React.useRef<HTMLDivElement>(null);
70
+ // Read through a ref so re-creating the editor isn't tied to callback identity.
71
+ const onChangeRef = React.useRef(props.onChange);
72
+ onChangeRef.current = props.onChange;
73
+
74
+ React.useEffect(() => {
75
+ const { monaco, path, value, language, options, onMount } = props;
76
+ if (!containerRef.current) {
77
+ return;
78
+ }
79
+ const uri = monaco.Uri.parse(`inmemory://tina/${path}`);
80
+ const model =
81
+ monaco.editor.getModel(uri) ||
82
+ monaco.editor.createModel(value, language, uri);
83
+ const editor = monaco.editor.create(containerRef.current, {
84
+ model,
85
+ automaticLayout: true,
86
+ ...options,
87
+ });
88
+ const subscription = editor.onDidChangeModelContent(() => {
89
+ onChangeRef.current(editor.getValue());
90
+ });
91
+ onMount(editor, monaco);
92
+ return () => {
93
+ subscription.dispose();
94
+ model.dispose();
95
+ editor.dispose();
96
+ };
97
+ }, [props.monaco]);
98
+
99
+ return <div ref={containerRef} style={{ height: '100%', width: '100%' }} />;
100
+ };
101
+
32
102
  /**
33
103
  * Since monaco lazy-loads we may have a delay from when the block is inserted
34
104
  * to when monaco has intantiated, keep trying to focus on it.
@@ -166,52 +236,52 @@ export const RawEditor = (props: RichTextType) => {
166
236
  <ErrorMessage error={error} />
167
237
  </div>
168
238
  <div style={{ height: `${height}px` }}>
169
- <MonacoEditor
170
- beforeMount={() => {}}
171
- height='100%'
172
- width='100%'
173
- path={id}
174
- onMount={handleEditorDidMount}
175
- // Setting a custom theme is kind of buggy because it doesn't get defined until monaco has mounted.
176
- // So we end up with the default (light) theme in some scenarios. Seems like a race condition.
177
- // theme="vs-dark"
178
- options={{
179
- scrollBeyondLastLine: false,
180
- tabSize: 2,
181
- disableLayerHinting: true,
182
- accessibilitySupport: 'off',
183
- codeLens: false,
184
- wordWrap: 'on',
185
- minimap: {
186
- enabled: false,
187
- },
188
- fontSize: 14,
189
- lineHeight: 2,
190
- formatOnPaste: true,
191
- lineNumbers: 'on',
192
- lineNumbersMinChars: 2,
193
- formatOnType: true,
194
- fixedOverflowWidgets: true,
195
- // Takes too much horizontal space for iframe
196
- folding: false,
197
- renderLineHighlight: 'none',
198
- scrollbar: {
199
- verticalScrollbarSize: 4,
200
- horizontalScrollbarSize: 4,
201
- // https://github.com/microsoft/monaco-editor/issues/2007#issuecomment-644425664
202
- alwaysConsumeMouseWheel: false,
203
- },
204
- }}
205
- language={'markdown'}
206
- value={value}
207
- onChange={(value) => {
208
- try {
209
- setValue(value);
210
- } catch (e) {
211
- console.log('error', e);
212
- }
213
- }}
214
- />
239
+ {monaco ? (
240
+ <MonacoEditor
241
+ monaco={monaco}
242
+ path={id}
243
+ onMount={handleEditorDidMount}
244
+ // Setting a custom theme is kind of buggy because it doesn't get defined until monaco has mounted.
245
+ // So we end up with the default (light) theme in some scenarios. Seems like a race condition.
246
+ // theme="vs-dark"
247
+ options={{
248
+ scrollBeyondLastLine: false,
249
+ tabSize: 2,
250
+ disableLayerHinting: true,
251
+ accessibilitySupport: 'off',
252
+ codeLens: false,
253
+ wordWrap: 'on',
254
+ minimap: {
255
+ enabled: false,
256
+ },
257
+ fontSize: 14,
258
+ lineHeight: 2,
259
+ formatOnPaste: true,
260
+ lineNumbers: 'on',
261
+ lineNumbersMinChars: 2,
262
+ formatOnType: true,
263
+ fixedOverflowWidgets: true,
264
+ // Takes too much horizontal space for iframe
265
+ folding: false,
266
+ renderLineHighlight: 'none',
267
+ scrollbar: {
268
+ verticalScrollbarSize: 4,
269
+ horizontalScrollbarSize: 4,
270
+ // https://github.com/microsoft/monaco-editor/issues/2007#issuecomment-644425664
271
+ alwaysConsumeMouseWheel: false,
272
+ },
273
+ }}
274
+ language={'markdown'}
275
+ value={value}
276
+ onChange={(value) => {
277
+ try {
278
+ setValue(value);
279
+ } catch (e) {
280
+ console.log('error', e);
281
+ }
282
+ }}
283
+ />
284
+ ) : null}
215
285
  </div>
216
286
  </div>
217
287
  );
package/src/index.css CHANGED
@@ -9,7 +9,15 @@
9
9
  @apply bg-blue-600 !important;
10
10
  }
11
11
 
12
- .icon-parent svg {
12
+ /*
13
+ * Exists for the hand-written Tina SVGs (AddIcon, CloseIcon, TinaIcon, …), which
14
+ * are solid shapes and expect to be coloured via `fill`.
15
+ *
16
+ * lucide icons are stroke-based and ship `fill="none"`. CSS beats a presentation
17
+ * attribute, so without the :not() they get flooded with currentColor and their
18
+ * internal detail disappears — a filled disc instead of a refresh arrow.
19
+ */
20
+ .icon-parent svg:not(.lucide) {
13
21
  fill: currentColor;
14
22
  }
15
23
 
package/src/preview.tsx CHANGED
@@ -20,7 +20,7 @@ export const Preview = (
20
20
  data-test='tina-iframe'
21
21
  id='tina-iframe'
22
22
  ref={props.iframeRef}
23
- className='h-screen w-full bg-white'
23
+ className='h-full w-full bg-white'
24
24
  src={props.url}
25
25
  />
26
26
  );