WT. Практика (Desktop)
Платформа WTПрактикаСинтаксисБаза знаний
  • Приветствие
  • Основной
    • Урок 1. Форма списка и добавление записей
    • Урок 2. Редактирование таблицы
    • Урок 3. Выпадающий список
    • Урок 4. Паттерн onClose
    • Урок 5. Удаление связанных данных
    • Урок 6. Главная форма
    • Урок 7. Фильтры
    • Урок 8. Редактирование выпадающего списка
    • Урок 9. Список категорий
    • Урок 10. Паттерн Add/Edit
    • Урок 11. Экспорт данных в документ
    • Урок 12. Дерево в таблице
    • Урок 13. Самостоятельная
    • Урок 14. Постраничный просмотр
    • Дополнительно
      • Array
      • ArrayGetDataConnection
      • ConvertDataConnection
  • Загрузка данных
    • Урок 15. Режимы загрузки данных
    • Урок 16. Режим блокировки форм (Lock)
  • Многопользовательский режим
    • Урок 17. Аутентификация пользователей в программе
    • Урок 18. Права доступа
    • Урок 19. Динамические права доступа
    • Урок 20. Пользовательские настройки
    • Урок 21. Автоматическое обновление данных
  • Кастомизация
    • Урок 22. Создание кастомных команд для форм
    • Урок 23. Создание кастомных команд для серверной части
    • Урок 24. Планировщик задач
  • Продвинутый уровень
    • Урок 25. Создание API-запросов
    • Урок 26. Работа с JSON на форме
    • Урок 27. Разделение формы на несколько файлов
Powered by GitBook
On this page
  • Паттерн onClose
  • Проверка изменений на форме
  • Обработка ответа пользователя на сообщение
  • Обязательные поля
  • Проверка обязательных полей
  • Предупреждение пользователя
  • Паттерн onClose и обязательные поля
  • Использование маски в текстовом поле
  • Самостоятельно
  • Итоги
  • Ответы
  1. Основной

Урок 4. Паттерн onClose

Last updated 1 year ago

На прошлом уроке мы расширили приложение, добавив форму списка клиентов и карточку редактирования клиента. Список клиентов стал главной формой, а доступ к форме списка городов осуществляется через карточку клиента.

На этом уроке рассмотрим подход к улучшению опыта взаимодействия пользователя с формами для редактирования данных, а именно, паттерн onClose для проверки изменений на форме и уведомления о них пользователя при закрытии формы без сохранения.

Также рассмотрим, как обозначать обязательные поля на форме и проверять корректность их заполнения.

Если хотите начать практику с этого урока, то вам необходимо развернуть учебный проект по инструкции в статье .

При разворачивании проекта используйте backup базы данных, который можете найти в архиве из раздела прошлого урока. Скопируйте папки Forms, Workflow и Patterns в папку с развернутым проектом, например, в папку D:\WT\Projects\Template\Projects\1. Template.

Инструкция по подключению шаблонов находится по .

Паттерн onClose

Суть паттерна заключается в том, чтобы при закрытии формы без сохранения проверять, были ли изменения на форме, и предупреждать о них пользователя. Тем самым исключать неприятную ситуацию потери данных, когда пользователь случайно закрыл форму, не сохранив изменения. Вопрос, который будем задавать пользователю, может выглядеть следующим образом:

По кнопке "Да" мы будем сохранять изменения перед тем, как закрыть форму. По кнопке "Нет" будет закрывать форму без сохранения. А по кнопке "Отмена" будем оставаться на форме и ждать дальнейших действий пользователя.

Перейдем в файл карточки клиента (TemplateClientEdit.xml). На ее примере будем рассматривать паттерн.

Проверка изменений на форме

<Command Name="ResetFormChangedValueSetCommand" Type="ValueSetCommand" Assembly="Commands">
  <Form>
    <Property Name="FormChanged">False</Property>
  </Form>
</Command>

Давайте создадим условие, в котором будем проверять значение этого свойства:

TemplateClientEdit.xml
<Condition Name="FormChangedTrueEqualCondition" Type="EqualCondition" Assembly="Conditions">
  <Items>
    <Item>
      <Form>
        <Property Name="FormChanged" />
      </Form>
    </Item>
    <Item>True</Item>
  </Items>
  <DataType Type="BooleanDataType" />
