diff --git a/back/evolutions/9.sql b/back/evolutions/9.sql new file mode 100644 index 00000000..ec81d2b6 --- /dev/null +++ b/back/evolutions/9.sql @@ -0,0 +1,15 @@ +CREATE TABLE public.custom_tip ( + "id" character varying NOT NULL, + "title" character varying NOT NULL, + "text" character varying NOT NULL, + "link" character varying, + "expireAt" timestamp with time zone NOT NULL, + "important" BOOLEAN NOT NULL DEFAULT FALSE +); + +ALTER TABLE ONLY public.custom_tip + ADD CONSTRAINT "PK_custom_tip_id" PRIMARY KEY ("id"); + +#DOWN + +DROP TABLE public.custom_tip; diff --git a/back/src/mind/application/TipsCreator.ts b/back/src/mind/application/TipsCreator.ts new file mode 100644 index 00000000..68046da4 --- /dev/null +++ b/back/src/mind/application/TipsCreator.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common' + +import { EntitySaver } from '@back/db/EntitySaver' +import { CustomTipModel } from '@shared/models/mind/CustomTipModel' +import { IdGenerator } from '@back/utils/infrastructure/IdGenerator/IdGenerator' + +import { DisabledTip } from '../domain/DisabledTip.entity' +import { CustomTip } from '../domain/CustomTip.entity' + +@Injectable() +export class TipsCreator { + public constructor( + private readonly entitySaver: EntitySaver, + private readonly idGenerator: IdGenerator, + ) {} + + public async createCustom(fields: CustomTipModel): Promise { + const id = await this.idGenerator.getId() + const { title, text, expireAt, important, link } = fields + + const tip = new CustomTip(id, title, text, expireAt, important, link) + + await this.entitySaver.save(tip) + } +} diff --git a/back/src/mind/domain/CustomTip.entity.ts b/back/src/mind/domain/CustomTip.entity.ts new file mode 100644 index 00000000..e550eaef --- /dev/null +++ b/back/src/mind/domain/CustomTip.entity.ts @@ -0,0 +1,40 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm' + +import { CustomTipModel } from '@shared/models/mind/CustomTipModel' + +@Entity() +export class CustomTip implements CustomTipModel { + @PrimaryColumn() + public readonly id: string + + @Column() + public readonly title: string + + @Column() + public readonly text: string + + @Column() + public readonly link?: string + + @Column() + public readonly expireAt: Date + + @Column() + public readonly important: boolean + + public constructor( + id: string, + title: string, + text: string, + expireAt: Date, + important: boolean = false, + link?: string, + ) { + this.id = id + this.title = title + this.text = text + this.expireAt = expireAt + this.important = important + this.link = link + } +} diff --git a/back/src/mind/mind.module.ts b/back/src/mind/mind.module.ts index d8c3630d..db430601 100644 --- a/back/src/mind/mind.module.ts +++ b/back/src/mind/mind.module.ts @@ -18,6 +18,7 @@ import { TypoController } from './presentation/http/controller/TypoController' import { TypoMerger } from './application/TypoMerger' import { BudgetAdviser } from './application/adviser/BudgetAdviser' import { ExtraSpendingAdviser } from './application/adviser/ExtraSpendingAdviser' +import { TipsCreator } from './application/TipsCreator' @Module({ imports: [ @@ -36,6 +37,7 @@ import { ExtraSpendingAdviser } from './application/adviser/ExtraSpendingAdviser AdviserUnity, TipsFilter, TipsDisabler, + TipsCreator, DisabledTipRepository, ], }) diff --git a/back/src/mind/presentation/http/controller/TipController.ts b/back/src/mind/presentation/http/controller/TipController.ts index abcd6aec..4cec8866 100644 --- a/back/src/mind/presentation/http/controller/TipController.ts +++ b/back/src/mind/presentation/http/controller/TipController.ts @@ -6,15 +6,18 @@ import { ApiOkResponse, } from '@nestjs/swagger' +import { OnlyForManager } from '@back/user/presentation/http/security/OnlyForManager' import { OnlyForUsers } from '@back/user/presentation/http/security/OnlyForUsers' import { TokenPayloadModel } from '@shared/models/user/TokenPayloadModel' import { CurrentUser } from '@back/user/presentation/http/decorator/CurrentUser' import { AdviserUnity } from '@back/mind/infrastructure/adviser/AdviserUnity' import { TipsFilter } from '@back/mind/application/TipsFilter' import { TipsDisabler } from '@back/mind/application/TipsDisabler' +import { TipsCreator } from '@back/mind/application/TipsCreator' import { TipResponse } from '../reponse/TipResponse' import { DisableTipRequest } from '../request/DisableTipRequest' +import { CustomTipRequest } from '../request/CustomTipRequest' @Controller('mind/tip') @OnlyForUsers() @@ -25,6 +28,7 @@ export class TipController { private readonly adviser: AdviserUnity, private readonly tipsFilter: TipsFilter, private readonly tipsDisabler: TipsDisabler, + private readonly tipsCreator: TipsCreator, ) {} @Get() @@ -54,4 +58,12 @@ export class TipController { ) { await this.tipsDisabler.disable(request.tokens, user.login) } + + @Post('create') + @OnlyForManager() + @ApiOperation({ title: 'Create custom tip' }) + @ApiOkResponse({ description: 'Created' }) + public async create(@Body() request: CustomTipRequest) { + await this.tipsCreator.createCustom(request) + } } diff --git a/back/src/mind/presentation/http/request/CustomTipRequest.ts b/back/src/mind/presentation/http/request/CustomTipRequest.ts new file mode 100644 index 00000000..181fed70 --- /dev/null +++ b/back/src/mind/presentation/http/request/CustomTipRequest.ts @@ -0,0 +1,20 @@ +import { ApiModelProperty } from '@nestjs/swagger' + +import { CustomTipModel } from '@shared/models/mind/CustomTipModel' + +export class CustomTipRequest implements CustomTipModel { + @ApiModelProperty({ example: 'Alert' }) + public readonly title: string + + @ApiModelProperty({ example: 'All cool' }) + public readonly text: string + + @ApiModelProperty({ example: 'https://google.com', required: false }) + public readonly link?: string + + @ApiModelProperty({ example: new Date() }) + public readonly expireAt: Date + + @ApiModelProperty({ example: true }) + public readonly important: boolean +} diff --git a/back/src/user/presentation/http/security/OnlyForManager.ts b/back/src/user/presentation/http/security/OnlyForManager.ts index fcdf627f..b2fc33c3 100644 --- a/back/src/user/presentation/http/security/OnlyForManager.ts +++ b/back/src/user/presentation/http/security/OnlyForManager.ts @@ -2,4 +2,4 @@ import { UseGuards } from '@nestjs/common' import { JwtManagerGuard } from './JwtManagerGuard' -export const OnlyForUsers = () => UseGuards(JwtManagerGuard) +export const OnlyForManager = () => UseGuards(JwtManagerGuard) diff --git a/front/pages/internal/manager.tsx b/front/pages/internal/manager.tsx index dbf1627a..57b56b97 100644 --- a/front/pages/internal/manager.tsx +++ b/front/pages/internal/manager.tsx @@ -1,9 +1,11 @@ import * as React from 'react' +import { Manager } from '@front/features/manager' + export default class HisotryPage extends React.Component { public static isSecure = true public render() { - return

