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:
@@ -22,7 +22,9 @@
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-i18next": "^17.0.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.14.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"yaml": "^2.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -90,7 +90,7 @@ export const snippets = {
|
||||
get(id: string): Promise<Snippet> {
|
||||
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', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
@@ -98,7 +98,7 @@ export const snippets = {
|
||||
},
|
||||
update(
|
||||
id: string,
|
||||
patch: Partial<Pick<Snippet, 'name' | 'description' | 'code'>>,
|
||||
patch: Partial<Pick<Snippet, 'alias' | 'title' | 'description' | 'code'>>,
|
||||
): Promise<Snippet> {
|
||||
return request(`/snippets/${id}`, {
|
||||
method: 'PATCH',
|
||||
|
||||
@@ -36,7 +36,8 @@ export interface Credential {
|
||||
|
||||
export interface Snippet {
|
||||
id: string;
|
||||
name: string;
|
||||
alias: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
code: string;
|
||||
createdAt: string;
|
||||
|
||||
@@ -224,16 +224,20 @@
|
||||
"action_cancel": "Cancel",
|
||||
"create_title": "New Snippet",
|
||||
"edit_title": "Edit Snippet",
|
||||
"form_name": "Name",
|
||||
"form_name_placeholder": "e.g. clickLoginButton",
|
||||
"form_name_required": "Name is required",
|
||||
"form_alias": "Alias",
|
||||
"form_alias_placeholder": "e.g. clickLoginButton",
|
||||
"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_placeholder": "What does this snippet do?",
|
||||
"form_code": "Code",
|
||||
"form_code_placeholder": "await page.click('#login-btn');",
|
||||
"form_code_required": "Code is required",
|
||||
"field_id": "ID",
|
||||
"field_name": "Name",
|
||||
"field_alias": "Alias",
|
||||
"field_title": "Title",
|
||||
"field_description": "Description",
|
||||
"field_created": "Created",
|
||||
"field_updated": "Updated",
|
||||
|
||||
@@ -11,10 +11,12 @@ export function CreateSnippetPage() {
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [alias, setAlias] = useState('');
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [nameError, setNameError] = useState('');
|
||||
const [aliasError, setAliasError] = useState('');
|
||||
const [titleError, setTitleError] = useState('');
|
||||
const [codeError, setCodeError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -22,8 +24,12 @@ export function CreateSnippetPage() {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
let valid = true;
|
||||
if (!name.trim()) {
|
||||
setNameError(t('snippets.form_name_required'));
|
||||
if (!alias.trim()) {
|
||||
setAliasError(t('snippets.form_alias_required'));
|
||||
valid = false;
|
||||
}
|
||||
if (!title.trim()) {
|
||||
setTitleError(t('snippets.form_title_required'));
|
||||
valid = false;
|
||||
}
|
||||
if (!code.trim()) {
|
||||
@@ -35,7 +41,8 @@ export function CreateSnippetPage() {
|
||||
setError(null);
|
||||
try {
|
||||
const snippet = await snippets.create({
|
||||
name: name.trim(),
|
||||
alias: alias.trim(),
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
code: code.trim(),
|
||||
});
|
||||
@@ -65,23 +72,37 @@ export function CreateSnippetPage() {
|
||||
<Card className={styles.formCardFull}>
|
||||
<div className={styles.formFields}>
|
||||
<Input
|
||||
label={t('snippets.form_name')}
|
||||
placeholder={t('snippets.form_name_placeholder')}
|
||||
value={name}
|
||||
label={t('snippets.form_alias')}
|
||||
placeholder={t('snippets.form_alias_placeholder')}
|
||||
value={alias}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
setNameError('');
|
||||
setAlias(e.target.value);
|
||||
setAliasError('');
|
||||
}}
|
||||
error={nameError || undefined}
|
||||
error={aliasError || undefined}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<Input
|
||||
label={t('snippets.form_description')}
|
||||
placeholder={t('snippets.form_description_placeholder')}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
label={t('snippets.form_title')}
|
||||
placeholder={t('snippets.form_title_placeholder')}
|
||||
value={title}
|
||||
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}>
|
||||
<label className={styles.fieldLabel}>
|
||||
{t('snippets.form_code')}
|
||||
|
||||
@@ -14,10 +14,12 @@ export function EditSnippetPage() {
|
||||
const toast = useToast();
|
||||
|
||||
const [snippet, setSnippet] = useState<Snippet | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [alias, setAlias] = useState('');
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [nameError, setNameError] = useState('');
|
||||
const [aliasError, setAliasError] = useState('');
|
||||
const [titleError, setTitleError] = useState('');
|
||||
const [codeError, setCodeError] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -29,7 +31,8 @@ export function EditSnippetPage() {
|
||||
.get(id)
|
||||
.then((s) => {
|
||||
setSnippet(s);
|
||||
setName(s.name);
|
||||
setAlias(s.alias);
|
||||
setTitle(s.title);
|
||||
setDescription(s.description ?? '');
|
||||
setCode(s.code);
|
||||
})
|
||||
@@ -40,8 +43,12 @@ export function EditSnippetPage() {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
let valid = true;
|
||||
if (!name.trim()) {
|
||||
setNameError(t('snippets.form_name_required'));
|
||||
if (!alias.trim()) {
|
||||
setAliasError(t('snippets.form_alias_required'));
|
||||
valid = false;
|
||||
}
|
||||
if (!title.trim()) {
|
||||
setTitleError(t('snippets.form_title_required'));
|
||||
valid = false;
|
||||
}
|
||||
if (!code.trim()) {
|
||||
@@ -53,7 +60,8 @@ export function EditSnippetPage() {
|
||||
setError(null);
|
||||
try {
|
||||
await snippets.update(id!, {
|
||||
name: name.trim(),
|
||||
alias: alias.trim(),
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
code: code.trim(),
|
||||
});
|
||||
@@ -75,7 +83,7 @@ export function EditSnippetPage() {
|
||||
items={[
|
||||
{ label: t('snippets.title'), icon: <Braces size={14} />, onClick: () => navigate('/snippets') },
|
||||
{
|
||||
label: snippet?.name ?? `#${id}`,
|
||||
label: snippet?.title ?? `#${id}`,
|
||||
onClick: () => navigate(`/snippets/${id}`),
|
||||
},
|
||||
{ label: t('snippets.edit_title') },
|
||||
@@ -90,23 +98,37 @@ export function EditSnippetPage() {
|
||||
<Card className={styles.formCardFull}>
|
||||
<div className={styles.formFields}>
|
||||
<Input
|
||||
label={t('snippets.form_name')}
|
||||
placeholder={t('snippets.form_name_placeholder')}
|
||||
value={name}
|
||||
label={t('snippets.form_alias')}
|
||||
placeholder={t('snippets.form_alias_placeholder')}
|
||||
value={alias}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
setNameError('');
|
||||
setAlias(e.target.value);
|
||||
setAliasError('');
|
||||
}}
|
||||
error={nameError || undefined}
|
||||
error={aliasError || undefined}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<Input
|
||||
label={t('snippets.form_description')}
|
||||
placeholder={t('snippets.form_description_placeholder')}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
label={t('snippets.form_title')}
|
||||
placeholder={t('snippets.form_title_placeholder')}
|
||||
value={title}
|
||||
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}>
|
||||
<label className={styles.fieldLabel}>
|
||||
{t('snippets.form_code')}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
DescriptionList,
|
||||
MarkdownContent,
|
||||
Timestamp,
|
||||
UuidBadge,
|
||||
} from '../../ui';
|
||||
@@ -46,7 +47,7 @@ export function SnippetDetailPage() {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `snippet-${snippet.name}.yaml`;
|
||||
a.download = `snippet-${snippet.alias}.yaml`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
@@ -57,7 +58,7 @@ export function SnippetDetailPage() {
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: t('snippets.title'), icon: <Braces size={14} />, onClick: () => navigate('/snippets') },
|
||||
{ label: snippet?.name ?? `#${id}` },
|
||||
{ label: snippet?.title ?? `#${id}` },
|
||||
]}
|
||||
/>
|
||||
{snippet && (
|
||||
@@ -91,11 +92,8 @@ export function SnippetDetailPage() {
|
||||
layout="grid"
|
||||
items={[
|
||||
{ term: t('snippets.field_id'), detail: <UuidBadge id={snippet.id} /> },
|
||||
{ term: t('snippets.field_name'), detail: snippet.name },
|
||||
{
|
||||
term: t('snippets.field_description'),
|
||||
detail: snippet.description ?? '—',
|
||||
},
|
||||
{ term: t('snippets.field_alias'), detail: snippet.alias },
|
||||
{ term: t('snippets.field_title'), detail: snippet.title },
|
||||
{
|
||||
term: t('snippets.field_created'),
|
||||
detail: <Timestamp value={snippet.createdAt} />,
|
||||
@@ -108,8 +106,19 @@ export function SnippetDetailPage() {
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{snippet.description && (
|
||||
<div className={styles.stepsSection}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<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.sectionHeadingRow}>
|
||||
<h2 className={styles.sectionHeading}>{t('snippets.section_code')}</h2>
|
||||
</div>
|
||||
<Card>
|
||||
|
||||
@@ -5,16 +5,29 @@ import { Settings, Pencil, Trash2, Plus, Download, Braces } from 'lucide-react';
|
||||
import { parse as yamlParse } from 'yaml';
|
||||
import { snippets } 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';
|
||||
|
||||
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 }) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const header = (
|
||||
<div className={styles.envCardHeader}>
|
||||
<span className={styles.envCardName}>{snippet.name}</span>
|
||||
<span className={styles.envCardName}>{snippet.title}</span>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<ContextMenu
|
||||
align="right"
|
||||
@@ -56,7 +69,19 @@ function SnippetCard({ snippet, onDelete }: { snippet: Snippet; onDelete: (id: s
|
||||
footer={footer}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -45,6 +45,9 @@ export type { CodeBlockProps } from './CodeBlock/CodeBlock';
|
||||
export { CodeEditor } 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 type { ContextMenuProps, ContextMenuItem } from './ContextMenu/ContextMenu';
|
||||
|
||||
|
||||
@@ -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"}
|
||||
@@ -39,6 +39,31 @@ export default defineConfig({
|
||||
if (id.includes('/node_modules/monaco-editor') || id.includes('/node_modules/@monaco-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/')) {
|
||||
return 'vendor';
|
||||
}
|
||||
|
||||
Generated
+1439
-7
File diff suppressed because it is too large
Load Diff
@@ -104,18 +104,18 @@ export class CodeExecutorService {
|
||||
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.
|
||||
*
|
||||
* Example: await helpers.runSnippet('clickLoginButton', '#submit')
|
||||
*/
|
||||
runSnippet: async (
|
||||
name: string,
|
||||
alias: string,
|
||||
...args: unknown[]
|
||||
): Promise<unknown> => {
|
||||
const snippetCode = snippetMap[name];
|
||||
const snippetCode = snippetMap[alias];
|
||||
if (snippetCode == null) {
|
||||
throw new Error(`Snippet "${name}" not found`);
|
||||
throw new Error(`Snippet alias "${alias}" not found`);
|
||||
}
|
||||
const snippetFn = new Function(
|
||||
"page",
|
||||
|
||||
@@ -5,7 +5,12 @@ export class CreateSnippetDto {
|
||||
@ApiProperty({ example: "clickLoginButton" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
alias: string;
|
||||
|
||||
@ApiProperty({ example: "Click Login Button" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
title: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: "Clicks the login button and waits for navigation",
|
||||
|
||||
@@ -18,10 +18,22 @@ export class SnippetExportDto {
|
||||
@IsUUID()
|
||||
id?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
alias?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
title?: string;
|
||||
|
||||
@ApiPropertyOptional({ deprecated: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
|
||||
@@ -6,7 +6,13 @@ export class UpdateSnippetDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name?: string;
|
||||
alias?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: "Click Login Button" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
title?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
|
||||
@@ -25,7 +25,7 @@ export class SnippetController {
|
||||
@Post()
|
||||
@ApiOperation({ summary: "Create a new snippet" })
|
||||
@ApiResponse({ status: 201, description: "Snippet created" })
|
||||
@ApiResponse({ status: 409, description: "Name already taken" })
|
||||
@ApiResponse({ status: 409, description: "Alias already taken" })
|
||||
create(@Body() dto: CreateSnippetDto) {
|
||||
return this.snippetService.create(dto);
|
||||
}
|
||||
@@ -65,7 +65,7 @@ export class SnippetController {
|
||||
@ApiOperation({ summary: "Update a snippet" })
|
||||
@ApiResponse({ status: 200, description: "Snippet updated" })
|
||||
@ApiResponse({ status: 404, description: "Snippet not found" })
|
||||
@ApiResponse({ status: 409, description: "Name already taken" })
|
||||
@ApiResponse({ status: 409, description: "Alias already taken" })
|
||||
update(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateSnippetDto,
|
||||
|
||||
@@ -11,9 +11,13 @@ export class SnippetEntity {
|
||||
@PrimaryGeneratedColumn("uuid")
|
||||
id: string;
|
||||
|
||||
/** Unique identifier used to call the snippet: helpers.runSnippet('mySnippet', ...) */
|
||||
@Column({ unique: true })
|
||||
name: string;
|
||||
/** Unique identifier used to invoke the snippet: helpers.runSnippet('mySnippet', ...) */
|
||||
@Column({ unique: true, nullable: true })
|
||||
alias: string;
|
||||
|
||||
/** Human-readable title displayed in the UI. */
|
||||
@Column({ default: "" })
|
||||
title: string;
|
||||
|
||||
@Column("text", { nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
OnModuleInit,
|
||||
} from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
@@ -14,22 +15,47 @@ import {
|
||||
PaginatedResult,
|
||||
} from "../common/dto/pagination.dto";
|
||||
|
||||
export type SnippetOrderBy = "id" | "name" | "createdAt" | "updatedAt";
|
||||
export type SnippetOrderBy = "id" | "alias" | "title" | "createdAt" | "updatedAt";
|
||||
|
||||
@Injectable()
|
||||
export class SnippetService {
|
||||
export class SnippetService implements OnModuleInit {
|
||||
constructor(
|
||||
@InjectRepository(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> {
|
||||
const existing = await this.repo.findOneBy({ name: dto.name });
|
||||
const existing = await this.repo.findOneBy({ alias: dto.alias });
|
||||
if (existing) {
|
||||
throw new ConflictException(`Snippet "${dto.name}" already exists`);
|
||||
throw new ConflictException(`Snippet alias "${dto.alias}" already exists`);
|
||||
}
|
||||
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> {
|
||||
const snippet = await this.findOne(id);
|
||||
if (dto.name && dto.name !== snippet.name) {
|
||||
const conflict = await this.repo.findOneBy({ name: dto.name });
|
||||
if (dto.alias && dto.alias !== snippet.alias) {
|
||||
const conflict = await this.repo.findOneBy({ alias: dto.alias });
|
||||
if (conflict)
|
||||
throw new ConflictException(`Snippet "${dto.name}" already exists`);
|
||||
throw new ConflictException(`Snippet alias "${dto.alias}" already exists`);
|
||||
}
|
||||
Object.assign(snippet, dto);
|
||||
return this.repo.save(snippet);
|
||||
@@ -70,28 +96,36 @@ export class SnippetService {
|
||||
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>> {
|
||||
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 {
|
||||
return {
|
||||
kind: "snippet",
|
||||
id: snippet.id,
|
||||
name: snippet.name,
|
||||
alias: snippet.alias,
|
||||
title: snippet.title,
|
||||
description: snippet.description,
|
||||
code: snippet.code,
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
const existing = await this.repo.findOneBy({ id: dto.id });
|
||||
if (existing) {
|
||||
Object.assign(existing, {
|
||||
name: dto.name,
|
||||
alias,
|
||||
title,
|
||||
description: dto.description ?? null,
|
||||
code: dto.code,
|
||||
});
|
||||
@@ -100,7 +134,8 @@ export class SnippetService {
|
||||
return this.repo.save(
|
||||
this.repo.create({
|
||||
id: dto.id,
|
||||
name: dto.name,
|
||||
alias,
|
||||
title,
|
||||
description: dto.description ?? null,
|
||||
code: dto.code,
|
||||
}),
|
||||
@@ -108,7 +143,8 @@ export class SnippetService {
|
||||
}
|
||||
return this.repo.save(
|
||||
this.repo.create({
|
||||
name: dto.name,
|
||||
alias,
|
||||
title,
|
||||
description: dto.description ?? null,
|
||||
code: dto.code,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user