b4um_generators 0.2.17 → 0.3.0

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: 39e3c318690d26739ba27564ca702e481d33cce273b21a46b8c304a60251d31a
4
- data.tar.gz: 657648494cdca9cd40e9aafc9f333ee49967c6c31786ae67fdbc951575ca158a
3
+ metadata.gz: 167af7d5ad10e01abf428bf058e8d0ebc1b85e7791e43882dd1c53966d1ef89f
4
+ data.tar.gz: 715da7ed28265ec0f4451684dd35d375c749fbb8eb7f06b915a13e8bbadea26b
5
5
  SHA512:
6
- metadata.gz: e375a782413296d33953bbe9192657a7e8772ee9095480b2c30c1a4b3d13da6dd3fefe0b04b170f7bc8747939beb43a3728f9b73e018cbcdf1c7ab032421d167
7
- data.tar.gz: 9fd996e86f29f3c47a791a2cda1f663c4e465b576ef240cab9c88adea8d572bfe48ab91c833830ae8d27bb924bd71bb0d21d2a74fb0cc3b85436a94f81dcd662
6
+ metadata.gz: 2478f3db2a6f42ca3ca83158f86d9313676b5263c07b324f69d786b587ed603ab5af6a2319d123e7524e4cab508530431a161c18ed3e5e1deea50cd581f90a4f
7
+ data.tar.gz: 7c874c56a5057bbb5110b683ae3cdfbfd84c4471f5f40cc96e2f53fb1ca32843df64f8f5dde7ba3614274cad19e3fa33e5e563c217b9d46394f4e5dbbbdb9c16
data/README.de.md CHANGED
@@ -732,6 +732,86 @@ Die Suche bietet:
732
732
  - Kein zusätzliches Such-Gem erforderlich
733
733
  - Sichere wiederholte Ausführung des Generators
734
734
 
735
+ ## Sortable
736
+
737
+ Füge einer bestehenden Ressource eine dauerhaft gespeicherte Sortierreihenfolge hinzu:
738
+
739
+ ```bash
740
+ bin/rails generate b4um:sortable Product
741
+ bin/rails db:migrate
742
+ ```
743
+
744
+ Der Generator fügt eine `position`-Spalte hinzu und weist bestehenden Datensätzen beim Ausführen der generierten
745
+ Migration automatisch Positionen zu.
746
+
747
+ Neue Datensätze werden automatisch am Ende der Liste eingefügt.
748
+
749
+ Standardmäßig wird die bestehende Index-Seite zur sortierbaren Seite:
750
+
751
+ ```text
752
+ /products
753
+ ```
754
+
755
+ Das bestehende Index-Layout wird auf das b4um-Sortable-Layout umgestellt. Die gespeicherte Position wird außerdem als
756
+ Standardsortierung der Ressource verwendet.
757
+
758
+ Sortable bietet:
759
+
760
+ - Dauerhaft in der Datenbank gespeicherte ganzzahlige Positionen
761
+ - Automatische Positionierung bestehender Datensätze während der Migration
762
+ - Automatische Positionierung neuer Datensätze am Ende
763
+ - Drag-and-Drop-Sortierung mit Stimulus
764
+ - Direkte Änderung der Position über ein Zahlenfeld
765
+ - Automatisches Verschieben der betroffenen Datensätze
766
+ - Lückenlose Positionen nach dem Verschieben oder Löschen von Datensätzen
767
+ - Serverseitige Sortierung innerhalb einer Datenbanktransaktion
768
+ - CSRF-geschützte Update-Anfragen
769
+ - Integration mit b4um Search und Pagination
770
+ - Kein zusätzliches Sortier-Gem erforderlich
771
+ - Sichere wiederholte Ausführung des Generators
772
+
773
+ ### Separate Sortierseite
774
+
775
+ Mit `--path` kann eine separate Sortierseite erstellt werden:
776
+
777
+ ```bash
778
+ bin/rails generate b4um:sortable Product --path=sortierung
779
+ bin/rails db:migrate
780
+ ```
781
+
782
+ Dadurch wird die sortierbare Seite genau unter folgender URL erstellt:
783
+
784
+ ```text
785
+ /sortierung
786
+ ```
787
+
788
+ während der normale Ressourcen-Index weiterhin unter
789
+
790
+ ```text
791
+ /products
792
+ ```
793
+
794
+ verfügbar bleibt.
795
+
796
+ Das bestehende Index-Layout, beispielsweise Bento oder Table, bleibt unverändert. Es verwendet weiterhin die
797
+ gespeicherte `position`-Sortierung.
798
+
799
+ Die separate Sortierseite verwendet den bestehenden Controller der Ressource. Es wird kein zusätzlicher Controller und
800
+ kein Namespace erzeugt.
801
+
802
+ Der mit `--path` angegebene Wert bestimmt direkt die URL der separaten Sortierseite. Zum Beispiel:
803
+
804
+ ```bash
805
+ bin/rails generate b4um:sortable Product --path=produktuebersicht
806
+ bin/rails generate b4um:sortable Product --path=verwaltung
807
+ bin/rails generate b4um:sortable Product --path=admin
808
+ ```
809
+
810
+ Dadurch entstehen entsprechend `/produktuebersicht`, `/verwaltung` und `/admin`.
811
+
812
+ Eine separate Sortierseite ermöglicht außerdem, nur die `sort`-Action durch die Authentifizierung zu schützen, während
813
+ der normale Ressourcen-Index öffentlich bleiben kann.
814
+
735
815
  ## Kommentare
736
816
 
737
817
  Füge einem bestehenden Modell polymorphe Kommentare hinzu:
