|
// Drag & Drop 인터페이스
interface Draggable {
dragStartHandler(event: DragEvent): void; // 드래그 시작 이벤트 핸들러
dragEndHandler(event: DragEvent): void; // 드래그 종료 이벤트 핸들러
}
interface DragTarget {
dragOverHandler(event: DragEvent): void; // 드래그 오버 이벤트 핸들러
dropHandler(event: DragEvent): void; // 드랍 이벤트 핸들러
dragLeaveHandler(event: DragEvent): void; // 드래그 리브 이벤트 핸들러
}
// Project Type
enum ProjectStatus { Active, Finished }
class Project {
constructor(
public id: string,
public title: string,
public description: string,
public people: number,
public status: ProjectStatus
){}
}
// Project State Management and Rendering
type Listener<T> = (items: T[]) => void;
class State<T> {
protected listeners: Listener<T>[] = [];
addListener(listenerFn: Listener<T>) {
this.listeners.push(listenerFn);
}
}
class ProjectState extends State<Project>{
private projects: Project[] = [];
private static instance: ProjectState;
private constructor() {
super();
}
// 싱글톤 패턴 사용하기
static getInstance() {
if(this.instance) { // 이미 인스턴스가 존재한다면
return this.instance; // 인스턴스를 반환한다.
}
this.instance = new ProjectState(); // 인스턴스가 존재하지 않는다면 새로 생성한다.
return this.instance; // 인스턴스를 반환한다.
}
addProject(title: string, description: string, numOfPeople: number) {
const newProject = new Project(
Math.random().toString(),
title,
description,
numOfPeople,
ProjectStatus.Active
);
this.projects.push(newProject); // 새로운 프로젝트를 추가한다.
for(const listenerFn of this.listeners) { // 이벤트 리스너를 순회하면서
listenerFn(this.projects.slice()); // 이벤트 리스너를 실행한다.
}
}
moveProject(projectId: string, newStatus: ProjectStatus) {
const project = this.projects.find(prj => prj.id === projectId);
if(project && project.status !== newStatus) {
project.status = newStatus;
this.updateListeners();
}
}
private updateListeners() {
for(const listenerFn of this.listeners) {
listenerFn(this.projects.slice());
}
}
}
const projectState = ProjectState.getInstance(); // 싱글톤 패턴으로 인스턴스 생성하기
// Validation
// 선택적인 프로퍼티를 사용하려면, 인터페이스에 ?를 붙여주면 된다.
interface Validatable {
value: string | number;
required?: boolean;
minLength?: number;
maxLength?: number;
min?: number;
max?: number;
}
// 입력값 유효성 검사 함수
function validate(validatableInput: Validatable){
let isValid = true;
if(validatableInput.required) {
isValid = isValid && validatableInput.value.toString().trim().length !== 0;
}
if(validatableInput.minLength != null &&
typeof validatableInput.value === 'string') {
isValid = isValid &&
validatableInput.value.length >= validatableInput.minLength;
}
if(validatableInput.maxLength != null &&
typeof validatableInput.value === 'string') {
isValid = isValid &&
validatableInput.value.length <= validatableInput.maxLength;
}
if(validatableInput.min != null &&
typeof validatableInput.value === 'number') {
isValid = isValid &&
validatableInput.value >= validatableInput.min;
}
if(validatableInput.max != null &&
typeof validatableInput.value === 'number') {
isValid = isValid &&
validatableInput.value <= validatableInput.max;
}
return isValid;
}
// autobind 데코레이터
// (target: any, methodName: string, descriptor: PropertyDescriptor) => PropertyDescriptor
function autobind(_: any, _2: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
const adjDescriptor: PropertyDescriptor = {
configurable: true,
get() {
const boundFn = originalMethod.bind(this);
return boundFn;
}
};
return adjDescriptor;
}
// Component Base Class
abstract class Component<T extends HTMLElement, U extends HTMLElement> {
templateElement: HTMLTemplateElement;
hostElement: T;
element: U;
// 생성자 함수
constructor(
templateId: string,
hostElementId: string,
insertAtStart: boolean,
newElementId?: string
) {
this.templateElement = document.getElementById(
templateId
)! as HTMLTemplateElement;
this.hostElement = document.getElementById(hostElementId)! as T;
const importedNode = document.importNode(
this.templateElement.content,
true
);
this.element = importedNode.firstElementChild as U;
if(newElementId) {
this.element.id = newElementId;
}
this.attach(insertAtStart);
}
private attach(insertAtBeginning: boolean) {
this.hostElement.insertAdjacentElement(
insertAtBeginning ? 'afterbegin' : 'beforeend',
this.element
);
}
abstract configure(): void;
abstract renderContent(): void;
}
// ProjectItem 클래스
class ProjectItem extends Component<HTMLUListElement, HTMLLIElement>
implements Draggable{
private project: Project;
constructor(hostId: string, project: Project) {
super('single-project', hostId, false, project.id);
this.project = project;
this.configure();
this.renderContent();
}
@autobind
dragStartHandler(event: DragEvent): void {
event.dataTransfer!.setData('text/plain', this.project.id);
event.dataTransfer!.effectAllowed = 'move';
}
dragEndHandler(_: DragEvent): void {
console.log('DragEnd');
}
configure() {
this.element.addEventListener('dragstart', this.dragStartHandler);
this.element.addEventListener('dragend', this.dragEndHandler);
}
renderContent() {
console.log("renderContent", this.project.title, this.project.people, this.project.description, "renderContent")
this.element.querySelector('h2')!.textContent = this.project.title;
this.element.querySelector(
'h3'
)!.textContent = this.project.people.toString();
this.element.querySelector('p')!.textContent = this.project.description;
}
}
// ProjectList 클래스
class ProjectList extends Component<HTMLDivElement, HTMLElement>
implements DragTarget{
assignedProjects: Project[];
// 생성자 함수
constructor(private type: 'active' | 'finished') {
super('project-list', 'app', false, `${type}-projects`);
this.assignedProjects = [];
this.configure();
this.renderContent();
}
@autobind
dragOverHandler(event: DragEvent): void {
if(event.dataTransfer && event.dataTransfer.types[0] === 'text/plain') {
event.preventDefault();
const listEl = this.element.querySelector('ul')!;
listEl.classList.add('droppable');
}
}
@autobind
dropHandler(event: DragEvent): void {
const prjId = event.dataTransfer!.getData('text/plain');
projectState.moveProject(prjId, this.type === 'active' ? ProjectStatus.Active : ProjectStatus.Finished);
}
@autobind
dragLeaveHandler(_: DragEvent): void {
const listEl = this.element.querySelector('ul')!;
listEl.classList.remove('droppable');
}
configure(): void {
this.element.addEventListener('dragover', this.dragOverHandler);
this.element.addEventListener('dragleave', this.dragLeaveHandler);
this.element.addEventListener('drop', this.dropHandler);
projectState.addListener((projects: Project[]) => {
const relevantProjects = projects.filter(prj => {
if (this.type === 'active') {
return prj.status === ProjectStatus.Active;
}
return prj.status === ProjectStatus.Finished;
});
this.assignedProjects = relevantProjects;
this.renderProjects();
});
}
renderContent() {
const listId = `${this.type}-projects-list`;
this.element.querySelector('ul')!.id = listId;
this.element.querySelector('h2')!.textContent =
this.type.toUpperCase() + ' PROJECTS';
}
private renderProjects() {
const listEl = document.getElementById(
`${this.type}-projects-list`
)! as HTMLUListElement;
listEl.innerHTML = '';
for (const prjItem of this.assignedProjects) {
new ProjectItem(this.element.querySelector('ul')!.id, prjItem);
}
}
}
// ProjectInput 클래스
class ProjectInput extends Component<HTMLDivElement, HTMLFormElement> {
// HTML 요소에 대한 선언
titleInputElement: HTMLInputElement;
descriptionInputElement: HTMLInputElement;
peopleInputElement: HTMLInputElement;
// 생성자 함수
constructor() {
super('project-input', 'app', true, 'user-input');
this.titleInputElement = this.element.querySelector('#title') as HTMLInputElement;
this.descriptionInputElement = this.element.querySelector('#description') as HTMLInputElement;
this.peopleInputElement = this.element.querySelector('#people') as HTMLInputElement;
this.configure();
}
// 동작 설정
configure() {
// 기존에는 bind(this)를 사용했지만, 데코레이터를 사용하면 자동으로 바인딩이 된다.
this.element.addEventListener('submit', this.submitHandler);
}
renderContent() {}
// 사용자 입력값 수집 - [제목, 내용, 인원] 반환
private gatherUserInput(): [string, string, number] | void {
const enteredTitle = this.titleInputElement.value;
const enteredDescription = this.descriptionInputElement.value;
const enteredPeople = this.peopleInputElement.value;
const titleValidatable: Validatable = {
value: enteredTitle,
required: true,
};
const descriptionValidatable: Validatable = {
value: enteredDescription,
required: true,
minLength: 5,
};
const peopleValidatable: Validatable = {
value: +enteredPeople,
required: true,
min: 1,
max: 5,
};
// 입력값 유효성 검사
if(
!validate(titleValidatable) ||
!validate(descriptionValidatable) ||
!validate(peopleValidatable)
) {
alert('잘못된 입력입니다. 다시 입력해주세요.');
} else {
return [enteredTitle, enteredDescription, +enteredPeople]; // +를 붙이면 number로 변환된다.
}
}
private clearInputs() {
this.titleInputElement.value = '';
this.descriptionInputElement.value = '';
this.peopleInputElement.value = '';
}
// 이벤트 핸들러
@autobind
private submitHandler(event: Event) {
event.preventDefault(); // 기본적으로 submit 이벤트는 새로고침을 발생시키는데, 이를 막기 위해 preventDefault()를 사용한다.
const userInput = this.gatherUserInput();
if(Array.isArray(userInput)) { // 입력값이 배열이면
const [title, desc, people] = userInput; // 배열 비구조화 할당
projectState.addProject(title, desc, people); // 새로운 프로젝트 추가
this.clearInputs(); // 입력값 초기화
}
}
}
// 인스턴스 생성 - app.ts 파일이 실행될 때, 자동으로 실행된다.
const prjInput = new ProjectInput();
const activePrjList = new ProjectList('active');
const finishedPrjList = new ProjectList('finished');
|