Основы. Часть 3 – Простой проект

Давайте создадим простой проект, где практически весь код добавим самостоятельно. Это позволит лучше понять его структуру и принципы работы ASP.NET MVC 3.

Необходимо сразу отметить, что в следующих частях будет показано как можно упростить и ускорить разработку на начальном этапе. Для этого используются дополнительные инструменты, которые возьмут на себя часть рутинной работы. Поэтому все изменения, сделанные в этой части, временные и их необходимо будет удалить. Для этого можно использовать копию проекта BookCatalog (например, расположенную в другой папке) или же пересоздать его впоследствии.

Создаем Модель

Разработку начнем с Модели. В папке Models создадим класс BookDetails. Чтобы не усложнять пример добавим в него только три свойства: уникальный код (Id), название книги (Title) и имя её автора (Author):

namespace BookCatalog.Models
{
    public class BookDetails
    {
        public int Id { get; set; }

        public string Title { get; set; }

        public string Author { get; set; }
    }
}

Для хранения информации в эту же папку добавим класс BookRepository, который будет эмулировать работу с базой данных:

namespace BookCatalog.Models
{
    using System;
    using System.Collections.Generic;

    public class BookRepository
    {
        private static int _nextId = 0;

        private static readonly List<BookDetails> _book = new List<BookDetails>();

        public List<BookDetails> Books { get { return _book; } }

        public void Add(BookDetails newBook)
        {
            if (newBook == null) {
                throw new ArgumentNullException("Parameter newBook can't be null.");
            }

            newBook.Id = _nextId;
            _book.Add(newBook);

            _nextId++;
        }
    }
}

Здесь присутствуют только метод Add() для добавления книг и свойство Books, которое предоставляет доступ к их полному списку.

Важный момент: после создания или изменения классов Модели необходимо скомпилировать проект. Это необходимо для создания или обновления его сборки. Тогда помощники ASP.NET MVC, которые будут использоваться в дальнейшем, смогут использовать актуальную информацию о классах. В частности, это относится к диалогу создания Представлений.

Модель готова к использованию. Следующий шаг – разработка Контроллера.

Разработка Контроллера

Adding ControllerДля добавления Контроллера необходимо в Solution Explorer или Solution Navigator вызвать контекстное меню для папки Controllers и выбрать в нем пункт "Add > Controller...". В результате откроется диалог "Add Controller", упрощающий создание соответствующего класса.

Adding empty controllerСоздаваемый Контроллер будет использоваться для работы с каталогом книг. Поэтому назовем его Catalog. В этом случае, согласно принятому в ASP.NET MVC соглашению, класс должен получить имя CatalogController. Укажем его в открывшемся диалоге. Кроме того, выберем опцию, указывающую что создаем пустой класс (Empty controller). Остальные настройки пока не важны, поэтому нажмем кнопку "Add".

В результате будет создан класс с единственным методом Index(), который является Действием по умолчанию. Добавим в него код для создания экземпляра Модели – списка книг. Полученные данные передадим в Представление используя метод Контроллера View().

namespace BookCatalog.Controllers
{
    using System.Web.Mvc;
    using BookCatalog.Models;

    public class CatalogController : Controller
    {
        // GET: /Catalog/
        // GET: /Catalog/Index/
        public ActionResult Index()
        {
            var bookRepository = new BookRepository();
            return this.View(bookRepository.Books);
        }
    }
}

Подобный класс можно создать также используя пункт "Add > Class". Однако в этом случае будет чуть больше работы, т.к. не будет использоваться шаблон Контроллера.

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

С первым все просто. Create() будет создавать и передавать экземпляр класса BookDetails в Представление. В некоторых примерах можно встретить код, где в подобной ситуации просто передается значение null. Однако это плохой подход, т.к. приводит к созданию и обработке исключения. 

// GET: /Catalog/Create/
public ActionResult Create()
{
    return this.View(new BookDetails());
}

После отправки формы пользователем, ядро ASP.NET MVC получит экземпляр BookDetails. Оно постарается обратиться к методу с именем Действия при этом принимающему параметр указанного типа. Поэтому его описание будет следующим образом: Create(NewUserProfile newUser).

