1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
require "pro_tacts/admin/phlex"

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
    # GET /groups/:id/edit — a group's editor: Admin::ContactsEdit's
    # shape over what a group holds, which is a name and the addresses
    # and note it lends. The address rows are the
    # contact editor's own — addressed by digest, blank removes
    # (docs/plans/2026-09-05-web-card-editor.md) — and the save runs
    # the contact editor's own splice over the group's lines
    # (Web#apply_group_edit). A group's version rides along hidden, the
    # snapshot guard a contact's etag is on its editor.
    #
    # Membership is edited from the group's card (Admin::GroupsShow),
    # not here: it is a relationship, not a line the group holds. The
    # form carries no `members[]`, so a save leaves membership as it
    # stands (Web#apply_group_edit).
    class GroupsEdit < Phlex::HTML
      # @rbs @group: Group
      # @rbs @login: String
      # @rbs @reading: Contact
      # @rbs @notice: String?

      # The form's id, for the footer's Save outside it.
      FORM = "group-form" #: String
      private_constant :FORM

      PHOTO_FORM = "group-photo-form" #: String
      private_constant :PHOTO_FORM

      #: (group: Group, login: String, ?notice: String?) -> void
      def initialize(group:, login:, notice: nil)
        @group = group
        @login = login
        @reading = group.reading
        @notice = notice
      end

      def view_template
        render Layout.new(title: "Edit #{@group.label}", login: @login, notice: @notice) do
          # 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(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. A
                # book's group keeps its name (Store#rename_group), so
                # the name is shown rather than offered: text where the
                # field would be, and a save that carries none keeps it
                # (Web#apply_group_edit).
                if @group.sync?
                  div(class: "field") do
                    span { "Name" }
                    span { @group.name }
                  end
                else
                  label(class: "field") do
                    span { "Name" }
                    input(type: "text", name: "name", value: @group.name, placeholder: @group.id, autofocus: true)
                  end
                end
                @reading.addresses.each { |address| address_row(address) }
                added_address_row
                # Every NOTE, joined, the contact editor's own rule:
                # this row edits one, and a save writes one.
                note = CardForm.note_of(@reading)
                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
            end
          end
          photo_section
        end
      end

      private

      # The group's photo, set or removed on its own rather than with
      # the form above: it lends nothing, so it has no place in the
      # version guard or the lines the save splices, and an upload
      # needs a multipart form the editor's save does not.
      #: () -> void
      def photo_section
        render RecordCard.new(
          nav: -> {
            span(class: "type-label") { "photo" }
            if @group.photo
              form(action: "/groups/#{@group.id}/photo", method: "post") do
                button(type: "submit", name: "remove", value: "1", data_size: "sm") { "remove photo" }
              end
            end
          },
          submit: "Upload", form: PHOTO_FORM,
        ) do
          form(action: "/groups/#{@group.id}/photo", method: "post", enctype: "multipart/form-data",
               class: "field-stack", id: PHOTO_FORM) do
            img(class: "group-photo", src: "/groups/#{@group.id}/photo", alt: "") if @group.photo
            label(class: "field") do
              span { @group.photo ? "Replace" : "Photo" }
              input(type: "file", name: "photo", accept: "image/jpeg,image/png,image/gif", required: true)
            end
          end
        end
      end

      # ContactsEdit#address_row, over a group's line.
      #: (Contact::Address address) -> void
      def address_row(address)
        div(class: "field", data: {blank_removes: address.po_box.nil?}) do
          span { address.label || "address" }
          div(class: "field-stack") do
            ContactsEdit::ADDRESS_FIELDS.each do |component, label|
              input(type: "text", name: "address[#{address.line.digest}][#{component}]",
                    value: address.public_send(component), placeholder: label, aria_label: label)
            end
          end
        end
      end

      # One address added per save, revealed by the caption row's button
      # so no empty row stands in the form until one is wanted
      # (ContactsEdit#added_rows' reason, and Alpine's).
      #: () -> void
      def added_address_row
        template("x-if": "adding") do
          div(class: "field") do
            span { "address" }
            div(class: "field-stack") do
              ContactsEdit::ADDRESS_FIELDS.each_with_index do |(component, label), index|
                input(type: "text", name: "new_address[0][#{component}]", placeholder: label,
                      aria_label: label, "x-init": index.zero? ? "$el.focus()" : nil)
              end
            end
          end
        end
      end
    end
  end
end