</Condition>

Свойство ValueChanged обозначает, было ли изменено значение объекта в процессе работы.

Чтобы форма исключала объект из проверки для определения значения свойства FormChanged, необходимо при описании объекта в тэге <MyObject> указать необязательный атрибут ChangeForm со значением False.

Также у тэга <MyObject> есть необязательный вложенный тэг <Change>, который задает настройки изменения свойства ValueChanged:

<Change User="True" Source="False" ValueSet="True" />

  • Атрибут User относится к графическому интерфейсу и определяет, будет ли свойство ValueChanged иметь значение True, если пользователь изменит значение объекта;

  • Атрибут Source определяет, какое значение будет присвоено свойству ValueChanged, если значение объекта обновится из источника. Если атрибут Source имеет значение False, и при этом значение из источника обновится, то ValueChanged присваивается значение False;

  • Атрибут ValueSet также определяет, какое значение будет присвоено свойству ValueChanged, если значение объекта будет присвоено из команды ValueSetCommand. Если атрибут ValueSet имеет значение False, и при этом значение было присвоено из команды ValueSetCommand, то свойство ValueChanged будет иметь значение False.

<Form>
  <Property Name="ChangedObjects" />
</Form>
TemplateClientEdit.xml
<Command Name="SaveOnCloseMessageBoxCommand" Type="MessageBoxCommand" Assembly="Commands">
  <Caption>Сохранение</Caption>
  <Text>Форма содержит несохраненные изменения.\rСохранить их перед закрытием?</Text>
  <Icon Type="Question" />
  <Buttons Type="YesNoCancel" />
</Command>

В атрибуте Type тэга <Buttons> указываем нужный нам набор кнопок.

Теперь нам нужен Execution, который по условию будет вызывать команду SaveOnCloseMessageBoxCommand.

На форме уже есть Execution, который обрабатывает событие закрытия формы:

TemplateClientEdit.xml
<Execution>
  <ConditionExpression>
    <Or>
      <Condition Name="FormClosingCondition" />
      <Condition Name="EscapeKeyDownCondition" />
    </Or>
  </ConditionExpression>
  <Commands>
    <Command Name="FormCloseCommand" />
  </Commands>
</Execution>

Давайте переделаем этот Execution так, чтобы он учитывал наличие изменений на форме и вызывал команду с диалоговым окном:

TemplateClientEdit.xml
<Execution>
  <ConditionExpression>
    <And>
      <Or>
        <Condition Name="FormClosingCondition" />
        <Condition Name="EscapeKeyDownCondition" />
      </Or>
      <Condition Name="FormChangedTrueEqualCondition" />
    </And>
  </ConditionExpression>
  <Commands>
    <Command Name="SaveOnCloseMessageBoxCommand" />
  </Commands>
</Execution>

Обработка ответа пользователя на сообщение

Создадим условия, которые будут проверять, какую кнопку в диалоговом окне нажал пользователь. Команда MessageBoxCommand возвращает значение нажатой кнопки:

  • Yes - нажали кнопку "Да";

  • No - нажали кнопку "Нет";

  • Cancel - нажали кнопку "Отмена".

Достаточно создать два условия для кнопок "Да" и "Нет":

TemplateClientEdit.xml
<Condition Name="SaveOnCloseMessageBoxCommandYesEqualCondition" Type="EqualCondition" Assembly="Conditions">
  <AlwaysChange Value="True" />
  <Items>
    <Item>
      <Command Name="SaveOnCloseMessageBoxCommand" />
    </Item>
    <Item>Yes</Item>
  </Items>
</Condition>

<Condition Name="SaveOnCloseMessageBoxCommandNoEqualCondition" Type="EqualCondition" Assembly="Conditions">
  <AlwaysChange Value="True" />
  <Items>
    <Item>
      <Command Name="SaveOnCloseMessageBoxCommand" />
    </Item>
    <Item>No</Item>
  </Items>
</Condition>

Создадим Execution для обработки нажатия кнопки "Да" диалогового окна:

TemplateClientEdit.xml
<Execution>
  <ConditionExpression>
    <Condition Name="SaveOnCloseMessageBoxCommandYesEqualCondition" />
  </ConditionExpression>
  <Commands>
    <Command Name="SaveSequentialCommand" />
  </Commands>
