Я и моя команда работаем над проектом с использованием PWA Starter Kit с начала этого года. Я так многому научился в этом проекте, как создавать производительные веб-компоненты, как использовать Redux (вместо того, чтобы бороться с ним) и как защитить все приложение с помощью Azure Active Directory, и это лишь некоторые из них. Хотя мы несколько раз застревали на разных вещах (о которых мы планируем написать в будущем), ничто не ставило меня в тупик больше, чем создание формы с проверкой. Я думаю, это было потому, что я думал примерно так: "Но это всего лишь форма, мы создаем формы в Интернете через день". К концу этой задачи я подошел к своей команде с широкой улыбкой на лице и сказал Я чертовски перекодировал эту форму.

В этом посте я хотел бы поделиться с вами тем, что я сделал, чтобы наша форма работала так, как мы этого хотели.

Конечная цель состоит в том, чтобы иметь возможность создавать или обновлять объект с именем Quest, который состоит из одной или нескольких Миссий. Структура нашего объекта будет выглядеть примерно так:

Quest {
  goal: string
}
Mission {
  name: string,
  description: string
}

Первая миссия

Первым компонентом будет HTML-форма, которая создает для нас объект миссии, у него будет свойство хранить ошибки и он не позволит вам отправить форму, если на странице есть ошибки. Код для этого выглядит следующим образом:

import { LitElement, html } from 'lit-element';
export class MissionsForm extends LitElement {
  constructor() {
    super();
    this.errors = [];
  }
  
  static get properties() {
    return {
      errors: Array
    };
  }
 
  render() {
    const hasError = (name) => 
                   (this.errors.indexOf(name) >= 0 ? 'error' : '');
    return html`
      <style>
        .error {
          border: 1px solid red;
        }
      </style>
      <form @submit="${(e) => this.submit(e)}">
        <div>
          <label>Name: </label>
          <input class="${hasError('name')}" 
                 type="input" 
                 name="name"/>
        </div>
        <div>
          <label>Description: </label>
          <textarea class="${hasError('description')}" 
                    name="description"></textarea>
        </div>
        <div>
          <button type="submit">Save</button>
        </div>
      </form>
    `;
  }
  submit(e) {
    e.preventDefault();
    let form = e.target;
    this.errors = this.checkForErrors(form);
    if (!this.errors.length) {
      let mission = {
        name: form.name.value,
        description: form.description.value
      };
      //save mission here
      form.reset();
    }
  }
  checkForErrors(form) {
    let errors = [];
 
    if (!form.name.value) {
      errors.push('name');
    }
    if (!form.description.value) {
      errors.push('description');
    }
    return errors;
  }
}
customElements.define('missions-form', MissionsForm);

Это отлично работает, потому что каждый раз, когда мы инициируем отправку, если есть какие-либо ошибки, мы обновляем свойство, которое вызывает повторную визуализацию страницы и показывает нам эти ошибки. Однако не очень хорошо, что ошибки исчезают только в том случае, если пользователь снова нажимает кнопку «Отправить». Мы хотим, чтобы они знали, что ошибка исправлена, как только они ее исправят. Для этого мы должны прослушать событие изменения в форме:

<form @submit="${(e) => this.submit(e)}" 
      @change="${(e) => this.formValueUpdated(e)}">
  <!--...-->
</form>

Теперь мы можем удалить ошибки, как только они будут исправлены, реализуя метод:

formValueUpdated(e) {
  let errorList = [...this.errors];
  if (!e.target.value) {
    errorList.push(e.target.name);
  } else {
    let indexOfError = errorList.indexOf(e.target.name);
    if (indexOfError >= 0) {
      errorList.splice(indexOfError, 1);
    }
  }
  this.errors = [...errorList];
}

Добавление дополнительных миссий

Далее нам нужно реализовать метод //save mission here. Для этого мы сначала создадим новый компонент, этот новый компонент будет иметь наш список миссий, а также наш компонент формы. Основная схема будет выглядеть так:

import { LitElement, html } from 'lit-element';
import './missions-form.component';
export class MissionsList extends LitElement {
  constructor() {
    super();
    this.missions = [];
  }
  static get properties() {
    return {
      missions: Array
    };
  }
  render() {
    return html`
      <h2>Missions</h2>
      <ul>
        ${this.missions.map(
          (m) =>
            html`
              <li><strong>${m.name}:</strong> ${m.description}</li>
            `
        )}
      </ul>
      <missions-form></missions-form>
    `;
  }
}
customElements.define('missions-list', MissionsList);

