Editing Book Management App in Umbraco 8 ‐ Pierre - FadiZahhar/umbraco8showandtell GitHub Wiki

Add the necessary files inHighlyDeveloped.Core

We use the composer to register the component, which is BookTableMigrationComponent. In this component we check if the books table exists, if not it will create it, and work with the data inside it.

1. Add The Composers

Add Files: /Composers/BookServiceComposer.cs

using HighlyDeveloped.Core.Repositories;
using HighlyDeveloped.Core.Services;
using Umbraco.Core;
using Umbraco.Core.Composing;

public class BookServiceComposer : IUserComposer
{
    public void Compose(Composition composition)
    {
        composition.Register<BookService>(Lifetime.Singleton);
        // If BookRepository is a dependency, register it too!
        composition.Register<BookRepository>(Lifetime.Singleton);
    }
}

/Composers/BookTableMigrationComposer.cs

using HighlyDeveloped.Core.Migrations;
using Umbraco.Core;
using Umbraco.Core.Composing;

namespace HighlyDeveloped.Core.Composers
{
    public class BookTableMigrationComposer : IUserComposer
    {
        public void Compose(Composition composition)
        {
            composition.Components().Append<BookTableMigrationComponent>();
        }
    }
}

2. Add The Controllers

Add Files: /Controllers/BookApiController.cs

using HighlyDeveloped.Core.Migrations;
using Umbraco.Core;
using Umbraco.Core.Composing;

namespace HighlyDeveloped.Core.Composers
{
    public class BookTableMigrationComposer : IUserComposer
    {
        public void Compose(Composition composition)
        {
            composition.Components().Append<BookTableMigrationComponent>();
        }
    }
}

/Controllers/BooksPublicController.cs

using HighlyDeveloped.Core.Models;
using HighlyDeveloped.Core.Services;
using System.Collections.Generic;
using System.Web.Http;
using Umbraco.Web.WebApi;

public class BooksPublicController : UmbracoApiController
{
    private readonly BookService _service;
    public BooksPublicController(BookService service) { _service = service; }

    [HttpGet]
    public IEnumerable<Book> GetAllBooks() => _service.GetAll();
}

We use IComponent to create database tables, work with data and register tasks on startup.

3. Add The Migrations

/Migrations/BookTableMigrationComponent.cs

using Umbraco.Core.Composing;
using Umbraco.Core.Migrations;
using Umbraco.Core.Scoping;
using Umbraco.Core.Services;
using Umbraco.Core.Logging;
using Umbraco.Core.Migrations.Upgrade;

namespace HighlyDeveloped.Core.Migrations
{
    public class BookTableMigrationComponent : IComponent
    {
        private readonly IScopeProvider _scopeProvider;
        private readonly IMigrationBuilder _migrationBuilder;
        private readonly IKeyValueService _keyValueService;
        private readonly ILogger _logger;

        public BookTableMigrationComponent(
            IScopeProvider scopeProvider,
            IMigrationBuilder migrationBuilder,
            IKeyValueService keyValueService,
            ILogger logger)
        {
            _scopeProvider = scopeProvider;
            _migrationBuilder = migrationBuilder;
            _keyValueService = keyValueService;
            _logger = logger;
        }

        public void Initialize()
        {
            var migrationPlan = new MigrationPlan("BookTable");
            migrationPlan.From(string.Empty)
                .To<CreateBookTableMigration>("create-book-table");
            var upgrader = new Upgrader(migrationPlan);

            upgrader.Execute(_scopeProvider, _migrationBuilder, _keyValueService, _logger);
        }

        public void Terminate() { }
    }
}

/Migrations/CreateBookTableMigration.cs

using Umbraco.Core.Migrations;

namespace HighlyDeveloped.Core.Migrations
{
    public class CreateBookTableMigration : MigrationBase
    {
        public CreateBookTableMigration(IMigrationContext context) : base(context) { }

