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
require "pro_tacts/admin/phlex"

require "pro_tacts/admin/avatar"
require "pro_tacts/admin/format"
require "pro_tacts/admin/group_tag"
require "pro_tacts/admin/layout"

module ProTacts
  module Admin
    # GET /contacts — every contact in one list, sorted by last name:
    # the one screen that browses the whole contact set (docs/DESIGN.md,
    # "The core idea"), in Contact#<=>'s order. Rows carry the
    # label the dashboard's rows do (Format.name_label) and the
    # contact's groups as chips under it.
    #
    # Nothing on the page narrows the list. Finding a contact is the
    # header's search, which matches every field rather than the name
    # alone; a filter here would be a second, weaker match that could
    # only disagree with it. This page is for reading the book by letter.
    class ContactsIndex < Phlex::HTML
      # @rbs @rows: Array[ListedContact]
      # @rbs @login: String
      # @rbs @groups_of: Hash[String, Array[ListedGroup]]

      #: (contacts: Array[ListedContact], groups: Array[ListedGroup], login: String) -> void
      def initialize(contacts:, groups:, login:)
        @login = login
        @rows = contacts.sort
        # Whole groups inverted into memberships, the way Admin::Search
        # inverts them: which groups a contact is in is the store's
        # fact, read from that side once instead of per row. The
        # inversion keeps Store#all_groups' order.
        @groups_of = {}
        groups.each { |group| group.members.each { (@groups_of[it] ||= []) << group } }
      end

      def view_template
        render Layout.new(title: "Contacts", login: @login) do
          section(class: "contacts-list") do
            div(class: "section-head") do
              h2(class: "type-label") { "contacts (#{@rows.length})" }
            end
            if @rows.empty?
              p(class: "type-body-sm") { "No contacts yet." }
            else
              div(class: "letter-groups") do
                letter_groups.each do |letter, rows|
                  section do
                    h3(class: "type-label") { letter }
                    ul(class: "card") do
                      rows.each { render_row(it) }
                    end
                  end
                end
              end
            end
          end
        end
      end

      private

      #: (ListedContact contact) -> void
      def render_row(contact)
        # Not a ListItem, whose one anchor wraps the whole row, because
        # the chips are links too and an anchor cannot hold one. The
        # name is the row's link and stretches over the row
        # (.linked-row in admin.css); the chips, each a link to its
        # group's page as on the record page, sit above that stretch.
        li(class: "linked-row") do
          render Avatar.new(contact:, size: "lg")
          div(style: "flex: 1; min-width: 0;") do
            a(href: "/contacts/#{contact.id}", class: "row-link") { Format.name_label(contact) }
            groups = @groups_of[contact.id]
            if groups
              div(class: "tag-set", style: "margin-top: var(--gl-space-3xs);") do
                groups.each { |group| render GroupTag.new(group:) }
              end
            end
          end
        end
      end

      # The rows cut at each first letter of Contact#sort_key, the way
      # a phone's contacts list is: a contact files under the letter
      # it sorts by, so one with no family name files under its FN's.
      # An accent files with its base letter, whose rows it follows,
      # and anything that is no letter at all files under "#", after
      # Z. The rows arrive sorted, and group_by keeps their order
      # within each letter.
      #: () -> Array[[String, Array[ListedContact]]]
      def letter_groups
        @rows
          .group_by { letter_of(it) }
          .sort_by { |letter, _| [letter == "#" ? 1 : 0, letter] }
      end

      #: (ListedContact contact) -> String
      def letter_of(contact)
        base = contact.sort_key.unicode_normalize(:nfd)[0].to_s
        base.match?(/[a-z]/) ? base.upcase : "#"
      end
    end
  end
end