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-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": {
+2 -2
View File
@@ -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',
+2 -1
View File
@@ -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;
+8 -4
View File
@@ -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",
+36 -15
View File
@@ -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')}
+39 -17
View File
@@ -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')}
+17 -8
View File
@@ -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.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.sectionHeader}>
<div className={styles.sectionHeadingRow}>
<h2 className={styles.sectionHeading}>{t('snippets.section_code')}</h2>
</div>
<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 { 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>
);
}
+3
View File
@@ -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
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')) {
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';
}
+1439 -7
View File
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",
+6 -1
View File
@@ -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",
+14 -2
View File
@@ -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()
+7 -1
View File
@@ -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()
+2 -2
View File
@@ -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,
+7 -3
View File
@@ -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;
+50 -14
View File
@@ -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,
}),