@@ -1135,7 +1215,7 @@ wird.
1135
1215
 
1136
1216
  ## Version
1137
1217
 
1138
- Aktuelle Version: `0.2.17`
1218
+ Aktuelle Version: `0.3.0`
1139
1219
 
1140
1220
  ## Autor
1141
1221
 
data/README.md CHANGED
@@ -725,6 +725,81 @@ Search provides:
725
725
  - No additional search gem
726
726
  - Safe repeated generator runs
727
727
 
728
+ ## Sortable
729
+
730
+ Add persistent sortable positioning to an existing resource:
731
+
732
+ ```bash
733
+ bin/rails generate b4um:sortable Product
734
+ bin/rails db:migrate
735
+ ```
736
+
737
+ The generator adds a `position` column and assigns positions to existing records when the generated migration is run.
738
+
739
+ New records are automatically added at the end of the list.
740
+
741
+ By default, the existing resource index becomes the sortable page:
742
+
743
+ ```text
744
+ /products
745
+ ```
746
+
747
+ The existing index layout is switched to the b4um sortable layout. The stored position is also used as the default
748
+ ordering for the resource.
749
+
750
+ Sortable provides:
751
+
752
+ - Persistent integer positions stored in the database
753
+ - Automatic positioning of existing records during migration
754
+ - Automatic positioning of new records at the end
755
+ - Drag-and-drop sorting with Stimulus
756
+ - Direct position changes using a number field
757
+ - Automatic shifting of affected records
758
+ - Contiguous positions after moving or deleting records
759
+ - Server-side sorting inside a database transaction
760
+ - CSRF-protected update requests
761
+ - Integration with b4um Search and Pagination
762
+ - No additional sorting gem
763
+ - Safe repeated generator runs
764
+
765
+ ### Separate Sortable Page
766
+
767
+ A separate sortable page can be created with `--path`:
768
+
769
+ ```bash
770
+ bin/rails generate b4um:sortable Product --path=sortierung
771
+ bin/rails db:migrate
772
+ ```
773
+
774
+ This creates the sortable page at exactly:
775
+
776
+ ```text
777
+ /sortierung
778
+ ```
779
+
780
+ while the regular resource index remains available at:
781
+
782
+ ```text
783
+ /products
784
+ ```
785
+
786
+ The existing index layout, such as Bento or Table, remains unchanged. It still uses the stored `position` ordering.
787
+
788
+ The separate sortable page uses the existing resource controller. No additional controller or namespace is generated.
789
+
790
+ The value passed to `--path` directly defines the URL of the separate sortable page. For example:
791
+
792
+ ```bash
793
+ bin/rails generate b4um:sortable Product --path=produktuebersicht
794
+ bin/rails generate b4um:sortable Product --path=verwaltung
795
+ bin/rails generate b4um:sortable Product --path=admin
796
+ ```
797
+
798
+ These create `/produktuebersicht`, `/verwaltung`, and `/admin` respectively.
799
+
800
+ Using a separate sortable page also makes it possible to protect only the `sort` action with authentication while
801
+ keeping the regular resource index public.
802
+
728
803
  ## Comments
729
804
 
730
805
  Add polymorphic comments to an existing model:
@@ -1124,7 +1199,7 @@ The gem can be tested locally by referencing the repository with `path:` from a
1124
1199
 
1125
1200
  ## Version
1126
1201
 
1127
- Current version: `0.2.17`
1202
+ Current version: `0.3.0`
1128
1203
 
1129
1204
  ## Author
1130
1205
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module B4umGenerators
4
- VERSION = "0.2.17"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -943,6 +943,72 @@ module B4um
943
943
  - No additional search gem required
944
944
  - Safe repeated generator runs
945
945
 
946
+ SORTABLE
947
+
948
+ bin/rails generate b4um:sortable MODEL
949
+
950
+ Adds persistent sortable positioning to an existing resource.
951
+
952
+ Example:
953
+
954
+ bin/rails generate b4um:sortable Product
955
+ bin/rails db:migrate
956
+
957
+ Existing records receive positions automatically when the
958
+ generated migration is run.
959
+
960
+ New records are automatically added at the end of the list.
961
+
962
+ By default, the existing index page becomes the sortable page:
963
+
964
+ /products
965
+
966
+ The existing index layout is switched to the b4um sortable
967
+ layout. The stored position is also used as the default
968
+ ordering for the resource.
969
+
970
+ A separate sortable page can be created with --path:
971
+
972
+ bin/rails generate b4um:sortable Product --path=sortierung
973
+ bin/rails db:migrate
974
+
975
+ This creates the sortable page at exactly:
976
+
977
+ /sortierung
978
+
979
+ The existing resource index remains unchanged:
980
+
981
+ /products
982
+
983
+ The separate page uses the existing resource controller.
984
+ No additional controller or namespace is generated.
985
+
986
+ Other examples:
987
+
988
+ --path=produktuebersicht
989
+ --path=verwaltung
990
+ --path=admin
991
+
992
+ The value passed to --path defines the URL of the separate
993
+ sortable page.
994
+
995
+ Sortable features:
996
+
997
+ - Persistent integer positions stored in the database
998
+ - Existing records receive positions during migration
999
+ - New records are automatically placed at the end
1000
+ - Drag-and-drop sorting with Stimulus
1001
+ - Positions can also be changed directly with a number field
1002
+ - Moving a record automatically shifts the affected records
1003
+ - Positions remain contiguous after moving or deleting records
1004
+ - Server-side sorting inside a database transaction
1005
+ - CSRF-protected update requests
1006
+ - No additional sorting gem required
1007
+ - Works with b4um Search and Pagination
1008
+ - Stored ordering is reflected by the regular resource index
1009
+ - Optional separate sortable page with --path
1010
+ - Safe repeated generator runs
1011
+
946
1012
 
