[test-entity]: front pt. 1

This commit is contained in:
it-is-not-alright 2024-11-11 23:56:59 +04:00
parent d387e5c5bf
commit 6e87595e2f
72 changed files with 1006 additions and 175 deletions

51
front/src/api/api.ts Normal file
View 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' });
},
};

View File

@ -0,0 +1 @@
export const BASE_URL = 'http://localhost:8000';

9
front/src/api/types.ts Normal file
View 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
View 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;
};

View 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',
};

View File

@ -0,0 +1,2 @@
export * from './service';
export { type Park, type TurbineType } from './types';

View 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);
};

View 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;
};

View 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'),
};
};

View File

@ -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>
);

View File

@ -9,7 +9,9 @@ function MainLayout() {
<div className={styles.mainLayout}>
<Sidebar />
<main className={styles.main}>
<div className={styles.content}>
<Outlet />
</div>
</main>
</div>
);

View File

@ -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);
}

View File

@ -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>
);
}

View File

@ -1 +0,0 @@
export { FormPage } from './component';

View File

@ -1,11 +0,0 @@
.about {
display: grid;
padding: 20px;
grid-template:
'. form .' auto
/ auto minmax(0, 380px) auto;
}
.form {
grid-area: form;
}

View File

@ -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>
);
}

View File

@ -1 +0,0 @@
export { HomePage } from './component';

View File

@ -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;
}

View File

@ -0,0 +1,2 @@
export * from './parks-page';
export * from './turbine-types-page';

View File

@ -1,2 +0,0 @@
export { FormPage } from './form-page';
export { HomePage } from './home-page';

View 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>
);
}

View 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) },
];

View File

@ -0,0 +1 @@
export * from './component';

View 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;
}

View 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>
);
}

View File

@ -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) },
];

View File

@ -0,0 +1 @@
export * from './component';

View 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;
}

View File

@ -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);
}
}

View File

@ -1,2 +1,3 @@
export * from './fade';
export * from './ripple';
export * from './slide';

View File

@ -50,7 +50,6 @@ export function Ripple() {
};
const handlePointerDown = (event: React.MouseEvent) => {
event.stopPropagation();
const { pageX, pageY } = event;
addWave(pageX, pageY);
};

View 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);

View File

@ -0,0 +1 @@
export * from './component';

View 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);
}
}

View File

@ -0,0 +1,4 @@
export type SlideProps = {
visible: boolean;
duration?: number;
} & React.ComponentProps<'div'>;

View File

@ -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}

View File

@ -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);
}

View File

@ -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>
);
}

View File

@ -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 {

View File

@ -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>

View File

@ -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;
}

View File

@ -0,0 +1,4 @@
.dataGrid {
border-radius: 10px;
box-shadow: 0px 2px 2px var(--clr-shadow-200);
}

View File

@ -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 = {

View File

@ -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';

View 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,
);
}

View File

@ -0,0 +1 @@
export * from './component';

View 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;
}

View File

@ -0,0 +1,7 @@
import { ReactNode } from 'react';
export type OverlayProps = {
open: boolean;
children: ReactNode;
variant?: 'small' | 'large';
};

View File

@ -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,
);
}

View File

@ -0,0 +1,4 @@
export * from './header';
export * from './park-modal';
export * from './sidebar';
export * from './turbine-type-modal';

View File

@ -1,4 +0,0 @@
export * from './header';
export * from './sidebar';
export * from './sign-in-form';
export * from './theme-select';

View 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>
);
}

View File

@ -0,0 +1,2 @@
export * from './component';
export { type ParkFormValues } from './types';

View 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;
}

View 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;
};

View 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),
};
};

View File

@ -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>

View File

@ -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 },
];

View File

@ -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;

View File

@ -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>
);
}

View File

@ -1,7 +0,0 @@
import { FormValues } from '@utils/form';
import { SignInFormStore } from './types';
export const initialValues: FormValues<SignInFormStore> = {
email: 'aaa',
};

View File

@ -1 +0,0 @@
export { SignInForm } from './component';

View File

@ -1,6 +0,0 @@
export type SignInFormStore = {
email: string;
password: string;
};
export type SignInFormProps = {} & React.ComponentProps<'form'>;

View 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>
);
}

View File

@ -0,0 +1,2 @@
export * from './component';
export { type TurbineTypeFormValues } from './types';

View File

@ -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;
}

View 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;
};

View 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),
};
};

View 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,
);

View File

@ -0,0 +1,3 @@
export { ROUTES } from './constants';
export { type AppRoute } from './types';
export { useRoute } from './use-route';

View File

@ -0,0 +1,6 @@
export type AppRouteName = 'turbineTypes' | 'parks';
export type AppRoute = {
path: string;
title: string;
};

View 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;
};