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": "^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": {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 @@
|
|||||||
{"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')) {
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1439
-7
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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user