Add base model, service and view

- Add wallet, transaction model
- Add wallet, transaction service
- Add home view, controller
- Additional some type and interface
This commit is contained in:
2023-09-30 21:40:22 +07:00
parent 36e0c55faa
commit 97dfd4c715
16 changed files with 815 additions and 17 deletions
@@ -0,0 +1,63 @@
import HomeView from 'views/home/homeView';
import Transform from '../helpers/transform';
import Service from 'services';
import View from 'views';
import Wallet from 'models/wallet';
import Transaction from 'models/transaction';
export default class HomeController {
public homeView: HomeView | null = null;
constructor(
public service: Service,
public view: View,
) {
this.service = service;
this.homeView = view.homeView;
}
handlerGetInfoUserLogin() {
return this.service.userService.getInfoUserLogin();
}
handlerGetWalletByIdUser(idUser: number) {
return this.service.walletService.getWalletByIdUser(idUser);
}
handlerSaveWallet(wallet: Wallet) {
return this.service.walletService.saveWallet(wallet);
}
handlerSaveTransaction(transaction: Transaction) {
return this.service.transactionService.saveTransaction(transaction);
}
// handlerGetAllCategory() {
// return this.service.categoryService.getAllCategory();
// }
// handlerGetAllTransactions(idUser) {
// return this.service.transactionService.getListTransactionByIdUser(idUser);
// }
// handlerDeleteTransaction(idTransaction) {
// return this.service.transactionService.deleteTransaction(idTransaction);
// }
init() {
if (this.homeView) {
this.homeView.initFunction(
this.handlerGetInfoUserLogin.bind(this),
this.handlerGetWalletByIdUser.bind(this),
this.handlerSaveWallet.bind(this),
this.handlerSaveTransaction.bind(this),
new Transform(),
);
this.homeView.loadPage();
// Subscribe listener data update
this.homeView.subscribeListenerData();
}
}
}
+15 -2
View File
@@ -1,3 +1,5 @@
import Wallet from 'models/wallet';
export interface IDataObject<T> {
id: string;
data: T;
@@ -14,10 +16,10 @@ export class DataObject<T> {
}
}
export type TError = {
export interface TError {
title: string;
message: string;
};
}
export class CustomError extends Error {
constructor(
@@ -27,3 +29,14 @@ export class CustomError extends Error {
super(message);
}
}
export type TSignal = {
[key: string]: {
name: string;
handler: (value: Data) => void;
};
};
export interface Data {
wallet: Wallet;
}
+13 -1
View File
@@ -1,6 +1,8 @@
import localStorageService from 'services/localStorageService';
import { DataObject, IDataObject } from '../global/types';
import { LOCAL_STORAGE } from 'constants/config';
export const createIdUser = (): number => {
export const generateId = (): number => {
return new Date().getTime();
};
@@ -30,3 +32,13 @@ export const createToken = (): string => {
}
return token;
};
export const formatNumber = (number: number): string => {
return number.toLocaleString(undefined, {
minimumFractionDigits: 2,
});
};
export const clearAccessToken = (): void => {
localStorageService.remove(LOCAL_STORAGE.ACCESS_TOKEN);
};
@@ -0,0 +1,33 @@
import { TSignal, Data } from 'global/types';
export default class Transform {
public signal: TSignal;
constructor() {
this.signal = {};
}
onSendSignal(fromClass: string, value: Data) {
Object.keys(this.signal).forEach((key) => {
const item = this.signal[key as keyof TSignal];
// Same receiver
if (item.name === fromClass) {
return;
}
// Send signal
const { handler } = this.signal[key];
if (handler) {
handler(value);
}
});
}
create(
className: string,
handler: ((value: Data) => void) | null = null,
): void {
if (handler) this.signal[className] = { name: className, handler };
}
}
@@ -0,0 +1,54 @@
import { generateId } from 'helpers/data';
export default class Transaction {
private readonly _id: number;
private _categoryName: string;
private _date: string;
private _note: string;
private _amount: number;
private _idUser: number;
constructor(
categoryName: string,
date: string,
note: string,
amount: number,
idUser: number,
) {
this._id = generateId();
this._categoryName = categoryName;
this._date = date;
this._note = note;
this._amount = amount;
this._idUser = idUser;
}
get id() {
return this._id;
}
get categoryName() {
return this._categoryName;
}
get date() {
return this._date;
}
get note() {
return this._note;
}
get amount() {
return this._amount;
}
get idUser() {
return this._idUser;
}
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { createIdUser } from '../helpers/data';
import { generateId } from '../helpers/data';
export default class User {
private readonly _id: number;
@@ -10,7 +10,7 @@ export default class User {
private _accessToken: string;
constructor(email: string, password: string, accessToken?: string) {
this._id = createIdUser();
this._id = generateId();
this._email = email;
this._password = password || '';
this._accessToken = accessToken || '';
@@ -0,0 +1,50 @@
import { generateId } from '../helpers/data';
export default class Wallet {
private readonly _id: number;
private _walletName: string;
private _inflow: number;
private _outflow: number;
private _idUser: number;
constructor(
walletName: string,
inflow: number,
outflow: number,
idUser: number,
) {
this._id = generateId();
this._walletName = walletName;
this._inflow = inflow;
this._outflow = outflow;
this._idUser = idUser;
}
get id() {
return this._id;
}
get walletName() {
return this._walletName;
}
get inflow() {
return this._inflow;
}
get outflow() {
return this._outflow;
}
get idUser() {
return this._idUser;
}
get amountWallet() {
return this._inflow + this._outflow;
}
}
@@ -77,7 +77,7 @@ export default class CommonService<T> {
return null;
}
async getAllDataFromPath(path = this.defaultPath): Promise<object[] | null> {
async getAllDataFromPath(path = this.defaultPath): Promise<T | null> {
this.connectToDb();
const results = await timeOutConnect(
@@ -90,7 +90,7 @@ export default class CommonService<T> {
const tempData = data as DataObject<T>;
return convertDataObjectToModel(tempData);
}) as object[];
}) as T;
}
return null;
@@ -98,9 +98,9 @@ export default class CommonService<T> {
async getListDataFromProp(
property: string,
value: object,
value: string,
path: string = this.defaultPath,
): Promise<object[] | null> {
): Promise<T | null> {
this.connectToDb();
const results = await timeOutConnect(
@@ -114,14 +114,14 @@ export default class CommonService<T> {
const tempData = data as DataObject<T>;
return convertDataObjectToModel(tempData);
}) as object[];
}) as T;
}
return null;
}
async deleteData(
id: string,
id: number,
path = this.defaultPath,
): Promise<string | void> {
this.connectToDb();
@@ -34,7 +34,7 @@ class FirebaseService {
return set(ref(this._db, path), data);
}
delete(id: string, path: string): Promise<void> {
delete(id: number, path: string): Promise<void> {
return remove(ref(this._db, path + id));
}
@@ -145,7 +145,7 @@ class FirebaseService {
getListDataFromProp(
path: string,
property: string,
value: object,
value: string,
): Promise<object[]> {
return new Promise((resolve) => {
onValue(
@@ -1,9 +1,17 @@
import TransactionService from './transactionService';
import UserService from './userService';
import WalletService from './walletService';
export default class Service {
public userService: UserService;
public walletService: WalletService;
public transactionService: TransactionService;
constructor() {
this.userService = new UserService();
this.walletService = new WalletService();
this.transactionService = new TransactionService();
}
}
@@ -0,0 +1,34 @@
import Transaction from 'models/transaction';
import CommonService from './commonService';
export default class TransactionService extends CommonService<Transaction> {
constructor() {
super();
this.defaultPath = 'transactions/';
}
/**
* Save transaction into database
* @param {Object} transaction The wallet object need to be saved into database
*/
async saveTransaction(transaction: Transaction): Promise<void> {
await this.save(transaction);
}
async getListTransactionByIdUser(
idUser: number,
): Promise<Transaction | null> {
const results = this.getListDataFromProp(
'idUser',
idUser.toString(),
this.defaultPath,
);
return results || null;
}
async deleteTransaction(idTransaction: number) {
await this.deleteData(idTransaction);
}
}
@@ -109,8 +109,4 @@ export default class UserService {
return result || null;
}
static clearAccessToken(): void {
LocalStorageService.remove(LOCAL_STORAGE.ACCESS_TOKEN);
}
}
@@ -0,0 +1,43 @@
import Wallet from '../models/wallet';
import CommonService from './commonService';
export default class WalletService extends CommonService<Wallet> {
constructor() {
super();
this.defaultPath = 'wallets/';
}
/**
* Save wallet into database
* @param {Object} wallet The wallet object need to be saved into database
*/
async saveWallet(wallet: Wallet): Promise<void> {
await this.save(wallet);
}
/**
* Check wallet exist in database
* @param {string} idUser The id user to find user's wallet
* @returns {boolean} Return true if find, otherwise return false
*/
async isValidWallet(idUser: number): Promise<boolean> {
const wallet = await this.getWalletByIdUser(idUser);
return !!wallet;
}
/**
* Get wallet data from idUser
* @param {string} email The id user to find user's wallet
* @returns {Object || null} Return new Wallet Object if find, otherwise return null.
*/
async getWalletByIdUser(idUser: number): Promise<Wallet | null> {
const result = (await this.getDataFromProp(
'idUser',
idUser.toString(),
)) as Wallet;
return result || null;
}
}
@@ -0,0 +1,311 @@
import { BTN_CONTENT, TypeToast } from '../../constants/config';
import CommonView from '../commonView';
import { clearAccessToken, formatNumber } from '../../helpers/data';
import WalletView from './walletView';
import Wallet from 'models/wallet';
import Transform from 'helpers/transform';
import Transaction from 'models/transaction';
import User from 'models/user';
import { Data, TError } from 'global/types';
import { DEFAULT_TITLE_ERROR_TOAST } from 'constants/messages/dialog';
import { redirectToLoginPage } from 'helpers/url';
export default class HomeView extends CommonView {
private _tabs: NodeListOf<Element>;
private _allContent: NodeListOf<Element>;
private _cancelBtns: NodeListOf<Element>;
private _saveBtns: NodeListOf<Element>;
private _dialogs: NodeListOf<Element>;
private _amountInputs: NodeListOf<Element>;
private _transactionDialog: HTMLElement | null;
private _walletView: WalletView;
private _user: User | null = null;
private _wallet: Wallet | null = null;
private _getInfoUserLogin: (() => Promise<User | null>) | null = null;
private _getWalletByIdUser:
| ((idUser: number) => Promise<Wallet | null>)
| null = null;
private _saveWallet: ((wallet: Wallet) => Promise<void>) | null = null;
private _saveTransaction:
| ((transaction: Transaction) => Promise<void>)
| null = null;
private _transform: Transform | null = null;
constructor() {
super();
this._tabs = document.querySelectorAll('.app__tab-item');
this._allContent = document.querySelectorAll('.app__content-item');
this._cancelBtns = document.querySelectorAll('.form__cancel-btn');
this._saveBtns = document.querySelectorAll('.form__save-btn');
this._dialogs = document.querySelectorAll('.dialog');
this._amountInputs = document.querySelectorAll('.form__input-balance');
this._transactionDialog = document.getElementById('transactionDialog');
this._walletView = new WalletView();
}
initFunction(
getInfoUserLogin: () => Promise<User | null>,
getWalletByIdUser: (idUser: number) => Promise<Wallet | null>,
saveWallet: (wallet: Wallet) => Promise<void>,
saveTransaction: (transaction: Transaction) => Promise<void>,
transform: Transform,
) {
this._getInfoUserLogin = getInfoUserLogin;
this._getWalletByIdUser = getWalletByIdUser;
this._saveWallet = saveWallet;
this._saveTransaction = saveTransaction;
this._transform = transform;
// Init function for child view
this.initWalletViewFunction();
}
initWalletViewFunction() {
this._walletView.initFunction(
this._transform,
this.toggleLoaderSpinner.bind(this),
this._saveWallet,
this._saveTransaction,
this.loadData.bind(this),
this.loadEvent.bind(this),
this.showSuccessToast.bind(this),
this.showErrorToast.bind(this),
);
}
subscribeListenerData() {
this.subscribe();
this._walletView.subscribe();
}
async loadData() {
// Send data to other class
this.sendData();
await this.loadWalletUser();
}
async loadPage() {
this.toggleLoaderSpinner();
const user = await this._getInfoUserLogin!();
if (!user) {
// If user not login yet
clearAccessToken();
window.location.replace('/login');
} else {
// If user already login
this._user = user;
this._wallet = await this._getWalletByIdUser!(user.id);
this.sendData();
// Check user's wallet if have or not
if (!this._wallet) {
// Show add wallet dialog
this._walletView.showDialog();
} else {
// Init data
await this.loadData();
// Load event page
this.loadEvent();
}
}
this.toggleLoaderSpinner();
}
// ---------------------LOAD DATA---------------------//
subscribe() {
this._transform!.create('homeView', this.updateData.bind(this));
}
sendData() {
const data: Data = {
wallet: this._wallet!,
};
this._transform!.onSendSignal('homeView', data);
}
updateData(data: Data) {
if (data.wallet) this._wallet = data.wallet;
}
/**
* Load wallet user
*/
async loadWalletUser() {
const wallet = this._wallet
? this._wallet
: await this._getWalletByIdUser!(this._user!.id);
const walletName = document.querySelector('.wallet__name');
const walletPrice = document.querySelector('.wallet__price');
const walletNameValue = wallet!.walletName;
const walletAmountValue = wallet!.amountWallet;
const sign = walletAmountValue >= 0 ? '+' : '-';
this._wallet = wallet; // Make wallet into global variable
walletName!.textContent = walletNameValue;
walletPrice!.textContent = `${sign}$ ${formatNumber(
Math.abs(walletAmountValue),
)}`; // Math.abs(walletAmountValue) to keep the value always > 0
// Update amount wallet into database
this._saveWallet!(this._wallet!);
}
// ---------------------END---------------------//
showSuccessToast(title: string, message: string) {
const typeToast = TypeToast.success;
const btnContent = BTN_CONTENT.OK;
this.initToastContent(typeToast, title, message, btnContent);
this.toastDialog!.showModal();
}
/**
* Implement error toast in site
* @param {string} content The content will show in error toast
*/
showErrorToast(error: TError | string): void {
const title =
typeof error === 'object' && error.title
? error.title
: DEFAULT_TITLE_ERROR_TOAST;
const content =
typeof error === 'object' && error.message
? error.message
: (error as string);
this.initToastContent(TypeToast.error, title, content, BTN_CONTENT.OK);
if (this.toastDialog && this.toastBtn) {
// Show toast
this.toastDialog.showModal();
// Remove event for toast button
this.toastBtn.removeEventListener('click', redirectToLoginPage);
}
}
// ------------------------------- HANDLER EVENT ------------------------------- //
loadEvent() {
this.addCommonEventPage();
this.handlerTabsTransfer();
}
/**
* Handle event when click on tabs
*/
handlerTabsTransfer() {
this._tabs.forEach((tab, index) => {
tab.addEventListener('click', (e: Event) => {
this.removeActiveTab();
tab.classList.add('active');
const line = document.querySelector<HTMLElement>(
'.app__line',
) as HTMLElement;
const eventTarget = e.target as HTMLElement;
line.style.width = `${eventTarget.offsetWidth}px`;
line.style.left = `${eventTarget.offsetLeft}px`;
this._allContent.forEach((content) => {
content.classList.remove('active');
});
this._allContent[index].classList.add('active');
});
});
}
addCommonEventPage() {
// Add event close dialog when click outside
this._dialogs.forEach((dialog) => {
dialog.addEventListener('click', (e) => {
const dialogDimensions = dialog.getBoundingClientRect();
const mouseEvent = e as MouseEvent;
if (
mouseEvent.clientX < dialogDimensions.left ||
mouseEvent.clientX > dialogDimensions.right ||
mouseEvent.clientY < dialogDimensions.top ||
mouseEvent.clientY > dialogDimensions.bottom
) {
(<HTMLDialogElement>dialog).close();
}
});
});
this._cancelBtns.forEach((btn) => {
btn.addEventListener('click', () => {
this.closeAllDialog();
});
});
// Prevent input =,-,e into amount input
this._amountInputs.forEach((item) => {
item.addEventListener(
'keypress',
(e) =>
['+', '-', 'e'].includes((<KeyboardEvent>e).key) &&
e.preventDefault(),
);
});
// Prevent user input at category search
const categorySearchEl = document.querySelector(
'.category-name',
) as Element;
categorySearchEl.addEventListener('keydown', (e) => {
e.preventDefault();
});
const logoutBtn = document.querySelector('.logout-text') as Element;
logoutBtn.addEventListener('click', () => {
clearAccessToken();
window.location.replace('/login');
});
}
closeAllDialog() {
this._dialogs.forEach((dialog) => {
(<HTMLDialogElement>dialog).close();
});
}
removeActiveTab() {
this._tabs.forEach((tab) => {
tab.classList.remove('active');
});
}
}
@@ -0,0 +1,176 @@
import { FIRST_ADD_WALLET_NOTE } from '../constants/config';
import Transaction from '../models/transaction';
import Wallet from '../models/wallet';
import * as MESSAGE from '../constants/message';
import { renderRequiredText } from '../helpers/validateForm';
export default class WalletView {
constructor() {
this.walletDialog = document.getElementById('walletDialog');
this.addHandlerEventWalletForm();
}
initFunction(
transform,
toggleLoaderSpinner,
saveWallet,
saveTransaction,
loadData,
loadEvent,
showSuccessToast,
showErrorToast,
) {
this.transform = transform;
this.toggleLoaderSpinner = toggleLoaderSpinner;
this.saveWallet = saveWallet;
this.saveTransaction = saveTransaction;
this.loadData = loadData;
this.loadEvent = loadEvent;
this.showSuccessToast = showSuccessToast;
this.showErrorToast = showErrorToast;
}
subscribe() {
this.transform.create('walletView', this.updateData.bind(this));
}
sendData() {
const data = {
wallet: this.wallet,
listTransactions: this.listTransactions,
user: this.user,
};
this.transform.onSendSignal('walletView', data);
}
updateData(data) {
if (data.wallet) this.wallet = data.wallet;
if (data.listTransactions) this.listTransaction = data.listTransactions;
if (data.listCategory) this.listCategory = data.listCategory;
if (data.user) this.user = data.user;
}
showDialog() {
this.walletDialog.showModal();
}
addHandlerEventWalletForm() {
this.walletDialog.addEventListener('submit', (e) => {
e.preventDefault();
this.clearErrorStyleWalletDialog();
this.submitWalletForm();
});
this.walletDialog.addEventListener('input', (e) => {
const bodyDialog = e.target.closest('.dialog__body');
this.changeBtnStyleWalletDialog(bodyDialog);
this.clearErrorStyleWalletDialog();
});
}
clearErrorStyleWalletDialog() {
const inputFieldEls =
this.walletDialog.querySelectorAll('.form__input-field');
const errorTextEls = this.walletDialog.querySelectorAll('.error-text');
inputFieldEls.forEach((item) => {
if (item.classList.contains('error-input'))
item.classList.remove('error-input');
});
errorTextEls.forEach((item) => {
if (item) item.remove();
});
}
async submitWalletForm() {
try {
// Wallet info
const { walletForm } = document.forms;
const form = new FormData(walletForm);
const walletName = form.get('walletName');
const amount = form.get('amount');
if (this.validateWalletDialog(walletName, amount)) {
this.walletDialog.close();
this.toggleLoaderSpinner();
this.wallet = new Wallet({
walletName,
amount: +amount,
idUser: this.user.id,
inflow: +amount,
});
this.sendData();
await this.saveWallet(this.wallet);
// Transaction info
const transaction = new Transaction({
categoryName: 'Income',
date: new Date().toISOString().slice(0, 10),
note: FIRST_ADD_WALLET_NOTE,
amount: +amount,
idUser: this.user.id,
});
await this.saveTransaction(transaction);
// Load data and event
await this.loadTransactionData();
await this.loadData();
this.loadEvent();
this.showSuccessToast(
MESSAGE.ADD_WALLET_SUCCESS,
MESSAGE.DEFAULT_MESSAGE,
);
await this.loadData(); // Load data from database into page
this.toggleLoaderSpinner();
}
} catch (error) {
// Show toast error
this.showErrorToast(error);
this.toggleLoaderSpinner();
}
}
validateWalletDialog(walletName, amount) {
const inputFieldEls =
this.walletDialog.querySelectorAll('.form__input-field');
if (!walletName || !amount) {
if (!walletName) {
renderRequiredText('wallet name', inputFieldEls[0]);
inputFieldEls[0].classList.add('error-input');
}
if (!amount) {
renderRequiredText('amount', inputFieldEls[2]);
inputFieldEls[2].classList.add('error-input');
}
return false;
}
return true;
}
// eslint-disable-next-line class-methods-use-this
changeBtnStyleWalletDialog(bodyDialog) {
const walletName = bodyDialog.querySelector('.form__input-text').value;
const amount = bodyDialog.querySelector('.form__input-balance').value;
const saveBtn = bodyDialog.querySelector('.form__save-btn');
if (walletName.length >= 3 && amount >= 1) {
saveBtn.classList.add('active');
} else {
saveBtn.classList.remove('active');
}
}
}
@@ -2,12 +2,15 @@ import { getSubdirectoryURL } from 'helpers/url';
import LoginView from './loginView';
import RegisterView from './registerView';
import { URL } from 'constants/config';
import HomeView from './home/homeView';
export default class View {
public registerView: RegisterView | null = null;
public loginView: LoginView | null = null;
public homeView: HomeView | null = null;
constructor() {
switch (getSubdirectoryURL()) {
case URL.LOGIN:
@@ -16,6 +19,8 @@ export default class View {
case URL.REGISTER:
this.registerView = new RegisterView();
break;
case URL.HOME:
this.homeView = new HomeView();
default:
}
}