Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add files via file tree #314

Merged
merged 16 commits into from
Sep 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,26 +1,32 @@
import { useState } from 'react';
import { useState, type ComponentProps } from 'react';
import FileTree from '@tutorialkit/react/core/FileTree';

export default function ExampleFileTree() {
const [selectedFile, setSelectedFile] = useState(FILES[0]);
const [files, setFiles] = useState(INITIAL_FILES);
const [selectedFile, setSelectedFile] = useState(INITIAL_FILES[0].path);

return (
<FileTree
files={FILES}
files={files}
hideRoot
className="my-file-tree"
hiddenFiles={['package-lock.json']}
selectedFile={selectedFile}
onFileSelect={setSelectedFile}
onFileChange={(event) => {
if (event.method === 'add') {
setFiles([...files, { path: event.value, type: event.type }]);
}
}}
/>
);
}

const FILES = [
'/src/index.js',
'/src/index.html',
'/src/assets/logo.svg',
'/package-lock.json',
'/package.json',
'/vite.config.js',
const INITIAL_FILES: ComponentProps<typeof FileTree>['files'] = [
{ path: '/package-lock.json', type: 'file' },
{ path: '/package.json', type: 'file' },
{ path: '/src/assets/logo.svg', type: 'file' },
{ path: '/src/index.html', type: 'file' },
{ path: '/src/index.js', type: 'file' },
{ path: '/vite.config.js', type: 'file' },
];
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ const FILES: Record<string, EditorDocument> = {
},
};

const FILE_PATHS = Object.keys(FILES);
const FILE_PATHS = Object.keys(FILES).map((path) => ({ path, type: 'file' }) as const);

