Jieunny의 블로그
Section9. Drag & Drop 프로젝트 만들기 본문
너무 어려워서 블로깅 어떻게 해야할지 모르곘다..
일단 완벽히 이해하는 건 포기하고 따라 치면서 흐름만이라도 이해하려고 노력했다ㅠㅠ
내가 이해할 수 있는 언어로 주석을 작성하려고 나름대로 적었지만, 코드가 길어지면서 힘들어졌다...🥹
간단한 프로젝트라매..쉽다매..ㅠㅠㅠㅠㅠㅠㅠ
이번 강의 들으면서 느낀것
🚨 컴파일이 되고 있는지 항상 확인하자..
🚨 에러가 나면 오타가 없는지 항상 확인하자..
📣 ts 코드
📌 app.ts
// Drag & Drop interface
// 한 박스에서 다른 박스로 프로젝트 항목 옮기기
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
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);
// 프로젝트 목록 배열에 새 프로젝트 추가
this.updateListeners();
}
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;
// 수치 값이 특정 숫자 이상인지,특정 최댓값 이하인지 확인(number일 때)
// 수치 외에는 모두 선택사항 이므로 물음표 추가
}
// 검증 가능한 객체 구조 정의
function validate(validatableInput: Validatable){
let isValid = true;
// 처음엔 true로 설정하고 하나라도 유효하지 않으면 false로 바꾸기
if(validatableInput.required) {
// 꼭 채워야 하는 칸이 비어있는지 확인
isValid = isValid && validatableInput.value.toString().trim().length !== 0;
// 들어온 게 문자열이어야만 trim() 메서드 사용가능
// 비어있으면 isValid가 false가 된다.
}
if(validatableInput.minLength != null && typeof validatableInput.value === 'string'){
// 들어온 값이 문자열일 때 최소 길이를 가지고 있는지 확인
// 왜 validatableInput.minLength 이게 참인지 여부확인을 안하고 null인지 확인하냐? -> 값이 0이면 문제가 생기기 때문에
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 decorator
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;
}
// app.js:9 Uncaught TypeError: Cannot read properties of undefined (reading 'value')
// at autobind
// 왜 에러나지..ㅠ
abstract class Component<T extends HTMLElement, U extends HTMLElement > {
// 추상화로 만들어서 인스턴스화 할 수 없게 한다.
templateElement: HTMLTemplateElement;
hostElement: T;
element: U;
constructor(templateId: string, hostElementId: string, insertAtStart: boolean, newElementId?: string) {
//id를 알아야 그것을 어떻게 선택할 지 알 수 있음
//newElementId 는 새롭게 렌더링 된 요소에 할당된다.
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
class ProjectItem extends Component<HTMLUListElement, HTMLElement> implements Draggable {
// 프로젝트 클래스의 이 프로젝트 항목에 속하는 프로젝트 저장하기
private project: Project;
get persons() {
// 사람 수에 맞에 뒤에 문장 바꿔주기
if(this.project.people === 1){
return '1 person';
} else {
return `${this.project.people} persons`;
}
}
constructor(hostId: string, project: Project) {
super('single-project', hostId, false, project.id);
// super에 프로젝트 항목이 렌더링될 요소 id가 어디에 있는지 알려주어야 한다.
this.project = project;
this.configure();
this.renderContent();
}
@autobind
dragStartHandler(event: DragEvent) {
event.dataTransfer!.setData('text/plain', this.project.id);
// 모든 드래그 관련 이벤트가 데이터 전송 객체가 있는 이벤트 객체를 만들어내지는 않으므로 !추가 해준다.
event.dataTransfer!.effectAllowed = 'move';
// 드래그 항목을 원래 위치에서 지우고 새로운 위치에 등록한다는 의미.
}
dragEndHandler(_: DragEvent) {
console.log('dg')
}
// 드래깅 가능한 클래스를 추가하면 항상 위의 메소드를 얻게 된다.
configure() {
this.element.addEventListener('dragstart', this.dragStartHandler);
this.element.addEventListener('dragend', this.dragEndHandler);
}
renderContent() {
// li요소 렌더링
this.element.querySelector('h2')!.textContent = this.project.title;
this.element.querySelector('h3')!.textContent = this.persons + ' assigned';
this.element.querySelector('p')!.textContent = this.project.description;
}
}
// ProjectList Class
class ProjectList extends Component<HTMLDivElement, HTMLElement> implements DragTarget {
// 프로젝트 리스트 만들 class
assignedProjects: Project[];
constructor(private type: 'active' | 'finished') {
super('project-list', 'app', false, `${type}-projects`);
// ????????????
// 앞이 아니라 뒤애 넣어야 하므로 false 전달
this.assignedProjects = [];
this.configure();
this.renderContent();
// 생성
}
@autobind
dragOverHandler(event: DragEvent) {
// 박스의 모습이나 정리되지 않은 목록을 바꾼다.
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) {
const prjId = event.dataTransfer!.getData('text/plain');
projectState.moveProject(
prjId,
this.type === 'active' ? ProjectStatus.Active : ProjectStatus.Finished
);
}
@autobind
dragLeaveHandler(_: DragEvent) {
const listEl = this.element.querySelector('ul')!;
listEl.classList.remove('droppable');
}
configure() {
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);
// 적절한 위치에 렌더링 해서 list-style 안나오게 하기
}
}
}
// ProjectInput Class
class ProjectInput extends Component<HTMLDListElement, HTMLFormElement> {
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;
// DOM으로 요소 불러오기
this.configure();
}
configure() {
// 이벤트 리스너 설정
this.element.addEventListener('submit', this.submitHandler);
// (2) submitHendler 함수를 호출할 때 bind를 줘서 this가 클래스를 바인딩 하게 한다.
// this.element.addEventListener('submit', this.submitHandler.bind(this));
// 여기서는 데코레이터를 이용해서 bind를 구현.
}
renderContent(){
}
private gatherUserInput(): [string, string, number] | void {
const enteredTitle = this.titleInputElement.value;
const enteredDescription = this.descriptionInputElement.value;
const enteredPeople = this.peopleInputElement.value;
// value로 불러온건 기본적으로 text로 인식된다(Number가 아니라는 뜻)
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(
// enteredTitle.trim().length === 0 ||
// enteredDescription.trim().length === 0 ||
// enteredPeople.trim().length === 0
// trim()으로 공백 없애기
!validate(titleValidatable) ||
!validate(descriptionValidatable) ||
!validate(peopleValidatable)
// input 값들이 유효한지 확인하는 validate 함수 사용
// 하나라도 false를 반환하면 alert를 띄운다.
){
alert('Invalid input, please try again!');
return; // 여기서 반환되는 건 튜플이 아니라 정의되지 않은 것
// -> 위에 undefined 추가해줘야 하는데 부정형은 함수 반환타임으로 못 쓰니까 void로 추가
} else {
// 유효한 입력이 있는 경우
return [enteredTitle, enteredDescription, +enteredPeople];
// people은 number 형이므로 + 붙여서 숫자로 바꿔준다.
}
}
private clearInputs() {
this.titleInputElement.value = '';
this.descriptionInputElement.value = '';
this.peopleInputElement.value = '';
// 제출 하고 나면 input칸 비우기
}
@autobind
private submitHandler(event: Event) {
event.preventDefault();
const userInput = this.gatherUserInput();
// (1) this 키워드가 submitHandler에서 저 클래스를 가리키지 않아서 값을 제대로 불러올 수 없다.
// 현재 this는 이벤트 대상에 바인딩 되고 있다.
if(Array.isArray(userInput)){
// userInput이 tuple인 것을 확인 할 수 없으므로(JS에 없음) tuple도 일단 배열이니까 배열인지 확인해준다.
const [title, desc, people] = userInput;
projectState.addProject(title, desc, people);
// 누르면 새 프로젝트의 정보가 추가된다.
this.clearInputs();
}
}
// 꼭 있어야 하는 건 아니지만 기본적으로 별도 메서드에서 해주는 게 좋다.
}
const prjInput = new ProjectInput();
const activePrjList = new ProjectList('active');
const finishedPrjList = new ProjectList('finished');
📣 시연 화면
'CodeStates > TS 스터디' 카테고리의 다른 글
Section11. TS와 함께 Webpack 사용하기 (0) | 2023.03.24 |
---|---|
Section10. 모듈 및 네임스페이스 (0) | 2023.03.23 |
Section8. 데코레이터 (0) | 2023.03.03 |
Section 7. 제네릭 (0) | 2023.02.22 |
Section 6. 고급 타입 (0) | 2023.02.21 |