[test-entity]: front pt. 1
This commit is contained in:
parent
d387e5c5bf
commit
6e87595e2f
51
front/src/api/api.ts
Normal file
51
front/src/api/api.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { BASE_URL } from './constants';
|
||||
import { ApiResponse } from './types';
|
||||
import { unpack } from './utils';
|
||||
|
||||
const send = async <T>(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
): Promise<ApiResponse<T>> => {
|
||||
const fullURL = `${BASE_URL}/${url}`;
|
||||
const fullInit: RequestInit = { ...init };
|
||||
try {
|
||||
const response = await fetch(fullURL, fullInit);
|
||||
if (!response.ok) {
|
||||
return {
|
||||
data: null,
|
||||
error: { status: response.status, message: 'Something went wrong' },
|
||||
};
|
||||
}
|
||||
const raw = await response.json();
|
||||
const data: T = unpack(raw);
|
||||
return { data: data, error: null };
|
||||
} catch {
|
||||
return {
|
||||
data: null,
|
||||
error: { status: 0, message: 'Something went wrong' },
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const api = {
|
||||
get: async <T>(url: string) => {
|
||||
return send<T>(url, { method: 'GET' });
|
||||
},
|
||||
post: async <T>(url: string, body: unknown) => {
|
||||
return send<T>(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
},
|
||||
put: async <T>(url: string, body: unknown) => {
|
||||
return send<T>(url, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
},
|
||||
delete: async <T>(url: string) => {
|
||||
return send<T>(url, { method: 'DELETE' });
|
||||
},
|
||||
};
|
1
front/src/api/constants.ts
Normal file
1
front/src/api/constants.ts
Normal file
@ -0,0 +1 @@
|
||||
export const BASE_URL = 'http://localhost:8000';
|
9
front/src/api/types.ts
Normal file
9
front/src/api/types.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export type ApiError = {
|
||||
status: number;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ApiResponse<T> = {
|
||||
data: T | null;
|
||||
error: ApiError | null;
|
||||
};
|
23
front/src/api/utils.ts
Normal file
23
front/src/api/utils.ts
Normal file
@ -0,0 +1,23 @@
|
||||
export const toCamelCase = (str: string) => {
|
||||
return str
|
||||
.split(/[_\s-]+|(?=[A-Z])/)
|
||||
.map((word, index) =>
|
||||
index === 0
|
||||
? word.toLowerCase()
|
||||
: word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
|
||||
)
|
||||
.join('');
|
||||
};
|
||||
|
||||
export const unpack = (obj: unknown) => {
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => unpack(item));
|
||||
} else if (obj !== null && typeof obj === 'object') {
|
||||
return Object.entries(obj).reduce((acc, [key, value]) => {
|
||||
const newKey = toCamelCase(key);
|
||||
acc[newKey] = unpack(value);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
return obj;
|
||||
};
|
6
front/src/api/wind/constants.ts
Normal file
6
front/src/api/wind/constants.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export const WIND_ENDPOINTS = {
|
||||
turbines: 'api/wind/turbines',
|
||||
turbineType: 'api/wind/turbine_type',
|
||||
parks: 'api/wind/parks',
|
||||
park: 'api/wind/park',
|
||||
};
|
2
front/src/api/wind/index.ts
Normal file
2
front/src/api/wind/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './service';
|
||||
export { type Park, type TurbineType } from './types';
|
48
front/src/api/wind/service.ts
Normal file
48
front/src/api/wind/service.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { ParkFormValues, TurbineTypeFormValues } from '@components/ux';
|
||||
|
||||
import { api } from '../api';
|
||||
import { WIND_ENDPOINTS } from './constants';
|
||||
import { Park, ParkTurbine, TurbineType } from './types';
|
||||
import { packParkFormValues, packTurbineTypeFormValues } from './utils';
|
||||
|
||||
export const getTurbineTypes = () => {
|
||||
return api.get<TurbineType[]>(WIND_ENDPOINTS.turbines);
|
||||
};
|
||||
|
||||
export const createTurbineTypes = (values: Partial<TurbineTypeFormValues>) => {
|
||||
return api.post(
|
||||
WIND_ENDPOINTS.turbineType,
|
||||
packTurbineTypeFormValues(values),
|
||||
);
|
||||
};
|
||||
|
||||
export const editTurbineTypes = (
|
||||
values: Partial<TurbineTypeFormValues>,
|
||||
id: number,
|
||||
) => {
|
||||
const url = `${WIND_ENDPOINTS.turbineType}/${id}`;
|
||||
return api.put(url, packTurbineTypeFormValues(values));
|
||||
};
|
||||
|
||||
export const deleteTurbineTypes = (id: number) => {
|
||||
const url = `${WIND_ENDPOINTS.turbineType}/${id}`;
|
||||
return api.delete(url);
|
||||
};
|
||||
|
||||
export const getParks = () => {
|
||||
return api.get<Park[]>(WIND_ENDPOINTS.parks);
|
||||
};
|
||||
|
||||
export const createPark = (values: Partial<ParkFormValues>) => {
|
||||
return api.post(WIND_ENDPOINTS.park, packParkFormValues(values));
|
||||
};
|
||||
|
||||
export const editPark = (values: Partial<ParkFormValues>, id: number) => {
|
||||
const url = `${WIND_ENDPOINTS.park}/${id}`;
|
||||
return api.put(url, packParkFormValues(values));
|
||||
};
|
||||
|
||||
export const getParkTurines = (id: number) => {
|
||||
const url = `${WIND_ENDPOINTS.parks}/${id}/turbines`;
|
||||
return api.get<ParkTurbine[]>(url);
|
||||
};
|
22
front/src/api/wind/types.ts
Normal file
22
front/src/api/wind/types.ts
Normal file
@ -0,0 +1,22 @@
|
||||
export type TurbineType = {
|
||||
id: number;
|
||||
name: string;
|
||||
height: number;
|
||||
bladeLength: number;
|
||||
};
|
||||
|
||||
export type Park = {
|
||||
id: number;
|
||||
name: string;
|
||||
centerLatitude: number;
|
||||
centerLongitude: number;
|
||||
};
|
||||
|
||||
export type ParkTurbine = {
|
||||
windParkId: number;
|
||||
turbineId: number;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
angle: number;
|
||||
comment: string;
|
||||
};
|
19
front/src/api/wind/utils.ts
Normal file
19
front/src/api/wind/utils.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { ParkFormValues, TurbineTypeFormValues } from '@components/ux';
|
||||
|
||||
export const packTurbineTypeFormValues = (
|
||||
values: Partial<TurbineTypeFormValues>,
|
||||
) => {
|
||||
return {
|
||||
Name: values.name ?? '',
|
||||
Height: parseInt(values.height ?? '0'),
|
||||
BladeLength: parseInt(values.bladeLength ?? '0'),
|
||||
};
|
||||
};
|
||||
|
||||
export const packParkFormValues = (values: Partial<ParkFormValues>) => {
|
||||
return {
|
||||
Name: values.name ?? '',
|
||||
CenterLatitude: parseInt(values.centerLatitude ?? '0'),
|
||||
CenterLongitude: parseInt(values.centerLongitude ?? '0'),
|
||||
};
|
||||
};
|
@ -2,18 +2,27 @@ import './styles.scss';
|
||||
import '@public/fonts/styles.css';
|
||||
|
||||
import { MainLayout } from '@components/layouts';
|
||||
import { FormPage, HomePage } from '@components/pages';
|
||||
import { ParksPage, TurbineTypesPage } from '@components/pages';
|
||||
import { ROUTES } from '@utils/route';
|
||||
import React from 'react';
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path={'/'} element={<HomePage />} />
|
||||
<Route path={'/form'} element={<FormPage />} />
|
||||
<Route
|
||||
index
|
||||
element={<Navigate to={ROUTES.turbineTypes.path} replace />}
|
||||
/>
|
||||
<Route
|
||||
path={ROUTES.turbineTypes.path}
|
||||
element={<TurbineTypesPage />}
|
||||
/>
|
||||
<Route path={ROUTES.parks.path} element={<ParksPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
@ -9,7 +9,9 @@ function MainLayout() {
|
||||
<div className={styles.mainLayout}>
|
||||
<Sidebar />
|
||||
<main className={styles.main}>
|
||||
<div className={styles.content}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
@ -7,6 +7,15 @@
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
width: 1000px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
}
|
||||
|
@ -1,12 +0,0 @@
|
||||
import { SignInForm } from '@components/ux';
|
||||
import React from 'react';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
export function FormPage() {
|
||||
return (
|
||||
<div className={styles.about}>
|
||||
<SignInForm className={styles.form} />
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1 +0,0 @@
|
||||
export { FormPage } from './component';
|
@ -1,11 +0,0 @@
|
||||
.about {
|
||||
display: grid;
|
||||
padding: 20px;
|
||||
grid-template:
|
||||
'. form .' auto
|
||||
/ auto minmax(0, 380px) auto;
|
||||
}
|
||||
|
||||
.form {
|
||||
grid-area: form;
|
||||
}
|
@ -1,36 +0,0 @@
|
||||
import { AutocompletePreview } from '@components/ui/autocomplete';
|
||||
import { ButtonPreview } from '@components/ui/button';
|
||||
import { CheckboxGroupPreview } from '@components/ui/checkbox-group';
|
||||
import { DataGridPreview } from '@components/ui/data-grid';
|
||||
import { DateInputPreview } from '@components/ui/date-input';
|
||||
import { ImageFileManagerPreview } from '@components/ui/image-file-manager/preview';
|
||||
import { NumberInputPreview } from '@components/ui/number-input/preview';
|
||||
import { PasswordInputPreview } from '@components/ui/password-input';
|
||||
import { RadioGroupPreview } from '@components/ui/radio-group';
|
||||
import { SelectPreview } from '@components/ui/select';
|
||||
import { TextAreaPreview } from '@components/ui/text-area/preview';
|
||||
import { TextInputPreview } from '@components/ui/text-input';
|
||||
import React from 'react';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
export function HomePage() {
|
||||
return (
|
||||
<div className={styles.home}>
|
||||
<div className={styles.content}>
|
||||
<DataGridPreview />
|
||||
<ButtonPreview />
|
||||
<TextInputPreview />
|
||||
<PasswordInputPreview />
|
||||
<SelectPreview />
|
||||
<AutocompletePreview />
|
||||
<DateInputPreview />
|
||||
<NumberInputPreview />
|
||||
<TextAreaPreview />
|
||||
<CheckboxGroupPreview />
|
||||
<RadioGroupPreview />
|
||||
<ImageFileManagerPreview />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1 +0,0 @@
|
||||
export { HomePage } from './component';
|
@ -1,14 +0,0 @@
|
||||
.home {
|
||||
display: grid;
|
||||
grid-template:
|
||||
'. content .' auto
|
||||
/ auto minmax(0, 1000px) auto;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px 20px 60px 20px;
|
||||
gap: 30px;
|
||||
grid-area: content;
|
||||
}
|
2
front/src/components/pages/index.ts
Normal file
2
front/src/components/pages/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './parks-page';
|
||||
export * from './turbine-types-page';
|
@ -1,2 +0,0 @@
|
||||
export { FormPage } from './form-page';
|
||||
export { HomePage } from './home-page';
|
81
front/src/components/pages/parks-page/component.tsx
Normal file
81
front/src/components/pages/parks-page/component.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import { Button, Heading } from '@components/ui';
|
||||
import { DataGrid } from '@components/ui/data-grid';
|
||||
import { ParkModal } from '@components/ux';
|
||||
import { useRoute } from '@utils/route';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getParks, Park } from 'src/api/wind';
|
||||
|
||||
import { columns } from './constants';
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
export function ParksPage() {
|
||||
const [createModalOpen, setCreateModalOpen] = useState<boolean>(false);
|
||||
const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
|
||||
const [parks, setParks] = useState<Park[]>([]);
|
||||
const [selected, setSelected] = useState<Park>(null);
|
||||
const route = useRoute();
|
||||
|
||||
const fetchParks = async () => {
|
||||
const res = await getParks();
|
||||
setParks(res.data ?? []);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchParks();
|
||||
}, []);
|
||||
|
||||
const handleCreateButtonClick = () => {
|
||||
setCreateModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEditButtonClick = () => {
|
||||
setEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleParkSelect = (items: Park[]) => {
|
||||
setSelected(items[0] ?? null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<Heading tag="h1" className={styles.heading}>
|
||||
{route.title}
|
||||
</Heading>
|
||||
<div className={styles.actions}>
|
||||
<Button onClick={handleCreateButtonClick}>Create new</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleEditButtonClick}
|
||||
disabled={!selected}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="secondary" disabled={!selected}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.dataGridWrapper}>
|
||||
<DataGrid
|
||||
items={parks}
|
||||
columns={columns}
|
||||
getItemKey={({ id }) => String(id)}
|
||||
selectedItems={selected ? [selected] : []}
|
||||
onItemsSelect={handleParkSelect}
|
||||
multiselect={false}
|
||||
/>
|
||||
</div>
|
||||
<ParkModal
|
||||
park={null}
|
||||
open={createModalOpen}
|
||||
onClose={() => setCreateModalOpen(false)}
|
||||
onSuccess={fetchParks}
|
||||
/>
|
||||
<ParkModal
|
||||
park={selected}
|
||||
open={editModalOpen}
|
||||
onClose={() => setEditModalOpen(false)}
|
||||
onSuccess={fetchParks}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
8
front/src/components/pages/parks-page/constants.ts
Normal file
8
front/src/components/pages/parks-page/constants.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { DataGridColumnConfig } from '@components/ui/data-grid/types';
|
||||
import { Park } from 'src/api/wind';
|
||||
|
||||
export const columns: DataGridColumnConfig<Park>[] = [
|
||||
{ name: 'Name', getText: (t) => t.name, flex: '2' },
|
||||
{ name: 'Center latitude', getText: (t) => String(t.centerLatitude) },
|
||||
{ name: 'Center longitude', getText: (t) => String(t.centerLongitude) },
|
||||
];
|
1
front/src/components/pages/parks-page/index.ts
Normal file
1
front/src/components/pages/parks-page/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './component';
|
20
front/src/components/pages/parks-page/styles.module.scss
Normal file
20
front/src/components/pages/parks-page/styles.module.scss
Normal file
@ -0,0 +1,20 @@
|
||||
.page {
|
||||
display: grid;
|
||||
padding: 40px 20px 20px;
|
||||
gap: 20px;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.dataGridWrapper {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--clr-border-100);
|
||||
border-radius: 10px;
|
||||
background-color: var(--clr-layer-200);
|
||||
box-shadow: 0px 1px 2px var(--clr-shadow-100);
|
||||
gap: 10px;
|
||||
}
|
90
front/src/components/pages/turbine-types-page/component.tsx
Normal file
90
front/src/components/pages/turbine-types-page/component.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import { Button, Heading } from '@components/ui';
|
||||
import { DataGrid } from '@components/ui/data-grid';
|
||||
import { TurbineTypeModal } from '@components/ux';
|
||||
import { useRoute } from '@utils/route';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { deleteTurbineTypes, getTurbineTypes, TurbineType } from 'src/api/wind';
|
||||
|
||||
import { columns } from './constants';
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
export function TurbineTypesPage() {
|
||||
const [createModalOpen, setCreateModalOpen] = useState<boolean>(false);
|
||||
const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
|
||||
const [turbineTypes, setTurbineTypes] = useState<TurbineType[]>([]);
|
||||
const [selected, setSelected] = useState<TurbineType>(null);
|
||||
const route = useRoute();
|
||||
|
||||
const fetchTurbineTypes = async () => {
|
||||
const res = await getTurbineTypes();
|
||||
setTurbineTypes(res.data ?? []);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTurbineTypes();
|
||||
}, []);
|
||||
|
||||
const handleCreateButtonClick = () => {
|
||||
setCreateModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEditButtonClick = () => {
|
||||
setEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteButtonClick = async () => {
|
||||
await deleteTurbineTypes(selected.id);
|
||||
fetchTurbineTypes();
|
||||
};
|
||||
|
||||
const handleTurbineTypeSelect = (items: TurbineType[]) => {
|
||||
setSelected(items[0] ?? null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<Heading tag="h1" className={styles.heading}>
|
||||
{route.title}
|
||||
</Heading>
|
||||
<div className={styles.actions}>
|
||||
<Button onClick={handleCreateButtonClick}>Create new</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleEditButtonClick}
|
||||
disabled={!selected}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleDeleteButtonClick}
|
||||
disabled={!selected}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.dataGridWrapper}>
|
||||
<DataGrid
|
||||
items={turbineTypes}
|
||||
columns={columns}
|
||||
getItemKey={({ id }) => String(id)}
|
||||
selectedItems={selected ? [selected] : []}
|
||||
onItemsSelect={handleTurbineTypeSelect}
|
||||
multiselect={false}
|
||||
/>
|
||||
</div>
|
||||
<TurbineTypeModal
|
||||
turbineType={null}
|
||||
open={createModalOpen}
|
||||
onClose={() => setCreateModalOpen(false)}
|
||||
onSuccess={fetchTurbineTypes}
|
||||
/>
|
||||
<TurbineTypeModal
|
||||
turbineType={selected}
|
||||
open={editModalOpen}
|
||||
onClose={() => setEditModalOpen(false)}
|
||||
onSuccess={fetchTurbineTypes}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
import { DataGridColumnConfig } from '@components/ui/data-grid/types';
|
||||
import { TurbineType } from 'src/api/wind';
|
||||
|
||||
export const columns: DataGridColumnConfig<TurbineType>[] = [
|
||||
{ name: 'Name', getText: (t) => t.name, flex: '2' },
|
||||
{ name: 'Height', getText: (t) => String(t.height) },
|
||||
{ name: 'Blade length', getText: (t) => String(t.bladeLength) },
|
||||
];
|
1
front/src/components/pages/turbine-types-page/index.ts
Normal file
1
front/src/components/pages/turbine-types-page/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './component';
|
@ -0,0 +1,20 @@
|
||||
.page {
|
||||
display: grid;
|
||||
padding: 40px 20px 20px;
|
||||
gap: 20px;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.dataGridWrapper {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--clr-border-100);
|
||||
border-radius: 10px;
|
||||
background-color: var(--clr-layer-200);
|
||||
box-shadow: 0px 1px 2px var(--clr-shadow-100);
|
||||
gap: 10px;
|
||||
}
|
@ -9,21 +9,17 @@
|
||||
@keyframes fadein {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9) translateY(-30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeout {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.9) translateY(-30px);
|
||||
}
|
||||
}
|
||||
|
@ -1,2 +1,3 @@
|
||||
export * from './fade';
|
||||
export * from './ripple';
|
||||
export * from './slide';
|
||||
|
@ -50,7 +50,6 @@ export function Ripple() {
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
const { pageX, pageY } = event;
|
||||
addWave(pageX, pageY);
|
||||
};
|
||||
|
58
front/src/components/ui/animation/slide/component.tsx
Normal file
58
front/src/components/ui/animation/slide/component.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { ForwardedRef, forwardRef, useEffect, useState } from 'react';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
import { SlideProps } from './types';
|
||||
|
||||
export function SlideInner(
|
||||
{
|
||||
visible,
|
||||
duration = 200,
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}: Omit<SlideProps, 'ref'>,
|
||||
ref: ForwardedRef<HTMLDivElement>,
|
||||
) {
|
||||
const [visibleInner, setVisibleInner] = useState<boolean>(visible);
|
||||
|
||||
const classNames = clsx(
|
||||
styles.slide,
|
||||
{ [styles.invisible]: !visible },
|
||||
className,
|
||||
);
|
||||
|
||||
const inlineStyle = {
|
||||
...style,
|
||||
'--animation-duration': `${duration}ms`,
|
||||
} as React.CSSProperties;
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setVisibleInner(true);
|
||||
return;
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const handleAnimationEnd = (event: React.AnimationEvent) => {
|
||||
if (event.animationName === styles.fadeout) {
|
||||
setVisibleInner(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!visibleInner) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames}
|
||||
ref={ref}
|
||||
style={inlineStyle}
|
||||
onAnimationEnd={handleAnimationEnd}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const Slide = forwardRef(SlideInner);
|
1
front/src/components/ui/animation/slide/index.ts
Normal file
1
front/src/components/ui/animation/slide/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './component';
|
29
front/src/components/ui/animation/slide/styles.module.scss
Normal file
29
front/src/components/ui/animation/slide/styles.module.scss
Normal file
@ -0,0 +1,29 @@
|
||||
.slide {
|
||||
animation: fadein var(--animation-duration);
|
||||
}
|
||||
|
||||
.invisible {
|
||||
animation: fadeout var(--animation-duration) forwards ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadein {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9) translateY(-30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeout {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.9) translateY(-30px);
|
||||
}
|
||||
}
|
4
front/src/components/ui/animation/slide/types.ts
Normal file
4
front/src/components/ui/animation/slide/types.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export type SlideProps = {
|
||||
visible: boolean;
|
||||
duration?: number;
|
||||
} & React.ComponentProps<'div'>;
|
@ -26,6 +26,8 @@ function AutocompleteInner<T>(
|
||||
label = {},
|
||||
name,
|
||||
id,
|
||||
className,
|
||||
...props
|
||||
}: Omit<AutocompleteProps<T>, 'ref'>,
|
||||
ref: ForwardedRef<HTMLDivElement>,
|
||||
) {
|
||||
@ -43,9 +45,12 @@ function AutocompleteInner<T>(
|
||||
menuVisible,
|
||||
);
|
||||
|
||||
const autocompleteClassName = clsx(styles.autocomplete, styles[scale], {
|
||||
[styles.menuVisible]: menuVisible,
|
||||
});
|
||||
const autocompleteClassName = clsx(
|
||||
styles.autocomplete,
|
||||
styles[scale],
|
||||
{ [styles.menuVisible]: menuVisible },
|
||||
className,
|
||||
);
|
||||
|
||||
const filteredOptions = options.filter((option) => {
|
||||
const label = getOptionLabel(option).toLocaleLowerCase();
|
||||
@ -75,7 +80,7 @@ function AutocompleteInner<T>(
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={autocompleteClassName} ref={autocompleteRef}>
|
||||
<div className={autocompleteClassName} ref={autocompleteRef} {...props}>
|
||||
<TextInput
|
||||
value={value ? getOptionLabel(value) : text}
|
||||
onClick={handleInputClick}
|
||||
|
@ -41,6 +41,7 @@
|
||||
background-color: var(--clr-primary-hover);
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
&.pending {
|
||||
background-color: var(--clr-primary-disabled);
|
||||
}
|
||||
@ -54,6 +55,7 @@
|
||||
background-color: var(--clr-secondary-hover);
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
&.pending {
|
||||
background-color: var(--clr-secondary-disabled);
|
||||
}
|
||||
|
@ -1,50 +1,74 @@
|
||||
import React, { useState } from 'react';
|
||||
import { arrayToObject } from '@utils/array';
|
||||
import clsx from 'clsx';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import { DataGridHeader, DataGridRow } from './components';
|
||||
import styles from './styles.module.scss';
|
||||
import { DataGridProps } from './types';
|
||||
|
||||
export function DataGrid<T>({
|
||||
items,
|
||||
columns,
|
||||
getItemKey,
|
||||
className,
|
||||
selectedItems,
|
||||
onItemsSelect,
|
||||
multiselect = true,
|
||||
...props
|
||||
}: DataGridProps<T>) {
|
||||
const [selectedRows, setSelectedRows] = useState<Record<string, boolean>>({});
|
||||
const [allRowsSelected, setAllRowsSelected] = useState<boolean>(false);
|
||||
|
||||
const selectedItemsMap = useMemo(
|
||||
() => arrayToObject(selectedItems, (i) => getItemKey(i)),
|
||||
[selectedItems],
|
||||
);
|
||||
|
||||
const handleSelectAllRows = () => {
|
||||
const newSelectedRows: Record<string, boolean> = {};
|
||||
items.forEach((_, index) => {
|
||||
newSelectedRows[index] = !allRowsSelected;
|
||||
});
|
||||
setSelectedRows(newSelectedRows);
|
||||
if (!multiselect) {
|
||||
return;
|
||||
}
|
||||
setAllRowsSelected(!allRowsSelected);
|
||||
if (allRowsSelected) {
|
||||
onItemsSelect([]);
|
||||
} else {
|
||||
onItemsSelect([...items]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRowSelect = (rowIndex: number) => {
|
||||
setSelectedRows({
|
||||
...selectedRows,
|
||||
[rowIndex]: selectedRows[rowIndex] ? !selectedRows[rowIndex] : true,
|
||||
});
|
||||
const handleRowSelect = (item: T) => {
|
||||
setAllRowsSelected(false);
|
||||
const key = getItemKey(item);
|
||||
const selected = selectedItemsMap[key];
|
||||
if (!multiselect) {
|
||||
onItemsSelect(selected ? [] : [item]);
|
||||
} else {
|
||||
onItemsSelect(
|
||||
selected
|
||||
? selectedItems.filter((i) => key !== getItemKey(i))
|
||||
: [...selectedItems, item],
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className} {...props}>
|
||||
<div className={clsx(styles.dataGrid, className)} {...props}>
|
||||
<DataGridHeader
|
||||
columns={columns}
|
||||
allRowsSelected={allRowsSelected}
|
||||
onSelectAllRows={handleSelectAllRows}
|
||||
/>
|
||||
{items.map((item, index) => (
|
||||
{items.map((item) => {
|
||||
const key = getItemKey(item);
|
||||
return (
|
||||
<DataGridRow
|
||||
object={item}
|
||||
columns={columns}
|
||||
selected={selectedRows[index] ?? false}
|
||||
onSelect={() => handleRowSelect(index)}
|
||||
key={index}
|
||||
selected={selectedItemsMap[key] ? true : false}
|
||||
onSelect={() => handleRowSelect(item)}
|
||||
key={key}
|
||||
/>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -6,7 +6,6 @@
|
||||
padding: 10px;
|
||||
border: solid 1px var(--clr-border-100);
|
||||
background-color: var(--clr-layer-300);
|
||||
border-top-left-radius: 10px;
|
||||
}
|
||||
|
||||
.cell {
|
||||
@ -19,16 +18,13 @@
|
||||
border: solid 1px var(--clr-border-100);
|
||||
background-color: var(--clr-layer-300);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
gap: 10px;
|
||||
transition: all var(--td-100) ease-in-out;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--clr-layer-300-hover);
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
border-top-right-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.name {
|
||||
|
@ -24,7 +24,7 @@ export function DataGridRow<T>({
|
||||
style={{ flex: column.flex }}
|
||||
key={column.name}
|
||||
>
|
||||
<Span>{column.getText(object)}</Span>
|
||||
<Span color="t200">{column.getText(object)}</Span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
@ -5,6 +5,7 @@
|
||||
.checkboxLabel {
|
||||
padding: 10px;
|
||||
border: solid 1px var(--clr-border-100);
|
||||
background-color: var(--clr-layer-200);
|
||||
}
|
||||
|
||||
.cell {
|
||||
@ -14,5 +15,6 @@
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border: solid 1px var(--clr-border-100);
|
||||
background-color: var(--clr-layer-200);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
4
front/src/components/ui/data-grid/styles.module.scss
Normal file
4
front/src/components/ui/data-grid/styles.module.scss
Normal file
@ -0,0 +1,4 @@
|
||||
.dataGrid {
|
||||
border-radius: 10px;
|
||||
box-shadow: 0px 2px 2px var(--clr-shadow-200);
|
||||
}
|
@ -13,6 +13,10 @@ export type DataGridSort = {
|
||||
export type DataGridProps<T> = {
|
||||
items: T[];
|
||||
columns: DataGridColumnConfig<T>[];
|
||||
getItemKey: (object: T) => string;
|
||||
selectedItems: T[];
|
||||
onItemsSelect: (selectedItems: T[]) => void;
|
||||
multiselect?: boolean;
|
||||
} & React.ComponentPropsWithoutRef<'div'>;
|
||||
|
||||
export type Cat = {
|
||||
|
@ -9,6 +9,7 @@ export { IconButton } from './icon-button';
|
||||
export { ImageFileManager } from './image-file-manager';
|
||||
export { Menu } from './menu';
|
||||
export { NumberInput } from './number-input';
|
||||
export { Overlay } from './overlay';
|
||||
export { Paragraph } from './paragraph';
|
||||
export { PasswordInput } from './password-input';
|
||||
export { RadioGroup } from './radio-group';
|
||||
|
16
front/src/components/ui/overlay/component.tsx
Normal file
16
front/src/components/ui/overlay/component.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { Fade } from '../animation';
|
||||
import styles from './styles.module.scss';
|
||||
import { OverlayProps } from './types';
|
||||
|
||||
export function Overlay({ open, children, variant = 'small' }: OverlayProps) {
|
||||
return createPortal(
|
||||
<Fade visible={open}>
|
||||
<div className={clsx(styles.backdrop, styles[variant])}>{children}</div>
|
||||
</Fade>,
|
||||
document.body,
|
||||
);
|
||||
}
|
1
front/src/components/ui/overlay/index.tsx
Normal file
1
front/src/components/ui/overlay/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export * from './component';
|
27
front/src/components/ui/overlay/styles.module.scss
Normal file
27
front/src/components/ui/overlay/styles.module.scss
Normal file
@ -0,0 +1,27 @@
|
||||
.backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 20px;
|
||||
backdrop-filter: blur(5px);
|
||||
background-color: rgba(0 0 0 / 0.75);
|
||||
}
|
||||
|
||||
.small {
|
||||
grid-template:
|
||||
'. . .' 1fr
|
||||
'. form .' auto
|
||||
'. . .' 5fr
|
||||
/ 1fr minmax(0, 400px) 1fr;
|
||||
}
|
||||
|
||||
.large {
|
||||
grid-template:
|
||||
'. . .' 1fr
|
||||
'. form .' auto
|
||||
'. . .' 5fr
|
||||
/ 1fr minmax(0, 600px) 1fr;
|
||||
}
|
7
front/src/components/ui/overlay/types.ts
Normal file
7
front/src/components/ui/overlay/types.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export type OverlayProps = {
|
||||
open: boolean;
|
||||
children: ReactNode;
|
||||
variant?: 'small' | 'large';
|
||||
};
|
@ -7,7 +7,7 @@ import React, {
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { Fade } from '../animation';
|
||||
import { Slide } from '../animation';
|
||||
import styles from './styles.module.scss';
|
||||
import { PopoverProps } from './types';
|
||||
import { calcFadeStyles } from './utils';
|
||||
@ -58,7 +58,7 @@ export function Popover({
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<Fade
|
||||
<Slide
|
||||
visible={visible}
|
||||
className={styles.fade}
|
||||
ref={fadeRef}
|
||||
@ -71,7 +71,7 @@ export function Popover({
|
||||
)}
|
||||
>
|
||||
{element}
|
||||
</Fade>,
|
||||
</Slide>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
4
front/src/components/ux/index.ts
Normal file
4
front/src/components/ux/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './header';
|
||||
export * from './park-modal';
|
||||
export * from './sidebar';
|
||||
export * from './turbine-type-modal';
|
@ -1,4 +0,0 @@
|
||||
export * from './header';
|
||||
export * from './sidebar';
|
||||
export * from './sign-in-form';
|
||||
export * from './theme-select';
|
115
front/src/components/ux/park-modal/component.tsx
Normal file
115
front/src/components/ux/park-modal/component.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
import {
|
||||
Autocomplete,
|
||||
Button,
|
||||
Heading,
|
||||
NumberInput,
|
||||
Overlay,
|
||||
TextInput,
|
||||
} from '@components/ui';
|
||||
import { Controller, useForm } from '@utils/form';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
createPark,
|
||||
editPark,
|
||||
getParkTurines,
|
||||
getTurbineTypes,
|
||||
TurbineType,
|
||||
} from 'src/api/wind';
|
||||
import { ParkTurbine } from 'src/api/wind/types';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
import { ParkFormValues, ParkModalProps } from './types';
|
||||
import { parkToFormValues } from './utils';
|
||||
|
||||
export function ParkModal({ park, open, onClose, onSuccess }: ParkModalProps) {
|
||||
const [turbineTypes, setTurbineTypes] = useState<TurbineType[]>([]);
|
||||
const [parkTurbines, setParkTurbines] = useState<ParkTurbine[]>([]);
|
||||
const [pending, setPending] = useState<boolean>(false);
|
||||
const isEdit = park !== null;
|
||||
|
||||
console.log(parkTurbines);
|
||||
|
||||
const { register, getValues, reset, control } = useForm<ParkFormValues>({
|
||||
initialValues: {},
|
||||
});
|
||||
|
||||
const fetchTurbineTypes = async () => {
|
||||
const res = await getTurbineTypes();
|
||||
setTurbineTypes(res.data ?? []);
|
||||
};
|
||||
|
||||
const fetchParkTurbines = async () => {
|
||||
if (!park) {
|
||||
return;
|
||||
}
|
||||
const res = await getParkTurines(park.id);
|
||||
setParkTurbines(res.data ?? []);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTurbineTypes();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
reset(park ? parkToFormValues(park) : {});
|
||||
fetchParkTurbines();
|
||||
}, [park]);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setPending(true);
|
||||
if (isEdit) {
|
||||
await editPark(getValues(), park.id);
|
||||
} else {
|
||||
await createPark(getValues());
|
||||
}
|
||||
setPending(false);
|
||||
onClose();
|
||||
onSuccess();
|
||||
};
|
||||
|
||||
return (
|
||||
<Overlay open={open} variant="large">
|
||||
<form className={styles.form} onSubmit={handleSubmit}>
|
||||
<Heading tag="h1" className={styles.heading}>
|
||||
{isEdit ? 'Edit' : 'Create new'}
|
||||
</Heading>
|
||||
<div className={styles.inputBox}>
|
||||
<TextInput {...register('name')} label={{ text: 'Name' }} />
|
||||
<div className={styles.duo}>
|
||||
<Controller
|
||||
{...control('centerLatitude')}
|
||||
render={(props) => (
|
||||
<NumberInput label={{ text: 'Center latitude' }} {...props} />
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
{...control('centerLongitude')}
|
||||
render={(props) => (
|
||||
<NumberInput label={{ text: 'Center longitude' }} {...props} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.autocompleteBox}>
|
||||
<Autocomplete
|
||||
options={turbineTypes}
|
||||
getOptionKey={({ id }) => id}
|
||||
getOptionLabel={({ name }) => name}
|
||||
className={styles.autocomplete}
|
||||
label={{ text: 'Turbines' }}
|
||||
/>
|
||||
<Button>Add</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.buttonBox}>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" pending={pending}>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
2
front/src/components/ux/park-modal/index.ts
Normal file
2
front/src/components/ux/park-modal/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './component';
|
||||
export { type ParkFormValues } from './types';
|
48
front/src/components/ux/park-modal/styles.module.scss
Normal file
48
front/src/components/ux/park-modal/styles.module.scss
Normal file
@ -0,0 +1,48 @@
|
||||
.form {
|
||||
display: grid;
|
||||
padding: 40px 20px;
|
||||
border-radius: 10px;
|
||||
background-color: var(--clr-layer-200);
|
||||
box-shadow: 0px 1px 2px var(--clr-shadow-100);
|
||||
gap: 40px;
|
||||
grid-area: form;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
|
||||
& > * {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.heading {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.inputBox {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.duo {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
|
||||
& > * {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.autocompleteBox {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.autocomplete {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.buttonBox {
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
gap: 10px;
|
||||
}
|
14
front/src/components/ux/park-modal/types.ts
Normal file
14
front/src/components/ux/park-modal/types.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { Park } from 'src/api/wind';
|
||||
|
||||
export type ParkFormValues = {
|
||||
name: string;
|
||||
centerLatitude: string;
|
||||
centerLongitude: string;
|
||||
};
|
||||
|
||||
export type ParkModalProps = {
|
||||
park: Park;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
};
|
11
front/src/components/ux/park-modal/utils.ts
Normal file
11
front/src/components/ux/park-modal/utils.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Park } from 'src/api/wind';
|
||||
|
||||
import { ParkFormValues } from './types';
|
||||
|
||||
export const parkToFormValues = (park: Park): ParkFormValues => {
|
||||
return {
|
||||
name: park.name,
|
||||
centerLatitude: String(park.centerLatitude),
|
||||
centerLongitude: String(park.centerLongitude),
|
||||
};
|
||||
};
|
@ -1,4 +1,5 @@
|
||||
import { Heading } from '@components/ui';
|
||||
import { useRoute } from '@utils/route';
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
@ -7,6 +8,9 @@ import { links } from './constants';
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
export function Sidebar() {
|
||||
const route = useRoute();
|
||||
console.log(route);
|
||||
|
||||
return (
|
||||
<div className={styles.sidebar}>
|
||||
<Heading tag="h3">Wind App</Heading>
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { ROUTES } from '@utils/route';
|
||||
|
||||
import { SidebarLink } from './types';
|
||||
|
||||
export const links: SidebarLink[] = [
|
||||
{ url: '/turbine-types', title: 'Turbine types' },
|
||||
{ url: '/parks', title: 'Parks' },
|
||||
{ url: ROUTES.turbineTypes.path, title: ROUTES.turbineTypes.title },
|
||||
{ url: ROUTES.parks.path, title: ROUTES.parks.title },
|
||||
];
|
||||
|
@ -2,6 +2,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
border-right: 1px solid var(--clr-border-100);
|
||||
background-color: var(--clr-layer-200);
|
||||
box-shadow: 0px 1px 2px var(--clr-shadow-100);
|
||||
gap: 20px;
|
||||
|
@ -1,33 +0,0 @@
|
||||
import { Button, Heading, PasswordInput, TextInput } from '@components/ui';
|
||||
import { useForm } from '@utils/form';
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
import { initialValues } from './constants';
|
||||
import styles from './styles.module.scss';
|
||||
import { SignInFormProps, SignInFormStore } from './types';
|
||||
|
||||
export function SignInForm({ className, ...props }: SignInFormProps) {
|
||||
const { register, getValues } = useForm<SignInFormStore>({
|
||||
initialValues,
|
||||
});
|
||||
const classNames = clsx(className, styles.form);
|
||||
|
||||
const handleSubmit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
console.log(getValues());
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={classNames} {...props}>
|
||||
<Heading tag="h1" className={styles.heading}>
|
||||
Sign in
|
||||
</Heading>
|
||||
<div className={styles.inputBox}>
|
||||
<TextInput {...register('email')} label={{ text: 'Email' }} />
|
||||
<PasswordInput {...register('password')} label={{ text: 'Password' }} />
|
||||
</div>
|
||||
<Button type="submit">Sign in</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
import { FormValues } from '@utils/form';
|
||||
|
||||
import { SignInFormStore } from './types';
|
||||
|
||||
export const initialValues: FormValues<SignInFormStore> = {
|
||||
email: 'aaa',
|
||||
};
|
@ -1 +0,0 @@
|
||||
export { SignInForm } from './component';
|
@ -1,6 +0,0 @@
|
||||
export type SignInFormStore = {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export type SignInFormProps = {} & React.ComponentProps<'form'>;
|
79
front/src/components/ux/turbine-type-modal/component.tsx
Normal file
79
front/src/components/ux/turbine-type-modal/component.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import {
|
||||
Button,
|
||||
Heading,
|
||||
NumberInput,
|
||||
Overlay,
|
||||
TextInput,
|
||||
} from '@components/ui';
|
||||
import { Controller, useForm } from '@utils/form';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createTurbineTypes, editTurbineTypes } from 'src/api/wind';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
import { TurbineTypeFormValues, TurbineTypeModalProps } from './types';
|
||||
import { turbineTypeToFormValues } from './utils';
|
||||
|
||||
export function TurbineTypeModal({
|
||||
turbineType,
|
||||
open,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: TurbineTypeModalProps) {
|
||||
const [pending, setPending] = useState<boolean>(false);
|
||||
const isEdit = turbineType !== null;
|
||||
|
||||
const { register, control, getValues, reset } =
|
||||
useForm<TurbineTypeFormValues>({
|
||||
initialValues: {},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
reset(turbineType ? turbineTypeToFormValues(turbineType) : {});
|
||||
}, [turbineType]);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setPending(true);
|
||||
if (isEdit) {
|
||||
await editTurbineTypes(getValues(), turbineType.id);
|
||||
} else {
|
||||
await createTurbineTypes(getValues());
|
||||
}
|
||||
setPending(false);
|
||||
onClose();
|
||||
onSuccess();
|
||||
};
|
||||
|
||||
return (
|
||||
<Overlay open={open} variant="small">
|
||||
<form className={styles.form} onSubmit={handleSubmit}>
|
||||
<Heading tag="h1" className={styles.heading}>
|
||||
{isEdit ? 'Edit' : 'Create new'}
|
||||
</Heading>
|
||||
<div className={styles.inputBox}>
|
||||
<TextInput {...register('name')} label={{ text: 'Name' }} />
|
||||
<Controller
|
||||
{...control('height')}
|
||||
render={(props) => (
|
||||
<NumberInput label={{ text: 'Height' }} {...props} />
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
{...control('bladeLength')}
|
||||
render={(props) => (
|
||||
<NumberInput label={{ text: 'Blade length' }} {...props} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.buttonBox}>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" pending={pending}>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
2
front/src/components/ux/turbine-type-modal/index.ts
Normal file
2
front/src/components/ux/turbine-type-modal/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './component';
|
||||
export { type TurbineTypeFormValues } from './types';
|
@ -5,6 +5,8 @@
|
||||
background-color: var(--clr-layer-200);
|
||||
box-shadow: 0px 1px 2px var(--clr-shadow-100);
|
||||
gap: 40px;
|
||||
grid-area: form;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
|
||||
& > * {
|
||||
width: 100%;
|
||||
@ -19,3 +21,9 @@
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.buttonBox {
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
gap: 10px;
|
||||
}
|
14
front/src/components/ux/turbine-type-modal/types.ts
Normal file
14
front/src/components/ux/turbine-type-modal/types.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { TurbineType } from 'src/api/wind';
|
||||
|
||||
export type TurbineTypeFormValues = {
|
||||
name: string;
|
||||
height: string;
|
||||
bladeLength: string;
|
||||
};
|
||||
|
||||
export type TurbineTypeModalProps = {
|
||||
turbineType: TurbineType;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
};
|
13
front/src/components/ux/turbine-type-modal/utils.ts
Normal file
13
front/src/components/ux/turbine-type-modal/utils.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { TurbineType } from 'src/api/wind';
|
||||
|
||||
import { TurbineTypeFormValues } from './types';
|
||||
|
||||
export const turbineTypeToFormValues = (
|
||||
turbineType: TurbineType,
|
||||
): TurbineTypeFormValues => {
|
||||
return {
|
||||
name: turbineType.name,
|
||||
height: String(turbineType.height),
|
||||
bladeLength: String(turbineType.bladeLength),
|
||||
};
|
||||
};
|
15
front/src/utils/route/constants.ts
Normal file
15
front/src/utils/route/constants.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { arrayToObject } from '@utils/array';
|
||||
|
||||
import { AppRoute, AppRouteName } from './types';
|
||||
|
||||
export const ROUTES: Record<AppRouteName, AppRoute> = {
|
||||
turbineTypes: { path: 'turbine-types', title: 'Turbine types' },
|
||||
parks: { path: 'parks', title: 'Parks' },
|
||||
};
|
||||
|
||||
export const routeArray = Object.values(ROUTES);
|
||||
|
||||
export const routeMap = arrayToObject(
|
||||
Object.keys(ROUTES) as AppRouteName[],
|
||||
(route) => ROUTES[route]?.path,
|
||||
);
|
3
front/src/utils/route/index.tsx
Normal file
3
front/src/utils/route/index.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export { ROUTES } from './constants';
|
||||
export { type AppRoute } from './types';
|
||||
export { useRoute } from './use-route';
|
6
front/src/utils/route/types.ts
Normal file
6
front/src/utils/route/types.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export type AppRouteName = 'turbineTypes' | 'parks';
|
||||
|
||||
export type AppRoute = {
|
||||
path: string;
|
||||
title: string;
|
||||
};
|
9
front/src/utils/route/use-route.ts
Normal file
9
front/src/utils/route/use-route.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { matchRoutes, useLocation } from 'react-router-dom';
|
||||
|
||||
import { routeArray, routeMap, ROUTES } from './constants';
|
||||
|
||||
export const useRoute = () => {
|
||||
const location = useLocation();
|
||||
const match = matchRoutes(routeArray, location);
|
||||
return match ? ROUTES[routeMap[match[0].route.path]] : null;
|
||||
};
|
Loading…
Reference in New Issue
Block a user