From eef08aabaa5d64a4e2887c67c4fe72121eab5a7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ari=20Perkki=C3=B6?= Date: Mon, 16 Sep 2024 11:04:05 +0300 Subject: [PATCH 1/8] feat(runtime): support glob patterns in `editor.fileTree.allowEdits` --- .../content/docs/reference/configuration.mdx | 16 ++- .../src/webcontainer/editor-config.spec.ts | 125 ++++++++++++++++++ .../runtime/src/webcontainer/editor-config.ts | 18 ++- packages/types/src/schemas/common.ts | 11 +- 4 files changed, 166 insertions(+), 4 deletions(-) create mode 100644 packages/runtime/src/webcontainer/editor-config.spec.ts diff --git a/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx b/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx index 4884dce8..6236a2c0 100644 --- a/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx +++ b/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx @@ -144,9 +144,11 @@ File tree can be set to allow file editing from right clicks by setting `fileTre The `Editor` type has the following shape: ```ts +type GlobPattern = string + type Editor = | false - | { editor: { allowEdits: boolean } } + | { editor: { allowEdits: boolean | GlobPattern | GlobPattern[] } } ``` @@ -161,6 +163,18 @@ editor: # Editor is visible editor: # Editor is visible fileTree: # File tree is visible allowEdits: true # User can add new files and folders from the file tree + + +editor: # Editor is visible + fileTree: # File tree is visible + allowEdits: "/src/**" # User can add files and folders anywhere inside "/src/" + +editor: # Editor is visible + fileTree: # File tree is visible + allowEdits: + - "/*" # User can add files and folders directly in the root + - "/first-level/allowed-filename-only.js" # Only "allowed-filename-only.js" inside "/first-level" folder + - "**/second-level/**" # Anything inside "second-level" folders anywhere ``` ##### `previews` diff --git a/packages/runtime/src/webcontainer/editor-config.spec.ts b/packages/runtime/src/webcontainer/editor-config.spec.ts new file mode 100644 index 00000000..34cdd2a1 --- /dev/null +++ b/packages/runtime/src/webcontainer/editor-config.spec.ts @@ -0,0 +1,125 @@ +import { expect, test } from 'vitest'; +import { EditorConfig } from './editor-config.js'; + +expect.addSnapshotSerializer({ + serialize: (val: EditorConfig) => JSON.stringify({ visible: val.visible, fileTree: val.fileTree }, null, 2), + test: (val) => val instanceof EditorConfig, +}); + +test('sets default values', () => { + const config = new EditorConfig(); + + expect(config).toMatchInlineSnapshot(` + { + "visible": true, + "fileTree": { + "visible": true, + "allowEdits": false + } + } + `); +}); + +test('false hides editor', () => { + const config = new EditorConfig(false); + + expect(config).toMatchInlineSnapshot(` + { + "visible": false, + "fileTree": { + "visible": false, + "allowEdits": false + } + } + `); +}); + +test('true enables editor, doesnt allow editing', () => { + const config = new EditorConfig(true); + + expect(config).toMatchInlineSnapshot(` + { + "visible": true, + "fileTree": { + "visible": true, + "allowEdits": false + } + } + `); +}); + +test('"fileTree: false" hides fileTree', () => { + const config = new EditorConfig({ fileTree: false }); + + expect(config).toMatchInlineSnapshot(` + { + "visible": true, + "fileTree": { + "visible": false, + "allowEdits": false + } + } + `); +}); + +test('"fileTree: true" enables fileTree, disables editing', () => { + const config = new EditorConfig({ fileTree: true }); + + expect(config.fileTree).toMatchInlineSnapshot(` + { + "allowEdits": false, + "visible": true, + } + `); +}); + +test('"fileTree.allowEdits: true" allows editing all entries', () => { + const config = new EditorConfig({ fileTree: { allowEdits: true } }); + + expect(config.fileTree).toMatchInlineSnapshot(` + { + "allowEdits": [ + "**", + ], + "visible": true, + } + `); +}); + +test('"fileTree.allowEdits: false" disables all editing', () => { + const config = new EditorConfig({ fileTree: { allowEdits: false } }); + + expect(config.fileTree).toMatchInlineSnapshot(` + { + "allowEdits": false, + "visible": true, + } + `); +}); + +test('"fileTree.allowEdits" is converted to array', () => { + const config = new EditorConfig({ fileTree: { allowEdits: '/src/**' } }); + + expect(config.fileTree).toMatchInlineSnapshot(` + { + "allowEdits": [ + "/src/**", + ], + "visible": true, + } + `); +}); + +test('"fileTree.allowEdits: [...]" works', () => { + const config = new EditorConfig({ fileTree: { allowEdits: ['/src/**', '**/*.test.ts'] } }); + + expect(config.fileTree).toMatchInlineSnapshot(` + { + "allowEdits": [ + "/src/**", + "**/*.test.ts", + ], + "visible": true, + } + `); +}); diff --git a/packages/runtime/src/webcontainer/editor-config.ts b/packages/runtime/src/webcontainer/editor-config.ts index 976a50c5..b1e07dfd 100644 --- a/packages/runtime/src/webcontainer/editor-config.ts +++ b/packages/runtime/src/webcontainer/editor-config.ts @@ -9,7 +9,7 @@ interface NormalizedEditorConfig { visible: boolean; /** Whether to allow file and folder editing in file tree */ - allowEdits: boolean; + allowEdits: false | string[]; }; } @@ -60,11 +60,25 @@ function normalizeEditorConfig(config?: EditorSchema): NormalizedEditorConfig { }; } + if (typeof config.fileTree?.allowEdits === 'boolean' || !config.fileTree?.allowEdits) { + return { + visible: true, + fileTree: { + visible: true, + allowEdits: config.fileTree?.allowEdits ? ['**'] : false, + }, + }; + } + return { visible: true, fileTree: { visible: true, - allowEdits: config.fileTree?.allowEdits || false, + allowEdits: toArray(config.fileTree.allowEdits), }, }; } + +function toArray(items: T | T[]): T[] { + return Array.isArray(items) ? items : [items]; +} diff --git a/packages/types/src/schemas/common.ts b/packages/types/src/schemas/common.ts index b1473cb8..585f1ef8 100644 --- a/packages/types/src/schemas/common.ts +++ b/packages/types/src/schemas/common.ts @@ -174,7 +174,16 @@ export const editorSchema = z.union([ // or configure file tree with options z.strictObject({ allowEdits: z - .boolean() + .union([ + // allow editing all files or disable completely + z.boolean(), + + // limit file editing to files and folders that match a single glob pattern + z.string(), + + // limit file editing to files and folders that match one of multiple glob patterns + z.array(z.string()), + ]) .describe( 'Allow file tree’s items to be edited by right clicking them. Supports file and folder creation.', ), From 596235400c5a3ddae731269115fb6a2d8ddffd80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ari=20Perkki=C3=B6?= Date: Mon, 16 Sep 2024 11:10:45 +0300 Subject: [PATCH 2/8] feat(react): add `allowEditPatterns` to `FileTree` --- .../docs/reference/react-components.mdx | 2 ++ packages/react/package.json | 2 ++ packages/react/src/Panels/EditorPanel.tsx | 3 +++ packages/react/src/Panels/WorkspacePanel.tsx | 1 + packages/react/src/core/ContextMenu.tsx | 23 +++++++++++++++---- packages/react/src/core/FileTree.tsx | 22 ++++++++++++++++-- packages/react/src/types.d.ts | 4 ++++ pnpm-lock.yaml | 22 ++++++++++-------- 8 files changed, 62 insertions(+), 17 deletions(-) create mode 100644 packages/react/src/types.d.ts diff --git a/docs/tutorialkit.dev/src/content/docs/reference/react-components.mdx b/docs/tutorialkit.dev/src/content/docs/reference/react-components.mdx index e7042d82..957e1af7 100644 --- a/docs/tutorialkit.dev/src/content/docs/reference/react-components.mdx +++ b/docs/tutorialkit.dev/src/content/docs/reference/react-components.mdx @@ -116,6 +116,8 @@ A component to list files in a tree view. } ``` +* `allowEditPatterns?: string[]` - Glob patterns for paths that allow editing files and folders. Defaults to `['**']`. + * `hideRoot: boolean` - Whether or not to hide the root directory in the tree. Defaults to `false`. * `hiddenFiles: (string | RegExp)[]` - A list of file paths that should be hidden from the tree. diff --git a/packages/react/package.json b/packages/react/package.json index 03d74458..eb5e3d6f 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -85,12 +85,14 @@ "codemirror": "^6.0.1", "framer-motion": "^11.2.11", "nanostores": "^0.10.3", + "picomatch": "^4.0.2", "react": "^18.3.1", "react-resizable-panels": "^2.0.19" }, "devDependencies": { "@codemirror/search": "^6.5.6", "@tutorialkit/types": "workspace:*", + "@types/picomatch": "^3.0.1", "@types/react": "^18.3.3", "chokidar": "3.6.0", "execa": "^9.2.0", diff --git a/packages/react/src/Panels/EditorPanel.tsx b/packages/react/src/Panels/EditorPanel.tsx index 4447182e..789349b3 100644 --- a/packages/react/src/Panels/EditorPanel.tsx +++ b/packages/react/src/Panels/EditorPanel.tsx @@ -25,6 +25,7 @@ interface Props { helpAction?: 'solve' | 'reset'; editorDocument?: EditorDocument; selectedFile?: string | undefined; + allowEditPatterns?: ComponentProps['allowEditPatterns']; onEditorChange?: OnEditorChange; onEditorScroll?: OnEditorScroll; onHelpClick?: () => void; @@ -43,6 +44,7 @@ export function EditorPanel({ helpAction, editorDocument, selectedFile, + allowEditPatterns, onEditorChange, onEditorScroll, onHelpClick, @@ -83,6 +85,7 @@ export function EditorPanel({ hideRoot={hideRoot ?? true} files={files} scope={fileTreeScope} + allowEditPatterns={allowEditPatterns} onFileSelect={onFileSelect} onFileChange={onFileTreeChange} /> diff --git a/packages/react/src/Panels/WorkspacePanel.tsx b/packages/react/src/Panels/WorkspacePanel.tsx index fbe27323..ae68ad6b 100644 --- a/packages/react/src/Panels/WorkspacePanel.tsx +++ b/packages/react/src/Panels/WorkspacePanel.tsx @@ -161,6 +161,7 @@ function EditorSection({ theme, tutorialStore, hasEditor }: PanelProps) { onHelpClick={lessonFullyLoaded ? onHelpClick : undefined} onFileSelect={(filePath) => tutorialStore.setSelectedFile(filePath)} onFileTreeChange={editorConfig.fileTree.allowEdits ? onFileTreeChange : undefined} + allowEditPatterns={editorConfig.fileTree.allowEdits ? editorConfig.fileTree.allowEdits : undefined} selectedFile={selectedFile} onEditorScroll={(position) => tutorialStore.setCurrentDocumentScrollPosition(position)} onEditorChange={(update) => tutorialStore.setCurrentDocumentContent(update.content)} diff --git a/packages/react/src/core/ContextMenu.tsx b/packages/react/src/core/ContextMenu.tsx index ddb3dfa8..b62efc8c 100644 --- a/packages/react/src/core/ContextMenu.tsx +++ b/packages/react/src/core/ContextMenu.tsx @@ -1,5 +1,6 @@ import { useRef, useState, type ComponentProps } from 'react'; import { Root, Portal, Content, Item, Trigger } from '@radix-ui/react-context-menu'; +import picomatch from 'picomatch/posix'; import type { FileDescriptor, I18n } from '@tutorialkit/types'; interface FileChangeEvent { @@ -17,6 +18,9 @@ interface Props extends ComponentProps<'div'> { /** Callback invoked when file is changed. */ onFileChange?: (event: FileChangeEvent | FileRenameEvent) => void; + /** Glob patterns for paths that allow editing files and folders. Defaults to `['**']`. */ + allowEditPatterns?: string[]; + /** Directory of the clicked file. */ directory: string; @@ -32,6 +36,7 @@ interface Props extends ComponentProps<'div'> { export function ContextMenu({ onFileChange, + allowEditPatterns = ['**'], directory, i18n, position = 'before', @@ -50,11 +55,19 @@ export function ContextMenu({ const name = event.currentTarget.value; if (name) { - onFileChange?.({ - value: `${directory}/${name}`, - type: state === 'add_file' ? 'file' : 'folder', - method: 'add', - }); + const value = `${directory}/${name}`; + const isAllowed = picomatch.isMatch(value, allowEditPatterns); + + if (isAllowed) { + onFileChange?.({ + value, + type: state === 'add_file' ? 'file' : 'folder', + method: 'add', + }); + } else { + // TODO: Use `@radix-ui/react-dialog` instead + alert(`File "${value}" is not allowed. Allowed patterns: [${allowEditPatterns.join(', ')}].`); + } } setState('idle'); diff --git a/packages/react/src/core/FileTree.tsx b/packages/react/src/core/FileTree.tsx index 87bd9122..746daa29 100644 --- a/packages/react/src/core/FileTree.tsx +++ b/packages/react/src/core/FileTree.tsx @@ -11,6 +11,7 @@ interface Props { selectedFile?: string; onFileSelect?: (filePath: string) => void; onFileChange?: ComponentProps['onFileChange']; + allowEditPatterns?: ComponentProps['allowEditPatterns']; i18n?: ComponentProps['i18n']; hideRoot: boolean; scope?: string; @@ -22,6 +23,7 @@ export function FileTree({ files, onFileSelect, onFileChange, + allowEditPatterns, selectedFile, hideRoot, scope, @@ -109,6 +111,7 @@ export function FileTree({ collapsed={collapsedFolders.has(fileOrFolder.id)} onClick={() => toggleCollapseState(fileOrFolder.id)} onFileChange={onFileChange} + allowEditPatterns={allowEditPatterns} /> ); } @@ -121,6 +124,7 @@ export function FileTree({ style={getDepthStyle(0)} directory="" onFileChange={onFileChange} + allowEditPatterns={allowEditPatterns} triggerProps={{ className: 'h-full', 'data-testid': 'file-tree-root-context-menu' }} /> @@ -134,12 +138,26 @@ interface FolderProps { collapsed: boolean; onClick: () => void; onFileChange: Props['onFileChange']; + allowEditPatterns: Props['allowEditPatterns']; i18n: Props['i18n']; } -function Folder({ folder: { depth, name, fullPath }, i18n, collapsed, onClick, onFileChange }: FolderProps) { +function Folder({ + folder: { depth, name, fullPath }, + i18n, + collapsed, + onClick, + onFileChange, + allowEditPatterns, +}: FolderProps) { return ( - + =8.6'} - dependencies: - braces: 3.0.3 - picomatch: 2.3.1 - /micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -7956,7 +7959,6 @@ packages: /picomatch@4.0.2: resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} engines: {node: '>=12'} - dev: true /pify@4.0.1: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} From d07d47c9472bf676b97c3015baacf52f90f2d4e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ari=20Perkki=C3=B6?= Date: Mon, 16 Sep 2024 11:37:07 +0300 Subject: [PATCH 3/8] test: verify `allowEdits` as glob patterns --- .../_files/first-level/file.js | 1 + .../_files/first-level/second-level/file.js | 1 + .../file-tree/allow-edits-glob/content.md | 18 +++++ e2e/test/file-tree.test.ts | 67 +++++++++++++++++++ 4 files changed, 87 insertions(+) create mode 100644 e2e/src/content/tutorial/tests/file-tree/allow-edits-glob/_files/first-level/file.js create mode 100644 e2e/src/content/tutorial/tests/file-tree/allow-edits-glob/_files/first-level/second-level/file.js create mode 100644 e2e/src/content/tutorial/tests/file-tree/allow-edits-glob/content.md diff --git a/e2e/src/content/tutorial/tests/file-tree/allow-edits-glob/_files/first-level/file.js b/e2e/src/content/tutorial/tests/file-tree/allow-edits-glob/_files/first-level/file.js new file mode 100644 index 00000000..7e6506ff --- /dev/null +++ b/e2e/src/content/tutorial/tests/file-tree/allow-edits-glob/_files/first-level/file.js @@ -0,0 +1 @@ +export default 'File in first level'; diff --git a/e2e/src/content/tutorial/tests/file-tree/allow-edits-glob/_files/first-level/second-level/file.js b/e2e/src/content/tutorial/tests/file-tree/allow-edits-glob/_files/first-level/second-level/file.js new file mode 100644 index 00000000..6c549d4e --- /dev/null +++ b/e2e/src/content/tutorial/tests/file-tree/allow-edits-glob/_files/first-level/second-level/file.js @@ -0,0 +1 @@ +export default 'File in second level'; diff --git a/e2e/src/content/tutorial/tests/file-tree/allow-edits-glob/content.md b/e2e/src/content/tutorial/tests/file-tree/allow-edits-glob/content.md new file mode 100644 index 00000000..36d1b764 --- /dev/null +++ b/e2e/src/content/tutorial/tests/file-tree/allow-edits-glob/content.md @@ -0,0 +1,18 @@ +--- +type: lesson +title: Allow Edits Glob +previews: false +editor: + fileTree: + allowEdits: + # Items in root + - "/*" + # Only "allowed-filename-only.js" inside "/first-level" folder + - "/first-level/allowed-filename-only.js" + # Anything inside "second-level" folders anywhere + - "**/second-level/**" +terminal: + panels: terminal +--- + +# File Tree test - Allow Edits Glob diff --git a/e2e/test/file-tree.test.ts b/e2e/test/file-tree.test.ts index ef935215..3dbd7b52 100644 --- a/e2e/test/file-tree.test.ts +++ b/e2e/test/file-tree.test.ts @@ -155,3 +155,70 @@ test('user can create folders', async ({ page }) => { await expect(terminalOutput).toContainText(folder, { useInnerText: true }); } }); + +test('user can create files and folders in allowed directories', async ({ page }) => { + await page.goto(`${BASE_URL}/allow-edits-glob`); + await expect(page.getByRole('heading', { level: 1, name: 'File Tree test - Allow Edits Glob' })).toBeVisible(); + + // wait for terminal to start + const terminal = page.getByRole('textbox', { name: 'Terminal input' }); + const terminalOutput = page.getByRole('tabpanel', { name: 'Terminal' }); + await expect(terminalOutput).toContainText('~/tutorial', { useInnerText: true }); + + // can create files in root and inside "second-level" + for (const [locator, name, type] of [ + [page.getByTestId('file-tree-root-context-menu'), 'new-file.js', 'file'], + [page.getByRole('button', { name: 'second-level' }), 'new-folder.js', 'folder'], + ] as const) { + await locator.click({ button: 'right' }); + await page.getByRole('menuitem', { name: `Create ${type}` }).click(); + + await page.locator('*:focus').fill(name); + await page.locator('*:focus').press('Enter'); + await expect(page.getByRole('button', { name })).toBeVisible(); + } + + await expect(page.getByRole('button', { name: 'new-file.js' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'new-folder' })).toBeVisible(); + + // verify that files are present on file system via terminal + for (const [directory, folder] of [ + ['./', 'new-file.js'], + ['./first-level/second-level/', 'new-folder'], + ]) { + await terminal.fill(`clear; ls ${directory}`); + await terminal.press('Enter'); + + await expect(terminalOutput).toContainText(folder, { useInnerText: true }); + } +}); + +test('user cannot create files or folders in disallowed directories', async ({ page }) => { + await page.goto(`${BASE_URL}/allow-edits-glob`); + await expect(page.getByRole('heading', { level: 1, name: 'File Tree test - Allow Edits Glob' })).toBeVisible(); + + // wait for terminal to start + const terminalOutput = page.getByRole('tabpanel', { name: 'Terminal' }); + await expect(terminalOutput).toContainText('~/tutorial', { useInnerText: true }); + + for (const [name, type] of [ + ['new-file.js', 'file'], + ['new-folder', 'folder'], + ] as const) { + await page.getByRole('button', { name: 'first-level' }).click({ button: 'right' }); + await page.getByRole('menuitem', { name: `Create ${type}` }).click(); + + const message = new Promise((resolve) => + page.once('dialog', (dialog) => { + resolve(dialog.message()); + dialog.accept(); + }), + ); + + await page.locator('*:focus').fill(name); + await page.locator('*:focus').press('Enter'); + expect(await message).toBe( + `File \"/first-level/${name}\" is not allowed. Allowed patterns: [/*, /first-level/allowed-filename-only.js, **/second-level/**].`, + ); + } +}); From c4b359b5962d721a80009c20434742f63873c2f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ari=20Perkki=C3=B6?= Date: Tue, 17 Sep 2024 14:10:34 +0300 Subject: [PATCH 4/8] feat(react): use radix dialog on `` errors --- .../content/docs/reference/configuration.mdx | 35 ++++++++ e2e/test/file-tree.test.ts | 21 ++--- e2e/uno.config.ts | 7 +- .../utils/content/default-localization.ts | 3 + packages/react/package.json | 1 + packages/react/src/core/ContextMenu.tsx | 84 ++++++++++++++++--- packages/types/src/schemas/i18n.ts | 30 +++++++ pnpm-lock.yaml | 56 ++++++++----- 8 files changed, 195 insertions(+), 42 deletions(-) diff --git a/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx b/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx index 6236a2c0..2a2905bf 100644 --- a/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx +++ b/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx @@ -84,6 +84,41 @@ type I18nText = { */ filesTitleText?: string, + /** + * Text shown on file tree's context menu's file creation button. + * + * @default 'Create file' + */ + fileTreeCreateFileText?: string, + + /** + * Text shown on file tree's context menu's folder creation button. + * + * @default 'Create folder' + */ + fileTreeCreateFolderText?: string, + + /** + * Text shown on dialog when file creation failed. Variables: ${filename}. + * + * @default 'Failed to create file "${filename}".' + */ + fileTreeFailedToCreateFileText?: string, + + /** + * Text shown on dialog when folder creation failed. Variables: ${filename}. + * + * @default 'Failed to create folder "${filename}".' + */ + fileTreeFailedToCreateFolderText?: string, + + /** + * Text shown on dialog describing allowed patterns when file or folder createion failed. + * + * @default 'Allowed patterns are:' + */ + fileTreeAllowedPatternsText?: string, + /** * Text shown on top of the steps section. * diff --git a/e2e/test/file-tree.test.ts b/e2e/test/file-tree.test.ts index 3dbd7b52..ba2b5762 100644 --- a/e2e/test/file-tree.test.ts +++ b/e2e/test/file-tree.test.ts @@ -208,17 +208,18 @@ test('user cannot create files or folders in disallowed directories', async ({ p await page.getByRole('button', { name: 'first-level' }).click({ button: 'right' }); await page.getByRole('menuitem', { name: `Create ${type}` }).click(); - const message = new Promise((resolve) => - page.once('dialog', (dialog) => { - resolve(dialog.message()); - dialog.accept(); - }), - ); - await page.locator('*:focus').fill(name); await page.locator('*:focus').press('Enter'); - expect(await message).toBe( - `File \"/first-level/${name}\" is not allowed. Allowed patterns: [/*, /first-level/allowed-filename-only.js, **/second-level/**].`, - ); + + const dialog = page.getByRole('dialog', { name: 'Error' }); + await expect(dialog.getByText(`Failed to create ${type} "/first-level/${name}".`)).toBeVisible(); + + await expect(dialog.getByText('Allowed patterns are:')).toBeVisible(); + await expect(dialog.getByRole('listitem').nth(0)).toHaveText('/*'); + await expect(dialog.getByRole('listitem').nth(1)).toHaveText('/first-level/allowed-filename-only.js'); + await expect(dialog.getByRole('listitem').nth(2)).toHaveText('**/second-level/**'); + + await dialog.getByRole('button', { name: 'Close' }).click(); + await expect(dialog).not.toBeVisible(); } }); diff --git a/e2e/uno.config.ts b/e2e/uno.config.ts index 949120d9..74c8497f 100644 --- a/e2e/uno.config.ts +++ b/e2e/uno.config.ts @@ -1,5 +1,10 @@ import { defineConfig } from '@tutorialkit/theme'; export default defineConfig({ - // add your UnoCSS config here: https://unocss.dev/guide/config-file + // required for TutorialKit monorepo development mode + content: { + pipeline: { + include: '**', + }, + }, }); diff --git a/packages/astro/src/default/utils/content/default-localization.ts b/packages/astro/src/default/utils/content/default-localization.ts index a5cdc8c7..f5307747 100644 --- a/packages/astro/src/default/utils/content/default-localization.ts +++ b/packages/astro/src/default/utils/content/default-localization.ts @@ -9,6 +9,9 @@ export const DEFAULT_LOCALIZATION = { filesTitleText: 'Files', fileTreeCreateFileText: 'Create file', fileTreeCreateFolderText: 'Create folder', + fileTreeFailedToCreateFileText: 'Failed to create file "${filename}".', + fileTreeFailedToCreateFolderText: 'Failed to create folder "${filename}".', + fileTreeAllowedPatternsText: 'Allowed patterns are:', prepareEnvironmentTitleText: 'Preparing Environment', defaultPreviewTitleText: 'Preview', reloadPreviewTitle: 'Reload Preview', diff --git a/packages/react/package.json b/packages/react/package.json index eb5e3d6f..67f77e45 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -75,6 +75,7 @@ "@nanostores/react": "0.7.2", "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-context-menu": "^2.2.1", + "@radix-ui/react-dialog": "^1.1.1", "@replit/codemirror-lang-svelte": "^6.0.0", "@tutorialkit/runtime": "workspace:*", "@tutorialkit/theme": "workspace:*", diff --git a/packages/react/src/core/ContextMenu.tsx b/packages/react/src/core/ContextMenu.tsx index b62efc8c..21b59878 100644 --- a/packages/react/src/core/ContextMenu.tsx +++ b/packages/react/src/core/ContextMenu.tsx @@ -1,7 +1,8 @@ -import { useRef, useState, type ComponentProps } from 'react'; +import { useRef, useState, type ComponentProps, type ReactNode } from 'react'; import { Root, Portal, Content, Item, Trigger } from '@radix-ui/react-context-menu'; +import * as RadixDialog from '@radix-ui/react-dialog'; import picomatch from 'picomatch/posix'; -import type { FileDescriptor, I18n } from '@tutorialkit/types'; +import { interpolateString, type FileDescriptor, type I18n } from '@tutorialkit/types'; interface FileChangeEvent { type: FileDescriptor['type']; @@ -28,45 +29,69 @@ interface Props extends ComponentProps<'div'> { position?: 'before' | 'after'; /** Localized texts for menu. */ - i18n?: Pick; + i18n?: Pick< + I18n, + | 'fileTreeCreateFileText' + | 'fileTreeCreateFolderText' + | 'fileTreeFailedToCreateFileText' + | 'fileTreeFailedToCreateFolderText' + | 'fileTreeAllowedPatternsText' + >; /** Props for trigger wrapper. */ triggerProps?: ComponentProps<'div'> & { 'data-testid'?: string }; } +const i18nDefaults = { + fileTreeFailedToCreateFileText: 'Failed to create file "${filename}".', + fileTreeFailedToCreateFolderText: 'Failed to create folder "${filename}".', + fileTreeCreateFileText: 'Create file', + fileTreeCreateFolderText: 'Create folder', + fileTreeAllowedPatternsText: 'Allowed patterns are:', +} as const satisfies Props['i18n']; + export function ContextMenu({ onFileChange, allowEditPatterns = ['**'], directory, - i18n, + i18n: i18nProps, position = 'before', children, triggerProps, ...props }: Props) { - const [state, setState] = useState<'idle' | 'add_file' | 'add_folder'>('idle'); + const [state, setState] = useState<'idle' | 'add_file' | 'add_folder' | { error: string }>('idle'); const inputRef = useRef(null); + const error = typeof state === 'string' ? false : state.error; + const i18n = { ...i18nProps, ...i18nDefaults }; + if (!onFileChange) { return children; } function onFileNameEnd(event: React.KeyboardEvent | React.FocusEvent) { + if (state !== 'add_file' && state !== 'add_folder') { + return; + } + const name = event.currentTarget.value; if (name) { const value = `${directory}/${name}`; const isAllowed = picomatch.isMatch(value, allowEditPatterns); + const isFile = state === 'add_file'; if (isAllowed) { onFileChange?.({ value, - type: state === 'add_file' ? 'file' : 'folder', + type: isFile ? 'file' : 'folder', method: 'add', }); } else { - // TODO: Use `@radix-ui/react-dialog` instead - alert(`File "${value}" is not allowed. Allowed patterns: [${allowEditPatterns.join(', ')}].`); + const text = isFile ? i18n.fileTreeFailedToCreateFileText : i18n.fileTreeFailedToCreateFolderText; + + return setState({ error: interpolateString(text, { filename: value }) }); } } @@ -118,14 +143,31 @@ export function ContextMenu({ className="border border-tk-border-brighter b-rounded-md bg-tk-background-brighter py-2" > setState('add_file')}> - {i18n?.fileTreeCreateFileText || 'Create file'} + {i18n.fileTreeCreateFileText} setState('add_folder')}> - {i18n?.fileTreeCreateFolderText || 'Create folder'} + {i18n.fileTreeCreateFolderText} + + {error && ( + setState('idle')}> +