</Execution>

А для обработки нажатия кнопки "Нет" диалогового окна создадим Execution, который помимо этого условия будет отрабатывать закрытие формы без изменений:

TemplateClientEdit.xml
<Execution>
  <ConditionExpression>
    <Or>
      <And>
        <Or>
          <Condition Name="FormClosingCondition" />
          <Condition Name="EscapeKeyDownCondition" />
        </Or>
        <Not>
          <Condition Name="FormChangedTrueEqualCondition" />
        </Not>
      </And>
      <Condition Name="SaveOnCloseMessageBoxCommandNoEqualCondition" />
    </Or>
  </ConditionExpression>
  <Commands>
    <Command Name="FormCloseCommand" />
  </Commands>
</Execution>

Запустите приложение. Убедитесь, что формы успешно загружены. Проверьте только что добавленный функционал.

Стоит отметить, что такой подход отлично работает, когда на форме нет полей, обязательных для заполнения, и полей, для которых важна корректность введенных данных. Если такие поля есть и заполнены некорректно, то при нажатии на кнопку "Да" диалогового окна в базу запишутся некорректные данные. Следовательно, при закрытии формы без сохранения мы должны проверять еще и корректность введенных данных.

Обязательные поля

Так как сущность "Клиент" будет одной из основных в нашем приложении, и клиент, что логично, не может быть без имени, поле "Имя (ФИО)" необходимо сделать обязательным для заполнения. Также сделаем обязательным поле "Город", так как дальнейшая логика будет привязываться к городам клиентов.

Дополнительной гарантией корректности данных будут ограничения на колонки в таблице template.client в базе данных. Перейдем в программу для управления СУБД PostgreSQL, и для нашей базы данных template_project выполним запросы, представленные ниже. Перед этим очистите таблицу от данных, либо заполните эти колонки во всех строках какой-либо информацией, чтобы не возникло ошибок с заданием ограничений на колонки.

ALTER TABLE template.client
   ALTER COLUMN city_id SET NOT NULL;
ALTER TABLE template.client
   ALTER COLUMN title SET NOT NULL;

Проверка обязательных полей

TemplateClientEdit.xml
<Condition Name="NameTextBoxIsNullOrEmptyCondition" Type="IsNullOrEmptyCondition" Assembly="Conditions">
  <Items>
    <Item>
      <Object Name="NameTextBox"/>
    </Item>
  </Items>
</Condition>
TemplateClientEdit.xml
<Condition Name="CityComboBoxIsNullCondition" Type="IsNullCondition" Assembly="Conditions">
  <Items>
    <Item>
      <Object Name="CityComboBox"/>
    </Item>
  </Items>
</Condition>
TemplateClientEdit.xml
<Checking>
  <Object Name="NameTextBox" />
  <ConditionExpression>
    <Condition Name="NameTextBoxIsNullOrEmptyCondition" />
  </ConditionExpression>
  <AsteriskHint>Пожалуйста, заполните это поле</AsteriskHint>
</Checking>

<Checking>
  <Object Name="CityComboBox" />
  <ConditionExpression>
    <Condition Name="CityComboBoxIsNullCondition" />
  </ConditionExpression>
  <AsteriskHint>Пожалуйста, выберите элемент в этом списке</AsteriskHint>
</Checking>

Назначение конструкции <Checking>в том, что при выполнении условия, описанного в тэге <ConditionExpression>, слева от объекта, указанного в тэге <Object>, будет отображаться красная полоска. При наведении курсора мыши на полоску будет всплывать подсказка с текстом из тэга <AsteriskHint>.

Предупреждение пользователя

Теперь на кнопку "Сохранить" необходимо повесить проверку заполнения обязательных полей.

TemplateClientEdit.xml
<Condition Name="MandatoryFieldsAreFilledEqualCondition" Type="EqualCondition" Assembly="Conditions">
  <Items>
    <Item>
      <Form>
        <Property Name="CheckingFired" />
      </Form>
    </Item>
    <Item>False</Item>
  </Items>
  <DataType Type="BooleanDataType" />
</Condition>

Это условие можно просто указать в тэге <Enabled> кнопки SaveButton, чтобы она стала неактивной. Так мы поступали с кнопками редактирования и удаления записей на формах списков клиентов и городов. Но такой подход не позволяет пользователю узнать, где он допустил ошибку, и почему кнопка неактивна.

