From 40ead6f4b3cb7b23d8cdf5d4db2e345277a7be20 Mon Sep 17 00:00:00 2001 From: Ilya Andriyanov Date: Sat, 7 Dec 2024 21:52:11 +0300 Subject: [PATCH 01/17] feature: vdom with events --- src/modules/vdom/virtual_dom.ts | 282 +++++++++++++++++++++++++++ src/modules/vdom/virtual_dom_root.ts | 112 +++++++++++ src/modules/vdom/virtual_node.ts | 62 ++++++ 3 files changed, 456 insertions(+) create mode 100644 src/modules/vdom/virtual_dom.ts create mode 100644 src/modules/vdom/virtual_dom_root.ts create mode 100644 src/modules/vdom/virtual_node.ts diff --git a/src/modules/vdom/virtual_dom.ts b/src/modules/vdom/virtual_dom.ts new file mode 100644 index 0000000..5155f3e --- /dev/null +++ b/src/modules/vdom/virtual_dom.ts @@ -0,0 +1,282 @@ +import type { + ComponentClass, + VirtualNode, + VirtualNodeSpec, + NodeWithVirtualNode, +} from './virtual_node'; +import { setEventListener, unsetEventListeners, isEventProperty } from './virtual_dom_root'; + +export function createElement( + type: ComponentClass | string, + props: ({ [key: string]: unknown } & { key?: string; className?: string; class?: string }) | null, + ...children: Array +): VirtualNodeSpec { + let key: string = null; + if (typeof props === 'object' && props !== null) { + if (props.hasOwnProperty('key')) { + key = props.key; + delete props.key; + } + if (props.hasOwnProperty('className')) { + props.class = props.className; + delete props.className; + const allClasses = props.class.split(' '); + for (let i = 0; i < allClasses.length; i++) { + const className = allClasses[i]; + // key is autogenerated according to BEM element class + if (className.includes('__')) { + key = className; + break; + } + } + } + } + + return { + type, + props, + children: fixChildren(children), + key, + root: null, + }; +} + +function fixChildren( + children: Array | boolean>, +): Array { + return children + .reduce((acc: Array, child) => { + if (Array.isArray(child)) { + return [...acc, ...fixChildren(child)]; + } + return [...acc, child]; + }, []) + .filter((child) => { + return typeof child !== 'boolean'; + }); +} + +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace JSX { + export interface IntrinsicElements { + [elemName: string]: unknown; + } + export type Element = VirtualNodeSpec; + export interface ElementAttributesProperty { + props: { [key: string]: unknown }; + } + + export interface ElementChildrenAttribute { + children: Array; + } +} + +export function createNode(spec: VirtualNodeSpec | string): NodeWithVirtualNode { + const isTextNode = typeof spec === 'string'; + if (isTextNode) { + return document.createTextNode(spec); + } + + const { type, props, children, key, root } = spec; + const newVirtualNode: VirtualNode = { + type, + props, + key, + children, + parent: null, + root, + }; + + const isPlainHtmlElement = typeof type === 'string'; + if (isPlainHtmlElement) { + const domNode: NodeWithVirtualNode & HTMLElement = document.createElement(type); + domNode.virtualNode = newVirtualNode; + + if (props) { + Object.entries(props).forEach(([key, value]) => { + if (isEventProperty(key)) { + setEventListener(newVirtualNode, key, <{ (ev: Event): void }>value); + } else { + domNode.setAttribute(key, value); + } + }); + } + + if (children) { + children.forEach((child) => { + if (typeof child !== 'string') { + child.root = newVirtualNode.root; + } + const domChild = createNode(child); + if (domChild instanceof HTMLElement) { + (domChild as NodeWithVirtualNode).virtualNode.parent = newVirtualNode; + } + domNode.appendChild(domChild); + if (domChild.virtualNode && domChild.virtualNode.state) { + domChild.virtualNode.state.didMount(); + } + }); + } + + newVirtualNode.renderedSpec = spec; + return domNode; + } + + // We got a component + + newVirtualNode.state = new type({ props, children }); + newVirtualNode.renderedSpec = newVirtualNode.state.render(); + newVirtualNode.renderedSpec.root = newVirtualNode.root; + const domNode = createNode(newVirtualNode.renderedSpec); + // domNode.virtualNode is a subtree of elements. + // We should bind it to its origin element (its not quite parent) + domNode.virtualNode.parent = newVirtualNode; + domNode.subtreeVirtualNode = domNode.virtualNode; + domNode.virtualNode = newVirtualNode; + newVirtualNode.state.domNode = domNode; + newVirtualNode.state.didCreate(); + + return domNode; +} + +export function updateNode( + curHtml: NodeWithVirtualNode, + newSpec: VirtualNodeSpec, +): NodeWithVirtualNode { + // We assume that newSpec declares the same component as curNode + // If we done right, not the same component will be sieved earlier + const curNode = curHtml.virtualNode; + const prevSpec = curNode.renderedSpec; + + const isComponentNode = typeof curNode.type !== 'string'; + if (isComponentNode) { + curNode.state.willUpdate(newSpec); + const newRender = curNode.state.render(); + curNode.renderedSpec = newRender; + } else { + curNode.renderedSpec = newSpec; + } + + // curHtml is guarantied to be HTMLElement since text HTML node is handled outside + updateSelfProps(curHtml, prevSpec.props, curNode.renderedSpec.props); + updateChildren(curHtml, prevSpec.children, curNode.renderedSpec.children); + + if (isComponentNode) { + curNode.state.didUpdate(); + } + + return curHtml; +} + +function updateChildren( + curHtml: HTMLElement & NodeWithVirtualNode, + prevChildren: Array = [], + newChildren: Array = [], +) { + const newChildrenCount = newChildren.length; + + for ( + let oldChildIdx = 0, newChildIdx = 0; + newChildIdx < newChildrenCount; + newChildIdx++, oldChildIdx++ + ) { + const prevChild = prevChildren[oldChildIdx]; + const newChild = newChildren[newChildIdx]; + + const isPrevChildText = typeof prevChild === 'string'; + const isNewChildText = typeof newChild === 'string'; + + if (isPrevChildText && isNewChildText) { + curHtml.childNodes[newChildIdx].textContent = newChild; + } else if ( + !isPrevChildText && + !isNewChildText && + prevChild && + prevChild.type === newChild.type && + prevChild.key === newChild.key + ) { + updateNode(curHtml.childNodes[newChildIdx], newChild); + } else { + // Delete old node and add new one + if (typeof newChild !== 'string') { + newChild.root = curHtml.virtualNode.root; + } + const newHtmlNode = createNode(newChild); + newHtmlNode.virtualNode.parent = curHtml.subtreeVirtualNode || curHtml.virtualNode; + curHtml.insertBefore(newHtmlNode, curHtml.childNodes[newChildIdx]); + if (newHtmlNode.virtualNode && newHtmlNode.virtualNode.state) { + newHtmlNode.virtualNode.state.didMount(); + } + oldChildIdx--; + } + } + + const curChildrenCount = curHtml.childNodes.length; + + for (let i = newChildrenCount; i < curChildrenCount; i++) { + destroyNode(curHtml.childNodes[newChildrenCount]); + curHtml.removeChild(curHtml.childNodes[newChildrenCount]); + } +} + +function updateSelfProps( + curHtml: HTMLElement & NodeWithVirtualNode, + prevProps: object | null, + newProps: object | null, +) { + const virtualNode = curHtml.subtreeVirtualNode || curHtml.virtualNode; + if (virtualNode.eventListeners) { + virtualNode.eventListeners.forEach( + (listeners: Array<{ (ev: Event): void }>, eventName: string) => { + unsetEventListeners(virtualNode, eventName); + }, + ); + delete virtualNode.eventListeners; + } + + if (newProps) { + Object.entries(newProps).forEach(([key, value]) => { + if (isEventProperty(key)) { + setEventListener(virtualNode, key, <{ (ev: Event): void }>value); + } else { + curHtml.setAttribute(key, value); + } + }); + } + + if (prevProps) { + Object.keys(prevProps).forEach((key) => { + if (!newProps || !newProps.hasOwnProperty(key)) { + curHtml.removeAttribute(key); + } + }); + } +} + +export function destroyNode(domNode: NodeWithVirtualNode) { + const virtualNode = domNode.virtualNode; + + if (virtualNode) { + const isComponentNode = typeof virtualNode.type !== 'string'; + + if (isComponentNode) { + virtualNode.state.willDestroy(); + } + + if (virtualNode.eventListeners) { + virtualNode.eventListeners.forEach((_, eventName: string) => { + unsetEventListeners(virtualNode, eventName); + }); + delete virtualNode.eventListeners; + } + + if (domNode.subtreeVirtualNode && domNode.subtreeVirtualNode.eventListeners) { + domNode.subtreeVirtualNode.eventListeners.forEach((_, eventName: string) => { + unsetEventListeners(domNode.subtreeVirtualNode, eventName); + }); + delete domNode.subtreeVirtualNode.eventListeners; + } + + domNode.childNodes.forEach((child) => destroyNode(child)); + } +} diff --git a/src/modules/vdom/virtual_dom_root.ts b/src/modules/vdom/virtual_dom_root.ts new file mode 100644 index 0000000..32a7b5c --- /dev/null +++ b/src/modules/vdom/virtual_dom_root.ts @@ -0,0 +1,112 @@ +import type { Tsx, NodeWithVirtualNode, VirtualNode } from './virtual_node'; +import { createElement, createNode, updateNode, destroyNode } from './virtual_dom'; + +export function isEventProperty(propertyName: string): boolean { + return ( + propertyName.startsWith('on') && + propertyName[2] !== undefined && + propertyName[2].toUpperCase() === propertyName[2] + ); +} + +export class VirtualDomRoot { + private eventListeners: Map void>; + private registeredEventsAmount: Map; + private renderedNode?: NodeWithVirtualNode; + constructor(private domNode: HTMLElement & NodeWithVirtualNode) { + this.eventListeners = new Map void>(); + this.registeredEventsAmount = new Map(); + } + + update(tsx: Tsx) { + const newSpec = createElement(tsx.type, tsx.props, ...tsx.children); + newSpec.root = this; + updateNode(this.renderedNode, newSpec); + } + + render(tsx: Tsx | string) { + destroyNode(this.domNode); + if (typeof tsx === 'string') { + this.domNode.textContent = tsx; + return; + } + const vDomSpec = createElement(tsx.type, tsx.props, ...tsx.children); + vDomSpec.root = this; + this.renderedNode = createNode(vDomSpec); + this.domNode.appendChild(this.renderedNode); + } + + registerEvent(eventName: string): void { + if (this.eventListeners.has(eventName)) { + this.registeredEventsAmount.set(eventName, this.registeredEventsAmount.get(eventName) + 1); + return; + } + const eventHandler = (ev: Event) => { + const targetNode = ev.target as NodeWithVirtualNode; + if (targetNode.virtualNode === undefined) { + return; + } + const virtualNode = targetNode.virtualNode; + bubbleEvent(ev, virtualNode); + }; + this.domNode.addEventListener(eventName, eventHandler); + this.eventListeners.set(eventName, eventHandler); + this.registeredEventsAmount.set(eventName, 1); + } + + unregisterEvent(eventName: string): void { + if (!this.eventListeners.has(eventName)) { + return; + } + this.registeredEventsAmount.set(eventName, this.registeredEventsAmount.get(eventName) - 1); + if (this.registeredEventsAmount.get(eventName) === 0) { + this.registeredEventsAmount.delete(eventName); + this.domNode.removeEventListener(eventName, this.eventListeners.get(eventName)); + this.eventListeners.delete(eventName); + } + } +} + +function bubbleEvent(event: Event, targetNode: VirtualNode): void { + let currentTarget = targetNode; + while (currentTarget !== null) { + if ( + currentTarget.eventListeners !== undefined && + currentTarget.eventListeners.has(event.type) + ) { + currentTarget.eventListeners.get(event.type).forEach((handler) => handler(event)); + } + currentTarget = currentTarget.parent; + } +} + +export function setEventListener( + virtualNode: VirtualNode, + eventKey: string, + handler: (ev: Event) => void, +) { + if (!virtualNode.root) { + console.log('Unexpected no root'); + return; + } + const eventName = eventKey.slice(2).toLowerCase(); + if (!virtualNode.eventListeners) { + virtualNode.eventListeners = new Map>(); + } + if (virtualNode.eventListeners.has(eventName)) { + virtualNode.eventListeners.get(eventName).push(handler); + } else { + virtualNode.eventListeners.set(eventName, [handler]); + } + virtualNode.root.registerEvent(eventName); +} + +export function unsetEventListeners(virtualNode: VirtualNode, eventName: string) { + if (virtualNode.eventListeners.has(eventName)) { + const listenersAmount = virtualNode.eventListeners.get(eventName).length; + virtualNode.eventListeners.delete(eventName); + for (let i = 0; i < listenersAmount; i++) { + virtualNode.root.unregisterEvent(eventName); + } + } +} diff --git a/src/modules/vdom/virtual_node.ts b/src/modules/vdom/virtual_node.ts new file mode 100644 index 0000000..3f963e3 --- /dev/null +++ b/src/modules/vdom/virtual_node.ts @@ -0,0 +1,62 @@ +import { VirtualDomRoot } from './virtual_dom_root'; + +export interface Tsx { + type: ComponentClass | string; + props: ({ [key: string]: unknown } & { key?: string }) | null; + children?: Array; +} + +export interface VirtualNodeSpec extends Tsx { + key: string | null; + root: VirtualDomRoot | null; +} + +export interface VirtualNode { + type: ComponentClass | string; + props: { [key: string]: unknown } | null; + children?: Array; + key: string | null; + state?: Component; + renderedSpec?: VirtualNodeSpec; + parent: VirtualNode | null; + eventListeners?: Map>; + root: VirtualDomRoot | null; +} + +export interface NodeWithVirtualNode extends Node { + virtualNode?: VirtualNode; + subtreeVirtualNode?: VirtualNode; +} + +export interface ComponentOptions { + props?: { [key: string]: unknown }; + children?: Array; +} + +export abstract class Component { + public props?: { [key: string]: unknown }; + public children?: Array; + public domNode?: NodeWithVirtualNode; + + constructor({ props, children }: ComponentOptions) { + this.props = props; + this.children = children; + } + + didCreate() {} + + didMount() {} + + willUpdate({ props, children }: ComponentOptions) { + this.props = props; + this.children = children; + } + + didUpdate() {} + + willDestroy() {} + + abstract render(): VirtualNodeSpec; +} + +export type ComponentClass = { new (options: ComponentOptions): Component }; From 4db764239b4a37624033bb823db28ae09992ef3f Mon Sep 17 00:00:00 2001 From: Ilya Andriyanov Date: Mon, 9 Dec 2024 15:36:12 +0300 Subject: [PATCH 02/17] feature: store manager and page-container and started responsive design --- eslint.config.mjs | 13 +++ index.d.ts | 10 ++ .../action_creators/user_action_creators.ts | 99 +++++++++++++++++++ .../components/dropdown/dropdown.tsx | 46 +++++++++ .../components/header/header.scss} | 12 +-- src/application/components/header/header.tsx | 88 +++++++++++++++++ .../page_container/page-container.scss | 13 +++ .../page_container/page_container.tsx | 17 ++++ src/application/models/applicant.ts | 32 ++++++ src/application/models/employer.ts | 30 ++++++ src/application/models/user-type.ts | 4 + .../pages/login_page/login-page.scss | 21 ++++ .../pages/login_page/login_page.tsx | 16 +++ .../pages/not_found_page/not_found_page.tsx | 16 +++ .../pages/vacancies_page/vacancies_page.tsx | 16 +++ .../stores/backend_store/backend_store.ts | 20 ++++ .../stores/user_store/user_actions.ts | 26 +++++ .../stores/user_store/user_store.ts | 43 ++++++++ src/index.ts | 44 --------- src/index.tsx | 47 +++++++++ src/modules/store_manager/action.ts | 4 + src/modules/store_manager/store.ts | 29 ++++++ src/modules/store_manager/store_manager.ts | 28 ++++++ src/modules/vdom/virtual_dom.ts | 4 +- src/modules/vdom/virtual_dom_root.ts | 19 +++- src/modules/vdom/virtual_node.ts | 13 +-- src/modules/vdom_router/router.ts | 94 ++++++++++++++++++ src/scss/_login.scss | 34 +++---- src/scss/index.scss | 56 ++++++++--- tsconfig.json | 5 +- webpack.common.js | 5 +- 31 files changed, 800 insertions(+), 104 deletions(-) create mode 100644 index.d.ts create mode 100644 src/application/action_creators/user_action_creators.ts create mode 100644 src/application/components/dropdown/dropdown.tsx rename src/{scss/_header.scss => application/components/header/header.scss} (90%) create mode 100644 src/application/components/header/header.tsx create mode 100644 src/application/components/page_container/page-container.scss create mode 100644 src/application/components/page_container/page_container.tsx create mode 100644 src/application/models/applicant.ts create mode 100644 src/application/models/employer.ts create mode 100644 src/application/models/user-type.ts create mode 100644 src/application/pages/login_page/login-page.scss create mode 100644 src/application/pages/login_page/login_page.tsx create mode 100644 src/application/pages/not_found_page/not_found_page.tsx create mode 100644 src/application/pages/vacancies_page/vacancies_page.tsx create mode 100644 src/application/stores/backend_store/backend_store.ts create mode 100644 src/application/stores/user_store/user_actions.ts create mode 100644 src/application/stores/user_store/user_store.ts delete mode 100644 src/index.ts create mode 100644 src/index.tsx create mode 100644 src/modules/store_manager/action.ts create mode 100644 src/modules/store_manager/store.ts create mode 100644 src/modules/store_manager/store_manager.ts create mode 100644 src/modules/vdom_router/router.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 39f9f1a..495a4bb 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -15,8 +15,21 @@ export default tsEslint.config( ...pluginJs.configs.recommended, files: ['src/**/*.js', 'src/**/*.mjs'], }, + { + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { + vars: 'all', + varsIgnorePattern: 'vdom', + args: 'after-used', + }, + ], + }, + }, tsEslint.configs.recommended, eslintConfigPrettier, + { files: ['src/**/*.tsx', 'src/**/*.ts'], }, diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..3cbaaa9 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,10 @@ +declare module '*.svg'; +declare module '*.png'; +declare module '*.jpg'; +declare module '*.jpeg'; +declare module '*.gif'; +declare module '*.woff'; +declare module '*.woff2'; +declare module '*.eot'; +declare module '*.ttf'; +declare module '*.otf'; diff --git a/src/application/action_creators/user_action_creators.ts b/src/application/action_creators/user_action_creators.ts new file mode 100644 index 0000000..166a811 --- /dev/null +++ b/src/application/action_creators/user_action_creators.ts @@ -0,0 +1,99 @@ +import { + UserActions, + LoginAction, + LogoutAction, +} from '@application/stores/user_store/user_actions'; +import { userStore } from '@application/stores/user_store/user_store'; +import { + login as apiLogin, + logout as apiLogout, + registerApplicant as apiRegisterApplicant, + registerEmployer as apiRegisterEmployer, + getEmployer, + getApplicant, +} from '@api/api'; +import type { + registerApplicantOptions, + registerEmployerOptions, +} from '@/modules/api/src/handlers/auth/register'; +import { backendStore } from '@application/stores/backend_store/backend_store'; +import { LoginOptions } from '@api/src/handlers/auth/login'; +import { UserType } from '@application/models/user-type'; +import { makeApplicantFromApi } from '../models/applicant'; +import { makeEmployerFromApi } from '../models/employer'; +import type { Applicant as ApiApplicant } from '@api/src/responses/applicant'; +import type { Employer as ApiEmployer } from '@api/src/responses/employer'; +import { assertIfError } from '@/modules/common_utils/asserts/asserts'; + +async function login({ userType, email, password }: LoginOptions) { + const backendOrigin = backendStore.getData().backendOrigin; + try { + const loginResponse = await apiLogin(backendOrigin, { + userType, + email, + password, + }); + const userProfile = await getUser(backendOrigin, userType, loginResponse.id); + userStore.dispatch({ + type: UserActions.Login, + payload: { + email, + userType, + userProfile, + }, + } as LoginAction); + } catch (err) { + assertIfError(err); + userStore.dispatch({ + type: UserActions.Logout, + } as LogoutAction); + } +} + +async function getUser(backendOrigin: URL, userType: UserType, id: number) { + return userType === UserType.Applicant + ? makeApplicantFromApi((await getApplicant(backendOrigin, id)) as ApiApplicant) + : makeEmployerFromApi((await getEmployer(backendOrigin, id)) as ApiEmployer); +} + +async function logout() { + const backendOrigin = backendStore.getData().backendOrigin; + try { + await apiLogout(backendOrigin); + userStore.dispatch({ + type: UserActions.Logout, + } as LogoutAction); + } catch {} +} + +async function register( + userType: UserType, + body: registerApplicantOptions | registerEmployerOptions, +) { + const backendOrigin = backendStore.getData().backendOrigin; + try { + const response = + userType === UserType.Applicant + ? await apiRegisterApplicant(backendOrigin, body as registerApplicantOptions) + : await apiRegisterEmployer(backendOrigin, body as registerEmployerOptions); + const userProfile = await getUser(backendOrigin, userType, response.id); + userStore.dispatch({ + type: UserActions.Login, + payload: { + email: body.email, + userType, + userProfile, + }, + }); + } catch { + userStore.dispatch({ + type: UserActions.Logout, + }); + } +} + +export const userActionCreators = { + login, + logout, + register, +}; diff --git a/src/application/components/dropdown/dropdown.tsx b/src/application/components/dropdown/dropdown.tsx new file mode 100644 index 0000000..c6faa74 --- /dev/null +++ b/src/application/components/dropdown/dropdown.tsx @@ -0,0 +1,46 @@ +import * as vdom from '@/modules/vdom/virtual_dom'; +import { Component } from '@/modules/vdom/virtual_node'; +import { VirtualNodeSpec } from '@/modules/vdom/virtual_node'; + +export interface DropdownProps { + elementClass: string; +} + +export class Dropdown extends Component { + private dropdownOpen: boolean = false; + constructor({ elementClass }: DropdownProps, children?: Array) { + super({ elementClass }, children); + } + + didMount(): void { + window.addEventListener('click', this.onClick); + } + + willDestroy(): void { + window.removeEventListener('click', this.onClick); + } + + private onClick = (ev: Event): void => { + if (!this.dropdownOpen) { + return; + } + const clickedInsideDropdown = + this.domNode.contains(ev.target as HTMLElement) || Object.is(this.domNode, ev.target); + if (!clickedInsideDropdown) { + this.toggleDropdown(); + } + }; + + toggleDropdown(): void { + this.dropdownOpen = !this.dropdownOpen; + vdom.updateNode(this.domNode, this.render()); + } + + render(): VirtualNodeSpec { + if (this.dropdownOpen) { + return
{...this.children}
; + } else { + return null; + } + } +} diff --git a/src/scss/_header.scss b/src/application/components/header/header.scss similarity index 90% rename from src/scss/_header.scss rename to src/application/components/header/header.scss index 60e181f..d7727c6 100644 --- a/src/scss/_header.scss +++ b/src/application/components/header/header.scss @@ -1,19 +1,9 @@ -.header-container { - display: flex; - justify-content: center; - align-items: center; - width: 100vw; - min-width: 1280px; - background-color: var(--color-background-800); -} - .header { display: flex; justify-content: space-between; align-items: center; height: 100px; - width: 100%; - max-width: 1280px; + width: var(--12-col); margin: 0 16px; background-color: var(--color-background-800); diff --git a/src/application/components/header/header.tsx b/src/application/components/header/header.tsx new file mode 100644 index 0000000..c1ba3f5 --- /dev/null +++ b/src/application/components/header/header.tsx @@ -0,0 +1,88 @@ +import * as vdom from '@/modules/vdom/virtual_dom'; +import { Component, VirtualNodeSpec } from '@/modules/vdom/virtual_node'; +import { userStore } from '@/application/stores/user_store/user_store'; +import { resolveUrl } from '@/modules/UrlUtils/UrlUtils'; +import profileMenuIconSvg from '@static/img/profile-menu-icon.svg'; +import notificationIconSvg from '@static/img/notification-icon-36.svg'; +import menuIconSvg from '@static/img/menu-icon-48.svg'; +import cvMenuIconSvg from '@static/img/cv-menu-icon.svg'; +import vacancyMenuIconSvg from '@static/img/vacancy-menu-icon.svg'; +import logoutMenuIconSvg from '@static/img/logout-menu-icon.svg'; +import { Dropdown } from '@/application/components/dropdown/dropdown'; +import { UserType } from '@/application/models/user-type'; +import './header.scss'; + +export class Header extends Component { + render(): VirtualNodeSpec { + const userData = userStore.getData(); + return ( + + ); + } +} diff --git a/src/application/components/page_container/page-container.scss b/src/application/components/page_container/page-container.scss new file mode 100644 index 0000000..c6cc610 --- /dev/null +++ b/src/application/components/page_container/page-container.scss @@ -0,0 +1,13 @@ +.page-container__header-container { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + background-color: var(--color-background-800); +} + +.page-container { + display: block; + height: 100vh; + width: 100vw; +} diff --git a/src/application/components/page_container/page_container.tsx b/src/application/components/page_container/page_container.tsx new file mode 100644 index 0000000..0ad6c89 --- /dev/null +++ b/src/application/components/page_container/page_container.tsx @@ -0,0 +1,17 @@ +import { Component } from '@/modules/vdom/virtual_node'; +import * as vdom from '@/modules/vdom/virtual_dom'; +import { Header } from '@/application/components/header/header'; +import './page-container.scss'; + +export class PageContainer extends Component { + render() { + return ( +
+
+
+
+ {this.children} +
+ ); + } +} diff --git a/src/application/models/applicant.ts b/src/application/models/applicant.ts new file mode 100644 index 0000000..9d6fd8f --- /dev/null +++ b/src/application/models/applicant.ts @@ -0,0 +1,32 @@ +import fallbackUserAvatar from '@static/img/user-icon-80.svg'; +import { Applicant as ApiApplicant } from '@api/src/responses/applicant'; + +/** Data structure representing Applicant */ +export interface Applicant { + id: number; + firstName: string; + secondName: string; + city: string; + birthDate: Date; + avatar: string; + contacts: string; + education: string; +} + +/** + * Converts an applicant response from the API to an instance of Applicant. + * @param apiResponse - Applicant response from the API + * @returns an instance of Applicant + */ +export function makeApplicantFromApi(apiResponse: ApiApplicant): Applicant { + return { + id: apiResponse.id, + firstName: apiResponse.firstName, + secondName: apiResponse.lastName, + city: apiResponse.city, + birthDate: new Date(apiResponse.birthDate), + avatar: apiResponse.avatar || fallbackUserAvatar, + contacts: apiResponse.contacts, + education: apiResponse.education, + }; +} diff --git a/src/application/models/employer.ts b/src/application/models/employer.ts new file mode 100644 index 0000000..77d555d --- /dev/null +++ b/src/application/models/employer.ts @@ -0,0 +1,30 @@ +import fallbackUserAvatar from '@static/img/user-icon-80.svg'; +import { Employer as ApiEmployer } from '@api/src/responses/employer'; + +export interface Employer { + id: number; + firstName: string; + secondName: string; + city: string; + position: string; + companyName: string; + companyDescription: string; + companyWebsite: string; + contacts: string; + avatar: string; +} + +export function makeEmployerFromApi(apiResponse: ApiEmployer): Employer { + return { + id: apiResponse.id, + firstName: apiResponse.firstName, + secondName: apiResponse.lastName, + city: apiResponse.city, + position: apiResponse.position, + companyName: apiResponse.companyName, + companyDescription: apiResponse.companyDescription, + companyWebsite: apiResponse.companyWebsite, + contacts: apiResponse.contacts, + avatar: apiResponse.avatar || fallbackUserAvatar, + }; +} diff --git a/src/application/models/user-type.ts b/src/application/models/user-type.ts new file mode 100644 index 0000000..e9dbdbb --- /dev/null +++ b/src/application/models/user-type.ts @@ -0,0 +1,4 @@ +export enum UserType { + Employer = 'employer', + Applicant = 'applicant', +} diff --git a/src/application/pages/login_page/login-page.scss b/src/application/pages/login_page/login-page.scss new file mode 100644 index 0000000..47bb0a1 --- /dev/null +++ b/src/application/pages/login_page/login-page.scss @@ -0,0 +1,21 @@ +.login-page { + display: flex; + flex-direction: column; + height: 100vh; + align-items: center; + justify-content: center; + + &__login-container { + display: flex; + flex-direction: column; + justify-content: center; + width: var(--4-col); + + &_theme-dark { + background-color: var(--color-background-900); + } + } + + &__header { + } +} diff --git a/src/application/pages/login_page/login_page.tsx b/src/application/pages/login_page/login_page.tsx new file mode 100644 index 0000000..9a0f760 --- /dev/null +++ b/src/application/pages/login_page/login_page.tsx @@ -0,0 +1,16 @@ +import { Component } from '@/modules/vdom/virtual_node'; +import * as vdom from '@/modules/vdom/virtual_dom'; +import './login-page.scss'; + +export class LoginPage extends Component { + render() { + return ( + + // login form here + ); + } +} diff --git a/src/application/pages/not_found_page/not_found_page.tsx b/src/application/pages/not_found_page/not_found_page.tsx new file mode 100644 index 0000000..d2feab8 --- /dev/null +++ b/src/application/pages/not_found_page/not_found_page.tsx @@ -0,0 +1,16 @@ +import { Component, VirtualNodeSpec } from '@/modules/vdom/virtual_node'; +import * as vdom from '@/modules/vdom/virtual_dom'; + +export interface NotFoundPageProps { + url: URL; +} + +export class NotFoundPage extends Component { + constructor({ url }: NotFoundPageProps) { + super({ url }); + } + + render(): VirtualNodeSpec { + return
{this.props.url.toString()} Not Found (404)
; + } +} diff --git a/src/application/pages/vacancies_page/vacancies_page.tsx b/src/application/pages/vacancies_page/vacancies_page.tsx new file mode 100644 index 0000000..1777c3c --- /dev/null +++ b/src/application/pages/vacancies_page/vacancies_page.tsx @@ -0,0 +1,16 @@ +import { Component } from '@/modules/vdom/virtual_node'; +import * as vdom from '@/modules/vdom/virtual_dom'; +import { PageContainer } from '@/application/components/page_container/page_container'; + +export class VacanciesPage extends Component { + constructor({ url }: { url: URL }) { + super({ url }); + } + render() { + return ( + +

Вакансии

+
+ ); + } +} diff --git a/src/application/stores/backend_store/backend_store.ts b/src/application/stores/backend_store/backend_store.ts new file mode 100644 index 0000000..a5dc102 --- /dev/null +++ b/src/application/stores/backend_store/backend_store.ts @@ -0,0 +1,20 @@ +import { Store } from '@/modules/store_manager/store'; +import { storeManager } from '@/modules/store_manager/store_manager'; +import backendConfig from '@/config/backend.json'; + +export interface BackendData { + backendOrigin: URL; +} + +function backendStoreReducer(curData: BackendData): BackendData { + return curData; +} + +export const backendStore = new Store( + { + backendOrigin: new URL(backendConfig.backendPrefix), + }, + backendStoreReducer, +); + +storeManager.addStore(backendStore); diff --git a/src/application/stores/user_store/user_actions.ts b/src/application/stores/user_store/user_actions.ts new file mode 100644 index 0000000..ae2665f --- /dev/null +++ b/src/application/stores/user_store/user_actions.ts @@ -0,0 +1,26 @@ +/** @fileoverview This file contains actions for user store */ + +import { UserType } from '@/application/models/user-type'; +import { Action } from '@/modules/store_manager/action'; +import { Applicant } from '@application/models/applicant'; +import { Employer } from '@application/models/employer'; + +export enum UserActions { + Logout = 'logout', + Login = 'login', +} + +export interface LogoutAction extends Action { + type: UserActions.Logout; +} + +export interface LoginActionPayload { + email: string; + userType: UserType; + userProfile: Applicant | Employer; +} + +export interface LoginAction extends Action { + type: UserActions.Login; + payload: LoginActionPayload; +} diff --git a/src/application/stores/user_store/user_store.ts b/src/application/stores/user_store/user_store.ts new file mode 100644 index 0000000..dbe03da --- /dev/null +++ b/src/application/stores/user_store/user_store.ts @@ -0,0 +1,43 @@ +import { Store } from '@/modules/store_manager/store'; +import { Applicant } from '@application/models/applicant'; +import { Employer } from '@application/models/employer'; +import { storeManager } from '@/modules/store_manager/store_manager'; +import { Action } from '@/modules/store_manager/action'; +import { LoginActionPayload, UserActions } from './user_actions'; +import { UserType } from '@/application/models/user-type'; + +export interface UserData { + isLoggedIn: boolean; + userType?: UserType; + email?: string; + userProfile?: Applicant | Employer; +} + +function userStoreReducer(state: UserData, action: Action) { + switch (action.type) { + case UserActions.Logout: { + return { + isLoggedIn: false, + } as UserData; + } + + case UserActions.Login: { + const payload = action.payload as LoginActionPayload; + return { + isLoggedIn: true, + userType: payload.userType, + email: payload.email, + userProfile: payload.userProfile, + }; + } + } +} + +export const userStore = new Store( + { + isLoggedIn: false, + }, + userStoreReducer, +); + +storeManager.addStore(userStore); diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 9fdadc7..0000000 --- a/src/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -import router from '@/modules/Router/Router'; -import eventBus from './modules/Events/EventBus'; -import appState from './modules/AppState/AppState'; -import { LoginPage } from './Pages/LoginPage/LoginPage'; -import { RegistrationPage } from './Pages/RegistrationPage/RegistrationPage'; -import { VacanciesPage } from './Pages/VacanciesPage/VacanciesPage'; -import { ProfilePage } from './Pages/ProfilePage/ProfilePage'; -import { VacancyPage } from './Pages/VacancyPage/VacancyPage'; -import { VacancyEditPage } from './Pages/VacancyEditPage/VacancyEditPage'; -import { resolveUrl } from './modules/UrlUtils/UrlUtils'; -import { REDIRECT_TO, GO_TO } from './modules/Events/Events'; -import { CvPage } from './Pages/CvPage/CvPage'; -import { CvEditPage } from './Pages/CvEditPage/CvEditPage'; -import { NotificationBox } from './Components/NotificationBox/NotificationBox'; -import './scss/index.scss'; - -// eslint-disable-next-line -const notificationBox = new NotificationBox({ - existingElement: document.querySelector('.notification-box'), -}); - -router.addRoute(resolveUrl('vacancies', null).pathname, VacanciesPage); -router.addRoute(resolveUrl('login', null).pathname, LoginPage); -router.addRoute(resolveUrl('register', null).pathname, RegistrationPage); -router.addRoute(resolveUrl('myProfile', null).pathname, ProfilePage); -router.addRoute(resolveUrl('profile', null).pathname, ProfilePage); -router.addRoute(resolveUrl('vacancy', null).pathname, VacancyPage); -router.addRoute(resolveUrl('createVacancy', null).pathname, VacancyEditPage); -router.addRoute(resolveUrl('editVacancy', null).pathname, VacancyEditPage); -router.addRoute(resolveUrl('cv', null).pathname, CvPage); -router.addRoute(resolveUrl('createCv', null).pathname, CvEditPage); -router.addRoute(resolveUrl('editCv', null).pathname, CvEditPage); - -eventBus.on(REDIRECT_TO, ({ redirectUrl }: { redirectUrl: URL }) => { - router.navigate(redirectUrl, true, true); -}); - -eventBus.on(GO_TO, ({ redirectUrl }: { redirectUrl: URL }) => { - router.navigate(redirectUrl, false, true); -}); - -appState.userSession.checkAuthorization().finally(() => { - router.start(); -}); diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 0000000..2c5870d --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,47 @@ +import { Router } from '@/modules/vdom_router/router'; +import eventBus from './modules/Events/EventBus'; +import appState from './modules/AppState/AppState'; +import { LoginPage } from '@/application/pages/login_page/login_page'; +// import { RegistrationPage } from './Pages/RegistrationPage/RegistrationPage'; +// import { ProfilePage } from './Pages/ProfilePage/ProfilePage'; +// import { VacancyPage } from './Pages/VacancyPage/VacancyPage'; +// import { VacancyEditPage } from './Pages/VacancyEditPage/VacancyEditPage'; +import { resolveUrl } from './modules/UrlUtils/UrlUtils'; +import { REDIRECT_TO, GO_TO } from './modules/Events/Events'; +// import { CvPage } from './Pages/CvPage/CvPage'; +// import { CvEditPage } from './Pages/CvEditPage/CvEditPage'; +import { NotificationBox } from './Components/NotificationBox/NotificationBox'; +import { VacanciesPage } from '@/application/pages/vacancies_page/vacancies_page'; +import './scss/index.scss'; + +// eslint-disable-next-line +const notificationBox = new NotificationBox({ + existingElement: document.querySelector('.notification-box'), +}); + +// router.addRoute(resolveUrl('register', null).pathname, RegistrationPage); +// router.addRoute(resolveUrl('myProfile', null).pathname, ProfilePage); +// router.addRoute(resolveUrl('profile', null).pathname, ProfilePage); +// router.addRoute(resolveUrl('vacancy', null).pathname, VacancyPage); +// router.addRoute(resolveUrl('createVacancy', null).pathname, VacancyEditPage); +// router.addRoute(resolveUrl('editVacancy', null).pathname, VacancyEditPage); +// router.addRoute(resolveUrl('cv', null).pathname, CvPage); +// router.addRoute(resolveUrl('createCv', null).pathname, CvEditPage); +// router.addRoute(resolveUrl('editCv', null).pathname, CvEditPage); + +const router = new Router(document.getElementById('app')); + +router.addRoute(resolveUrl('vacancies', null).pathname, VacanciesPage); +router.addRoute(resolveUrl('login', null).pathname, LoginPage); + +eventBus.on(REDIRECT_TO, ({ redirectUrl }: { redirectUrl: URL }) => { + router.navigate(redirectUrl, true, true); +}); + +eventBus.on(GO_TO, ({ redirectUrl }: { redirectUrl: URL }) => { + router.navigate(redirectUrl, false, true); +}); + +appState.userSession.checkAuthorization().finally(() => { + router.start(); +}); diff --git a/src/modules/store_manager/action.ts b/src/modules/store_manager/action.ts new file mode 100644 index 0000000..e08e494 --- /dev/null +++ b/src/modules/store_manager/action.ts @@ -0,0 +1,4 @@ +export interface Action { + type: string; + payload?: unknown; +} diff --git a/src/modules/store_manager/store.ts b/src/modules/store_manager/store.ts new file mode 100644 index 0000000..aaa988e --- /dev/null +++ b/src/modules/store_manager/store.ts @@ -0,0 +1,29 @@ +import { Action } from './action'; + +export class Store { + private data: DataType; + private reducer: (curData: DataType, action?: Action) => DataType; + private updateCallbacks: Array<() => void> = []; + + constructor(initialData: DataType, reducer: (curData: DataType, action?: Action) => DataType) { + this.data = initialData; + this.reducer = reducer; + } + + dispatch(action: Action) { + this.data = this.reducer(this.data, action); + this.updateCallbacks.forEach((callback) => callback()); + } + + addCallback(callback: () => void) { + this.updateCallbacks.push(callback); + } + + removeCallback(callback: () => void) { + this.updateCallbacks = this.updateCallbacks.filter((cb) => cb !== callback); + } + + getData() { + return this.data; + } +} diff --git a/src/modules/store_manager/store_manager.ts b/src/modules/store_manager/store_manager.ts new file mode 100644 index 0000000..6e74adf --- /dev/null +++ b/src/modules/store_manager/store_manager.ts @@ -0,0 +1,28 @@ +import { Store } from './store'; +import { Action } from './action'; +import { VirtualDomRoot } from '@/modules/vdom/virtual_dom_root'; +import * as vdom from '@/modules/vdom/virtual_dom'; + +class StoreManager { + private stores: Array> = []; + // Since there can be more than one Virtual DOM in page, + // it is an Array + private vdomRoots: Array = []; + + addStore(store: Store) { + this.stores.push(store); + } + + bindVirtualDom(vdomRoot: VirtualDomRoot) { + this.vdomRoots.push(vdomRoot); + } + + dispatch(action: Action) { + // All views should subscribe to each store they want to use, + // But dom update will be performed here + this.stores.forEach((store) => store.dispatch(action)); + this.vdomRoots.forEach((vdomRoot: VirtualDomRoot) => vdomRoot.update()); + } +} + +export const storeManager = new StoreManager(); diff --git a/src/modules/vdom/virtual_dom.ts b/src/modules/vdom/virtual_dom.ts index 5155f3e..c695c63 100644 --- a/src/modules/vdom/virtual_dom.ts +++ b/src/modules/vdom/virtual_dom.ts @@ -124,7 +124,7 @@ export function createNode(spec: VirtualNodeSpec | string): NodeWithVirtualNode // We got a component - newVirtualNode.state = new type({ props, children }); + newVirtualNode.state = new type(props, children); newVirtualNode.renderedSpec = newVirtualNode.state.render(); newVirtualNode.renderedSpec.root = newVirtualNode.root; const domNode = createNode(newVirtualNode.renderedSpec); @@ -150,7 +150,7 @@ export function updateNode( const isComponentNode = typeof curNode.type !== 'string'; if (isComponentNode) { - curNode.state.willUpdate(newSpec); + curNode.state.willUpdate(newSpec.props, newSpec.children); const newRender = curNode.state.render(); curNode.renderedSpec = newRender; } else { diff --git a/src/modules/vdom/virtual_dom_root.ts b/src/modules/vdom/virtual_dom_root.ts index 32a7b5c..5139291 100644 --- a/src/modules/vdom/virtual_dom_root.ts +++ b/src/modules/vdom/virtual_dom_root.ts @@ -1,4 +1,4 @@ -import type { Tsx, NodeWithVirtualNode, VirtualNode } from './virtual_node'; +import type { Tsx, NodeWithVirtualNode, VirtualNode, VirtualNodeSpec } from './virtual_node'; import { createElement, createNode, updateNode, destroyNode } from './virtual_dom'; export function isEventProperty(propertyName: string): boolean { @@ -13,25 +13,38 @@ export class VirtualDomRoot { private eventListeners: Map void>; private registeredEventsAmount: Map; private renderedNode?: NodeWithVirtualNode; + private previousSpec?: VirtualNodeSpec; constructor(private domNode: HTMLElement & NodeWithVirtualNode) { this.eventListeners = new Map void>(); this.registeredEventsAmount = new Map(); } - update(tsx: Tsx) { + update(tsx?: Tsx) { + if (tsx === undefined) { + if (this.previousSpec === undefined) { + return; + } + updateNode(this.renderedNode, this.previousSpec); + return; + } const newSpec = createElement(tsx.type, tsx.props, ...tsx.children); newSpec.root = this; + this.previousSpec = newSpec; updateNode(this.renderedNode, newSpec); } render(tsx: Tsx | string) { - destroyNode(this.domNode); + this.domNode.childNodes.forEach((child) => { + destroyNode(child); + this.domNode.removeChild(child); + }); if (typeof tsx === 'string') { this.domNode.textContent = tsx; return; } const vDomSpec = createElement(tsx.type, tsx.props, ...tsx.children); vDomSpec.root = this; + this.previousSpec = vDomSpec; this.renderedNode = createNode(vDomSpec); this.domNode.appendChild(this.renderedNode); } diff --git a/src/modules/vdom/virtual_node.ts b/src/modules/vdom/virtual_node.ts index 3f963e3..2898cde 100644 --- a/src/modules/vdom/virtual_node.ts +++ b/src/modules/vdom/virtual_node.ts @@ -28,17 +28,12 @@ export interface NodeWithVirtualNode extends Node { subtreeVirtualNode?: VirtualNode; } -export interface ComponentOptions { - props?: { [key: string]: unknown }; - children?: Array; -} - export abstract class Component { public props?: { [key: string]: unknown }; public children?: Array; public domNode?: NodeWithVirtualNode; - constructor({ props, children }: ComponentOptions) { + constructor(props?: { [key: string]: unknown }, children?: Array) { this.props = props; this.children = children; } @@ -47,7 +42,7 @@ export abstract class Component { didMount() {} - willUpdate({ props, children }: ComponentOptions) { + willUpdate(props?: { [key: string]: unknown }, children?: Array) { this.props = props; this.children = children; } @@ -59,4 +54,6 @@ export abstract class Component { abstract render(): VirtualNodeSpec; } -export type ComponentClass = { new (options: ComponentOptions): Component }; +export type ComponentClass = { + new (props?: { [key: string]: unknown }, children?: Array): Component; +}; diff --git a/src/modules/vdom_router/router.ts b/src/modules/vdom_router/router.ts new file mode 100644 index 0000000..3e126c4 --- /dev/null +++ b/src/modules/vdom_router/router.ts @@ -0,0 +1,94 @@ +import { Component } from '@/modules/vdom/virtual_node'; +import { NotFoundPage } from '@/application/pages/not_found_page/not_found_page'; +import { VirtualDomRoot } from '@/modules/vdom/virtual_dom_root'; + +export interface PageClass { + new (props: { url: URL }): Component; +} + +export class ForbiddenPage extends Error { + public redirectUrl: URL; + constructor(redirectUrl: URL) { + super('forbidden page'); + this.redirectUrl = redirectUrl; + Object.setPrototypeOf(this, ForbiddenPage.prototype); + } +} + +export class NotFoundError extends Error { + constructor() { + super('resource not found'); + Object.setPrototypeOf(this, NotFoundError.prototype); + } +} + +export class Router { + private routes: Map; + private currentPage?: Component; + private vdomRoot: VirtualDomRoot; + + constructor(rootNode: HTMLElement) { + this.routes = new Map(); + this.vdomRoot = new VirtualDomRoot(rootNode); + } + + addRoute(pathname: string, pageClass: PageClass) { + this.routes.set(pathname, pageClass); + } + + removeRoute(pathname: string): boolean { + return this.routes.delete(pathname); + } + + navigate(url: URL, redirection: boolean = false, modifyHistory: boolean = true) { + try { + if (modifyHistory) { + if (!redirection) { + history.pushState(null, '', url); + } else { + history.replaceState(null, '', url); + } + } + const newPage = this.routes.has(url.pathname) ? this.routes.get(url.pathname) : NotFoundPage; + this.replacePage(newPage, url); + } catch (err) { + if (err instanceof ForbiddenPage) { + this.navigate(err.redirectUrl, true, true); + return; + } + if (err instanceof NotFoundError) { + this.replacePage(NotFoundPage, url); + return; + } + throw err; + } + } + + private replacePage(newPageClass: PageClass, newPageUrl: URL) { + this.vdomRoot.render({ type: newPageClass, props: { url: newPageUrl }, children: [] }); + } + + start() { + window.addEventListener('popstate', (ev) => { + ev.preventDefault(); + this.navigate(new URL(location.href), false, false); + }); + + window.addEventListener('click', (ev) => { + let currentElement = ev.target as HTMLElement; + while (currentElement) { + if ( + currentElement instanceof HTMLAnchorElement && + currentElement.origin === location.origin + ) { + ev.preventDefault(); + this.navigate(new URL(currentElement.href)); + break; + } + currentElement = currentElement.parentElement; + } + }); + + this.navigate(new URL(location.href), false, false); + } +} diff --git a/src/scss/_login.scss b/src/scss/_login.scss index 9d2b919..4dd7050 100644 --- a/src/scss/_login.scss +++ b/src/scss/_login.scss @@ -1,20 +1,20 @@ -.login-page { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - width: 100vw; - height: 100vh; - &__login-container { - padding: 20px; - border-radius: 10px; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); - width: 100%; - max-width: 400px; - box-sizing: border-box; - background-color: var(--color-background-900); - } -} +// .login-page { +// display: flex; +// flex-direction: column; +// justify-content: center; +// align-items: center; +// width: 100vw; +// height: 100vh; +// &__login-container { +// padding: 20px; +// border-radius: 10px; +// box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +// width: 100%; +// max-width: 400px; +// box-sizing: border-box; +// background-color: var(--color-background-900); +// } +// } .login-container { display: flex; diff --git a/src/scss/index.scss b/src/scss/index.scss index f0ae7bd..cfe8722 100644 --- a/src/scss/index.scss +++ b/src/scss/index.scss @@ -1,7 +1,6 @@ @use 'fonts'; @use 'button'; @use 'vacancies'; -@use 'header'; @use 'login'; @use 'registration'; @use 'forms'; @@ -10,15 +9,12 @@ @use 'cv'; @use 'common'; -:root { - --grey-very-dark: #1b1b1b; - --grey-dark: #313131; - --grey-medium: #3d3d3d; - --grey-light: #555555; - --primary-light: #53dcd4; - --secondary-light: #d0bcff; - --text-primary: #cac4d0; +$container-width: 1248px; +$col-width: 82px; +$col-margin: 24px; +$mobile-breakpoint: 768px; +:root { --color-background-1000: #102a43; --color-background-900: #243b53; --color-background-800: #334e68; @@ -77,16 +73,44 @@ --text-weight-regular: 400; --text-weight-bold: 600; + + --container-width: #{$container-width}; + --col-width: #{$col-width}; + --col-margin: #{$col-margin}; + + --2-col: #{calc(2 * $col-width + $col-margin)}; + --3-col: #{calc(3 * $col-width + 2 * $col-margin)}; + --4-col: #{calc(4 * $col-width + 3 * $col-margin)}; + --5-col: #{calc(5 * $col-width + 4 * $col-margin)}; + --6-col: #{calc(6 * $col-width + 5 * $col-margin)}; + --7-col: #{calc(7 * $col-width + 6 * $col-margin)}; + --8-col: #{calc(8 * $col-width + 7 * $col-margin)}; + --9-col: #{calc(9 * $col-width + 8 * $col-margin)}; + --10-col: #{calc(10 * $col-width + 9 * $col-margin)}; + --11-col: #{calc(11 * $col-width + 10 * $col-margin)}; + --12-col: #{calc(12 * $col-width + 11 * $col-margin)}; +} + +@media screen and (max-width: $mobile-breakpoint) { + :root { + --container-width: 100%; + --col-width: 100%; + --col-margin: 0; + --2-col: 100%; + --3-col: 100%; + --4-col: 100%; + --5-col: 100%; + --6-col: 100%; + --7-col: 100%; + --8-col: 100%; + --9-col: 100%; + --10-col: 100%; + --11-col: 100%; + --12-col: 100%; + } } body { margin: 0; padding: 0; - background-color: var(--color-background-1000); -} - -.page-container { - display: flex; - flex-direction: column; - align-items: center; } diff --git a/tsconfig.json b/tsconfig.json index d6f381c..0ad112e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,11 +10,14 @@ "esModuleInterop": true, "moduleResolution": "bundler", "baseUrl": "./", + "jsx": "react", + "jsxFactory": "vdom.createElement", "paths": { "@/*": ["src/*"], "@static/*": ["src/public/*"], "@api/*": ["src/modules/api/*"], - "@common_utils/*": ["src/modules/common_utils/*"] + "@common_utils/*": ["src/modules/common_utils/*"], + "@application/*": ["src/application/*"] } } } diff --git a/webpack.common.js b/webpack.common.js index 14694ea..4330492 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -90,11 +90,11 @@ module.exports = { ], }, entry: { - app: './src/index.ts', + app: './src/index.tsx', }, plugins: [ new HtmlWebpackPlugin({ - title: 'Art', + title: 'uArt', template: path.resolve(__dirname, './src/index.hbs'), }), ], @@ -104,6 +104,7 @@ module.exports = { '@static': path.resolve(__dirname, 'src/public'), '@api': path.resolve(__dirname, 'src/modules/api'), '@common_utils': path.resolve(__dirname, 'src/modules/common_utils'), + '@application': path.resolve(__dirname, 'src/application'), }, extensions: ['.tsx', '.ts', '.js'], }, From 6ac43c95fd079b27603e7e84b77b80058861373d Mon Sep 17 00:00:00 2001 From: Ilya Andriyanov Date: Tue, 10 Dec 2024 22:29:25 +0300 Subject: [PATCH 03/17] feature: working login without redirect --- .../FormInputs/EmailInput/EmailInput.js | 16 -- .../EmailInput/EmailInputController.js | 3 - .../FormInputs/EmailInput/EmailInputModel.js | 9 -- .../FormInputs/EmailInput/EmailInputView.js | 3 - .../FormInputs/PasswordInput/PasswordInput.js | 16 -- .../PasswordInput/PasswordInputController.js | 3 - .../PasswordInput/PasswordInputModel.js | 12 -- .../PasswordInput/PasswordInputView.js | 3 - src/Pages/LoginPage/LoginPageController.js | 3 - src/Pages/LoginPage/LoginPageModel.js | 3 - src/Pages/LoginPage/LoginPageView.js | 16 -- src/Pages/LoginPage/login-page.hbs | 8 - src/Pages/NotFoundPage/NotFoundPage.js | 24 --- .../NotFoundPage/NotFoundPageController.js | 3 - src/Pages/NotFoundPage/NotFoundPageModel.js | 3 - src/Pages/NotFoundPage/NotFoundPageView.js | 8 - src/Pages/NotFoundPage/not-found-page.hbs | 1 - .../action_creators/user_action_creators.ts | 40 ++++- src/application/components/input/input.scss | 40 +++++ src/application/components/input/input.tsx | 72 +++++++++ .../page_container/page-container.scss | 5 +- .../user_type_select/user_type_select.scss | 50 +++++++ .../user_type_select/user_type_select.tsx | 60 ++++++++ src/application/models/form_value.ts | 6 + .../pages/login_page/login-page.scss | 49 +++++-- .../pages/login_page/login_page.tsx | 72 ++++++++- .../stores/user_store/user_actions.ts | 7 + .../stores/user_store/user_store.ts | 18 +++ src/application/validators/validators.ts | 28 ++++ src/index.tsx | 9 +- src/modules/FormUtils/FormUtils.js | 8 - src/modules/Router/Router.ts | 138 ------------------ src/modules/vdom/virtual_dom.ts | 20 ++- src/modules/vdom/virtual_dom_root.ts | 3 + src/modules/vdom_router/router.ts | 5 +- src/scss/_common.scss | 12 -- src/scss/_forms.scss | 92 ------------ src/scss/_login.scss | 59 -------- src/scss/index.scss | 69 +++++---- tsconfig.json | 1 + webpack.dev.js | 1 + 41 files changed, 496 insertions(+), 502 deletions(-) delete mode 100644 src/Components/FormInputs/EmailInput/EmailInput.js delete mode 100644 src/Components/FormInputs/EmailInput/EmailInputController.js delete mode 100644 src/Components/FormInputs/EmailInput/EmailInputModel.js delete mode 100644 src/Components/FormInputs/EmailInput/EmailInputView.js delete mode 100644 src/Components/FormInputs/PasswordInput/PasswordInput.js delete mode 100644 src/Components/FormInputs/PasswordInput/PasswordInputController.js delete mode 100644 src/Components/FormInputs/PasswordInput/PasswordInputModel.js delete mode 100644 src/Components/FormInputs/PasswordInput/PasswordInputView.js delete mode 100644 src/Pages/LoginPage/LoginPageController.js delete mode 100644 src/Pages/LoginPage/LoginPageModel.js delete mode 100644 src/Pages/LoginPage/LoginPageView.js delete mode 100644 src/Pages/LoginPage/login-page.hbs delete mode 100644 src/Pages/NotFoundPage/NotFoundPage.js delete mode 100644 src/Pages/NotFoundPage/NotFoundPageController.js delete mode 100644 src/Pages/NotFoundPage/NotFoundPageModel.js delete mode 100644 src/Pages/NotFoundPage/NotFoundPageView.js delete mode 100644 src/Pages/NotFoundPage/not-found-page.hbs create mode 100644 src/application/components/input/input.scss create mode 100644 src/application/components/input/input.tsx create mode 100644 src/application/components/user_type_select/user_type_select.scss create mode 100644 src/application/components/user_type_select/user_type_select.tsx create mode 100644 src/application/models/form_value.ts create mode 100644 src/application/validators/validators.ts delete mode 100644 src/modules/FormUtils/FormUtils.js delete mode 100644 src/modules/Router/Router.ts diff --git a/src/Components/FormInputs/EmailInput/EmailInput.js b/src/Components/FormInputs/EmailInput/EmailInput.js deleted file mode 100644 index 612a30b..0000000 --- a/src/Components/FormInputs/EmailInput/EmailInput.js +++ /dev/null @@ -1,16 +0,0 @@ -import { Component } from '@/modules/Components/Component'; -import { EmailInputController } from './EmailInputController'; -import { EmailInputView } from './EmailInputView'; -import { EmailInputModel } from './EmailInputModel'; - -export class EmailInput extends Component { - constructor({ existingElement, selfValidate = false }) { - super({ - modelClass: EmailInputModel, - viewClass: EmailInputView, - existingElement, - controllerClass: EmailInputController, - controllerParams: { selfValidate }, - }); - } -} diff --git a/src/Components/FormInputs/EmailInput/EmailInputController.js b/src/Components/FormInputs/EmailInput/EmailInputController.js deleted file mode 100644 index a52336e..0000000 --- a/src/Components/FormInputs/EmailInput/EmailInputController.js +++ /dev/null @@ -1,3 +0,0 @@ -import { ValidatedInputController } from '@/Components/FormInputs/ValidatedInput/ValidatedInputController'; - -export const EmailInputController = ValidatedInputController; diff --git a/src/Components/FormInputs/EmailInput/EmailInputModel.js b/src/Components/FormInputs/EmailInput/EmailInputModel.js deleted file mode 100644 index 3fc44d3..0000000 --- a/src/Components/FormInputs/EmailInput/EmailInputModel.js +++ /dev/null @@ -1,9 +0,0 @@ -import { ValidatedInputModel } from '@/Components/FormInputs/ValidatedInput/ValidatedInputModel'; - -export class EmailInputModel extends ValidatedInputModel { - validate(email) { - email = email.trim(); - const matches = email.match(/^(".*"|[^@]*)@[^@]*$/); - return matches ? '' : 'Введен некорректный адрес'; - } -} diff --git a/src/Components/FormInputs/EmailInput/EmailInputView.js b/src/Components/FormInputs/EmailInput/EmailInputView.js deleted file mode 100644 index f29a155..0000000 --- a/src/Components/FormInputs/EmailInput/EmailInputView.js +++ /dev/null @@ -1,3 +0,0 @@ -import { ValidatedInputView } from '@/Components/FormInputs/ValidatedInput/ValidatedInputView'; - -export const EmailInputView = ValidatedInputView; diff --git a/src/Components/FormInputs/PasswordInput/PasswordInput.js b/src/Components/FormInputs/PasswordInput/PasswordInput.js deleted file mode 100644 index c93f374..0000000 --- a/src/Components/FormInputs/PasswordInput/PasswordInput.js +++ /dev/null @@ -1,16 +0,0 @@ -import { Component } from '@/modules/Components/Component'; -import { PasswordInputController } from './PasswordInputController'; -import { PasswordInputView } from './PasswordInputView'; -import { PasswordInputModel } from './PasswordInputModel'; - -export class PasswordInput extends Component { - constructor({ existingElement, selfValidate = false }) { - super({ - modelClass: PasswordInputModel, - viewClass: PasswordInputView, - existingElement, - controllerClass: PasswordInputController, - controllerParams: { selfValidate }, - }); - } -} diff --git a/src/Components/FormInputs/PasswordInput/PasswordInputController.js b/src/Components/FormInputs/PasswordInput/PasswordInputController.js deleted file mode 100644 index 85f0e18..0000000 --- a/src/Components/FormInputs/PasswordInput/PasswordInputController.js +++ /dev/null @@ -1,3 +0,0 @@ -import { ValidatedInputController } from '@/Components/FormInputs/ValidatedInput/ValidatedInputController'; - -export const PasswordInputController = ValidatedInputController; diff --git a/src/Components/FormInputs/PasswordInput/PasswordInputModel.js b/src/Components/FormInputs/PasswordInput/PasswordInputModel.js deleted file mode 100644 index c435f73..0000000 --- a/src/Components/FormInputs/PasswordInput/PasswordInputModel.js +++ /dev/null @@ -1,12 +0,0 @@ -import { ValidatedInputModel } from '@/Components/FormInputs/ValidatedInput/ValidatedInputModel'; - -export class PasswordInputModel extends ValidatedInputModel { - #MIN_PASSWORD_LEN = 8; - - validate(password) { - if (password.length < this.#MIN_PASSWORD_LEN) { - return 'Введите пароль длиной хотя бы 8 символов'; - } - return ''; - } -} diff --git a/src/Components/FormInputs/PasswordInput/PasswordInputView.js b/src/Components/FormInputs/PasswordInput/PasswordInputView.js deleted file mode 100644 index f44ef61..0000000 --- a/src/Components/FormInputs/PasswordInput/PasswordInputView.js +++ /dev/null @@ -1,3 +0,0 @@ -import { ValidatedInputView } from '@/Components/FormInputs/ValidatedInput/ValidatedInputView'; - -export const PasswordInputView = ValidatedInputView; diff --git a/src/Pages/LoginPage/LoginPageController.js b/src/Pages/LoginPage/LoginPageController.js deleted file mode 100644 index 0f96ebd..0000000 --- a/src/Pages/LoginPage/LoginPageController.js +++ /dev/null @@ -1,3 +0,0 @@ -import { PageController } from '@/modules/Page/Page'; - -export const LoginPageController = PageController; diff --git a/src/Pages/LoginPage/LoginPageModel.js b/src/Pages/LoginPage/LoginPageModel.js deleted file mode 100644 index 56a44bb..0000000 --- a/src/Pages/LoginPage/LoginPageModel.js +++ /dev/null @@ -1,3 +0,0 @@ -import { PageModel } from '@/modules/Page/Page'; - -export const LoginPageModel = PageModel; diff --git a/src/Pages/LoginPage/LoginPageView.js b/src/Pages/LoginPage/LoginPageView.js deleted file mode 100644 index 0d720b0..0000000 --- a/src/Pages/LoginPage/LoginPageView.js +++ /dev/null @@ -1,16 +0,0 @@ -import { PageView } from '@/modules/Page/Page'; -import LoginPageHbs from './login-page.hbs'; - -export class LoginPageView extends PageView { - #loginForm; - constructor() { - super({ - template: LoginPageHbs, - }); - this.#loginForm = this._html.querySelector('.login-container__login-form'); - } - - get loginForm() { - return this.#loginForm; - } -} diff --git a/src/Pages/LoginPage/login-page.hbs b/src/Pages/LoginPage/login-page.hbs deleted file mode 100644 index 519400b..0000000 --- a/src/Pages/LoginPage/login-page.hbs +++ /dev/null @@ -1,8 +0,0 @@ -
- -
diff --git a/src/Pages/NotFoundPage/NotFoundPage.js b/src/Pages/NotFoundPage/NotFoundPage.js deleted file mode 100644 index 1536cf7..0000000 --- a/src/Pages/NotFoundPage/NotFoundPage.js +++ /dev/null @@ -1,24 +0,0 @@ -import { Page } from '@/modules/Page/Page'; -import { NotFoundPageController } from './NotFoundPageController'; -import { NotFoundPageModel } from './NotFoundPageModel'; -import { NotFoundPageView } from './NotFoundPageView'; - -/** A class representing 404 not found page. - * @extends Page - */ -export class NotFoundPage extends Page { - /** - * Create an instance of 404 Page. - * @param {URL} url --- a URL object containing the link with which this page were navigated - * @throws {TypeError} url is not an instance of URL - */ - constructor({ url }) { - super({ - url, - modelClass: NotFoundPageModel, - viewClass: NotFoundPageView, - controllerClass: NotFoundPageController, - viewParams: { url }, - }); - } -} diff --git a/src/Pages/NotFoundPage/NotFoundPageController.js b/src/Pages/NotFoundPage/NotFoundPageController.js deleted file mode 100644 index ad728e1..0000000 --- a/src/Pages/NotFoundPage/NotFoundPageController.js +++ /dev/null @@ -1,3 +0,0 @@ -import { PageController } from '@/modules/Page/Page'; - -export const NotFoundPageController = PageController; diff --git a/src/Pages/NotFoundPage/NotFoundPageModel.js b/src/Pages/NotFoundPage/NotFoundPageModel.js deleted file mode 100644 index 4df9d34..0000000 --- a/src/Pages/NotFoundPage/NotFoundPageModel.js +++ /dev/null @@ -1,3 +0,0 @@ -import { PageModel } from '@/modules/Page/Page'; - -export const NotFoundPageModel = PageModel; diff --git a/src/Pages/NotFoundPage/NotFoundPageView.js b/src/Pages/NotFoundPage/NotFoundPageView.js deleted file mode 100644 index 33a00af..0000000 --- a/src/Pages/NotFoundPage/NotFoundPageView.js +++ /dev/null @@ -1,8 +0,0 @@ -import { PageView } from '@/modules/Page/Page'; -import NotFoundPageHtml from './not-found-page.hbs'; - -export class NotFoundPageView extends PageView { - constructor({ url }) { - super({ renderParams: { notFoundUrl: url }, template: NotFoundPageHtml }); - } -} diff --git a/src/Pages/NotFoundPage/not-found-page.hbs b/src/Pages/NotFoundPage/not-found-page.hbs deleted file mode 100644 index e585804..0000000 --- a/src/Pages/NotFoundPage/not-found-page.hbs +++ /dev/null @@ -1 +0,0 @@ -
{{notFoundUrl}} Not Found (404)
\ No newline at end of file diff --git a/src/application/action_creators/user_action_creators.ts b/src/application/action_creators/user_action_creators.ts index 166a811..02bdc05 100644 --- a/src/application/action_creators/user_action_creators.ts +++ b/src/application/action_creators/user_action_creators.ts @@ -3,7 +3,7 @@ import { LoginAction, LogoutAction, } from '@application/stores/user_store/user_actions'; -import { userStore } from '@application/stores/user_store/user_store'; +import { LoginFormData } from '@application/stores/user_store/user_store'; import { login as apiLogin, logout as apiLogout, @@ -24,8 +24,36 @@ import { makeEmployerFromApi } from '../models/employer'; import type { Applicant as ApiApplicant } from '@api/src/responses/applicant'; import type { Employer as ApiEmployer } from '@api/src/responses/employer'; import { assertIfError } from '@/modules/common_utils/asserts/asserts'; +import { validateEmail, validateOk } from '../validators/validators'; +import { storeManager } from '@/modules/store_manager/store_manager'; + +function validateLoginData({ userType, email, password }: LoginOptions): LoginFormData { + const isValid = [userType, email, password].every((field) => field !== ''); + const validatedData = { + userType: validateOk(userType), + email: validateEmail(email), + password: validateOk(password), + isValid, + errorMsg: isValid ? '' : 'Заполните обязательные поля', + }; + validatedData.isValid = + validatedData.isValid && + [validatedData.userType, validatedData.email, validatedData.password].every( + (field) => field.isValid, + ); + return validatedData; +} async function login({ userType, email, password }: LoginOptions) { + const validatedLoginData = validateLoginData({ userType, email, password }); + if (!validatedLoginData.isValid) { + storeManager.dispatch({ + type: UserActions.LoginFormSubmit, + payload: validatedLoginData, + }); + return; + } + const backendOrigin = backendStore.getData().backendOrigin; try { const loginResponse = await apiLogin(backendOrigin, { @@ -34,7 +62,7 @@ async function login({ userType, email, password }: LoginOptions) { password, }); const userProfile = await getUser(backendOrigin, userType, loginResponse.id); - userStore.dispatch({ + storeManager.dispatch({ type: UserActions.Login, payload: { email, @@ -44,7 +72,7 @@ async function login({ userType, email, password }: LoginOptions) { } as LoginAction); } catch (err) { assertIfError(err); - userStore.dispatch({ + storeManager.dispatch({ type: UserActions.Logout, } as LogoutAction); } @@ -60,7 +88,7 @@ async function logout() { const backendOrigin = backendStore.getData().backendOrigin; try { await apiLogout(backendOrigin); - userStore.dispatch({ + storeManager.dispatch({ type: UserActions.Logout, } as LogoutAction); } catch {} @@ -77,7 +105,7 @@ async function register( ? await apiRegisterApplicant(backendOrigin, body as registerApplicantOptions) : await apiRegisterEmployer(backendOrigin, body as registerEmployerOptions); const userProfile = await getUser(backendOrigin, userType, response.id); - userStore.dispatch({ + storeManager.dispatch({ type: UserActions.Login, payload: { email: body.email, @@ -86,7 +114,7 @@ async function register( }, }); } catch { - userStore.dispatch({ + storeManager.dispatch({ type: UserActions.Logout, }); } diff --git a/src/application/components/input/input.scss b/src/application/components/input/input.scss new file mode 100644 index 0000000..4f918de --- /dev/null +++ b/src/application/components/input/input.scss @@ -0,0 +1,40 @@ +.input { + display: flex; + flex-direction: column; + + &__label { + margin-bottom: 5px; + } + + &__required-sign { + color: var(--color-secondary-support-500); + } + + &__field { + padding: 8px; + background-color: var(--color-background-600); + color: var(--color-main-100); + border: none; + border-radius: var(--radius-s); + &::placeholder { + color: var(--color-background-400); + } + + &_ok { + box-shadow: inset 0px 0px 6px var(--color-main-400); + } + + &_error { + box-shadow: inset 0px 0px 4px var(--color-secondary-support-500); + } + } + + &__error-text { + margin-top: 10px; + color: var(--color-secondary-support-500); + } + + &__input_disabled { + background-color: rgba(0, 0, 0, 0); + } +} diff --git a/src/application/components/input/input.tsx b/src/application/components/input/input.tsx new file mode 100644 index 0000000..861bc07 --- /dev/null +++ b/src/application/components/input/input.tsx @@ -0,0 +1,72 @@ +import { Component } from '@/modules/vdom/virtual_node'; +import * as vdom from '@/modules/vdom/virtual_dom'; +import './input.scss'; + +export interface InputProps { + elementClass?: string; + id: string; + isRequired?: boolean; + label: string; + name: string; + type: string; + value?: string; + placeholder?: string; + error?: string; + isValid?: boolean; + maxlength?: number; +} + +export class Input extends Component { + constructor({ + elementClass, + id, + isRequired = false, + label, + name, + type, + value, + placeholder = '', + error = '', + isValid, + maxlength, + }: InputProps) { + super({ + elementClass, + id, + isRequired, + name, + label, + type, + value, + placeholder, + error, + isValid, + maxlength, + }); + } + + render() { + return ( +
+ + + {this.props.isValid === false && this.props.error !== '' ? ( + {this.props.error ?? false} + ) : ( + false + )} +
+ ); + } +} diff --git a/src/application/components/page_container/page-container.scss b/src/application/components/page_container/page-container.scss index c6cc610..55bbf84 100644 --- a/src/application/components/page_container/page-container.scss +++ b/src/application/components/page_container/page-container.scss @@ -7,7 +7,8 @@ } .page-container { - display: block; + display: flex; + flex-direction: column; height: 100vh; - width: 100vw; + width: 100%; } diff --git a/src/application/components/user_type_select/user_type_select.scss b/src/application/components/user_type_select/user_type_select.scss new file mode 100644 index 0000000..8695e75 --- /dev/null +++ b/src/application/components/user_type_select/user_type_select.scss @@ -0,0 +1,50 @@ +.user-type-select { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + justify-content: space-between; + text-decoration: none; + text-align: center; + gap: 12px; + width: 100%; + &__applicant { + width: 50%; + } + + &__employer { + width: 50%; + } +} + +.user-type-radiobutton { + display: flex; + flex-direction: column; + align-items: stretch; + font-weight: var(--text-weight-bold); + border-radius: var(--radius-m); + border: solid 1px var(--color-background-600); + transition: + background-color 0.3s ease, + border-color 0.3s ease; + + &:hover { + background-color: var(--color-main-100); + color: var(--color-background-900); + } + + &__input { + display: none; + } + + &__label { + padding: 8px 4px; + cursor: pointer; + margin: 0; + } + + &_checked { + background-color: var(--color-main-400); + border: none; + color: #fff; + } +} diff --git a/src/application/components/user_type_select/user_type_select.tsx b/src/application/components/user_type_select/user_type_select.tsx new file mode 100644 index 0000000..1ba59e7 --- /dev/null +++ b/src/application/components/user_type_select/user_type_select.tsx @@ -0,0 +1,60 @@ +import { Component } from '@/modules/vdom/virtual_node'; +import * as vdom from '@/modules/vdom/virtual_dom'; +import { UserType } from '@/application/models/user-type'; +import './user_type_select.scss'; + +export interface UserTypeSelectProps { + elementClass?: string; +} + +export class UserTypeSelect extends Component { + private checked: UserType = UserType.Applicant; + constructor({ elementClass = '' }: UserTypeSelectProps) { + super({ elementClass }); + } + + private setActive = (userType: UserType) => { + this.checked = userType; + vdom.updateNode(this.domNode, vdom.createElement('UserTypeSelect', this.props)); + }; + render() { + return ( +
+
this.setActive(UserType.Applicant)} + > + + +
+
this.setActive(UserType.Employer)} + > + + +
+
+ ); + } +} diff --git a/src/application/models/form_value.ts b/src/application/models/form_value.ts new file mode 100644 index 0000000..684ad89 --- /dev/null +++ b/src/application/models/form_value.ts @@ -0,0 +1,6 @@ +/** FormValue holds a value and its validation status */ +export interface FormValue { + value: unknown; + isValid: boolean; + errorMsg?: string; +} diff --git a/src/application/pages/login_page/login-page.scss b/src/application/pages/login_page/login-page.scss index 47bb0a1..ea8503d 100644 --- a/src/application/pages/login_page/login-page.scss +++ b/src/application/pages/login_page/login-page.scss @@ -1,21 +1,50 @@ .login-page { display: flex; flex-direction: column; - height: 100vh; - align-items: center; + align-items: stretch; justify-content: center; + width: var(--4-col); + max-width: 400px; + padding: 16px; - &__login-container { - display: flex; - flex-direction: column; - justify-content: center; - width: var(--4-col); + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + + border-radius: var(--radius-m); + + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); - &_theme-dark { - background-color: var(--color-background-900); - } + &_theme-dark { + background-color: var(--color-background-900); } &__header { + margin-top: 0px; + margin-bottom: 20px; + text-align: center; + color: var(--color-main-100); + } + + &__user-type-select { + margin-bottom: 20px; + } + + &__email { + margin-bottom: 20px; + } + + &__password { + margin-bottom: 20px; + } + + &__button-container { + display: flex; + flex-direction: row; + align-items: baseline; + gap: 16px; } } diff --git a/src/application/pages/login_page/login_page.tsx b/src/application/pages/login_page/login_page.tsx index 9a0f760..6952b69 100644 --- a/src/application/pages/login_page/login_page.tsx +++ b/src/application/pages/login_page/login_page.tsx @@ -1,16 +1,76 @@ import { Component } from '@/modules/vdom/virtual_node'; import * as vdom from '@/modules/vdom/virtual_dom'; import './login-page.scss'; +import { Input } from '@/application/components/input/input'; +import { UserTypeSelect } from '@/application/components/user_type_select/user_type_select'; +import { resolveUrl } from '@/modules/UrlUtils/UrlUtils'; +import { userStore } from '@/application/stores/user_store/user_store'; +import { userActionCreators } from '@/application/action_creators/user_action_creators'; +import { LoginOptions } from '@/modules/api/src/handlers/auth/login'; export class LoginPage extends Component { + constructor({ url }: { url: URL }) { + super({ url }); + } + render() { + const formData = userStore.getData().loginForm; return ( - - // login form here +
+

Вход в аккаунт

+
+ + + +
+ + +
+ +
); } + + private handleSubmit = (ev: SubmitEvent) => { + ev.preventDefault(); + const formData = Object.fromEntries(new FormData(ev.target as HTMLFormElement)); + userActionCreators.login({ + userType: (formData.userType as string).trim(), + email: (formData.email as string).trim(), + password: formData.password, + } as LoginOptions); + }; } diff --git a/src/application/stores/user_store/user_actions.ts b/src/application/stores/user_store/user_actions.ts index ae2665f..5037310 100644 --- a/src/application/stores/user_store/user_actions.ts +++ b/src/application/stores/user_store/user_actions.ts @@ -4,10 +4,12 @@ import { UserType } from '@/application/models/user-type'; import { Action } from '@/modules/store_manager/action'; import { Applicant } from '@application/models/applicant'; import { Employer } from '@application/models/employer'; +import { LoginFormData } from './user_store'; export enum UserActions { Logout = 'logout', Login = 'login', + LoginFormSubmit = 'loginFormSubmit', } export interface LogoutAction extends Action { @@ -24,3 +26,8 @@ export interface LoginAction extends Action { type: UserActions.Login; payload: LoginActionPayload; } + +export interface LoginFormSubmitAction extends Action { + type: UserActions.LoginFormSubmit; + payload: LoginFormData; +} diff --git a/src/application/stores/user_store/user_store.ts b/src/application/stores/user_store/user_store.ts index dbe03da..7efd9f7 100644 --- a/src/application/stores/user_store/user_store.ts +++ b/src/application/stores/user_store/user_store.ts @@ -5,12 +5,22 @@ import { storeManager } from '@/modules/store_manager/store_manager'; import { Action } from '@/modules/store_manager/action'; import { LoginActionPayload, UserActions } from './user_actions'; import { UserType } from '@/application/models/user-type'; +import { FormValue } from '@/application/models/form_value'; + +export interface LoginFormData { + userType: FormValue; + email: FormValue; + password: FormValue; + isValid: boolean; + errorMsg?: string; +} export interface UserData { isLoggedIn: boolean; userType?: UserType; email?: string; userProfile?: Applicant | Employer; + loginForm?: LoginFormData; } function userStoreReducer(state: UserData, action: Action) { @@ -28,9 +38,17 @@ function userStoreReducer(state: UserData, action: Action) { userType: payload.userType, email: payload.email, userProfile: payload.userProfile, + loginForm: state.loginForm, }; } + + case UserActions.LoginFormSubmit: { + const payload = action.payload as LoginFormData; + state.loginForm = payload; + return state; + } } + return state; } export const userStore = new Store( diff --git a/src/application/validators/validators.ts b/src/application/validators/validators.ts new file mode 100644 index 0000000..a5140ef --- /dev/null +++ b/src/application/validators/validators.ts @@ -0,0 +1,28 @@ +/** @fileoverview This file contains validators for form fields or form itself */ + +import { FormValue } from '../models/form_value'; + +export function validateEmail(email: string): FormValue { + const matches = email.match(/^(".*"|[^@]*)@[^@]*$/); + return { + value: email, + isValid: Boolean(matches), + errorMsg: matches ? '' : 'Введен некорректный адрес', + }; +} + +export function validatePassword(password: string): FormValue { + const isValid = password.length >= 8; + return { + value: password, + isValid, + errorMsg: isValid ? '' : 'Введите пароль длиной хотя бы 8 символов', + }; +} + +export function validateOk(value: unknown): FormValue { + return { + value, + isValid: true, + }; +} diff --git a/src/index.tsx b/src/index.tsx index 2c5870d..f7af027 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,6 +1,5 @@ import { Router } from '@/modules/vdom_router/router'; import eventBus from './modules/Events/EventBus'; -import appState from './modules/AppState/AppState'; import { LoginPage } from '@/application/pages/login_page/login_page'; // import { RegistrationPage } from './Pages/RegistrationPage/RegistrationPage'; // import { ProfilePage } from './Pages/ProfilePage/ProfilePage'; @@ -13,6 +12,7 @@ import { REDIRECT_TO, GO_TO } from './modules/Events/Events'; import { NotificationBox } from './Components/NotificationBox/NotificationBox'; import { VacanciesPage } from '@/application/pages/vacancies_page/vacancies_page'; import './scss/index.scss'; +import { storeManager } from './modules/store_manager/store_manager'; // eslint-disable-next-line const notificationBox = new NotificationBox({ @@ -42,6 +42,7 @@ eventBus.on(GO_TO, ({ redirectUrl }: { redirectUrl: URL }) => { router.navigate(redirectUrl, false, true); }); -appState.userSession.checkAuthorization().finally(() => { - router.start(); -}); +storeManager.bindVirtualDom(router.getVdomRoot()); + +// TODO: add auth check +router.start(); diff --git a/src/modules/FormUtils/FormUtils.js b/src/modules/FormUtils/FormUtils.js deleted file mode 100644 index 5a960f2..0000000 --- a/src/modules/FormUtils/FormUtils.js +++ /dev/null @@ -1,8 +0,0 @@ -export const getFormData = (formHtml) => { - const formData = new FormData(formHtml); - const dataObj = {}; - formData.entries().forEach((entry) => { - dataObj[entry[0]] = entry[1]; - }); - return dataObj; -}; diff --git a/src/modules/Router/Router.ts b/src/modules/Router/Router.ts deleted file mode 100644 index 5dfa2b5..0000000 --- a/src/modules/Router/Router.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { Page } from '@/modules/Page/Page'; -import { NotFoundPage } from '@/Pages/NotFoundPage/NotFoundPage'; - -interface IPage { - new ({ url }: { url: URL }): Page; -} - -const APP_ID = 'app'; - -export class ForbiddenPage extends Error { - public redirectUrl: URL; - constructor(redirectUrl: URL) { - super('forbidden page'); - this.redirectUrl = redirectUrl; - Object.setPrototypeOf(this, ForbiddenPage.prototype); - } -} - -export class NotFoundError extends Error { - constructor() { - super('resource not found'); - Object.setPrototypeOf(this, NotFoundError.prototype); - } -} - -/** Simple navigation router class. */ -export class Router { - private currentPage: Page | undefined; - private routes: Map; - - /** - * Create a router without any routes. - */ - constructor() { - this.routes = new Map(); - this.currentPage = undefined; - } - - /** - * Get current page instance - * @returns {Page} Current page object - */ - getCurrentPage(): Page { - return this.currentPage; - } - - /** - * Add a route to router - * @param {string} pathname - The string representation of URL pathname of the page to be added. - * @param {Page} pageClass - A class of added page. - */ - addRoute(pathname: string, pageClass: IPage) { - this.routes.set(pathname, pageClass); - } - - /** - * Remove a route from router - * @param {string} pathname - The string representation of URL pathname of the page to be removed. - * @returns {boolean} Is route deleted. - */ - removeRoute(pathname: string): boolean { - return this.routes.delete(pathname); - } - - /** - * Navigate to the page with given url - * @param {URL} url - The URL to navigate to - * @param {boolean} redirection - Is this navigation a redirection - * @param {boolean} modifyHistory - If true, browser history will be modified - * @throws {TypeError} Invalid argument types - */ - async navigate(url: URL, redirection: boolean = false, modifyHistory: boolean = true) { - try { - if (modifyHistory) { - if (!redirection) { - history.pushState(null, '', url); - } else { - history.replaceState(null, '', url); - } - } - - const newPage = this.routes.has(url.pathname) ? this.routes.get(url.pathname) : NotFoundPage; - await this._replacePage(newPage, url); - } catch (err) { - if (err instanceof ForbiddenPage) { - this.navigate(err.redirectUrl, true, true); - return; - } - if (err instanceof NotFoundError) { - this._replacePage(NotFoundPage, url); - return; - } - throw err; - } - } - - // TODO: remove any here (after all code in typescript) - // eslint-disable-next-line - async _replacePage(newPageClass: any, newPageUrl: URL) { - if (this.currentPage) { - this.currentPage.cleanup(); - } - this.currentPage = new newPageClass({ url: newPageUrl }); - const app = document.getElementById(APP_ID); - app.innerHTML = ''; - app.appendChild(this.currentPage.render()); - this.currentPage.postRenderInit(); - } - - /** - * Start routing. - */ - start() { - window.addEventListener('popstate', (ev) => { - ev.preventDefault(); - this.navigate(new URL(location.href), false, false); - }); - - window.addEventListener('click', (ev) => { - let currentElement = ev.target as HTMLElement; - while (currentElement) { - if ( - currentElement instanceof HTMLAnchorElement && - currentElement.origin === location.origin - ) { - ev.preventDefault(); - this.navigate(new URL(currentElement.href)); - break; - } - currentElement = currentElement.parentElement; - } - }); - - this.navigate(new URL(location.href), false, false); - } -} - -export default new Router(); diff --git a/src/modules/vdom/virtual_dom.ts b/src/modules/vdom/virtual_dom.ts index c695c63..ba3e53a 100644 --- a/src/modules/vdom/virtual_dom.ts +++ b/src/modules/vdom/virtual_dom.ts @@ -96,7 +96,15 @@ export function createNode(spec: VirtualNodeSpec | string): NodeWithVirtualNode Object.entries(props).forEach(([key, value]) => { if (isEventProperty(key)) { setEventListener(newVirtualNode, key, <{ (ev: Event): void }>value); - } else { + return; + } + if (typeof value === 'boolean') { + if (value) { + domNode.setAttribute(key, ''); + } + return; + } + if (value !== undefined) { domNode.setAttribute(key, value); } }); @@ -238,7 +246,15 @@ function updateSelfProps( Object.entries(newProps).forEach(([key, value]) => { if (isEventProperty(key)) { setEventListener(virtualNode, key, <{ (ev: Event): void }>value); - } else { + return; + } + if (typeof value === 'boolean') { + if (value) { + curHtml.setAttribute(key, ''); + } + return; + } + if (value !== undefined) { curHtml.setAttribute(key, value); } }); diff --git a/src/modules/vdom/virtual_dom_root.ts b/src/modules/vdom/virtual_dom_root.ts index 5139291..9932dbc 100644 --- a/src/modules/vdom/virtual_dom_root.ts +++ b/src/modules/vdom/virtual_dom_root.ts @@ -102,6 +102,9 @@ export function setEventListener( console.log('Unexpected no root'); return; } + if (!handler) { + return; + } const eventName = eventKey.slice(2).toLowerCase(); if (!virtualNode.eventListeners) { virtualNode.eventListeners = new Map>(); diff --git a/src/modules/vdom_router/router.ts b/src/modules/vdom_router/router.ts index 3e126c4..96fc983 100644 --- a/src/modules/vdom_router/router.ts +++ b/src/modules/vdom_router/router.ts @@ -24,7 +24,6 @@ export class NotFoundError extends Error { export class Router { private routes: Map; - private currentPage?: Component; private vdomRoot: VirtualDomRoot; constructor(rootNode: HTMLElement) { @@ -40,6 +39,10 @@ export class Router { return this.routes.delete(pathname); } + getVdomRoot() { + return this.vdomRoot; + } + navigate(url: URL, redirection: boolean = false, modifyHistory: boolean = true) { try { if (modifyHistory) { diff --git a/src/scss/_common.scss b/src/scss/_common.scss index de9e77c..d1247d3 100644 --- a/src/scss/_common.scss +++ b/src/scss/_common.scss @@ -1,15 +1,3 @@ -.app { - display: flex; - flex-direction: column; - min-height: 100vh; - max-width: 100vw; - font-family: 'Roboto', sans-serif; - &_theme-dark { - background-color: var(--color-background-1000); - color: var(--color-main-100); - } -} - .border_radius-small { border: solid; border-radius: 4px; diff --git a/src/scss/_forms.scss b/src/scss/_forms.scss index 3e7078d..0e32061 100644 --- a/src/scss/_forms.scss +++ b/src/scss/_forms.scss @@ -1,95 +1,3 @@ -/* User type radiogroup component */ - -.user-type-radiogroup { - display: flex; - flex-direction: row; - flex-wrap: nowrap; - justify-content: space-between; - text-decoration: none; - text-align: center; - gap: 12px; - width: 100%; - &__applicant { - width: 50%; - } - - &__employer { - width: 50%; - } -} - -/* user type radiobutton component */ - -.user-type-radiobutton { - display: flex; - flex-direction: column; - align-items: stretch; - border: 1px solid var(--grey-light); - font-weight: var(--text-weight-bold); - border-radius: 14px; - transition: - background-color 0.3s ease, - border-color 0.3s ease; - - &:hover { - background-color: #f0f0f0; - border-color: #888; - color: black; - } - - &__input { - display: none; - } - - &__label { - padding: 8px 4px; - cursor: pointer; - margin: 0; - } - - &_checked { - background-color: var(--primary-light); - color: #fff; - border-color: var(--primary-light); - } -} - -/* Validated input component */ - -.validated-input { - display: flex; - flex-direction: column; - - &__label { - margin-bottom: 5px; - } - - &__input { - padding: 8px; - background-color: var(--color-background-600); - color: var(--color-main-100); - border: none; - border-radius: 4px; - } - - &__error { - margin-top: 10px; - color: var(--color-secondary-support-500); - } - - &__input_error { - box-shadow: inset 0px 0px 4px var(--color-secondary-support-500); - } - - &__input_ok { - box-shadow: inset 0px 0px 6px var(--color-main-400); - } - - &__input_disabled { - background-color: rgba(0, 0, 0, 0); - } -} - /* Validated textarea component */ .validated-textarea { diff --git a/src/scss/_login.scss b/src/scss/_login.scss index 4dd7050..e69de29 100644 --- a/src/scss/_login.scss +++ b/src/scss/_login.scss @@ -1,59 +0,0 @@ -// .login-page { -// display: flex; -// flex-direction: column; -// justify-content: center; -// align-items: center; -// width: 100vw; -// height: 100vh; -// &__login-container { -// padding: 20px; -// border-radius: 10px; -// box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); -// width: 100%; -// max-width: 400px; -// box-sizing: border-box; -// background-color: var(--color-background-900); -// } -// } - -.login-container { - display: flex; - align-items: stretch; - flex-direction: column; - &__header { - margin-top: 0px; - text-align: center; - color: white; - } - - &__login-form { - margin-bottom: 10px; - } -} - -.login-form { - display: flex; - flex-direction: column; - &__button-container { - display: flex; - gap: 20px; - flex-wrap: wrap; - align-items: baseline; - } - &__error { - margin-bottom: 20px; - color: #aa0000; - } - - &__user-type-radiogroup { - margin-bottom: 20px; - } - - &__email { - margin-bottom: 10px; - } - - &__password { - margin-bottom: 20px; - } -} diff --git a/src/scss/index.scss b/src/scss/index.scss index cfe8722..39e401b 100644 --- a/src/scss/index.scss +++ b/src/scss/index.scss @@ -74,43 +74,56 @@ $mobile-breakpoint: 768px; --text-weight-regular: 400; --text-weight-bold: 600; - --container-width: #{$container-width}; - --col-width: #{$col-width}; + --mobile-breakpoint: #{$mobile-breakpoint}; + + --container-width: 100%; + --col-width: 100%; --col-margin: #{$col-margin}; + --2-col: 100%; + --3-col: 100%; + --4-col: 100%; + --5-col: 100%; + --6-col: 100%; + --7-col: 100%; + --8-col: 100%; + --9-col: 100%; + --10-col: 100%; + --11-col: 100%; + --12-col: 100%; - --2-col: #{calc(2 * $col-width + $col-margin)}; - --3-col: #{calc(3 * $col-width + 2 * $col-margin)}; - --4-col: #{calc(4 * $col-width + 3 * $col-margin)}; - --5-col: #{calc(5 * $col-width + 4 * $col-margin)}; - --6-col: #{calc(6 * $col-width + 5 * $col-margin)}; - --7-col: #{calc(7 * $col-width + 6 * $col-margin)}; - --8-col: #{calc(8 * $col-width + 7 * $col-margin)}; - --9-col: #{calc(9 * $col-width + 8 * $col-margin)}; - --10-col: #{calc(10 * $col-width + 9 * $col-margin)}; - --11-col: #{calc(11 * $col-width + 10 * $col-margin)}; - --12-col: #{calc(12 * $col-width + 11 * $col-margin)}; + --radius-s: 4px; + --radius-xs: 8px; + --radius-m: 16px; + --radius-l: 24px; } -@media screen and (max-width: $mobile-breakpoint) { +@media screen and (min-width: $mobile-breakpoint) { :root { - --container-width: 100%; - --col-width: 100%; - --col-margin: 0; - --2-col: 100%; - --3-col: 100%; - --4-col: 100%; - --5-col: 100%; - --6-col: 100%; - --7-col: 100%; - --8-col: 100%; - --9-col: 100%; - --10-col: 100%; - --11-col: 100%; - --12-col: 100%; + --2-col: #{calc(2 * $col-width + $col-margin)}; + --3-col: #{calc(3 * $col-width + 2 * $col-margin)}; + --4-col: #{calc(4 * $col-width + 3 * $col-margin)}; + --5-col: #{calc(5 * $col-width + 4 * $col-margin)}; + --6-col: #{calc(6 * $col-width + 5 * $col-margin)}; + --7-col: #{calc(7 * $col-width + 6 * $col-margin)}; + --8-col: #{calc(8 * $col-width + 7 * $col-margin)}; + --9-col: #{calc(9 * $col-width + 8 * $col-margin)}; + --10-col: #{calc(10 * $col-width + 9 * $col-margin)}; + --11-col: #{calc(11 * $col-width + 10 * $col-margin)}; + --12-col: #{calc(12 * $col-width + 11 * $col-margin)}; } } body { margin: 0; padding: 0; + background-color: var(--color-background-1000); + color: var(--color-main-100); +} + +.app { + display: flex; + flex-direction: column; + align-items: center; + justify-content: start; + font-family: 'Roboto', sans-serif; } diff --git a/tsconfig.json b/tsconfig.json index 0ad112e..4cb52c7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ "resolveJsonModule": true, "esModuleInterop": true, "moduleResolution": "bundler", + "lib": ["DOM", "DOM.Iterable", "ES2019"], "baseUrl": "./", "jsx": "react", "jsxFactory": "vdom.createElement", diff --git a/webpack.dev.js b/webpack.dev.js index 3543d65..b8445da 100644 --- a/webpack.dev.js +++ b/webpack.dev.js @@ -9,5 +9,6 @@ module.exports = merge(common, { historyApiFallback: true, hot: true, open: true, + host: '0.0.0.0', }, }); From d245d663ecbc241b7be537d6af84632e70215123 Mon Sep 17 00:00:00 2001 From: Ilya Andriyanov Date: Wed, 11 Dec 2024 21:27:15 +0300 Subject: [PATCH 04/17] fix: virtual dom works fine with component in component in component... --- .../action_creators/router_action_creators.ts | 69 ++++++++ src/application/components/router/router.tsx | 47 +++++ .../pages/login_page/login_page.tsx | 5 + .../stores/router_store/router_actions.ts | 67 +++++++ .../stores/router_store/router_store.ts | 73 ++++++++ src/index.tsx | 27 ++- src/modules/vdom/virtual_dom.ts | 165 +++++++++++++----- src/modules/vdom/virtual_dom_root.ts | 8 + src/modules/vdom/virtual_node.ts | 3 +- src/modules/vdom_router/router.ts | 97 ---------- 10 files changed, 404 insertions(+), 157 deletions(-) create mode 100644 src/application/action_creators/router_action_creators.ts create mode 100644 src/application/components/router/router.tsx create mode 100644 src/application/stores/router_store/router_actions.ts create mode 100644 src/application/stores/router_store/router_store.ts delete mode 100644 src/modules/vdom_router/router.ts diff --git a/src/application/action_creators/router_action_creators.ts b/src/application/action_creators/router_action_creators.ts new file mode 100644 index 0000000..1c695d6 --- /dev/null +++ b/src/application/action_creators/router_action_creators.ts @@ -0,0 +1,69 @@ +import { + RouterActions, + PageClass, + RemoveRouteAction, + RemoveRouteActionPayload, + NavigationActionPayload, + RedirectAction, + NavigateAction, + StartAction, +} from '@/application/stores/router_store/router_actions'; +import { storeManager } from '@/modules/store_manager/store_manager'; +import { + AddRouteAction, + AddRouteActionPayload, +} from '@/application/stores/router_store/router_actions'; + +function addRoute(url: URL, page: PageClass) { + storeManager.dispatch({ + type: RouterActions.AddRoute, + payload: { + url, + page, + } as AddRouteActionPayload, + } as AddRouteAction); +} + +function removeRoute(url: URL) { + storeManager.dispatch({ + type: RouterActions.RemoveRoute, + payload: { + url, + } as RemoveRouteActionPayload, + } as RemoveRouteAction); +} + +function redirect(url: URL) { + storeManager.dispatch({ + type: RouterActions.Redirect, + payload: { + url, + } as NavigationActionPayload, + } as RedirectAction); +} + +function navigate(url: URL) { + storeManager.dispatch({ + type: RouterActions.Navigate, + payload: { + url, + } as NavigationActionPayload, + } as NavigateAction); +} + +function startRouting(url: URL) { + storeManager.dispatch({ + type: RouterActions.Start, + payload: { + url, + } as NavigationActionPayload, + } as StartAction); +} + +export const routerActionCreators = { + addRoute, + removeRoute, + redirect, + navigate, + startRouting, +}; diff --git a/src/application/components/router/router.tsx b/src/application/components/router/router.tsx new file mode 100644 index 0000000..959836e --- /dev/null +++ b/src/application/components/router/router.tsx @@ -0,0 +1,47 @@ +import { Component, VirtualNodeSpec } from '@/modules/vdom/virtual_node'; +import * as vdom from '@/modules/vdom/virtual_dom'; +import { routerStore } from '@/application/stores/router_store/router_store'; +import { PageClass } from '@/application/stores/router_store/router_actions'; +import { PageSwitchStatus } from '@/application/stores/router_store/router_store'; +import { routerActionCreators } from '@/application/action_creators/router_action_creators'; + +export class Router extends Component { + private prevPage?: PageClass; + + didMount() { + window.addEventListener('popstate', (ev) => { + ev.preventDefault(); + routerActionCreators.navigate(new URL(location.href)); + }); + + window.addEventListener('click', (ev) => { + let currentElement = ev.target as HTMLElement; + while (currentElement) { + if ( + currentElement instanceof HTMLAnchorElement && + currentElement.origin === location.origin + ) { + ev.preventDefault(); + routerActionCreators.navigate(new URL(currentElement.href)); + break; + } + currentElement = currentElement.parentElement; + } + }); + } + + render(): VirtualNodeSpec { + const routerData = routerStore.getData(); + const { currentUrl, switchStatus } = routerData; + const CurrentPage = routerData.currentPage; + if (this.prevPage !== CurrentPage) { + this.prevPage = CurrentPage; + if (switchStatus === PageSwitchStatus.Navigate) { + history.pushState(null, '', currentUrl); + } else { + history.replaceState(null, '', currentUrl); + } + } + return ; + } +} diff --git a/src/application/pages/login_page/login_page.tsx b/src/application/pages/login_page/login_page.tsx index 6952b69..58a6f79 100644 --- a/src/application/pages/login_page/login_page.tsx +++ b/src/application/pages/login_page/login_page.tsx @@ -7,6 +7,7 @@ import { resolveUrl } from '@/modules/UrlUtils/UrlUtils'; import { userStore } from '@/application/stores/user_store/user_store'; import { userActionCreators } from '@/application/action_creators/user_action_creators'; import { LoginOptions } from '@/modules/api/src/handlers/auth/login'; +import { routerActionCreators } from '@/application/action_creators/router_action_creators'; export class LoginPage extends Component { constructor({ url }: { url: URL }) { @@ -14,6 +15,10 @@ export class LoginPage extends Component { } render() { + const userData = userStore.getData(); + if (userData.isLoggedIn) { + routerActionCreators.redirect(new URL(resolveUrl('vacancies', null))); + } const formData = userStore.getData().loginForm; return (
diff --git a/src/application/stores/router_store/router_actions.ts b/src/application/stores/router_store/router_actions.ts new file mode 100644 index 0000000..e027c98 --- /dev/null +++ b/src/application/stores/router_store/router_actions.ts @@ -0,0 +1,67 @@ +/** @fileoverview This file contains actions for router store */ + +import type { Action } from '@/modules/store_manager/action'; +import { Component } from '@/modules/vdom/virtual_node'; + +export interface PageClass { + new (props: { url: URL }): Component; +} + +/** Enum describing possible router actions */ +export enum RouterActions { + Navigate = 'navigate', + Redirect = 'redirect', + AddRoute = 'add_route', + RemoveRoute = 'remove_route', + Start = 'start', +} + +/** Payload for navigate and redirect actions */ +export interface NavigationActionPayload { + /** URL to navigate to */ + url: URL; +} + +/** Payload for add route action */ +export interface AddRouteActionPayload { + /** URL to assign */ + url: URL; + /** Page component */ + page: PageClass; +} + +/** Payload for remove route action */ +export interface RemoveRouteActionPayload { + /** URL to remove */ + url: URL; +} + +/** Navigate action. It symbolizes standard page navigation */ +export interface NavigateAction extends Action { + type: RouterActions.Navigate; + payload: NavigationActionPayload; +} + +/** Redirect action. It symbolizes redirection to another page */ +export interface RedirectAction extends Action { + type: RouterActions.Redirect; + payload: NavigationActionPayload; +} + +/** Action used to add new route in router */ +export interface AddRouteAction extends Action { + type: RouterActions.AddRoute; + payload: AddRouteActionPayload; +} + +/** Action used to remove route from router */ +export interface RemoveRouteAction extends Action { + type: RouterActions.RemoveRoute; + payload: RemoveRouteActionPayload; +} + +/** Action used to start routing */ +export interface StartAction extends Action { + type: RouterActions.Start; + payload: NavigationActionPayload; +} diff --git a/src/application/stores/router_store/router_store.ts b/src/application/stores/router_store/router_store.ts new file mode 100644 index 0000000..601d15b --- /dev/null +++ b/src/application/stores/router_store/router_store.ts @@ -0,0 +1,73 @@ +import { Store } from '@/modules/store_manager/store'; +import { storeManager } from '@/modules/store_manager/store_manager'; +import { + RouterActions, + AddRouteAction, + RemoveRouteAction, + NavigationActionPayload, +} from './router_actions'; +import { Action } from '@/modules/store_manager/action'; +import { NotFoundPage } from '@/application/pages/not_found_page/not_found_page'; +import { PageClass } from './router_actions'; + +export enum PageSwitchStatus { + Navigate = 'navigate', + Redirect = 'redirect', +} + +export interface RouterData { + currentPage?: PageClass; + currentUrl?: URL; + switchStatus: PageSwitchStatus; + modifyHistory: boolean; + possiblePages: Map; + fallbackPage: PageClass; +} + +function routerStoreReducer(curData: RouterData, action?: Action): RouterData { + switch (action.type) { + case RouterActions.Navigate: + case RouterActions.Redirect: { + return switchPageReducer(curData, action, true); + } + case RouterActions.AddRoute: { + const addRouteAction = action as AddRouteAction; + curData.possiblePages.set(addRouteAction.payload.url.pathname, addRouteAction.payload.page); + break; + } + case RouterActions.RemoveRoute: { + const removeRouteAction = action as RemoveRouteAction; + curData.possiblePages.delete(removeRouteAction.payload.url.pathname); + break; + } + case RouterActions.Start: { + return switchPageReducer(curData, action, false); + } + } + return curData; +} + +function switchPageReducer(data: RouterData, action: Action, modifyHistory: boolean = true) { + const navData = action.payload as NavigationActionPayload; + data.currentUrl = navData.url; + data.currentPage = data.possiblePages.has(navData.url.pathname) + ? data.possiblePages.get(navData.url.pathname) + : data.fallbackPage; + data.switchStatus = + action.type === RouterActions.Navigate ? PageSwitchStatus.Navigate : PageSwitchStatus.Redirect; + data.modifyHistory = modifyHistory; + return data; +} + +export const routerStore = new Store( + { + fallbackPage: NotFoundPage, + possiblePages: new Map(), + switchStatus: PageSwitchStatus.Navigate, + currentUrl: new URL(window.location.href), + modifyHistory: false, + }, + routerStoreReducer, +); + +storeManager.addStore(routerStore); diff --git a/src/index.tsx b/src/index.tsx index f7af027..66b4a1b 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,18 +1,19 @@ -import { Router } from '@/modules/vdom_router/router'; -import eventBus from './modules/Events/EventBus'; +import { Router } from './application/components/router/router'; +import * as vdom from '@/modules/vdom/virtual_dom'; import { LoginPage } from '@/application/pages/login_page/login_page'; // import { RegistrationPage } from './Pages/RegistrationPage/RegistrationPage'; // import { ProfilePage } from './Pages/ProfilePage/ProfilePage'; // import { VacancyPage } from './Pages/VacancyPage/VacancyPage'; // import { VacancyEditPage } from './Pages/VacancyEditPage/VacancyEditPage'; import { resolveUrl } from './modules/UrlUtils/UrlUtils'; -import { REDIRECT_TO, GO_TO } from './modules/Events/Events'; // import { CvPage } from './Pages/CvPage/CvPage'; // import { CvEditPage } from './Pages/CvEditPage/CvEditPage'; import { NotificationBox } from './Components/NotificationBox/NotificationBox'; import { VacanciesPage } from '@/application/pages/vacancies_page/vacancies_page'; import './scss/index.scss'; import { storeManager } from './modules/store_manager/store_manager'; +import { routerActionCreators } from './application/action_creators/router_action_creators'; +import { VirtualDomRoot } from './modules/vdom/virtual_dom_root'; // eslint-disable-next-line const notificationBox = new NotificationBox({ @@ -29,20 +30,12 @@ const notificationBox = new NotificationBox({ // router.addRoute(resolveUrl('createCv', null).pathname, CvEditPage); // router.addRoute(resolveUrl('editCv', null).pathname, CvEditPage); -const router = new Router(document.getElementById('app')); +const appRoot = new VirtualDomRoot(document.getElementById('app')); -router.addRoute(resolveUrl('vacancies', null).pathname, VacanciesPage); -router.addRoute(resolveUrl('login', null).pathname, LoginPage); - -eventBus.on(REDIRECT_TO, ({ redirectUrl }: { redirectUrl: URL }) => { - router.navigate(redirectUrl, true, true); -}); - -eventBus.on(GO_TO, ({ redirectUrl }: { redirectUrl: URL }) => { - router.navigate(redirectUrl, false, true); -}); - -storeManager.bindVirtualDom(router.getVdomRoot()); +routerActionCreators.addRoute(resolveUrl('vacancies', null), VacanciesPage); +routerActionCreators.addRoute(resolveUrl('login', null), LoginPage); +routerActionCreators.startRouting(resolveUrl('vacancies', null)); // TODO: add auth check -router.start(); +storeManager.bindVirtualDom(appRoot); +appRoot.render(); diff --git a/src/modules/vdom/virtual_dom.ts b/src/modules/vdom/virtual_dom.ts index ba3e53a..c03ead9 100644 --- a/src/modules/vdom/virtual_dom.ts +++ b/src/modules/vdom/virtual_dom.ts @@ -77,23 +77,14 @@ export function createNode(spec: VirtualNodeSpec | string): NodeWithVirtualNode return document.createTextNode(spec); } - const { type, props, children, key, root } = spec; - const newVirtualNode: VirtualNode = { - type, - props, - key, - children, - parent: null, - root, - }; + const newVirtualNode = createVirtualNode(spec); - const isPlainHtmlElement = typeof type === 'string'; - if (isPlainHtmlElement) { - const domNode: NodeWithVirtualNode & HTMLElement = document.createElement(type); + if (typeof newVirtualNode.type === 'string') { + const domNode: NodeWithVirtualNode & HTMLElement = document.createElement(newVirtualNode.type); domNode.virtualNode = newVirtualNode; - if (props) { - Object.entries(props).forEach(([key, value]) => { + if (newVirtualNode.props) { + Object.entries(newVirtualNode.props).forEach(([key, value]) => { if (isEventProperty(key)) { setEventListener(newVirtualNode, key, <{ (ev: Event): void }>value); return; @@ -110,8 +101,8 @@ export function createNode(spec: VirtualNodeSpec | string): NodeWithVirtualNode }); } - if (children) { - children.forEach((child) => { + if (newVirtualNode.children) { + newVirtualNode.children.forEach((child) => { if (typeof child !== 'string') { child.root = newVirtualNode.root; } @@ -122,31 +113,62 @@ export function createNode(spec: VirtualNodeSpec | string): NodeWithVirtualNode domNode.appendChild(domChild); if (domChild.virtualNode && domChild.virtualNode.state) { domChild.virtualNode.state.didMount(); + if (domChild.oldComponentVirtualNodes) { + domChild.oldComponentVirtualNodes.forEach((vNode) => { + vNode.state.didMount(); + }); + } } }); } - newVirtualNode.renderedSpec = spec; return domNode; } // We got a component - newVirtualNode.state = new type(props, children); - newVirtualNode.renderedSpec = newVirtualNode.state.render(); - newVirtualNode.renderedSpec.root = newVirtualNode.root; const domNode = createNode(newVirtualNode.renderedSpec); // domNode.virtualNode is a subtree of elements. // We should bind it to its origin element (its not quite parent) domNode.virtualNode.parent = newVirtualNode; - domNode.subtreeVirtualNode = domNode.virtualNode; - domNode.virtualNode = newVirtualNode; + if (domNode.originalVirtualNode === undefined) { + // Got first component virtual node in the row + domNode.originalVirtualNode = domNode.virtualNode; + domNode.virtualNode = newVirtualNode; + } else { + if (domNode.oldComponentVirtualNodes !== undefined) { + domNode.oldComponentVirtualNodes.push(domNode.virtualNode); + } else { + domNode.oldComponentVirtualNodes = [domNode.virtualNode]; + } + domNode.virtualNode = newVirtualNode; + } newVirtualNode.state.domNode = domNode; newVirtualNode.state.didCreate(); return domNode; } +function createVirtualNode(spec: VirtualNodeSpec) { + const newVirtualNode: VirtualNode = { + type: spec.type, + props: spec.props, + key: spec.key, + children: spec.children, + parent: null, + root: spec.root, + }; + if (typeof newVirtualNode.type !== 'string') { + newVirtualNode.state = new newVirtualNode.type(newVirtualNode.props, newVirtualNode.children); + newVirtualNode.renderedSpec = newVirtualNode.state.render(); + newVirtualNode.renderedSpec.root = newVirtualNode.root; + } else { + newVirtualNode.renderedSpec = spec; + } + + return newVirtualNode; +} + export function updateNode( curHtml: NodeWithVirtualNode, newSpec: VirtualNodeSpec, @@ -154,20 +176,26 @@ export function updateNode( // We assume that newSpec declares the same component as curNode // If we done right, not the same component will be sieved earlier const curNode = curHtml.virtualNode; - const prevSpec = curNode.renderedSpec; + let prevSpec = curHtml.virtualNode.renderedSpec; + let curSpec: VirtualNodeSpec; const isComponentNode = typeof curNode.type !== 'string'; if (isComponentNode) { + // We have to update each virtual node in component row and render this row + // till the moment when render result gives us plain html virtual node spec. curNode.state.willUpdate(newSpec.props, newSpec.children); const newRender = curNode.state.render(); curNode.renderedSpec = newRender; + curSpec = updateComponentChain(curHtml, newRender); } else { + prevSpec = curNode.renderedSpec; curNode.renderedSpec = newSpec; + curSpec = curNode.renderedSpec; } // curHtml is guarantied to be HTMLElement since text HTML node is handled outside - updateSelfProps(curHtml, prevSpec.props, curNode.renderedSpec.props); - updateChildren(curHtml, prevSpec.children, curNode.renderedSpec.children); + updateSelfProps(curHtml, prevSpec.props, curSpec.props); + updateChildren(curHtml, prevSpec.children, curSpec.children); if (isComponentNode) { curNode.state.didUpdate(); @@ -176,6 +204,50 @@ export function updateNode( return curHtml; } +function updateComponentChain(curHtml: NodeWithVirtualNode, firstRender: VirtualNodeSpec) { + let newRender = firstRender; + const oldComponentVirtualNodes = + curHtml.oldComponentVirtualNodes !== undefined + ? curHtml.oldComponentVirtualNodes.reverse() + : []; + const newOldComponentVirtualNodes = []; + + // Forward update loop + let oldComponentIdx = 0; + for (oldComponentIdx = 0; oldComponentIdx < oldComponentVirtualNodes.length; oldComponentIdx++) { + const oldVirtualNode = oldComponentVirtualNodes[oldComponentIdx]; + if (oldVirtualNode.type !== newRender.type) { + break; + } + oldVirtualNode.state.willUpdate(newRender.props, newRender.children); + newRender = oldVirtualNode.state.render(); + oldVirtualNode.renderedSpec = newRender; + newOldComponentVirtualNodes.push(oldVirtualNode); + } + // Now newOldComponentVirtualNodes contains updated component chain. + // We have to delete remaining virtual nodes if chain was broken and + // render more if there any chain pieces to add + for (oldComponentIdx; oldComponentIdx < oldComponentVirtualNodes.length; oldComponentIdx++) { + destroyVirtualNode(oldComponentVirtualNodes[oldComponentIdx]); + } + + let lastChainPiece = + newOldComponentVirtualNodes.length > 0 + ? newOldComponentVirtualNodes[newOldComponentVirtualNodes.length - 1] + : curHtml.virtualNode; + while (typeof newRender.type !== 'string') { + // Got new component to render + newRender.root = lastChainPiece.root; + const newVirtualNode = createVirtualNode(newRender); + newRender = newVirtualNode.renderedSpec; + newVirtualNode.parent = lastChainPiece; + lastChainPiece = newVirtualNode; + newOldComponentVirtualNodes.push(newVirtualNode); + } + curHtml.oldComponentVirtualNodes = newOldComponentVirtualNodes.reverse(); + return newRender; +} + function updateChildren( curHtml: HTMLElement & NodeWithVirtualNode, prevChildren: Array = [], @@ -210,10 +282,13 @@ function updateChildren( newChild.root = curHtml.virtualNode.root; } const newHtmlNode = createNode(newChild); - newHtmlNode.virtualNode.parent = curHtml.subtreeVirtualNode || curHtml.virtualNode; + newHtmlNode.virtualNode.parent = curHtml.originalVirtualNode || curHtml.virtualNode; curHtml.insertBefore(newHtmlNode, curHtml.childNodes[newChildIdx]); if (newHtmlNode.virtualNode && newHtmlNode.virtualNode.state) { newHtmlNode.virtualNode.state.didMount(); + if (newHtmlNode.oldComponentVirtualNodes) { + newHtmlNode.oldComponentVirtualNodes.forEach((v) => v.state.didMount()); + } } oldChildIdx--; } @@ -232,7 +307,7 @@ function updateSelfProps( prevProps: object | null, newProps: object | null, ) { - const virtualNode = curHtml.subtreeVirtualNode || curHtml.virtualNode; + const virtualNode = curHtml.originalVirtualNode || curHtml.virtualNode; if (virtualNode.eventListeners) { virtualNode.eventListeners.forEach( (listeners: Array<{ (ev: Event): void }>, eventName: string) => { @@ -275,24 +350,30 @@ export function destroyNode(domNode: NodeWithVirtualNode) { if (virtualNode) { const isComponentNode = typeof virtualNode.type !== 'string'; + destroyVirtualNode(virtualNode); if (isComponentNode) { - virtualNode.state.willDestroy(); - } - - if (virtualNode.eventListeners) { - virtualNode.eventListeners.forEach((_, eventName: string) => { - unsetEventListeners(virtualNode, eventName); - }); - delete virtualNode.eventListeners; - } - - if (domNode.subtreeVirtualNode && domNode.subtreeVirtualNode.eventListeners) { - domNode.subtreeVirtualNode.eventListeners.forEach((_, eventName: string) => { - unsetEventListeners(domNode.subtreeVirtualNode, eventName); - }); - delete domNode.subtreeVirtualNode.eventListeners; + if (domNode.oldComponentVirtualNodes) { + domNode.oldComponentVirtualNodes.forEach((vNode) => { + destroyVirtualNode(vNode); + }); + } + if (domNode.originalVirtualNode) { + destroyVirtualNode(domNode.originalVirtualNode); + } } domNode.childNodes.forEach((child) => destroyNode(child)); } } + +function destroyVirtualNode(virtualNode: VirtualNode) { + if (typeof virtualNode.type !== 'string') { + virtualNode.state.willDestroy(); + } + if (virtualNode.eventListeners) { + virtualNode.eventListeners.forEach((_, eventName: string) => { + unsetEventListeners(virtualNode, eventName); + }); + delete virtualNode.eventListeners; + } +} diff --git a/src/modules/vdom/virtual_dom_root.ts b/src/modules/vdom/virtual_dom_root.ts index 9932dbc..6f431f0 100644 --- a/src/modules/vdom/virtual_dom_root.ts +++ b/src/modules/vdom/virtual_dom_root.ts @@ -47,6 +47,14 @@ export class VirtualDomRoot { this.previousSpec = vDomSpec; this.renderedNode = createNode(vDomSpec); this.domNode.appendChild(this.renderedNode); + if (vDomSpec.type !== 'string') { + this.renderedNode.virtualNode.state.didMount(); + if (this.renderedNode.oldComponentVirtualNodes) { + this.renderedNode.oldComponentVirtualNodes.forEach((vNode) => { + vNode.state.didMount(); + }); + } + } } registerEvent(eventName: string): void { diff --git a/src/modules/vdom/virtual_node.ts b/src/modules/vdom/virtual_node.ts index 2898cde..7a74e7d 100644 --- a/src/modules/vdom/virtual_node.ts +++ b/src/modules/vdom/virtual_node.ts @@ -25,7 +25,8 @@ export interface VirtualNode { export interface NodeWithVirtualNode extends Node { virtualNode?: VirtualNode; - subtreeVirtualNode?: VirtualNode; + oldComponentVirtualNodes?: Array; + originalVirtualNode?: VirtualNode; } export abstract class Component { diff --git a/src/modules/vdom_router/router.ts b/src/modules/vdom_router/router.ts deleted file mode 100644 index 96fc983..0000000 --- a/src/modules/vdom_router/router.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { Component } from '@/modules/vdom/virtual_node'; -import { NotFoundPage } from '@/application/pages/not_found_page/not_found_page'; -import { VirtualDomRoot } from '@/modules/vdom/virtual_dom_root'; - -export interface PageClass { - new (props: { url: URL }): Component; -} - -export class ForbiddenPage extends Error { - public redirectUrl: URL; - constructor(redirectUrl: URL) { - super('forbidden page'); - this.redirectUrl = redirectUrl; - Object.setPrototypeOf(this, ForbiddenPage.prototype); - } -} - -export class NotFoundError extends Error { - constructor() { - super('resource not found'); - Object.setPrototypeOf(this, NotFoundError.prototype); - } -} - -export class Router { - private routes: Map; - private vdomRoot: VirtualDomRoot; - - constructor(rootNode: HTMLElement) { - this.routes = new Map(); - this.vdomRoot = new VirtualDomRoot(rootNode); - } - - addRoute(pathname: string, pageClass: PageClass) { - this.routes.set(pathname, pageClass); - } - - removeRoute(pathname: string): boolean { - return this.routes.delete(pathname); - } - - getVdomRoot() { - return this.vdomRoot; - } - - navigate(url: URL, redirection: boolean = false, modifyHistory: boolean = true) { - try { - if (modifyHistory) { - if (!redirection) { - history.pushState(null, '', url); - } else { - history.replaceState(null, '', url); - } - } - const newPage = this.routes.has(url.pathname) ? this.routes.get(url.pathname) : NotFoundPage; - this.replacePage(newPage, url); - } catch (err) { - if (err instanceof ForbiddenPage) { - this.navigate(err.redirectUrl, true, true); - return; - } - if (err instanceof NotFoundError) { - this.replacePage(NotFoundPage, url); - return; - } - throw err; - } - } - - private replacePage(newPageClass: PageClass, newPageUrl: URL) { - this.vdomRoot.render({ type: newPageClass, props: { url: newPageUrl }, children: [] }); - } - - start() { - window.addEventListener('popstate', (ev) => { - ev.preventDefault(); - this.navigate(new URL(location.href), false, false); - }); - - window.addEventListener('click', (ev) => { - let currentElement = ev.target as HTMLElement; - while (currentElement) { - if ( - currentElement instanceof HTMLAnchorElement && - currentElement.origin === location.origin - ) { - ev.preventDefault(); - this.navigate(new URL(currentElement.href)); - break; - } - currentElement = currentElement.parentElement; - } - }); - - this.navigate(new URL(location.href), false, false); - } -} From aaae567764e5fd57551e8451ddaaaf40540537f0 Mon Sep 17 00:00:00 2001 From: Ilya Andriyanov Date: Thu, 12 Dec 2024 10:55:26 +0300 Subject: [PATCH 05/17] fix: component chain bugs --- .../page_container/page_container.tsx | 2 +- .../pages/vacancies_page/vacancies_page.tsx | 2 +- src/index.tsx | 2 +- src/modules/vdom/virtual_dom.ts | 32 ++++++++++++++++--- src/modules/vdom/virtual_dom_root.ts | 11 +++---- 5 files changed, 36 insertions(+), 13 deletions(-) diff --git a/src/application/components/page_container/page_container.tsx b/src/application/components/page_container/page_container.tsx index 0ad6c89..8c120e7 100644 --- a/src/application/components/page_container/page_container.tsx +++ b/src/application/components/page_container/page_container.tsx @@ -6,7 +6,7 @@ import './page-container.scss'; export class PageContainer extends Component { render() { return ( -
+
diff --git a/src/application/pages/vacancies_page/vacancies_page.tsx b/src/application/pages/vacancies_page/vacancies_page.tsx index 1777c3c..286541b 100644 --- a/src/application/pages/vacancies_page/vacancies_page.tsx +++ b/src/application/pages/vacancies_page/vacancies_page.tsx @@ -8,7 +8,7 @@ export class VacanciesPage extends Component { } render() { return ( - +

Вакансии

); diff --git a/src/index.tsx b/src/index.tsx index 66b4a1b..a97554e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -34,7 +34,7 @@ const appRoot = new VirtualDomRoot(document.getElementById('app')); routerActionCreators.addRoute(resolveUrl('vacancies', null), VacanciesPage); routerActionCreators.addRoute(resolveUrl('login', null), LoginPage); -routerActionCreators.startRouting(resolveUrl('vacancies', null)); +routerActionCreators.startRouting(new URL(location.href)); // TODO: add auth check storeManager.bindVirtualDom(appRoot); diff --git a/src/modules/vdom/virtual_dom.ts b/src/modules/vdom/virtual_dom.ts index c03ead9..5963eed 100644 --- a/src/modules/vdom/virtual_dom.ts +++ b/src/modules/vdom/virtual_dom.ts @@ -127,6 +127,9 @@ export function createNode(spec: VirtualNodeSpec | string): NodeWithVirtualNode // We got a component + if (newVirtualNode.renderedSpec.key === null) { + newVirtualNode.renderedSpec.key = newVirtualNode.key; + } const domNode = createNode(newVirtualNode.renderedSpec); // domNode.virtualNode is a subtree of elements. // We should bind it to its origin element (its not quite parent) @@ -176,7 +179,9 @@ export function updateNode( // We assume that newSpec declares the same component as curNode // If we done right, not the same component will be sieved earlier const curNode = curHtml.virtualNode; - let prevSpec = curHtml.virtualNode.renderedSpec; + let prevSpec = curHtml.originalVirtualNode + ? curHtml.originalVirtualNode.renderedSpec + : curHtml.virtualNode.renderedSpec; let curSpec: VirtualNodeSpec; const isComponentNode = typeof curNode.type !== 'string'; @@ -187,6 +192,23 @@ export function updateNode( const newRender = curNode.state.render(); curNode.renderedSpec = newRender; curSpec = updateComponentChain(curHtml, newRender); + // Have to check if new rendered html virtual node has the same type + // if not, we should just replace html + if (curSpec.type !== curHtml.originalVirtualNode.type) { + destroyVirtualNode(curHtml.originalVirtualNode); + const newHtmlNode = createNode(curSpec); + newHtmlNode.originalVirtualNode = newHtmlNode.virtualNode; + newHtmlNode.oldComponentVirtualNodes = curHtml.oldComponentVirtualNodes; + newHtmlNode.virtualNode = curNode; + (curHtml.parentElement as HTMLElement).insertBefore(newHtmlNode, curHtml); + // We are replacing dom node, so the states have to be updated with new domNode + curHtml.virtualNode.state.domNode = newHtmlNode; + curHtml.oldComponentVirtualNodes.forEach( + (virtualNode) => (virtualNode.state.domNode = newHtmlNode), + ); + curHtml.parentElement.removeChild(curHtml); + return newHtmlNode; + } } else { prevSpec = curNode.renderedSpec; curNode.renderedSpec = newSpec; @@ -194,6 +216,7 @@ export function updateNode( } // curHtml is guarantied to be HTMLElement since text HTML node is handled outside + // prev and new specs are guaranteed to be of the same plain html type updateSelfProps(curHtml, prevSpec.props, curSpec.props); updateChildren(curHtml, prevSpec.children, curSpec.children); @@ -278,11 +301,13 @@ function updateChildren( updateNode(curHtml.childNodes[newChildIdx], newChild); } else { // Delete old node and add new one - if (typeof newChild !== 'string') { + if (!isNewChildText) { newChild.root = curHtml.virtualNode.root; } const newHtmlNode = createNode(newChild); - newHtmlNode.virtualNode.parent = curHtml.originalVirtualNode || curHtml.virtualNode; + if (newHtmlNode.virtualNode) { + newHtmlNode.virtualNode.parent = curHtml.originalVirtualNode || curHtml.virtualNode; + } curHtml.insertBefore(newHtmlNode, curHtml.childNodes[newChildIdx]); if (newHtmlNode.virtualNode && newHtmlNode.virtualNode.state) { newHtmlNode.virtualNode.state.didMount(); @@ -290,7 +315,6 @@ function updateChildren( newHtmlNode.oldComponentVirtualNodes.forEach((v) => v.state.didMount()); } } - oldChildIdx--; } } diff --git a/src/modules/vdom/virtual_dom_root.ts b/src/modules/vdom/virtual_dom_root.ts index 6f431f0..37567b2 100644 --- a/src/modules/vdom/virtual_dom_root.ts +++ b/src/modules/vdom/virtual_dom_root.ts @@ -24,25 +24,24 @@ export class VirtualDomRoot { if (this.previousSpec === undefined) { return; } - updateNode(this.renderedNode, this.previousSpec); + this.renderedNode = updateNode(this.renderedNode, this.previousSpec); return; } const newSpec = createElement(tsx.type, tsx.props, ...tsx.children); newSpec.root = this; this.previousSpec = newSpec; - updateNode(this.renderedNode, newSpec); + this.renderedNode = updateNode(this.renderedNode, newSpec); } - render(tsx: Tsx | string) { + render(vDomSpec: VirtualNodeSpec | string) { this.domNode.childNodes.forEach((child) => { destroyNode(child); this.domNode.removeChild(child); }); - if (typeof tsx === 'string') { - this.domNode.textContent = tsx; + if (typeof vDomSpec === 'string') { + this.domNode.textContent = vDomSpec; return; } - const vDomSpec = createElement(tsx.type, tsx.props, ...tsx.children); vDomSpec.root = this; this.previousSpec = vDomSpec; this.renderedNode = createNode(vDomSpec); From 8112b44b99c897f24d6dc039ba3d2adceca0358d Mon Sep 17 00:00:00 2001 From: Ilya Andriyanov Date: Fri, 13 Dec 2024 04:52:35 +0300 Subject: [PATCH 06/17] fix: more vdom and router bugs --- .../action_creators/user_action_creators.ts | 28 ++++++++- .../components/dropdown/dropdown.tsx | 61 ++++++++++--------- src/application/components/header/header.tsx | 49 +++++++++++---- .../page_container/page_container.tsx | 4 +- .../pages/login_page/login_page.tsx | 10 +-- .../pages/vacancies_page/vacancies_page.tsx | 2 +- .../stores/user_store/user_actions.ts | 2 +- .../stores/user_store/user_store.ts | 4 +- src/index.tsx | 8 ++- src/modules/store_manager/store_manager.ts | 20 +++++- src/modules/vdom/virtual_dom.ts | 24 +++++++- src/modules/vdom/virtual_dom_root.ts | 27 +++++--- 12 files changed, 170 insertions(+), 69 deletions(-) diff --git a/src/application/action_creators/user_action_creators.ts b/src/application/action_creators/user_action_creators.ts index 02bdc05..4a73a67 100644 --- a/src/application/action_creators/user_action_creators.ts +++ b/src/application/action_creators/user_action_creators.ts @@ -2,6 +2,7 @@ import { UserActions, LoginAction, LogoutAction, + LoginActionPayload, } from '@application/stores/user_store/user_actions'; import { LoginFormData } from '@application/stores/user_store/user_store'; import { @@ -9,6 +10,7 @@ import { logout as apiLogout, registerApplicant as apiRegisterApplicant, registerEmployer as apiRegisterEmployer, + getUserAuthenticationStatus, getEmployer, getApplicant, } from '@api/api'; @@ -65,12 +67,13 @@ async function login({ userType, email, password }: LoginOptions) { storeManager.dispatch({ type: UserActions.Login, payload: { - email, + id: loginResponse.id, userType, userProfile, }, } as LoginAction); } catch (err) { + console.log(err); assertIfError(err); storeManager.dispatch({ type: UserActions.Logout, @@ -78,6 +81,26 @@ async function login({ userType, email, password }: LoginOptions) { } } +async function isAuthorized() { + const backendOrigin = backendStore.getData().backendOrigin; + try { + const response = await getUserAuthenticationStatus(backendOrigin); + const userProfile = await getUser(backendOrigin, response.userType, response.id); + storeManager.dispatch({ + type: UserActions.Login, + payload: { + id: response.id, + userType: response.userType, + userProfile, + } as LoginActionPayload, + } as LoginAction); + } catch { + storeManager.dispatch({ + type: UserActions.Logout, + } as LogoutAction); + } +} + async function getUser(backendOrigin: URL, userType: UserType, id: number) { return userType === UserType.Applicant ? makeApplicantFromApi((await getApplicant(backendOrigin, id)) as ApiApplicant) @@ -108,7 +131,7 @@ async function register( storeManager.dispatch({ type: UserActions.Login, payload: { - email: body.email, + id: response.id, userType, userProfile, }, @@ -121,6 +144,7 @@ async function register( } export const userActionCreators = { + isAuthorized, login, logout, register, diff --git a/src/application/components/dropdown/dropdown.tsx b/src/application/components/dropdown/dropdown.tsx index c6faa74..cb87e91 100644 --- a/src/application/components/dropdown/dropdown.tsx +++ b/src/application/components/dropdown/dropdown.tsx @@ -4,43 +4,48 @@ import { VirtualNodeSpec } from '@/modules/vdom/virtual_node'; export interface DropdownProps { elementClass: string; + isOpen: boolean; + setIsOpen: (newIsOpen: boolean) => void; } export class Dropdown extends Component { private dropdownOpen: boolean = false; - constructor({ elementClass }: DropdownProps, children?: Array) { - super({ elementClass }, children); + private handleClick: (ev: Event) => void; + constructor( + { elementClass, isOpen, setIsOpen }: DropdownProps, + children?: Array, + ) { + super({ elementClass, isOpen, setIsOpen }, children); + // this.handleClick = this.onClick.bind(this); } - didMount(): void { - window.addEventListener('click', this.onClick); - } - - willDestroy(): void { - window.removeEventListener('click', this.onClick); - } + // didMount(): void { + // window.addEventListener('click', this.handleClick); + // } - private onClick = (ev: Event): void => { - if (!this.dropdownOpen) { - return; - } - const clickedInsideDropdown = - this.domNode.contains(ev.target as HTMLElement) || Object.is(this.domNode, ev.target); - if (!clickedInsideDropdown) { - this.toggleDropdown(); - } - }; + // willDestroy(): void { + // window.removeEventListener('click', this.handleClick); + // } - toggleDropdown(): void { - this.dropdownOpen = !this.dropdownOpen; - vdom.updateNode(this.domNode, this.render()); - } + // private onClick = (ev: Event): void => { + // if (!this.props.isOpen) { + // return; + // } + // const clickedInsideDropdown = + // this.domNode.contains(ev.target as HTMLElement) || Object.is(this.domNode, ev.target); + // if (!clickedInsideDropdown) { + // (this.props.setIsOpen as (newIsOpen: boolean) => void)(false); + // } + // }; render(): VirtualNodeSpec { - if (this.dropdownOpen) { - return
{...this.children}
; - } else { - return null; - } + return ( +
+ {this.children} +
+ ); } } diff --git a/src/application/components/header/header.tsx b/src/application/components/header/header.tsx index c1ba3f5..b398ed1 100644 --- a/src/application/components/header/header.tsx +++ b/src/application/components/header/header.tsx @@ -11,12 +11,24 @@ import logoutMenuIconSvg from '@static/img/logout-menu-icon.svg'; import { Dropdown } from '@/application/components/dropdown/dropdown'; import { UserType } from '@/application/models/user-type'; import './header.scss'; +import { userActionCreators } from '@/application/action_creators/user_action_creators'; export class Header extends Component { + private isDropdownOpen: boolean; + private setIsDropdownOpen: (newIsDropdownOpen: boolean) => void; + constructor({ elementClass }: { elementClass?: string }) { + super({ elementClass }); + this.isDropdownOpen = false; + this.setIsDropdownOpen = (newIsDropdownOpen: boolean) => { + this.isDropdownOpen = newIsDropdownOpen; + this.domNode.virtualNode.root.update(); + }; + } + render(): VirtualNodeSpec { const userData = userStore.getData(); return ( -
diff --git a/src/application/stores/profile_store/profile_actions.ts b/src/application/stores/profile_store/profile_actions.ts index 4347298..55428aa 100644 --- a/src/application/stores/profile_store/profile_actions.ts +++ b/src/application/stores/profile_store/profile_actions.ts @@ -13,6 +13,7 @@ export enum ProfileActions { ProfileFormReset = 'profileFormReset', UpdateVacancyList = 'updateVacancyList', UpdateCvList = 'updateCvList', + UpdateFavoriteVacancyList = 'updateFavoriteVacancyList', } export interface UpdateProfileAction { @@ -25,6 +26,11 @@ export interface UpdateVacancyListAction { payload: Vacancy[]; } +export interface UpdateFavoriteVacancyListAction { + type: ProfileActions.UpdateFavoriteVacancyList; + payload: Vacancy[]; +} + export interface UpdateCvListAction { type: ProfileActions.UpdateCvList; payload: Cv[]; diff --git a/src/application/stores/profile_store/profile_store.ts b/src/application/stores/profile_store/profile_store.ts index 00b3d1e..cb8cd64 100644 --- a/src/application/stores/profile_store/profile_store.ts +++ b/src/application/stores/profile_store/profile_store.ts @@ -36,6 +36,7 @@ export interface ProfileData { profileForm?: ProfileFormData; vacancyList?: Vacancy[]; cvList?: Cv[]; + favoriteVacancyList?: Vacancy[]; } function profileStoreReducer(state: ProfileData, action: Action) { @@ -73,6 +74,12 @@ function profileStoreReducer(state: ProfileData, action: Action) { state.cvList = payload; return state; } + + case ProfileActions.UpdateFavoriteVacancyList: { + const payload = action.payload as Vacancy[]; + state.favoriteVacancyList = payload; + return state; + } } return state; } diff --git a/src/application/stores/vacancy_store/vacancy_actions.ts b/src/application/stores/vacancy_store/vacancy_actions.ts index dd3bb73..1a11409 100644 --- a/src/application/stores/vacancy_store/vacancy_actions.ts +++ b/src/application/stores/vacancy_store/vacancy_actions.ts @@ -6,6 +6,8 @@ import { Applicant } from '@/application/models/applicant'; export enum VacancyActions { Apply = 'vacancyApply', ResetApply = 'vacancyResetApply', + AddToFavorite = 'vacancyAddToFavorite', + RemoveFromFavorite = 'vacancyRemoveFromFavorite', Update = 'vacancyUpdate', Clear = 'vacancyClear', } @@ -29,6 +31,14 @@ export interface ResetApplyAction { type: VacancyActions.ResetApply; } +export interface AddToFavoriteAction { + type: VacancyActions.AddToFavorite; +} + +export interface RemoveFromFavoriteAction { + type: VacancyActions.RemoveFromFavorite; +} + export interface ClearAction { type: VacancyActions.Clear; } diff --git a/src/application/stores/vacancy_store/vacancy_store.ts b/src/application/stores/vacancy_store/vacancy_store.ts index 004f8df..a294376 100644 --- a/src/application/stores/vacancy_store/vacancy_store.ts +++ b/src/application/stores/vacancy_store/vacancy_store.ts @@ -9,6 +9,7 @@ export interface VacancyData { vacancy?: Vacancy; loadedVacancy?: boolean; applied?: boolean; + favorite?: boolean; appliers?: Array; } @@ -33,6 +34,17 @@ function vacancyStoreReducer(state: VacancyData, action: Action) { state.applied = false; return state; } + + case VacancyActions.AddToFavorite: { + state.favorite = true; + return state; + } + + case VacancyActions.RemoveFromFavorite: { + state.favorite = false; + return state; + } + case VacancyActions.Clear: { return {}; } diff --git a/src/modules/api/api.ts b/src/modules/api/api.ts index 6685092..226f4d5 100644 --- a/src/modules/api/api.ts +++ b/src/modules/api/api.ts @@ -10,6 +10,11 @@ export { getApplicantCvs } from './src/handlers/cv/applicant_cv'; export { createCv, deleteCv, getCv, updateCv } from './src/handlers/cv/cv'; export { getEmployer, updateEmployerProfile } from './src/handlers/employer/profile'; export { getApplicantPortfolios } from './src/handlers/portfolio/applicant_portfolios'; +export { + getApplicantFavoriteVacancies, + addVacancyToFavorites, + removeVacancyFromFavorites, +} from './src/handlers/vacancy/applicant_vacancies'; export { applyToVacancy, getVacancyAppliers, diff --git a/src/modules/api/src/handlers/vacancy/applicant_vacancies.ts b/src/modules/api/src/handlers/vacancy/applicant_vacancies.ts new file mode 100644 index 0000000..63fdd22 --- /dev/null +++ b/src/modules/api/src/handlers/vacancy/applicant_vacancies.ts @@ -0,0 +1,46 @@ +import { Vacancy } from '../../responses/vacancy'; +import { HttpMethod } from '../utils/fetch_with_cors'; +import { fetchCors } from '../utils/fetch_with_cors'; +import { unpackJsonResponseBody } from '../utils/unpack_body'; + +export async function getApplicantFavoriteVacancies( + apiOrigin: URL, + id: number, +): Promise> { + const response = await fetchCors(new URL(apiOrigin.href + `applicant/${id}/favorite-vacancy`), { + method: HttpMethod.Get, + }); + return (await unpackJsonResponseBody(response)) as Array; +} + +export async function addVacancyToFavorites( + apiOrigin: URL, + token: string, + id: number, +): Promise { + const response = await fetchCors( + new URL(apiOrigin.href + `applicant/${id}/favorite-vacancy`), + { + method: HttpMethod.Post, + credentials: 'include', + }, + token, + ); + return (await unpackJsonResponseBody(response)) as Promise; +} + +export async function removeVacancyFromFavorites( + apiOrigin: URL, + token: string, + id: number, +): Promise { + const response = await fetchCors( + new URL(apiOrigin.href + `applicant/${id}/favorite-vacancy`), + { + method: HttpMethod.Delete, + credentials: 'include', + }, + token, + ); + return (await unpackJsonResponseBody(response)) as Promise; +} diff --git a/src/modules/api/src/handlers/vacancy/appliers.ts b/src/modules/api/src/handlers/vacancy/appliers.ts index dd3b621..724a4e9 100644 --- a/src/modules/api/src/handlers/vacancy/appliers.ts +++ b/src/modules/api/src/handlers/vacancy/appliers.ts @@ -26,11 +26,19 @@ export async function getVacancyAppliers( * @param id - The id of the vacancy. * @returns A promise that resolves to the status of the application to that vacancy. */ -export async function getVacancyApplyStatus(apiOrigin: URL, id: number): Promise { - const response = await fetchCors(new URL(apiOrigin.href + `vacancy/${id}/subscription`), { - method: HttpMethod.Get, - credentials: 'include', - }); +export async function getVacancyApplyStatus( + apiOrigin: URL, + token: string, + id: number, +): Promise { + const response = await fetchCors( + new URL(apiOrigin.href + `vacancy/${id}/subscription`), + { + method: HttpMethod.Get, + credentials: 'include', + }, + token, + ); return (await unpackJsonResponseBody(response)) as Application; } diff --git a/src/public/img/card-bookmark-filled.svg b/src/public/img/card-bookmark-filled.svg new file mode 100644 index 0000000..758d9b9 --- /dev/null +++ b/src/public/img/card-bookmark-filled.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/public/img/card-bookmark-whole.svg b/src/public/img/card-bookmark-whole.svg new file mode 100644 index 0000000..39e72e3 --- /dev/null +++ b/src/public/img/card-bookmark-whole.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/public/img/card-bookmark.svg b/src/public/img/card-bookmark.svg deleted file mode 100644 index 9f436b3..0000000 --- a/src/public/img/card-bookmark.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file From 35a4d0e39530c11e9c90d143dec6479b0da1dfdf Mon Sep 17 00:00:00 2001 From: Ilya Andriyanov Date: Mon, 16 Dec 2024 12:55:36 +0300 Subject: [PATCH 15/17] feature: notifications --- .../action_creators/cv_action_creators.ts | 26 ++++++ .../components/cv_article/cv_article.scss | 10 +++ .../components/cv_article/cv_article.tsx | 11 +++ src/application/components/header/header.scss | 23 ++++- src/application/components/header/header.tsx | 32 ++++++- .../notification_list/notification_list.scss | 18 ++++ .../notification_list/notification_list.tsx | 46 ++++++++++ .../vacancy_edit_page/vacancy_edit_page.scss | 0 .../vacancy_edit_page/vacancy_edit_page.tsx | 83 ++++++++++++++++++ .../stores/backend_store/backend_store.ts | 2 + src/application/stores/cv_store/cv_actions.ts | 7 ++ src/application/stores/cv_store/cv_store.ts | 9 ++ .../stores/user_store/user_actions.ts | 5 ++ .../stores/user_store/user_store.ts | 9 ++ src/config/backend.json | 3 +- src/modules/api/api.ts | 2 +- src/modules/api/src/handlers/cv/cv.ts | 8 ++ src/modules/api/src/responses/notification.ts | 9 ++ src/modules/api/src/responses/pdf.ts | 3 + .../notification_manager.ts | 38 ++++++++ src/public/img/icon-pdf-50.png | Bin 0 -> 860 bytes 21 files changed, 339 insertions(+), 5 deletions(-) create mode 100644 src/application/components/notification_list/notification_list.scss create mode 100644 src/application/components/notification_list/notification_list.tsx create mode 100644 src/application/pages/vacancy_edit_page/vacancy_edit_page.scss create mode 100644 src/application/pages/vacancy_edit_page/vacancy_edit_page.tsx create mode 100644 src/modules/api/src/responses/notification.ts create mode 100644 src/modules/api/src/responses/pdf.ts create mode 100644 src/modules/api/src/websockets/notification_manager./notification_manager.ts create mode 100644 src/public/img/icon-pdf-50.png diff --git a/src/application/action_creators/cv_action_creators.ts b/src/application/action_creators/cv_action_creators.ts index 6e58363..cc834fb 100644 --- a/src/application/action_creators/cv_action_creators.ts +++ b/src/application/action_creators/cv_action_creators.ts @@ -13,6 +13,7 @@ import { deleteCv, createCv as apiCreateCv, updateCv as apiUpdateCv, + convertCvToPdf, } from '@/modules/api/api'; import { assertIfError } from '@/modules/common_utils/asserts/asserts'; import { FormValue } from '../models/form_value'; @@ -171,6 +172,30 @@ async function updateCv(id: number, body: CvFormFields) { } } +async function loadPdf(cvId: number) { + const backendOrigin = backendStore.getData().backendOrigin; + try { + const pdf = await convertCvToPdf(backendOrigin, cvId); + storeManager.dispatch({ + type: CvActions.LoadPdf, + payload: pdf, + }); + const pdfBlob = new Blob([pdf.location], { type: 'application/pdf' }); + const pdfUrl = URL.createObjectURL(pdfBlob); + const a = document.createElement('a'); + a.setAttribute('href', pdfUrl); + a.setAttribute('download', 'cv.pdf'); + a.click(); + } catch (err) { + assertIfError(err); + console.log(err); + storeManager.dispatch({ + type: CvActions.LoadPdf, + payload: null, + }); + } +} + export const cvActionCreators = { loadCv, clearCv, @@ -178,4 +203,5 @@ export const cvActionCreators = { createCv, removeCv, submitCvFields, + loadPdf, }; diff --git a/src/application/components/cv_article/cv_article.scss b/src/application/components/cv_article/cv_article.scss index 5bc99e1..e4099c2 100644 --- a/src/application/components/cv_article/cv_article.scss +++ b/src/application/components/cv_article/cv_article.scss @@ -1,6 +1,7 @@ .cv-article { display: flex; flex-direction: column; + position: relative; align-items: start; &__header-container { @@ -31,6 +32,15 @@ margin-bottom: 32px; } + &__pdf-button { + position: absolute; + top: var(--col-margin); + width: 36px; + height: 36px; + right: var(--col-margin); + cursor: pointer; + } + &__working-experience { padding: 0px 32px; font-size: var(--text-size-5); diff --git a/src/application/components/cv_article/cv_article.tsx b/src/application/components/cv_article/cv_article.tsx index 78f2ddd..d62ace3 100644 --- a/src/application/components/cv_article/cv_article.tsx +++ b/src/application/components/cv_article/cv_article.tsx @@ -11,6 +11,7 @@ import { ProfilePageParams, ProfilePageStartingFrames, } from '@/application/pages/profile_page/profile_page'; +import IconPdf from '@static/img/icon-pdf-50.png'; export interface CvArticleProps { elementClass: string; @@ -88,12 +89,22 @@ export class CvArticle extends Component { > Удалить +
{`последнее обновление: ${cv.updatedAt.toLocaleString('ru-RU')}`}
) )} + { + cvActionCreators.loadPdf(cv.id); + }} + className="cv-article__pdf-button" + > + PDF + ); } diff --git a/src/application/components/header/header.scss b/src/application/components/header/header.scss index d7727c6..cb518ea 100644 --- a/src/application/components/header/header.scss +++ b/src/application/components/header/header.scss @@ -48,15 +48,34 @@ height: 48px; } + &__notifications { + position: relative; + } &__notification-button { width: 36px; height: 36px; - visibility: hidden; + &:hover { + background-color: var(--color-background-600); + border-radius: var(--radius-xs); + } + } + + &__notify-dropdown { + position: absolute; + top: calc(50% + 30px); + right: 0%; + background-color: var(--color-background-700); + border-radius: 8px; + padding: 12px; + display: flex; + flex-direction: column; + align-items: left; + font-size: var(--text-size-5); } &__dropdown { position: absolute; - top: 60px; + top: calc(50% + 30px); right: 0; background-color: var(--color-background-700); border-radius: 8px; diff --git a/src/application/components/header/header.tsx b/src/application/components/header/header.tsx index 5c180d3..7ab0c1c 100644 --- a/src/application/components/header/header.tsx +++ b/src/application/components/header/header.tsx @@ -12,10 +12,13 @@ import { Dropdown } from '@/application/components/dropdown/dropdown'; import { UserType } from '@/application/models/user-type'; import './header.scss'; import { userActionCreators } from '@/application/action_creators/user_action_creators'; +import { NotificationList } from '../notification_list/notification_list'; export class Header extends Component { private isDropdownOpen: boolean; private setIsDropdownOpen: (newIsDropdownOpen: boolean) => void; + private isNotifyDropdownOpen: boolean = false; + private setIsNotifyDropdownOpen: (newIsNotifyDropdownOpen: boolean) => void; constructor({ elementClass }: { elementClass?: string }) { super({ elementClass }); this.isDropdownOpen = false; @@ -23,10 +26,18 @@ export class Header extends Component { this.isDropdownOpen = newIsDropdownOpen; this.domNode.virtualNode.root.update(); }; + this.isNotifyDropdownOpen = false; + this.setIsNotifyDropdownOpen = (newIsNotifyDropdownOpen: boolean) => { + this.isNotifyDropdownOpen = newIsNotifyDropdownOpen; + this.domNode.virtualNode.root.update(); + }; } render(): VirtualNodeSpec { const userData = userStore.getData(); + const notifications = userData.notificationManager + ? userData.notificationManager.getNotifications() + : []; return (