Static page management

This commit is contained in:
Kacper Donat 2020-11-14 21:44:08 +01:00
parent 999cde6726
commit ac963d658e
15 changed files with 333 additions and 89 deletions

View File

@ -1,8 +1,12 @@
import React, { HTMLProps } from "react";
import { useHorizontalSpacing } from "@/styles";
export const Actions = (props: HTMLProps<HTMLDivElement>) => {
const classes = useHorizontalSpacing(2);
type ActionsProps = {
spacing?: number;
} & HTMLProps<HTMLDivElement>;
return <div className={ classes.root } { ...props } style={{ display: "flex", alignItems: "center" }}/>
export const Actions = ({ spacing = 2, ...props }: ActionsProps) => {
const classes = useHorizontalSpacing(spacing);
return <div className={ classes.root } { ...props } style={{ display: "flex", alignItems: "center", ...props.style }}/>
}

View File

@ -9,21 +9,22 @@ type AsyncProps<TValue, TError = any> = {
children: (value: TValue) => JSX.Element,
loading?: () => JSX.Element,
error?: (error: TError) => JSX.Element,
keepValue?: boolean;
}
const defaultLoading = () => <Loading />;
const defaultError = (error: any) => <Alert severity="error">{ error.message }</Alert>;
export function Async<TValue, TError = any>(
{ async, children: render, loading = defaultLoading, error = defaultError }: AsyncProps<TValue, TError>
{ async, children: render, loading = defaultLoading, error = defaultError, keepValue = false }: AsyncProps<TValue, TError>
) {
if (async.isLoading || (!async.error && !async.value)) {
return loading();
if (async.value && (!async.isLoading || keepValue)) {
return render(async.value as TValue);
}
if (typeof async.error !== "undefined") {
return error(async.error);
}
return render(async.value as TValue);
return loading();
}

View File

@ -0,0 +1,45 @@
import { useState } from "react";
import React from "react";
import { createPortal } from "react-dom";
import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from "@material-ui/core";
import { useTranslation } from "react-i18next";
export type ConfirmProps = {
children: (action: () => void) => React.ReactNode,
title?: string,
content?: React.ReactNode,
onConfirm?: () => void,
onCancel?: () => void,
}
export function Confirm({ children, title, content, onConfirm, onCancel }: ConfirmProps) {
const [ open, setOpen ] = useState<boolean>(false);
const { t } = useTranslation();
const handleCancel = () => {
setOpen(false);
onCancel?.();
}
const handleConfirm = () => {
setOpen(false);
onConfirm?.();
}
return <>
{ children(() => { setOpen(true) }) }
{ createPortal(
<Dialog open={ open } onClose={ handleCancel }>
{ title && <DialogTitle>{ title }</DialogTitle>}
<DialogContent>
<DialogContentText>{ content || t('confirmation') }</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={ handleCancel }>{ t('cancel') }</Button>
<Button color="primary" autoFocus onClick={ handleConfirm }>{ t('confirm') }</Button>
</DialogActions>
</Dialog>,
document.getElementById("modals") as Element,
) }
</>
}

View File

@ -26,3 +26,19 @@ export function throttle<TArgs extends any[]>(decorated: (...args: TArgs) => voi
}, time);
}
}
export function encapsulate<T>(value: T|T[]): T[] {
if (value instanceof Array) {
return value;
}
return [ value ];
}
export function one<T>(value: T|T[]): T {
if (value instanceof Array) {
return value[0];
}
return value;
}

View File