Можно создать команду MessageBoxCommand, которая предупредит пользователя, если он попытается сохранить некорректные данные. Для этого в тэге <Commands> кнопки SaveButton нужно создать блок <If>, который определит, какая команда будет выполняться: либо команда сохранения данных, либо команда отображения сообщения о некорректном заполнении полей.

Скорректируем описание кнопки "Сохранить" с использованием DisabledMode. Для этого в тэге <Enabled> пропишем условие MandatoryFieldsAreFilledEqualCondition и добавим тэги <DisabledMode> и <DisabledText>.

Таким образом, общий синтаксис кнопки SaveButton будет выглядеть так:

TemplateClientEdit.xml
<MyObject Name="SaveButton" Type="Button" Assembly="BaseControls">
  <Top>5</Top>
  <Right>
    <Formula>
      <Minus DataType="IntegerDataType">
        <Item>
          <Object Name="FootPanel">
            <Property Name="Width" />
          </Object>
        </Item>
        <Item>5</Item>
      </Minus>
    </Formula>
  </Right>
  <Height>30</Height>
  <Width>200</Width>
  <TextAlign>MiddleCenter</TextAlign>
  <FontStyle>ButtonFontStyle</FontStyle>
  <ImageAlign>MiddleLeft</ImageAlign>
  <Image>Images\24x24\content-save.png</Image>
  <FlatStyle>Flat</FlatStyle>
  <FlatBorderSize>1</FlatBorderSize>
  <FlatBorderColor>ButtonFlatBorderColor</FlatBorderColor>
  <FlatMouseDownBackColor>ButtonFlatMouseDownBackColor</FlatMouseDownBackColor>
  <FlatMouseOverBackColor>ButtonFlatMouseOverBackColor</FlatMouseOverBackColor>
  <Text>Сохранить</Text>
  <TabIndex>1</TabIndex>
  <Enabled>
    <Condition Name="MandatoryFieldsAreFilledEqualCondition" />
  </Enabled>
  <DisabledMode>True</DisabledMode>
  <DisabledText>Одно или несколько полей заполнены некорректно.</DisabledText>
  <Commands>
    <Command Name="SaveSequentialCommand" />
  </Commands>
</MyObject>

Запустите приложение. Убедитесь, что формы успешно загружены. Проверьте только что добавленный функционал.

Паттерн onClose и обязательные поля

Изменения в паттерне будут заключаться в том, что, отловив событие закрытия формы и проверив наличие изменений, мы должны выяснить, все ли поля корректно заполнены. И если поля заполнены правильно, то задаем вопрос о сохранении, вызвав команду SaveOnCloseMessageBoxCommand. А если есть поля с некорректными данными, или не все обязательные поля заполнены, то мы должны предупредить пользователя об утере несохраненных изменений. В этом случае будем показывать сообщение вида:

По кнопке "Да" мы будем закрыть форму без сохранения изменений. По кнопке "Нет" будем оставаться на форме и ждать дальнейших действий пользователя.

Давайте опишем команду MessageBoxCommand, которая будет отображать это диалоговое окно:

TemplateClientEdit.xml
<Command Name="CloseOnCloseMessageBoxCommand" Type="MessageBoxCommand" Assembly="Commands">
  <Caption>Закрытие</Caption>
  <Text>При закрытии все несохраненные изменения будут утеряны.\rВы уверены, что хотите закрыть форму?</Text>
  <Icon Type="Question" />
  <Buttons Type="YesNo" />
</Command>

Создадим условие проверки нажатия кнопки "Да" диалогового окна:

TemplateClientEdit.xml
<Condition Name="CloseOnCloseMessageBoxCommandYesEqualCondition" Type="EqualCondition" Assembly="Conditions">
  <AlwaysChange Value="True" />
  <Items>
    <Item>
      <Command Name="CloseOnCloseMessageBoxCommand" />
    </Item>
    <Item>Yes</Item>
  </Items>
</Condition>

Скорректируем наш первый Execution следующим образом:

TemplateClientEdit.xml
<Execution>
  <ConditionExpression>
    <And>
      <Or>
        <Condition Name="FormClosingCondition" />
        <Condition Name="EscapeKeyDownCondition" />
      </Or>
      <Condition Name="FormChangedTrueEqualCondition" />
    </And>
  </ConditionExpression>
  <Commands> 
    <If>
      <When>
        <Condition Name="MandatoryFieldsAreFilledEqualCondition" />
      </When>
      <Then>
        <Command Name="SaveOnCloseMessageBoxCommand" />
      </Then>
      <Else>
        <Command Name="CloseOnCloseMessageBoxCommand" />
      </Else>
    </If>
  </Commands>
</Execution>

А в Execution на закрытие формы добавим условие CloseOnCloseMessageBoxCommandYesEqualCondition:

TemplateClientEdit.xml
<Execution>
  <ConditionExpression>
    <Or>
      <And>
        <Or>
          <Condition Name="FormClosingCondition" />
          <Condition Name="EscapeKeyDownCondition" />
        </Or>
        <Not>
          <Condition Name="FormChangedTrueEqualCondition" />
        </Not>
      </And>
      <Condition Name="SaveOnCloseMessageBoxCommandNoEqualCondition" />
      <Condition Name="CloseOnCloseMessageBoxCommandYesEqualCondition" />
    </Or>
  </ConditionExpression>
  <Commands>
    <Command Name="FormCloseCommand" />
  </Commands>
</Execution>

Запустите приложение. Убедитесь, что формы успешно загружены. Проверьте только что добавленный функционал.

На этом мы закончили рассматривать паттерн onClose.

Использование маски в текстовом поле

Давайте для поля "Контактный телефон" реализуем проверку формата введенного номера. Для этого в описание объекта PhoneTextBox добавим тэг <Mask> со значением PHONE. Таким образом, синтаксис текстового поля будет выглядеть так:

TemplateClientEdit.xml
<MyObject Name="PhoneTextBox" Type="TextBox" Assembly="BaseControls">
  <Top>
    <Object Name="PhoneLabel">
      <Property Name="Bottom" />
    </Object>
  </Top>
  <Left>
    <Object Name="PhoneLabel">
      <Property Name="Left" />
    </Object>
  </Left>
  <Width>
    <Calculate>
      <Expression>{0} - {1} - 10</Expression>
      <Items>
        <Item>
          <Object Name="ContentPanel">
            <Property Name="Width" />
          </Object>
        </Item>
        <Item>
          <Object Name="PhoneTextBox">
            <Property Name="Left" />
          </Object>
        </Item>
      </Items>
    </Calculate>
  </Width>
  <Text>
    <DataConnection SourceDataConnection="ClientPrimaryGetDataConnection">
      <Fields>
        <Field Name="Phone" />
      </Fields>
    </DataConnection>
  </Text>
  <Mask>PHONE</Mask>
</MyObject>

Запустите приложение и откройте карточку клиента. Проверьте, как теперь отображается поле "Контактный телефон".

TemplateClientEdit.xml
<Condition Name="PhoneMaskNotCompletedCondition" Type="EqualCondition" Assembly="Conditions">
  <Items>
    <Item>
      <Object Name="PhoneTextBox">
        <Property Name="MaskCompleted" />
      </Object>
    </Item>
    <Item>False</Item>
  </Items>
  <DataType Type="BooleanDataType" />
</Condition>

Так как поле "Контактный телефон" не является обязательным для заполнения, то полноту введенных данных необходимо проверять только в том случае, если пользователь начал заполнять поле. Для этого создадим Condition, в котором будем проверять, пустое ли поле:

TemplateClientEdit.xml
<Condition Name="PhoneIsEmptyCondition" Type="EqualCondition" Assembly="Conditions">
  <Items>
    <Item>
      <Object Name="PhoneTextBox" />
    </Item>
    <Item>+</Item>
  </Items>
</Condition>

Значение объекта PhoneTextBox сравниваем с символом "+", т.к. маска начинается с этого символа.

Создадим Checking для PhoneTextBox:

TemplateClientEdit.xml
<Checking>
  <Object Name="PhoneTextBox" />
  <ConditionExpression>
    <And>
      <Not>
        <Condition Name="PhoneIsEmptyCondition" />
      </Not>
      <Condition Name="PhoneMaskNotCompletedCondition" />
    </And>
  </ConditionExpression>
  <AsteriskHint>Некорректный номер телефона</AsteriskHint>