MANAGER

+ return } } diff --git a/front/src/domain/mind/actions/createTip.ts b/front/src/domain/mind/actions/createTip.ts new file mode 100644 index 00000000..f7308fa3 --- /dev/null +++ b/front/src/domain/mind/actions/createTip.ts @@ -0,0 +1,10 @@ +import { fetchOrFail } from '@front/domain/store' +import { CustomTipModel } from '@shared/models/mind/CustomTipModel' + +import { createTipRequest } from '../api/createTipRequest' +import { actions as tipFetchingActions } from '../reducer/createTipFetching' + +export const createTip = (tip: CustomTipModel) => + fetchOrFail(tipFetchingActions, async (_, getApi) => { + await createTipRequest(getApi())(tip) + }) diff --git a/front/src/domain/mind/api/createTipRequest.ts b/front/src/domain/mind/api/createTipRequest.ts new file mode 100644 index 00000000..cdac3115 --- /dev/null +++ b/front/src/domain/mind/api/createTipRequest.ts @@ -0,0 +1,7 @@ +import { Api } from '@front/domain/api' +import { CustomTipModel } from '@shared/models/mind/CustomTipModel' + +export const createTipRequest = (api: Api) => ( + tip: CustomTipModel, +): Promise => + api.client.post('/mind/tip/create', tip).then(response => response.data) diff --git a/front/src/domain/mind/reducer/createTipFetching.ts b/front/src/domain/mind/reducer/createTipFetching.ts new file mode 100644 index 00000000..4e5b5199 --- /dev/null +++ b/front/src/domain/mind/reducer/createTipFetching.ts @@ -0,0 +1,7 @@ +import { createFetchingRedux, FetchingState } from 'redux-clear' + +type State = FetchingState + +const { reducer, actions } = createFetchingRedux('manager/create-tip-fetching') + +export { reducer, actions, State } diff --git a/front/src/domain/mind/reducer/index.ts b/front/src/domain/mind/reducer/index.ts index af072035..bf1676c3 100644 --- a/front/src/domain/mind/reducer/index.ts +++ b/front/src/domain/mind/reducer/index.ts @@ -1,13 +1,19 @@ import { combineReducers } from 'redux' import { reducer as tipsReducer, State as TipsState } from './tips' +import { + reducer as createTipFetchingReducer, + State as CreateTipTipState, +} from './createTipFetching' interface State { tips: TipsState + createTipFetching: CreateTipTipState } const reducer = combineReducers({ tips: tipsReducer, + createTipFetching: createTipFetchingReducer, }) export { reducer, State } diff --git a/front/src/domain/mind/selectors/getCreateTipFetching.ts b/front/src/domain/mind/selectors/getCreateTipFetching.ts new file mode 100644 index 00000000..91d457cf --- /dev/null +++ b/front/src/domain/mind/selectors/getCreateTipFetching.ts @@ -0,0 +1,4 @@ +import { State } from '@front/domain/store' + +export const getCreateTipFetching = (state: State) => + state.mind.createTipFetching diff --git a/front/src/features/final-form/components/Checkbox.tsx b/front/src/features/final-form/components/Checkbox.tsx new file mode 100644 index 00000000..bb6e10e5 --- /dev/null +++ b/front/src/features/final-form/components/Checkbox.tsx @@ -0,0 +1,23 @@ +import { Field, FieldRenderProps } from 'react-final-form' +import { Diff } from 'utility-types' + +import { + Checkbox as JustCheckbox, + CheckboxProps, +} from '@front/ui/components/form/checkbox' + +interface OwnProps { + name: string +} + +type ComponentProps = Diff + +export const Checkbox = ({ + name, + ...componentProps +}: OwnProps & ComponentProps) => ( + } + /> +) diff --git a/front/src/features/final-form/components/TextArea.tsx b/front/src/features/final-form/components/TextArea.tsx new file mode 100644 index 00000000..9e0143e3 --- /dev/null +++ b/front/src/features/final-form/components/TextArea.tsx @@ -0,0 +1,23 @@ +import { Field, FieldRenderProps } from 'react-final-form' +import { Diff } from 'utility-types' + +import { + TextArea as JustTextArea, + TextAreaProps, +} from '@front/ui/components/form/text-area' + +interface OwnProps { + name: string +} + +type ComponentProps = Diff + +export const TextArea = ({ + name, + ...componentProps +}: OwnProps & ComponentProps) => ( + } + /> +) diff --git a/front/src/features/final-form/index.ts b/front/src/features/final-form/index.ts index 078f0260..18f70a84 100644 --- a/front/src/features/final-form/index.ts +++ b/front/src/features/final-form/index.ts @@ -1,7 +1,9 @@ export { AutoComplete } from './components/AutoComplete' +export { Checkbox } from './components/Checkbox' export { Input } from './components/Input' export { InputMoney } from './components/InputMoney' export { Select } from './components/Select' export { EnumSelect } from './components/EnumSelect' export { DatePicker } from './components/DatePicker' +export { TextArea } from './components/TextArea' export { Toggle } from './components/Toggle' diff --git a/front/src/features/manager/Manager.tsx b/front/src/features/manager/Manager.tsx new file mode 100644 index 00000000..ccf08345 --- /dev/null +++ b/front/src/features/manager/Manager.tsx @@ -0,0 +1,11 @@ +import { Container } from '@front/ui/components/layout/container' + +import { CreateTip } from './features/create-tip' + +export const Manager = () => { + return ( + + + + ) +} diff --git a/front/src/features/manager/features/create-tip/CreateTip.css b/front/src/features/manager/features/create-tip/CreateTip.css new file mode 100644 index 00000000..87e26de0 --- /dev/null +++ b/front/src/features/manager/features/create-tip/CreateTip.css @@ -0,0 +1,48 @@ +.form { + display: grid; + + grid-template: + 'title title ' + 'content content' + 'link expire ' + 'important submit ' + / 1fr 1fr; + + @media (max-width: 768px) { + grid-template: + 'title' + 'content' + 'link' + 'expire' + 'important' + 'submit'; + } + + align-items: end; + gap: 16px; + + & > *:nth-child(1) { + grid-area: title; + } + + & > *:nth-child(2) { + grid-area: content; + } + + & > *:nth-child(3) { + grid-area: link; + } + + & > *:nth-child(4) { + grid-area: expire; + } + + & > *:nth-child(5) { + grid-area: important; + align-self: center; + } + + & > *:nth-child(6) { + grid-area: submit; + } +} diff --git a/front/src/features/manager/features/create-tip/CreateTip.tsx b/front/src/features/manager/features/create-tip/CreateTip.tsx new file mode 100644 index 00000000..2b3af790 --- /dev/null +++ b/front/src/features/manager/features/create-tip/CreateTip.tsx @@ -0,0 +1,69 @@ +import { addDays } from 'date-fns' +import { useCallback } from 'react' +import { Form } from 'react-final-form' + +import { + Input, + DatePicker, + Checkbox, + TextArea, +} from '@front/features/final-form' +import { Button } from '@front/ui/components/form/button' +import { Card } from '@front/ui/components/layout/card' +import { Label } from '@front/ui/components/form/label' +import { useThunk } from '@front/domain/store' +import { createTip } from '@front/domain/mind/actions/createTip' +import { useNotifyAlert } from '@front/ui/hooks/useNotifyAlert' + +import * as styles from './CreateTip.css' + +export const CreateTip = () => { + const dispatch = useThunk() + const notify = useNotifyAlert() + + const onSubmit = useCallback( + async (fields: any) => { + await dispatch(createTip(fields)) + + notify('Tip created') + }, + [dispatch], + ) + + return ( +
+ {({ handleSubmit }) => ( + + + + +