@ -19,7 +19,6 @@ export function useAsync<T, TError = any>(supplier: Promise<T> | (() => Promise<
useEffect(() => {
setLoading(true);
setError(undefined);
setValue(undefined);
const myMagicNumber = semaphore.value + 1;
semaphore.value = myMagicNumber;
@ -54,9 +53,9 @@ export function useAsync<T, TError = any>(supplier: Promise<T> | (() => Promise<
};
}
export function useAsyncState<T, TError = any>(initial: Promise<T> | undefined): AsyncState<T, TError> {
export function useAsyncState<T, TError = any>(initial?: Promise<T> | undefined): AsyncState<T, TError> {
const [promise, setPromise] = useState<Promise<T> | undefined>(initial);
const asyncState = useAsync(promise);
const asyncState = useAsync<T, TError>(promise);
return [ asyncState, setPromise ];
}

View File

@ -1,6 +1,8 @@
import { Page } from "@/data/page";
import pageDtoTransformer, { PageDTO } from "@/api/dto/page";
import { axios } from "@/api";
import { STATIC_PAGE_ENDPOINT } from "@/api/page";
import { prepare } from "@/routing";
const STATIC_PAGE_INDEX_ENDPOINT = "/staticPage";
@ -10,3 +12,16 @@ export async function all(): Promise<Page[]> {
const response = await axios.get<PageDTO[]>(STATIC_PAGE_INDEX_ENDPOINT);
return response.data.map(dto => pageDtoTransformer.transform(dto));
}
export async function remove(page: Pick<Page, "slug">): Promise<void> {
await axios.delete(prepare(STATIC_PAGE_ENDPOINT, { slug: page.slug }));
}
export async function save(page: Page): Promise<Page> {
const response = await axios.put<PageDTO>(
STATIC_PAGE_INDEX_ENDPOINT,
pageDtoTransformer.reverseTransform(page),
);
return pageDtoTransformer.transform(response.data);
}

View File

@ -1,7 +1,7 @@
import React, { useCallback } from "react";
import React, { useCallback, useEffect } from "react";
import { Page } from "@/pages/base";
import { useTranslation } from "react-i18next";
import { useAsync } from "@/hooks";
import { useAsync, useAsyncState } from "@/hooks";
import api from "@/management/api";
import { Async } from "@/components/async";
import { Container, Typography } from "@material-ui/core";
@ -9,6 +9,8 @@ import MaterialTable, { Action, Column } from "material-table";
import { Edition } from "@/data/edition";
import { Pencil } from "mdi-material-ui";
import { Management } from "../main";
import { createPortal } from "react-dom";
import { CreateStaticPageDialog } from "@/management/page/create";
export type EditionDetailsProps = {
edition: string;

View File

@ -0,0 +1,40 @@
import { Button, Dialog, DialogActions, DialogContent, DialogProps, DialogTitle } from "@material-ui/core";
import React from "react";
import { Form, Formik } from "formik";
import { initialStaticPageFormValues, StaticPageForm, StaticPageFormValues, staticPageFormValuesTransformer } from "@/management/page/form";
import { Actions } from "@/components";
import { Save } from "@material-ui/icons";
import { useTranslation } from "react-i18next";
import { Cancel } from "mdi-material-ui";
import { useSpacing } from "@/styles";
import { default as StaticPage } from "@/data/page";
export type CreateStaticPageDialogProps = {
onSave?: (page: StaticPage) => void;
} & DialogProps;
export function CreateStaticPageDialog({ onSave, ...props }: CreateStaticPageDialogProps) {
const { t } = useTranslation("management");
const spacing = useSpacing(3);
const handleSubmit = (values: StaticPageFormValues) => {
onSave?.(staticPageFormValuesTransformer.reverseTransform(values));
};
return <Dialog { ...props } maxWidth="lg">
<Formik initialValues={ initialStaticPageFormValues } onSubmit={ handleSubmit }>
<Form className={ spacing.vertical }>
<DialogTitle>{ t("page.create.title") }</DialogTitle>
<DialogContent>
<StaticPageForm />
</DialogContent>
<DialogActions>
<Actions>
<Button variant="contained" color="primary" startIcon={ <Save /> } type="submit">{ t("save") }</Button>
<Button startIcon={ <Cancel /> } onClick={ ev => props.onClose?.(ev, "escapeKeyDown") }>{ t("cancel") }</Button>
</Actions>
</DialogActions>
</Form>
</Formik>
</Dialog>
}

View File

@ -0,0 +1,39 @@
import { default as StaticPage } from "@/data/page";
import { identityTransformer, Transformer } from "@/serialization";
import { Field, Form, FormikFormProps } from "formik";
import React from "react";
import { TextField as TextFieldFormik } from "formik-material-ui";
import { Typography } from "@material-ui/core";
import { useTranslation } from "react-i18next";
import { useSpacing } from "@/styles";
export type StaticPageFormValues = StaticPage;
export const initialStaticPageFormValues: StaticPageFormValues = {
slug: "",
title: {
en: "",
pl: "",
},
content: {
en: "",
pl: "",
}
}
export const staticPageFormValuesTransformer: Transformer<StaticPage, StaticPageFormValues> = identityTransformer;
export function StaticPageForm() {
const { t } = useTranslation("management");
const spacing = useSpacing(2);
return <div className={ spacing.vertical }>
<Field label={ t("page.field.slug") } name="slug" fullWidth component={ TextFieldFormik }/>
<Typography variant="subtitle2">{ t("page.field.title") }</Typography>
<Field label={ t("translation:language.pl") } name="title.pl" fullWidth component={ TextFieldFormik }/>
<Field label={ t("translation:language.en") } name="title.en" fullWidth component={ TextFieldFormik }/>
<Typography variant="subtitle2">{ t("page.field.content") }</Typography>
<Field label={ t("translation:language.pl") } name="content.pl" fullWidth component={ TextFieldFormik }/>
<Field label={ t("translation:language.en") } name="content.en" fullWidth component={ TextFieldFormik }/>
</div>
}

View File

@ -0,0 +1,137 @@
import { Page } from "@/pages/base";
import { Management } from "@/management/main";
import { Box, Button, CircularProgress, Container, IconButton, Tooltip, Typography } from "@material-ui/core";
import React, { useEffect, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { useAsyncState } from "@/hooks";
import api from "@/management/api";
import { Async } from "@/components/async";
import MaterialTable, { Action, Column } from "material-table";
import { default as StaticPage } from "@/data/page";
import { Delete, FileFind, Pencil, Refresh } from "mdi-material-ui";
import { encapsulate, one } from "@/helpers";
import { Actions } from "@/components";
import { useSpacing } from "@/styles";
import { useHistory } from "react-router-dom";
import { Add } from "@material-ui/icons";
import { createPortal } from "react-dom";
import { CreateStaticPageDialog } from "@/management/page/create";
import { Confirm } from "@/components/confirm";
export const StaticPageManagement = () => {
const { t } = useTranslation("management");
const [ result, setPagesPromise ] = useAsyncState<StaticPage[]>();
const spacing = useSpacing(2);
const updatePageList = () => {
setPagesPromise(api.page.all());
}
useEffect(updatePageList, []);
const CreateStaticPageAction = () => {
const [ open, setOpen ] = useState<boolean>(false);
const handlePageCreation = async (page: StaticPage) => {
await api.page.save(page);
setOpen(false);
updatePageList();
}
return <>
<Button variant="contained" color="primary" startIcon={ <Add /> } onClick={ () => setOpen(true) }>{ t("create") }</Button>
{ createPortal(
<CreateStaticPageDialog open={ open } onSave={ handlePageCreation } onClose={ () => setOpen(false) }/>,
document.getElementById("modals") as Element
) }
</>
}
const DeleteStaticPageAction = ({ page }: { page: StaticPage }) => {
const handlePageDeletion = async () => {
await api.page.remove(page);
updatePageList();
}
const confirmation = <>
<Trans i18nKey="page.confirm.delete">
Czy na pewno chcesz usunąć stronę <strong>{ page.title.pl }</strong>?
</Trans>
</>;
return <Confirm onConfirm={ handlePageDeletion } content={ confirmation }>
{ action => <Tooltip title={ t("actions.delete") as string }><IconButton onClick={ action }><Delete /></IconButton></Tooltip> }
</Confirm>;
}
const PreviewStaticPageAction = ({ page }: { page: StaticPage }) => {
const history = useHistory();
const handlePagePreview = async () => history.push(`/${page.slug}`);
return <Tooltip title={ t("actions.preview") as string }>
<IconButton onClick={ handlePagePreview }><FileFind /></IconButton>
</Tooltip>;
}
const columns: Column<StaticPage>[] = [
{
render: page => page.title.pl,
title: t("page.field.title"),
},
{
field: "slug",
title: t("page.field.slug"),
},
{
title: t("actions.label"),
render: page => <Actions style={{ margin: "-1rem" }} spacing={ 0 }>
<DeleteStaticPageAction page={ page } />
<PreviewStaticPageAction page={ page } />
</Actions>,
sorting: false,
width: 0,
resizable: false,
removable: false,
searchable: false,
},
];
const PagePreview = ({ page }: { page: StaticPage }) =>
<Box className={ spacing.vertical } p={ 3 }>
<div>
<Typography variant="subtitle2">Polski</Typography>
<Typography variant="h2">{ page.title.pl }</Typography>
<div dangerouslySetInnerHTML={{ __html: page.content.pl }} />
</div>
<div>
<Typography variant="subtitle2">English</Typography>
<Typography variant="h2">{ page.title.en }</Typography>
<div dangerouslySetInnerHTML={{ __html: page.content.en }} />
</div>
</Box>
return <Page>
<Page.Header maxWidth="lg">
<Management.Breadcrumbs>
<Typography color="textPrimary">{ t("page.index.title") }</Typography>
</Management.Breadcrumbs>
<Page.Title>{ t("page.index.title") }</Page.Title>
</Page.Header>
<Container maxWidth="lg" className={ spacing.vertical }>
<Actions>
<CreateStaticPageAction />
<Button onClick={ updatePageList } startIcon={ <Refresh /> }>{ t("refresh") }</Button>
</Actions>
<Async async={ result } keepValue>{
pages => <MaterialTable
title={ <div style={{ display: "flex", alignItems: "center" }}>{ t("page.index.title") } { result.isLoading && <CircularProgress size="1.5rem" style={{ marginLeft: "1rem" }}/> }</div> }
columns={ columns }
data={ pages }
detailPanel={ page => <PagePreview page={ page } /> }
/>
}</Async>
</Container>
</Page>
}
export default StaticPageManagement;

View File

@ -1,76 +0,0 @@
import { Page } from "@/pages/base";
import { Management } from "@/management/main";
import { Box, Container, Typography } from "@material-ui/core";
import React, { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useAsync } from "@/hooks";
import api from "@/management/api";
import { Async } from "@/components/async";
import MaterialTable, { Action, Column } from "material-table";
import { default as StaticPage } from "@/data/page";
import { Edition } from "@/data/edition";
import { Delete, Pencil } from "mdi-material-ui";
export const StaticPageManagement = () => {
const { t } = useTranslation("management");
const pages = useAsync(useCallback(api.page.all, []));
const columns: Column<StaticPage>[] = [
{
render: page => page.title.pl,
title: t("page.field.title"),
},
{
field: "slug",
title: t("page.field.slug"),
},
];
const actions: Action<StaticPage>[] = [
{
icon: () => <Pencil />,
onClick: () => {},
},
{
icon: () => <Delete />,
onClick: () => {},
},
]
const PagePreview = ({ page }: { page: StaticPage }) =>
<>
<Box p={2}>
<Typography variant="subtitle2">Polski</Typography>
<Typography variant="h2">{ page.title.pl }</Typography>
<div dangerouslySetInnerHTML={{ __html: page.content.pl }} />
</Box>
<Box p={2}>
<Typography variant="subtitle2">English</Typography>
<Typography variant="h2">{ page.title.en }</Typography>
<div dangerouslySetInnerHTML={{ __html: page.content.en }} />
</Box>
</>
return <Page>
<Page.Header maxWidth="lg">
<Management.Breadcrumbs>
<Typography color="textPrimary">{ t("page.index.title") }</Typography>
</Management.Breadcrumbs>
<Page.Title>{ t("page.index.title") }</Page.Title>
</Page.Header>
<Container maxWidth="lg">
<Async async={ pages }>{
pages => <MaterialTable
title={ t("page.index.title") }
columns={ columns }
actions={ actions }
data={ pages }
detailPanel={ page => <PagePreview page={ page } /> }
options={{ actionsColumnIndex: -1 }}
/>
}</Async>
</Container>
</Page>
}
export default StaticPageManagement;

View File

@ -3,7 +3,7 @@ import { isManagerMiddleware } from "@/management/middleware";
import { EditionsManagement } from "@/management/edition/list";
import React from "react";
import { ManagementIndex } from "@/management/main";
import StaticPageManagement from "@/management/pages/list";
import StaticPageManagement from "@/management/page/list";
export const managementRoutes: Route[] = ([
{ name: "index", path: "/", content: ManagementIndex, exact: true },

View File

@ -19,3 +19,8 @@ export type SerializationTransformer<T, TSerialized = Serializable<T>> = Transfo
export type OneWayTransformer<TFrom, TResult, TContext = never> = {
transform(subject: TFrom, context?: TContext): TResult;
}
export const identityTransformer: Transformer<any, any> = {
transform: subject => subject,
reverseTransform: subject => subject
}

View File

@ -1,5 +1,15 @@
title: Zarządzanie
create: utwórz
refresh: $t(translation:refresh)
save: zapisz
cancel: anuluj
actions:
label: Akcje
preview: Podgląd
delete: Usuń
edition:
index:
title: "Edycje praktyk"
@ -16,3 +26,5 @@ page:
title: Tytuł
content: Treść
slug: Adres
create:
title: Utwórz stronę statyczną

View File

@ -211,6 +211,10 @@ steps:
instructions: >
Należy zgłosić się do pełnomocnika ds. praktyk Twojego kierunku i podpisać umowę ubezpieczenia. (TODO)
language:
pl: Polski
en: Angielski
validation:
api:
GreaterThanOrEqualValidator: Wartość pola "{{ PropertyName }}" musi być większa bądź równa {{ ComparisonValue }}.
@ -224,3 +228,4 @@ validation:
contact-coordinator: "Skontaktuj się z koordynatorem"
download: "pobierz"
management: "zarządzanie"
refresh: "odśwież"