Skip to content

Commit

Permalink
feat(astro): override components to support Dialog
Browse files Browse the repository at this point in the history
  • Loading branch information
AriPerkkio committed Sep 23, 2024
1 parent c8ce182 commit 0be55f0
Show file tree
Hide file tree
Showing 21 changed files with 242 additions and 60 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ lerna-debug.log*
node_modules
dist
dist-ssr
e2e/dist-*
*.local
!.vscode/extensions.json
.idea
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -64,4 +65,12 @@ When overriding `TopBar` you can place TutorialKit's default components using fo

<slot name="login-button" />
</nav>
```
```

### Dialog

<Image src={uiDialog} alt="TutorialKit's Dialog" />

Component for overriding confirmation dialogs. This component has to be a React component.

It will receive same props that `@tutorialkit/react/dialog` supports.
16 changes: 16 additions & 0 deletions e2e/configs/override-components.ts
Original file line number Diff line number Diff line change
@@ -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',
},
}),
],
});
5 changes: 4 additions & 1 deletion e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand All @@ -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",
Expand Down
39 changes: 29 additions & 10 deletions e2e/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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',
},
});
19 changes: 19 additions & 0 deletions e2e/src/components/Dialog.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof DialogType>) {
return createPortal(
<div role="dialog" className="fixed inset-50 color-tk-text-warning bg-tk-background-accent p-10 z-99">
<h2>Custom Dialog</h2>
<h3>{title}</h3>

{children}

<button className="mt2 p2 border border-tk-border-warning rounded" onClick={onClose}>
{confirmText}
</button>
</div>,
document.body,
);
}
21 changes: 21 additions & 0 deletions e2e/src/components/TopBar.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<nav
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]"
>
<div class="flex flex-1">
<slot name="logo" />
</div>

<div class="mr-2 color-tk-text-primary">Custom Top Bar Mounted</div>

<div class="mr-2">
<slot name="open-in-stackblitz-link" />
</div>

<div>
<slot name="theme-switch" />
</div>

<div>
<slot name="login-button" />
</div>
</nav>
26 changes: 26 additions & 0 deletions e2e/test/dialog.override-components.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
12 changes: 12 additions & 0 deletions e2e/test/topbar.override-components.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
2 changes: 1 addition & 1 deletion e2e/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@
"@*": ["src/*"]
}
},
"include": ["src", "./*.ts"],
"include": ["src", "./*.ts", "configs/astro.config.override-components.ts"],
"exclude": ["node_modules", "dist"]
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';

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

return <WorkspacePanel tutorialStore={tutorialStore} theme={theme} />;
return <WorkspacePanel dialog={Dialog} tutorialStore={tutorialStore} theme={theme} />;
}
3 changes: 2 additions & 1 deletion packages/astro/src/default/env-default.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
15 changes: 14 additions & 1 deletion packages/astro/src/vite-plugins/override-components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
* tutorialkit({
* components: {
* TopBar: './CustomTopBar.astro',
* Dialog: './CustomDialog.tsx',
* },
* }),
* ],
Expand All @@ -29,20 +30,30 @@ 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
*
* Usage:
*
* ```jsx
* <slot name="logo" />
* <slot name="open-in-stackblitz-link" />
* <slot name="theme-switch" />
* <slot name="login-button" />
* ```
*/
TopBar?: string;

/**
* Component for overriding confirmation dialogs.
*
* This component has to be a React component.
* It will receive same props that `@tutorialkit/react/dialog` supports.
*/
Dialog?: string;
}

interface Options {
Expand All @@ -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}';
`;
}

Expand Down
6 changes: 6 additions & 0 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@
"default": "./dist/core/Terminal/index.js"
}
},
"./dialog": {
"import": {
"types": "./dist/core/Dialog/Dialog.d.ts",
"default": "./dist/core/Dialog/Dialog.js"
}
},
"./package.json": "./package.json"
},
"files": [
Expand Down
20 changes: 12 additions & 8 deletions packages/react/src/Panels/WorkspacePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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/DialogProvider.js';
import type { Theme } from '../core/types.js';
import resizePanelStyles from '../styles/resize-panel.module.css';
import { classNames } from '../utils/classnames.js';
Expand All @@ -17,6 +18,7 @@ type FileTreeChangeEvent = Parameters<NonNullable<ComponentProps<typeof EditorPa
interface Props {
tutorialStore: TutorialStore;
theme: Theme;
dialog?: ComponentProps<typeof DialogProvider>['value'];
}

interface PanelProps extends Props {
Expand All @@ -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
Expand All @@ -50,13 +52,15 @@ export function WorkspacePanel({ tutorialStore, theme }: Props) {

return (
<PanelGroup className={resizePanelStyles.PanelGroup} direction="vertical">
<EditorSection
theme={theme}
tutorialStore={tutorialStore}
hasEditor={hasEditor}
hasPreviews={hasPreviews}
hideTerminalPanel={hideTerminalPanel}
/>
<DialogProvider value={dialog}>
<EditorSection
theme={theme}
tutorialStore={tutorialStore}
hasEditor={hasEditor}
hasPreviews={hasPreviews}
hideTerminalPanel={hideTerminalPanel}
/>
</DialogProvider>

<PanelResizeHandle
className={resizePanelStyles.PanelResizeHandle}
Expand Down
40 changes: 7 additions & 33 deletions packages/react/src/core/ContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Root, Portal, Content, Item, Trigger } from '@radix-ui/react-context-menu';
import * as RadixDialog from '@radix-ui/react-dialog';
import { DEFAULT_LOCALIZATION, type FileDescriptor, type I18n } from '@tutorialkit/types';
import picomatch from 'picomatch/posix';
import { useRef, useState, type ComponentProps, type ReactNode } from 'react';
import { Button } from '../Button.js';
import { useRef, useState, type ComponentProps } from 'react';
import { classNames } from '../utils/classnames.js';
import DefaultDialog from './Dialog/Dialog.js';
import { useDialog } from './Dialog/DialogProvider.js';

interface FileChangeEvent {
type: FileDescriptor['type'];
Expand Down Expand Up @@ -172,34 +172,8 @@ function MenuItem({ icon, children, ...props }: { icon: string } & ComponentProp
);
}

function Dialog({
title,
confirmText,
onClose,
children,
}: {
title: string;
confirmText: string;
onClose: () => void;
children: ReactNode;
}) {
return (
<RadixDialog.Root open={true} onOpenChange={(open) => !open && onClose()}>
<RadixDialog.Portal>
<RadixDialog.Overlay className="fixed inset-0 opacity-50 bg-black" />

<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-primary">
<div className="relative py-4 px-10">
<RadixDialog.Title className="text-6">{title}</RadixDialog.Title>

<div className="my-4">{children}</div>

<RadixDialog.Close asChild>
<Button className="min-w-20 justify-center">{confirmText}</Button>
</RadixDialog.Close>
</div>
</RadixDialog.Content>
</RadixDialog.Portal>
</RadixDialog.Root>
);
function Dialog(props: ComponentProps<typeof DefaultDialog>) {
const Component = useDialog() || DefaultDialog;

return <Component {...props} />;
}
Loading

0 comments on commit 0be55f0

Please sign in to comment.