category / part of page

Category editing pages done; page list and delete are done
This commit is contained in:
Daniel J. Summers 2016-07-11 22:04:18 -05:00
parent 2e8d002e30
commit 08ee8990d3
14 changed files with 620 additions and 9 deletions

View File

@ -1,7 +1,15 @@
module myWebLog.Data.Category
open FSharp.Interop.Dynamic
open myWebLog.Entities
open Rethink
open System.Dynamic
/// Shorthand to get a category by Id and filter by web log Id
let private category webLogId catId =
table Table.Category
|> get catId
|> filter (fun c -> upcast c.["webLogId"].Eq(webLogId))
/// Sort categories by their name, with their children sorted below them, including an indent level
let sortCategories categories =
@ -34,3 +42,91 @@ let countCategories conn webLogId =
|> optArg "index" "webLogId"
|> count
|> runAtomAsync<int> conn
/// Get a specific category by its Id
let tryFindCategory conn webLogId catId : Category option =
match category webLogId catId
|> runAtomAsync<Category> conn
|> box with
| null -> None
| cat -> Some <| unbox cat
/// Save a category
let saveCategory conn webLogId (cat : Category) =
match cat.id with
| "new" -> let newCat = { cat with id = string <| System.Guid.NewGuid()
webLogId = webLogId }
table Table.Category
|> insert newCat
|> runResultAsync conn
|> ignore
newCat.id
| _ -> let upd8 = ExpandoObject()
upd8?name <- cat.name
upd8?slug <- cat.slug
upd8?description <- cat.description
upd8?parentId <- cat.parentId
category webLogId cat.id
|> update upd8
|> runResultAsync conn
|> ignore
cat.id
/// Remove a category from a given parent
let removeCategoryFromParent conn webLogId parentId catId =
match tryFindCategory conn webLogId parentId with
| Some parent -> let upd8 = ExpandoObject()
upd8?children <- parent.children
|> List.filter (fun ch -> ch <> catId)
category webLogId parentId
|> update upd8
|> runResultAsync conn
|> ignore
| None -> ()
/// Add a category to a given parent
let addCategoryToParent conn webLogId parentId catId =
match tryFindCategory conn webLogId parentId with
| Some parent -> let upd8 = ExpandoObject()
upd8?children <- catId :: parent.children
category webLogId parentId
|> update upd8
|> runResultAsync conn
|> ignore
| None -> ()
/// Delete a category
let deleteCategory conn cat =
// Remove the category from its parent
match cat.parentId with
| Some parentId -> removeCategoryFromParent conn cat.webLogId parentId cat.id
| None -> ()
// Move this category's children to its parent
let newParent = ExpandoObject()
newParent?parentId <- cat.parentId
cat.children
|> List.iter (fun childId -> category cat.webLogId childId
|> update newParent
|> runResultAsync conn
|> ignore)
// Remove the category from posts where it is assigned
table Table.Post
|> getAll [| cat.webLogId |]
|> optArg "index" "webLogId"
|> filter (fun p -> upcast p.["categoryIds"].Contains(cat.id))
|> runCursorAsync<Post> conn
|> Seq.toList
|> List.iter (fun post -> let newCats = ExpandoObject()
newCats?categoryIds <- post.categoryIds
|> List.filter (fun c -> c <> cat.id)
table Table.Post
|> get post.id
|> update newCats
|> runResultAsync conn
|> ignore)
// Now, delete the category
table Table.Category
|> get cat.id
|> delete
|> runResultAsync conn
|> ignore

View File