Необходимо отметить, что по умолчанию Действия в ASP.NET MVC отвечают только на запросы типа GET. Для отправки форм, как правило, используется POST-запрос. Поэтому, чтобы разрешить методу его обработку, необходимо отметить его атрибутом [HttpPost].

// POST: /Catalog/Create/
[HttpPost]
public ActionResult Create(BookDetails newBook)
{
    var bookRepository = new BookRepository();
    bookRepository.Add(newBook);

    return this.RedirectToAction("Index");
}

После успешного сохранения данных веб-приложение вернется к списку. Для этого используется метод Контроллера RedirectToAction(), который осуществляет переход к указанному Действию. Добавим вывод сообщения о прошедшем событии. Для этого используем две коллекции, предоставляемые ASP.NET MVC:

  • ViewBag является динамической (dynamic) коллекцией и предназначена для передачи данных от Контроллера к Представлению. Она обеспечивает доступ к помещенным в нее объектам в течении текущего запроса, после чего очищается. Кроме того, поддерживается коллекция ViewData, которая отличается только типом: IDictionary<string, object>. При этом обе коллекции указывают на одни и те же объекты, т.е. ViewBag.Message всегда равно ViewData["Message"].
  • TempData это также коллекция типа IDictionary<string, object>. Но, в отличии от ViewData, объекты в ней сохраняются до первого их чтения или завершения сессии. Это подходит для передачи простых сообщений между Действиями.

Обратите внимание, что все указанные коллекции не являются строго типизированными. Поэтому использовать их следует очень осторожно. Также рекомендуется подробно документировать создаваемые в них свойства (например завести их перечень).

Условимся, что Представление, относящееся к Действию Index, будет использовать свойство ViewBag.Message для вывода сообщения. Для передачи текста между Действиями воспользуемся TempData.

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

namespace BookCatalog.Controllers
{
    using System.Web.Mvc;
    using BookCatalog.Models;

    public class CatalogController : Controller
    {
        // GET: /Catalog/
        // GET: /Catalog/Index/
        public ActionResult Index()
        {
            this.ViewBag.Message = this.TempData["Message"];

            var bookRepository = new BookRepository();
            return this.View(bookRepository.Books);
        }

        // GET: /Catalog/Create/
        public ActionResult Create()
        {
            return this.View(new BookDetails());
        }

        // POST: /Catalog/Create/
        [HttpPost]
        public ActionResult Create(BookDetails newBook)
        {
            var bookRepository = new BookRepository();
            bookRepository.Add(newBook);

            this.TempData["Message"] = "The book has been added to repository.";

            return this.RedirectToAction("Index");
        }
    }
}

Обратите внимание, что после обращения к TempData["Message"], ядро ASP.NET MVC удалит ссылку на объект с этим индексом из коллекции. Поэтому при следующем обращении к Представлению Index, например при обновлении страницы, сообщение исчезнет.

Для завершения проекта не хватает Представлений и небольшой настройки.

Добавляем Представления

Шаблон разметки страниц

Перед тем как создать первое представление, давайте посмотрим на содержимое файла _Layout.cshtml. Это шаблон разметки страниц, созданный по умолчанию. Разработчики, знакомые с WebForms, сразу проведут аналогию с главными страницами (master pages).

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title>@ViewBag.Title</title>
    <link href="@Url.Content("~/Content/Site.css")" rel="stylesheet" type="text/css" />
    <script src="@Url.Content("~/Scripts/jquery-1.5.1.min.js")" type="text/javascript"></script>
    <script src="@Url.Content("~/Scripts/modernizr-1.7.min.js")" type="text/javascript"></script>
</head>

<body>
    @RenderBody()
</body>
</html>

Шаблон разметки позволяет задать единую структуру для всех или группы страниц веб-приложения. Он содержит общий код, в который будут вставлены Представления на место вызова метода RenderBody().

Свойство ViewBag.Title используется для установки текста заголовка страницы. Его значение будет определяться непосредственно в Представлениях.

Для указания используемого шаблона разметки используется конструкция:

@{ Layout = "~/Views/Shared/_Layout.cshtml"; }

Чтобы не повторять этот код в каждом Представлении, он вынесен в файл _ViewStart.cshtml. При этом всегда можно указать другой шаблон разметки для выбранного Представления, задав в нем нужное значение Layout. Также есть возможность отключить их использование, присвоив данному свойству null.