function stripIndent(string: string) {
const indent = minIndent(string.slice(1));
Expand Down
30 changes: 28 additions & 2 deletions docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,34 @@ Defines which file should be opened in the [code editor](/guides/ui/#code-editor
<PropertyTable inherited type="string" />

##### `editor`
Configure whether or not the editor should be rendered. If an object is provided with `fileTree: false`, only the file tree is hidden.
<PropertyTable inherited type="boolean | { fileTree: false }" />
Configures options for the editor and its file tree. Editor can be hidden by providing `false`.
Optionally you can hide just file tree by providing `fileTree: false`.

File tree can be set to allow file editing from right clicks by setting `fileTree.allowEdits: true`.

<PropertyTable inherited type={'Editor'} />

The `Editor` type has the following shape:

```ts
type Editor =
| false
| { editor: { allowEdits: boolean } }

```

Example values:

```yaml
editor: false # Editor is hidden

editor: # Editor is visible
fileTree: false # File tree is hidden

editor: # Editor is visible
fileTree: # File tree is visible
allowEdits: true # User can add new files and folders from the file tree
```

##### `previews`
Configure which ports should be used for the previews allowing you to align the behavior with your demo application's dev server setup. If not specified, the lowest port will be used.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,12 +107,23 @@ A component to list files in a tree view.

* `onFileSelect: (file: string) => void` - A callback that will be called when a file is clicked. The path of the file that was clicked will be passed as an argument.

* `onFileChange: (event: FileChangeEvent) => void` - An optional callback that will be called when a new file or folder is created from the file tree's context menu. When callback is not passed, file tree does not allow adding new files.
```ts
interface FileChangeEvent {
type: 'file' | 'folder';
method: 'add' | 'remove' | 'rename';
value: string;
}
```

* `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.

* `scope?: string` - Every file path that does not start with this scope will be hidden.

* `i18n?: object` - Texts for file tree's components.

* `className?: string` - A class name to apply to the root element of the component.


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default 'File in first level';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default 'File in second level';
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
type: lesson
title: Allow Edits Disabled
previews: false
terminal:
panels: terminal
---

# File Tree test - Allow Edits Disabled

Option `editor.fileTree.allowEdits` has default `false` value.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default 'File in first level';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default 'File in second level';
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
type: lesson
title: Allow Edits Enabled
previews: false
editor:
fileTree:
allowEdits: true
terminal:
panels: terminal
---

# File Tree test - Allow Edits Enabled
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default 'Lesson file example.js content';
9 changes: 9 additions & 0 deletions e2e/src/content/tutorial/tests/file-tree/hidden/content.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
type: lesson
title: Hidden
editor:
fileTree: false
focus: /example.js
---

# File Tree test - Hidden
98 changes: 98 additions & 0 deletions e2e/test/file-tree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,101 @@ test('user can see cannot click solve on lessons without solution files', async
// reset-button should be immediately visible
await expect(page.getByRole('button', { name: 'Reset' })).toBeVisible();
});

// TODO: Requires #245
test.skip('user should not see hidden file tree', async ({ page }) => {
await page.goto(`${BASE_URL}/hidden`);
await expect(page.getByRole('heading', { level: 1, name: 'File Tree test - Hidden' })).toBeVisible();

await expect(page.getByText('Files')).not.toBeVisible();
await expect(page.getByRole('button', { name: 'example.js' })).not.toBeVisible();
});

test('user cannot create files or folders when lesson is not configured via allowEdits', async ({ page }) => {
await page.goto(`${BASE_URL}/allow-edits-disabled`);

await expect(page.getByTestId('file-tree-root-context-menu')).not.toBeVisible();

await page.getByRole('button', { name: 'first-level' }).click({ button: 'right' });
await expect(page.getByRole('menuitem', { name: 'Create file' })).not.toBeVisible();
});

test('user can create files', async ({ page }) => {
await page.goto(`${BASE_URL}/allow-edits-enabled`);
await expect(page.getByRole('heading', { level: 1, name: 'File Tree test - Allow Edits Enabled' })).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 });

for (const [locator, filename] of [
[page.getByTestId('file-tree-root-context-menu'), 'file-in-root.js'],
[page.getByRole('button', { name: 'first-level' }), 'file-in-first-level.js'],
[page.getByRole('button', { name: 'second-level' }), 'file-in-second-level.js'],
] as const) {
await locator.click({ button: 'right' });
await page.getByRole('menuitem', { name: 'Create file' }).click();

await page.locator('*:focus').fill(filename);
await page.locator('*:focus').press('Enter');
await expect(page.getByRole('button', { name: filename, pressed: true })).toBeVisible();
}

// verify that all files are present on file tree after last creation
await expect(page.getByRole('button', { name: 'file-in-root.js' })).toBeVisible();
await expect(page.getByRole('button', { name: 'file-in-first-level' })).toBeVisible();
await expect(page.getByRole('button', { name: 'file-in-second-level' })).toBeVisible();

// verify that files are present on file system via terminal
for (const [directory, filename] of [
['./', 'file-in-root.js'],
['./first-level', 'file-in-first-level.js'],
['./first-level/second-level', 'file-in-second-level.js'],
]) {
await terminal.fill(`clear; ls ${directory}`);
await terminal.press('Enter');

await expect(terminalOutput).toContainText(filename, { useInnerText: true });
}
});

test('user can create folders', async ({ page }) => {
await page.goto(`${BASE_URL}/allow-edits-enabled`);
await expect(page.getByRole('heading', { level: 1, name: 'File Tree test - Allow Edits Enabled' })).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 });

for (const [locator, folder] of [
[page.getByTestId('file-tree-root-context-menu'), 'folder-1'],
[page.getByRole('button', { name: 'folder-1' }), 'folder-2'],
[page.getByRole('button', { name: 'folder-2' }), 'folder-3'],
] as const) {
await locator.click({ button: 'right' });
await page.getByRole('menuitem', { name: 'Create folder' }).click();

await page.locator('*:focus').fill(folder);
await page.locator('*:focus').press('Enter');
await expect(page.getByRole('button', { name: folder })).toBeVisible();
}

// verify that all folders are present on file tree after last creation
await expect(page.getByRole('button', { name: 'folder-1' })).toBeVisible();
await expect(page.getByRole('button', { name: 'folder-2' })).toBeVisible();
await expect(page.getByRole('button', { name: 'folder-3' })).toBeVisible();

// verify that files are present on file system via terminal
for (const [directory, folder] of [
['./', 'folder-1'],
['./folder-1', 'folder-2'],
['./folder-1/folder-2', 'folder-3'],
]) {
await terminal.fill(`clear; ls ${directory}`);
await terminal.press('Enter');

await expect(terminalOutput).toContainText(folder, { useInnerText: true });
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ export const DEFAULT_LOCALIZATION = {
editPageText: 'Edit this page',
webcontainerLinkText: 'Powered by WebContainers',
filesTitleText: 'Files',
fileTreeCreateFileText: 'Create file',
fileTreeCreateFolderText: 'Create folder',
prepareEnvironmentTitleText: 'Preparing Environment',
defaultPreviewTitleText: 'Preview',
reloadPreviewTitle: 'Reload Preview',
toggleTerminalButtonText: 'Toggle Terminal',
solveButtonText: 'Solve',
resetButtonText: 'Reset',
} satisfies Lesson['data']['i18n'];
} satisfies Required<Lesson['data']['i18n']>;
AriPerkkio marked this conversation as resolved.
Show resolved Hide resolved
1 change: 1 addition & 0 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
"@lezer/lr": "^1.0.0",
"@nanostores/react": "0.7.2",
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-context-menu": "^2.2.1",
"@replit/codemirror-lang-svelte": "^6.0.0",
"@tutorialkit/runtime": "workspace:*",
"@tutorialkit/theme": "workspace:*",
Expand Down
8 changes: 6 additions & 2 deletions packages/react/src/Panels/EditorPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { I18n } from '@tutorialkit/types';
import { useEffect, useRef } from 'react';
import { useEffect, useRef, type ComponentProps } from 'react';
import { Panel, PanelGroup, PanelResizeHandle, type ImperativePanelHandle } from 'react-resizable-panels';
import {
CodeMirrorEditor,
Expand All @@ -17,7 +17,7 @@ const DEFAULT_FILE_TREE_SIZE = 25;
interface Props {
theme: Theme;
id: unknown;
files: string[];
files: ComponentProps<typeof FileTree>['files'];
i18n: I18n;
hideRoot?: boolean;
fileTreeScope?: string;
Expand All @@ -29,6 +29,7 @@ interface Props {
onEditorScroll?: OnEditorScroll;
onHelpClick?: () => void;
onFileSelect?: (value?: string) => void;
onFileTreeChange?: ComponentProps<typeof FileTree>['onFileChange'];
}

export function EditorPanel({
Expand All @@ -46,6 +47,7 @@ export function EditorPanel({
onEditorScroll,
onHelpClick,
onFileSelect,
onFileTreeChange,
}: Props) {
const fileTreePanelRef = useRef<ImperativePanelHandle>(null);

Expand Down Expand Up @@ -76,11 +78,13 @@ export function EditorPanel({
</div>
<FileTree
className="flex-grow py-2 border-r border-tk-elements-app-borderColor text-sm"
i18n={i18n}
selectedFile={selectedFile}
hideRoot={hideRoot ?? true}
files={files}
scope={fileTreeScope}
onFileSelect={onFileSelect}
onFileChange={onFileTreeChange}
/>
</Panel>
<PanelResizeHandle
Expand Down
21 changes: 18 additions & 3 deletions packages/react/src/Panels/WorkspacePanel.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useStore } from '@nanostores/react';
import { TutorialStore } from '@tutorialkit/runtime';
import type { TutorialStore } from '@tutorialkit/runtime';
import type { I18n } from '@tutorialkit/types';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState, type ComponentProps } from 'react';
import { Panel, PanelGroup, PanelResizeHandle, type ImperativePanelHandle } from 'react-resizable-panels';
import type { Theme } from '../core/types.js';
import resizePanelStyles from '../styles/resize-panel.module.css';
Expand All @@ -12,6 +12,8 @@ import { TerminalPanel } from './TerminalPanel.js';

const DEFAULT_TERMINAL_SIZE = 25;

type FileTreeChangeEvent = Parameters<NonNullable<ComponentProps<typeof EditorPanel>['onFileTreeChange']>>[0];

interface Props {
tutorialStore: TutorialStore;
theme: Theme;
Expand Down Expand Up @@ -96,7 +98,9 @@ function EditorSection({ theme, tutorialStore, hasEditor }: PanelProps) {
const selectedFile = useStore(tutorialStore.selectedFile);
const currentDocument = useStore(tutorialStore.currentDocument);
const lessonFullyLoaded = useStore(tutorialStore.lessonFullyLoaded);
const editorConfig = useStore(tutorialStore.editorConfig);
const storeRef = useStore(tutorialStore.ref);
const files = useStore(tutorialStore.files);

const lesson = tutorialStore.lesson!;

Expand All @@ -118,6 +122,16 @@ function EditorSection({ theme, tutorialStore, hasEditor }: PanelProps) {
}
}

function onFileTreeChange({ method, type, value }: FileTreeChangeEvent) {
if (method === 'add' && type === 'file') {
return tutorialStore.addFile(value);
}

if (method === 'add' && type === 'folder') {
return tutorialStore.addFolder(value);
}
}

useEffect(() => {
if (tutorialStore.hasSolution()) {
setHelpAction('solve');
Expand All @@ -140,12 +154,13 @@ function EditorSection({ theme, tutorialStore, hasEditor }: PanelProps) {
theme={theme}
showFileTree={tutorialStore.hasFileTree()}
editorDocument={currentDocument}
files={lesson.files[1]}
files={files}
i18n={lesson.data.i18n as I18n}
hideRoot={lesson.data.hideRoot}
helpAction={helpAction}
onHelpClick={lessonFullyLoaded ? onHelpClick : undefined}
onFileSelect={(filePath) => tutorialStore.setSelectedFile(filePath)}
onFileTreeChange={editorConfig.fileTree.allowEdits ? onFileTreeChange : undefined}
selectedFile={selectedFile}
onEditorScroll={(position) => tutorialStore.setCurrentDocumentScrollPosition(position)}
onEditorChange={(update) => tutorialStore.setCurrentDocumentContent(update.content)}
Expand Down
Loading