Skip to content

Commit c4b359b

Browse files
committed
feat(react): use radix dialog on <ContextMenu> errors
1 parent d07d47c commit c4b359b

File tree

8 files changed

+195
-42
lines changed

8 files changed

+195
-42
lines changed

docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx

+35
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,41 @@ type I18nText = {
8484
*/
8585
filesTitleText?: string,
8686

87+
/**
88+
* Text shown on file tree's context menu's file creation button.
89+
*
90+
* @default 'Create file'
91+
*/
92+
fileTreeCreateFileText?: string,
93+
94+
/**
95+
* Text shown on file tree's context menu's folder creation button.
96+
*
97+
* @default 'Create folder'
98+
*/
99+
fileTreeCreateFolderText?: string,
100+
101+
/**
102+
* Text shown on dialog when file creation failed. Variables: ${filename}.
103+
*
104+
* @default 'Failed to create file "${filename}".'
105+
*/
106+
fileTreeFailedToCreateFileText?: string,
107+
108+
/**
109+
* Text shown on dialog when folder creation failed. Variables: ${filename}.
110+
*
111+
* @default 'Failed to create folder "${filename}".'
112+
*/
113+
fileTreeFailedToCreateFolderText?: string,
114+
115+
/**
116+
* Text shown on dialog describing allowed patterns when file or folder createion failed.
117+
*
118+
* @default 'Allowed patterns are:'
119+
*/
120+
fileTreeAllowedPatternsText?: string,
121+
87122
/**
88123
* Text shown on top of the steps section.
89124
*

e2e/test/file-tree.test.ts

+11-10
Original file line numberDiff line numberDiff line change
@@ -208,17 +208,18 @@ test('user cannot create files or folders in disallowed directories', async ({ p
208208
await page.getByRole('button', { name: 'first-level' }).click({ button: 'right' });
209209
await page.getByRole('menuitem', { name: `Create ${type}` }).click();
210210

211-
const message = new Promise<string>((resolve) =>
212-
page.once('dialog', (dialog) => {
213-
resolve(dialog.message());
214-
dialog.accept();
215-
}),
216-
);
217-
218211
await page.locator('*:focus').fill(name);
219212
await page.locator('*:focus').press('Enter');
220-
expect(await message).toBe(
221-
`File \"/first-level/${name}\" is not allowed. Allowed patterns: [/*, /first-level/allowed-filename-only.js, **/second-level/**].`,
222-
);
213+
214+
const dialog = page.getByRole('dialog', { name: 'Error' });
215+
await expect(dialog.getByText(`Failed to create ${type} "/first-level/${name}".`)).toBeVisible();
216+
217+
await expect(dialog.getByText('Allowed patterns are:')).toBeVisible();
218+
await expect(dialog.getByRole('listitem').nth(0)).toHaveText('/*');
219+
await expect(dialog.getByRole('listitem').nth(1)).toHaveText('/first-level/allowed-filename-only.js');
220+
await expect(dialog.getByRole('listitem').nth(2)).toHaveText('**/second-level/**');
221+
222+
await dialog.getByRole('button', { name: 'Close' }).click();
223+
await expect(dialog).not.toBeVisible();
223224
}
224225
});

e2e/uno.config.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { defineConfig } from '@tutorialkit/theme';
22

33
export default defineConfig({
4-
// add your UnoCSS config here: https://unocss.dev/guide/config-file
4+
// required for TutorialKit monorepo development mode
5+
content: {
6+
pipeline: {
7+
include: '**',
8+
},
9+
},
510
});

packages/astro/src/default/utils/content/default-localization.ts

+3
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ export const DEFAULT_LOCALIZATION = {
99
filesTitleText: 'Files',
1010
fileTreeCreateFileText: 'Create file',
1111
fileTreeCreateFolderText: 'Create folder',
12+
fileTreeFailedToCreateFileText: 'Failed to create file "${filename}".',
13+
fileTreeFailedToCreateFolderText: 'Failed to create folder "${filename}".',
14+
fileTreeAllowedPatternsText: 'Allowed patterns are:',
1215
prepareEnvironmentTitleText: 'Preparing Environment',
1316
defaultPreviewTitleText: 'Preview',
1417
reloadPreviewTitle: 'Reload Preview',

packages/react/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
"@nanostores/react": "0.7.2",
7676
"@radix-ui/react-accordion": "^1.2.0",
7777
"@radix-ui/react-context-menu": "^2.2.1",
78+
"@radix-ui/react-dialog": "^1.1.1",
7879
"@replit/codemirror-lang-svelte": "^6.0.0",
7980
"@tutorialkit/runtime": "workspace:*",
8081
"@tutorialkit/theme": "workspace:*",

packages/react/src/core/ContextMenu.tsx

+74-10
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { useRef, useState, type ComponentProps } from 'react';
1+
import { useRef, useState, type ComponentProps, type ReactNode } from 'react';
22
import { Root, Portal, Content, Item, Trigger } from '@radix-ui/react-context-menu';
3+
import * as RadixDialog from '@radix-ui/react-dialog';
34
import picomatch from 'picomatch/posix';
4-
import type { FileDescriptor, I18n } from '@tutorialkit/types';
5+
import { interpolateString, type FileDescriptor, type I18n } from '@tutorialkit/types';
56

67
interface FileChangeEvent {
78
type: FileDescriptor['type'];
@@ -28,45 +29,69 @@ interface Props extends ComponentProps<'div'> {
2829
position?: 'before' | 'after';
2930

3031
/** Localized texts for menu. */
31-
i18n?: Pick<I18n, 'fileTreeCreateFileText' | 'fileTreeCreateFolderText'>;
32+
i18n?: Pick<
33+
I18n,
34+
| 'fileTreeCreateFileText'
35+
| 'fileTreeCreateFolderText'
36+
| 'fileTreeFailedToCreateFileText'
37+
| 'fileTreeFailedToCreateFolderText'
38+
| 'fileTreeAllowedPatternsText'
39+
>;
3240

3341
/** Props for trigger wrapper. */
3442
triggerProps?: ComponentProps<'div'> & { 'data-testid'?: string };
3543
}
3644