947
1013
  COMMENTS
948
1014
 
@@ -0,0 +1,158 @@
1
+ /* -------------------------------------------------------
2
+ Sortable
3
+ ------------------------------------------------------- */
4
+
5
+ .b4um-sortable {
6
+ display: grid;
7
+ gap: 0.75rem;
8
+ }
9
+
10
+ .b4um-sortable__item {
11
+ display: grid;
12
+ grid-template-columns: auto minmax(0, 1fr) auto;
13
+ align-items: center;
14
+ gap: 1.25rem;
15
+
16
+ padding: 1rem 1.25rem;
17
+
18
+ background: var(--b4um-surface);
19
+ border: 1px solid var(--b4um-border-light);
20
+ border-radius: var(--b4um-radius);
21
+
22
+ transition:
23
+ border-color 0.2s ease,
24
+ box-shadow 0.2s ease,
25
+ opacity 0.2s ease;
26
+ }
27
+
28
+ .b4um-sortable__item:hover {
29
+ border-color: var(--b4um-border-hover);
30
+ }
31
+
32
+ .b4um-sortable__item--dragging {
33
+ opacity: 0.5;
34
+ box-shadow: 0 8px 24px var(--b4um-primary-shadow-soft);
35
+ }
36
+
37
+ /* -------------------------------------------------------
38
+ Controls
39
+ ------------------------------------------------------- */
40
+
41
+ .b4um-sortable__controls {
42
+ display: flex;
43
+ align-items: center;
44
+ gap: 0.5rem;
45
+ }
46
+
47
+ .b4um-sortable__position {
48
+ box-sizing: border-box;
49
+
50
+ width: 4rem;
51
+ min-width: 4rem;
52
+ height: 2.75rem;
53
+
54
+ margin: 0;
55
+ padding: 0.5rem;
56
+
57
+ background: var(--b4um-surface);
58
+ color: var(--b4um-text);
59
+
60
+ border: 1px solid var(--b4um-border);
61
+ border-radius: 10px;
62
+
63
+ font: inherit;
64
+ text-align: center;
65
+ }
66
+
67
+ .b4um-sortable__position:hover {
68
+ border-color: var(--b4um-border-hover);
69
+ }
70
+
71
+ .b4um-sortable__position:focus {
72
+ border-color: var(--b4um-primary);
73
+ outline: 3px solid var(--b4um-primary-focus);
74
+ outline-offset: 1px;
75
+ }
76
+
77
+ /* -------------------------------------------------------
78
+ Drag handle
79
+ ------------------------------------------------------- */
80
+
81
+ .b4um-sortable__handle {
82
+ display: inline-flex;
83
+ align-items: center;
84
+ justify-content: center;
85
+
86
+ width: 2.75rem;
87
+ height: 2.75rem;
88
+
89
+ margin: 0;
90
+ padding: 0;
91
+
92
+ background: transparent;
93
+ color: var(--b4um-text-secondary);
94
+
95
+ border: 0;
96
+ border-radius: 10px;
97
+
98
+ font: inherit;
99
+ font-size: 1.2rem;
100
+ line-height: 1;
101
+
102
+ cursor: grab;
103
+
104
+ transition:
105
+ background 0.2s ease,
106
+ color 0.2s ease;
107
+ }
108
+
109
+ .b4um-sortable__handle:hover {
110
+ background: var(--b4um-primary-soft);
111
+ color: var(--b4um-primary);
112
+ }
113
+
114
+ .b4um-sortable__handle:active {
115
+ cursor: grabbing;
116
+ }
117
+
118
+ /* -------------------------------------------------------
119
+ Content
120
+ ------------------------------------------------------- */
121
+
122
+ .b4um-sortable__content {
123
+ min-width: 0;
124
+ }
125
+
126
+ .b4um-sortable__content .resource {
127
+ margin: 0;
128
+ }
129
+
130
+ /* -------------------------------------------------------
131
+ Actions
132
+ ------------------------------------------------------- */
133
+
134
+ .b4um-sortable__actions {
135
+ display: flex;
136
+ align-items: center;
137
+ justify-content: flex-end;
138
+ }
139
+
140
+ .b4um-sortable__actions .button {
141
+ margin: 0;
142
+ white-space: nowrap;
143
+ }
144
+
145
+ /* -------------------------------------------------------
146
+ Responsive
147
+ ------------------------------------------------------- */
148
+
149
+ @media (max-width: 48rem) {
150
+ .b4um-sortable__item {
151
+ grid-template-columns: 1fr;
152
+ gap: 0.75rem;
153
+ }
154
+
155
+ .b4um-sortable__actions {
156
+ justify-content: flex-start;
157
+ }
158
+ }
@@ -22,6 +22,7 @@
22
22
  @import 'b4um/hero.css';
23
23
  @import 'b4um/cards.css';
24
24
  @import 'b4um/tables.css';
25
+ @import 'b4um/sortable.css';
25
26
  @import 'b4um/pagination.css';
26
27
  @import 'b4um/badges.css';
27
28
  @import 'b4um/callouts.css';
@@ -108,20 +108,33 @@ module B4um
108
108
  /
109
109
  @#{Regexp.escape(plural_table_name)}\s*=\s*
110
110
  b4um_search\(\s*
111
- #{Regexp.escape(class_name)}\.all,\s*
111
+ (?<collection>
112
+ #{Regexp.escape(class_name)}
113
+ \.(?:all|order\(\s*:position\s*,\s*:id\s*\))
114
+ )\s*,\s*
112
115
  params\[:q\]\s*
113
116
  \)