@ -166,6 +166,8 @@ with
type Category = {
/// The Id
id : string
/// The Id of the web log to which this category belongs
webLogId : string
/// The displayed name
name : string
/// The slug (used in category URLs)
@ -180,7 +182,8 @@ type Category = {
with
/// An empty category
static member empty =
{ id = ""
{ id = "new"
webLogId = ""
name = ""
slug = ""
description = None

View File

@ -3,11 +3,15 @@
open myWebLog.Entities
open Rethink
/// Shorthand to get the page by its Id, filtering on web log Id
let private page webLogId pageId =
table Table.Page
|> get pageId
|> filter (fun p -> upcast p.["webLogId"].Eq(webLogId))
/// Get a page by its Id
let tryFindPage conn webLogId pageId : Page option =
match table Table.Page
|> get pageId
|> filter (fun p -> upcast p.["webLogId"].Eq(webLogId))
match page webLogId pageId
|> runAtomAsync<Page> conn
|> box with
| null -> None
@ -15,9 +19,7 @@ let tryFindPage conn webLogId pageId : Page option =
/// Get a page by its Id (excluding revisions)
let tryFindPageWithoutRevisions conn webLogId pageId : Page option =
match table Table.Page
|> get pageId
|> filter (fun p -> upcast p.["webLogId"].Eq(webLogId))
match page webLogId pageId
|> without [| "revisions" |]
|> runAtomAsync<Page> conn
|> box with
@ -40,3 +42,19 @@ let countPages conn webLogId =
|> optArg "index" "webLogId"
|> count
|> runAtomAsync<int> conn
/// Get a list of all pages (excludes page text and revisions)
let findAllPages conn webLogId =
table Table.Page
|> getAll [| webLogId |]
|> orderBy (fun p -> upcast p.["title"])
|> without [| "text"; "revisions" |]
|> runCursorAsync<Page> conn
|> Seq.toList
/// Delete a page
let deletePage conn webLogId pageId =
page webLogId pageId
|> delete
|> runResultAsync conn
|> ignore

View File

@ -60,6 +60,15 @@ namespace myWebLog {
}
}
/// <summary>
/// Looks up a localized string similar to Action.
/// </summary>
public static string Action {
get {
return ResourceManager.GetString("Action", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Added.
/// </summary>
@ -114,6 +123,24 @@ namespace myWebLog {
}
}
/// <summary>
/// Looks up a localized string similar to Category.
/// </summary>
public static string Category {
get {
return ResourceManager.GetString("Category", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Are you sure you wish to delete the category.
/// </summary>
public static string CategoryDeleteWarning {
get {
return ResourceManager.GetString("CategoryDeleteWarning", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Dashboard.
/// </summary>
@ -141,6 +168,15 @@ namespace myWebLog {
}
}
/// <summary>
/// Looks up a localized string similar to Description.
/// </summary>
public static string Description {
get {
return ResourceManager.GetString("Description", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Edit.
/// </summary>
@ -177,6 +213,15 @@ namespace myWebLog {
}
}
/// <summary>
/// Looks up a localized string similar to Last Updated.
/// </summary>
public static string LastUpdated {
get {
return ResourceManager.GetString("LastUpdated", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to List All.
/// </summary>
@ -204,6 +249,33 @@ namespace myWebLog {
}
}
/// <summary>
/// Looks up a localized string similar to Deleted category {0} successfully.
/// </summary>
public static string MsgCategoryDeleted {
get {
return ResourceManager.GetString("MsgCategoryDeleted", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} category successfully.
/// </summary>
public static string MsgCategoryEditSuccess {
get {
return ResourceManager.GetString("MsgCategoryEditSuccess", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Deleted page successfully.
/// </summary>
public static string MsgPageDeleted {
get {
return ResourceManager.GetString("MsgPageDeleted", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0}{1} post successfully.
/// </summary>
@ -213,6 +285,15 @@ namespace myWebLog {
}
}
/// <summary>
/// Looks up a localized string similar to Name.
/// </summary>
public static string Name {
get {
return ResourceManager.GetString("Name", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Newer Posts.
/// </summary>
@ -231,6 +312,15 @@ namespace myWebLog {
}
}
/// <summary>
/// Looks up a localized string similar to No Parent.
/// </summary>
public static string NoParent {
get {
return ResourceManager.GetString("NoParent", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Older Posts.
/// </summary>
@ -240,6 +330,15 @@ namespace myWebLog {
}
}
/// <summary>
/// Looks up a localized string similar to Are you sure you wish to delete the page.
/// </summary>
public static string PageDeleteWarning {
get {
return ResourceManager.GetString("PageDeleteWarning", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Page #.
/// </summary>
@ -258,6 +357,15 @@ namespace myWebLog {
}
}
/// <summary>
/// Looks up a localized string similar to Parent Category.
/// </summary>
public static string ParentCategory {
get {
return ResourceManager.GetString("ParentCategory", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Permalink.
/// </summary>
@ -348,6 +456,15 @@ namespace myWebLog {
}
}
/// <summary>
/// Looks up a localized string similar to Slug.
/// </summary>
public static string Slug {
get {
return ResourceManager.GetString("Slug", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to starting with.
/// </summary>

View File

@ -234,4 +234,43 @@
<data name="View" xml:space="preserve">
<value>View</value>
</data>
<data name="Action" xml:space="preserve">
<value>Action</value>
</data>
<data name="Category" xml:space="preserve">
<value>Category</value>
</data>
<data name="CategoryDeleteWarning" xml:space="preserve">
<value>Are you sure you wish to delete the category</value>
</data>
<data name="Description" xml:space="preserve">
<value>Description</value>
</data>
<data name="LastUpdated" xml:space="preserve">
<value>Last Updated</value>
</data>
<data name="MsgCategoryDeleted" xml:space="preserve">
<value>Deleted category {0} successfully</value>
</data>
<data name="MsgCategoryEditSuccess" xml:space="preserve">
<value>{0} category successfully</value>
</data>
<data name="MsgPageDeleted" xml:space="preserve">
<value>Deleted page successfully</value>
</data>
<data name="Name" xml:space="preserve">
<value>Name</value>
</data>
<data name="NoParent" xml:space="preserve">
<value>No Parent</value>
</data>
<data name="PageDeleteWarning" xml:space="preserve">
<value>Are you sure you wish to delete the page</value>
</data>
<data name="ParentCategory" xml:space="preserve">
<value>Parent Category</value>
</data>
<data name="Slug" xml:space="preserve">
<value>Slug</value>
</data>
</root>

View File

@ -0,0 +1,91 @@
namespace myWebLog
open myWebLog.Data.Category
open myWebLog.Entities
open Nancy
open Nancy.ModelBinding
open Nancy.Security
open RethinkDb.Driver.Net
/// Handle /category and /categories URLs
type CategoryModule(conn : IConnection) as this =
inherit NancyModule()
do
this.Get .["/categories" ] <- fun _ -> upcast this.CategoryList ()
this.Get .["/category/{id}/edit" ] <- fun parms -> upcast this.EditCategory (downcast parms)
this.Post .["/category/{id}/edit" ] <- fun parms -> upcast this.SaveCategory (downcast parms)
this.Delete.["/category/{id}/delete"] <- fun parms -> upcast this.DeleteCategory (downcast parms)
/// Display a list of categories
member this.CategoryList () =
this.RequiresAccessLevel AuthorizationLevel.Administrator
let model = CategoryListModel(this.Context, this.WebLog,
(getAllCategories conn this.WebLog.id
|> List.map (fun cat -> IndentedCategory.create cat (fun _ -> false))))
this.View.["/admin/category/list", model]
/// Edit a category
member this.EditCategory (parameters : DynamicDictionary) =
this.RequiresAccessLevel AuthorizationLevel.Administrator
let catId : string = downcast parameters.["id"]
match (match catId with
| "new" -> Some Category.empty
| _ -> tryFindCategory conn this.WebLog.id catId) with
| Some cat -> let model = CategoryEditModel(this.Context, this.WebLog, cat)
let cats = getAllCategories conn this.WebLog.id
|> List.map (fun cat -> IndentedCategory.create cat
(fun c -> c = defaultArg (fst cat).parentId ""))
model.categories <- getAllCategories conn this.WebLog.id
|> List.map (fun cat -> IndentedCategory.create cat
(fun c -> c = defaultArg (fst cat).parentId ""))
this.View.["admin/category/edit", model]
| None -> this.NotFound ()
/// Save a category
member this.SaveCategory (parameters : DynamicDictionary) =
this.ValidateCsrfToken ()
this.RequiresAccessLevel AuthorizationLevel.Administrator
let catId : string = downcast parameters.["id"]
let form = this.Bind<CategoryForm> ()
let oldCat = match catId with
| "new" -> Some Category.empty
| _ -> tryFindCategory conn this.WebLog.id catId
match oldCat with
| Some old -> let cat = { old with name = form.name
slug = form.slug
description = match form.description with | "" -> None | d -> Some d
parentId = match form.parentId with | "" -> None | p -> Some p }
let newCatId = saveCategory conn this.WebLog.id cat
match old.parentId = cat.parentId with
| true -> ()
| _ -> match old.parentId with
| Some parentId -> removeCategoryFromParent conn this.WebLog.id parentId newCatId
| None -> ()
match cat.parentId with
| Some parentId -> addCategoryToParent conn this.WebLog.id parentId newCatId
| None -> ()
let model = MyWebLogModel(this.Context, this.WebLog)
{ level = Level.Info
message = System.String.Format
(Resources.MsgCategoryEditSuccess,
(match catId with | "new" -> Resources.Added | _ -> Resources.Updated))
details = None }
|> model.addMessage
this.Redirect (sprintf "/category/%s/edit" newCatId) model
| None -> this.NotFound ()
/// Delete a category
member this.DeleteCategory (parameters : DynamicDictionary) =
this.ValidateCsrfToken ()
this.RequiresAccessLevel AuthorizationLevel.Administrator
let catId : string = downcast parameters.["id"]
match tryFindCategory conn this.WebLog.id catId with
| Some cat -> deleteCategory conn cat
let model = MyWebLogModel(this.Context, this.WebLog)
{ level = Level.Info
message = System.String.Format(Resources.MsgCategoryDeleted, cat.name)
details = None }
|> model.addMessage
this.Redirect "/categories" model
| None -> this.NotFound ()

View File

@ -0,0 +1,39 @@
namespace myWebLog
open myWebLog.Data.Page
open myWebLog.Entities
open Nancy
open Nancy.Security
open RethinkDb.Driver.Net
/// Handle /pages and /page URLs
type PageModule(conn : IConnection) as this =
inherit NancyModule()
do
this.Get .["/pages" ] <- fun _ -> upcast this.PageList ()
this.Delete.["/page/{id}/delete"] <- fun parms -> upcast this.DeletePage (downcast parms)
/// List all pages
member this.PageList () =
this.RequiresAccessLevel AuthorizationLevel.Administrator
let model = PagesModel(this.Context, this.WebLog, findAllPages conn this.WebLog.id)
model.pageTitle <- Resources.Pages
this.View.["admin/page/list", model]
// TODO: edit goes here!
/// Delete a page
member this.DeletePage (parameters : DynamicDictionary) =
this.ValidateCsrfToken ()
this.RequiresAccessLevel AuthorizationLevel.Administrator
let pageId : string = downcast parameters.["id"]
match tryFindPageWithoutRevisions conn this.WebLog.id pageId with
| Some page -> deletePage conn page.webLogId page.id
let model = MyWebLogModel(this.Context, this.WebLog)
{ level = Level.Info
message = Resources.MsgPageDeleted
details = None }
|> model.addMessage
this.Redirect "/pages" model
| None -> this.NotFound ()

View File

@ -92,6 +92,60 @@ type DashboardModel(ctx, webLog) =
member val categories = 0 with get, set
// ---- Category models ----
type IndentedCategory = {
category : Category
indent : int
selected : bool
}
with
/// Create an indented category
static member create (cat : Category * int) (isSelected : string -> bool) =
{ category = fst cat
indent = snd cat
selected = isSelected (fst cat).id }
/// Display name for a category on the list page, complete with indents
member this.listName = sprintf "%s%s" (String.replicate this.indent " &#xabb; &nbsp; ") this.category.name
/// Display for this category as an option within a select box
member this.option =
seq {
yield sprintf "<option value=\"%s\"" this.category.id
yield (match this.selected with | true -> """ selected="selected">""" | _ -> ">")
yield String.replicate this.indent " &nbsp; &nbsp; "
yield this.category.name
yield "</option>"
}
|> String.concat ""
/// Model for the list of categories
type CategoryListModel(ctx, webLog, categories) =
inherit MyWebLogModel(ctx, webLog)
/// The categories
member this.categories : IndentedCategory list = categories
/// Form for editing a category
type CategoryForm(category : Category) =
new() = CategoryForm(Category.empty)
/// The name of the category
member val name = category.name with get, set
/// The slug of the category (used in category URLs)
member val slug = category.slug with get, set
/// The description of the category
member val description = defaultArg category.description "" with get, set
/// The parent category for this one
member val parentId = defaultArg category.parentId "" with get, set
/// Model for editing a category
type CategoryEditModel(ctx, webLog, category) =
inherit MyWebLogModel(ctx, webLog)
/// The form with the category information
member val form = CategoryForm(category) with get, set
/// The categories
member val categories : IndentedCategory list = List.empty with get, set
// ---- Page models ----
/// Model for page display
@ -102,6 +156,12 @@ type PageModel(ctx, webLog, page) =
member this.page : Page = page
/// Model for page list display
type PagesModel(ctx, webLog, pages) =
inherit MyWebLogModel(ctx, webLog)
/// The pages
member this.pages : Page list = pages
// ---- Post models ----
/// Model for post display

View File

@ -55,6 +55,8 @@
<Compile Include="ViewModels.fs" />
<Compile Include="ModuleExtensions.fs" />
<Compile Include="AdminModule.fs" />
<Compile Include="CategoryModule.fs" />
<Compile Include="PageModule.fs" />
<Compile Include="PostModule.fs" />
<Compile Include="App.fs" />
<Content Include="packages.config" />

View File

@ -69,7 +69,10 @@
<Content Include="content\scripts\tinymce-init.js" />
<Content Include="content\styles\admin.css" />
<Content Include="views\admin\admin-layout.html" />
<Content Include="views\admin\category\edit.html" />
<Content Include="views\admin\category\list.html" />
<Content Include="views\admin\dashboard.html" />
<Content Include="views\admin\page\list.html" />
<Content Include="views\admin\post\edit.html" />
<Content Include="views\admin\post\list.html" />
<Content Include="views\default\index-content.html" />

View File

@ -0,0 +1,52 @@
@Master['admin/admin-layout']
@Section['Content']
<form action="/category/@Model.category.id/edit" method="post">
@AntiForgeryToken
<div class="row">
<div class="col-xs-12">
<div class="form-group">
<label class="control-label" for="name">@Translate.Name</label>
<input type="text" class="form-control" id="name" name="name" value="@Model.form.name" />
</div>
</div>
</div>
<div class="row">
<div class="col-xs-8">
<div class="form-group">
<label class="control-label" for="slug">@Translate.Slug</label>
<input type="text" class="form-control" id="slug" name="slug" value="@Model.form.slug}" />
</div>
<div class="form-group">
<label class="control-label" for="description">@Translate.Description</label>
<textarea class="form-control" rows="4" id="description" name="description">@Model.form.description</textarea>
</div>
</div>
<div class="col-xs-4">
<div class="form-group">
<label class="control-label" for="parentId">@Translate.ParentCategory</label>
<select class="form-control" id="parentId" name="parentId">
<option value="">&mdash; @Translate.NoParent &mdash;</option>
@Each.categories
@Current.option
@EndEach
</select>
</div>
<br />
<p class="text-center">
<button class="btn btn-primary" type="submit">
<i class="fa fa-floppy-o"></i> @Translate.Save
</button>
</p>
</div>
</div>
</form>
@EndSection
@Section['Scripts']
<script type="text/javascript">
/* <![CDATA[ */
$(document).ready(function () { $("#name").focus() })
/* ]] */
</script>
@EndSection

View File

@ -0,0 +1,44 @@
@Master['admin/admin-layout']
@Section['Content']
<div class="row">
<p><a class="btn btn-primary" href="/category/new/edit"><i class="fa fa-plus"></i> @Translate.AddNew"</a></p>
</div>
<div class="row">
<table class="table table-hover">
<tr>
<th>@Translate.Action</th>
<th>@Translate.Category</th>
<th>@Translate.Description</th>
</tr>
@Each.categories
<tr>
<td>
<a href="/category/@Current.category.id/edit">@Translate.Edit</a> &nbsp;
<a href="javascript:void(0)" onclick="deleteCategory('@Current.category.id', '@Current.category.name')">
@Translate.Delete
</a>
</td>
<td>@Current.listName</td>
<td>@Current.category.description</td>
</tr>
@EndEach
</table>
</div>
<form method="delete" id="deleteForm">
@AntiForgeryToken
</form>
@EndSection
@Section['Scripts']
<script type="text/javascript">
/* <![CDATA[ */
function deleteCategory(id, title) {
if (confirm('@Translate.CategoryDeleteWarning "' + title + '"?')) {
document.getElementById("deleteForm").action = "/category/" + id + "/delete"
document.getElementById("deleteForm").submit()
}
}
/* ]] */
</script>
@EndSection

View File

@ -13,7 +13,7 @@
<div class="col-xs-6">
<h3>@Translate.Pages &nbsp;<span class="badge">@Model.pages</span></h3>
<p>
<a href="/pages/list"><i class="fa fa-list-ul"></i> @Translate.ListAll</a>
<a href="/pages"><i class="fa fa-list-ul"></i> @Translate.ListAll</a>
&nbsp; &nbsp;
<a href="/page/new/edit"><i class="fa fa-plus"></i> @Translate.AddNew</a>
</p>
@ -23,7 +23,7 @@
<div class="col-xs-6">
<h3>@Translate.Categories &nbsp;<span class="badge">@Model.categories</span></h3>
<p>
<a href="/categories/list"><i class="fa fa-list.ul"></i> @Translate.ListAll</a>
<a href="/categories"><i class="fa fa-list.ul"></i> @Translate.ListAll</a>
&nbsp; &nbsp;
<a href="/category/new/edit"><i class="fa fa-plus"></i> @Translate.AddNew</a>
</p>

View File

@ -0,0 +1,47 @@
@Master['admin/admin-layout']
@Section['Content']
<div class="row">
<p><a class="btn btn-primary" href="/page/new/edit"><i class="fa fa-plus"></i> @Translate.AddNew</a></p>
</div>
<div class="row">
<table class="table table-hover">
<tr>
<th>@Translate.Title</th>
<th>@Translate.LastUpdated</th>
</tr>
@Each.pages
<tr>
<td>
@Current.title<br />
<a href="/@Current.permalink">@Translate.View</a> &nbsp;
<a href="/page/@Current.id}/edit">@Translate.Edit</a> &nbsp;
<a href="javascript:void(0)" onclick="deletePage('@Current.id', '@Current.title')">@Translate.Delete</a>
</td>
<td>
<!-- // TODO: make the formatting stuff work in a loop
=theDate.format('MMM D, YYYY')
br
#{__("at")} #{theDate.format('h:mma')} -->
</td>
</tr>
@EndEach
</table>
</div>
<form method="delete" id="deleteForm">
@AntiForgeryToken
</form>
@EndSection
@Section['Scripts']
<script type="text/javascript">
/* <![CDATA[ */
function deletePage(id, title) {
if (confirm('@Translate.PageDeleteWarning "' + title + '"?')) {
document.getElementById("deleteForm").action = "/page/" + id + "/delete"
document.getElementById("deleteForm").submit()
}
}
/* ]] */
</script>
@EndSection