45+
const i18nDefaults = {
46+
fileTreeFailedToCreateFileText: 'Failed to create file "${filename}".',
47+
fileTreeFailedToCreateFolderText: 'Failed to create folder "${filename}".',
48+
fileTreeCreateFileText: 'Create file',
49+
fileTreeCreateFolderText: 'Create folder',
50+
fileTreeAllowedPatternsText: 'Allowed patterns are:',
51+
} as const satisfies Props['i18n'];
52+
3753
export function ContextMenu({
3854
onFileChange,
3955
allowEditPatterns = ['**'],
4056
directory,
41-
i18n,
57+
i18n: i18nProps,
4258
position = 'before',
4359
children,
4460
triggerProps,
4561
...props
4662
}: Props) {
47-
const [state, setState] = useState<'idle' | 'add_file' | 'add_folder'>('idle');
63+
const [state, setState] = useState<'idle' | 'add_file' | 'add_folder' | { error: string }>('idle');
4864
const inputRef = useRef<HTMLInputElement>(null);
4965

66+
const error = typeof state === 'string' ? false : state.error;
67+
const i18n = { ...i18nProps, ...i18nDefaults };
68+
5069
if (!onFileChange) {
5170
return children;
5271
}
5372

5473
function onFileNameEnd(event: React.KeyboardEvent<HTMLInputElement> | React.FocusEvent<HTMLInputElement>) {
74+
if (state !== 'add_file' && state !== 'add_folder') {
75+
return;
76+
}
77+
5578
const name = event.currentTarget.value;
5679

5780
if (name) {
5881
const value = `${directory}/${name}`;
5982
const isAllowed = picomatch.isMatch(value, allowEditPatterns);
83+
const isFile = state === 'add_file';
6084

6185
if (isAllowed) {
6286
onFileChange?.({
6387
value,
64-
type: state === 'add_file' ? 'file' : 'folder',
88+
type: isFile ? 'file' : 'folder',
6589
method: 'add',
6690
});
6791
} else {
68-
// TODO: Use `@radix-ui/react-dialog` instead
69-
alert(`File "${value}" is not allowed. Allowed patterns: [${allowEditPatterns.join(', ')}].`);
92+
const text = isFile ? i18n.fileTreeFailedToCreateFileText : i18n.fileTreeFailedToCreateFolderText;
93+
94+
return setState({ error: interpolateString(text, { filename: value }) });
7095
}
7196
}
7297

@@ -118,14 +143,31 @@ export function ContextMenu({
118143
className="border border-tk-border-brighter b-rounded-md bg-tk-background-brighter py-2"
119144
>
120145
<MenuItem icon="i-ph-file-plus" onClick={() => setState('add_file')}>
121-
{i18n?.fileTreeCreateFileText || 'Create file'}
146+
{i18n.fileTreeCreateFileText}
122147
</MenuItem>
123148

124149
<MenuItem icon="i-ph-folder-plus" onClick={() => setState('add_folder')}>
125-
{i18n?.fileTreeCreateFolderText || 'Create folder'}
150+
{i18n.fileTreeCreateFolderText}
126151
</MenuItem>
127152
</Content>
128153
</Portal>
154+
155+
{error && (
156+
<Dialog onClose={() => setState('idle')}>
157+
<p className="mb-2">{error}</p>
158+
159+
<div>
160+
{i18n.fileTreeAllowedPatternsText}
161+
<ul className="list-disc ml-4 mt-2">
162+
{allowEditPatterns.map((pattern) => (
163+
<li key={pattern}>
164+
<code>{pattern}</code>
165+
</li>
166+
))}
167+
</ul>
168+
</div>
169+
</Dialog>
170+
)}
129171
</Root>
130172
);
131173
}
@@ -141,3 +183,25 @@ function MenuItem({ icon, children, ...props }: { icon: string } & ComponentProp
141183
</Item>
142184
);
143185
}
186+
187+
function Dialog({ onClose, children }: { onClose: () => void; children: ReactNode }) {
188+
return (
189+
<RadixDialog.Root open={true} onOpenChange={(open) => !open && onClose()}>
190+
<RadixDialog.Portal>
191+
<RadixDialog.Overlay className="fixed inset-0 opacity-50 bg-black" />
192+
193+
<RadixDialog.Content className="fixed top-50% left-50% transform-translate--50% w-90vw max-w-450px max-h-85vh rounded-xl text-tk-text-primary bg-tk-background-negative">
194+
<div className="relative py-4 px-10">
195+
<RadixDialog.Title className="text-6 mb-2">Error</RadixDialog.Title>
196+
197+
{children}
198+
199+
<RadixDialog.Close title="Close" className="absolute top-4 right-4 w-6 h-6">
200+
<span aria-hidden className="i-ph-x block w-full h-full"></span>
201+
</RadixDialog.Close>
202+
</div>
203+
</RadixDialog.Content>
204+
</RadixDialog.Portal>
205+
</RadixDialog.Root>
206+
);
207+
}

packages/types/src/schemas/i18n.ts

+30
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,36 @@ export const i18nSchema = z.object({
7272
.optional()
7373
.describe("Text shown on file tree's context menu's folder creation button."),
7474

75+
/**
76+
* Text shown on dialog when file creation failed. Variables: ${filename}.
77+
*
78+
* @default 'Failed to create file "${filename}".'
79+
*/
80+
fileTreeFailedToCreateFileText: z
81+
.string()
82+
.optional()
83+
.describe('Text shown on dialog when file creation failed. Variables: ${filename}.'),
84+
85+
/**
86+
* Text shown on dialog when folder creation failed. Variables: ${filename}.
87+
*
88+
* @default 'Failed to create folder "${filename}".'
89+
*/
90+
fileTreeFailedToCreateFolderText: z
91+
.string()
92+
.optional()
93+
.describe('Text shown on dialog when folder creation failed. Variables: ${filename}.'),
94+
95+
/**
96+
* Text shown on dialog describing allowed patterns when file or folder createion failed.
97+
*
98+
* @default 'Allowed patterns are:'
99+
*/
100+
fileTreeAllowedPatternsText: z
101+
.string()
102+
.optional()
103+
.describe('Text shown on dialog describing allowed patterns when file or folder createion failed.'),
104+
75105
/**
76106
* Text shown on top of the steps section.
77107
*

pnpm-lock.yaml

+35-21
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)