admin: one RecordCard for the form cards
The upload form wore the record editor's field-stack rhythm with no
grid to align to, which was the tightness the /import cleanup set out
to fix.
Assisted-by: GLM-5.3 via pi
diff --git a/Steepfile b/Steepfile
index ae836d7..81e88a3 100644
--- a/Steepfile
+++ b/Steepfile
@@ -23,7 +23,7 @@ target :lib do
ignore(*%w[
avatar contact_dialog contacts_edit contacts_index contacts_show dashboard device_setup format
group_dialog group_filter group_label groups_edit groups_index groups_show icons import_groups import_original import_review import_sidebar
- import_upload layout list_item phlex upcoming_birthdays
+ import_upload layout list_item phlex record_card upcoming_birthdays
].map { "lib/pro_tacts/admin/#{it}.rb" }, inline: true)
signature "sig"
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index 6628f9e..943fb29 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -6,6 +6,7 @@ require "pro_tacts/admin/format"
require "pro_tacts/admin/icons"
require "pro_tacts/admin/layout"
require "pro_tacts/admin/name_pair"
+require "pro_tacts/admin/record_card"
module ProTacts
module Admin
@@ -137,23 +138,17 @@ module ProTacts
# this pass, `type` is the radio's binding, and the rest is
# the name boxes' (NamePair).
div(x_data: "{ added: [], type: '#{ADDABLE_TYPES.first}', #{NamePair.state(@first, @last)} }") do
- # The caption-to-card block the details page uses (.record
- # in admin.css): the back link is this card's caption row,
- # and the row's one action sits at its right edge — the
- # same place, and the same shape, as the details page's
- # edit link. Each mode's row names the other thing you can
- # do to the record from it.
- walked do
- div(class: "record") do
- div(class: "record-nav") do
- href, label = @back
- a(href:, class: "type-label") { "‹ #{label}" }
- button(type: "button", data_size: "sm",
- popovertarget: "add-property") { "add property" }
- end
- paired do
- div(class: "card") do
- div(class: "card-body") do
+ render RecordCard.new(
+ back: @back,
+ nav: -> {
+ button(type: "button", data_size: "sm",
+ popovertarget: "add-property") { "add property" }
+ },
+ aside: @aside,
+ sidebar: @sidebar,
+ submit: @save,
+ form: FORM,
+ ) do
form(action: @action, method: "post", class: "field-stack", id: FORM) do
input(type: "hidden", name: "etag", value: @contact.etag)
# The caption is an element rather than bare text
@@ -220,19 +215,6 @@ module ProTacts
render @fields if @fields
end
end
- # The card's action bar, a dialog footer's shape (admin.css),
- # outside the form and reaching it by id. Save is the form's
- # submit; Cancel is navigation — a link in Gloss's `.btn`
- # contract, which is what an anchor that acts like a button
- # opts into.
- footer do
- a(href: @back.fetch(0), class: "btn") { "Cancel" }
- button(type: "submit", form: FORM, data: {variant: "primary"}) { @save }
- end
- end
- end
- end
- end
add_property_dialog
end
end
@@ -240,37 +222,6 @@ module ProTacts
private
- # The walk's list of contacts, and the record beside it when
- # there is one — the sidebar grid (admin.css). Skipped rather
- # than rendered around one column for the reason #paired is:
- # an ordinary edit has no walk to stand in.
- #: () { () -> void } -> void
- def walked
- sidebar = @sidebar
- return yield if sidebar.nil?
-
- div(class: "walk") do
- render sidebar
- yield
- end
- end
-
- # The editor's card, and what stands beside it when something
- # does — the two-column grid the dashboard uses, for its reason
- # (admin.css). With nothing beside it the wrapper is skipped
- # rather than rendered around one card: a grid of one is a
- # different element for no difference on screen.
- #: () { () -> void } -> void
- def paired
- aside = @aside
- return yield if aside.nil?
-
- div(class: "paired") do
- render aside
- yield
- end
- end
-
# The rows added this pass, rendered by Alpine from `added` —
# one per type named in the dialog, in the order they were
# asked for. Nothing stands here until something is added and
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index cac5949..157dbaf 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -61,7 +61,7 @@ module ProTacts
div(class: "card-body") do
div(class: "detail-header") do
div do
- h1(class: "type-h2", style: "margin: 0;") { @contact.name || @contact.id }
+ h1(class: "type-h2") { @contact.name || @contact.id }
# The nickname rides under the name in the heading
# family — type-h3 to the name's h2 — so it reads as
# a name rather than a property value; muted, so the
diff --git a/lib/pro_tacts/admin/device_setup.rb b/lib/pro_tacts/admin/device_setup.rb
index 02f1385..28c607a 100644
--- a/lib/pro_tacts/admin/device_setup.rb
+++ b/lib/pro_tacts/admin/device_setup.rb
@@ -36,7 +36,7 @@ module ProTacts
end
div(class: "card") do
div(class: "card-body") do
- h1(class: "type-h2", style: "margin: 0;") { "CardDAV profile" }
+ h1(class: "type-h2") { "CardDAV profile" }
dl(class: "detail-grid") do
dt(class: "type-label") { "server" }
dd(class: "type-body-sm") { @hostname }
diff --git a/lib/pro_tacts/admin/groups_edit.rb b/lib/pro_tacts/admin/groups_edit.rb
index 7bdc52b..9b2e7ad 100644
--- a/lib/pro_tacts/admin/groups_edit.rb
+++ b/lib/pro_tacts/admin/groups_edit.rb
@@ -4,6 +4,7 @@ require "pro_tacts/admin/contacts_edit"
require "pro_tacts/admin/format"
require "pro_tacts/admin/group_label"
require "pro_tacts/admin/layout"
+require "pro_tacts/admin/record_card"
module ProTacts
module Admin
@@ -42,42 +43,34 @@ module ProTacts
# The scope wraps the whole record because the add button sits
# in the caption row above the card, where the contact editor's
# add button sits, and the row it reveals is inside the form.
- div(class: "record", x_data: "{ adding: false }") do
- div(class: "record-nav") do
- a(href: "/groups/#{@group.id}", class: "type-label") do
- plain "‹ "
- render GroupLabel.new(group: @group)
- end
- button(type: "button", data_size: "sm", "x-show": "!adding",
- "@click": "adding = true") { "add address" }
- end
- div(class: "card") do
- div(class: "card-body") do
- form(action: "/groups/#{@group.id}", method: "post", class: "field-stack", id: FORM) do
- input(type: "hidden", name: "version", value: @group.version)
- # The id as the blank's placeholder, because a group
- # with no name is shown as its id everywhere else.
- label(class: "field") do
- span { "Name" }
- input(type: "text", name: "name", value: @group.name,
- placeholder: @group.id, autofocus: true)
- end
- @reading.addresses.each { |address| address_row(address) }
- added_address_row
- # The first NOTE, the contact editor's own rule: this
- # row edits one, and a save writes one.
- note = @reading.notes.first&.value
- label(class: "field", data: {blank_removes: !!note}) do
- span { "Note" }
- textarea(name: "note", rows: 4,
- placeholder: note ? "removed on save" : nil) { note.to_s }
- end
+ div(x_data: "{ adding: false }") do
+ render RecordCard.new(
+ back: ["/groups/#{@group.id}", GroupLabel.new(group: @group)],
+ nav: -> {
+ button(type: "button", data_size: "sm", "x-show": "!adding",
+ "@click": "adding = true") { "add address" }
+ },
+ submit: "Save", form: FORM,
+ ) do
+ form(action: "/groups/#{@group.id}", method: "post", class: "field-stack", id: FORM) do
+ input(type: "hidden", name: "version", value: @group.version)
+ # The id as the blank's placeholder, because a group
+ # with no name is shown as its id everywhere else.
+ label(class: "field") do
+ span { "Name" }
+ input(type: "text", name: "name", value: @group.name,
+ placeholder: @group.id, autofocus: true)
+ end
+ @reading.addresses.each { |address| address_row(address) }
+ added_address_row
+ # The first NOTE, the contact editor's own rule: this
+ # row edits one, and a save writes one.
+ note = @reading.notes.first&.value
+ label(class: "field", data: {blank_removes: !!note}) do
+ span { "Note" }
+ textarea(name: "note", rows: 4,
+ placeholder: note ? "removed on save" : nil) { note.to_s }
end
- end
- # ContactsEdit's action bar.
- footer do
- a(href: "/groups/#{@group.id}", class: "btn") { "Cancel" }
- button(type: "submit", form: FORM, data: {variant: "primary"}) { "Save" }
end
end
end
diff --git a/lib/pro_tacts/admin/groups_show.rb b/lib/pro_tacts/admin/groups_show.rb
index fcc250d..dd4504d 100644
--- a/lib/pro_tacts/admin/groups_show.rb
+++ b/lib/pro_tacts/admin/groups_show.rb
@@ -34,7 +34,7 @@ module ProTacts
div(class: "card") do
div(class: "card-body") do
div(class: "detail-header") do
- h1(class: "type-h2", style: "margin: 0;") { render GroupLabel.new(group: @group) }
+ h1(class: "type-h2") { render GroupLabel.new(group: @group) }
end
# Never empty, unlike ContactsShow's: the members row
# always renders (#members_row).
diff --git a/lib/pro_tacts/admin/import_review.rb b/lib/pro_tacts/admin/import_review.rb
index fa3b362..1172b3e 100644
--- a/lib/pro_tacts/admin/import_review.rb
+++ b/lib/pro_tacts/admin/import_review.rb
@@ -69,7 +69,7 @@ module ProTacts
def summary_card
div(class: "card") do
div(class: "card-body") do
- h1(class: "type-h2", style: "margin: 0;") { count(@contacts, "contact") }
+ h1(class: "type-h2") { count(@contacts, "contact") }
losses
group_line
progress
diff --git a/lib/pro_tacts/admin/import_upload.rb b/lib/pro_tacts/admin/import_upload.rb
index 1d2c7f3..af0f00d 100644
--- a/lib/pro_tacts/admin/import_upload.rb
+++ b/lib/pro_tacts/admin/import_upload.rb
@@ -1,6 +1,7 @@
require "pro_tacts/admin/phlex"
require "pro_tacts/admin/layout"
+require "pro_tacts/admin/record_card"
module ProTacts
module Admin
@@ -22,6 +23,9 @@ module ProTacts
class ImportUpload < Phlex::HTML
# @rbs @notice: String?
+ FORM = "import-form" #: String
+ private_constant :FORM
+
#: (?notice: String?) -> void
def initialize(notice: nil)
@notice = notice
@@ -29,33 +33,13 @@ module ProTacts
def view_template
render Layout.new(title: "Import", notice: @notice) do
- div(class: "record") do
- div(class: "record-nav") do
- a(href: "/", class: "type-label") { "‹ contacts" }
- end
- upload_card
- end
- end
- end
-
- private
-
- #: () -> void
- def upload_card
- div(class: "card") do
- div(class: "card-body") do
- h1(class: "type-h2", style: "margin: 0;") { "Import contacts" }
- p(class: "type-body-sm") do
- "Choose a vCard file — Contacts exports one with File, Export, Export vCard. " \
- "The next screen says what is in it before anything is added, including what " \
- "pro-tacts cannot show and will leave behind."
- end
- form(action: "/import", method: "post", enctype: "multipart/form-data", class: "field-stack") do
+ render RecordCard.new(back: ["/", "contacts"], heading: "Import contacts",
+ submit: "Import", form: FORM) do
+ form(action: "/import", method: "post", enctype: "multipart/form-data", id: FORM) do
label(class: "field") do
- span(class: "type-label") { "vcard file" }
+ plain "vcard file"
input(type: "file", name: "vcf", accept: ".vcf,text/vcard")
end
- button(type: "submit", data: {variant: "primary"}) { "read the file" }
end
end
end
diff --git a/lib/pro_tacts/admin/record_card.rb b/lib/pro_tacts/admin/record_card.rb
new file mode 100644
index 0000000..d50f983
--- /dev/null
+++ b/lib/pro_tacts/admin/record_card.rb
@@ -0,0 +1,118 @@
+require "pro_tacts/admin/phlex"
+
+module ProTacts
+ module Admin
+ # The trunk every form card shares: the record's caption row and
+ # card (admin.css), the form as the card's body, and the action
+ # bar under both — a shape ContactsEdit, GroupsEdit, and
+ # ImportUpload each carried by hand before this.
+ #
+ # The form is the caller's block rather than the component's,
+ # because its rhythm is the caller's question: an editor's rows
+ # are a field-stack on the record's grid, and an upload's lone
+ # field is a Gloss settings field with no grid to align to.
+ # Everything around the form is the same for all three, which is
+ # why it lives here.
+ #
+ # `back` names the caption row's link, `[href, label]`, the label
+ # a string or whatever `render` takes (GroupsEdit's is a
+ # GroupLabel). `nav`, when something is passed, is the row's one
+ # action at its right edge. The footer's Cancel goes where the
+ # back link goes — the editors' own rule — and the submit reaches
+ # the form by id rather than sitting in it, so the action bar is
+ # the card's and not the form's.
+ class RecordCard < Phlex::HTML
+ # @rbs @back: [String, (String | Phlex::HTML | Proc)]
+ # @rbs @nav: (Proc | Phlex::HTML)?
+ # @rbs @heading: String?
+ # @rbs @aside: Phlex::HTML?
+ # @rbs @sidebar: Phlex::HTML?
+ # @rbs @submit: String
+ # @rbs @form: String
+
+ #: (back: [String, (String | Phlex::HTML | Proc)], submit: String, form: String, ?nav: (Proc | Phlex::HTML)?, ?heading: String?, ?aside: Phlex::HTML?, ?sidebar: Phlex::HTML?) -> void
+ def initialize(back:, submit:, form:, nav: nil, heading: nil, aside: nil, sidebar: nil)
+ @back = back
+ @nav = nav
+ @heading = heading
+ @aside = aside
+ @sidebar = sidebar
+ @submit = submit
+ @form = form
+ end
+
+ def view_template(&)
+ sidebar = @sidebar
+ return record(&) if sidebar.nil?
+
+ walk(sidebar, &)
+ end
+
+ private
+
+ # The walk's list of contacts, and the record beside it when
+ # there is one — the sidebar grid (admin.css). Skipped rather
+ # than rendered around one column for the reason #paired is:
+ # an ordinary edit has no walk to stand in.
+ #: (Phlex::HTML sidebar) { () -> void } -> void
+ def walk(sidebar, &)
+ div(class: "walk") do
+ render sidebar
+ record(&)
+ end
+ end
+
+ # The caption-to-card block the details page uses (.record in
+ # admin.css): the back link is this card's caption row, and the
+ # row's one action sits at its right edge — the same place, and
+ # the same shape, as the details page's edit link. Each mode's
+ # row names the other thing you can do to the record from it.
+ def record(&)
+ div(class: "record") do
+ div(class: "record-nav") do
+ href, label = @back
+ a(href:, class: "type-label") do
+ plain "‹ "
+ render label
+ end
+ render @nav if @nav
+ end
+ paired(&)
+ end
+ end
+
+ # The editor's card, and what stands beside it when something
+ # does — the two-column grid the dashboard uses, for its reason
+ # (admin.css). With nothing beside it the wrapper is skipped
+ # rather than rendered around one card: a grid of one is a
+ # different element for no difference on screen.
+ #: () { () -> void } -> void
+ def paired(&)
+ aside = @aside
+ return card(&) if aside.nil?
+
+ div(class: "paired") do
+ render aside
+ card(&)
+ end
+ end
+
+ def card(&)
+ div(class: "card") do
+ div(class: "card-body") do
+ h1(class: "type-h2") { @heading } if @heading
+ yield
+ end
+ # A dialog footer's shape (admin.css), outside the form and
+ # reaching it by id. The submit is the form's; Cancel is
+ # navigation — a link in Gloss's `.btn` contract, which is
+ # what an anchor that acts like a button opts into.
+ footer do
+ a(href: @back.fetch(0), class: "btn") { "Cancel" }
+ button(type: "submit", form: @form, data: {variant: "primary"}) { @submit }
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/public/admin.css b/public/admin.css
index 26694c4..26c8487 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -56,6 +56,17 @@
translate: 0 -50%;
}
+/* A card's h1 carries no margin of its own — Gloss zeroes h3 in a
+ card-body the same way — because every h1 in one is a page's
+ heading and its separation from what follows is the page's rhythm,
+ not the heading element's default. The descendant rule reaches the
+ name inside .detail-header too; the direct-child one adds the one
+ step of air under a card's own heading, which the card-body's gap
+ does not give it — that gap is the prose-block rhythm, and loosening
+ it to air a heading would loosen every paragraph after it. */
+.card-body h1 { margin: 0; }
+.card-body > h1 { margin-block-end: var(--gl-space-xs); }
+
/* The type column both record modes share: the details grid's label
column and the editor's caption column are the same column, read
from one place so they cannot drift apart. The one structural rem