Jieunny의 블로그

Section9. Drag & Drop 프로젝트 만들기 본문

CodeStates/TS 스터디

Section9. Drag & Drop 프로젝트 만들기

Jieunny 2023. 3. 20. 11:23

너무 어려워서 블로깅 어떻게 해야할지 모르곘다..

일단 완벽히 이해하는 건 포기하고 따라 치면서 흐름만이라도 이해하려고 노력했다ㅠㅠ

내가 이해할 수 있는 언어로 주석을 작성하려고 나름대로 적었지만, 코드가 길어지면서 힘들어졌다...🥹

간단한 프로젝트라매..쉽다매..ㅠㅠㅠㅠㅠㅠㅠ

 

이번 강의 들으면서 느낀것

🚨 컴파일이 되고 있는지 항상 확인하자..

🚨 에러가 나면 오타가 없는지 항상 확인하자..

 

📣  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