        public override void Migrate()
        {
            if (!TableExists("Books"))
            {
                Create.Table("Books")
                    .WithColumn("Id").AsInt32().PrimaryKey("PK_Books_Id").Identity()
                    .WithColumn("Title").AsString(255).NotNullable()
                    .WithColumn("Author").AsString(255).NotNullable()
                    .WithColumn("Year").AsInt32().NotNullable()
                    .WithColumn("ISBN").AsString(50).Nullable()
                    .Do();
            }
        }
    }
}

Advanced Features for the Book Manager

Adding validation

4. Add The Models

/Models/Book.cs

using System.ComponentModel.DataAnnotations;
using NPoco;

namespace HighlyDeveloped.Core.Models
{
    [TableName("Books")]
    [PrimaryKey("Id", AutoIncrement = true)]
    public class Book
    {
        public int Id { get; set; }

        [Required]
        [StringLength(150)]
        public string Title { get; set; }

        [Required]
        [StringLength(100)]
        public string Author { get; set; }

        [Range(1450, 2100)] // Reasonable year range for books
        public int Year { get; set; }

        [Required]
        [RegularExpression(@"^\d{3}-\d{10}$", ErrorMessage = "ISBN must be in the format 123-1234567890")]
        public string ISBN { get; set; }
    }
}

For CRUD operations, inject the IScopeProvider into your repository and use Database via a scope:

5. Add The Repositories

/Repositories/BookRepository.cs

using HighlyDeveloped.Core.Models;
using NPoco;
using System.Collections.Generic;
using System.Linq;
using Umbraco.Core.Scoping;

namespace HighlyDeveloped.Core.Repositories
{
    public class BookRepository
    {
        private readonly IScopeProvider _scopeProvider;

        public BookRepository(IScopeProvider scopeProvider)
        {
            _scopeProvider = scopeProvider;
        }

        public IEnumerable<Book> GetAll()
        {
            using (var scope = _scopeProvider.CreateScope())
            {
                return scope.Database.Fetch<Book>("SELECT * FROM Books");
            }
        }

        public Book GetById(int id)
        {
            using (var scope = _scopeProvider.CreateScope())
            {
                return scope.Database.SingleOrDefault<Book>("SELECT * FROM Books WHERE Id = @0", id);
            }
        }

        public void Insert(Book book)
        {
            using (var scope = _scopeProvider.CreateScope())
            {
                scope.Database.Insert(book);
                scope.Complete();
            }
        }

        public void Update(Book book)
        {
            using (var scope = _scopeProvider.CreateScope())
            {
                scope.Database.Update(book);
                scope.Complete();
            }
        }

        public void Delete(int id)
        {
            using (var scope = _scopeProvider.CreateScope())
            {
                scope.Database.Execute("DELETE FROM Books WHERE Id = @0", id);
                scope.Complete();
            }
        }

        public (IEnumerable<Book> Books, int TotalCount) Search(string term, int page, int pageSize)
        {
            using (var scope = _scopeProvider.CreateScope())
            {
                var hasTerm = !string.IsNullOrWhiteSpace(term);
                var where = hasTerm
                    ? "WHERE Title LIKE @0 OR Author LIKE @0 OR ISBN LIKE @0"
                    : "";

                var query = $"SELECT * FROM Books {where} ORDER BY Id DESC OFFSET @{(hasTerm ? 1 : 0)} ROWS FETCH NEXT @{(hasTerm ? 2 : 1)} ROWS ONLY";
                var countQuery = $"SELECT COUNT(*) FROM Books {where}";

                var parameters = hasTerm
                    ? new object[] { $"%{term}%", (page - 1) * pageSize, pageSize }
                    : new object[] { (page - 1) * pageSize, pageSize };

                var books = scope.Database.Fetch<Book>(query, parameters);
                var count = scope.Database.ExecuteScalar<int>(countQuery, hasTerm ? new object[] { $"%{term}%" } : null);

                return (books, count);
            }
        }

    }
}

