refactor(snippets): adopt alias/title model and markdown UX

- rename snippet identity from name to alias and add required title fields

- migrate snippet API, forms, cards, and detail views to alias/title semantics

- render markdown descriptions with short card previews and split vendor chunks
This commit is contained in:
2026-04-10 23:26:36 +03:00
parent 11db983ea6
commit 7223371fae
21 changed files with 1821 additions and 85 deletions
+2
View File
@@ -22,7 +22,9 @@
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-i18next": "^17.0.2", "react-i18next": "^17.0.2",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.14.0", "react-router-dom": "^7.14.0",
"remark-gfm": "^4.0.1",
"yaml": "^2.8.3" "yaml": "^2.8.3"
}, },
"devDependencies": { "devDependencies": {
+2 -2
View File
@@ -90,7 +90,7 @@ export const snippets = {
get(id: string): Promise<Snippet> { get(id: string): Promise<Snippet> {
return request(`/snippets/${id}`); return request(`/snippets/${id}`);
}, },
create(payload: Pick<Snippet, 'name' | 'code'> & { description?: string }): Promise<Snippet> { create(payload: Pick<Snippet, 'alias' | 'title' | 'code'> & { description?: string }): Promise<Snippet> {
return request('/snippets', { return request('/snippets', {
method: 'POST', method: 'POST',
body: JSON.stringify(payload), body: JSON.stringify(payload),
@@ -98,7 +98,7 @@ export const snippets = {
}, },
update( update(
id: string, id: string,
patch: Partial<Pick<Snippet, 'name' | 'description' | 'code'>>, patch: Partial<Pick<Snippet, 'alias' | 'title' | 'description' | 'code'>>,
): Promise<Snippet> { ): Promise<Snippet> {
return request(`/snippets/${id}`, { return request(`/snippets/${id}`, {
method: 'PATCH', method: 'PATCH',
+2 -1
View File
@@ -36,7 +36,8 @@ export interface Credential {
export interface Snippet { export interface Snippet {
id: string; id: string;
name: string; alias: string;
title: string;
description: string | null; description: string | null;
code: string; code: string;
createdAt: string; createdAt: string;
+8 -4
View File
@@ -224,16 +224,20 @@
"action_cancel": "Cancel", "action_cancel": "Cancel",
"create_title": "New Snippet", "create_title": "New Snippet",
"edit_title": "Edit Snippet", "edit_title": "Edit Snippet",
"form_name": "Name", "form_alias": "Alias",
"form_name_placeholder": "e.g. clickLoginButton", "form_alias_placeholder": "e.g. clickLoginButton",
"form_name_required": "Name is required", "form_alias_required": "Alias is required",
"form_title": "Title",
"form_title_placeholder": "e.g. Click Login Button",
"form_title_required": "Title is required",
"form_description": "Description", "form_description": "Description",
"form_description_placeholder": "What does this snippet do?", "form_description_placeholder": "What does this snippet do?",
"form_code": "Code", "form_code": "Code",
"form_code_placeholder": "await page.click('#login-btn');", "form_code_placeholder": "await page.click('#login-btn');",
"form_code_required": "Code is required", "form_code_required": "Code is required",
"field_id": "ID", "field_id": "ID",
"field_name": "Name", "field_alias": "Alias",
"field_title": "Title",
"field_description": "Description", "field_description": "Description",
"field_created": "Created", "field_created": "Created",
"field_updated": "Updated", "field_updated": "Updated",
+36 -15
View File
@@ -11,10 +11,12 @@ export function CreateSnippetPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const toast = useToast(); const toast = useToast();
const [name, setName] = useState(''); const [alias, setAlias] = useState('');
const [title, setTitle] = useState('');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [code, setCode] = useState(''); const [code, setCode] = useState('');
const [nameError, setNameError] = useState(''); const [aliasError, setAliasError] = useState('');
const [titleError, setTitleError] = useState('');
const [codeError, setCodeError] = useState(''); const [codeError, setCodeError] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -22,8 +24,12 @@ export function CreateSnippetPage() {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
let valid = true; let valid = true;
if (!name.trim()) { if (!alias.trim()) {
setNameError(t('snippets.form_name_required')); setAliasError(t('snippets.form_alias_required'));
valid = false;
}
if (!title.trim()) {
setTitleError(t('snippets.form_title_required'));
valid = false; valid = false;
} }
if (!code.trim()) { if (!code.trim()) {
@@ -35,7 +41,8 @@ export function CreateSnippetPage() {
setError(null); setError(null);
try { try {
const snippet = await snippets.create({ const snippet = await snippets.create({
name: name.trim(), alias: alias.trim(),
title: title.trim(),
description: description.trim() || undefined, description: description.trim() || undefined,
code: code.trim(), code: code.trim(),
}); });
@@ -65,23 +72,37 @@ export function CreateSnippetPage() {
<Card className={styles.formCardFull}> <Card className={styles.formCardFull}>
<div className={styles.formFields}> <div className={styles.formFields}>
<Input <Input
label={t('snippets.form_name')} label={t('snippets.form_alias')}
placeholder={t('snippets.form_name_placeholder')} placeholder={t('snippets.form_alias_placeholder')}
value={name} value={alias}
onChange={(e) => { onChange={(e) => {
setName(e.target.value); setAlias(e.target.value);
setNameError(''); setAliasError('');
}} }}
error={nameError || undefined} error={aliasError || undefined}
required required
autoFocus autoFocus
/> />
<Input <Input
label={t('snippets.form_description')} label={t('snippets.form_title')}
placeholder={t('snippets.form_description_placeholder')} placeholder={t('snippets.form_title_placeholder')}
value={description} value={title}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => {
setTitle(e.target.value);
setTitleError('');
}}
error={titleError || undefined}
required
/> />
<div className={styles.formField}>
<label className={styles.fieldLabel}>{t('snippets.form_description')}</label>
<CodeEditor
value={description}
onChange={setDescription}
language="markdown"
rows={10}
/>
</div>
<div className={styles.formField}> <div className={styles.formField}>
<label className={styles.fieldLabel}> <label className={styles.fieldLabel}>
{t('snippets.form_code')} {t('snippets.form_code')}
+39 -17
View File
@@ -14,10 +14,12 @@ export function EditSnippetPage() {
const toast = useToast(); const toast = useToast();
const [snippet, setSnippet] = useState<Snippet | null>(null); const [snippet, setSnippet] = useState<Snippet | null>(null);
const [name, setName] = useState(''); const [alias, setAlias] = useState('');
const [title, setTitle] = useState('');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [code, setCode] = useState(''); const [code, setCode] = useState('');
const [nameError, setNameError] = useState(''); const [aliasError, setAliasError] = useState('');
const [titleError, setTitleError] = useState('');
const [codeError, setCodeError] = useState(''); const [codeError, setCodeError] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -29,7 +31,8 @@ export function EditSnippetPage() {
.get(id) .get(id)
.then((s) => { .then((s) => {
setSnippet(s); setSnippet(s);
setName(s.name); setAlias(s.alias);
setTitle(s.title);
setDescription(s.description ?? ''); setDescription(s.description ?? '');
setCode(s.code); setCode(s.code);
}) })
@@ -40,8 +43,12 @@ export function EditSnippetPage() {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
let valid = true; let valid = true;
if (!name.trim()) { if (!alias.trim()) {
setNameError(t('snippets.form_name_required')); setAliasError(t('snippets.form_alias_required'));
valid = false;
}
if (!title.trim()) {
setTitleError(t('snippets.form_title_required'));
valid = false; valid = false;
} }
if (!code.trim()) { if (!code.trim()) {
@@ -53,7 +60,8 @@ export function EditSnippetPage() {
setError(null); setError(null);
try { try {
await snippets.update(id!, { await snippets.update(id!, {
name: name.trim(), alias: alias.trim(),
title: title.trim(),
description: description.trim() || undefined, description: description.trim() || undefined,
code: code.trim(), code: code.trim(),
}); });
@@ -75,7 +83,7 @@ export function EditSnippetPage() {
items={[ items={[
{ label: t('snippets.title'), icon: <Braces size={14} />, onClick: () => navigate('/snippets') }, { label: t('snippets.title'), icon: <Braces size={14} />, onClick: () => navigate('/snippets') },
{ {
label: snippet?.name ?? `#${id}`, label: snippet?.title ?? `#${id}`,
onClick: () => navigate(`/snippets/${id}`), onClick: () => navigate(`/snippets/${id}`),
}, },
{ label: t('snippets.edit_title') }, { label: t('snippets.edit_title') },
@@ -90,23 +98,37 @@ export function EditSnippetPage() {
<Card className={styles.formCardFull}> <Card className={styles.formCardFull}>
<div className={styles.formFields}> <div className={styles.formFields}>
<Input <Input
label={t('snippets.form_name')} label={t('snippets.form_alias')}
placeholder={t('snippets.form_name_placeholder')} placeholder={t('snippets.form_alias_placeholder')}
value={name} value={alias}
onChange={(e) => { onChange={(e) => {
setName(e.target.value); setAlias(e.target.value);
setNameError(''); setAliasError('');
}} }}
error={nameError || undefined} error={aliasError || undefined}
required required
autoFocus autoFocus
/> />
<Input <Input
label={t('snippets.form_description')} label={t('snippets.form_title')}
placeholder={t('snippets.form_description_placeholder')} placeholder={t('snippets.form_title_placeholder')}
value={description} value={title}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => {
setTitle(e.target.value);
setTitleError('');
}}
error={titleError || undefined}
required
/> />
<div className={styles.formField}>
<label className={styles.fieldLabel}>{t('snippets.form_description')}</label>
<CodeEditor
value={description}
onChange={setDescription}
language="markdown"
rows={10}
/>
</div>
<div className={styles.formField}> <div className={styles.formField}>
<label className={styles.fieldLabel}> <label className={styles.fieldLabel}>
{t('snippets.form_code')} {t('snippets.form_code')}
+17 -8
View File
@@ -11,6 +11,7 @@ import {
Button, Button,
Card, Card,
DescriptionList, DescriptionList,
MarkdownContent,
Timestamp, Timestamp,
UuidBadge, UuidBadge,
} from '../../ui'; } from '../../ui';
@@ -46,7 +47,7 @@ export function SnippetDetailPage() {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = `snippet-${snippet.name}.yaml`; a.download = `snippet-${snippet.alias}.yaml`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}; };
@@ -57,7 +58,7 @@ export function SnippetDetailPage() {
<Breadcrumbs <Breadcrumbs
items={[ items={[
{ label: t('snippets.title'), icon: <Braces size={14} />, onClick: () => navigate('/snippets') }, { label: t('snippets.title'), icon: <Braces size={14} />, onClick: () => navigate('/snippets') },
{ label: snippet?.name ?? `#${id}` }, { label: snippet?.title ?? `#${id}` },
]} ]}
/> />
{snippet && ( {snippet && (
@@ -91,11 +92,8 @@ export function SnippetDetailPage() {
layout="grid" layout="grid"
items={[ items={[
{ term: t('snippets.field_id'), detail: <UuidBadge id={snippet.id} /> }, { term: t('snippets.field_id'), detail: <UuidBadge id={snippet.id} /> },
{ term: t('snippets.field_name'), detail: snippet.name }, { term: t('snippets.field_alias'), detail: snippet.alias },
{ { term: t('snippets.field_title'), detail: snippet.title },
term: t('snippets.field_description'),
detail: snippet.description ?? '—',
},
{ {
term: t('snippets.field_created'), term: t('snippets.field_created'),
detail: <Timestamp value={snippet.createdAt} />, detail: <Timestamp value={snippet.createdAt} />,
@@ -108,8 +106,19 @@ export function SnippetDetailPage() {
/> />
</Card> </Card>
{snippet.description && (
<div className={styles.stepsSection}>
<div className={styles.sectionHeadingRow}>
<h2 className={styles.sectionHeading}>{t('snippets.field_description')}</h2>
</div>
<Card>
<MarkdownContent content={snippet.description} />
</Card>
</div>
)}
<div className={styles.stepsSection}> <div className={styles.stepsSection}>
<div className={styles.sectionHeader}> <div className={styles.sectionHeadingRow}>
<h2 className={styles.sectionHeading}>{t('snippets.section_code')}</h2> <h2 className={styles.sectionHeading}>{t('snippets.section_code')}</h2>
</div> </div>
<Card> <Card>
+28 -3
View File
@@ -5,16 +5,29 @@ import { Settings, Pencil, Trash2, Plus, Download, Braces } from 'lucide-react';
import { parse as yamlParse } from 'yaml'; import { parse as yamlParse } from 'yaml';
import { snippets } from '../../api'; import { snippets } from '../../api';
import type { Snippet } from '../../api'; import type { Snippet } from '../../api';
import { Breadcrumbs, Button, Card, ContextMenu, Timestamp, UuidBadge } from '../../ui'; import { Breadcrumbs, Button, Card, ContextMenu, MarkdownContent, Timestamp, UuidBadge } from '../../ui';
import styles from '../Page.module.css'; import styles from '../Page.module.css';
function firstParagraphBlocks(markdown: string, limit = 2): { preview: string; truncated: boolean } {
const blocks = markdown
.split(/\n\s*\n/g)
.map((block) => block.trim())
.filter(Boolean);
const sliced = blocks.slice(0, limit);
return {
preview: sliced.join('\n\n'),
truncated: blocks.length > limit,
};
}
function SnippetCard({ snippet, onDelete }: { snippet: Snippet; onDelete: (id: string) => void }) { function SnippetCard({ snippet, onDelete }: { snippet: Snippet; onDelete: (id: string) => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const header = ( const header = (
<div className={styles.envCardHeader}> <div className={styles.envCardHeader}>
<span className={styles.envCardName}>{snippet.name}</span> <span className={styles.envCardName}>{snippet.title}</span>
<div onClick={(e) => e.stopPropagation()}> <div onClick={(e) => e.stopPropagation()}>
<ContextMenu <ContextMenu
align="right" align="right"
@@ -56,7 +69,19 @@ function SnippetCard({ snippet, onDelete }: { snippet: Snippet; onDelete: (id: s
footer={footer} footer={footer}
onClick={() => navigate(`/snippets/${snippet.id}`)} onClick={() => navigate(`/snippets/${snippet.id}`)}
> >
{snippet.description && <p className={styles.muted}>{snippet.description}</p>} <p className={styles.muted}>
{t('snippets.field_alias')}: <code className={styles.codeInline}>{snippet.alias}</code>
</p>
{snippet.description && (() => {
const { preview, truncated } = firstParagraphBlocks(snippet.description, 2);
if (!preview) return null;
return (
<>
<MarkdownContent content={preview} />
{truncated && <p className={styles.muted}>...</p>}
</>
);
})()}
</Card> </Card>
); );
} }
@@ -0,0 +1,113 @@
.markdown {
color: var(--color-text);
line-height: 1.65;
font-size: var(--font-size-sm);
}
.markdown > :first-child {
margin-top: 0;
}
.markdown > :last-child {
margin-bottom: 0;
}
.markdown h1,
.markdown h2,
.markdown h3,
.markdown h4,
.markdown h5,
.markdown h6 {
line-height: 1.25;
margin: var(--space-5) 0 var(--space-3);
}
.markdown h1 {
font-size: var(--font-size-lg);
}
.markdown h2 {
font-size: var(--font-size-md);
}
.markdown h3,
.markdown h4,
.markdown h5,
.markdown h6 {
font-size: var(--font-size-sm);
}
.markdown p {
margin: 0 0 var(--space-3);
}
.markdown ul,
.markdown ol {
margin: 0 0 var(--space-3);
padding-left: 1.35rem;
}
.markdown li + li {
margin-top: var(--space-1);
}
.markdown blockquote {
margin: 0 0 var(--space-3);
padding: var(--space-2) var(--space-3);
border-left: 3px solid var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
border-radius: var(--radius-sm);
}
.markdown pre {
margin: 0 0 var(--space-3);
padding: var(--space-3);
border-radius: var(--radius-md);
background: var(--color-code-bg, var(--color-bg-raised));
border: var(--border-width) solid var(--color-border);
overflow-x: auto;
}
.markdown code {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.92em;
}
.markdown :not(pre) > code {
padding: 0.12em 0.4em;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-bg-raised) 60%, var(--color-secondary) 40%);
border: 1px solid var(--color-border);
}
.markdown table {
width: 100%;
border-collapse: collapse;
margin: 0 0 var(--space-3);
}
.markdown th,
.markdown td {
border: var(--border-width) solid var(--color-border);
padding: var(--space-2) var(--space-3);
text-align: left;
vertical-align: top;
}
.markdown th {
background: color-mix(in srgb, var(--color-bg-raised) 85%, var(--color-secondary) 15%);
}
.markdown a {
color: var(--color-link);
}
.markdown a:hover {
color: var(--color-link-hover);
}
.markdown hr {
border: 0;
border-top: var(--border-width) solid var(--color-border);
margin: var(--space-4) 0;
}
@@ -0,0 +1,16 @@
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import styles from './MarkdownContent.module.css';
export interface MarkdownContentProps {
content: string;
className?: string;
}
export function MarkdownContent({ content, className }: MarkdownContentProps) {
return (
<div className={`${styles.markdown}${className ? ` ${className}` : ''}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
);
}
+3
View File
@@ -45,6 +45,9 @@ export type { CodeBlockProps } from './CodeBlock/CodeBlock';
export { CodeEditor } from './CodeEditor/CodeEditor'; export { CodeEditor } from './CodeEditor/CodeEditor';
export type { CodeEditorProps } from './CodeEditor/CodeEditor'; export type { CodeEditorProps } from './CodeEditor/CodeEditor';
export { MarkdownContent } from './MarkdownContent/MarkdownContent';
export type { MarkdownContentProps } from './MarkdownContent/MarkdownContent';
export { ContextMenu } from './ContextMenu/ContextMenu'; export { ContextMenu } from './ContextMenu/ContextMenu';
export type { ContextMenuProps, ContextMenuItem } from './ContextMenu/ContextMenu'; export type { ContextMenuProps, ContextMenuItem } from './ContextMenu/ContextMenu';
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/declarations.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/index.ts","./src/api/types.ts","./src/hooks/useTheme.ts","./src/i18n/index.ts","./src/lib/hljs.ts","./src/lib/toast-events.ts","./src/pages/credential/CreateCredentialPage.tsx","./src/pages/credential/CredentialDetailPage.tsx","./src/pages/credential/CredentialsPage.tsx","./src/pages/credential/EditCredentialPage.tsx","./src/pages/environment/CreateEnvironmentPage.tsx","./src/pages/environment/EditEnvironmentPage.tsx","./src/pages/environment/EnvironmentDetailPage.tsx","./src/pages/environment/EnvironmentsPage.tsx","./src/pages/run/AllRunsPage.tsx","./src/pages/run/RunDetailPage.tsx","./src/pages/run/RunsPage.tsx","./src/pages/scenario/CreateScenarioPage.tsx","./src/pages/scenario/CreateStepPage.tsx","./src/pages/scenario/EditScenarioPage.tsx","./src/pages/scenario/EditStepPage.tsx","./src/pages/scenario/ScenarioDetailPage.tsx","./src/pages/scenario/ScenariosPage.tsx","./src/pages/session/SessionDetailPage.tsx","./src/pages/session/SessionsPage.tsx","./src/pages/snippet/CreateSnippetPage.tsx","./src/pages/snippet/EditSnippetPage.tsx","./src/pages/snippet/SnippetDetailPage.tsx","./src/pages/snippet/SnippetsPage.tsx","./src/ui/index.ts","./src/ui/AutoRefreshIndicator/AutoRefreshIndicator.tsx","./src/ui/Badge/Badge.tsx","./src/ui/Breadcrumbs/Breadcrumbs.tsx","./src/ui/Button/Button.tsx","./src/ui/Card/Card.tsx","./src/ui/CodeBlock/CodeBlock.tsx","./src/ui/CodeEditor/CodeEditor.tsx","./src/ui/ContextMenu/ContextMenu.tsx","./src/ui/DescriptionList/DescriptionList.tsx","./src/ui/Input/Input.tsx","./src/ui/Notification/Notification.tsx","./src/ui/Pagination/Pagination.tsx","./src/ui/Search/Search.tsx","./src/ui/Select/Select.tsx","./src/ui/SidePanel/SidePanel.tsx","./src/ui/Table/Table.tsx","./src/ui/Textarea/Textarea.tsx","./src/ui/ThemeSwitcher/ThemeSwitcher.tsx","./src/ui/Timestamp/Timestamp.tsx","./src/ui/Toast/ToastProvider.tsx","./src/ui/UuidBadge/UuidBadge.tsx"],"version":"5.8.3"} {"root":["./src/App.tsx","./src/declarations.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/index.ts","./src/api/types.ts","./src/hooks/useTheme.ts","./src/i18n/index.ts","./src/lib/hljs.ts","./src/lib/toast-events.ts","./src/pages/credential/CreateCredentialPage.tsx","./src/pages/credential/CredentialDetailPage.tsx","./src/pages/credential/CredentialsPage.tsx","./src/pages/credential/EditCredentialPage.tsx","./src/pages/environment/CreateEnvironmentPage.tsx","./src/pages/environment/EditEnvironmentPage.tsx","./src/pages/environment/EnvironmentDetailPage.tsx","./src/pages/environment/EnvironmentsPage.tsx","./src/pages/run/AllRunsPage.tsx","./src/pages/run/RunDetailPage.tsx","./src/pages/run/RunsPage.tsx","./src/pages/scenario/CreateScenarioPage.tsx","./src/pages/scenario/CreateStepPage.tsx","./src/pages/scenario/EditScenarioPage.tsx","./src/pages/scenario/EditStepPage.tsx","./src/pages/scenario/ScenarioDetailPage.tsx","./src/pages/scenario/ScenariosPage.tsx","./src/pages/session/SessionDetailPage.tsx","./src/pages/session/SessionsPage.tsx","./src/pages/snippet/CreateSnippetPage.tsx","./src/pages/snippet/EditSnippetPage.tsx","./src/pages/snippet/SnippetDetailPage.tsx","./src/pages/snippet/SnippetsPage.tsx","./src/ui/index.ts","./src/ui/AutoRefreshIndicator/AutoRefreshIndicator.tsx","./src/ui/Badge/Badge.tsx","./src/ui/Breadcrumbs/Breadcrumbs.tsx","./src/ui/Button/Button.tsx","./src/ui/Card/Card.tsx","./src/ui/CodeBlock/CodeBlock.tsx","./src/ui/CodeEditor/CodeEditor.tsx","./src/ui/ContextMenu/ContextMenu.tsx","./src/ui/DescriptionList/DescriptionList.tsx","./src/ui/Input/Input.tsx","./src/ui/MarkdownContent/MarkdownContent.tsx","./src/ui/Notification/Notification.tsx","./src/ui/Pagination/Pagination.tsx","./src/ui/Search/Search.tsx","./src/ui/Select/Select.tsx","./src/ui/SidePanel/SidePanel.tsx","./src/ui/Table/Table.tsx","./src/ui/Textarea/Textarea.tsx","./src/ui/ThemeSwitcher/ThemeSwitcher.tsx","./src/ui/Timestamp/Timestamp.tsx","./src/ui/Toast/ToastProvider.tsx","./src/ui/UuidBadge/UuidBadge.tsx"],"version":"5.8.3"}
+25
View File
@@ -39,6 +39,31 @@ export default defineConfig({
if (id.includes('/node_modules/monaco-editor') || id.includes('/node_modules/@monaco-editor')) { if (id.includes('/node_modules/monaco-editor') || id.includes('/node_modules/@monaco-editor')) {
return 'editor'; return 'editor';
} }
if (
id.includes('/node_modules/react-markdown')
|| id.includes('/node_modules/remark-')
|| id.includes('/node_modules/rehype-')
|| id.includes('/node_modules/unified')
|| id.includes('/node_modules/micromark')
|| id.includes('/node_modules/mdast-')
|| id.includes('/node_modules/hast-')
|| id.includes('/node_modules/unist-')
|| id.includes('/node_modules/vfile')
) {
return 'markdown';
}
if (
id.includes('/node_modules/i18next')
|| id.includes('/node_modules/react-i18next')
) {
return 'i18n';
}
if (id.includes('/node_modules/moment')) {
return 'datetime';
}
if (id.includes('/node_modules/yaml')) {
return 'yaml';
}
if (id.includes('/node_modules/')) { if (id.includes('/node_modules/')) {
return 'vendor'; return 'vendor';
} }
+1439 -7
View File
File diff suppressed because it is too large Load Diff
@@ -104,18 +104,18 @@ export class CodeExecutorService {
return value; return value;
}, },
/** /**
* Runs a named snippet by name. Snippets receive the same page/context/helpers * Runs a snippet by alias. Snippets receive the same page/context/helpers
* as regular exec code, plus any positional args you pass. * as regular exec code, plus any positional args you pass.
* *
* Example: await helpers.runSnippet('clickLoginButton', '#submit') * Example: await helpers.runSnippet('clickLoginButton', '#submit')
*/ */
runSnippet: async ( runSnippet: async (
name: string, alias: string,
...args: unknown[] ...args: unknown[]
): Promise<unknown> => { ): Promise<unknown> => {
const snippetCode = snippetMap[name]; const snippetCode = snippetMap[alias];
if (snippetCode == null) { if (snippetCode == null) {
throw new Error(`Snippet "${name}" not found`); throw new Error(`Snippet alias "${alias}" not found`);
} }
const snippetFn = new Function( const snippetFn = new Function(
"page", "page",
+6 -1
View File
@@ -5,7 +5,12 @@ export class CreateSnippetDto {
@ApiProperty({ example: "clickLoginButton" }) @ApiProperty({ example: "clickLoginButton" })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
name: string; alias: string;
@ApiProperty({ example: "Click Login Button" })
@IsString()
@IsNotEmpty()
title: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
example: "Clicks the login button and waits for navigation", example: "Clicks the login button and waits for navigation",
+14 -2
View File
@@ -18,10 +18,22 @@ export class SnippetExportDto {
@IsUUID() @IsUUID()
id?: string; id?: string;
@ApiProperty() @ApiPropertyOptional()
@IsOptional()
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
name: string; alias?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@IsNotEmpty()
title?: string;
@ApiPropertyOptional({ deprecated: true })
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()
+7 -1
View File
@@ -6,7 +6,13 @@ export class UpdateSnippetDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
name?: string; alias?: string;
@ApiPropertyOptional({ example: "Click Login Button" })
@IsOptional()
@IsString()
@IsNotEmpty()
title?: string;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()
+2 -2
View File
@@ -25,7 +25,7 @@ export class SnippetController {
@Post() @Post()
@ApiOperation({ summary: "Create a new snippet" }) @ApiOperation({ summary: "Create a new snippet" })
@ApiResponse({ status: 201, description: "Snippet created" }) @ApiResponse({ status: 201, description: "Snippet created" })
@ApiResponse({ status: 409, description: "Name already taken" }) @ApiResponse({ status: 409, description: "Alias already taken" })
create(@Body() dto: CreateSnippetDto) { create(@Body() dto: CreateSnippetDto) {
return this.snippetService.create(dto); return this.snippetService.create(dto);
} }
@@ -65,7 +65,7 @@ export class SnippetController {
@ApiOperation({ summary: "Update a snippet" }) @ApiOperation({ summary: "Update a snippet" })
@ApiResponse({ status: 200, description: "Snippet updated" }) @ApiResponse({ status: 200, description: "Snippet updated" })
@ApiResponse({ status: 404, description: "Snippet not found" }) @ApiResponse({ status: 404, description: "Snippet not found" })
@ApiResponse({ status: 409, description: "Name already taken" }) @ApiResponse({ status: 409, description: "Alias already taken" })
update( update(
@Param("id", ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateSnippetDto, @Body() dto: UpdateSnippetDto,
+7 -3
View File
@@ -11,9 +11,13 @@ export class SnippetEntity {
@PrimaryGeneratedColumn("uuid") @PrimaryGeneratedColumn("uuid")
id: string; id: string;
/** Unique identifier used to call the snippet: helpers.runSnippet('mySnippet', ...) */ /** Unique identifier used to invoke the snippet: helpers.runSnippet('mySnippet', ...) */
@Column({ unique: true }) @Column({ unique: true, nullable: true })
name: string; alias: string;
/** Human-readable title displayed in the UI. */
@Column({ default: "" })
title: string;
@Column("text", { nullable: true }) @Column("text", { nullable: true })
description: string | null; description: string | null;
+50 -14
View File
@@ -2,6 +2,7 @@ import {
ConflictException, ConflictException,
Injectable, Injectable,
NotFoundException, NotFoundException,
OnModuleInit,
} from "@nestjs/common"; } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm"; import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm"; import { Repository } from "typeorm";
@@ -14,22 +15,47 @@ import {
PaginatedResult, PaginatedResult,
} from "../common/dto/pagination.dto"; } from "../common/dto/pagination.dto";
export type SnippetOrderBy = "id" | "name" | "createdAt" | "updatedAt"; export type SnippetOrderBy = "id" | "alias" | "title" | "createdAt" | "updatedAt";
@Injectable() @Injectable()
export class SnippetService { export class SnippetService implements OnModuleInit {
constructor( constructor(
@InjectRepository(SnippetEntity) @InjectRepository(SnippetEntity)
private readonly repo: Repository<SnippetEntity>, private readonly repo: Repository<SnippetEntity>,
) {} ) {}
async onModuleInit(): Promise<void> {
// Best-effort backfill for existing DBs that still have legacy `name` values.
try {
await this.repo.query(
"UPDATE snippets SET alias = name WHERE (alias IS NULL OR alias = '') AND name IS NOT NULL",
);
} catch {
// Ignore when legacy `name` column does not exist.
}
try {
await this.repo.query(
"UPDATE snippets SET title = COALESCE(alias, name, '') WHERE title IS NULL OR title = ''",
);
} catch {
await this.repo.query(
"UPDATE snippets SET title = COALESCE(alias, '') WHERE title IS NULL OR title = ''",
);
}
}
async create(dto: CreateSnippetDto): Promise<SnippetEntity> { async create(dto: CreateSnippetDto): Promise<SnippetEntity> {
const existing = await this.repo.findOneBy({ name: dto.name }); const existing = await this.repo.findOneBy({ alias: dto.alias });
if (existing) { if (existing) {
throw new ConflictException(`Snippet "${dto.name}" already exists`); throw new ConflictException(`Snippet alias "${dto.alias}" already exists`);
} }
return this.repo.save( return this.repo.save(
this.repo.create({ ...dto, description: dto.description ?? null }), this.repo.create({
alias: dto.alias,
title: dto.title,
description: dto.description ?? null,
code: dto.code,
}),
); );
} }
@@ -56,10 +82,10 @@ export class SnippetService {
async update(id: string, dto: UpdateSnippetDto): Promise<SnippetEntity> { async update(id: string, dto: UpdateSnippetDto): Promise<SnippetEntity> {
const snippet = await this.findOne(id); const snippet = await this.findOne(id);
if (dto.name && dto.name !== snippet.name) { if (dto.alias && dto.alias !== snippet.alias) {
const conflict = await this.repo.findOneBy({ name: dto.name }); const conflict = await this.repo.findOneBy({ alias: dto.alias });
if (conflict) if (conflict)
throw new ConflictException(`Snippet "${dto.name}" already exists`); throw new ConflictException(`Snippet alias "${dto.alias}" already exists`);
} }
Object.assign(snippet, dto); Object.assign(snippet, dto);
return this.repo.save(snippet); return this.repo.save(snippet);
@@ -70,28 +96,36 @@ export class SnippetService {
await this.repo.delete(id); await this.repo.delete(id);
} }
/** Returns a name→code map for all snippets (used by the executor). */ /** Returns an alias→code map for all snippets (used by the executor). */
async buildSnippetMap(): Promise<Record<string, string>> { async buildSnippetMap(): Promise<Record<string, string>> {
const { data } = await this.findAll({ limit: 1000 }); const { data } = await this.findAll({ limit: 1000 });
return Object.fromEntries(data.map((s) => [s.name, s.code])); return Object.fromEntries(data.map((s) => [s.alias, s.code]));
} }
exportSnippet(snippet: SnippetEntity): SnippetExportDto { exportSnippet(snippet: SnippetEntity): SnippetExportDto {
return { return {
kind: "snippet", kind: "snippet",
id: snippet.id, id: snippet.id,
name: snippet.name, alias: snippet.alias,
title: snippet.title,
description: snippet.description, description: snippet.description,
code: snippet.code, code: snippet.code,
}; };
} }
async importSnippet(dto: SnippetExportDto): Promise<SnippetEntity> { async importSnippet(dto: SnippetExportDto): Promise<SnippetEntity> {
const alias = dto.alias ?? dto.name;
if (!alias) {
throw new ConflictException("Snippet alias is required");
}
const title = dto.title ?? alias;
if (dto.id) { if (dto.id) {
const existing = await this.repo.findOneBy({ id: dto.id }); const existing = await this.repo.findOneBy({ id: dto.id });
if (existing) { if (existing) {
Object.assign(existing, { Object.assign(existing, {
name: dto.name, alias,
title,
description: dto.description ?? null, description: dto.description ?? null,
code: dto.code, code: dto.code,
}); });
@@ -100,7 +134,8 @@ export class SnippetService {
return this.repo.save( return this.repo.save(
this.repo.create({ this.repo.create({
id: dto.id, id: dto.id,
name: dto.name, alias,
title,
description: dto.description ?? null, description: dto.description ?? null,
code: dto.code, code: dto.code,
}), }),
@@ -108,7 +143,8 @@ export class SnippetService {
} }
return this.repo.save( return this.repo.save(
this.repo.create({ this.repo.create({
name: dto.name, alias,
title,
description: dto.description ?? null, description: dto.description ?? null,
code: dto.code, code: dto.code,
}), }),