WIP on chapter edit page (#6)

This commit is contained in:
Daniel J. Summers 2024-02-08 22:48:46 -05:00
parent 90bca34be3
commit 12b23eab46
9 changed files with 284 additions and 31 deletions

View File

@ -190,7 +190,16 @@ type Chapter = {
/// A location that applies to a chapter
Location: Location option
}
} with
/// An empty chapter
static member Empty =
{ StartTime = Duration.Zero
Title = None
ImageUrl = None
IsHidden = None
EndTime = None
Location = None }
open NodaTime.Text

View File

@ -72,9 +72,10 @@ type DisplayCategory = {
PostCount: int
}
/// A display version of an episode chapter
type DisplayChapter = {
/// The start time of the chapter (HH:MM:SS.FF format)
/// The start time of the chapter (H:mm:ss.FF format)
StartTime: string
/// The title of the chapter
@ -86,7 +87,7 @@ type DisplayChapter = {
/// Whether this chapter should be displayed in podcast players
IsHidden: bool
/// The end time of the chapter (HH:MM:SS.FF format)
/// The end time of the chapter (H:mm:ss.FF format)
EndTime: string
/// The name of a location
@ -101,15 +102,15 @@ type DisplayChapter = {
/// Create a display chapter from a chapter
static member FromChapter (chapter: Chapter) =
let pattern = DurationPattern.CreateWithInvariantCulture("H:mm:ss.ff")
{ StartTime = pattern.Format(chapter.StartTime)
let pattern = DurationPattern.CreateWithInvariantCulture "H:mm:ss.FF"
{ StartTime = pattern.Format chapter.StartTime
Title = defaultArg chapter.Title ""
ImageUrl = defaultArg chapter.ImageUrl ""
IsHidden = defaultArg chapter.IsHidden false
EndTime = chapter.EndTime |> Option.map pattern.Format |> Option.defaultValue ""
LocationName = chapter.Location |> Option.map (fun l -> l.Name) |> Option.defaultValue ""
LocationGeo = chapter.Location |> Option.map (fun l -> l.Geo) |> Option.flatten |> Option.defaultValue ""
LocationOsm = chapter.Location |> Option.map (fun l -> l.Osm) |> Option.flatten |> Option.defaultValue "" }
EndTime = chapter.EndTime |> Option.map pattern.Format |> Option.defaultValue ""
LocationName = chapter.Location |> Option.map _.Name |> Option.defaultValue ""
LocationGeo = chapter.Location |> Option.map _.Geo |> Option.flatten |> Option.defaultValue ""
LocationOsm = chapter.Location |> Option.map _.Osm |> Option.flatten |> Option.defaultValue "" }
/// A display version of a custom feed definition
@ -360,6 +361,78 @@ type EditCategoryModel = {
this.CategoryId = "new"
/// View model to add/edit an episode chapter
type EditChapterModel = {
/// The ID of the post to which the chapter belongs
PostId: string
/// The index in the chapter list (-1 means new)
Index: int
/// The start time of the chapter (H:mm:ss.FF format)
StartTime: string
/// The title of the chapter
Title: string
/// An image to display for this chapter
ImageUrl: string
/// Whether this chapter should be displayed in podcast players
IsHidden: bool
/// The end time of the chapter (HH:MM:SS.FF format)
EndTime: string
/// The name of a location
LocationName: string
/// The geographic coordinates of the location
LocationGeo: string
/// An OpenStreetMap query for this location
LocationOsm: string
} with
/// Create a display chapter from a chapter
static member FromChapter (postId: PostId) idx chapter =
let it = DisplayChapter.FromChapter chapter
{ PostId = string postId
Index = idx
StartTime = it.StartTime
Title = it.Title
ImageUrl = it.ImageUrl
IsHidden = it.IsHidden
EndTime = it.EndTime
LocationName = it.LocationName
LocationGeo = it.LocationGeo
LocationOsm = it.LocationOsm }
/// Create a chapter from the values in this model
member this.ToChapter () =
let parseDuration name value =
let pattern =
match value |> Seq.fold (fun count chr -> if chr = ':' then count + 1 else count) 0 with
| 0 -> "S"
| 1 -> "MM:ss"
| 2 -> "H:mm:ss"
| _ -> invalidArg name "Max time format is H:mm:ss"
|> function
| it -> DurationPattern.CreateWithInvariantCulture $"{it}.FFFFFFFFF"
let result = pattern.Parse value
if result.Success then result.Value else raise result.Exception
let location =
match noneIfBlank this.LocationName with
| None -> None
| Some name -> Some { Name = name; Geo = noneIfBlank this.LocationGeo; Osm = noneIfBlank this.LocationOsm }
{ StartTime = parseDuration (nameof this.StartTime) this.StartTime
Title = noneIfBlank this.Title
ImageUrl = noneIfBlank this.ImageUrl
IsHidden = if this.IsHidden then Some true else None
EndTime = noneIfBlank this.EndTime |> Option.map (parseDuration (nameof this.EndTime))
Location = location }
/// View model to edit a custom RSS feed
[<CLIMutable; NoComparison; NoEquality>]
type EditCustomFeedModel = {
@ -1033,14 +1106,15 @@ type ManageChaptersModel = {
/// The title of the post for which chapters are being edited
Title: string
Chapters: Chapter array
/// The chapters for the post
Chapters: DisplayChapter array
} with
/// Create a model from a post and its episode's chapters
static member Create (post: Post) =
{ Id = string post.Id
Title = post.Title
Chapters = Array.ofList post.Episode.Value.Chapters.Value }
Chapters = post.Episode.Value.Chapters.Value |> List.map DisplayChapter.FromChapter |> Array.ofList }
/// View model to manage permalinks

View File

@ -33,6 +33,39 @@ let addBaseToRelativeUrlsTests = testList "PublicHelpers.addBaseToRelativeUrls"
}
]
/// Unit tests for the DisplayChapter type
let displayChapterTests = testList "DisplayChapter.FromChapter" [
test "succeeds for a minimally-filled chapter" {
let chapter = DisplayChapter.FromChapter { Chapter.Empty with StartTime = Duration.FromSeconds 322L }
Expect.equal chapter.StartTime "0:05:22" "Start time not filled/formatted properly"
Expect.equal chapter.Title "" "Title not filled properly"
Expect.equal chapter.ImageUrl "" "Image URL not filled properly"
Expect.isFalse chapter.IsHidden "Is hidden flag not filled properly"
Expect.equal chapter.EndTime "" "End time not filled properly"
Expect.equal chapter.LocationName "" "Location name not filled properly"
Expect.equal chapter.LocationGeo "" "Location geo URL not filled properly"
Expect.equal chapter.LocationOsm "" "Location OSM query not filled properly"
}
test "succeeds for a fully-filled chapter" {
let chapter =
DisplayChapter.FromChapter
{ StartTime = Duration.FromSeconds 7201.43242
Title = Some "My Test Chapter"
ImageUrl = Some "two-hours-in.jpg"
IsHidden = Some true
EndTime = Some (Duration.FromSeconds 7313.788)
Location = Some { Name = "Over Here"; Geo = Some "geo:23432"; Osm = Some "SF98fFSu-8" } }
Expect.equal chapter.StartTime "2:00:01.43" "Start time not filled/formatted properly"
Expect.equal chapter.Title "My Test Chapter" "Title not filled properly"
Expect.equal chapter.ImageUrl "two-hours-in.jpg" "Image URL not filled properly"
Expect.isTrue chapter.IsHidden "Is hidden flag not filled properly"
Expect.equal chapter.EndTime "2:01:53.78" "End time not filled/formatted properly"
Expect.equal chapter.LocationName "Over Here" "Location name not filled properly"
Expect.equal chapter.LocationGeo "geo:23432" "Location geo URL not filled properly"
Expect.equal chapter.LocationOsm "SF98fFSu-8" "Location OSM query not filled properly"
}
]
/// Unit tests for the DisplayCustomFeed type
let displayCustomFeedTests = testList "DisplayCustomFeed.FromFeed" [
test "succeeds for a feed for an existing category" {
@ -1071,6 +1104,29 @@ let editUserModelTests = testList "EditUserModel" [
]
]
/// Unit tests for the ManageChaptersModel type
let manageChaptersModelTests = testList "ManageChaptersModel.Create" [
test "succeeds" {
let model =
ManageChaptersModel.Create
{ Post.Empty with
Id = PostId "test-post"
Title = "Look at all these chapters"
Episode = Some
{ Episode.Empty with
Chapters = Some
[ { Chapter.Empty with StartTime = Duration.FromSeconds 18L }
{ Chapter.Empty with StartTime = Duration.FromSeconds 36L }
{ Chapter.Empty with StartTime = Duration.FromSeconds 180.7 } ] } }
Expect.equal model.Id "test-post" "ID not filled properly"
Expect.equal model.Title "Look at all these chapters" "Title not filled properly"
Expect.hasLength model.Chapters 3 "There should be three chapters"
Expect.equal model.Chapters[0].StartTime "0:00:18" "First chapter not filled properly"
Expect.equal model.Chapters[1].StartTime "0:00:36" "Second chapter not filled properly"
Expect.equal model.Chapters[2].StartTime "0:03:00.7" "Third chapter not filled properly"
}
]
/// Unit tests for the ManagePermalinksModel type
let managePermalinksModelTests = testList "ManagePermalinksModel" [
test "FromPage succeeds" {
@ -1285,6 +1341,7 @@ let userMessageTests = testList "UserMessage" [
/// All tests in the Domain.ViewModels file
let all = testList "ViewModels" [
addBaseToRelativeUrlsTests
displayChapterTests
displayCustomFeedTests
displayPageTests
displayRevisionTests
@ -1300,6 +1357,7 @@ let all = testList "ViewModels" [
editRssModelTests
editTagMapModelTests
editUserModelTests
manageChaptersModelTests
managePermalinksModelTests
manageRevisionsModelTests
postListItemTests

View File

@ -12,15 +12,19 @@ let postgresOnly = (RethinkDbDataTests.env "PG_ONLY" "0") = "1"
/// Whether any of the data tests are being isolated
let dbOnly = rethinkOnly || sqliteOnly || postgresOnly
/// Whether to only run the unit tests (skip database/integration tests)
let unitOnly = (RethinkDbDataTests.env "UNIT_ONLY" "0") = "1"
let allTests = testList "MyWebLog" [
if not dbOnly then testList "Domain" [ SupportTypesTests.all; DataTypesTests.all; ViewModelsTests.all ]
testList "Data" [
if not dbOnly then ConvertersTests.all
if not dbOnly then UtilsTests.all
if not dbOnly || (dbOnly && rethinkOnly) then RethinkDbDataTests.all
if not dbOnly || (dbOnly && sqliteOnly) then SQLiteDataTests.all
if not dbOnly || (dbOnly && postgresOnly) then PostgresDataTests.all
]
if not unitOnly then
testList "Data" [
if not dbOnly then ConvertersTests.all
if not dbOnly then UtilsTests.all
if not dbOnly || (dbOnly && rethinkOnly) then RethinkDbDataTests.all
if not dbOnly || (dbOnly && sqliteOnly) then SQLiteDataTests.all
if not dbOnly || (dbOnly && postgresOnly) then PostgresDataTests.all
]
]
[<EntryPoint>]

View File

@ -227,15 +227,15 @@ let register () =
typeof<CustomFeed>; typeof<Episode>; typeof<Episode option>; typeof<MetaItem>; typeof<Page>
typeof<RedirectRule>; typeof<RssOptions>; typeof<TagMap>; typeof<UploadDestination>; typeof<WebLog>
// View models
typeof<DashboardModel>; typeof<DisplayCategory>; typeof<DisplayChapter>
typeof<DisplayCustomFeed>; typeof<DisplayPage>; typeof<DisplayRevision>
typeof<DisplayTheme>; typeof<DisplayUpload>; typeof<DisplayUser>
typeof<EditCategoryModel>; typeof<EditCustomFeedModel>; typeof<EditMyInfoModel>
typeof<EditPageModel>; typeof<EditPostModel>; typeof<EditRedirectRuleModel>
typeof<EditRssModel>; typeof<EditTagMapModel>; typeof<EditUserModel>
typeof<LogOnModel>; typeof<ManageChaptersModel>; typeof<ManagePermalinksModel>
typeof<ManageRevisionsModel>; typeof<PostDisplay>; typeof<PostListItem>
typeof<SettingsModel>; typeof<UserMessage>
typeof<DashboardModel>; typeof<DisplayCategory>; typeof<DisplayChapter>
typeof<DisplayCustomFeed>; typeof<DisplayPage>; typeof<DisplayRevision>
typeof<DisplayTheme>; typeof<DisplayUpload>; typeof<DisplayUser>
typeof<EditCategoryModel>; typeof<EditChapterModel>; typeof<EditCustomFeedModel>
typeof<EditMyInfoModel>; typeof<EditPageModel>; typeof<EditPostModel>
typeof<EditRedirectRuleModel>; typeof<EditRssModel>; typeof<EditTagMapModel>
typeof<EditUserModel>; typeof<LogOnModel>; typeof<ManageChaptersModel>
typeof<ManagePermalinksModel>; typeof<ManageRevisionsModel>; typeof<PostDisplay>
typeof<PostListItem>; typeof<SettingsModel>; typeof<UserMessage>
// Framework types
typeof<AntiforgeryTokenSet>; typeof<DateTime option>; typeof<int option>; typeof<KeyValuePair>
typeof<MetaItem list>; typeof<string list>; typeof<string option>; typeof<TagMap list>

View File

@ -386,6 +386,29 @@ let chapters postId : HttpHandler = requireAccess Author >=> fun next ctx -> tas
| Some _ | None -> return! Error.notFound next ctx
}
// GET /admin/post/{id}/chapter/{idx}
let editChapter (postId, index) : HttpHandler = requireAccess Author >=> fun next ctx -> task {
match! ctx.Data.Post.FindById (PostId postId) ctx.WebLog.Id with
| Some post
when Option.isSome post.Episode
&& Option.isSome post.Episode.Value.Chapters
&& canEdit post.AuthorId ctx ->
let chapter =
if index = -1 then Some Chapter.Empty
else
let chapters = post.Episode.Value.Chapters.Value
if index < List.length chapters then Some chapters[index] else None
match chapter with
| Some chap ->
return!
hashForPage (if index = -1 then "Add a Chapter" else "Edit Chapter")
|> withAntiCsrf ctx
|> addToHash ViewContext.Model (EditChapterModel.FromChapter post.Id index chap)
|> adminBareView "chapter-edit" next ctx
| None -> return! Error.notFound next ctx
| Some _ | None -> return! Error.notFound next ctx
}
// POST /admin/post/save
let save : HttpHandler = requireAccess Author >=> fun next ctx -> task {
let! model = ctx.BindFormAsync<EditPostModel>()

View File

@ -129,6 +129,7 @@ let router : HttpHandler = choose [
routef "/%s/permalinks" Post.editPermalinks
routef "/%s/revision/%s/preview" Post.previewRevision
routef "/%s/revisions" Post.editRevisions
routef "/%s/chapter/%i" Post.editChapter
routef "/%s/chapters" Post.chapters
])
subRoute "/settings" (requireAccess WebLogAdmin >=> choose [

View File

@ -0,0 +1,84 @@
<h2 class=my-3>{% if model.index < 0 %}Add{% else %}Edit{% endif %} Chapter</h2>
<form method=post hx-target=#chapter_list class=container>
<input type=hidden name=PostId value="{{ model.post_id }}">
<input type=hidden name=Index value={{ model.index }}>
<div class=row>
<div class="col">
<div class=form-floating>
<input type=text id=start_time name=StartTime class=form-control value="{{ model.start_time }}">
<label for=start_time>Start Time</label>
</div>
</div>
<div class="col">
<div class=form-floating>
<input type=text id=title name=Title class=form-control value="{{ model.title }}" placeholder=Title>
<label for=title>Chapter Title</label>
</div>
</div>
</div>
<div class=row>
<div class="col">
<div class=form-floating>
<input type=text id=image_url name=ImageUrl class=form-control value="{{ model.image_url }}"
placeholder="Image URL">
<label for=image_url>Image URL</label>
</div>
</div>
<div class="col">
<div class="form-check form-switch">
<input type=checkbox id=is_hidden name=IsHidden class=form-check-input value=true
{%- if model.is_hidden %} checked{% endif %}>
<label for=is_hidden>Hidden Chapter</label>
</div>
</div>
<div class="col">
<div class=form-floating>
<input type=text id=end_time name=EndTime class=form-control value="{{ model.end_time }}"
placeholder="End Time">
<label for=end_time>End Time</label>
</div>
</div>
</div>
<div class=row>
<div class="col">
<div class="form-check form-switch">
<input type=checkbox id=has_location class=form-check-input value=true
{%- if model.location_name != "" %} checked{% endif %}>
<label for=has_location>Associate Location</label>
</div>
</div>
<div class="col">
<div class=form-floating>
<input type=text id=location_name name=LocationName class=form-control value="{{ model.location_name }}"
placeholder="Location Name">
<label for=location_name>Name</label>
</div>
</div>
<div class="col">
<div class=form-floating>
<input type=text id=location_geo name=LocationGeo class=form-control value="{{ model.location_geo }}"
placeholder="Location Geo URL">
<label for=location_geo>Geo URL</label>
</div>
</div>
<div class="col">
<div class=form-floating>
<input type=text id=location_osm name=LocationOsm class=form-control value="{{ model.location_osm }}"
placeholder="Location OSM Query">
<label for=location_osm>OSM Query</label>
</div>
</div>
</div>
<div class=row>
<div class=col>
{% if model.index < 0 -%}
<button type=submit class="btn btn-primary">Add + New Chapter</button>
<button type=button class="btn btn-secondary">Save</button>
{% else -%}
<button type=submit class="btn btn-primary">Save</button>
{% endif %}
{% assign cancel_link = "admin/post/" | append: model.post_id | append: "/chapters" | relative_link %}
<a href="{{ cancel_link }}" hx-get="{{ cancel_link }}" class="btn btn-secondary" hx-target=body>Cancel</a>
</div>
</div>
</form>

View File

@ -1,6 +1,6 @@
<h2 class=my-3>{{ page_title }}</h2>
<article>
<form method=post hx-target=body>
<form method=post id=chapter_list hx-target=body>
<input type=hidden name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
<input type=hidden name=Id value="{{ model.id }}">
<div class="container mb-3">
@ -29,9 +29,9 @@
<div class=col>{% if chapter.location %}Y{% else %}N{% endif %}</div>
</div>
{% endfor %}
<div class="row pb-3 mwl-table-detail" id=chapter_new>
{% assign new_link = "admin/post/" | append: model.id | append: "/chapter/new" | relative_link %}
<a href="{{ new_link }}" hx-get="{{ new_link }}" hx-target=#chapter_new>Add a New Chapter</a>
<div class="row pb-3" id=chapter_new>
{% assign new_link = "admin/post/" | append: model.id | append: "/chapter/-1" | relative_link %}
<a class="btn btn-primary" href="{{ new_link }}" hx-get="{{ new_link }}" hx-target=#chapter_new>Add a New Chapter</a>
</div>
</div>
</form>