</Checking>

Запустите приложение и откройте карточку клиента. Проверьте отображение сообщения об ошибке при заполнении текстового поля "Контактный телефон".

Давайте поправим один момент. Сейчас при сохранении информации о клиенте, если поле "Контактный телефон" оставили пустым, в базу запишется строка "+". Чтобы в таком случае в таблицу template.client в базе данных в колонку phone писалось значение NULL, необходимо в ClientInsertSetDataConnection и ClientUpdateSetDataConnection изменить описание параметра Phone на следующий код:

<Parameter NativeName="Phone">
  <Value>
    <Switch>
      <Case>
        <When>
          <Not>
            <Condition Name="PhoneIsEmptyCondition" />
          </Not>
        </When>
        <Then>
          <Object Name="PhoneTextBox" />
        </Then>
      </Case>
    </Switch>
  </Value>
</Parameter>

Самостоятельно

  • На форме списка клиентов (TemplateStart.xml) на кнопки редактирования и удаления записей добавьте режим DisabledMode;

  • На форме списка городов (TemplateCityList.xml) на кнопки редактирования и удаления записей добавьте режим DisabledMode;

  • На форме карточки города (TemplateCityEdit.xml) поле "Наименование" сделайте обязательным для заполнения. Не забудьте про ограничение на колонку в таблице в базе данных;

  • На форме карточки города (TemplateCityEdit.xml) на кнопку "Сохранить" добавьте режим DisabledMode;

  • На форме карточки города (TemplateCityEdit.xml) реализуйте паттерн onClose;

  • На форме списка городов (TemplateCityList.xml) реализуйте паттерн onClose.

Итоги

На уроке мы рассмотрели паттерн onClose, суть которого заключается в том, чтобы определить, были ли изменения на форме, и предупредить пользователя, если он захочет закрыть форму без сохранения.

Также мы узнали, как можно уведомить пользователя об обязательных полях, и как такие поля повлияют на использование паттерна onClose.

Ответы

В архиве присутствуют xml-файлы форм и серверный xml-файл, также лежит бэкап базы данных и файл с запросами на изменение структуры базы данных - с помощью файлов можете проверить себя.

Для отслеживания изменений у формы есть get-проперти , которое возвращает значение True, если изменилось значение хотя бы одного объекта.

Также есть set-проперти , с его помощью можно вручную сбрасывать признак изменений на форме, но не сами изменения.

Значение свойства формы определяется совокупностью get-проперти всех объектов на форме.

У формы есть get-проперти , которое возвращает список измененных объектов в виде строки:

С помощью этого get-проперти можно проверить, какие объекты формы изменились и повлияли на результат get-проперти формы .

Для вывода сообщений пользователю используется команда , которая открывает диалоговое окно сообщения с набором кнопок. Давайте создадим такую команду:

В нем обрабатывается не только закрытие по крестику, которое отслеживает условие . Также отслеживается нажатие клавиши Esc. За это отвечает условие EscapeKeyDownCondition типа .

Для проверки текстового поля NameTextBox понадобится условие типа :

А для проверки выпадающего списка создадим условие типа :

Чтобы сообщить пользователю, что поля обязательны для заполнения, воспользуемся конструкцией . Для этого перед тэгом <Executions> разместим тэг <Checkings>, в который расположим следующий код:

Чтобы проверить, все ли поля заполнены, достаточно узнать, сработал ли хотя бы один Checking. Для этого у формы есть свойство . Создадим , в котором проверим это свойство. И если оно равно False, то все поля корректно заполнены.

Но есть более изящный способ. У кнопки есть режим , который включается через тэг <DisabledMode>. Кнопка всегда остается активной, даже если Enabled равен False. При нажатии на "неактивную" кнопку (Enabled = False) будет показываться сообщение с текстом из тэга <DisabledText>.

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

FormChanged
FormChanged
FormChanged
ValueChanged
ChangedObjects
FormChanged
MessageBoxCommand
FormClosingCondition
KeyDownCondition
IsNullOrEmptyCondition
IsNullCondition
Checking
CheckingFired
EqualCondition
Button
DisabledMode
TextBox
MaskCompleted
Разворачивание проекта
317KB
lesson4-answer.zip
archive
ссылке
Ответы