uchi 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9d88ecc3805c99b55a1fa5cc94817732f98378328a34be0dfb429bc99072af17
4
- data.tar.gz: b56d6050c427211cf45202b60c7d24c5772aabad1242f6cd7a026b25db396142
3
+ metadata.gz: 92849fb71ff71c56d796e3e43601dd99eabc565180c902ac129feb25f8cc5fc0
4
+ data.tar.gz: 789df26e207ac6ed7e8857269bafc4bcd06d21c3ec22b6070c1092c222a42d08
5
5
  SHA512:
6
- metadata.gz: 023fb2f1a38c05cd69a4d2f5e6655e7900afb0160dd8972250212dc9503eacdf228cff5e08be6541444108e0aec235e9ec903a43f901f9022287dc81de17acea
7
- data.tar.gz: e9d190e66e174e78883a423880dfef26bd53c3f753e3d6c6fc66c979b79ae59a7a9e05d94db48d50d01c9fdcb3dd44c0f91c852c876db6e6670252df4ab34cda
6
+ metadata.gz: 146839a586037ec5e0b713aec63ca699b50346c9d0be98935db42a562fe1f78be1bc6d16d4afe9a4ec38431cb440efb4d63b79527b2873db87ce01225f256153
7
+ data.tar.gz: 002ea4b08886edfefbab4c7580db0524b1776824f2aea0d75077c90ed2331cc3297ed3616121cf6dd86bc9e54cc0556e5d4a00ccc8848a749d1af6b593bf9223
data/CHANGELOG.md ADDED
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ### Added
11
+
12
+ ### Fixed
13
+
14
+ ### Removed
15
+
16
+
17
+ ## [0.1.3]
18
+
19
+ ### Added
20
+
21
+ - Field::Text for multi-line text content like descriptions, biographies, notes, and comments.
22
+ - Field for HasAndBelongsToMany associations.
23
+ - Better blank slate when a RecordsTable has no records; we also no longer show page navigation when there are no records.
24
+ - A changelog!
25
+ - Everything else up until now ;)
26
+
27
+ ### Fixed
28
+
29
+ - uchi:controller generator now generates proper controller names when name contains multiple words.
data/docs/fields.md ADDED
@@ -0,0 +1,82 @@
1
+ # Fields
2
+
3
+ ## Only show a field on specific pages
4
+
5
+ Use the `on` method to control what pages to show a field on. For example if your id field should only be visible on the index listing, you can configure it as
6
+
7
+ ```ruby
8
+ Field::Number.new(:id).on(:index)
9
+ ```
10
+
11
+ Possible actions are
12
+
13
+ - `:index`
14
+ - `:show`
15
+ - `:new`
16
+ - `:edit`
17
+
18
+ The default is to show all fields on all pages.
19
+
20
+ ## Search
21
+
22
+ If a repository contains at least one searchable `Field` a search field appears on the index page. By default all text-based fields are considered searchable.
23
+
24
+ The search is fairly naive and is a bunch of `LIKE '%query%'` (`ILIKE` in PostgreSQL) clauses strung together by `OR`.
25
+
26
+ ### Disable search
27
+
28
+ To toggle searchability for a field use the `:searchable` option:
29
+
30
+ ```ruby
31
+ Field::String.new(:password).searchable(false)
32
+ ```
33
+
34
+ ### Enable search
35
+
36
+ You can also enable search for fields that don't enable it by default:
37
+
38
+ ```ruby
39
+ Field::Number.new(:id).searchable(true)
40
+ ```
41
+
42
+ Uchi casts whatever datatype the field uses into a string when searching and perform a partial match on it using `LIKE` (`ILIKE` in PostgreSQL), which may or may not yield the results you expect.
43
+
44
+
45
+ ## Sorting
46
+
47
+ All fields are considered sortable by default. This means that a link to toggle the order of a column appears for all columns on index pages. How to sort a specific field - or to disable it entirely - is configured using the `:sortable` option.
48
+
49
+ ### Disable sorting
50
+
51
+ To disable sorting a specific field:
52
+
53
+ ```ruby
54
+ Field::Number.new(:calculated_sum).sortable(false)
55
+ ```
56
+
57
+ ### Customize sorting
58
+
59
+ To customize the query used to sort by a given field, pass a lambda to the `sortable` method:
60
+
61
+ ```ruby
62
+ Field::Number.new(:users_count).sortable(lambda { |query, direction|
63
+ query.joins(:users).group(:id).order("COUNT(users.id) #{direction}")
64
+ })
65
+ ```
66
+
67
+ The lambda receives 2 arguments:
68
+
69
+ 1. `query`: The `ActiveRecord::Relation` that makes up the current database query
70
+ 2. `direction`: A symbol indicating what order to sort; either `:asc` or `:desc`.
71
+
72
+ The lambda should return an `ActiveRecord::Relation` with the desired sort order added.
73
+
74
+ ### Sorting by columns in another table
75
+
76
+ Thanks to ActiveRecord we can even sort by columns in other tables/models. If you have an `Employee` model that belongs to a `Company` and you want to allow your users to sort the employee list by company name, you can configure the field like this:
77
+
78
+ ```ruby
79
+ Field::BelongsTo.new(:company).sortable(lambda { |query, direction|
80
+ query.joins(:office).order(:offices => {:name => direction})
81
+ })
82
+ ```
@@ -0,0 +1,63 @@
1
+ # Repositories
2
+
3
+ The cornerstones of Uchi are the repositories. This is where you configure what parts of your models you want to expose and how to do it.
4
+
5
+ ## Models
6
+
7
+ There's a one-to-one mapping between a repository and a model. So if you have a `User` model that you want to include in Uchi, you must have a `User` repository as well.
8
+
9
+ ## Routes
10
+
11
+ In order to expose your requests to your users, you need a route for each of them. These routes are added to `config/routes.rb` in your main application under the `uchi` namespace:
12
+
13
+ ```ruby
14
+ namespace :uchi do
15
+ resources :companies
16
+ end
17
+ ```
18
+
19
+ See [Rails' routing documentation](https://guides.rubyonrails.org/routing.html) for more details.
20
+
21
+ ### Root URL
22
+
23
+ If you want to expose a repository at the root URL (ie `/uchi/`) you can configure a [`root`](https://guides.rubyonrails.org/routing.html#using-root) for the namespace:
24
+
25
+ ```ruby
26
+ namespace :uchi do
27
+ root "companies#index"
28
+ end
29
+ ```
30
+
31
+ ## Default sort order
32
+
33
+ Lists of records in a repository are by default sorted by a column called `id`. To customize the default sort order, which is used when a user hasn’t explicitly chosen to sort by a specific field, you can create a `default_sort_order` method in the repository:
34
+
35
+ ```ruby
36
+ module Uchi
37
+ class CustomersRepository < Uchi::Repository
38
+ def default_sort_order
39
+ SortOrder.new(:name, :desc)
40
+ end
41
+ end
42
+ end
43
+ ```
44
+
45
+ `default_sort_order` should return a `Uchi::Repository::SortOrder`.
46
+
47
+ ## Avoiding n+1
48
+
49
+ To avoid n+1 performance issues on your index pages and other lists, you can set up includes for the repository.
50
+
51
+ ```ruby
52
+ module Uchi
53
+ module Repositories
54
+ class User < Repository
55
+ def includes
56
+ [:account]
57
+ end
58
+ end
59
+ end
60
+ end
61
+ ```
62
+
63
+ See https://api.rubyonrails.org/classes/ActiveRecord/QueryMethods.html#method-i-includes for details.
@@ -22,7 +22,7 @@ module Uchi
22
22
 
23
23
  test "has custom collection_query" do
24
24
  custom_query = ->(query) { query.where(active: true) }
25
- field = Uchi::Field::BelongsTo.new(:book, collection_query: custom_query)
25
+ field = Uchi::Field::BelongsTo.new(:book).collection_query(custom_query)
26
26
  assert_equal custom_query, field.collection_query
27
27
  end
28
28
 
@@ -61,7 +61,7 @@ module Uchi
61
61
  end
62
62
 
63
63
  test "#searchable? returns false when explicitly set" do
64
- field = Uchi::Field::BelongsTo.new(:book, searchable: false)
64
+ field = Uchi::Field::BelongsTo.new(:book).searchable(false)
65
65
  assert_not field.searchable?
66
66
  end
67
67
 
@@ -74,7 +74,7 @@ module Uchi
74
74
  end
75
75
 
76
76
  test "#sortable? returns false when explicitly set" do
77
- field = Uchi::Field::BelongsTo.new(:book, sortable: false)
77
+ field = Uchi::Field::BelongsTo.new(:book).sortable(false)
78
78
  assert_not field.sortable?
79
79
  end
80
80
  end
@@ -39,7 +39,7 @@ module Uchi
39
39
  end
40
40
 
41
41
  test "#searchable? returns false when explicitly set" do
42
- field = Uchi::Field::Blank.new(:separator, searchable: false)
42
+ field = Uchi::Field::Blank.new(:separator).searchable(false)
43
43
  assert_not field.searchable?
44
44
  end
45
45
 
@@ -52,7 +52,7 @@ module Uchi
52
52
  end
53
53
 
54
54
  test "#sortable? returns false when explicitly set" do
55
- field = Uchi::Field::Blank.new(:separator, sortable: false)
55
+ field = Uchi::Field::Blank.new(:separator).sortable(false)
56
56
  assert_not field.sortable?
57
57
  end
58
58
  end
@@ -39,7 +39,7 @@ module Uchi
39
39
  end
40
40
 
41
41
  test "#searchable? returns false when explicitly set" do
42
- field = Uchi::Field::Boolean.new(:active, searchable: false)
42
+ field = Uchi::Field::Boolean.new(:active).searchable(false)
43
43
  assert_not field.searchable?
44
44
  end
45
45
 
@@ -52,7 +52,7 @@ module Uchi
52
52
  end
53
53
 
54
54
  test "#sortable? returns false when explicitly set" do
55
- field = Uchi::Field::Boolean.new(:active, sortable: false)
55
+ field = Uchi::Field::Boolean.new(:active).sortable(false)
56
56
  assert_not field.sortable?
57
57
  end
58
58
  end
@@ -39,7 +39,7 @@ module Uchi
39
39
  end
40
40
 
41
41
  test "#searchable? returns false when explicitly set" do
42
- field = Uchi::Field::Date.new(:born_on, searchable: false)
42
+ field = Uchi::Field::Date.new(:born_on).searchable(false)
43
43
  assert_not field.searchable?
44
44
  end
45
45
 
@@ -52,7 +52,7 @@ module Uchi
52
52
  end
53
53
 
54
54
  test "#sortable? returns false when explicitly set" do
55
- field = Uchi::Field::Date.new(:born_on, sortable: false)
55
+ field = Uchi::Field::Date.new(:born_on).sortable(false)
56
56
  assert_not field.sortable?
57
57
  end
58
58
  end
@@ -39,7 +39,7 @@ module Uchi
39
39
  end
40
40
 
41
41
  test "#searchable? returns false when explicitly set" do
42
- field = Uchi::Field::DateTime.new(:created_at, searchable: false)
42
+ field = Uchi::Field::DateTime.new(:created_at).searchable(false)
43
43
  assert_not field.searchable?
44
44
  end
45
45
 
@@ -52,7 +52,7 @@ module Uchi
52
52
  end
53
53
 
54
54
  test "#sortable? returns false when explicitly set" do
55
- field = Uchi::Field::DateTime.new(:created_at, sortable: false)
55
+ field = Uchi::Field::DateTime.new(:created_at).sortable(false)
56
56
  assert_not field.sortable?
57
57
  end
58
58
  end
@@ -0,0 +1,144 @@
1
+ require "test_helper"
2
+ require "ostruct"
3
+
4
+ module Uchi
5
+ class Field
6
+ class HasAndBelongsToManyTest < ActiveSupport::TestCase
7
+ def setup
8
+ @record = Book.new
9
+ @form = OpenStruct.new(object: @record)
10
+
11
+ @repository = Uchi::Repositories::Book.new
12
+ @field = Uchi::Field::HasAndBelongsToMany.new(:authors)
13
+ @field.repository = @repository
14
+ end
15
+
16
+ test "inherits from Uchi::Field" do
17
+ assert_kind_of Uchi::Field, @field
18
+ end
19
+
20
+ test "has default options specific to HasAndBelongsToMany field" do
21
+ assert_not @field.searchable?
22
+ assert @field.sortable?
23
+ end
24
+
25
+ test "has custom collection_query" do
26
+ custom_query = ->(query) { query.where(published: true) }
27
+ field = Uchi::Field::HasAndBelongsToMany.new(:categories).collection_query(custom_query)
28
+ assert_equal custom_query, field.collection_query
29
+ end
30
+
31
+ test "uses default collection_query" do
32
+ assert_equal Uchi::Field::HasAndBelongsToMany::DEFAULT_COLLECTION_QUERY, @field.collection_query
33
+ end
34
+
35
+ test "#param_key returns foreign key name" do
36
+ assert_equal :author_ids, @field.param_key
37
+ end
38
+
39
+ test "#permitted_param returns key for strong parameters" do
40
+ assert_equal({author_ids: []}, @field.permitted_param)
41
+ end
42
+
43
+ test "#group_as returns :associations" do
44
+ assert_equal :associations, @field.group_as(:show)
45
+ assert_equal :associations, @field.group_as(:edit)
46
+ end
47
+
48
+ test "#edit_component returns an instance of Edit component" do
49
+ component = @field.edit_component(form: @form, hint: "Custom hint", label: "Custom label", repository: @repository)
50
+ assert_equal "Custom hint", component.hint
51
+ assert_equal "Custom label", component.label
52
+ assert_equal @field, component.field
53
+ assert_equal @form, component.form
54
+ assert_equal @repository, component.repository
55
+ assert_kind_of Uchi::Field::HasAndBelongsToMany::Edit, component
56
+ end
57
+
58
+ test "#index_component returns an instance of Index component" do
59
+ component = @field.index_component(record: @form.object, repository: @repository)
60
+ assert_equal @field, component.field
61
+ assert_equal @form.object, component.record
62
+ assert_equal @repository, component.repository
63
+ assert_kind_of Uchi::Field::HasAndBelongsToMany::Index, component
64
+ end
65
+
66
+ test "#show_component returns an instance of Show component" do
67
+ component = @field.show_component(record: @form.object, repository: @repository)
68
+ assert_equal @field, component.field
69
+ assert_equal @form.object, component.record
70
+ assert_equal @repository, component.repository
71
+ assert_kind_of Uchi::Field::HasAndBelongsToMany::Show, component
72
+ end
73
+
74
+ test "#searchable? returns false when explicitly set" do
75
+ field = Uchi::Field::HasAndBelongsToMany.new(:categories).searchable(false)
76
+ assert_not field.searchable?
77
+ end
78
+
79
+ test "#sortable? returns false when explicitly set" do
80
+ field = Uchi::Field::HasAndBelongsToMany.new(:categories).sortable(false)
81
+ assert_not field.sortable?
82
+ end
83
+ end
84
+
85
+ class HasAndBelongsToManyEditTest < ViewComponent::TestCase
86
+ def setup
87
+ @field = Uchi::Field::HasAndBelongsToMany.new(:categories)
88
+ @book = Book.new(original_title: "The Hobbit")
89
+ @repository = Uchi::Repositories::Book.new
90
+ @view_context = ActionController::Base.new.view_context
91
+
92
+ @form = ActionView::Helpers::FormBuilder.new(:book, @book, @view_context, {})
93
+
94
+ @component = Uchi::Field::HasAndBelongsToMany::Edit.new(
95
+ field: @field,
96
+ form: @form,
97
+ hint: "Custom hint",
98
+ label: "Custom label",
99
+ repository: @repository
100
+ )
101
+ end
102
+
103
+ test "inherits from Base component" do
104
+ assert_kind_of Uchi::Field::Base::Edit, @component
105
+ end
106
+ end
107
+
108
+ class HasAndBelongsToManyIndexTest < ViewComponent::TestCase
109
+ def setup
110
+ @field = Uchi::Field::HasAndBelongsToMany.new(:categories)
111
+ @book = Book.new(original_title: "The Hobbit")
112
+ @repository = Uchi::Repositories::Book.new
113
+
114
+ @component = Uchi::Field::HasAndBelongsToMany::Index.new(
115
+ field: @field,
116
+ record: @book,
117
+ repository: @repository
118
+ )
119
+ end
120
+
121
+ test "inherits from Base component" do
122
+ assert_kind_of Uchi::Field::Base::Index, @component
123
+ end
124
+ end
125
+
126
+ class HasAndBelongsToManyShowTest < ViewComponent::TestCase
127
+ def setup
128
+ @field = Uchi::Field::HasAndBelongsToMany.new(:categories)
129
+ @book = Book.new(original_title: "The Hobbit")
130
+ @repository = Uchi::Repositories::Book.new
131
+
132
+ @component = Uchi::Field::HasAndBelongsToMany::Show.new(
133
+ field: @field,
134
+ record: @book,
135
+ repository: @repository
136
+ )
137
+ end
138
+
139
+ test "inherits from Base component" do
140
+ assert_kind_of Uchi::Field::Base::Show, @component
141
+ end
142
+ end
143
+ end
144
+ end
@@ -22,7 +22,7 @@ module Uchi
22
22
 
23
23
  test "has custom collection_query" do
24
24
  custom_query = ->(query) { query.where(published: true) }
25
- field = Uchi::Field::HasMany.new(:titles, collection_query: custom_query)
25
+ field = Uchi::Field::HasMany.new(:titles).collection_query(custom_query)
26
26
  assert_equal custom_query, field.collection_query
27
27
  end
28
28
 
@@ -66,12 +66,12 @@ module Uchi
66
66
  end
67
67
 
68
68
  test "#searchable? returns false when explicitly set" do
69
- field = Uchi::Field::HasMany.new(:titles, searchable: false)
69
+ field = Uchi::Field::HasMany.new(:titles).searchable(false)
70
70
  assert_not field.searchable?
71
71
  end
72
72
 
73
73
  test "#sortable? returns false when explicitly set" do
74
- field = Uchi::Field::HasMany.new(:titles, sortable: false)
74
+ field = Uchi::Field::HasMany.new(:titles).sortable(false)
75
75
  assert_not field.sortable?
76
76
  end
77
77
  end
@@ -42,7 +42,7 @@ module Uchi
42
42
  end
43
43
 
44
44
  test "#searchable? returns false when explicitly set" do
45
- field = Uchi::Field::Id.new(:id, searchable: false)
45
+ field = Uchi::Field::Id.new(:id).searchable(false)
46
46
  assert_not field.searchable?
47
47
  end
48
48
 
@@ -55,7 +55,7 @@ module Uchi
55
55
  end
56
56
 
57
57
  test "#sortable? returns false when explicitly set" do
58
- field = Uchi::Field::Id.new(:id, sortable: false)
58
+ field = Uchi::Field::Id.new(:id).sortable(false)
59
59
  assert_not field.sortable?
60
60
  end
61
61
  end
@@ -39,7 +39,7 @@ module Uchi
39
39
  end
40
40
 
41
41
  test "#searchable? returns false when explicitly set" do
42
- field = Uchi::Field::Number.new(:age, searchable: false)
42
+ field = Uchi::Field::Number.new(:age).searchable(false)
43
43
  assert_not field.searchable?
44
44
  end
45
45
 
@@ -52,7 +52,7 @@ module Uchi
52
52
  end
53
53
 
54
54
  test "#sortable? returns false when explicitly set" do
55
- field = Uchi::Field::Number.new(:age, sortable: false)
55
+ field = Uchi::Field::Number.new(:age).sortable(false)
56
56
  assert_not field.sortable?
57
57
  end
58
58
  end
@@ -39,7 +39,7 @@ module Uchi
39
39
  end
40
40
 
41
41
  test "#searchable? returns false when explicitly set" do
42
- field = Uchi::Field::String.new(:name, searchable: false)
42
+ field = Uchi::Field::String.new(:name).searchable(false)
43
43
  assert_not field.searchable?
44
44
  end
45
45
 
@@ -52,7 +52,7 @@ module Uchi
52
52
  end
53
53
 
54
54
  test "#sortable? returns false when explicitly set" do
55
- field = Uchi::Field::String.new(:name, sortable: false)
55
+ field = Uchi::Field::String.new(:name).sortable(false)
56
56
  assert_not field.sortable?
57
57
  end
58
58
  end
@@ -60,7 +60,7 @@ module Uchi
60
60
  class StringEditTest < ViewComponent::TestCase
61
61
  def setup
62
62
  @field = Uchi::Field::String.new(:name)
63
- @record = Author.new(name: "J.R.R Tolkien")
63
+ @record = Author.new(name: "J.R.R. Tolkien")
64
64
  @repository = Uchi::Repositories::Author.new
65
65
  @view_context = ActionController::Base.new.view_context
66
66
 
@@ -111,7 +111,7 @@ module Uchi
111
111
  class StringIndexTest < ViewComponent::TestCase
112
112
  def setup
113
113
  @field = Uchi::Field::String.new(:name)
114
- @record = Author.new(name: "J.R.R Tolkien")
114
+ @record = Author.new(name: "J.R.R. Tolkien")
115
115
  @repository = Uchi::Repositories::Author.new
116
116
 
117
117
  @component = Uchi::Field::String::Index.new(
@@ -128,14 +128,14 @@ module Uchi
128
128
  test "renders the field content" do
129
129
  result = render_inline(@component)
130
130
 
131
- assert_includes result.to_html, "J.R.R Tolkien"
131
+ assert_includes result.to_html, "J.R.R. Tolkien"
132
132
  end
133
133
  end
134
134
 
135
135
  class StringShowTest < ViewComponent::TestCase
136
136
  def setup
137
137
  @field = Uchi::Field::String.new(:name)
138
- @record = Author.new(name: "J.R.R Tolkien")
138
+ @record = Author.new(name: "J.R.R. Tolkien")
139
139
  @repository = Uchi::Repositories::Author.new
140
140
 
141
141
  @component = Uchi::Field::String::Show.new(
@@ -152,7 +152,7 @@ module Uchi
152
152
  test "renders the field content" do
153
153
  result = render_inline(@component)
154
154
 
155
- assert_includes result.to_html, "J.R.R Tolkien"
155
+ assert_includes result.to_html, "J.R.R. Tolkien"
156
156
  end
157
157
  end
158
158
  end
@@ -0,0 +1,160 @@
1
+ require "test_helper"
2
+ require "ostruct"
3
+
4
+ module Uchi
5
+ class Field
6
+ class TextTest < ActiveSupport::TestCase
7
+ def setup
8
+ @field = Uchi::Field::Text.new(:biography)
9
+ @form = OpenStruct.new(object: OpenStruct.new(biography: "Test Biography"))
10
+ @repository = Uchi::Repositories::Author.new
11
+ end
12
+
13
+ test "inherits from Uchi::Field" do
14
+ assert_kind_of Uchi::Field, @field
15
+ end
16
+
17
+ test "has default options" do
18
+ assert_equal [:edit, :show], @field.on
19
+ assert @field.searchable?
20
+ assert @field.sortable?
21
+ end
22
+
23
+ test "#edit_component returns an instance of Edit component" do
24
+ component = @field.edit_component(form: @form, hint: "Custom hint", label: "Custom label", repository: @repository)
25
+ assert_equal "Custom hint", component.hint
26
+ assert_equal "Custom label", component.label
27
+ assert_equal @field, component.field
28
+ assert_equal @form, component.form
29
+ assert_equal @repository, component.repository
30
+ assert_kind_of Uchi::Field::Text::Edit, component
31
+ end
32
+
33
+ test "#index_component returns an instance of Index component" do
34
+ component = @field.index_component(record: @form.object, repository: @repository)
35
+ assert_equal @field, component.field
36
+ assert_equal @form.object, component.record
37
+ assert_equal @repository, component.repository
38
+ assert_kind_of Uchi::Field::Text::Index, component
39
+ end
40
+
41
+ test "#searchable? returns false when explicitly set" do
42
+ field = Uchi::Field::Text.new(:biography).searchable(false)
43
+ assert_not field.searchable?
44
+ end
45
+
46
+ test "#show_component returns an instance of Show component" do
47
+ component = @field.show_component(record: @form.object, repository: @repository)
48
+ assert_equal @field, component.field
49
+ assert_equal @form.object, component.record
50
+ assert_equal @repository, component.repository
51
+ assert_kind_of Uchi::Field::Text::Show, component
52
+ end
53
+
54
+ test "#sortable? returns false when explicitly set" do
55
+ field = Uchi::Field::Text.new(:biography).sortable(false)
56
+ assert_not field.sortable?
57
+ end
58
+ end
59
+
60
+ class TextEditTest < ViewComponent::TestCase
61
+ def setup
62
+ @field = Uchi::Field::Text.new(:biography)
63
+ @record = Author.new(name: "J.R.R. Tolkien", biography: "Famous author")
64
+ @repository = Uchi::Repositories::Author.new
65
+ @view_context = ActionController::Base.new.view_context
66
+
67
+ @form = ActionView::Helpers::FormBuilder.new(:author, @record, @view_context, {})
68
+
69
+ @component = Uchi::Field::Text::Edit.new(
70
+ field: @field,
71
+ form: @form,
72
+ hint: "Custom hint",
73
+ label: "Custom label",
74
+ repository: @repository
75
+ )
76
+ end
77
+
78
+ test "inherits from Base component" do
79
+ assert_kind_of Uchi::Field::Base::Edit, @component
80
+ end
81
+
82
+ test "renders a textarea field with the field content" do
83
+ render_inline(@component)
84
+
85
+ assert_selector("textarea[name='author[biography]'][rows='8']", text: "Famous author")
86
+ end
87
+
88
+ test "renders label with specified text" do
89
+ render_inline(@component)
90
+
91
+ assert_selector("label[for='author_biography']", text: "Custom label")
92
+ end
93
+
94
+ test "renders hint when provided" do
95
+ render_inline(@component)
96
+
97
+ assert_selector("p[id=author_biography_hint]", text: "Custom hint")
98
+ end
99
+
100
+ test "initializes the input component with the correct options" do
101
+ expected_options = {
102
+ attribute: :biography,
103
+ form: @form,
104
+ label: {content: "Custom label"},
105
+ input: {options: {rows: 8}},
106
+ hint: {content: "Custom hint"}
107
+ }
108
+ assert_equal expected_options, @component.send(:options)
109
+ end
110
+ end
111
+
112
+ class TextIndexTest < ViewComponent::TestCase
113
+ def setup
114
+ @field = Uchi::Field::Text.new(:biography)
115
+ @record = Author.new(name: "J.R.R. Tolkien", biography: "Famous author of The Lord of the Rings")
116
+ @repository = Uchi::Repositories::Author.new
117
+
118
+ @component = Uchi::Field::Text::Index.new(
119
+ field: @field,
120
+ record: @record,
121
+ repository: @repository
122
+ )
123
+ end
124
+
125
+ test "inherits from Base component" do
126
+ assert_kind_of Uchi::Field::Base::Index, @component
127
+ end
128
+
129
+ test "renders the field content" do
130
+ result = render_inline(@component)
131
+
132
+ assert_includes result.to_html, "Famous author of The Lord of the Rings"
133
+ end
134
+ end
135
+
136
+ class TextShowTest < ViewComponent::TestCase
137
+ def setup
138
+ @field = Uchi::Field::Text.new(:biography)
139
+ @record = Author.new(name: "J.R.R. Tolkien", biography: "Famous author of The Lord of the Rings")
140
+ @repository = Uchi::Repositories::Author.new
141
+
142
+ @component = Uchi::Field::Text::Show.new(
143
+ field: @field,
144
+ record: @record,
145
+ repository: @repository
146
+ )
147
+ end
148
+
149
+ test "inherits from Base component" do
150
+ assert_kind_of Uchi::Field::Base::Show, @component
151
+ end
152
+
153
+ test "renders the field content" do
154
+ result = render_inline(@component)
155
+
156
+ assert_includes result.to_html, "Famous author of The Lord of the Rings"
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,171 @@
1
+ require "test_helper"
2
+
3
+ module Uchi
4
+ module Ui
5
+ module Form
6
+ module Input
7
+ class CollectionCheckboxesTest < ViewComponent::TestCase
8
+ class Tag
9
+ attr_accessor :id, :name
10
+
11
+ def initialize(id, name)
12
+ @id = id
13
+ @name = name
14
+ end
15
+ end
16
+
17
+ def setup
18
+ @tags = [
19
+ Tag.new(1, "Ruby"),
20
+ Tag.new(2, "Rails"),
21
+ Tag.new(3, "JavaScript")
22
+ ]
23
+
24
+ @record = Author.new(name: "Test Author")
25
+ @record.define_singleton_method(:tag_ids) { [1, 3] }
26
+ @record.define_singleton_method(:tag_ids=) { |val| @tag_ids = val }
27
+
28
+ @view_context = ActionController::Base.new.view_context
29
+ @form = ActionView::Helpers::FormBuilder.new(:author, @record, @view_context, {})
30
+
31
+ @component = Uchi::Ui::Form::Input::CollectionCheckboxes.new(
32
+ attribute: :tag_ids,
33
+ collection: @tags,
34
+ form: @form,
35
+ label: "Select Tags",
36
+ hint: "Choose one or more tags",
37
+ value_method: :id,
38
+ text_method: :name
39
+ )
40
+ end
41
+
42
+ test "renders collection checkboxes" do
43
+ render_inline(@component)
44
+
45
+ assert_selector("input[type='hidden'][name='author[tag_ids][]']", visible: :all, count: 1)
46
+ assert_selector("input[type='checkbox'][name='author[tag_ids][]']", count: 3)
47
+ end
48
+
49
+ test "renders label when provided" do
50
+ render_inline(@component)
51
+
52
+ assert_selector("label", text: "Select Tags")
53
+ end
54
+
55
+ test "does not render label when not provided" do
56
+ component = Uchi::Ui::Form::Input::CollectionCheckboxes.new(
57
+ attribute: :tag_ids,
58
+ collection: @tags,
59
+ form: @form
60
+ )
61
+ render_inline(component)
62
+
63
+ assert_no_selector("label", text: "Select Tags")
64
+ end
65
+
66
+ test "renders hint when provided" do
67
+ render_inline(@component)
68
+
69
+ assert_selector("p", text: "Choose one or more tags")
70
+ end
71
+
72
+ test "does not render hint when not provided" do
73
+ component = Uchi::Ui::Form::Input::CollectionCheckboxes.new(
74
+ attribute: :tag_ids,
75
+ collection: @tags,
76
+ form: @form
77
+ )
78
+ render_inline(component)
79
+
80
+ assert_no_selector("p", text: "Choose one or more tags")
81
+ end
82
+
83
+ test "renders checkbox labels for each item" do
84
+ render_inline(@component)
85
+
86
+ assert_selector("label", text: "Ruby")
87
+ assert_selector("label", text: "Rails")
88
+ assert_selector("label", text: "JavaScript")
89
+ end
90
+
91
+ test "renders checkboxes with correct values" do
92
+ render_inline(@component)
93
+
94
+ assert_selector("input[type='checkbox'][value='1']")
95
+ assert_selector("input[type='checkbox'][value='2']")
96
+ assert_selector("input[type='checkbox'][value='3']")
97
+ end
98
+
99
+ test "applies disabled state to all checkboxes" do
100
+ component = Uchi::Ui::Form::Input::CollectionCheckboxes.new(
101
+ attribute: :tag_ids,
102
+ collection: @tags,
103
+ form: @form,
104
+ disabled: true
105
+ )
106
+ render_inline(component)
107
+
108
+ assert_selector("input[type='checkbox'][disabled]", count: 3)
109
+ end
110
+
111
+ test "renders errors when present" do
112
+ @record.errors.add(:tag_ids, "must be selected")
113
+ render_inline(@component)
114
+
115
+ assert_selector("p", text: /MUST BE SELECTED/i)
116
+ end
117
+
118
+ test "applies error styling when errors present" do
119
+ @record.errors.add(:tag_ids, "must be selected")
120
+ component = Uchi::Ui::Form::Input::CollectionCheckboxes.new(
121
+ attribute: :tag_ids,
122
+ collection: @tags,
123
+ form: @form
124
+ )
125
+
126
+ render_inline(component)
127
+
128
+ # Check that error classes are applied to checkboxes
129
+ assert component.errors?
130
+ end
131
+
132
+ test "uses custom value and text methods" do
133
+ custom_tags = [
134
+ OpenStruct.new(identifier: "ruby", title: "Ruby Programming"),
135
+ OpenStruct.new(identifier: "rails", title: "Rails Framework")
136
+ ]
137
+
138
+ component = Uchi::Ui::Form::Input::CollectionCheckboxes.new(
139
+ attribute: :tag_ids,
140
+ collection: custom_tags,
141
+ form: @form,
142
+ value_method: :identifier,
143
+ text_method: :title
144
+ )
145
+
146
+ render_inline(component)
147
+
148
+ assert_selector("input[type='checkbox'][value='ruby']")
149
+ assert_selector("input[type='checkbox'][value='rails']")
150
+ assert_selector("label", text: "Ruby Programming")
151
+ assert_selector("label", text: "Rails Framework")
152
+ end
153
+
154
+ test "handles empty collection" do
155
+ component = Uchi::Ui::Form::Input::CollectionCheckboxes.new(
156
+ attribute: :tag_ids,
157
+ collection: [],
158
+ form: @form
159
+ )
160
+
161
+ render_inline(component)
162
+
163
+ # Should only have the hidden field, no checkboxes
164
+ assert_selector("input[type='hidden'][name='author[tag_ids][]']", visible: :all, count: 1)
165
+ assert_selector("input[type='checkbox']", count: 0)
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
@@ -47,8 +47,9 @@ module Uchi
47
47
  end
48
48
 
49
49
  test "PATCH update redirects to show after successful update" do
50
+ srand(42)
50
51
  patch uchi_author_url(id: @author.id), params: {author: {name: "Updated Name"}}
51
- assert_redirected_to uchi_author_url(id: @author.id)
52
+ assert_redirected_to uchi_author_url(id: @author.id, uniq: 0.3745401188473625)
52
53
  end
53
54
 
54
55
  test "PATCH update responds with 303 after successful update" do
@@ -65,13 +66,13 @@ module Uchi
65
66
  test "PATCH update flashes a translated success message after successful update" do
66
67
  ::I18n.with_locale(:da) do
67
68
  patch uchi_author_url(id: @author.id), params: {author: {name: "Updated Name"}}
68
- assert_equal "Dine ændringer til forfatteren blev gemt", flash[:notice]
69
+ assert_equal "Dine ændringer til forfatteren blev gemt", flash[:success]
69
70
  end
70
71
  end
71
72
 
72
73
  test "PATCH update falls back to default success message after successful update" do
73
74
  patch uchi_author_url(id: @author.id), params: {author: {name: "Updated Name"}}
74
- assert_equal "Your changes have been saved", flash[:notice]
75
+ assert_equal "Your changes have been saved", flash[:success]
75
76
  end
76
77
 
77
78
  test "PATCH update rerenders the edit view after unsuccessful update" do
@@ -97,7 +98,7 @@ module Uchi
97
98
  test "POST create flashes a translated success message after successful creation" do
98
99
  ::I18n.with_locale(:da) do
99
100
  post uchi_authors_url, params: {author: {name: "New Author"}}
100
- assert_equal "Forfatteren er blevet tilføjet", flash[:notice]
101
+ assert_equal "Forfatteren er blevet tilføjet", flash[:success]
101
102
  end
102
103
  end
103
104
 
@@ -1,3 +1,5 @@
1
1
  class Author < ApplicationRecord
2
+ has_and_belongs_to_many :books
3
+
2
4
  validates :name, presence: true
3
5
  end
@@ -1,3 +1,4 @@
1
1
  class Book < ApplicationRecord
2
+ has_and_belongs_to_many :authors
2
3
  has_many :titles
3
4
  end
@@ -3,10 +3,10 @@ module Uchi
3
3
  class Author < Repository
4
4
  def fields
5
5
  [
6
- Field::Number.new(:id, on: [:index, :show]),
6
+ Field::Number.new(:id).on(:index, :show),
7
7
  Field::String.new(:name),
8
8
  Field::Date.new(:born_on),
9
- Field::String.new(:biography, on: [:edit, :new, :show])
9
+ Field::Text.new(:biography).on(:edit, :new, :show)
10
10
  ]
11
11
  end
12
12
 
@@ -3,6 +3,7 @@ da:
3
3
  uchi:
4
4
  common:
5
5
  cancel: "Annuller"
6
+ no_records_found: "Ingen resultater"
6
7
  save: "Gem"
7
8
  repository:
8
9
  common:
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddAuthorBooksJoinTable < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_join_table :authors, :books do |t|
6
+ t.index [:author_id, :book_id]
7
+ end
8
+ end
9
+ end
@@ -10,7 +10,7 @@
10
10
  #
11
11
  # It's strongly recommended that you check this file into your version control system.
12
12
 
13
- ActiveRecord::Schema[8.0].define(version: 2025_10_05_131811) do
13
+ ActiveRecord::Schema[8.0].define(version: 2025_10_31_140958) do
14
14
  create_table "authors", force: :cascade do |t|
15
15
  t.string "name"
16
16
  t.text "biography"
@@ -19,6 +19,12 @@ ActiveRecord::Schema[8.0].define(version: 2025_10_05_131811) do
19
19
  t.datetime "updated_at", null: false
20
20
  end
21
21
 
22
+ create_table "authors_books", id: false, force: :cascade do |t|
23
+ t.integer "author_id", null: false
24
+ t.integer "book_id", null: false
25
+ t.index ["author_id", "book_id"], name: "index_authors_books_on_author_id_and_book_id"
26
+ end
27
+
22
28
  create_table "books", force: :cascade do |t|
23
29
  t.string "original_title"
24
30
  t.datetime "created_at", null: false
@@ -30,24 +30,38 @@ class UchiFieldTest < ActiveSupport::TestCase
30
30
  assert_equal :name, @field.param_key
31
31
  end
32
32
 
33
+ test "#permitted_param returns name as symbol" do
34
+ assert_equal :name, @field.permitted_param
35
+ end
36
+
33
37
  test "#searchable? returns false by default" do
34
38
  assert_not @field.searchable?
35
39
  end
36
40
 
37
41
  test "#searchable? returns true when explicitly set" do
38
- field = Uchi::Field.new(:name, searchable: true)
42
+ field = Uchi::Field.new(:name).searchable(true)
39
43
  assert field.searchable?
40
44
  end
41
45
 
46
+ test "#searchable? returns default (false) when explicitly set to nil" do
47
+ field = Uchi::Field.new(:name).searchable(nil)
48
+ assert_not field.searchable?
49
+ end
50
+
42
51
  test "#sortable? returns true by default" do
43
52
  assert @field.sortable?
44
53
  end
45
54
 
46
55
  test "#sortable? returns false when explicitly set" do
47
- field = Uchi::Field.new(:name, sortable: false)
56
+ field = Uchi::Field.new(:name).sortable(false)
48
57
  assert_not field.sortable?
49
58
  end
50
59
 
60
+ test "#sortable? returns default (true) when explicitly set to nil" do
61
+ field = Uchi::Field.new(:name).sortable(nil)
62
+ assert field.sortable?
63
+ end
64
+
51
65
  test "#value uses reader to get value from record" do
52
66
  record = OpenStruct.new(name: "Test Name")
53
67
  assert_equal "Test Name", @field.value(record)
@@ -55,7 +69,7 @@ class UchiFieldTest < ActiveSupport::TestCase
55
69
 
56
70
  test "#value uses custom reader when provided" do
57
71
  custom_reader = ->(record, field_name) { "Custom: #{record.public_send(field_name)}" }
58
- field = Uchi::Field.new(:name, reader: custom_reader)
72
+ field = Uchi::Field.new(:name).reader(custom_reader)
59
73
  record = OpenStruct.new(name: "Test")
60
74
 
61
75
  assert_equal "Custom: Test", field.value(record)
@@ -49,9 +49,11 @@ class UchiRepositoryTranslateTest < ActiveSupport::TestCase
49
49
  end
50
50
 
51
51
  test "#destroy_dialog_title returns translation from uchi.repository.author.dialog.destroy.title" do
52
- record = Author.new(name: "J. K. Rowling")
53
- result = @translate.destroy_dialog_title(record)
54
- assert_equal "Er du sikker på, at du vil slette J. K. Rowling?", result
52
+ I18n.with_locale(:da) do
53
+ record = Author.new(name: "J. K. Rowling")
54
+ result = @translate.destroy_dialog_title(record)
55
+ assert_equal "Er du sikker på, at du vil slette J. K. Rowling?", result
56
+ end
55
57
  end
56
58
 
57
59
  test "#destroy_dialog_title falls back to default translation" do
@@ -169,6 +171,13 @@ class UchiRepositoryTranslateTest < ActiveSupport::TestCase
169
171
  assert_equal "Loading...", result
170
172
  end
171
173
 
174
+ test "#no_records_found returns translation from uchi.common.no_records_found" do
175
+ I18n.with_locale(:da) do
176
+ result = @translate.no_records_found
177
+ assert_equal "Ingen resultater", result
178
+ end
179
+ end
180
+
172
181
  test "#plural_name returns translation from uchi.repository.author.model with count 2" do
173
182
  I18n.with_locale(:da) do
174
183
  result = @translate.plural_name
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: uchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jakob Skjerning
@@ -9,20 +9,6 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
- - !ruby/object:Gem::Dependency
13
- name: pagy
14
- requirement: !ruby/object:Gem::Requirement
15
- requirements:
16
- - - ">="
17
- - !ruby/object:Gem::Version
18
- version: 43.0.0.rc1
19
- type: :runtime
20
- prerelease: false
21
- version_requirements: !ruby/object:Gem::Requirement
22
- requirements:
23
- - - ">="
24
- - !ruby/object:Gem::Version
25
- version: 43.0.0.rc1
26
12
  - !ruby/object:Gem::Dependency
27
13
  name: rails
28
14
  requirement: !ruby/object:Gem::Requirement
@@ -65,8 +51,12 @@ dependencies:
65
51
  - - ">="
66
52
  - !ruby/object:Gem::Version
67
53
  version: '4.0'
54
+ description: Level up your scaffolds with a modern admin backend framework, designed
55
+ for Rails developers who demand both beauty, functionality, and extensibility. Uchi
56
+ provides a set of components and conventions for creating user interfaces that are
57
+ both powerful and easy to use.
68
58
  email:
69
- - jakob@mentalized.net
59
+ - jakob@substancelab.com
70
60
  executables: []
71
61
  extensions: []
72
62
  extra_rdoc_files: []
@@ -74,6 +64,9 @@ files:
74
64
  - ".github/dependabot.yml"
75
65
  - ".github/workflows/build.yml"
76
66
  - ".github/workflows/lint.yml"
67
+ - CHANGELOG.md
68
+ - docs/fields.md
69
+ - docs/repositories.md
77
70
  - package.json
78
71
  - sig/uchi.rbs
79
72
  - test/components/uchi/field/belongs_to_test.rb
@@ -81,10 +74,13 @@ files:
81
74
  - test/components/uchi/field/boolean_test.rb
82
75
  - test/components/uchi/field/date_test.rb
83
76
  - test/components/uchi/field/date_time_test.rb
77
+ - test/components/uchi/field/has_and_belongs_to_many_test.rb
84
78
  - test/components/uchi/field/has_many_test.rb
85
79
  - test/components/uchi/field/id_test.rb
86
80
  - test/components/uchi/field/number_test.rb
87
81
  - test/components/uchi/field/string_test.rb
82
+ - test/components/uchi/field/text_test.rb
83
+ - test/components/uchi/ui/form/input/collection_checkboxes_test.rb
88
84
  - test/controllers/uchi/authors_controller_test.rb
89
85
  - test/controllers/uchi/repository_controller_test.rb
90
86
  - test/controllers/uchi/scoped_repository_controller_test.rb
@@ -136,6 +132,7 @@ files:
136
132
  - test/dummy/db/migrate/20251002183635_create_authors.rb
137
133
  - test/dummy/db/migrate/20251005131726_create_books.rb
138
134
  - test/dummy/db/migrate/20251005131811_create_titles.rb
135
+ - test/dummy/db/migrate/20251031140958_add_author_books_join_table.rb
139
136
  - test/dummy/db/schema.rb
140
137
  - test/dummy/log/.keep
141
138
  - test/dummy/public/400.html
@@ -162,7 +159,7 @@ files:
162
159
  homepage: https://github.com/substancelab/uchi
163
160
  licenses: []
164
161
  metadata:
165
- homepage_uri: https://github.com/substancelab/uchi
162
+ homepage_uri: https://www.uchiadmin.com/
166
163
  source_code_uri: https://github.com/substancelab/uchi
167
164
  rdoc_options: []
168
165
  require_paths:
@@ -180,5 +177,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
180
177
  requirements: []
181
178
  rubygems_version: 3.6.9
182
179
  specification_version: 4
183
- summary: Some automated admin stuff for Rails apps
180
+ summary: Build usable and extensible admin panels for your Ruby on Rails application
181
+ in minutes.
184
182
  test_files: []