diff --git a/.gitignore b/.gitignore index 130572d6..a903f1ee 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ lerna-debug.log* node_modules dist dist-ssr +e2e/dist-* *.local !.vscode/extensions.json .idea diff --git a/docs/tutorialkit.dev/src/content/docs/guides/images/ui-dialog.png b/docs/tutorialkit.dev/src/content/docs/guides/images/ui-dialog.png new file mode 100644 index 00000000..d18a10a6 Binary files /dev/null and b/docs/tutorialkit.dev/src/content/docs/guides/images/ui-dialog.png differ diff --git a/docs/tutorialkit.dev/src/content/docs/guides/overriding-components.mdx b/docs/tutorialkit.dev/src/content/docs/guides/overriding-components.mdx index 1c94dfdf..52c09114 100644 --- a/docs/tutorialkit.dev/src/content/docs/guides/overriding-components.mdx +++ b/docs/tutorialkit.dev/src/content/docs/guides/overriding-components.mdx @@ -3,6 +3,7 @@ title: 'Overriding Components' description: "Override TutorialKit's default components to fit your needs." --- import { Image } from 'astro:assets'; +import uiDialog from './images/ui-dialog.png'; import uiTopBar from './images/ui-top-bar.png'; TutorialKit's default components are customizable with [theming](/reference/theming/) options. @@ -64,4 +65,28 @@ When overriding `TopBar` you can place TutorialKit's default components using fo +``` + +### Dialog + +TutorialKit's Dialog + +Component for overriding confirmation dialogs. This component has to be a React component and be the default export of that module. + +It will receive same props that `@tutorialkit/react/dialog` supports: + +```ts +interface Props { + /** Title of the dialog */ + title: string; + + /** Text for the confirmation button */ + confirmText: string; + + /** Callback invoked when dialog is closed */ + onClose: () => void; + + /** Content of the dialog */ + children: ReactNode; +} ``` \ No newline at end of file diff --git a/e2e/configs/override-components.ts b/e2e/configs/override-components.ts new file mode 100644 index 00000000..120234dc --- /dev/null +++ b/e2e/configs/override-components.ts @@ -0,0 +1,16 @@ +import tutorialkit from '@tutorialkit/astro'; +import { defineConfig } from 'astro/config'; + +export default defineConfig({ + devToolbar: { enabled: false }, + server: { port: 4330 }, + outDir: './dist-override-components', + integrations: [ + tutorialkit({ + components: { + Dialog: './src/components/Dialog.tsx', + TopBar: './src/components/TopBar.astro', + }, + }), + ], +}); diff --git a/e2e/package.json b/e2e/package.json index b94f67b6..5f4e5a29 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -5,6 +5,8 @@ "scripts": { "dev": "astro dev", "preview": "astro build && astro preview", + "dev:override-components": "astro dev --config ./configs/override-components.ts", + "preview:override-components": "astro build --config ./configs/override-components.ts && astro preview --config ./configs/override-components.ts", "test": "playwright test", "test:ui": "pnpm run test --ui" }, @@ -18,8 +20,9 @@ "@tutorialkit/runtime": "workspace:*", "@tutorialkit/theme": "workspace:*", "@tutorialkit/types": "workspace:*", - "@types/react": "^18.3.3", "@types/node": "^22.2.0", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", "@unocss/reset": "^0.59.4", "@unocss/transformer-directives": "^0.62.0", "astro": "^4.15.0", diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 3c09769f..a9654578 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -1,17 +1,36 @@ import { defineConfig } from '@playwright/test'; export default defineConfig({ + projects: [ + { + name: 'Default', + testMatch: 'test/*.test.ts', + testIgnore: 'test/*.override-components.test.ts', + use: { baseURL: 'http://localhost:4329' }, + }, + { + name: 'Override Components', + testMatch: 'test/*.override-components.test.ts', + use: { baseURL: 'http://localhost:4330' }, + }, + ], + webServer: [ + { + command: 'pnpm preview', + url: 'http://localhost:4329', + reuseExistingServer: !process.env.CI, + stdout: 'ignore', + stderr: 'pipe', + }, + { + command: 'pnpm preview:override-components', + url: 'http://localhost:4330', + reuseExistingServer: !process.env.CI, + stdout: 'ignore', + stderr: 'pipe', + }, + ], expect: { timeout: process.env.CI ? 30_000 : 10_000, }, - use: { - baseURL: 'http://localhost:4329', - }, - webServer: { - command: 'pnpm preview', - url: 'http://localhost:4329', - reuseExistingServer: !process.env.CI, - stdout: 'ignore', - stderr: 'pipe', - }, }); diff --git a/e2e/src/components/Dialog.tsx b/e2e/src/components/Dialog.tsx new file mode 100644 index 00000000..21a5d366 --- /dev/null +++ b/e2e/src/components/Dialog.tsx @@ -0,0 +1,19 @@ +import type DialogType from '@tutorialkit/react/dialog'; +import type { ComponentProps } from 'react'; +import { createPortal } from 'react-dom'; + +export default function Dialog({ title, confirmText, onClose, children }: ComponentProps) { + return createPortal( +
+

Custom Dialog

+

{title}

+ + {children} + + +
, + document.body, + ); +} diff --git a/e2e/src/components/TopBar.astro b/e2e/src/components/TopBar.astro new file mode 100644 index 00000000..09961074 --- /dev/null +++ b/e2e/src/components/TopBar.astro @@ -0,0 +1,21 @@ + diff --git a/e2e/test/dialog.override-components.test.ts b/e2e/test/dialog.override-components.test.ts new file mode 100644 index 00000000..9c38473c --- /dev/null +++ b/e2e/test/dialog.override-components.test.ts @@ -0,0 +1,26 @@ +import { test, expect } from '@playwright/test'; + +const BASE_URL = '/tests/file-tree'; + +test('developer can override dialog in File Tree', 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(); + + await page.getByRole('button', { name: 'first-level' }).click({ button: 'right' }); + await page.getByRole('menuitem', { name: `Create file` }).click(); + + await page.locator('*:focus').fill('new-file.js'); + await page.locator('*:focus').press('Enter'); + + const dialog = page.getByRole('dialog'); + await expect(dialog.getByRole('heading', { level: 2, name: 'Custom Dialog' })).toBeVisible(); + + // default elements should also be visible + 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: 'OK' }).click(); + await expect(dialog).not.toBeVisible(); +}); diff --git a/e2e/test/topbar.override-components.test.ts b/e2e/test/topbar.override-components.test.ts new file mode 100644 index 00000000..cb34ba1c --- /dev/null +++ b/e2e/test/topbar.override-components.test.ts @@ -0,0 +1,12 @@ +import { test, expect } from '@playwright/test'; + +test('developer can override TopBar', async ({ page }) => { + await page.goto('/'); + + const nav = page.getByRole('navigation'); + await expect(nav.getByText('Custom Top Bar Mounted')).toBeVisible(); + + // default elements should also be visible + await expect(nav.getByRole('button', { name: 'Open in StackBlitz' })).toBeVisible(); + await expect(nav.getByRole('button', { name: 'Toggle Theme' })).toBeVisible(); +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json index 272d33e8..085bc833 100644 --- a/e2e/tsconfig.json +++ b/e2e/tsconfig.json @@ -8,6 +8,6 @@ "@*": ["src/*"] } }, - "include": ["src", "./*.ts"], + "include": ["src", "./*.ts", "configs/astro.config.override-components.ts"], "exclude": ["node_modules", "dist"] } diff --git a/packages/astro/src/default/components/WorkspacePanelWrapper.tsx b/packages/astro/src/default/components/WorkspacePanelWrapper.tsx index d33cfd84..1fdbc4a4 100644 --- a/packages/astro/src/default/components/WorkspacePanelWrapper.tsx +++ b/packages/astro/src/default/components/WorkspacePanelWrapper.tsx @@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'; import { WorkspacePanel } from '@tutorialkit/react'; import type { Lesson } from '@tutorialkit/types'; import { useEffect } from 'react'; +import { Dialog } from 'tutorialkit:override-components'; import { themeStore } from '../stores/theme-store.js'; import { tutorialStore } from './webcontainer.js'; @@ -20,5 +21,5 @@ export function WorkspacePanelWrapper({ lesson }: Props) { tutorialStore.setLesson(lesson, { ssr: import.meta.env.SSR }); } - return ; + return ; } diff --git a/packages/astro/src/default/env-default.d.ts b/packages/astro/src/default/env-default.d.ts index f61727d1..101487bf 100644 --- a/packages/astro/src/default/env-default.d.ts +++ b/packages/astro/src/default/env-default.d.ts @@ -9,8 +9,9 @@ interface WebContainerConfig { declare module 'tutorialkit:override-components' { const topBar: typeof import('./src/default/components/TopBar.astro').default; + const dialog: typeof import('@tutorialkit/react/dialog').default; - export { topBar as TopBar }; + export { topBar as TopBar, dialog as Dialog }; } declare const __ENTERPRISE__: boolean; diff --git a/packages/astro/src/vite-plugins/override-components.ts b/packages/astro/src/vite-plugins/override-components.ts index 15ad4515..b71a2ea8 100644 --- a/packages/astro/src/vite-plugins/override-components.ts +++ b/packages/astro/src/vite-plugins/override-components.ts @@ -17,6 +17,7 @@ * tutorialkit({ * components: { * TopBar: './CustomTopBar.astro', + * Dialog: './CustomDialog.tsx', * }, * }), * ], @@ -29,8 +30,9 @@ export interface OverrideComponentsOptions { /** * Component for overriding the top bar. * - * This component has 3 slots that are used to pass TutorialKit's default components: + * This component has slots that are used to pass TutorialKit's default components: * - `logo`: Logo of the application + * - `open-in-stackblitz-link`: Link for opening current lesson in StackBlitz * - `theme-switch`: Switch for changing the theme * - `login-button`: For StackBlitz Enterprise user, the login button * @@ -38,11 +40,20 @@ export interface OverrideComponentsOptions { * * ```jsx * + * * * * ``` */ TopBar?: string; + + /** + * Component for overriding confirmation dialogs. + * + * This component has to be a React component and be the default export of that module. + * It will receive same props that `@tutorialkit/react/dialog` supports. + */ + Dialog?: string; } interface Options { @@ -66,9 +77,11 @@ export function overrideComponents({ components, defaultRoutes }: Options): Vite async load(id) { if (id === resolvedId) { const topBar = components?.TopBar || resolveDefaultTopBar(defaultRoutes); + const dialog = components?.Dialog || '@tutorialkit/react/dialog'; return ` export { default as TopBar } from '${topBar}'; + export { default as Dialog } from '${dialog}'; `; } diff --git a/packages/react/package.json b/packages/react/package.json index 0d5b8dda..624cdc2e 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -44,6 +44,12 @@ "default": "./dist/core/Terminal/index.js" } }, + "./dialog": { + "import": { + "types": "./dist/core/Dialog.d.ts", + "default": "./dist/core/Dialog.js" + } + }, "./package.json": "./package.json" }, "files": [ diff --git a/packages/react/src/Panels/WorkspacePanel.tsx b/packages/react/src/Panels/WorkspacePanel.tsx index 7665b0ef..1ed82a0e 100644 --- a/packages/react/src/Panels/WorkspacePanel.tsx +++ b/packages/react/src/Panels/WorkspacePanel.tsx @@ -3,6 +3,7 @@ import type { TutorialStore } from '@tutorialkit/runtime'; import type { I18n } from '@tutorialkit/types'; import { useCallback, useEffect, useRef, useState, type ComponentProps } from 'react'; import { Panel, PanelGroup, PanelResizeHandle, type ImperativePanelHandle } from 'react-resizable-panels'; +import { DialogProvider } from '../core/Dialog.js'; import type { Theme } from '../core/types.js'; import resizePanelStyles from '../styles/resize-panel.module.css'; import { classNames } from '../utils/classnames.js'; @@ -17,9 +18,10 @@ type FileTreeChangeEvent = Parameters['value']>; } -interface PanelProps extends Props { +interface PanelProps extends Omit { hasEditor: boolean; hasPreviews: boolean; hideTerminalPanel: boolean; @@ -33,7 +35,7 @@ interface TerminalProps extends PanelProps { /** * This component is the orchestrator between various interactive components. */ -export function WorkspacePanel({ tutorialStore, theme }: Props) { +export function WorkspacePanel({ tutorialStore, theme, dialog }: Props) { /** * Re-render when lesson changes. * The `tutorialStore.hasEditor()` and other methods below access @@ -50,13 +52,15 @@ export function WorkspacePanel({ tutorialStore, theme }: Props) { return ( - + + + ('idle'); const inputRef = useRef(null); + const Dialog = useDialog(); if (!allowEditPatterns?.length) { return children; @@ -183,38 +183,6 @@ function MenuItem({ icon, children, ...props }: { icon: string } & ComponentProp ); } -function Dialog({ - title, - confirmText, - onClose, - children, -}: { - title: string; - confirmText: string; - onClose: () => void; - children: ReactNode; -}) { - return ( - !open && onClose()}> - - - - -
- {title} - -
{children}
- - - - -
-
-
-
- ); -} - function AllowPatternsList({ allowEditPatterns }: Required>) { return (
    1 && 'list-disc ml-4')}> diff --git a/packages/react/src/core/Dialog.tsx b/packages/react/src/core/Dialog.tsx new file mode 100644 index 00000000..d83e3e1c --- /dev/null +++ b/packages/react/src/core/Dialog.tsx @@ -0,0 +1,46 @@ +import * as RadixDialog from '@radix-ui/react-dialog'; +import { type ReactNode, createContext, useContext } from 'react'; +import { Button } from '../Button.js'; + +interface Props { + /** Title of the dialog */ + title: string; + + /** Text for the confirmation button */ + confirmText: string; + + /** Callback invoked when dialog is closed */ + onClose: () => void; + + /** Content of the dialog */ + children: ReactNode; +} + +const context = createContext(Dialog); +export const DialogProvider = context.Provider; + +export function useDialog() { + return useContext(context); +} + +export default function Dialog({ title, confirmText, onClose, children }: Props) { + return ( + !open && onClose()}> + + + + +
    + {title} + +
    {children}
    + + + + +
    +
    +
    +
    + ); +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 33dbbc25..80a5735a 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -5,5 +5,6 @@ export * from './Panels/EditorPanel.js'; export * from './Panels/PreviewPanel.js'; export * from './Panels/TerminalPanel.js'; export * from './Panels/WorkspacePanel.js'; +export { default as Dialog } from './core/Dialog.js'; export type * from './core/types.js'; export * from './utils/classnames.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ef15b05..12a26601 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -184,6 +184,9 @@ importers: '@types/react': specifier: ^18.3.3 version: 18.3.3 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.0 '@unocss/reset': specifier: ^0.59.4 version: 0.59.4 @@ -4003,7 +4006,7 @@ packages: '@unocss/core': 0.59.4 '@unocss/reset': 0.59.4 '@unocss/vite': 0.59.4(vite@5.4.2) - vite: 5.4.2(@types/node@22.4.2)(sass@1.77.6) + vite: 5.4.2(@types/node@22.4.2) transitivePeerDependencies: - rollup @@ -4202,7 +4205,7 @@ packages: chokidar: 3.6.0 fast-glob: 3.3.2 magic-string: 0.30.11 - vite: 5.4.2(@types/node@22.4.2)(sass@1.77.6) + vite: 5.4.2(@types/node@22.4.2) transitivePeerDependencies: - rollup @@ -6733,6 +6736,7 @@ packages: /immutable@4.3.6: resolution: {integrity: sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ==} + dev: true /import-fresh@3.3.0: resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} @@ -8825,6 +8829,7 @@ packages: chokidar: 3.6.0 immutable: 4.3.6 source-map-js: 1.2.0 + dev: true /sax@1.4.1: resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} @@ -9633,7 +9638,7 @@ packages: '@unocss/transformer-directives': 0.59.4 '@unocss/transformer-variant-group': 0.59.4 '@unocss/vite': 0.59.4(vite@5.4.2) - vite: 5.4.2(@types/node@22.4.2)(sass@1.77.6) + vite: 5.4.2(@types/node@22.4.2) transitivePeerDependencies: - postcss - rollup @@ -9979,6 +9984,7 @@ packages: sass: 1.77.6 optionalDependencies: fsevents: 2.3.3 + dev: true /vitefu@0.2.5(vite@5.4.2): resolution: {integrity: sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==}