114
117
  /x
115
118
  end
116
119
 
117
120
  def paginate_existing_search
121
+ controller_content = File.read(controller_path)
122
+ match = controller_content.match(search_pattern)
123
+
124
+ unless match
125
+ raise Thor::Error,
126
+ "Could not find the search collection in app/controllers/#{plural_table_name}_controller.rb"
127
+ end
128
+
129
+ collection = match[:collection]
130
+
118
131
  gsub_file(
119
132
  controller_path,
120
133
  search_pattern,
121
134
  [
122
135
  "@#{plural_table_name}, @pagination = b4um_paginate(",
123
136
  " b4um_search(",
124
- " #{class_name}.all,",
137
+ " #{collection},",
125
138
  " params[:q]",
126
139
  " ),",
127
140
  " per_page: #{options[:per_page]}",
@@ -131,19 +144,33 @@ module B4um
131
144
  end
132
145
 
133
146
  def paginate_plain_collection(controller_content)
134
- old_index = "@#{plural_table_name} = #{class_name}.all"
147
+ plain_collection = "#{class_name}.all"
148
+ sortable_collection = "#{class_name}.order(:position, :id)"
135
149
 
136
- unless controller_content.include?(old_index)
150
+ collection =
151
+ if controller_content.include?(
152
+ "@#{plural_table_name} = #{sortable_collection}"
153
+ )
154
+ sortable_collection
155
+ elsif controller_content.include?(
156
+ "@#{plural_table_name} = #{plain_collection}"
157
+ )
158
+ plain_collection
159
+ end
160
+
161
+ unless collection
137
162
  raise Thor::Error,
138
163
  "Could not find the index collection in app/controllers/#{plural_table_name}_controller.rb"
139
164
  end
140
165
 
166
+ old_index = "@#{plural_table_name} = #{collection}"
167
+
141
168
  gsub_file(
142
169
  controller_path,
143
170
  old_index,
144
171
  [
145
172
  "@#{plural_table_name}, @pagination = b4um_paginate(",
146
- " #{class_name}.all,",
173
+ " #{collection},",
147
174
  " per_page: #{options[:per_page]}",
148
175
  " )"
149
176
  ].join("\n")
@@ -92,19 +92,33 @@ module B4um
92
92
  return
93
93
  end
94
94
 
95
- old_index = "@#{file_name.pluralize} = #{class_name}.all"
95
+ plain_collection = "#{class_name}.all"
96
+ sortable_collection = "#{class_name}.order(:position, :id)"
96
97
 
97
- unless controller_content.include?(old_index)
98
+ collection =
99
+ if controller_content.include?(
100
+ "@#{file_name.pluralize} = #{sortable_collection}"
101
+ )
102
+ sortable_collection
103
+ elsif controller_content.include?(
104
+ "@#{file_name.pluralize} = #{plain_collection}"
105
+ )
106
+ plain_collection
107
+ end
108
+
109
+ unless collection
98
110
  raise Thor::Error,
99
111
  "Could not find the index collection in #{controller_path}"
100
112
  end
101
113
 
102
- search_code = <<~RUBY.chomp
103
- @#{file_name.pluralize} = b4um_search(
104
- #{class_name}.all,
105
- params[:q]
106
- )
107
- RUBY
114
+ old_index = "@#{file_name.pluralize} = #{collection}"
115
+
116
+ search_code = [
117
+ "@#{file_name.pluralize} = b4um_search(",
118
+ " #{collection},",
119
+ " params[:q]",
120
+ " )"
121
+ ].join("\n")
108
122
 
109
123
  gsub_file(
110
124
  controller_path,
@@ -0,0 +1,529 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/named_base"
5
+
6
+ module B4um
7
+ module Generators
8
+ class SortableGenerator < Rails::Generators::NamedBase
9
+ include Rails::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ class_option :path,
14
+ type: :string,
15
+ default: nil,
16
+ desc: "Creates a separate sortable page under the given path."
17
+
18
+ class << self
19
+ def desc(_description = nil)
20
+ "Adds sortable positioning to an existing B4UM resource."
21
+ end
22
+ end
23
+
24
+ def validate_model
25
+ return if File.exist?(model_path)
26
+
27
+ raise Thor::Error,
28
+ "Model not found: app/models/#{file_name}.rb"
29
+ end
30
+
31
+ def validate_controller
32
+ return if File.exist?(controller_path)
33
+
34
+ raise Thor::Error,
35
+ "Controller not found: app/controllers/#{plural_table_name}_controller.rb"
36
+ end
37
+
38
+ def validate_index_view
39
+ return if File.exist?(index_view_path)
40
+
41
+ raise Thor::Error,
42
+ "Index view not found: app/views/#{plural_table_name}/index.html.erb"
43
+ end
44
+
45
+ def create_position_migration
46
+ return if position_column_exists?
47
+
48
+ migration_template(
49
+ "add_position_migration.rb.tt",
50
+ "db/migrate/add_position_to_#{plural_table_name}.rb"
51
+ )
52
+ end
53
+
54
+ def update_model
55
+ model_content = File.read(model_path)
56
+
57
+ return if model_content.include?("before_create :b4um_set_sortable_position")
58
+
59
+ inject_into_class(
60
+ model_path,
61
+ class_name,
62
+ <<~RUBY.indent(2)
63
+ before_create :b4um_set_sortable_position
64
+ after_destroy :b4um_compact_sortable_positions
65
+
66
+ def b4um_set_sortable_position
67
+ return if position.present?
68
+
69
+ self.position = self.class.maximum(:position).to_i + 1
70
+ end
71
+
72
+ def b4um_compact_sortable_positions
73
+ self.class
74
+ .where("position > ?", position)
75
+ .update_all("position = position - 1")
76
+ end
77
+ RUBY
78
+ )
79
+
80
+ say_status(
81
+ :sortable,
82
+ "#{class_name} now assigns sortable positions",
83
+ :green
84
+ )
85
+ end
86
+
87
+ def update_controller
88
+ controller_content = File.read(controller_path)
89
+
90
+ update_index_order(controller_content)
91
+ add_sort_action
92
+ end
93
+
94
+ def add_sort_route
95
+ routes_path = File.join(
96
+ destination_root,
97
+ "config/routes.rb"
98
+ )
99
+
100
+ unless File.exist?(routes_path)
101
+ raise Thor::Error,
102
+ "Routes not found: config/routes.rb"
103
+ end
104
+
105
+ routes = File.read(routes_path)
106
+
107
+ if options[:path].present?
108
+ add_separate_sort_routes(routes)
109
+ return
110
+ end
111
+
112
+ return if sort_route_exists?(routes)
113
+ return if separate_sort_route_exists?(routes)
114
+
115
+ add_sort_route_to_resources(routes)
116
+ end
117
+
118
+ def create_sortable_controller
119
+ copy_file(
120
+ File.expand_path(
121
+ "templates/sortable_controller.js",
122
+ __dir__
123
+ ),
124
+ "app/javascript/controllers/sortable_controller.js"
125
+ )
126
+ end
127
+
128
+ def create_sortable_partial
129
+ template(
130
+ "_sortable.html.erb.tt",
131
+ File.join(
132
+ "app/views",
133
+ plural_table_name,
134
+ "_sortable.html.erb"
135
+ )
136
+ )
137
+ end
138
+
139
+ def create_sortable_view
140
+ return unless options[:path].present?
141
+
142
+ template(
143
+ "sort.html.erb.tt",
144
+ File.join(
145
+ "app/views",
146
+ plural_table_name,
147
+ "sort.html.erb"
148
+ )
149
+ )
150
+ end
151
+
152
+ def update_index_layout
153
+ return unless File.exist?(index_path)
154
+
155
+ index = File.read(index_path)
156
+
157
+ if options[:path].present?
158
+ restore_index_layout(index)
159
+ else
160
+ use_sortable_index_layout(index)
161
+ end
162
+ end
163
+
164
+ def self.next_migration_number(_dirname)
165
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
166
+ end
167
+
168
+ private
169
+
170
+ def use_sortable_index_layout(index)
171
+ updated_index = index.sub(
172
+ /render\s+["'](?:bento|table|list|alternating|sortable)["']/,
173
+ 'render "sortable"'
174
+ )
175
+
176
+ return if updated_index == index
177
+
178
+ File.write(index_path, updated_index)
179
+ end
180
+
181
+ def restore_index_layout(index)
182
+ return unless index.match?(
183
+ /render\s+["']sortable["']/
184
+ )
185
+
186
+ layout = original_index_layout
187
+
188
+ return unless layout
189
+
190
+ updated_index = index.sub(
191
+ /render\s+["']sortable["']/,
192
+ %(render "#{layout}")
193
+ )
194
+
195
+ File.write(index_path, updated_index)
196
+ end
197
+
198
+ def original_index_layout
199
+ %w[bento table list alternating].find do |layout|
200
+ File.exist?(
201
+ File.join(
202
+ destination_root,
203
+ "app/views",
204
+ plural_table_name,
205
+ "_#{layout}.html.erb"
206
+ )
207
+ )
208
+ end
209
+ end
210
+
211
+ def index_path
212
+ File.join(
213
+ destination_root,
214
+ "app/views",
215
+ plural_table_name,
216
+ "index.html.erb"
217
+ )
218
+ end
219
+
220
+ def model_path
221
+ File.join(
222
+ destination_root,
223
+ "app/models",
224
+ "#{file_name}.rb"
225
+ )
226
+ end
227
+
228
+ def controller_path
229
+ File.join(
230
+ destination_root,
231
+ "app/controllers",
232
+ "#{plural_table_name}_controller.rb"
233
+ )
234
+ end
235
+
236
+ def index_view_path
237
+ File.join(
238
+ destination_root,
239
+ "app/views",
240
+ plural_table_name,
241
+ "index.html.erb"
242
+ )
243
+ end
244
+
245
+ def update_index_order(controller_content)
246
+ return if controller_content.include?(
247
+ "#{class_name}.order(:position, :id)"
248
+ )
249
+
250
+ old_scope = "#{class_name}.all"
251
+
252
+ unless controller_content.include?(old_scope)
253
+ raise Thor::Error,
254
+ "Could not find #{class_name}.all in app/controllers/#{plural_table_name}_controller.rb"
255
+ end
256
+
257
+ gsub_file(
258
+ controller_path,
259
+ old_scope,
260
+ "#{class_name}.order(:position, :id)"
261
+ )
262
+ end
263
+
264
+ def add_sort_action
265
+ controller_content = File.read(controller_path)
266
+
267
+ if controller_content.match?(/^\s*def\s+sort\b/)
268
+ add_sort_page_support(controller_content)
269
+ return
270
+ end
271
+
272
+ get_handling =
273
+ if options[:path].present?
274
+ <<~RUBY
275
+ if request.get?
276
+ @#{plural_table_name} = #{class_name}.order(:position, :id)
277
+ return
278
+ end
279
+
280
+ RUBY
281
+ else
282
+ ""
283
+ end
284
+
285
+ action = <<~RUBY.indent(2)
286
+ def sort
287
+ #{get_handling.indent(2)} record = #{class_name}.find_by(id: params[:id])
288
+ target_position = params[:position].to_i
289
+ maximum_position = #{class_name}.count
290
+
291
+ unless record &&
292
+ target_position.between?(1, maximum_position)
293
+ head :unprocessable_content
294
+ return
295
+ end
296
+
297
+ if target_position == record.position
298
+ head :no_content
299
+ return
300
+ end
301
+
302
+ old_position = record.position
303
+
304
+ #{class_name}.transaction do
305
+ if target_position < old_position
306
+ #{class_name}
307
+ .where(position: target_position...old_position)
308
+ .where.not(id: record.id)
309
+ .update_all("position = position + 1")
310
+ elsif target_position > old_position
311
+ #{class_name}
312
+ .where(position: (old_position + 1)..target_position)
313
+ .where.not(id: record.id)
314
+ .update_all("position = position - 1")
315
+ end
316
+
317
+ record.update_columns(
318
+ position: target_position,
319
+ updated_at: Time.current
320
+ )
321
+ end
322
+
323
+ head :no_content
324
+ end
325
+
326
+ RUBY
327
+
328
+ private_marker = "\n private\n"
329
+
330
+ if controller_content.include?(private_marker)
331
+ insert_into_file(
332
+ controller_path,
333
+ action,
334
+ before: private_marker
335
+ )
336
+ else
337
+ controller_end = /\nend\s*\z/
338
+
339
+ unless controller_content.match?(controller_end)
340
+ raise Thor::Error,
341
+ "Could not find the end of app/controllers/#{plural_table_name}_controller.rb"
342
+ end
343
+
344
+ insert_into_file(
345
+ controller_path,
346
+ "\n#{action}",
347
+ before: controller_end
348
+ )
349
+ end
350
+ end
351
+
352
+ def add_sort_page_support(controller_content)
353
+ return unless options[:path].present?
354
+ return if controller_content.include?("if request.get?")
355
+
356
+ sort_action =
357
+ /^(\s*)def sort\s*$/
358
+
359
+ unless controller_content.match?(sort_action)
360
+ raise Thor::Error,
361
+ "Could not find sort action in app/controllers/#{plural_table_name}_controller.rb"
362
+ end
363
+
364
+ gsub_file(
365
+ controller_path,
366
+ sort_action
367
+ ) do |match|
368
+ indentation = match[/\A\s*/]
369
+
370
+ <<~RUBY.chomp
371
+ #{match}
372
+ #{indentation} if request.get?
373
+ #{indentation} @#{plural_table_name} = #{class_name}.order(:position, :id)
374
+ #{indentation} return
375
+ #{indentation} end
376
+ RUBY
377
+ end
378
+ end
379
+
380
+ def add_separate_sort_routes(routes)
381
+ path = options[:path].to_s
382
+
383
+ remove_default_sort_route(routes)
384
+
385
+ routes = File.read(
386
+ File.join(
387
+ destination_root,
388
+ "config/routes.rb"
389
+ )
390
+ )
391
+
392
+ get_route =
393
+ %(get "#{path}", to: "#{plural_table_name}#sort")
394
+
395
+ patch_route =
396
+ %(patch "#{path}", to: "#{plural_table_name}#sort", as: :sort_#{plural_table_name})
397
+
398
+ routes_to_add = []
399
+
400
+ routes_to_add << get_route unless routes.include?(get_route)
401
+
402
+ routes_to_add << patch_route unless routes.include?(patch_route)
403
+
404
+ return if routes_to_add.empty?
405
+
406
+ routes_end = /^end\s*\z/
407
+
408
+ unless routes.match?(routes_end)
409
+ raise Thor::Error,
410
+ "Could not find the end of config/routes.rb"
411
+ end
412
+
413
+ route_code = <<~RUBY
414
+
415
+ #{routes_to_add.join("\n ")}
416
+ RUBY
417
+
418
+ insert_into_file(
419
+ "config/routes.rb",
420
+ route_code,
421
+ before: routes_end
422
+ )
423
+ end
424
+
425
+ def remove_default_sort_route(routes)
426
+ return unless sort_route_exists?(routes)
427
+
428
+ gsub_file(
429
+ "config/routes.rb",
430
+ /^(\s*)patch :sort, on: :collection\s*\n/,
431
+ ""
432
+ )
433
+ end
434
+
435
+ def sort_route_exists?(routes)
436
+ resource_block = sortable_resource_block(routes)
437
+
438
+ resource_block&.match?(
439
+ /^\s*patch\s+:sort,\s+on:\s+:collection\s*$/
440
+ ) || false
441
+ end
442
+
443
+ def separate_sort_route_exists?(routes)
444
+ routes.match?(
445
+ /^\s*patch\s+["'][^"']+["'],\s+to:\s+["']#{Regexp.escape(plural_table_name)}#sort["']/
446
+ )
447
+ end
448
+
449
+ def sortable_resource_block(routes)
450
+ pattern = /
451
+ ^(?<indent>\s*)
452
+ resources\s+:#{Regexp.escape(plural_table_name)}\s+do\s*$
453
+ (?<body>.*?)
454
+ ^\k<indent>end\s*$
455
+ /mx
456
+
457
+ routes[pattern]
458
+ end
459
+
460
+ def add_sort_route_to_resources(routes)
461
+ block_pattern =
462
+ /^(\s*)resources :#{Regexp.escape(plural_table_name)} do\s*$/
463
+
464
+ plain_pattern =
465
+ /^(\s*)resources :#{Regexp.escape(plural_table_name)}\s*$/
466
+
467
+ if routes.match?(block_pattern)
468
+ gsub_file(
469
+ "config/routes.rb",
470
+ block_pattern
471
+ ) do |match|
472
+ indentation = match[/\A\s*/]
473
+
474
+ [
475
+ match,
476
+ "#{indentation} patch :sort, on: :collection"
477
+ ].join("\n")
478
+ end
479
+ elsif routes.match?(plain_pattern)
480
+ gsub_file(
481
+ "config/routes.rb",
482
+ plain_pattern
483
+ ) do |match|
484
+ indentation = match[/\A\s*/]
485
+
486
+ [
487
+ "#{indentation}resources :#{plural_table_name} do",
488
+ "#{indentation} patch :sort, on: :collection",
489
+ "#{indentation}end"
490
+ ].join("\n")
491
+ end
492
+ else
493
+ raise Thor::Error,
494
+ "Could not find resources :#{plural_table_name} in config/routes.rb"
495
+ end
496
+ end
497
+
498
+ def position_column_exists?
499
+ migration_contains_position? ||
500
+ schema_contains_position?
501
+ end
502
+
503
+ def migration_contains_position?
504
+ Dir.glob(
505
+ File.join(
506
+ destination_root,
507
+ "db/migrate/*_add_position_to_#{plural_table_name}.rb"
508
+ )
509
+ ).any?
510
+ end
511
+
512
+ def schema_contains_position?
513
+ schema_path = File.join(destination_root, "db/schema.rb")
514
+
515
+ return false unless File.exist?(schema_path)
516
+
517
+ schema = File.read(schema_path)
518
+
519
+ table_pattern = /
520
+ create_table\s+"#{Regexp.escape(plural_table_name)}".*?^ end
521
+ /mx
522
+
523
+ table = schema[table_pattern]
524
+
525
+ table&.match?(/t\.integer\s+"position"/) || false
526
+ end
527
+ end
528
+ end
529
+ end
@@ -0,0 +1,40 @@
1
+ <div class="b4um-sortable"
2
+ data-controller="sortable"
3
+ data-sortable-update-url-value="<%%= sort_<%= plural_table_name %>_path %>">
4
+
5
+ <%% <%= plural_table_name %>.each do |<%= singular_table_name %>| %>
6
+ <div class="b4um-sortable__item"
7
+ data-sortable-id="<%%= <%= singular_table_name %>.id %>"
8
+ data-action="dragover->sortable#dragOver drop->sortable#drop">
9
+
10
+ <div class="b4um-sortable__controls">
11
+ <input type="number"
12
+ class="b4um-sortable__position"
13
+ value="<%%= <%= singular_table_name %>.position %>"
14
+ min="1"
15
+ aria-label="Position"
16
+ data-action="change->sortable#positionChanged keydown->sortable#positionKeydown">
17
+
18
+ <button type="button"
19
+ class="b4um-sortable__handle"
20
+ draggable="true"
21
+ aria-label="Move <%= singular_table_name.humanize.downcase %>"
22
+ data-action="dragstart->sortable#dragStart dragend->sortable#dragEnd">
23
+ &#9776;
24
+ </button>
25
+ </div>
26
+
27
+ <div class="b4um-sortable__content">
28
+ <%%= render <%= singular_table_name %>,
29
+ compact: true,
30
+ preview_length: 100 %>
31
+ </div>
32
+
33
+ <div class="b4um-sortable__actions">
34
+ <%%= link_to "Show",
35
+ <%= singular_table_name %>,
36
+ class: "button button--secondary" %>
37
+ </div>
38
+ </div>
39
+ <%% end %>
40
+ </div>
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddPositionTo<%= class_name.pluralize %> < ActiveRecord::Migration[8.0]
4
+ def up
5
+ add_column :<%= plural_table_name %>, :position, :integer
6
+
7
+ sortable_record = Class.new(ActiveRecord::Base) do
8
+ self.table_name = "<%= plural_table_name %>"
9
+ end
10
+
11
+ sortable_record.reset_column_information
12
+
13
+ sortable_record.order(:id).find_each.with_index(1) do |record, position|
14
+ record.update_columns(position: position)
15
+ end
16
+
17
+ change_column_null :<%= plural_table_name %>, :position, false
18
+
19
+ add_index :<%= plural_table_name %>, :position
20
+ end
21
+
22
+ def down
23
+ remove_index :<%= plural_table_name %>, :position
24
+ remove_column :<%= plural_table_name %>, :position
25
+ end
26
+ end
@@ -0,0 +1,3 @@
1
+ <h1><%= class_name.pluralize %> sortieren</h1>
2
+
3
+ <%%= render "sortable", <%= plural_table_name %>: @<%= plural_table_name %> %>
@@ -0,0 +1,166 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+
3
+ export default class extends Controller {
4
+ static values = {
5
+ updateUrl: String
6
+ }
7
+
8
+ connect() {
9
+ this.draggedItem = null
10
+ this.draggedFromPosition = null
11
+ }
12
+
13
+ dragStart(event) {
14
+ const item = event.currentTarget.closest('[data-sortable-id]')
15
+
16
+ if (!item) return
17
+
18
+ this.draggedItem = item
19
+ this.draggedFromPosition = this.positionFor(item)
20
+
21
+ event.dataTransfer.effectAllowed = 'move'
22
+ event.dataTransfer.setData('text/plain', item.dataset.sortableId)
23
+
24
+ item.classList.add('b4um-sortable__item--dragging')
25
+ }
26
+
27
+ dragEnd() {
28
+ if (this.draggedItem) {
29
+ this.draggedItem.classList.remove('b4um-sortable__item--dragging')
30
+ }
31
+
32
+ this.draggedItem = null
33
+ this.draggedFromPosition = null
34
+ }
35
+
36
+ dragOver(event) {
37
+ event.preventDefault()
38
+
39
+ if (!this.draggedItem) return
40
+
41
+ const target = event.currentTarget
42
+
43
+ if (target === this.draggedItem) return
44
+
45
+ const bounds = target.getBoundingClientRect()
46
+ const insertAfter = event.clientY > bounds.top + bounds.height / 2
47
+
48
+ if (insertAfter) {
49
+ target.after(this.draggedItem)
50
+ } else {
51
+ target.before(this.draggedItem)
52
+ }
53
+ }
54
+
55
+ drop(event) {
56
+ event.preventDefault()
57
+
58
+ if (!this.draggedItem) return
59
+
60
+ const item = this.draggedItem
61
+ const position = this.positionAfterDrop(item)
62
+
63
+ if (!position) {
64
+ window.location.reload()
65
+ return
66
+ }
67
+
68
+ this.persist(item.dataset.sortableId, position)
69
+ }
70
+
71
+ positionKeydown(event) {
72
+ if (event.key !== 'Enter') return
73
+
74
+ event.preventDefault()
75
+ event.currentTarget.blur()
76
+ }
77
+
78
+ positionChanged(event) {
79
+ const input = event.currentTarget
80
+ const item = input.closest('[data-sortable-id]')
81
+
82
+ if (!item) return
83
+
84
+ const position = Number.parseInt(input.value, 10)
85
+
86
+ if (Number.isNaN(position) || position < 1) {
87
+ window.location.reload()
88
+ return
89
+ }
90
+
91
+ this.persist(item.dataset.sortableId, position)
92
+ }
93
+
94
+ positionFor(item) {
95
+ const input = item.querySelector('.b4um-sortable__position')
96
+
97
+ if (!input) return null
98
+
99
+ const position = Number.parseInt(input.value, 10)
100
+
101
+ return Number.isNaN(position) ? null : position
102
+ }
103
+
104
+ positionAfterDrop(item) {
105
+ const previousItem = item.previousElementSibling
106
+ const nextItem = item.nextElementSibling
107
+
108
+ if (previousItem?.matches('[data-sortable-id]')) {
109
+ const previousPosition = this.positionFor(previousItem)
110
+
111
+ if (previousPosition) {
112
+ if (this.draggedFromPosition && this.draggedFromPosition < previousPosition) {
113
+ return previousPosition
114
+ }
115
+
116
+ return previousPosition + 1
117
+ }
118
+ }
119
+
120
+ if (nextItem?.matches('[data-sortable-id]')) {
121
+ const nextPosition = this.positionFor(nextItem)
122
+
123
+ if (nextPosition) {
124
+ if (this.draggedFromPosition && this.draggedFromPosition > nextPosition) {
125
+ return nextPosition
126
+ }
127
+
128
+ return Math.max(1, nextPosition - 1)
129
+ }
130
+ }
131
+
132
+ return this.draggedFromPosition
133
+ }
134
+
135
+ async persist(id, position) {
136
+ if (!this.hasUpdateUrlValue || !this.updateUrlValue) return
137
+
138
+ try {
139
+ const response = await fetch(this.updateUrlValue, {
140
+ method: 'PATCH',
141
+ headers: {
142
+ 'Content-Type': 'application/json',
143
+ Accept: 'application/json',
144
+ 'X-CSRF-Token': this.csrfToken()
145
+ },
146
+ body: JSON.stringify({
147
+ id,
148
+ position
149
+ })
150
+ })
151
+
152
+ if (!response.ok) {
153
+ throw new Error(`Sortable update failed: ${response.status}`)
154
+ }
155
+
156
+ window.location.reload()
157
+ } catch (error) {
158
+ console.error(error)
159
+ window.location.reload()
160
+ }
161
+ }
162
+
163
+ csrfToken() {
164
+ return document.querySelector('meta[name="csrf-token"]')?.content || ''
165
+ }
166
+ }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: b4um_generators
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.17
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alexander Baum
@@ -115,6 +115,7 @@ files:
115
115
  - lib/generators/b4um/install/templates/b4um/pagination.css
116
116
  - lib/generators/b4um/install/templates/b4um/resources.css
117
117
  - lib/generators/b4um/install/templates/b4um/search.css
118
+ - lib/generators/b4um/install/templates/b4um/sortable.css
118
119
  - lib/generators/b4um/install/templates/b4um/tables.css
119
120
  - lib/generators/b4um/install/templates/b4um/theme-switcher.css
120
121
  - lib/generators/b4um/install/templates/b4um/theme.css
@@ -145,6 +146,11 @@ files:
145
146
  - lib/generators/b4um/search/search_generator.rb
146
147
  - lib/generators/b4um/search/templates/search.rb
147
148
  - lib/generators/b4um/search/templates/search_form.html.erb.tt
149
+ - lib/generators/b4um/sortable/sortable_generator.rb
150
+ - lib/generators/b4um/sortable/templates/_sortable.html.erb.tt
151
+ - lib/generators/b4um/sortable/templates/add_position_migration.rb.tt
152
+ - lib/generators/b4um/sortable/templates/sort.html.erb.tt
153
+ - lib/generators/b4um/sortable/templates/sortable_controller.js
148
154
  - lib/generators/b4um/table/table_generator.rb
149
155
  - lib/generators/b4um/table/templates/_table.html.erb.tt
150
156
  - lib/generators/b4um/trix/templates/b4um_rich_text_helper.rb