Service: Business Logic Layer

6. Add The Services

/Services/BookService.cs

using HighlyDeveloped.Core.Models;
using HighlyDeveloped.Core.Repositories;
using System.Collections.Generic;

namespace HighlyDeveloped.Core.Services
{
    public class BookService
    {
        private readonly BookRepository _repo;
        public BookService(BookRepository repo) { _repo = repo; }

        public IEnumerable<Book> GetAll() => _repo.GetAll();
        public Book GetById(int id) => _repo.GetById(id);
        public void Add(Book book) => _repo.Insert(book);
        public void Update(Book book) => _repo.Update(book);
        public void Delete(int id) => _repo.Delete(id);
        public (IEnumerable<Book> Books, int TotalCount) Search(string term, int page, int pageSize)
    => _repo.Search(term, page, pageSize);
    }
}

Add the necessary files inHighlyDeveloped.Web

package.manifest

{
    "dashboards": [
        {
            "alias": "bookManagerDashboard",
            "view": "/App_Plugins/BookManager/dashboard/dashboard.html",
            "sections": [ "content" ],
            "weight": 10,
            "label": "Book Manager"
        }
    ],
    "javascript": [
        "~/App_Plugins/BookManager/dashboard/dashboard.controller.js"

    ],
    "css": [
        "https://www.w3schools.com/w3css/5/w3.css",
        "~/App_Plugins/BookManager/dashboard/dashboard.css"
    ]
}

/dashboard/dashboard.html