{error}

+ +
+ {i18n.fileTreeAllowedPatternsText} +
    + {allowEditPatterns.map((pattern) => ( +
  • + {pattern} +
  • + ))} +
+
+
+ )} ); } @@ -141,3 +183,25 @@ function MenuItem({ icon, children, ...props }: { icon: string } & ComponentProp ); } + +function Dialog({ onClose, children }: { onClose: () => void; children: ReactNode }) { + return ( + !open && onClose()}> + + + + +
+ Error + + {children} + + + + +
+
+
+
+ ); +} diff --git a/packages/types/src/schemas/i18n.ts b/packages/types/src/schemas/i18n.ts index 1f34834b..c4bf9721 100644 --- a/packages/types/src/schemas/i18n.ts +++ b/packages/types/src/schemas/i18n.ts @@ -72,6 +72,36 @@ export const i18nSchema = z.object({ .optional() .describe("Text shown on file tree's context menu's folder creation button."), + /** + * Text shown on dialog when file creation failed. Variables: ${filename}. + * + * @default 'Failed to create file "${filename}".' + */ + fileTreeFailedToCreateFileText: z + .string() + .optional() + .describe('Text shown on dialog when file creation failed. Variables: ${filename}.'), + + /** + * Text shown on dialog when folder creation failed. Variables: ${filename}. + * + * @default 'Failed to create folder "${filename}".' + */ + fileTreeFailedToCreateFolderText: z + .string() + .optional() + .describe('Text shown on dialog when folder creation failed. Variables: ${filename}.'), + + /** + * Text shown on dialog describing allowed patterns when file or folder createion failed. + * + * @default 'Allowed patterns are:' + */ + fileTreeAllowedPatternsText: z + .string() + .optional() + .describe('Text shown on dialog describing allowed patterns when file or folder createion failed.'), + /** * Text shown on top of the steps section. * diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c12737a..5d029d4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -539,6 +539,9 @@ importers: '@radix-ui/react-context-menu': specifier: ^2.2.1 version: 2.2.1(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-dialog': + specifier: ^1.1.1 + version: 1.1.1(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) '@replit/codemirror-lang-svelte': specifier: ^6.0.0 version: 6.0.0(@codemirror/autocomplete@6.16.3)(@codemirror/lang-css@6.2.1)(@codemirror/lang-html@6.4.9)(@codemirror/lang-javascript@6.2.2)(@codemirror/language@6.10.2)(@codemirror/state@6.4.1)(@codemirror/view@6.28.1)(@lezer/common@1.2.1)(@lezer/highlight@1.2.0)(@lezer/javascript@1.4.17)(@lezer/lr@1.4.1) @@ -2957,6 +2960,38 @@ packages: react: 18.3.1 dev: false + /@radix-ui/react-dialog@1.1.1(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-zysS+iU4YP3STKNS6USvFVqI4qqx8EpiwmT5TuCApVEBca+eRCbONi4EgzfNSuVnOXvC5UPHHMjs8RXO6DH9Bg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-context': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.0(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.0(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-portal': 1.1.1(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-presence': 1.1.0(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-slot': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@types/react': 18.3.3 + aria-hidden: 1.2.4 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.5.7(@types/react@18.3.3)(react@18.3.1) + dev: false + /@radix-ui/react-direction@1.1.0(@types/react@18.3.3)(react@18.3.1): resolution: {integrity: sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==} peerDependencies: @@ -3606,27 +3641,6 @@ packages: - typescript dev: true - /@tutorialkit/cli@0.2.2: - resolution: {integrity: sha512-ZsBFHdjO/XbbXme+tkQu9UU7G9qiHVLKUQp2abt9prxennZdPkIBR3Y3gdYnYdezaWQ+8k19xrx6152pnPruIQ==} - engines: {node: '>=18.18.0'} - hasBin: true - dependencies: - '@babel/generator': 7.24.5 - '@babel/parser': 7.24.5 - '@babel/traverse': 7.24.5 - '@babel/types': 7.24.5 - '@clack/prompts': 0.7.0 - chalk: 5.3.0 - detect-indent: 7.0.1 - execa: 9.2.0 - ignore: 5.3.1 - lookpath: 1.2.2 - which-pm: 2.2.0 - yargs-parser: 21.1.1 - transitivePeerDependencies: - - supports-color - dev: false - /@types/acorn@4.0.6: resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==} dependencies: From 3d75f4bcdf005f9eb4953630b2dd0f5c00c6e70b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ari=20Perkki=C3=B6?= Date: Wed, 18 Sep 2024 11:29:37 +0300 Subject: [PATCH 5/8] fix: code review --- .../content/docs/reference/configuration.mdx | 15 ++-- e2e/test/file-tree.test.ts | 7 +- packages/astro/src/default/utils/content.ts | 3 +- .../create-tutorial.test.ts.snap | 1 - packages/react/src/core/ContextMenu.tsx | 70 +++++++------------ .../src}/default-localization.ts | 7 +- packages/types/src/index.ts | 1 + packages/types/src/schemas/i18n.ts | 20 ++---- 8 files changed, 43 insertions(+), 81 deletions(-) rename packages/{astro/src/default/utils/content => types/src}/default-localization.ts (72%) diff --git a/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx b/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx index 2a2905bf..33e8ba6e 100644 --- a/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx +++ b/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx @@ -99,23 +99,16 @@ type I18nText = { fileTreeCreateFolderText?: string, /** - * Text shown on dialog when file creation failed. Variables: ${filename}. + * Text shown on dialog when user attempts to edit files that don't match allowed patterns. * - * @default 'Failed to create file "${filename}".' + * @default 'This action is not allowed' */ - fileTreeFailedToCreateFileText?: string, - - /** - * Text shown on dialog when folder creation failed. Variables: ${filename}. - * - * @default 'Failed to create folder "${filename}".' - */ - fileTreeFailedToCreateFolderText?: string, + fileTreeActionNotAllowed?: string, /** * Text shown on dialog describing allowed patterns when file or folder createion failed. * - * @default 'Allowed patterns are:' + * @default 'Created files and folders must match following patterns:' */ fileTreeAllowedPatternsText?: string, diff --git a/e2e/test/file-tree.test.ts b/e2e/test/file-tree.test.ts index ba2b5762..72c3bc51 100644 --- a/e2e/test/file-tree.test.ts +++ b/e2e/test/file-tree.test.ts @@ -211,15 +211,14 @@ test('user cannot create files or folders in disallowed directories', async ({ p await page.locator('*:focus').fill(name); await page.locator('*:focus').press('Enter'); - const dialog = page.getByRole('dialog', { name: 'Error' }); - await expect(dialog.getByText(`Failed to create ${type} "/first-level/${name}".`)).toBeVisible(); + const dialog = page.getByRole('dialog', { name: 'This action is not allowed' }); - await expect(dialog.getByText('Allowed patterns are:')).toBeVisible(); + await expect(dialog.getByText('Created files and folders must match following patterns:')).toBeVisible(); await expect(dialog.getByRole('listitem').nth(0)).toHaveText('/*'); await expect(dialog.getByRole('listitem').nth(1)).toHaveText('/first-level/allowed-filename-only.js'); await expect(dialog.getByRole('listitem').nth(2)).toHaveText('**/second-level/**'); - await dialog.getByRole('button', { name: 'Close' }).click(); + await dialog.getByRole('button', { name: 'OK' }).click(); await expect(dialog).not.toBeVisible(); } }); diff --git a/packages/astro/src/default/utils/content.ts b/packages/astro/src/default/utils/content.ts index 4f879bd6..0ad2103b 100644 --- a/packages/astro/src/default/utils/content.ts +++ b/packages/astro/src/default/utils/content.ts @@ -1,8 +1,7 @@ import path from 'node:path'; import type { ChapterSchema, Lesson, LessonSchema, PartSchema, Tutorial, TutorialSchema } from '@tutorialkit/types'; -import { interpolateString } from '@tutorialkit/types'; +import { interpolateString, DEFAULT_LOCALIZATION } from '@tutorialkit/types'; import { getCollection } from 'astro:content'; -import { DEFAULT_LOCALIZATION } from './content/default-localization'; import { getFilesRefList } from './content/files-ref'; import { squash } from './content/squash.js'; import { logger } from './logger'; diff --git a/packages/cli/tests/__snapshots__/create-tutorial.test.ts.snap b/packages/cli/tests/__snapshots__/create-tutorial.test.ts.snap index 67e94228..62b060f9 100644 --- a/packages/cli/tests/__snapshots__/create-tutorial.test.ts.snap +++ b/packages/cli/tests/__snapshots__/create-tutorial.test.ts.snap @@ -304,7 +304,6 @@ exports[`create and eject a project 1`] = ` "src/utils/constants.ts", "src/utils/content", "src/utils/content.ts", - "src/utils/content/default-localization.ts", "src/utils/content/files-ref.ts", "src/utils/content/squash.ts", "src/utils/logger.ts", diff --git a/packages/react/src/core/ContextMenu.tsx b/packages/react/src/core/ContextMenu.tsx index f43befc7..e9622d01 100644 --- a/packages/react/src/core/ContextMenu.tsx +++ b/packages/react/src/core/ContextMenu.tsx @@ -1,6 +1,6 @@ import { Root, Portal, Content, Item, Trigger } from '@radix-ui/react-context-menu'; import * as RadixDialog from '@radix-ui/react-dialog'; -import { interpolateString, type FileDescriptor, type I18n } from '@tutorialkit/types'; +import { DEFAULT_LOCALIZATION, type FileDescriptor, type I18n } from '@tutorialkit/types'; import picomatch from 'picomatch/posix'; import { useRef, useState, type ComponentProps, type ReactNode } from 'react'; @@ -33,8 +33,7 @@ interface Props extends ComponentProps<'div'> { I18n, | 'fileTreeCreateFileText' | 'fileTreeCreateFolderText' - | 'fileTreeFailedToCreateFileText' - | 'fileTreeFailedToCreateFolderText' + | 'fileTreeActionNotAllowedText' | 'fileTreeAllowedPatternsText' >; @@ -42,30 +41,19 @@ interface Props extends ComponentProps<'div'> { triggerProps?: ComponentProps<'div'> & { 'data-testid'?: string }; } -const i18nDefaults = { - fileTreeFailedToCreateFileText: 'Failed to create file "${filename}".', - fileTreeFailedToCreateFolderText: 'Failed to create folder "${filename}".', - fileTreeCreateFileText: 'Create file', - fileTreeCreateFolderText: 'Create folder', - fileTreeAllowedPatternsText: 'Allowed patterns are:', -} as const satisfies Props['i18n']; - export function ContextMenu({ onFileChange, allowEditPatterns = ['**'], directory, - i18n: i18nProps, + i18n, position = 'before', children, triggerProps, ...props }: Props) { - const [state, setState] = useState<'idle' | 'add_file' | 'add_folder' | { error: string }>('idle'); + const [state, setState] = useState<'idle' | 'add_file' | 'add_folder' | 'add_failed'>('idle'); const inputRef = useRef(null); - const error = typeof state === 'string' ? false : state.error; - const i18n = { ...i18nProps, ...i18nDefaults }; - if (!onFileChange) { return children; } @@ -80,18 +68,15 @@ export function ContextMenu({ if (name) { const value = `${directory}/${name}`; const isAllowed = picomatch.isMatch(value, allowEditPatterns); - const isFile = state === 'add_file'; if (isAllowed) { onFileChange?.({ value, - type: isFile ? 'file' : 'folder', + type: state === 'add_file' ? 'file' : 'folder', method: 'add', }); } else { - const text = isFile ? i18n.fileTreeFailedToCreateFileText : i18n.fileTreeFailedToCreateFolderText; - - return setState({ error: interpolateString(text, { filename: value }) }); + return setState('add_failed'); } } @@ -143,29 +128,28 @@ export function ContextMenu({ className="border border-tk-border-brighter b-rounded-md bg-tk-background-brighter py-2" > setState('add_file')}> - {i18n.fileTreeCreateFileText} + {i18n?.fileTreeCreateFileText || DEFAULT_LOCALIZATION.fileTreeCreateFileText} setState('add_folder')}> - {i18n.fileTreeCreateFolderText} + {i18n?.fileTreeCreateFolderText || DEFAULT_LOCALIZATION.fileTreeCreateFolderText} - {error && ( - setState('idle')}> -

{error}

- -
- {i18n.fileTreeAllowedPatternsText} -
    - {allowEditPatterns.map((pattern) => ( -
  • - {pattern} -
  • - ))} -
-
+ {state === 'add_failed' && ( + setState('idle')} + > + {i18n?.fileTreeAllowedPatternsText || DEFAULT_LOCALIZATION.fileTreeAllowedPatternsText} +
    + {allowEditPatterns.map((pattern) => ( +
  • + {pattern} +
  • + ))} +
)} @@ -184,21 +168,19 @@ function MenuItem({ icon, children, ...props }: { icon: string } & ComponentProp ); } -function Dialog({ onClose, children }: { onClose: () => void; children: ReactNode }) { +function Dialog({ title, onClose, children }: { title: string; onClose: () => void; children: ReactNode }) { return ( !open && onClose()}> - +
- Error + {title} - {children} +
{children}
- - - + OK
diff --git a/packages/astro/src/default/utils/content/default-localization.ts b/packages/types/src/default-localization.ts similarity index 72% rename from packages/astro/src/default/utils/content/default-localization.ts rename to packages/types/src/default-localization.ts index f5307747..b596d4f2 100644 --- a/packages/astro/src/default/utils/content/default-localization.ts +++ b/packages/types/src/default-localization.ts @@ -1,4 +1,4 @@ -import type { Lesson } from '@tutorialkit/types'; +import type { Lesson } from './entities/index.js'; export const DEFAULT_LOCALIZATION = { partTemplate: 'Part ${index}: ${title}', @@ -9,9 +9,8 @@ export const DEFAULT_LOCALIZATION = { filesTitleText: 'Files', fileTreeCreateFileText: 'Create file', fileTreeCreateFolderText: 'Create folder', - fileTreeFailedToCreateFileText: 'Failed to create file "${filename}".', - fileTreeFailedToCreateFolderText: 'Failed to create folder "${filename}".', - fileTreeAllowedPatternsText: 'Allowed patterns are:', + fileTreeActionNotAllowedText: 'This action is not allowed', + fileTreeAllowedPatternsText: 'Created files and folders must match following patterns:', prepareEnvironmentTitleText: 'Preparing Environment', defaultPreviewTitleText: 'Preview', reloadPreviewTitle: 'Reload Preview', diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 1b085220..1f905179 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -2,3 +2,4 @@ export type * from './entities/index.js'; export * from './schemas/index.js'; export * from './files-ref.js'; export { interpolateString } from './utils/interpolation.js'; +export { DEFAULT_LOCALIZATION } from './default-localization.js'; diff --git a/packages/types/src/schemas/i18n.ts b/packages/types/src/schemas/i18n.ts index c4bf9721..68dae936 100644 --- a/packages/types/src/schemas/i18n.ts +++ b/packages/types/src/schemas/i18n.ts @@ -73,29 +73,19 @@ export const i18nSchema = z.object({ .describe("Text shown on file tree's context menu's folder creation button."), /** - * Text shown on dialog when file creation failed. Variables: ${filename}. + * Text shown on dialog when user attempts to edit files that don't match allowed patterns. * - * @default 'Failed to create file "${filename}".' + * @default 'This action is not allowed' */ - fileTreeFailedToCreateFileText: z + fileTreeActionNotAllowedText: z .string() .optional() - .describe('Text shown on dialog when file creation failed. Variables: ${filename}.'), - - /** - * Text shown on dialog when folder creation failed. Variables: ${filename}. - * - * @default 'Failed to create folder "${filename}".' - */ - fileTreeFailedToCreateFolderText: z - .string() - .optional() - .describe('Text shown on dialog when folder creation failed. Variables: ${filename}.'), + .describe("Text shown on dialog when user attempts to edit files that don't match allowed patterns."), /** * Text shown on dialog describing allowed patterns when file or folder createion failed. * - * @default 'Allowed patterns are:' + * @default 'Created files and folders must match following patterns:' */ fileTreeAllowedPatternsText: z .string() From 428bc1bcd7aa899c98f12772e22085230796dec9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ari=20Perkki=C3=B6?= Date: Wed, 18 Sep 2024 15:24:15 +0300 Subject: [PATCH 6/8] fix: code review --- .../docs/reference/react-components.mdx | 2 +- .../src/default/components/LoginButton.tsx | 19 ++--------- packages/react/src/Button.tsx | 32 +++++++++++++++++++ packages/react/src/Panels/WorkspacePanel.tsx | 4 +-- packages/react/src/core/ContextMenu.tsx | 16 ++++++---- packages/react/src/index.ts | 1 + packages/react/src/utils/classnames.ts | 2 +- 7 files changed, 50 insertions(+), 26 deletions(-) create mode 100644 packages/react/src/Button.tsx diff --git a/docs/tutorialkit.dev/src/content/docs/reference/react-components.mdx b/docs/tutorialkit.dev/src/content/docs/reference/react-components.mdx index 957e1af7..a659578c 100644 --- a/docs/tutorialkit.dev/src/content/docs/reference/react-components.mdx +++ b/docs/tutorialkit.dev/src/content/docs/reference/react-components.mdx @@ -116,7 +116,7 @@ A component to list files in a tree view. } ``` -* `allowEditPatterns?: string[]` - Glob patterns for paths that allow editing files and folders. Defaults to `['**']`. +* `allowEditPatterns?: string[]` - Glob patterns for paths that allow editing files and folders. Disabled by default. * `hideRoot: boolean` - Whether or not to hide the root directory in the tree. Defaults to `false`. diff --git a/packages/astro/src/default/components/LoginButton.tsx b/packages/astro/src/default/components/LoginButton.tsx index 24870297..cb912a61 100644 --- a/packages/astro/src/default/components/LoginButton.tsx +++ b/packages/astro/src/default/components/LoginButton.tsx @@ -1,5 +1,5 @@ import { useStore } from '@nanostores/react'; -import { classNames } from '@tutorialkit/react'; +import { Button } from '@tutorialkit/react'; import { useEffect, useRef, useState } from 'react'; import { authStore } from '../stores/auth-store'; import { login, logout } from './webcontainer'; @@ -48,21 +48,8 @@ export function LoginButton() { }, [authStatus.status]); return ( - + ); } diff --git a/packages/react/src/Button.tsx b/packages/react/src/Button.tsx new file mode 100644 index 00000000..5cc1804f --- /dev/null +++ b/packages/react/src/Button.tsx @@ -0,0 +1,32 @@ +import { type ComponentProps, forwardRef, type Ref } from 'react'; +import { classNames } from './utils/classnames.js'; + +interface Props extends ComponentProps<'button'> { + variant?: 'primary' | 'secondary'; +} + +export const Button = forwardRef(({ className, variant = 'primary', ...props }: Props, ref: Ref) => { + return ( + + diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 157cbb79..33dbbc25 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,4 +1,5 @@ export * from './BootScreen.js'; +export * from './Button.js'; export * from './Nav.js'; export * from './Panels/EditorPanel.js'; export * from './Panels/PreviewPanel.js'; diff --git a/packages/react/src/utils/classnames.ts b/packages/react/src/utils/classnames.ts index 0622c334..35f12620 100644 --- a/packages/react/src/utils/classnames.ts +++ b/packages/react/src/utils/classnames.ts @@ -5,7 +5,7 @@ * @link http://jedwatson.github.io/classnames */ -type ClassNamesArg = undefined | string | Record | ClassNamesArg[]; +type ClassNamesArg = undefined | false | string | Record | ClassNamesArg[]; /** * A simple JavaScript utility for conditionally joining classNames together. From 5bd7a03dc73d563ce059c8a6f8c330f8a5e03558 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ari=20Perkki=C3=B6?= Date: Wed, 18 Sep 2024 15:24:42 +0300 Subject: [PATCH 7/8] fix: topBar-Button to just Button --- .../astro/src/default/styles/variables.css | 32 +++++++++---------- packages/react/src/Button.tsx | 8 ++--- packages/theme/src/theme.ts | 32 +++++++++---------- 3 files changed, 36 insertions(+), 36 deletions(-) diff --git a/packages/astro/src/default/styles/variables.css b/packages/astro/src/default/styles/variables.css index f748dc0a..fffe6dbc 100644 --- a/packages/astro/src/default/styles/variables.css +++ b/packages/astro/src/default/styles/variables.css @@ -142,6 +142,22 @@ --tk-elements-link-secondaryColor: var(--tk-text-secondary); --tk-elements-link-secondaryColorHover: var(--tk-text-primary); + /* Primary Button */ + --tk-elements-primaryButton-backgroundColor: var(--tk-background-accent-secondary); + --tk-elements-primaryButton-backgroundColorHover: var(--tk-background-accent-active); + --tk-elements-primaryButton-textColor: var(--tk-text-primary-inverted); + --tk-elements-primaryButton-textColorHover: var(--tk-text-primary-inverted); + --tk-elements-primaryButton-iconColor: var(--tk-text-primary-inverted); + --tk-elements-primaryButton-iconColorHover: var(--tk-text-primary-inverted); + + /* Secondary Button */ + --tk-elements-secondaryButton-backgroundColor: var(--tk-elements-app-backgroundColor); + --tk-elements-secondaryButton-backgroundColorHover: var(--tk-background-secondary); + --tk-elements-secondaryButton-textColor: var(--tk-text-secondary); + --tk-elements-secondaryButton-textColorHover: var(--tk-text-primary); + --tk-elements-secondaryButton-iconColor: var(--tk-text-secondary); + --tk-elements-secondaryButton-iconColorHover: var(--tk-text-primary); + /* Content */ --tk-elements-content-textColor: var(--tk-text-body); --tk-elements-content-headingTextColor: var(--tk-text-primary); @@ -163,22 +179,6 @@ --tk-elements-topBar-logo-color: var(--tk-text-active); --tk-elements-topBar-logo-colorHover: var(--tk-text-active); - /* Top Bar > Primary Button */ - --tk-elements-topBar-primaryButton-backgroundColor: var(--tk-background-accent-secondary); - --tk-elements-topBar-primaryButton-backgroundColorHover: var(--tk-background-accent-active); - --tk-elements-topBar-primaryButton-textColor: var(--tk-text-primary-inverted); - --tk-elements-topBar-primaryButton-textColorHover: var(--tk-text-primary-inverted); - --tk-elements-topBar-primaryButton-iconColor: var(--tk-text-primary-inverted); - --tk-elements-topBar-primaryButton-iconColorHover: var(--tk-text-primary-inverted); - - /* Top Bar > Secondary Button */ - --tk-elements-topBar-secondaryButton-backgroundColor: var(--tk-elements-topBar-backgroundColor); - --tk-elements-topBar-secondaryButton-backgroundColorHover: var(--tk-background-secondary); - --tk-elements-topBar-secondaryButton-textColor: var(--tk-text-secondary); - --tk-elements-topBar-secondaryButton-textColorHover: var(--tk-text-primary); - --tk-elements-topBar-secondaryButton-iconColor: var(--tk-text-secondary); - --tk-elements-topBar-secondaryButton-iconColorHover: var(--tk-text-primary); - /* Previews */ --tk-elements-previews-borderColor: theme('colors.gray.200'); diff --git a/packages/react/src/Button.tsx b/packages/react/src/Button.tsx index 5cc1804f..2a73fce2 100644 --- a/packages/react/src/Button.tsx +++ b/packages/react/src/Button.tsx @@ -14,18 +14,18 @@ export const Button = forwardRef(({ className, variant = 'primary', ...props }: className, 'flex items-center font-500 text-sm ml-2 px-4 py-1 rounded-md disabled:opacity-32', variant === 'primary' && - 'bg-tk-elements-topBar-primaryButton-backgroundColor text-tk-elements-topBar-primaryButton-textColor', + 'bg-tk-elements-primaryButton-backgroundColor text-tk-elements-primaryButton-textColor', !props.disabled && variant === 'primary' && - 'hover:bg-tk-elements-topBar-primaryButton-backgroundColorHover hover:text-tk-elements-topBar-primaryButton-textColorHover', + 'hover:bg-tk-elements-primaryButton-backgroundColorHover hover:text-tk-elements-primaryButton-textColorHover', variant === 'secondary' && - 'bg-tk-elements-topBar-secondaryButton-backgroundColor text-tk-elements-topBar-secondaryButton-textColor', + 'bg-tk-elements-secondaryButton-backgroundColor text-tk-elements-secondaryButton-textColor', !props.disabled && variant === 'secondary' && - 'hover:bg-tk-elements-topBar-secondaryButton-backgroundColorHover hover:text-tk-elements-topBar-secondaryButton-textColorHover', + 'hover:bg-tk-elements-secondaryButton-backgroundColorHover hover:text-tk-elements-secondaryButton-textColorHover', )} /> ); diff --git a/packages/theme/src/theme.ts b/packages/theme/src/theme.ts index b7bf219d..904d0629 100644 --- a/packages/theme/src/theme.ts +++ b/packages/theme/src/theme.ts @@ -150,6 +150,22 @@ export const theme = { secondaryColor: 'var(--tk-elements-link-secondaryColor)', secondaryColorHover: 'var(--tk-elements-link-secondaryColorHover)', }, + primaryButton: { + backgroundColor: 'var(--tk-elements-primaryButton-backgroundColor)', + backgroundColorHover: 'var(--tk-elements-primaryButton-backgroundColorHover)', + textColor: 'var(--tk-elements-primaryButton-textColor)', + textColorHover: 'var(--tk-elements-primaryButton-textColorHover)', + iconColor: 'var(--tk-elements-primaryButton-iconColor)', + iconColorHover: 'var(--tk-elements-primaryButton-iconColorHover)', + }, + secondaryButton: { + backgroundColor: 'var(--tk-elements-secondaryButton-backgroundColor)', + backgroundColorHover: 'var(--tk-elements-secondaryButton-backgroundColorHover)', + textColor: 'var(--tk-elements-secondaryButton-textColor)', + textColorHover: 'var(--tk-elements-secondaryButton-textColorHover)', + iconColor: 'var(--tk-elements-secondaryButton-iconColor)', + iconColorHover: 'var(--tk-elements-secondaryButton-iconColorHover)', + }, content: { textColor: 'var(--tk-elements-content-textColor)', headingTextColor: 'var(--tk-elements-content-headingTextColor)', @@ -170,22 +186,6 @@ export const theme = { color: 'var(--tk-elements-topBar-logo-color)', colorHover: 'var(--tk-elements-topBar-logo-colorHover)', }, - primaryButton: { - backgroundColor: 'var(--tk-elements-topBar-primaryButton-backgroundColor)', - backgroundColorHover: 'var(--tk-elements-topBar-primaryButton-backgroundColorHover)', - textColor: 'var(--tk-elements-topBar-primaryButton-textColor)', - textColorHover: 'var(--tk-elements-topBar-primaryButton-textColorHover)', - iconColor: 'var(--tk-elements-topBar-primaryButton-iconColor)', - iconColorHover: 'var(--tk-elements-topBar-primaryButton-iconColorHover)', - }, - secondaryButton: { - backgroundColor: 'var(--tk-elements-topBar-secondaryButton-backgroundColor)', - backgroundColorHover: 'var(--tk-elements-topBar-secondaryButton-backgroundColorHover)', - textColor: 'var(--tk-elements-topBar-secondaryButton-textColor)', - textColorHover: 'var(--tk-elements-topBar-secondaryButton-textColorHover)', - iconColor: 'var(--tk-elements-topBar-secondaryButton-iconColor)', - iconColorHover: 'var(--tk-elements-topBar-secondaryButton-iconColorHover)', - }, }, panel: { backgroundColor: 'var(--tk-elements-panel-backgroundColor)', From 24853cff8d30c70e33789ea67b100ea0c6a5b788 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ari=20Perkki=C3=B6?= Date: Wed, 18 Sep 2024 18:19:15 +0300 Subject: [PATCH 8/8] fix: code review --- .../src/default/components/LoginButton.tsx | 2 +- packages/react/src/Button.tsx | 2 +- packages/react/src/core/ContextMenu.tsx | 18 +++++++++++++++--- packages/types/src/default-localization.ts | 1 + packages/types/src/schemas/i18n.ts | 11 +++++++++-- 5 files changed, 27 insertions(+), 7 deletions(-) diff --git a/packages/astro/src/default/components/LoginButton.tsx b/packages/astro/src/default/components/LoginButton.tsx index cb912a61..6745f72c 100644 --- a/packages/astro/src/default/components/LoginButton.tsx +++ b/packages/astro/src/default/components/LoginButton.tsx @@ -48,7 +48,7 @@ export function LoginButton() { }, [authStatus.status]); return ( - ); diff --git a/packages/react/src/Button.tsx b/packages/react/src/Button.tsx index 2a73fce2..06b08a4b 100644 --- a/packages/react/src/Button.tsx +++ b/packages/react/src/Button.tsx @@ -12,7 +12,7 @@ export const Button = forwardRef(({ className, variant = 'primary', ...props }: {...props} className={classNames( className, - 'flex items-center font-500 text-sm ml-2 px-4 py-1 rounded-md disabled:opacity-32', + 'flex items-center font-500 text-sm px-4 py-1 rounded-md disabled:opacity-32', variant === 'primary' && 'bg-tk-elements-primaryButton-backgroundColor text-tk-elements-primaryButton-textColor', diff --git a/packages/react/src/core/ContextMenu.tsx b/packages/react/src/core/ContextMenu.tsx index 0070bb73..0b70004a 100644 --- a/packages/react/src/core/ContextMenu.tsx +++ b/packages/react/src/core/ContextMenu.tsx @@ -37,6 +37,7 @@ interface Props extends ComponentProps<'div'> { | 'fileTreeCreateFolderText' | 'fileTreeActionNotAllowedText' | 'fileTreeAllowedPatternsText' + | 'confirmationText' >; /** Props for trigger wrapper. */ @@ -56,7 +57,7 @@ export function ContextMenu({ const [state, setState] = useState<'idle' | 'add_file' | 'add_folder' | 'add_failed'>('idle'); const inputRef = useRef(null); - if (!onFileChange || !allowEditPatterns?.length) { + if (!allowEditPatterns?.length) { return children; } @@ -142,6 +143,7 @@ export function ContextMenu({ {state === 'add_failed' && ( setState('idle')} > {i18n?.fileTreeAllowedPatternsText || DEFAULT_LOCALIZATION.fileTreeAllowedPatternsText} @@ -170,7 +172,17 @@ function MenuItem({ icon, children, ...props }: { icon: string } & ComponentProp ); } -function Dialog({ title, onClose, children }: { title: string; onClose: () => void; children: ReactNode }) { +function Dialog({ + title, + confirmText, + onClose, + children, +}: { + title: string; + confirmText: string; + onClose: () => void; + children: ReactNode; +}) { return ( !open && onClose()}> @@ -183,7 +195,7 @@ function Dialog({ title, onClose, children }: { title: string; onClose: () => vo
{children}
- + diff --git a/packages/types/src/default-localization.ts b/packages/types/src/default-localization.ts index b596d4f2..6d3c95dd 100644 --- a/packages/types/src/default-localization.ts +++ b/packages/types/src/default-localization.ts @@ -11,6 +11,7 @@ export const DEFAULT_LOCALIZATION = { fileTreeCreateFolderText: 'Create folder', fileTreeActionNotAllowedText: 'This action is not allowed', fileTreeAllowedPatternsText: 'Created files and folders must match following patterns:', + confirmationText: 'OK', prepareEnvironmentTitleText: 'Preparing Environment', defaultPreviewTitleText: 'Preview', reloadPreviewTitle: 'Reload Preview', diff --git a/packages/types/src/schemas/i18n.ts b/packages/types/src/schemas/i18n.ts index 68dae936..5c596dea 100644 --- a/packages/types/src/schemas/i18n.ts +++ b/packages/types/src/schemas/i18n.ts @@ -83,14 +83,21 @@ export const i18nSchema = z.object({ .describe("Text shown on dialog when user attempts to edit files that don't match allowed patterns."), /** - * Text shown on dialog describing allowed patterns when file or folder createion failed. + * Text shown on dialog describing allowed patterns when file or folder creation failed. * * @default 'Created files and folders must match following patterns:' */ fileTreeAllowedPatternsText: z .string() .optional() - .describe('Text shown on dialog describing allowed patterns when file or folder createion failed.'), + .describe('Text shown on dialog describing allowed patterns when file or folder creation failed.'), + + /** + * Text shown on confirmation buttons on dialogs. + * + * @default 'OK' + */ + confirmationText: z.string().optional().describe('Text shown on confirmation buttons on dialogs.'), /** * Text shown on top of the steps section.