Представление для отображения списка книг (Index)

Код Представлений можно создать самостоятельно. Для этого в папке Views необходимо создать папку. Её имя, как правило, совпадает с именем Контроллера, который использует данное Представление. А в ней уже размещаются файлы с разметкой, которые называются аналогично Действиям.

Adding ViewОднако, ASP.NET MVC позволяет автоматизировать данный процесс. Поэтому чтобы создать Представление достаточно в редакторе Visual Studio, внутри кода Действия, вызвать контекстное меню и выбрать пункт "Add View…". В результате будет открыт диалог создания Представления. Создадим подобным образом Представление для Действия Index:

Index ViewИмя (Index) и используемый движок (Razor) оставим без изменения.

Следующий пункт позволяет определить будет ли создаваемое Представление строго-типизированным (strongly-typed view). Это означает, что передаваемая ему Модель обязательно должна соответствовать заданному типу. В данном случае это будет класс BookDetails. Также в пункте Scaffold template выберем шаблоном List. Это автоматически добавит в Представление код вывода списка. Кроме того, оставим отметку для добавления ссылок на необходимые скрипты (Reference script libraries).

Остальные пункты пока не важны. Поэтому оставим их значения по умолчанию и нажмем кнопку "Add". В папке Views/Catalog будет создан файл Index.cshtml. Несколько изменим его код. Уберем все ссылки, за исключением необходимой для перехода на страницу добавления данных книги. Добавим вывод сообщения, переданного Контроллером через ViewBag.Message. В итоге получится следующий код (переформатирован для компактности):

@model IEnumerable<BookCatalog.Models.BookDetails>
@{ ViewBag.Title = "Index"; }

<h2>@ViewBag.Title</h2>
<p>@ViewBag.Message</p>
<p>@Html.ActionLink("Create New", "Create")</p>

<table>
    <tr>
        <th>Title</th>
        <th>Author</th>
    </tr>
    @foreach (var item in Model) {
        <tr>
            <td>@Html.DisplayFor(modelItem => item.Title)</td>
            <td>@Html.DisplayFor(modelItem => item.Author)</td>
        </tr>
    }

</table>

Рассмотрим его подробнее:

  • ключевое слово @model показывает что Представление строго-типизированное (тип BookDetails);
  • создается свойство ViewBag.Title и устанавливается его значение, которое будет использовано в шаблоне разметки для заголовка страницы;
  • конструкция вида @ViewBag.Message используется для вывода значения указанной переменной (в данном случае это текст сообщения);
  • метод ActionLink() создаёт код ссылки на указанное Действие;
  • для формирования таблицы с перечнем книг используется C# конструкция foreach;
  • вызовы метода DisplayFor() предназначены для вывода значений переменных;

Теперь перейдем к созданию Представления для Действия Create.

Представление для добавления данных (Create)

Создание этого Представления аналогично предыдущему, за исключением двух моментов:

  • контекстное меню можно вызывать для любого из двух методов Create();
  • в пункте Scaffold template используется шаблон Create.

Указываем маршрут для команд

Если сейчас запустить созданный проект, то результатом будет сообщение об ошибке. Причина заключена в обращении к несуществующему Контроллеру Home, который используется по умолчанию. В данном проекте вместо него необходимо указать созданный CatalogController. Для этого следует подставить соответствующее значение в методе RegisterRoutes() (файл Global.asax):

public static void RegisterRoutes(RouteCollection routes)
{
    routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

    routes.MapRoute(
        "Default", // Route name
        "{controller}/{action}/{id}", // URL with parameters
        new {
            controller = "Catalog",
            action = "Index",
            id = UrlParameter.Optional
        } // Parameter defaults
    );
}

Запустим созданное веб-приложение. Оно отображает список книг и позволяет пополнять его.

Разработку можно продолжить добавляя Действия и Представления. В дальнейшем также могут появиться новые классы Модели и Контроллеры. Не стоит забывать о необходимости заменить эмулятор на реальную базу данных. При этом надо учесть, что обновленный ASP.NET MVC 3 может сильно упростить эту задачу и ускорить разработку. Как именно? Это будет показано в дальнейшем.

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

Комментарии (49) -