Мы собираемся использовать Redux для обновления нашего списка миссий всякий раз, когда нажимается сохранение в компоненте формы. Если вы используете стартовый комплект PWA, то у вас уже есть все настройки Redux. Если вы начали с нуля, следуйте этому руководству, чтобы настроить его. Ниже приведена первая версия нашего редуктора:

import { MISSIONS_UPDATED } 
         from "../actions/missions-updated.action";
const INITIAL_STATE = {
  missions: []
};
export const editor = (state = INITIAL_STATE, action) => {
  switch (action.type) {
    case MISSIONS_UPDATED:
      return {
        ...state,
        missions: action.missions
      }
    default:
      return state;
  }
}

Этот редьюсер импортирует действие, давайте реализуем это действие:

export const MISSIONS_UPDATED = 'MISSIONS_UPDATED';
export const missionsUpdated = (missions) => {
  return {
    type: MISSIONS_UPDATED,
    missions
  };
};

Теперь всякий раз, когда нажимается «Сохранить», нам нужно будет отправить это действие. Это означает, что нам нужно будет изменить наш компонент MissionsList, чтобы подключить его к хранилищу Redux:

export class MissionsList extends connect(store)(LitElement) {
  //...
}

И наш компонент MissionsForm тоже нужно будет подключить:

export class MissionsForm extends connect(store)(LitElement) {
  //...
}

Оба эти компонента должны реализовать метод stateChanged:

stateChanged(state) {
  this.missions = state.missions;
}

Здесь мы получаем доступ к миссиям непосредственно из состояния. В моем проекте мы используем reselect, промежуточное ПО для создания оптимизированных селекторов. Чтобы увидеть, что мы сделали для повышения производительности и упрощения кода, ознакомьтесь со статьей моего коллеги Wrangling Redux.

Последнее, что осталось сделать, это заменить этот комментарий призывом к нашим действиям и обновить список миссий:

store.dispatch(missionsUpdated([...this.missions, mission]));

Квест

Наш следующий компонент будет отвечать за сбор информации о квесте. В нашем примере квест имеет только одно свойство, однако код написан таким образом, что его можно расширить. Давайте создадим компонент QuestEditor:

import { LitElement, html } from 'lit-element';
import { connect } from 'pwa-helpers';
import { store } from '../store';
import { questUpdated } from '../actions/quest-updated.action';
import { errorsDetected } from '../actions/errors-detected.action';
export class QuestEditor extends connect(store)(LitElement) {
  constructor() {
    super();
    this.errors = [];
  }
  static get properties() {
    return {
      quest: Object,
      errors: Array
    };
  }
  render() {
    const hasError = (name) => 
                   (this.errors.indexOf(name) >= 0 ? 'error' : '');
    return html`
      <style>
        .error {
          border: 1px solid red;
        }
      </style>
      <form @change="${(e) => this.formValueUpdated(e)}"
            @submit="${(e) => e.preventDefault()}">
        <div>
          <label>Goal:</label>
          <input class="${hasError('goal')}" 
                 name="goal" 
                 type="text" />
        </div>
      </form>
    `;
  }
  formValueUpdated(e) {
    let errorList = [...this.errors];
    
    if (!e.target.value) {
      errorList.push(e.target.name);
    } else {
      let indexOfError = errorList.indexOf(e.target.name);
      if (indexOfError >= 0) {
        errorList.splice(indexOfError, 1);
      }
    }
  
    let quest = {
      ...this.quest,
      [e.target.name]: e.target.value
    };
    store.dispatch(errorsDetected(errorList));
    store.dispatch(questUpdated(quest));
  }
  stateChanged(state) {
    this.quest = state.quest;
    this.errors = state.errors;
    if (!this.quest) {
      this.quest = {
        goal: ''
      };
    }
  }
}
customElements.define('quest-editor', QuestEditor);

Этот компонент очень похож на тот, который мы создали для миссий, большая разница в том, что у этого компонента нет кнопки сохранения. Это потому, что мы хотим сохранить квест и миссии одновременно (что мы сделаем в другом компоненте чуть позже). Компонент QuestEditor также имеет два новых действия errorsDetected и questUpdated. Мы можем реализовать их следующим образом:

export const ERRORS_DETECTED = 'ERRORS_DETECTED';
export const errorsDetected = (errors) => {
  return {
    type: ERRORS_DETECTED,
    errors
  };
};

и

export const QUEST_UPDATED = 'QUEST_UPDATED';
export const questUpdated = (quest) => {
  return {
    type: QUEST_UPDATED,
    quest
  };
};

Нам также нужно обновить наш редьюсер, чтобы он соответствовал этим двум действиям, сначала мы изменим наш INITIAL_STATE на:

const INITIAL_STATE = {
  quest: {},
  missions: [],
  errors: []
};

Затем добавьте еще два случая в наш оператор switch:

case QUEST_UPDATED:
  return {
    ...state,
    quest: action.quest
  }
case ERRORS_DETECTED:
  return {
    ...state,
    errors: action.errors
  }

Собираем все вместе

Нам нужно объединить то, что мы сделали, в один компонент main, этот компонент будет называться Quest и выглядеть следующим образом:

import { LitElement, html } from 'lit-element';
import { connect } from 'pwa-helpers';
import { store } from '../store';
import { errorsDetected } from '../actions/errors-detected.action';
import './quest-editor.component';
import './missions-list.component';
export class Quest extends connect(store)(LitElement) {
  render() {
    return html`
      <h1>Create Quest</h1>
      <quest-editor></quest-editor>
      <missions-list></missions-list>
      <div>
        <button type="button" 
                @click="${() => this.saveQuest()}">
          Save
        </button>
      </div>
    `;
  }
  saveQuest() {
    let errors = this.pageValid();
    
    if (!errors.length) {
      //save quest and missions here
    }
    store.dispatch(errorsDetected(errors));
  }
  pageValid() {
    let errors = [];
    if (!this.quest.goal) {
      errors.push('goal');
    }
    if (!this.missions.length) {
      errors.push('missions');
    }
    return errors;
  }
  stateChanged(state) {
    this.missions = state.missions;
    this.quest = state.quest;
  }
}
customElements.define('my-quest', Quest);

Компонент Quest отвечает за сохранение того, что мы заполнили. Он должен знать как о квесте, так и о миссиях. Однако вы могли заметить, что у этого компонента нет никаких собственных свойств, это потому, что нам не нужно перерисовывать его при изменении квеста, миссий или ошибок. Нам также нужно убедиться, что мы правильно заполнили все данные, метод pageValid сделает это за нас. Наконец, если ошибок нет, мы можем все сохранить (//save quest and missions here).

Немного уборки

Мы почти закончили, есть еще несколько мелких вещей, которые нам нужно обработать. Начнем с отображения ошибки missions в компоненте MissionsList. Для этого нам нужно:

Добавьте ошибки как свойство:

static get properties() {
  return {
    missions: Array,
    errors: Array
  };
}

Инициализируйте его пустым массивом в конструкторе:

constructor() {
  super();
  this.missions = [];
  this.errors = [];
}

Установите его в методе stateChanged:

stateChanged(state) {
  this.missions = state.missions;
  this.errors = state.errors;
}

Создайте новый метод для отображения нашего сообщения об ошибке:

hasError() {
  return this.errors.indexOf('missions') >= 0
    ? html`
        <div class="error">
          There must be at least one mission in every quest!
        </div>
      `
    : html``;
}

Вызовите этот метод внутри нашего метода рендеринга:

render() {
  return html`
    <style>
      .error {
        color: red;
      }
    </style>
    <h2>Missions</h2>
    ${this.hasError()}
    <ul>
      ${this.missions.map(
        (m) =>
          html`
            <li><strong>${m.name}:</strong> ${m.description}</li>
          `
      )}
    </ul>
    <missions-form></missions-form>
  `;
}

Последнее, что нам нужно сделать, это немного почистить наш компонент MissionsForm, чтобы он следовал тому же шаблону, что и другие. Для этого нам нужно изменить:

stateChanged для получения ошибок из состояния:

stateChanged(state) {
  this.missions = state.missions;
  this.errors = state.errors;
}

2. Метод formValueUpdated для отправки действия вместо прямого изменения свойства:

formValueUpdated(e) {
  let errorList = [...this.errors];
  if (!e.target.value) {
    errorList.push(e.target.name);
  } else {
    let indexOfError = errorList.indexOf(e.target.name);
    if (indexOfError >= 0) {
      errorList.splice(indexOfError, 1);
    }
  }
  
  store.dispatch(errorsDetected(errorList));
}

И метод submit сделать то же самое:

submit(e) {
  e.preventDefault();
  let form = e.target;
  let errors = this.checkForErrors(form);
  if (!errors.length) {
    let mission = {
      name: form.name.value,
      description: form.description.value
    };
    store.dispatch(missionsUpdated([...this.missions, mission]));
    form.reset();
  }
  store.dispatch(errorsDetected(errors))
}

Резюме

Это все, что нам нужно, чтобы наши формы работали с LitElement и Redux. Отсюда можно реализовать любые другие операции CRUD. Вы можете посмотреть полный пример в моем репозитории GitHub. Пример будет дополнен редактированием и удалением миссий, а также редактированием квеста.