<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.8.2/angular.min.js"></script>
<div ng-controller="BookManagerDashboardController as bookManager">
    <div class="w3-container">
        <h2>Book Manager</h2>
        <div class="w3-flex w3-margin-bottom" style="justify-content:space-between" ng-if="!bookManager.editing">
            <button class="btn mb-3" ng-click="bookManager.newBook()">Add New Book</button>
            <div class="w3-flex" style="align-items: center;gap:16px">
                <input ng-model="bookManager.searchTerm" ng-change="bookManager.searchBooks()" ng-show="showSearch" placeholder="Search books..." class="umb-search" />
                <i class="fa fa-search" ng-click="toggleSearch()"></i>
                <button class="btn" ng-click="bookManager.exportBooks()">Export CSV</button>
            </div>
        </div>
    </div>

    <div class="w3-container">
        <!-- Book List -->
        <table class="table table-bordered w3-transparent w3-striped" ng-if="!bookManager.editing">
            <thead>
                <tr>
                    <th>Title</th>
                    <th>Author</th>
                    <th>Year</th>
                    <th>ISBN</th>
                    <th>Actions</th>
                </tr>
            </thead>
            <tbody>
                <tr ng-repeat="book in bookManager.books track by book.Id">
                    <td>{{book.Title}}</td>
                    <td>{{book.Author}}</td>
                    <td>{{book.Year}}</td>
                    <td>{{book.ISBN}}</td>
                    <td>
                        <button class="btn w3-green btn-sm" ng-click="bookManager.editBook(book)">Edit</button>
                        <button class="btn btn-danger btn-sm" ng-click="bookManager.deleteBook(book.Id)">Delete</button>
                    </td>
                </tr>
            </tbody>
        </table>

        <div class="w3-center w3-margin-top" ng-if="!bookManager.editing">
            <div class="w3-bar w3-round-large w3-padding-small w3-display-inline-block">

                <!-- Prev Button -->
                <button class="w3-button w3-green w3-hover-dark-grey w3-round-large"
                        ng-class="{'w3-disabled': bookManager.page === 1}"
                        ng-click="bookManager.goToPage(bookManager.page - 1)">
                    &#x25C0; Prev
                </button>

                <!-- Page Numbers -->
                <span ng-repeat="p in [].constructor(bookManager.totalPages) track by $index">
                    <button class="w3-button w3-round-large w3-margin-right"
                            ng-class="{
                 'w3-green': $index + 1 === bookManager.page,
                 'w3-hover-green': $index + 1 !== bookManager.page
             }"
                            ng-click="bookManager.goToPage($index + 1)">
                        {{$index + 1}}
                    </button>
                </span>

                <!-- Next Button -->
                <button class="w3-button w3-green w3-hover-dark-grey w3-round-large"
                        ng-class="{'w3-disabled': bookManager.page === bookManager.totalPages}"
                        ng-click="bookManager.goToPage(bookManager.page + 1)">
                    Next &#x25B6;
                </button>

            </div>
        </div>
    </div>
    <!-- Book Edit/Create Form -->
    <div class="w3-container" ng-if="bookManager.editing">
        <form name="bookForm"
              ng-app="formApp"
              ng-submit="bookManager.saveBook(bookForm)"
              class="mb-4">

            <h4 class="w3-margin-bottom" ng-if="!bookManager.currentBook.Id">Add Book</h4>
            <h4 class="w3-margin-bottom" ng-if="bookManager.currentBook.Id">Edit Book</h4>

            <!-- Title -->
            <div class="form-group mb-2">
                <label class="w3-text-grey">Title:</label>
                <input type="text"
                       name="Title"
                       ng-model="bookManager.currentBook.Title"
                       ng-required="true"
                       ng-maxlength="150"
                       required
                       class="form-control w3-input w3-border-0 w3-border-bottom w3-round w3-transparent" />
                <span ng-show="(bookForm.Title.$touched || bookForm.$submitted) && bookForm.Title.$error.required" class="text-danger">
                    title required
                </span>
            </div>

            <!-- Author -->
            <div class="form-group mb-2">
                <label class="w3-text-grey">Author:</label>
                <input type="text"
                       name="Author"
                       ng-model="bookManager.currentBook.Author"
                       ng-required="true"
                       ng-maxlength="100"
                       class="form-control w3-input w3-border-0 w3-border-bottom w3-round w3-transparent" />
                <span ng-show="(bookForm.Author.$touched) && bookForm.Author.$error.required" class="text-danger">
                    Author is required.
                </span>
                <span ng-show="bookForm.Author.$error.maxlength" class="text-danger">
                    Author is too long (max 100).
                </span>
            </div>

            <!-- Year -->
            <div class="form-group mb-2">
                <label class="w3-text-grey">Year:</label>
                <input type="number"
                       name="Year"
                       ng-model="bookManager.currentBook.Year"
                       ng-required="true"
                       min="1450"
                       max="2100"
                       class="form-control w3-input w3-border-0 w3-border-bottom w3-round w3-transparent" />
                <span ng-show="(bookForm.Year.$touched || bookForm.$submitted) && bookForm.Year.$error.required" class="text-danger">
                    Year is required.
                </span>
                <span ng-show="bookForm.Year.$error.min || bookForm.Year.$error.max" class="text-danger">
                    Year must be between 1450 and 2100.
                </span>
            </div>

            <!-- ISBN -->
            <div class="form-group w3-margin-bottom">
                <label class="w3-text-grey">ISBN:</label>
                <input type="text"
                       name="ISBN"
                       ng-model="bookManager.currentBook.ISBN"
                       ng-required="true"
                       ng-pattern="/^\d{3}-\d{10}$/"
                       class="form-control w3-input w3-border-0 w3-border-bottom w3-round w3-transparent" />
                <span ng-show="(bookForm.ISBN.$touched || bookForm.$submitted) && bookForm.ISBN.$error.required" class="text-danger">
                    ISBN is required.
                </span>
                <span ng-show="bookForm.ISBN.$error.pattern" class="text-danger">
                    ISBN must be in the format 123-1234567890.
                </span>
            </div>

            <!-- Server-side validation errors -->
            <div ng-if="bookManager.validationErrors && bookManager.validationErrors.length > 0" class="alert alert-danger">
                <ul>
                    <li ng-repeat="error in bookManager.validationErrors track by $index">{{ error }}</li>
                </ul>
            </div>

            <!-- Buttons -->
            <button type="button"
                    class="btn w3-green"
                    ng-click="bookManager.saveBook(bookForm)">
                Save
            </button>
            <button type="button"
                    class="btn btn-secondary"
                    ng-click="bookManager.cancel()">
                Cancel
            </button>
        </form>
    </div>