Артур 21.03.2011 22:44:28

А зачем тут везде используется ключевое слово "this"?

Дополнительно выделяет методы и свойства (в частности, в редакторе VS цветом), которые принадлежат данному классу. Например, при чтении вот такой записи

ViewBag.Message = this.TempData["Message"];
ViewBag можно принять за свойство экземпляра или статический класс. this устраняет разночтение. Разумеется, оно не обязательно и у вас может быть свой стиль/стандарт кодирования.

Андрей, неужели вам не надоедает писать статьи для начинающих в стиле Капитана Очевидность? Я сам когда-то этим баловался, и мне просто интересно, откуда вы черпаете мотивацию на подобную деятельность?

Мотивация - закончить учебник по ASP.NET MVC 3 и не бросить его на середине пути. Согласитесь не серьезно ведь будет? А "мешает" – работа. Пока писал первый вариант (тогда я не знал что он первый), уже появились изменения в MVC (последнее обновление).

Собственно сейчас опубликованное - это не сколько новая статья, сколько доработка уже бывшей. На мой взгляд поправки для EF4.1 и NuGet того стоят. Плюс еще несколько мелочей. Но больших изменений не будет. Процентов 50, а то и больше – копия того что было. Может даже компактнее.

Единственное, возможно я не совсем корректно выбрал формат их публикации. Хотя тут меня терзают сомнения. С одной стороны правки в блоге вот так выходят на первую страницу и выглядят как новинка. Но если сделать все правки "втихоря" будет выглядеть как мёртвый блог.

Или вы считаете что стоит бросить эту затею ? Smile

Сложно сказать. Я когда-то писал нечто подобное капитанистое по Entity Framework, так люди до сих пор ходят ко мне в блог за этим материалом (несмотря на его устаревание). Так что такой материал всегда будет востребован. Другое дело, что мне, например, уже жалко времени на написание таких статей.

Насчет формата публикации. Наверное, вариант с обновлением старых статей выглядел бы интереснее. Хотя куда девать статьи, которые совсем потеряли актуальность? Удалять? А чтобы блог не выглядел мертвым, можно было бы публиковать апдейты на главной. И было бы удобно иметь некоторое оглавление со ссылками на все актуальные статьи учебника (или PDF-документ).

Про "бросать" вопрос риторический был. Я сразу отметил, что не буду бросать на полпути. Насчет формата - скорее всего так и будет. По мере времени уже кое-что реализовано в эту сторону (есть общее оглавление, ссылки, независящие от движка блога и тюд.). Будет время - возможно поменяю платформу сайта или хотя бы разделю блог и учебник.


Посмею вмешаться.

@ Или вы считаете что стоит бросить эту затею ?
Думаю, это будет первый шаг к тому, что-бы сделать блог мёртвым.

@ писать статьи для начинающих в стиле Капитана Очевидность?
Я не считаю, что статьи в стиле Капитана Очевидности, это пустая трата времени, по нескольким причинам. Первая причина, это то, что подобные статьи разъясняют новичкам сами принципы и это сделано на понятном русском. Вторая причина, это то, что со временем новички перемещают свои взоры на англоязычные ресурсы и тогда эти статьи обретают вторую жизнь, они позволяют сравнивать своё понимание технологии, с пониманием технологии автором данных статей. В итоге, комплексные знания не позволяют сбиться с пути и позволяют избежать ошибок.
В трет��их, лично для меня блог Андрея, это хорошая шпаргалка, в которую приятно заглянуть, если чего-то подзабыл и огромное ему спасибо за ту работу, которую он делает. Конечно, есть MSDN, но по мне, если есть актуальная, подробная информация на родном мне языке, грех не использовать её. Тем более, что этот цикл представляет из себя хороший и понятный концентрат, который занял достойное место в закладках многих.

@ Dzmuh: Спасибо за ваш отзыв. Ну я понимаю так, что больше все же речь про первые части. Тут действительно я задержался. Но например, на момент создания мое описание провайдеров было полнее чем в MSDN или встреченных блогах (сейчас может дополнили, не проверял). Не буду скрывать, что подобные статьи и самому создавать интереснее.

