Version 2.1 #41
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>]
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>()
|
||||
|
|
|
@ -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 [
|
||||
|
|
84
src/admin-theme/chapter-edit.liquid
Normal file
84
src/admin-theme/chapter-edit.liquid
Normal 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>
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue
Block a user