</div>

/dashboard/dashboard.controller.js

angular.module("umbraco")
    .controller("BookManagerDashboardController", function ($scope, $http) {

        var vm = this;
        vm.books = [];
        vm.editing = false;
        vm.currentBook = {};
        vm.validationErrors = null;
        vm.searchTerm = "";
        vm.page = 1;
        vm.pageSize = 5;
        vm.totalCount = 0;
        vm.totalPages = 1;

        vm.searchBooks = function () {
            $http.get("/umbraco/backoffice/api/BookApi/Search", {
                params: {
                    term: vm.searchTerm,
                    page: vm.page,
                    pageSize: vm.pageSize
                }
            }).then(function (res) {
                vm.books = res.data.books;
                vm.totalCount = res.data.totalCount;
                vm.totalPages = Math.ceil(vm.totalCount / vm.pageSize);
            });
        };

        vm.goToPage = function (page) {
            if (page < 1 || page > vm.totalPages) return;
            vm.page = page;
            vm.searchBooks();
        };

        vm.loadBooks = function () {
            vm.page = 1;
            vm.searchBooks();
            $http.get("/umbraco/backoffice/api/BookApi/GetAll").then(function (res) {
                vm.books = res.data;
            });
        };

        vm.newBook = function () {
            vm.editing = true;
            vm.currentBook = {};
            vm.validationErrors = null;
        };

        vm.editBook = function (book) {
            vm.editing = true;
            vm.currentBook = angular.copy(book);
            vm.validationErrors = null;
        };

        // Accept the form as a parameter so we can touch all fields if needed
        vm.saveBook = function (form) {
            // Touch all fields if invalid (shows validation errors)
            if (form.$invalid) {
                angular.forEach(form.$error, function (fields) {
                    angular.forEach(fields, function (field) {
                        field.$setTouched();
                    });
                });
                return;
            }
            $http.post("/umbraco/backoffice/api/BookApi/PostSave", vm.currentBook)
                .then(function () {
                    vm.editing = false;
                    vm.loadBooks();
                    vm.validationErrors = null;
                })
                .catch(function (err) {
                    if (err.data && err.data.ModelState) {
                        var errors = [];
                        angular.forEach(err.data.ModelState, function (messages) {
                            messages.forEach(function (message) {
                                errors.push(message);
                            });
                        });
                        vm.validationErrors = errors;
                    } else if (err.data && err.data.Message) {
                        vm.validationErrors = [err.data.Message];
                    } else {
                        vm.validationErrors = ["An unknown error occurred."];
                    }
                });
        };

        vm.deleteBook = function (id) {
            $http.post("/umbraco/backoffice/api/BookApi/PostDelete", id).then(function () {
                vm.loadBooks();
            });
        };

        vm.cancel = function () {
            vm.editing = false;
            vm.validationErrors = null;
        };
        vm.exportBooks = function () {
            var url = "/umbraco/backoffice/api/BookApi/ExportCsv";
            window.open(url, '_blank');
        };
        vm.loadBooks();

        $scope.showSearch = false;

        $scope.toggleSearch = function () {
            $scope.showSearch = !$scope.showSearch;
        };
    });

/dashboard/dashboard.css

.umb-search {
    width: auto;
    position: relative;
    top: 0;
    left: 0;
    transform: none;
    box-shadow: none;
    border: 0;
    border-bottom: 1px solid black;
    border-radius: 0;
    background-color: transparent;
}

.fa-search {
    cursor: pointer
}
span.text-danger {
    color: red !important;
}

⚠️ **GitHub.com Fallback** ⚠️