Skip to content

Commit 61a542e

Browse files
authored
feat(astro): override components to support Dialog (#345)
1 parent fb5c38c commit 61a542e

20 files changed

+250
-62
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ lerna-debug.log*
88
node_modules
99
dist
1010
dist-ssr
11+
e2e/dist-*
1112
*.local
1213
!.vscode/extensions.json
1314
.idea
Loading

docs/tutorialkit.dev/src/content/docs/guides/overriding-components.mdx

+25
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ title: 'Overriding Components'
33
description: "Override TutorialKit's default components to fit your needs."
44
---
55
import { Image } from 'astro:assets';
6+
import uiDialog from './images/ui-dialog.png';
67
import uiTopBar from './images/ui-top-bar.png';
78

89
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
6465

6566
<slot name="login-button" />
6667
</nav>
68+
```
69+
70+
### Dialog
71+
72+
<Image src={uiDialog} alt="TutorialKit's Dialog" />
73+
74+
Component for overriding confirmation dialogs. This component has to be a React component and be the default export of that module.
75+
76+
It will receive same props that `@tutorialkit/react/dialog` supports:
77+
78+
```ts
79+
interface Props {
80+
/** Title of the dialog */
81+
title: string;
82+
83+
/** Text for the confirmation button */
84+
confirmText: string;
85+
86+
/** Callback invoked when dialog is closed */
87+
onClose: () => void;
88+
89+
/** Content of the dialog */
90+
children: ReactNode;
91+
}
6792
```

e2e/configs/override-components.ts

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import tutorialkit from '@tutorialkit/astro';
2+
import { defineConfig } from 'astro/config';
3+
4+
export default defineConfig({
5+
devToolbar: { enabled: false },
6+
server: { port: 4330 },
7+
outDir: './dist-override-components',
8+
integrations: [
9+
tutorialkit({
10+
components: {
11+
Dialog: './src/components/Dialog.tsx',
12+
TopBar: './src/components/TopBar.astro',
13+
},
14+
}),
15+
],
16+
});

e2e/package.json

+4-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
"scripts": {
66
"dev": "astro dev",
77
"preview": "astro build && astro preview",
8+
"dev:override-components": "astro dev --config ./configs/override-components.ts",
9+
"preview:override-components": "astro build --config ./configs/override-components.ts && astro preview --config ./configs/override-components.ts",
810
"test": "playwright test",
911
"test:ui": "pnpm run test --ui"
1012
},
@@ -18,8 +20,9 @@
1820
"@tutorialkit/runtime": "workspace:*",
1921
"@tutorialkit/theme": "workspace:*",
2022
"@tutorialkit/types": "workspace:*",
21-
"@types/react": "^18.3.3",
2223
"@types/node": "^22.2.0",
24+
"@types/react": "^18.3.3",
25+
"@types/react-dom": "^18.3.0",
2326
"@unocss/reset": "^0.59.4",
2427
"@unocss/transformer-directives": "^0.62.0",
2528
"astro": "^4.15.0",

e2e/playwright.config.ts

+29-10
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,36 @@
11
import { defineConfig } from '@playwright/test';
22

33
export default defineConfig({
4+
projects: [
5+
{
6+
name: 'Default',
7+
testMatch: 'test/*.test.ts',
8+
testIgnore: 'test/*.override-components.test.ts',
9+
use: { baseURL: 'http://localhost:4329' },
10+
},
11+
{
12+
name: 'Override Components',
13+
testMatch: 'test/*.override-components.test.ts',
14+
use: { baseURL: 'http://localhost:4330' },
15+
},
16+
],
17+
webServer: [
18+
{
19+
command: 'pnpm preview',
20+
url: 'http://localhost:4329',
21+
reuseExistingServer: !process.env.CI,
22+
stdout: 'ignore',
23+
stderr: 'pipe',
24+
},
25+
{
26+
command: 'pnpm preview:override-components',
27+
url: 'http://localhost:4330',
28+
reuseExistingServer: !process.env.CI,
29+
stdout: 'ignore',
30+
stderr: 'pipe',
31+
},
32+
],
433
expect: {
534
timeout: process.env.CI ? 30_000 : 10_000,
635
},
7-
use: {
8-
baseURL: 'http://localhost:4329',
9-
},
10-
webServer: {
11-
command: 'pnpm preview',
12-
url: 'http://localhost:4329',
13-
reuseExistingServer: !process.env.CI,
14-
stdout: 'ignore',
15-
stderr: 'pipe',
16-
},
1736
});

e2e/src/components/Dialog.tsx

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type DialogType from '@tutorialkit/react/dialog';
2+
import type { ComponentProps } from 'react';
3+
import { createPortal } from 'react-dom';
4+
5+
export default function Dialog({ title, confirmText, onClose, children }: ComponentProps<typeof DialogType>) {
6+
return createPortal(
7+
<div role="dialog" className="fixed z-11 inset-50 color-tk-text-warning bg-tk-background-accent p-10 z-99">
8+
<h2>Custom Dialog</h2>
9+
<h3>{title}</h3>
10+
11+
{children}
12+
13+
<button className="mt2 p2 border border-tk-border-warning rounded" onClick={onClose}>
14+
{confirmText}
15+
</button>
16+
</div>,
17+
document.body,
18+
);
19+
}

e2e/src/components/TopBar.astro

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<nav
2+
class="bg-tk-elements-topBar-backgroundColor border-b border-tk-elements-app-borderColor flex gap-1 max-w-full items-center p-3 px-4 min-h-[56px]"
3+
>
4+
<div class="flex flex-1">
5+
<slot name="logo" />
6+
</div>
7+
8+
<div class="mr-2 color-tk-text-primary">Custom Top Bar Mounted</div>
9+
10+
<div class="mr-2">
11+
<slot name="open-in-stackblitz-link" />
12+
</div>
13+
14+
<div>
15+
<slot name="theme-switch" />
16+
</div>
17+
18+
<div>
19+
<slot name="login-button" />
20+
</div>
21+
</nav>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
const BASE_URL = '/tests/file-tree';
4+
5+
test('developer can override dialog in File Tree', async ({ page }) => {
6+
await page.goto(`${BASE_URL}/allow-edits-glob`);
7+
await expect(page.getByRole('heading', { level: 1, name: 'File Tree test - Allow Edits Glob' })).toBeVisible();
8+
9+
await page.getByRole('button', { name: 'first-level' }).click({ button: 'right' });
10+
await page.getByRole('menuitem', { name: `Create file` }).click();
11+
12+
await page.locator('*:focus').fill('new-file.js');
13+
await page.locator('*:focus').press('Enter');
14+
15+
const dialog = page.getByRole('dialog');
16+
await expect(dialog.getByRole('heading', { level: 2, name: 'Custom Dialog' })).toBeVisible();
17+
18+
// default elements should also be visible
19+
await expect(dialog.getByText('Created files and folders must match following patterns:')).toBeVisible();
20+
await expect(dialog.getByRole('listitem').nth(0)).toHaveText('/*');
21+
await expect(dialog.getByRole('listitem').nth(1)).toHaveText('/first-level/allowed-filename-only.js');
22+
await expect(dialog.getByRole('listitem').nth(2)).toHaveText('**/second-level/**');
23+
24+
await dialog.getByRole('button', { name: 'OK' }).click();
25+
await expect(dialog).not.toBeVisible();
26+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
test('developer can override TopBar', async ({ page }) => {
4+
await page.goto('/');
5+
6+
const nav = page.getByRole('navigation');
7+
await expect(nav.getByText('Custom Top Bar Mounted')).toBeVisible();
8+
9+
// default elements should also be visible
10+
await expect(nav.getByRole('button', { name: 'Open in StackBlitz' })).toBeVisible();
11+
await expect(nav.getByRole('button', { name: 'Toggle Theme' })).toBeVisible();
12+
});

e2e/tsconfig.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,6 @@
88
"@*": ["src/*"]
99
}
1010
},
11-
"include": ["src", "./*.ts"],
11+
"include": ["src", "./*.ts", "configs/astro.config.override-components.ts"],
1212
"exclude": ["node_modules", "dist"]
1313
}

packages/astro/src/default/components/WorkspacePanelWrapper.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react';
22
import { WorkspacePanel } from '@tutorialkit/react';
33
import type { Lesson } from '@tutorialkit/types';
44
import { useEffect } from 'react';
5+
import { Dialog } from 'tutorialkit:override-components';
56
import { themeStore } from '../stores/theme-store.js';
67
import { tutorialStore } from './webcontainer.js';
78

@@ -20,5 +21,5 @@ export function WorkspacePanelWrapper({ lesson }: Props) {
2021
tutorialStore.setLesson(lesson, { ssr: import.meta.env.SSR });
2122
}
2223

23-
return <WorkspacePanel tutorialStore={tutorialStore} theme={theme} />;
24+
return <WorkspacePanel dialog={Dialog} tutorialStore={tutorialStore} theme={theme} />;
2425
}

packages/astro/src/default/env-default.d.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ interface WebContainerConfig {
99

1010
declare module 'tutorialkit:override-components' {
1111
const topBar: typeof import('./src/default/components/TopBar.astro').default;
12+
const dialog: typeof import('@tutorialkit/react/dialog').default;
1213

13-
export { topBar as TopBar };
14+
export { topBar as TopBar, dialog as Dialog };
1415
}
1516

1617
declare const __ENTERPRISE__: boolean;

packages/astro/src/vite-plugins/override-components.ts

+14-1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
* tutorialkit({
1818
* components: {
1919
* TopBar: './CustomTopBar.astro',
20+
* Dialog: './CustomDialog.tsx',
2021
* },
2122
* }),
2223
* ],
@@ -29,20 +30,30 @@ export interface OverrideComponentsOptions {
2930
/**
3031
* Component for overriding the top bar.
3132
*
32-
* This component has 3 slots that are used to pass TutorialKit's default components:
33+
* This component has slots that are used to pass TutorialKit's default components:
3334
* - `logo`: Logo of the application
35+
* - `open-in-stackblitz-link`: Link for opening current lesson in StackBlitz
3436
* - `theme-switch`: Switch for changing the theme
3537
* - `login-button`: For StackBlitz Enterprise user, the login button
3638
*
3739
* Usage:
3840
*
3941
* ```jsx
4042
* <slot name="logo" />
43+
* <slot name="open-in-stackblitz-link" />
4144
* <slot name="theme-switch" />
4245
* <slot name="login-button" />
4346
* ```
4447
*/
4548
TopBar?: string;
49+
50+
/**
51+
* Component for overriding confirmation dialogs.
52+
*
53+
* This component has to be a React component and be the default export of that module.
54+
* It will receive same props that `@tutorialkit/react/dialog` supports.
55+
*/
56+
Dialog?: string;
4657
}
4758

4859
interface Options {
@@ -66,9 +77,11 @@ export function overrideComponents({ components, defaultRoutes }: Options): Vite
6677
async load(id) {
6778
if (id === resolvedId) {
6879
const topBar = components?.TopBar || resolveDefaultTopBar(defaultRoutes);
80+
const dialog = components?.Dialog || '@tutorialkit/react/dialog';
6981

7082
return `
7183
export { default as TopBar } from '${topBar}';
84+
export { default as Dialog } from '${dialog}';
7285
`;
7386
}
7487

packages/react/package.json

+6
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@
4444
"default": "./dist/core/Terminal/index.js"
4545
}
4646
},
47+
"./dialog": {
48+
"import": {
49+
"types": "./dist/core/Dialog.d.ts",
50+
"default": "./dist/core/Dialog.js"
51+
}
52+
},
4753
"./package.json": "./package.json"
4854
},
4955
"files": [

packages/react/src/Panels/WorkspacePanel.tsx

+13-9
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { TutorialStore } from '@tutorialkit/runtime';
33
import type { I18n } from '@tutorialkit/types';
44
import { useCallback, useEffect, useRef, useState, type ComponentProps } from 'react';
55
import { Panel, PanelGroup, PanelResizeHandle, type ImperativePanelHandle } from 'react-resizable-panels';
6+
import { DialogProvider } from '../core/Dialog.js';
67
import type { Theme } from '../core/types.js';
78
import resizePanelStyles from '../styles/resize-panel.module.css';
89
import { classNames } from '../utils/classnames.js';
@@ -17,9 +18,10 @@ type FileTreeChangeEvent = Parameters<NonNullable<ComponentProps<typeof EditorPa
1718
interface Props {
1819
tutorialStore: TutorialStore;
1920
theme: Theme;
21+
dialog: NonNullable<ComponentProps<typeof DialogProvider>['value']>;
2022
}
2123

22-
interface PanelProps extends Props {
24+
interface PanelProps extends Omit<Props, 'dialog'> {
2325
hasEditor: boolean;
2426
hasPreviews: boolean;
2527
hideTerminalPanel: boolean;
@@ -33,7 +35,7 @@ interface TerminalProps extends PanelProps {
3335
/**
3436
* This component is the orchestrator between various interactive components.
3537
*/
36-
export function WorkspacePanel({ tutorialStore, theme }: Props) {
38+
export function WorkspacePanel({ tutorialStore, theme, dialog }: Props) {
3739
/**
3840
* Re-render when lesson changes.
3941
* The `tutorialStore.hasEditor()` and other methods below access
@@ -50,13 +52,15 @@ export function WorkspacePanel({ tutorialStore, theme }: Props) {
5052

5153
return (
5254
<PanelGroup className={resizePanelStyles.PanelGroup} direction="vertical">
53-
<EditorSection
54-
theme={theme}
55-
tutorialStore={tutorialStore}
56-
hasEditor={hasEditor}
57-
hasPreviews={hasPreviews}
58-
hideTerminalPanel={hideTerminalPanel}
59-
/>
55+
<DialogProvider value={dialog}>
56+
<EditorSection
57+
theme={theme}
58+
tutorialStore={tutorialStore}
59+
hasEditor={hasEditor}
60+
hasPreviews={hasPreviews}
61+
hideTerminalPanel={hideTerminalPanel}
62+
/>
63+
</DialogProvider>
6064

6165
<PanelResizeHandle
6266
className={resizePanelStyles.PanelResizeHandle}

0 commit comments

Comments
 (0)