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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
+21
-15
@@ -47,25 +47,31 @@ export default function App() {
|
||||
header={
|
||||
<div className={styles.header}>
|
||||
<img src={liqaLogo} alt="Liqa" className={styles.brand} />
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
}
|
||||
width={220}
|
||||
>
|
||||
<nav className={styles.nav}>
|
||||
{NAV.map(({ path, labelKey, Icon }) => (
|
||||
<NavLink
|
||||
key={path}
|
||||
to={path}
|
||||
className={({ isActive }) =>
|
||||
[styles.navItem, isActive ? styles.navItemActive : ''].filter(Boolean).join(' ')
|
||||
}
|
||||
>
|
||||
<Icon size={16} className={styles.navIcon} aria-hidden="true" />
|
||||
{t(labelKey)}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className={styles.sideContent}>
|
||||
<nav className={styles.nav}>
|
||||
{NAV.map(({ path, labelKey, Icon }) => (
|
||||
<NavLink
|
||||
key={path}
|
||||
to={path}
|
||||
className={({ isActive }) =>
|
||||
[styles.navItem, isActive ? styles.navItemActive : ''].filter(Boolean).join(' ')
|
||||
}
|
||||
>
|
||||
<Icon size={16} className={styles.navIcon} aria-hidden="true" />
|
||||
{t(labelKey)}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className={styles.sideFooter}>
|
||||
<ThemeSwitcher />
|
||||
<div className={styles.version}>v{__APP_VERSION__}</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidePanel>
|
||||
|
||||
<main className={styles.main}>
|
||||
|
||||
@@ -133,6 +133,15 @@ export const environments = {
|
||||
remove(id: string): Promise<void> {
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
Vendored
+2
@@ -6,3 +6,5 @@ declare module '*.css' {
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL?: string;
|
||||
}
|
||||
|
||||
declare const __APP_VERSION__: string;
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
"action_delete": "Delete",
|
||||
"action_add": "Add environment",
|
||||
"action_edit": "Edit",
|
||||
"action_export": "Export",
|
||||
"action_import": "Import",
|
||||
"action_save": "Create",
|
||||
"action_update": "Save",
|
||||
"action_cancel": "Cancel",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
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 type { Environment } from '../../api';
|
||||
import { Breadcrumbs, Button, Card, DescriptionList, Notification, Timestamp, UuidBadge } from '../../ui';
|
||||
@@ -30,6 +31,18 @@ export function EnvironmentDetailPage() {
|
||||
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 (
|
||||
<div>
|
||||
<div className={styles.pageToolbar}>
|
||||
@@ -41,6 +54,10 @@ export function EnvironmentDetailPage() {
|
||||
/>
|
||||
{env && (
|
||||
<div className={styles.toolbarActions}>
|
||||
<Button variant="secondary" size="sm" onClick={handleExport}>
|
||||
<Upload size={14} />
|
||||
{t('environments.action_export')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
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 type { Environment } from '../../api';
|
||||
import {
|
||||
@@ -20,6 +22,17 @@ function EnvironmentCard({ env, onDelete }: { env: Environment; onDelete: (id: s
|
||||
const navigate = useNavigate();
|
||||
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 = (
|
||||
<div className={styles.envCardHeader}>
|
||||
<span className={styles.envCardName}>{env.name}</span>
|
||||
@@ -42,6 +55,11 @@ function EnvironmentCard({ env, onDelete }: { env: Environment; onDelete: (id: s
|
||||
icon: <Pencil size={14} />,
|
||||
onClick: () => navigate(`/environments/${env.id}/edit`),
|
||||
},
|
||||
{
|
||||
label: t('environments.action_export'),
|
||||
icon: <Download size={14} />,
|
||||
onClick: () => void handleExport(),
|
||||
},
|
||||
{
|
||||
label: t('environments.action_delete'),
|
||||
icon: <Trash2 size={14} />,
|
||||
@@ -98,9 +116,11 @@ function AddEnvironmentCard() {
|
||||
|
||||
export function EnvironmentsPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<Environment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const load = () => {
|
||||
environments
|
||||
@@ -119,10 +139,37 @@ export function EnvironmentsPage() {
|
||||
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 (
|
||||
<div>
|
||||
<div className={styles.pageToolbar}>
|
||||
<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>
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
{loading ? (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
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 { snippets } from '../../api';
|
||||
import type { Snippet } from '../../api';
|
||||
@@ -20,7 +20,7 @@ function SnippetCard({ snippet, onDelete }: { snippet: Snippet; onDelete: (id: s
|
||||
align="right"
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm" aria-label={t('snippets.menu_label')}>
|
||||
<Code size={14} />
|
||||
<Settings size={14} />
|
||||
</Button>
|
||||
}
|
||||
items={[
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
/// <reference types="vitest/config" />
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
|
||||
import { playwright } from '@vitest/browser-playwright';
|
||||
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
|
||||
export default defineConfig({
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(APP_VERSION),
|
||||
},
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"name": "liqa-server",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"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 { CreateEnvironmentDto } from "./dto/create-environment.dto";
|
||||
import { UpdateEnvironmentDto } from "./dto/update-environment.dto";
|
||||
import { EnvironmentExportDto } from "./dto/environment-export.dto";
|
||||
import { PaginationQueryDto } from "../common/dto/pagination.dto";
|
||||
import { EnvironmentOrderBy } from "./environment.service";
|
||||
|
||||
@@ -30,6 +31,13 @@ export class EnvironmentController {
|
||||
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()
|
||||
@ApiOperation({ summary: "List all environments (paginated)" })
|
||||
@ApiResponse({ status: 200, description: "Paginated environments" })
|
||||
@@ -37,6 +45,15 @@ export class EnvironmentController {
|
||||
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")
|
||||
@ApiOperation({ summary: "Get environment by ID" })
|
||||
@ApiResponse({ status: 200, description: "Environment record" })
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Repository } from "typeorm";
|
||||
import { EnvironmentEntity } from "./environment.entity";
|
||||
import { CreateEnvironmentDto } from "./dto/create-environment.dto";
|
||||
import { UpdateEnvironmentDto } from "./dto/update-environment.dto";
|
||||
import { EnvironmentExportDto } from "./dto/environment-export.dto";
|
||||
import {
|
||||
PaginationQueryDto,
|
||||
PaginatedResult,
|
||||
@@ -64,4 +65,32 @@ export class EnvironmentService {
|
||||
await this.findOne(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