@ Andrey: Это вам спасибо. До вашего цикла у меня на ASP.NET MVC была аллергия и я считал всё это дело громоздким, скучным и неинтересным и старательно обходил стороной. Оказалось, что я ошибался. Технология от Microsoft оказалось интересной, лёгкой и достаточно дружелюбной.
Я вот подбивал AlexIdsa на серию статей про EntityFramework Code First & DataAnnotations. Видел и вашу статью на данную тематику и некоторые другие на русском языке. Но все они очень поверхностные, нет информации по связыванию данных или эта информация примитивна. Есть отличная подборка статей на данную тему на английском языке и было бы круто, если бы кто-то написал бы хотя бы одну, качественную статью на данную тему на русском языке. Своей подборкой ссылок готов поделится, если есть необходимость.


Про "очевидность" и "никому не нужную работу" - это перебор. Андрею мое респектище и пожелание закончить эту нужную и почетную работу.

Привет, похоже что:
private static readonly List<BookDetails> _book = new List<BookDetails>();

в определении модели не совсем корректно - static лишний. Если же не лишний - то nextId тоже должен быть static.

Smile еще довесок. Логика BookRepository несколько кривая - я так понимаю что на все приложение должен быть только один объект такого типа и он должен передаваться в конструктор контроллера а не создаваться заново в каждом методе - иначе теряется смысл BookRepository.

@ ivan: По static согласен, добавил к _nextId.
По второму пункту - это приведет к усложнению примера, в частности необходимости пояснения работы механизма DI. А этому будет свое время. Также не хотелось делать BookRepository свойством или полем контроллера, что тоже не совсем правильно.

На данном этапе была задача показать простой пример ASP.NET MVC приложения.

подправьте : необходимо создания или обновления его сборки --->
необходимо для создания или обновления его сборки

@ oleg: Спасибо.

К сожалению, со static не все корректно:
"Ошибка  1  Доступ к члену "BookCatalog.Models.BookRepository._nextId" через ссылку на экземпляр невозможен; вместо этого уточните его, указав имя типа  ...\visual studio 2010\Projects\BookCatalog\BookCatalog\Models\BookRepository.cs"


P.S. Весьма благодарен за Ваш труд. Терпения Вам. Закончите работу и отошлите в Microsoft с предложением опубликовать как книги Федорова, которые вручают на семинарах.

@ pvalery: Согласен, this там не к месту. Поправил.

в шаблоне

@ViewBag.Message
не показывается
заменил на
@TempData["Message"]
- заработало

@ Michal:

Sorry, пропустил

this.ViewBag.Message = this.TempData["Message"];

Огромное Вам СПАСИБО! РЕСПЕКТ И УВАЖУХА!!!

Пожалуйста. Smile

А  можно небольшой вопрос не совсем по теме. Для чего вносить using в namespace ? просто всегда делал по дефолту, как студия создавала, за namespace

@Artem : Это помогает избежать возможного конфликта имен классов из различных namespace, т.к. это уточняет приоритет using внутри определенного namespace.

Вот тут есть хороший пример:
stackoverflow.com/.../should-usings-be-inside-or-outside-the-namespace

Andrey:
Благодарю

Андрей 30.11.2011 0:37:57

Цикл статей лучший из того что я видел на русском языке по данной тематике. Буду по ним учить студентов, и делать методички.

@ Андрей: Спасибо. А методички в виде ссылок на блог? ;)

Алексей 06.12.2011 18:07:54

Спасибо за прекрасную статью. Хотел уточнить один момент. Если я нажимаю кнопку в форме "Добавить" с пустыми полями Title и Author, то сообщение "Parameter newBook can't be null" не выводится, а пустые данные добавляются. Это из-за того, что свойство Id не пустое?

@ Алексей: Сам экземпляр newBook создается, только содержит пустые строки. Далее, пока что в коде Модели нет указаний для проверки допустимых значений свойств (они добавляется позже в виде атрибутов). Поэтому пустые строки не являются ошибкой. А Id генеригуется автоматически при добавлении записи в таблицу.

Димка 24.01.2012 17:23:53

Вы не могли бы пояснить зачем в контролере:

this.ViewBag.Message = this.TempData["Message"];

Димка 24.01.2012 18:39:26

скажите еще пж какие манипуляции нужно сделать, чтоб нормальные ЮРЛы были?   Сейчас у меня - http://localhost:19150/             цыфры...

