feat(app): add environment import/export and sidebar footer info
- add environment import/export API endpoints and client integration - add environment export actions and toolbar import/export buttons in UI - move theme switcher to sidebar bottom and show root package version - switch snippet card menu icon to cog for consistent options affordance
This commit is contained in:
@@ -30,6 +30,28 @@
|
|||||||
padding: var(--space-2) 0;
|
padding: var(--space-2) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sideContent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sideFooter {
|
||||||
|
margin-top: auto;
|
||||||
|
padding: var(--space-3) var(--space-2) var(--space-2);
|
||||||
|
border-top: var(--border-width) solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.navItem {
|
.navItem {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
+21
-15
@@ -47,25 +47,31 @@ export default function App() {
|
|||||||
header={
|
header={
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<img src={liqaLogo} alt="Liqa" className={styles.brand} />
|
<img src={liqaLogo} alt="Liqa" className={styles.brand} />
|
||||||
<ThemeSwitcher />
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
width={220}
|
width={220}
|
||||||
>
|
>
|
||||||
<nav className={styles.nav}>
|
<div className={styles.sideContent}>
|
||||||
{NAV.map(({ path, labelKey, Icon }) => (
|
<nav className={styles.nav}>
|
||||||
<NavLink
|
{NAV.map(({ path, labelKey, Icon }) => (
|
||||||
key={path}
|
<NavLink
|
||||||
to={path}
|
key={path}
|
||||||
className={({ isActive }) =>
|
to={path}
|
||||||
[styles.navItem, isActive ? styles.navItemActive : ''].filter(Boolean).join(' ')
|
className={({ isActive }) =>
|
||||||
}
|
[styles.navItem, isActive ? styles.navItemActive : ''].filter(Boolean).join(' ')
|
||||||
>
|
}
|
||||||
<Icon size={16} className={styles.navIcon} aria-hidden="true" />
|
>
|
||||||
{t(labelKey)}
|
<Icon size={16} className={styles.navIcon} aria-hidden="true" />
|
||||||
</NavLink>
|
{t(labelKey)}
|
||||||
))}
|
</NavLink>
|
||||||
</nav>
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className={styles.sideFooter}>
|
||||||
|
<ThemeSwitcher />
|
||||||
|
<div className={styles.version}>v{__APP_VERSION__}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</SidePanel>
|
</SidePanel>
|
||||||
|
|
||||||
<main className={styles.main}>
|
<main className={styles.main}>
|
||||||
|
|||||||
@@ -133,6 +133,15 @@ export const environments = {
|
|||||||
remove(id: string): Promise<void> {
|
remove(id: string): Promise<void> {
|
||||||
return request(`/environments/${id}`, { method: 'DELETE' });
|
return request(`/environments/${id}`, { method: 'DELETE' });
|
||||||
},
|
},
|
||||||
|
exportEnvironment(id: string): Promise<unknown> {
|
||||||
|
return request(`/environments/${id}/export`);
|
||||||
|
},
|
||||||
|
importEnvironment(payload: unknown): Promise<Environment> {
|
||||||
|
return request('/environments/import', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Sessions ──────────────────────────────────────────────────────────────────
|
// ── Sessions ──────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Vendored
+2
@@ -6,3 +6,5 @@ declare module '*.css' {
|
|||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_API_URL?: string;
|
readonly VITE_API_URL?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare const __APP_VERSION__: string;
|
||||||
|
|||||||
@@ -31,6 +31,8 @@
|
|||||||
"action_delete": "Delete",
|
"action_delete": "Delete",
|
||||||
"action_add": "Add environment",
|
"action_add": "Add environment",
|
||||||
"action_edit": "Edit",
|
"action_edit": "Edit",
|
||||||
|
"action_export": "Export",
|
||||||
|
"action_import": "Import",
|
||||||
"action_save": "Create",
|
"action_save": "Create",
|
||||||
"action_update": "Save",
|
"action_update": "Save",
|
||||||
"action_cancel": "Cancel",
|
"action_cancel": "Cancel",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Pencil, Trash2, Globe } from 'lucide-react';
|
import { Upload, Pencil, Trash2, Globe } from 'lucide-react';
|
||||||
|
import { stringify as yamlStringify } from 'yaml';
|
||||||
import { environments } from '../../api';
|
import { environments } from '../../api';
|
||||||
import type { Environment } from '../../api';
|
import type { Environment } from '../../api';
|
||||||
import { Breadcrumbs, Button, Card, DescriptionList, Notification, Timestamp, UuidBadge } from '../../ui';
|
import { Breadcrumbs, Button, Card, DescriptionList, Notification, Timestamp, UuidBadge } from '../../ui';
|
||||||
@@ -30,6 +31,18 @@ export function EnvironmentDetailPage() {
|
|||||||
navigate('/environments');
|
navigate('/environments');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
if (!env) return;
|
||||||
|
const data = await environments.exportEnvironment(env.id);
|
||||||
|
const blob = new Blob([yamlStringify(data)], { type: 'application/yaml' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `environment-${env.name}.yaml`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={styles.pageToolbar}>
|
<div className={styles.pageToolbar}>
|
||||||
@@ -41,6 +54,10 @@ export function EnvironmentDetailPage() {
|
|||||||
/>
|
/>
|
||||||
{env && (
|
{env && (
|
||||||
<div className={styles.toolbarActions}>
|
<div className={styles.toolbarActions}>
|
||||||
|
<Button variant="secondary" size="sm" onClick={handleExport}>
|
||||||
|
<Upload size={14} />
|
||||||
|
{t('environments.action_export')}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Settings, ExternalLink, Pencil, Trash2, Plus, Globe } from 'lucide-react';
|
import { Settings, ExternalLink, Pencil, Trash2, Plus, Globe, Download } from 'lucide-react';
|
||||||
|
import { parse as yamlParse } from 'yaml';
|
||||||
|
import { stringify as yamlStringify } from 'yaml';
|
||||||
import { environments } from '../../api';
|
import { environments } from '../../api';
|
||||||
import type { Environment } from '../../api';
|
import type { Environment } from '../../api';
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +22,17 @@ function EnvironmentCard({ env, onDelete }: { env: Environment; onDelete: (id: s
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const urlEntries = Object.entries(env.urls).filter(([, v]) => v);
|
const urlEntries = Object.entries(env.urls).filter(([, v]) => v);
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
const data = await environments.exportEnvironment(env.id);
|
||||||
|
const blob = new Blob([yamlStringify(data)], { type: 'application/yaml' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `environment-${env.name}.yaml`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
const header = (
|
const header = (
|
||||||
<div className={styles.envCardHeader}>
|
<div className={styles.envCardHeader}>
|
||||||
<span className={styles.envCardName}>{env.name}</span>
|
<span className={styles.envCardName}>{env.name}</span>
|
||||||
@@ -42,6 +55,11 @@ function EnvironmentCard({ env, onDelete }: { env: Environment; onDelete: (id: s
|
|||||||
icon: <Pencil size={14} />,
|
icon: <Pencil size={14} />,
|
||||||
onClick: () => navigate(`/environments/${env.id}/edit`),
|
onClick: () => navigate(`/environments/${env.id}/edit`),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: t('environments.action_export'),
|
||||||
|
icon: <Download size={14} />,
|
||||||
|
onClick: () => void handleExport(),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: t('environments.action_delete'),
|
label: t('environments.action_delete'),
|
||||||
icon: <Trash2 size={14} />,
|
icon: <Trash2 size={14} />,
|
||||||
@@ -98,9 +116,11 @@ function AddEnvironmentCard() {
|
|||||||
|
|
||||||
export function EnvironmentsPage() {
|
export function EnvironmentsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [items, setItems] = useState<Environment[]>([]);
|
const [items, setItems] = useState<Environment[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const load = () => {
|
const load = () => {
|
||||||
environments
|
environments
|
||||||
@@ -119,10 +139,37 @@ export function EnvironmentsPage() {
|
|||||||
setItems((prev) => prev.filter((e) => e.id !== id));
|
setItems((prev) => prev.filter((e) => e.id !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
e.target.value = '';
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const payload = yamlParse(text) as unknown;
|
||||||
|
const imported = await environments.importEnvironment(payload);
|
||||||
|
navigate(`/environments/${imported.id}`);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={styles.pageToolbar}>
|
<div className={styles.pageToolbar}>
|
||||||
<Breadcrumbs items={[{ label: t('environments.title'), icon: <Globe size={14} /> }]} />
|
<Breadcrumbs items={[{ label: t('environments.title'), icon: <Globe size={14} /> }]} />
|
||||||
|
<div className={styles.toolbarActions}>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".yaml,.yml,.json"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={handleImport}
|
||||||
|
/>
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => fileInputRef.current?.click()}>
|
||||||
|
<Download size={14} />
|
||||||
|
{t('environments.action_import')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className={styles.error}>{error}</p>}
|
{error && <p className={styles.error}>{error}</p>}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Code, Pencil, Trash2, Plus, Download, Braces } from 'lucide-react';
|
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';
|
||||||
@@ -20,7 +20,7 @@ function SnippetCard({ snippet, onDelete }: { snippet: Snippet; onDelete: (id: s
|
|||||||
align="right"
|
align="right"
|
||||||
trigger={
|
trigger={
|
||||||
<Button variant="ghost" size="sm" aria-label={t('snippets.menu_label')}>
|
<Button variant="ghost" size="sm" aria-label={t('snippets.menu_label')}>
|
||||||
<Code size={14} />
|
<Settings size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
items={[
|
items={[
|
||||||
|
|||||||
@@ -1,14 +1,29 @@
|
|||||||
/// <reference types="vitest/config" />
|
/// <reference types="vitest/config" />
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
|
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
|
||||||
import { playwright } from '@vitest/browser-playwright';
|
import { playwright } from '@vitest/browser-playwright';
|
||||||
const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
|
const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const rootPackageJsonPath = path.resolve(dirname, '../package.json');
|
||||||
|
const APP_VERSION = (() => {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(fs.readFileSync(rootPackageJsonPath, 'utf8')) as {
|
||||||
|
version?: string;
|
||||||
|
};
|
||||||
|
return json.version ?? 'dev';
|
||||||
|
} catch {
|
||||||
|
return 'dev';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon
|
// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(APP_VERSION),
|
||||||
|
},
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "liqa-server",
|
"name": "liqa-server",
|
||||||
|
"version": "1.0.0",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import {
|
||||||
|
IsIn,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsObject,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
} from "class-validator";
|
||||||
|
import { EnvironmentUrls } from "../environment.entity";
|
||||||
|
|
||||||
|
export class EnvironmentExportDto {
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(["environment"])
|
||||||
|
kind?: "environment";
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
id?: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsObject()
|
||||||
|
urls: EnvironmentUrls;
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
|||||||
import { EnvironmentService } from "./environment.service";
|
import { EnvironmentService } from "./environment.service";
|
||||||
import { CreateEnvironmentDto } from "./dto/create-environment.dto";
|
import { CreateEnvironmentDto } from "./dto/create-environment.dto";
|
||||||
import { UpdateEnvironmentDto } from "./dto/update-environment.dto";
|
import { UpdateEnvironmentDto } from "./dto/update-environment.dto";
|
||||||
|
import { EnvironmentExportDto } from "./dto/environment-export.dto";
|
||||||
import { PaginationQueryDto } from "../common/dto/pagination.dto";
|
import { PaginationQueryDto } from "../common/dto/pagination.dto";
|
||||||
import { EnvironmentOrderBy } from "./environment.service";
|
import { EnvironmentOrderBy } from "./environment.service";
|
||||||
|
|
||||||
@@ -30,6 +31,13 @@ export class EnvironmentController {
|
|||||||
return this.environmentService.create(dto);
|
return this.environmentService.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("import")
|
||||||
|
@ApiOperation({ summary: "Import an environment (upsert by id or name)" })
|
||||||
|
@ApiResponse({ status: 201, description: "Environment imported" })
|
||||||
|
async importEnvironment(@Body() dto: EnvironmentExportDto) {
|
||||||
|
return this.environmentService.importEnvironment(dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: "List all environments (paginated)" })
|
@ApiOperation({ summary: "List all environments (paginated)" })
|
||||||
@ApiResponse({ status: 200, description: "Paginated environments" })
|
@ApiResponse({ status: 200, description: "Paginated environments" })
|
||||||
@@ -37,6 +45,15 @@ export class EnvironmentController {
|
|||||||
return this.environmentService.findAll(query);
|
return this.environmentService.findAll(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(":id/export")
|
||||||
|
@ApiOperation({ summary: "Export an environment as a plain object" })
|
||||||
|
@ApiResponse({ status: 200, description: "Environment export payload" })
|
||||||
|
@ApiResponse({ status: 404, description: "Environment not found" })
|
||||||
|
async exportEnvironment(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
|
const env = await this.environmentService.findOne(id);
|
||||||
|
return this.environmentService.exportEnvironment(env);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(":id")
|
@Get(":id")
|
||||||
@ApiOperation({ summary: "Get environment by ID" })
|
@ApiOperation({ summary: "Get environment by ID" })
|
||||||
@ApiResponse({ status: 200, description: "Environment record" })
|
@ApiResponse({ status: 200, description: "Environment record" })
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Repository } from "typeorm";
|
|||||||
import { EnvironmentEntity } from "./environment.entity";
|
import { EnvironmentEntity } from "./environment.entity";
|
||||||
import { CreateEnvironmentDto } from "./dto/create-environment.dto";
|
import { CreateEnvironmentDto } from "./dto/create-environment.dto";
|
||||||
import { UpdateEnvironmentDto } from "./dto/update-environment.dto";
|
import { UpdateEnvironmentDto } from "./dto/update-environment.dto";
|
||||||
|
import { EnvironmentExportDto } from "./dto/environment-export.dto";
|
||||||
import {
|
import {
|
||||||
PaginationQueryDto,
|
PaginationQueryDto,
|
||||||
PaginatedResult,
|
PaginatedResult,
|
||||||
@@ -64,4 +65,32 @@ export class EnvironmentService {
|
|||||||
await this.findOne(id);
|
await this.findOne(id);
|
||||||
await this.repo.delete(id);
|
await this.repo.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
exportEnvironment(env: EnvironmentEntity): EnvironmentExportDto {
|
||||||
|
return {
|
||||||
|
kind: "environment",
|
||||||
|
id: env.id,
|
||||||
|
name: env.name,
|
||||||
|
urls: env.urls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async importEnvironment(dto: EnvironmentExportDto): Promise<EnvironmentEntity> {
|
||||||
|
if (dto.id) {
|
||||||
|
const existing = await this.repo.findOneBy({ id: dto.id });
|
||||||
|
if (existing) {
|
||||||
|
Object.assign(existing, { name: dto.name, urls: dto.urls });
|
||||||
|
return this.repo.save(existing);
|
||||||
|
}
|
||||||
|
return this.repo.save(
|
||||||
|
this.repo.create({ id: dto.id, name: dto.name, urls: dto.urls }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const byName = await this.repo.findOneBy({ name: dto.name });
|
||||||
|
if (byName) {
|
||||||
|
Object.assign(byName, { urls: dto.urls });
|
||||||
|
return this.repo.save(byName);
|
||||||
|
}
|
||||||
|
return this.repo.save(this.repo.create({ name: dto.name, urls: dto.urls }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user