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:
2026-04-10 20:29:40 +03:00
parent 99da7ac891
commit 0e4a2e1819
13 changed files with 218 additions and 20 deletions
+22
View File
@@ -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
View File
@@ -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}>
+9
View File
@@ -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 ──────────────────────────────────────────────────────────────────
+2
View File
@@ -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;
+2
View File
@@ -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 ? (
+2 -2
View File
@@ -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={[
+15
View File
@@ -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
View File
@@ -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 }));
}
} }