@ Димка: Прочитайте внимательнее, TempData хранит данные между запросами. А ViewBag используется для передачи данных в Представление.

Что касается ссылок, то вариант по умолчанию это
http://<site>/<controller>/<action>/<parameters>.
Если у вас по другому, то вы что-то изменили в проекте.

Димка 25.01.2012 17:01:41

спасибо за ответ. Их назначения я понял, просто меня смутило что вы присваиваете this.ViewBag.Message = this.TempData["Message"];    еще до инициализации TempData -  this.TempData["Message"] = "The book has been added to repository.";

инициируется TempData после попытки присвоения.....!  Или тут не так как в винформс?

@ Димка: TempData инициализируется при создании Контроллера. Механизм ASP.NET MVC позволяет передавать с его помощью данные между запросами. Исходно this.TempData["Message"] вернет null. После присвоения this.TempData["Message"] = "The book has been added to repository."; результатом this.TempData["Message"] уже будет строка.

Огромное спасибо!

Евгений 22.05.2012 21:55:57

Спасибо большое!!!

@ Евгений: Пожалуйста.

AlexIdsa:
неужели вам не надоедает писать статьи для начинающих в стиле Капитана Очевидность?
Andrey, от лица всех начинающих хочу высказать Вам огромное спасибо!) Ваши статьи - это как раз то, что мне было нужно. я искала что-нибудь краткое и доходчивое, чтобы можно было начать с нуля, и вот, кажется, нашла Smile

Огромное спасибо за такой материал! Хотелось бы поскорей увидеть учебник в полной мере, ибо лучшего аналога (на русском) я не встречал по данной тематике!

Разработку начнем с Модели. В папке Models создадим класс BookDetails. - какой класс тут нужно создать??.. если студия выдает 30 видов классов, из них 8 с окончанием MVC 3.. как по мне это большое упущение.. ибо студия "по умолчанию" предлагает создать "MVC 3 Controller class" - следовательно всего нужно выбрать из 9-ти возможных.. это если полагаться на интуицию..

@ ura: Интуиция тут не нужна. Код приведен там же и это обычный C# класс.

Николай 28.11.2012 3:16:41

Огромное спасибо за учебник. Очень уважаю и ценю людей умеющих на простом языке рассказывать сложные и не очень вещи. Темболее писать учебники понятным языком- это уже талант.

Павел 10.06.2013 15:42:20

"переход в указанному Действию." - опечатка

Павел 10.06.2013 16:18:37

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

@ Павел: Спасибо, поправил.

Павел 11.06.2013 16:26:15


Не за что !

Вот еще пара требующая корректировки :

- "В папке View/Catalog будет создан файл Index.shtml.", ошибка в написании расширения для индекс-файла, должно быть "Index.Сshtml";

- Папка "View/" если не ошибаюсь пишется с буквой "s" ( "Views/" ).

С уважением,
Павел

Возможно опечатка - "Представление для отображения списка пользователей (Index)". Может не пользователей, а "списка книг"?

И вопрос...
"Подобный класс можно создать также используя пункт "Add > Class". Однако в этом случае будет чуть больше работы, т.к. не будет использоваться шаблон Контроллера."
Т.е., как я понимаю,можно просто создать класс Контроллер, назвав его CatalogController и унаследоваться от Controller?Всё правильно?

@ Евгений: За внимательность спасибо.

По поводу контроллера вы верно понимаете - это обычный класс, который при желании можно создать как и любой другой (хоть в Блокноте с нуля). Никакой дополнительной "магии" тут нет. Диалог "Add Controller" просто генерирует код на основе определенного шаблона.

Лакшми 05.12.2013 20:45:33

Я безмерно благодарна за Ваш труд! Сколько я пыталась книг читать, как-то получалось, что по кускам все понятно, а в единую картину ну никак не складывается! Нашла Ваш блог и все, я тут поселилась Smile Я пришла в дикий восторг, когда обнаружила, что пазл складывается!!! Настолько понятно, "на пальцах" все объяснено, что даже ребенок поймет! Спасибо Вам огромное!!! Продолжаю изучать Smile

@ Лакшми: Пожалуйста.

Добавить комментарий