diff --git a/.rubocop.yml b/.rubocop.yml
index 189dd982c..0ac9954f0 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -1,4 +1,9 @@
-require: rubocop-rails
+require:
+ - rubocop-rails
+ - rubocop-rspec
+ - rubocop-capybara
+ - rubocop-rspec_rails
+
AllCops:
NewCops: enable
DisplayCopNames: true
@@ -156,3 +161,19 @@ Style/SingleLineBlockParams:
# We think that's fine
Style/HashSyntax:
Enabled: false
+
+# We think that's fine.
+Rails/HelperInstanceVariable:
+ Enabled: false
+
+Rails/OutputSafety:
+ Exclude:
+ - '**/*_test.rb'
+ - '**/*_spec.rb'
+
+# Rails does not, we do not
+Style/FrozenStringLiteralComment:
+ Enabled: false
+
+Style/Attr:
+ Enabled: false
diff --git a/Gemfile b/Gemfile
index 0418c8e94..8b71dcd7a 100644
--- a/Gemfile
+++ b/Gemfile
@@ -44,8 +44,12 @@ gem 'turbo-rails'
group :metrics do
gem 'brakeman'
+ gem 'haml_lint', require: false
gem 'rubocop'
+ gem 'rubocop-capybara', require: false
gem 'rubocop-rails', require: false
+ gem 'rubocop-rspec', require: false
+ gem 'rubocop-rspec_rails', require: false
end
group :development, :test do
diff --git a/Gemfile.lock b/Gemfile.lock
index 73da25c8a..6c6c6c9c6 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -160,6 +160,12 @@ GEM
activesupport (>= 5.1)
haml (>= 4.0.6)
railties (>= 5.1)
+ haml_lint (0.58.0)
+ haml (>= 5.0)
+ parallel (~> 1.10)
+ rainbow
+ rubocop (>= 1.0)
+ sysexits (~> 1.1)
hashie (5.0.0)
http-accept (1.7.0)
http-cookie (1.0.5)
@@ -252,7 +258,7 @@ GEM
omniauth (~> 2.0)
orm_adapter (0.5.0)
parallel (1.24.0)
- parser (3.3.0.5)
+ parser (3.3.2.0)
ast (~> 2.4.1)
racc
pg (1.5.6)
@@ -269,7 +275,7 @@ GEM
public_suffix (5.0.5)
puma (6.4.2)
nio4r (~> 2.0)
- racc (1.7.3)
+ racc (1.8.0)
rack (2.2.9)
rack-protection (3.2.0)
base64 (>= 0.1.0)
@@ -322,7 +328,7 @@ GEM
rb-inotify (0.10.1)
ffi (~> 1.0)
rb-readline (0.5.5)
- regexp_parser (2.9.0)
+ regexp_parser (2.9.2)
responders (3.1.1)
actionpack (>= 5.2)
railties (>= 5.2)
@@ -350,7 +356,7 @@ GEM
rspec-mocks (~> 3.13)
rspec-support (~> 3.13)
rspec-support (3.13.1)
- rubocop (1.63.1)
+ rubocop (1.64.1)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
@@ -361,13 +367,24 @@ GEM
rubocop-ast (>= 1.31.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0)
- rubocop-ast (1.31.2)
- parser (>= 3.3.0.4)
+ rubocop-ast (1.31.3)
+ parser (>= 3.3.1.0)
+ rubocop-capybara (2.20.0)
+ rubocop (~> 1.41)
+ rubocop-factory_bot (2.25.1)
+ rubocop (~> 1.41)
rubocop-rails (2.24.1)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 1.33.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
+ rubocop-rspec (2.29.2)
+ rubocop (~> 1.40)
+ rubocop-capybara (~> 2.17)
+ rubocop-factory_bot (~> 2.22)
+ rubocop-rspec_rails (~> 2.28)
+ rubocop-rspec_rails (2.28.3)
+ rubocop (~> 1.40)
ruby-graphviz (1.2.5)
rexml
ruby-progressbar (1.13.0)
@@ -409,6 +426,7 @@ GEM
stimulus-rails (1.3.3)
railties (>= 6.0.0)
strscan (3.1.0)
+ sysexits (1.2.0)
temple (0.10.3)
thor (1.3.1)
tilt (2.3.0)
@@ -456,6 +474,7 @@ DEPENDENCIES
dotenv
faker
haml-rails
+ haml_lint
i18n_data
jsbundling-rails
language_list
@@ -484,7 +503,10 @@ DEPENDENCIES
rest-client
rspec-rails
rubocop
+ rubocop-capybara
rubocop-rails
+ rubocop-rspec
+ rubocop-rspec_rails
seed-fu
selenium-webdriver (>= 4.11.8)
sentry-raven
diff --git a/app/assets/images/delete.png b/app/assets/images/delete.png
new file mode 100644
index 000000000..1514d51a3
Binary files /dev/null and b/app/assets/images/delete.png differ
diff --git a/app/assets/images/list.png b/app/assets/images/list.png
new file mode 100644
index 000000000..f75830a0e
Binary files /dev/null and b/app/assets/images/list.png differ
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index 579b8c11c..7e1b35187 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -1,4 +1,5 @@
@import 'bootstrap/scss/bootstrap';
@import 'bootstrap-icons/font/bootstrap-icons';
@import 'slim-select/src/slim-select/slimselect';
+@import 'crud';
@import 'styles';
diff --git a/app/assets/stylesheets/crud.scss b/app/assets/stylesheets/crud.scss
new file mode 100644
index 000000000..e9436c96d
--- /dev/null
+++ b/app/assets/stylesheets/crud.scss
@@ -0,0 +1,58 @@
+
+h1 {
+ margin-bottom: 20px;
+}
+
+.right {
+ text-align: right;
+}
+
+.center {
+ text-align: center;
+}
+
+#content {
+ padding-top: 10px;
+}
+
+#flash {
+ clear: both;
+ padding-top: 5px;
+}
+
+table.table td.action {
+ width: 20px;
+ text-align: center;
+}
+
+.cancel {
+ font-size: 80%;
+ margin-left: 7px;
+}
+
+#error_explanation h2 {
+ font-size: 100%;
+ margin-top: 0px;
+}
+
+#error_explanation ul {
+ margin-bottom: 5px;
+}
+
+footer {
+ clear: both;
+}
+
+.icon {
+ width: 16px;
+ height: 16px;
+ display: inline-block;
+ background: no-repeat;
+ vertical-align: top;
+}
+
+.icon-plus { background-image: url('assets/plus-lg.svg'); }
+.icon-remove { background-image: url('assets/delete.png'); }
+.icon-pencil { background-image: url('assets/pencil-square.svg'); }
+.icon-list { background-image: url('assets/list.png'); }
+.icon-zoom-in { background-image: url('assets/search.svg'); }
diff --git a/app/controllers/dry_crud/render_callbacks.rb b/app/controllers/dry_crud/render_callbacks.rb
index 00d7f6be1..d9844fae2 100644
--- a/app/controllers/dry_crud/render_callbacks.rb
+++ b/app/controllers/dry_crud/render_callbacks.rb
@@ -23,7 +23,7 @@ def render(...)
run_callbacks(callback) if respond_to?(:"_#{callback}_callbacks", true)
- super(...) unless performed?
+ super unless performed?
end
private
diff --git a/app/helpers/actions_helper.rb b/app/helpers/actions_helper.rb
new file mode 100644
index 000000000..878e604d2
--- /dev/null
+++ b/app/helpers/actions_helper.rb
@@ -0,0 +1,62 @@
+# Helpers to create action links. This default implementation supports
+# regular links with an icon and a label. To change the general style
+# of action links, change the method #action_link, e.g. to generate a button.
+# The common crud actions show, edit, destroy, index and add are provided here.
+module ActionsHelper
+
+ # A generic helper method to create action links.
+ # These link could be styled to look like buttons, for example.
+ def action_link(label, icon = nil, url = {}, html_options = {})
+ add_css_class html_options, 'action btn btn-light'
+ link_to(icon ? action_icon(icon, label) : label,
+ url, html_options)
+ end
+
+ # Outputs an icon for an action with an optional label.
+ def action_icon(icon, label = nil)
+ html = content_tag(:span, '', class: "icon icon-#{icon}")
+ html << ' ' << label if label
+ html
+ end
+
+ # Standard show action to the given path.
+ # Uses the current +entry+ if no path is given.
+ def show_action_link(path = nil)
+ path ||= path_args(entry)
+ action_link(ti('link.show'), 'zoom-in', path)
+ end
+
+ # Standard edit action to given path.
+ # Uses the current +entry+ if no path is given.
+ def edit_action_link(path = nil)
+ path ||= path_args(entry)
+ path = edit_polymorphic_path(path) unless path.is_a?(String)
+ action_link(ti('link.edit'), 'pencil', path)
+ end
+
+ # Standard destroy action to the given path.
+ # Uses the current +entry+ if no path is given.
+ def destroy_action_link(path = nil)
+ path ||= path_args(entry)
+ action_link(ti('link.delete'), 'remove', path,
+ data: { confirm: ti(:confirm_delete),
+ method: :delete, 'turbo-method': :delete })
+ end
+
+ # Standard list action to the given path.
+ # Uses the current +model_class+ if no path is given.
+ def index_action_link(path = nil, url_options = { returning: true })
+ path ||= path_args(model_class)
+ path = polymorphic_path(path, url_options) unless path.is_a?(String)
+ action_link(ti('link.list'), 'list', path)
+ end
+
+ # Standard add action to given path.
+ # Uses the current +model_class+ if no path is given.
+ def add_action_link(path = nil, url_options = {})
+ path ||= path_args(model_class)
+ path = new_polymorphic_path(path, url_options) unless path.is_a?(String)
+ action_link(ti('link.add'), 'plus', path)
+ end
+
+end
diff --git a/app/helpers/dry_crud/form/builder.rb b/app/helpers/dry_crud/form/builder.rb
new file mode 100644
index 000000000..05b57cd98
--- /dev/null
+++ b/app/helpers/dry_crud/form/builder.rb
@@ -0,0 +1,330 @@
+module DryCrud
+ module Form
+
+ # A form builder that automatically selects the corresponding input field
+ # for ActiveRecord column types. Convenience methods for each column type
+ # allow one to customize the different fields.
+ #
+ # All field methods may be prefixed with +labeled_+ in order to render
+ # a standard label, required mark and an optional help block with them.
+ #
+ # Use #labeled_input_field or #input_field to render a input field
+ # corresponding to the given attribute.
+ #
+ # See the Control class for how to customize the html rendered for a
+ # single input field.
+ class Builder < ActionView::Helpers::FormBuilder
+
+ class_attribute :control_class
+ self.control_class = Control
+
+ attr_reader :template
+
+ delegate :association, :column_type, :column_property, :captionize,
+ :ti, :ta, :link_to, :content_tag, :safe_join, :capture,
+ :add_css_class, :assoc_and_id_attr,
+ to: :template
+
+ ### INPUT FIELDS
+
+ # Render multiple input controls together with a label for the given
+ # attributes.
+ def labeled_input_fields(*attrs)
+ options = attrs.extract_options!
+ safe_join(attrs) { |a| labeled_input_field(a, options.dup) }
+ end
+
+ # Render a corresponding input control and label for the given attribute.
+ # The input field is chosen based on the ActiveRecord column type.
+ #
+ # The following options may be passed:
+ # * :addon - Addon content displayd just after the input field.
+ # * :help - A help text displayd below the input field.
+ # * :span - Number of columns the input field should span.
+ # * :caption - Different caption for the label.
+ # * :field_method - Different method to create the input field.
+ #
+ # Use additional html_options for the input element.
+ def labeled_input_field(attr, html_options = {})
+ control_class.new(self, attr, html_options).render_labeled
+ end
+
+ # Render a corresponding input control for the given attribute.
+ # The input field is chosen based on the ActiveRecord column type.
+ #
+ # The following options may be passed:
+ # * :addon - Addon content displayd just after the input field.
+ # * :help - A help text displayd below the input field.
+ # * :span - Number of columns the input field should span.
+ # * :field_method - Different method to create the input field.
+ #
+ # Use additional html_options for the input element.
+ def input_field(attr, html_options = {})
+ control_class.new(self, attr, html_options).render_content
+ end
+
+ # Render a standard string field with column contraints.
+ def string_field(attr, html_options = {})
+ html_options[:maxlength] ||= column_property(@object, attr, :limit)
+ text_field(attr, html_options)
+ end
+
+ # Render a boolean field.
+ def boolean_field(attr, html_options = {})
+ content_tag(:div, class: 'checkbox') do
+ content_tag(:label) do
+ detail = html_options.delete(:detail) || ' '.html_safe
+ safe_join([check_box(attr, html_options), ' ', detail])
+ end
+ end
+ end
+
+ # Add form-control class to all input fields.
+ %w[text_field password_field email_field
+ number_field date_field time_field datetime_field].each do |method|
+ define_method(method) do |attr, html_options = {}|
+ add_css_class(html_options, 'form-control')
+ super(attr, html_options)
+ end
+ end
+
+ def integer_field(attr, html_options = {})
+ html_options[:step] ||= 1
+ number_field(attr, html_options)
+ end
+
+ def float_field(attr, html_options = {})
+ html_options[:step] ||= 'any'
+ number_field(attr, html_options)
+ end
+
+ def decimal_field(attr, html_options = {})
+ html_options[:step] ||=
+ (10**-column_property(object, attr, :scale)).to_f
+ number_field(attr, html_options)
+ end
+
+ # Customize the standard text area to have 5 rows by default.
+ def text_area(attr, html_options = {})
+ add_css_class(html_options, 'form-control')
+ html_options[:rows] ||= 5
+ super
+ end
+
+ # Render a select element for a :belongs_to association defined by attr.
+ # Use additional html_options for the select element.
+ # To pass a custom element list, specify the list with the :list key or
+ # define an instance variable with the pluralized name of the
+ # association.
+ def belongs_to_field(attr, html_options = {})
+ list = association_entries(attr, html_options).to_a
+ if list.present?
+ add_css_class(html_options, 'form-control')
+ collection_select(attr, list, :id, :to_s,
+ select_options(attr, html_options),
+ html_options)
+ else
+ # rubocop:disable Rails/OutputSafety
+ none = ta(:none_available, association(@object, attr)).html_safe
+ # rubocop:enable Rails/OutputSafety
+ static_text(none)
+ end
+ end
+
+ # rubocop:disable Naming/PredicateName
+
+ # Render a multi select element for a :has_many or
+ # :has_and_belongs_to_many association defined by attr.
+ # Use additional html_options for the select element.
+ # To pass a custom element list, specify the list with the :list key or
+ # define an instance variable with the pluralized name of the
+ # association.
+ def has_many_field(attr, html_options = {})
+ html_options[:multiple] = true
+ add_css_class(html_options, 'multiselect')
+ belongs_to_field(attr, html_options)
+ end
+ # rubocop:enable Naming/PredicateName
+
+ ### VARIOUS FORM ELEMENTS
+
+ # Render the error messages for the current form.
+ def error_messages
+ @template.render('shared/error_messages',
+ errors: @object.errors,
+ object: @object)
+ end
+
+ # Renders the given content with an addon.
+ def with_addon(content, addon)
+ content_tag(:div, class: 'input-group') do
+ html = content_tag(:span, addon, class: 'input-group-text')
+ content + content_tag(:div, html, class: 'input-group-append')
+ end
+ end
+
+ # Renders a static text where otherwise form inputs appear.
+ def static_text(text)
+ content_tag(:p, text, class: 'form-control-static')
+ end
+
+ # Generates a help block for fields
+ def help_block(text)
+ content_tag(:p, text, class: 'help-block')
+ end
+
+ # Render a submit button and a cancel link for this form.
+ def standard_actions(submit_label = ti('button.save'), cancel_url = nil)
+ content_tag(:div, class: 'col-md-offset-2 col-md-8') do
+ safe_join([submit_button(submit_label),
+ cancel_link(cancel_url)],
+ ' ')
+ end
+ end
+
+ # Render a standard submit button with the given label.
+ def submit_button(label = ti('button.save'))
+ button(label, class: 'btn btn-primary', data: { disable_with: label })
+ end
+
+ # Render a cancel link pointing to the given url.
+ def cancel_link(url = nil)
+ url ||= cancel_url
+ link_to(ti('button.cancel'), url, class: 'cancel')
+ end
+
+ # Depending if the given attribute must be present, return
+ # only an initial selection prompt or a blank option, respectively.
+ def select_options(attr, options = {}) # rubocop:disable Metrics/MethodLength
+ prompt = options.delete(:prompt)
+ blank = options.delete(:include_blank)
+ if options[:multiple]
+ {}
+ elsif prompt
+ { prompt: prompt }
+ elsif blank
+ { include_blank: blank }
+ else
+ assoc = association(@object, attr)
+ if required?(attr)
+ { prompt: ta(:please_select, assoc) }
+ else
+ { include_blank: ta(:no_entry, assoc) }
+ end
+ end
+ end
+
+ # Returns true if the given attribute must be present.
+ def required?(attr)
+ attr, attr_id = assoc_and_id_attr(attr)
+ validators = @object.class.validators_on(attr) +
+ @object.class.validators_on(attr_id)
+ validators.any? do |v|
+ v.kind == :presence &&
+ !v.options.key?(:if) &&
+ !v.options.key?(:unless)
+ end
+ end
+
+ # Render a label for the given attribute with the passed content.
+ # The content may be given as an argument or as a block:
+ # labeled(:attr) { #content }
+ # labeled(:attr, content)
+ #
+ # The following options may be passed:
+ # * :span - Number of columns the content should span.
+ # * :caption - Different caption for the label.
+ def labeled(attr, content = {}, options = {}, &)
+ if block_given?
+ options = content
+ content = capture(&)
+ end
+ control = control_class.new(self, attr, options)
+ control.render_labeled(content)
+ end
+
+ # Dispatch methods starting with 'labeled_' to render a label and the
+ # corresponding input field.
+ # E.g. labeled_boolean_field(:checked, class: 'bold')
+ # To add an additional help text, use the help option.
+ # E.g. labeled_boolean_field(:checked, help: 'Some Help')
+ def method_missing(name, *)
+ field_method = labeled_field_method?(name)
+ if field_method
+ build_labeled_field(field_method, *)
+ else
+ super
+ end
+ end
+
+ # Overriden to fullfill contract with method_missing 'labeled_' methods.
+ def respond_to_missing?(name, include_private = false)
+ labeled_field_method?(name).present? || super
+ end
+
+ private
+
+ # Checks if the passed name corresponds to a field method with a
+ # 'labeled_' prefix.
+ def labeled_field_method?(name)
+ prefix = 'labeled_'
+ if name.to_s.start_with?(prefix)
+ field_method = name.to_s[prefix.size..]
+ field_method if respond_to?(field_method)
+ end
+ end
+
+ # Renders the corresponding field together with a label, required mark
+ # and an optional help block.
+ def build_labeled_field(field_method, *args)
+ options = args.extract_options!
+ options[:field_method] = field_method
+ control_class.new(self, *(args << options)).render_labeled
+ end
+
+ # Returns the list of association entries, either from options[:list] or
+ # the instance variable with the pluralized association name.
+ # Otherwise, if the association defines a #options_list or #list scope,
+ # this is used to load the entries.
+ # As a last resort, all entries from the association class are returned.
+ def association_entries(attr, options)
+ list = options.delete(:list)
+ unless list
+ assoc = association(@object, attr)
+ ivar = :"@#{assoc.name.to_s.pluralize}"
+ list = @template.send(:instance_variable_defined?, ivar) &&
+ @template.send(:instance_variable_get, ivar)
+ list ||= load_association_entries(assoc)
+ end
+ list
+ end
+
+ # Automatically load the entries for the given association.
+ def load_association_entries(assoc)
+ klass = assoc.klass
+ list = klass.all
+ list = list.merge(assoc.scope) if assoc.scope
+ # Use special scopes if they are defined
+ if klass.respond_to?(:options_list)
+ list.options_list
+ elsif klass.respond_to?(:list)
+ list.list
+ else
+ list
+ end
+ end
+
+ # Get the cancel url for the given object considering options:
+ # 1. Use :cancel_url_new or :cancel_url_edit option, if present
+ # 2. Use :cancel_url option, if present
+ def cancel_url
+ if @object.new_record?
+ options[:cancel_url_new] || options[:cancel_url]
+ else
+ options[:cancel_url_edit] || options[:cancel_url]
+ end
+ end
+
+ end
+ end
+end
diff --git a/app/helpers/dry_crud/form/control.rb b/app/helpers/dry_crud/form/control.rb
new file mode 100644
index 000000000..487d62207
--- /dev/null
+++ b/app/helpers/dry_crud/form/control.rb
@@ -0,0 +1,178 @@
+module DryCrud
+ module Form
+
+ # Internal class to handle the rendering of a single form control,
+ # consisting of a label, input field, addon, help text or
+ # required mark.
+ class Control
+
+ attr_reader :builder, :attr, :args, :options, :addon, :help
+
+ delegate :content_tag, :object,
+ to: :builder
+
+ # Html displayed to mark an input as required.
+ REQUIRED_MARK = '*'.freeze
+
+ # Number of default input field span columns depending
+ # on the #field_method.
+ INPUT_SPANS = Hash.new(8)
+ INPUT_SPANS[:number_field] =
+ INPUT_SPANS[:integer_field] =
+ INPUT_SPANS[:float_field] =
+ INPUT_SPANS[:decimal_field] = 2
+ INPUT_SPANS[:date_field] =
+ INPUT_SPANS[:time_field] = 3
+
+ # Create a new control instance.
+ # Takes the form builder, the attribute to build the control for
+ # as well as any additional arguments for the field method.
+ # This includes an options hash as the last argument, that
+ # may contain the following special options:
+ #
+ # * :addon - Addon content displayed just after the input field.
+ # * :help - A help text displayed below the input field.
+ # * :span - Number of columns the input field should span.
+ # * :caption - Different caption for the label.
+ # * :field_method - Different method to create the input field.
+ # * :required - Sets the field as required
+ # (The value for this option usually is 'required').
+ #
+ # All the other options will go to the field_method.
+ def initialize(builder, attr, *args)
+ @builder = builder
+ @attr = attr
+ @options = args.extract_options!
+ @args = args
+
+ @addon = options.delete(:addon)
+ @help = options.delete(:help)
+ @span = options.delete(:span)
+ @caption = options.delete(:caption)
+ @field_method = options.delete(:field_method)
+ @required = options[:required]
+ end
+
+ # Renders only the content of the control.
+ # I.e. no label and span divs.
+ def render_content
+ content
+ end
+
+ # Renders the complete control with label and everything.
+ # Render the content given or the default one.
+ def render_labeled(content = nil)
+ @content = content if content
+ labeled
+ end
+
+ private
+
+ # Create the HTML markup for any labeled content.
+ def labeled
+ errors = errors? ? ' has-error' : ''
+
+ content_tag(:div, class: "form-group#{errors}") do
+ builder.label(attr, caption, class: 'col-md-2 control-label') +
+ content_tag(:div, content, class: "col-md-#{span}")
+ end
+ end
+
+ # Return the currently set content or create it
+ # based on the various options given.
+ #
+ # Optionally renders addon, required mark and/or a help block
+ # additionally to the input field.
+ def content
+ @content ||= begin
+ content = input
+ if addon
+ content = builder.with_addon(content, addon)
+ elsif required
+ content = builder.with_addon(content, REQUIRED_MARK)
+ end
+ content << builder.help_block(help) if help.present?
+ content
+ end
+ end
+
+ # Return the currently set input field or create it
+ # depending on the attribute.
+ def input
+ @input ||= begin
+ options[:required] = 'required' if required
+ builder.send(field_method, attr, *(args << options))
+ end
+ end
+
+ # The field method used to create the input.
+ # If none is set, detect it from the attribute type.
+ def field_method
+ @field_method ||= detect_field_method
+ end
+
+ # True if the attr is required, false otherwise.
+ def required
+ @required = @required.nil? ? builder.required?(attr) : @required
+ end
+
+ # Number of grid columns the input field should span.
+ def span
+ @span ||= INPUT_SPANS[field_method]
+ end
+
+ # The caption of the label.
+ # If none is set, uses the I18n value of the attribute.
+ def caption
+ @caption ||= builder.captionize(attr, object.class)
+ end
+
+ # Returns true if any errors are found on the passed attribute or its
+ # association.
+ def errors?
+ attr_plain, attr_id = builder.assoc_and_id_attr(attr)
+ object.errors.key?(attr_plain.to_sym) ||
+ object.errors.key?(attr_id.to_sym)
+ end
+
+ # Defines the field method to use based on the attribute
+ # type, association or name.
+ def detect_field_method # rubocop:disable Metrics/CyclomaticComplexity,Metrics/MethodLength
+ if type == :text
+ :text_area
+ elsif association_kind?(:belongs_to)
+ :belongs_to_field
+ elsif association_kind?(:has_and_belongs_to_many, :has_many)
+ :has_many_field
+ elsif attr.to_s.include?('password')
+ :password_field
+ elsif attr.to_s.include?('email')
+ :email_field
+ elsif builder.respond_to?(:"#{type}_field")
+ :"#{type}_field"
+ else
+ :text_field
+ end
+ end
+
+ # The column type of the attribute.
+ def type
+ @type ||= builder.column_type(object, attr)
+ end
+
+ # Returns true if attr is a non-polymorphic association.
+ # If one or more macros are given, the association must be of this kind.
+ def association_kind?(*macros)
+ if type == :integer || type.nil?
+ assoc = builder.association(object, attr, *macros)
+
+ assoc.present? && assoc.options[:polymorphic].nil?
+ else
+ false
+ end
+ end
+
+ end
+
+ end
+end
diff --git a/app/helpers/dry_crud/table/actions.rb b/app/helpers/dry_crud/table/actions.rb
new file mode 100644
index 000000000..4c74b515e
--- /dev/null
+++ b/app/helpers/dry_crud/table/actions.rb
@@ -0,0 +1,93 @@
+module DryCrud
+ module Table
+
+ # Adds action columns to the table builder.
+ # Predefined actions are available for show, edit and destroy.
+ # Additionally, a special col type to define cells linked to the show page
+ # of the row entry is provided.
+ module Actions
+
+ extend ActiveSupport::Concern
+
+ included do
+ delegate :link_to, :path_args, :edit_polymorphic_path, :ti,
+ to: :template
+ end
+
+ # Renders the passed attr with a link to the show action for
+ # the current entry.
+ # A block may be given to define the link path for the row entry.
+ def attr_with_show_link(attr, &block)
+ sortable_attr(attr) do |entry|
+ link_to(format_attr(entry, attr), action_path(entry, &block))
+ end
+ end
+
+ # Action column to show the row entry.
+ # A block may be given to define the link path for the row entry.
+ # If the block returns nil, no link is rendered.
+ def show_action_col(html_options = {}, &block)
+ action_col do |entry|
+ path = action_path(entry, &block)
+ if path
+ table_action_link('zoom-in',
+ path,
+ html_options.clone)
+ end
+ end
+ end
+
+ # Action column to edit the row entry.
+ # A block may be given to define the link path for the row entry.
+ # If the block returns nil, no link is rendered.
+ def edit_action_col(html_options = {}, &block)
+ action_col do |entry|
+ path = action_path(entry, &block)
+ if path
+ path = edit_polymorphic_path(path) unless path.is_a?(String)
+ table_action_link('pencil', path, html_options.clone)
+ end
+ end
+ end
+
+ # Action column to destroy the row entry.
+ # A block may be given to define the link path for the row entry.
+ # If the block returns nil, no link is rendered.
+ def destroy_action_col(html_options = {}, &block) # rubocop:disable Metrics/MethodLength
+ action_col do |entry|
+ path = action_path(entry, &block)
+ if path && entry.destroyable?
+ table_action_link('remove',
+ path,
+ html_options.merge(
+ data: { confirm: ti(:confirm_delete),
+ 'turbo-method': :delete }
+ ))
+ end
+ end
+ end
+
+ # Action column inside a table. No header.
+ # The cell content should be defined in the passed block.
+ def action_col(&)
+ col('', class: 'action', &)
+ end
+
+ # Generic action link inside a table.
+ def table_action_link(icon, url, html_options = {})
+ add_css_class(html_options, "icon icon-#{icon}")
+ link_to('', url, html_options)
+ end
+
+ private
+
+ # If a block is given, call it to get the path for the current row entry.
+ # Otherwise, return the standard path args.
+ def action_path(entry)
+ block_given? ? yield(entry) : path_args(entry)
+ end
+
+ end
+
+ end
+end
diff --git a/app/helpers/dry_crud/table/builder.rb b/app/helpers/dry_crud/table/builder.rb
new file mode 100644
index 000000000..19daf8a9c
--- /dev/null
+++ b/app/helpers/dry_crud/table/builder.rb
@@ -0,0 +1,116 @@
+module DryCrud
+ module Table
+
+ # A simple helper to easily define tables listing several rows of the same
+ # data type.
+ #
+ # Example Usage:
+ # DryCrud::Table::Builder.table(entries, template) do |t|
+ # t.col('My Header', class: 'css') {|e| link_to 'Show', e }
+ # t.attrs :name, :city
+ # end
+ class Builder
+
+ include Sorting
+ include Actions
+
+ attr_reader :entries, :cols, :options, :template
+
+ delegate :content_tag, :format_attr, :column_type, :association, :dom_id,
+ :captionize, :add_css_class, :content_tag_nested,
+ to: :template
+
+ def initialize(entries, template, options = {})
+ @entries = entries
+ @template = template
+ @options = options
+ @cols = []
+ end
+
+ # Convenience method to directly generate a table. Renders a row for each
+ # entry in entries. Takes a block that gets the table object as parameter
+ # for configuration. Returns the generated html for the table.
+ def self.table(entries, template, options = {})
+ t = new(entries, template, options)
+ yield t
+ t.to_html
+ end
+
+ # Define a column for the table with the given header, the html_options
+ # used for each td and a block rendering the contents of a cell for the
+ # current entry. The columns appear in the order they are defined.
+ def col(header = '', html_options = {}, &block)
+ @cols << Col.new(header, html_options, @template, block)
+ end
+
+ # Convenience method to add one or more attribute columns.
+ # The attribute name will become the header, the cells will contain
+ # the formatted attribute value for the current entry.
+ def attrs(*attrs)
+ attrs.each do |a|
+ attr(a)
+ end
+ end
+
+ # Define a column for the given attribute and an optional header.
+ # If no header is given, the attribute name is used. The cell will
+ # contain the formatted attribute value for the current entry.
+ def attr(attr, header = nil, html_options = {}, &block)
+ header ||= attr_header(attr)
+ block ||= ->(e) { format_attr(e, attr) }
+ add_css_class(html_options, align_class(attr))
+ col(header, html_options, &block)
+ end
+
+ # Renders the table as HTML.
+ def to_html
+ content_tag :table, options do
+ content_tag(:thead, html_header) +
+ content_tag_nested(:tbody, entries) { |e| html_row(e) }
+ end
+ end
+
+ # Returns css classes used for alignment of the cell data.
+ # Based on the column type of the attribute.
+ def align_class(attr)
+ entry = entries.present? ? entry_class.new : nil
+ case column_type(entry, attr)
+ when :integer, :float, :decimal
+ 'right' unless association(entry, attr, :belongs_to)
+ when :boolean
+ 'center'
+ end
+ end
+
+ # Creates a header string for the given attr.
+ def attr_header(attr)
+ captionize(attr, entry_class)
+ end
+
+ private
+
+ # Renders the header row of the table.
+ def html_header
+ content_tag_nested(:tr, cols, &:html_header)
+ end
+
+ # Renders a table row for the given entry.
+ def html_row(entry)
+ attrs = {}
+ attrs[:id] = dom_id(entry) if entry.respond_to?(:to_key)
+ content_tag_nested(:tr, cols, attrs) { |c| c.html_cell(entry) }
+ end
+
+ # Determines the class of the table entries.
+ # All entries should be of the same type.
+ def entry_class
+ if entries.respond_to?(:klass)
+ entries.klass
+ else
+ entries.first.class
+ end
+ end
+
+ end
+ end
+end
diff --git a/app/helpers/dry_crud/table/col.rb b/app/helpers/dry_crud/table/col.rb
new file mode 100644
index 000000000..0a1f97c98
--- /dev/null
+++ b/app/helpers/dry_crud/table/col.rb
@@ -0,0 +1,35 @@
+module DryCrud
+ module Table
+
+ # Helper class to store column information.
+ class Col # :nodoc:
+
+ delegate :content_tag, :capture, to: :template
+
+ attr_reader :header, :html_options, :template, :block
+
+ def initialize(header, html_options, template, block)
+ @header = header
+ @html_options = html_options
+ @template = template
+ @block = block
+ end
+
+ # Runs the Col block for the given entry.
+ def content(entry)
+ entry.nil? ? '' : capture(entry, &block)
+ end
+
+ # Renders the header cell of the Col.
+ def html_header
+ content_tag(:th, header, html_options)
+ end
+
+ # Renders a table cell for the given entry.
+ def html_cell(entry)
+ content_tag(:td, content(entry), html_options)
+ end
+
+ end
+ end
+end
diff --git a/app/helpers/dry_crud/table/sorting.rb b/app/helpers/dry_crud/table/sorting.rb
new file mode 100644
index 000000000..2ad9da3d6
--- /dev/null
+++ b/app/helpers/dry_crud/table/sorting.rb
@@ -0,0 +1,67 @@
+module DryCrud
+ module Table
+
+ # Provides headers with sort links. Expects a method :sortable?(attr)
+ # in the template/controller to tell if an attribute is sortable or not.
+ # Extracted into an own module for convenience.
+ module Sorting
+
+ # Create a header with sort links and a mark for the current sort
+ # direction.
+ def sort_header(attr, label = nil)
+ label ||= attr_header(attr)
+ template.link_to(label, sort_params(attr)) + current_mark(attr)
+ end
+
+ # Same as :attrs, except that it renders a sort link in the header
+ # if an attr is sortable.
+ def sortable_attrs(*attrs)
+ attrs.each { |a| sortable_attr(a) }
+ end
+
+ # Renders a sort link header, otherwise similar to :attr.
+ def sortable_attr(attr, header = nil, &)
+ if template.sortable?(attr)
+ attr(attr, sort_header(attr, header), &)
+ else
+ attr(attr, header, &)
+ end
+ end
+
+ private
+
+ # Request params for the sort link.
+ def sort_params(attr)
+ result = params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h : params
+ result.merge(sort: attr, sort_dir: sort_dir(attr), only_path: true)
+ end
+
+ # The sort mark, if any, for the given attribute.
+ def current_mark(attr)
+ if current_sort?(attr)
+ # rubocop:disable Rails/OutputSafety
+ (sort_dir(attr) == 'asc' ? ' ↑' : ' ↓').html_safe
+ # rubocop:enable Rails/OutputSafety
+ else
+ ''
+ end
+ end
+
+ # Returns true if the given attribute is the current sort column.
+ def current_sort?(attr)
+ params[:sort] == attr.to_s
+ end
+
+ # The sort direction to use in the sort link for the given attribute.
+ def sort_dir(attr)
+ current_sort?(attr) && params[:sort_dir] == 'asc' ? 'desc' : 'asc'
+ end
+
+ # Delegate to template.
+ def params
+ template.params
+ end
+
+ end
+ end
+end
diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb
new file mode 100644
index 000000000..9a9bea6a2
--- /dev/null
+++ b/app/helpers/form_helper.rb
@@ -0,0 +1,51 @@
+# Defines forms to edit models. The helper methods come in different
+# granularities:
+# * #plain_form - A form using Crud::FormBuilder.
+# * #standard_form - A #plain_form for a given object and attributes with error
+# messages and save and cancel buttons.
+# * #crud_form - A #standard_form for the current +entry+, with the given
+# attributes or default.
+module FormHelper
+
+ # Renders a form using Crud::FormBuilder.
+ def plain_form(object, options = {}, &)
+ options[:html] ||= {}
+ add_css_class(options[:html], 'form-horizontal')
+ options[:html][:role] ||= 'form'
+ options[:builder] ||= DryCrud::Form::Builder
+ options[:cancel_url] ||= polymorphic_path(object, returning: true)
+
+ form_for(object, options, &)
+ end
+
+ # Renders a standard form for the given entry and attributes.
+ # The form is rendered with a basic save and cancel button.
+ # If a block is given, custom input fields may be rendered and attrs is
+ # ignored. Before the input fields, the error messages are rendered,
+ # if present. An options hash may be given as the last argument.
+ def standard_form(object, *attrs, &block)
+ plain_form(object, attrs.extract_options!) do |form|
+ content = [form.error_messages]
+
+ content << if block_given?
+ capture(form, &block)
+ else
+ form.labeled_input_fields(*attrs)
+ end
+
+ content << form.standard_actions
+ safe_join(content)
+ end
+ end
+
+ # Renders a crud form for the current entry with default_crud_attrs or the
+ # given attribute array. An options hash may be given as the last argument.
+ # If a block is given, a custom form may be rendered and attrs is ignored.
+ def crud_form(*attrs, &)
+ options = attrs.extract_options!
+ attrs = default_crud_attrs - %i[created_at updated_at] if attrs.blank?
+ attrs << options
+ standard_form(path_args(entry), *attrs, &)
+ end
+
+end
diff --git a/app/helpers/format_helper.rb b/app/helpers/format_helper.rb
new file mode 100644
index 000000000..3c7c34d41
--- /dev/null
+++ b/app/helpers/format_helper.rb
@@ -0,0 +1,165 @@
+# Provides uniform formatting of basic data types, based on Ruby class (#f)
+# or database column type (#format_attr). If other helpers define methods
+# with names like 'format_{class}_{attr}', these methods are used for
+# formatting.
+#
+# Futher helpers standartize the layout of multiple attributes (#render_attrs),
+# values with labels (#labeled) and simple lists.
+module FormatHelper
+
+ # Formats a basic value based on its Ruby class.
+ def f(value) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/MethodLength
+ case value
+ when Float, BigDecimal
+ number_with_precision(value, precision: t('number.format.precision'),
+ delimiter: t('number.format.delimiter'))
+ when Integer
+ number_with_delimiter(value, delimiter: t('number.format.delimiter'))
+ when Date then l(value)
+ when Time then "#{l(value.to_date)} #{l(value, format: :time)}"
+ when true then t('global.yes')
+ when false then t('global.no')
+ when nil then UtilityHelper::EMPTY_STRING
+ else value.to_s
+ end
+ end
+
+ # Formats an arbitrary attribute of the given ActiveRecord object.
+ # If no specific format_{class}_{attr} or format_{attr} method is found,
+ # formats the value as follows:
+ # If the value is an associated model, renders the label of this object.
+ # Otherwise, calls format_type.
+ def format_attr(obj, attr)
+ format_with_helper(obj, attr) ||
+ format_association(obj, attr) ||
+ format_type(obj, attr)
+ end
+
+ # Renders a simple unordered list, which will
+ # simply render all passed items or yield them
+ # to your block.
+ def simple_list(items, ul_options = {})
+ content_tag_nested(:ul, items, ul_options) do |item|
+ content_tag(:li, block_given? ? yield(item) : f(item))
+ end
+ end
+
+ # Renders a list of attributes with label and value for a given object.
+ # Optionally surrounded with a div.
+ def render_attrs(obj, *attrs)
+ content_tag_nested(:dl, attrs, class: 'dl-horizontal') do |a|
+ labeled_attr(obj, a)
+ end
+ end
+
+ # Renders the formatted content of the given attribute with a label.
+ def labeled_attr(obj, attr)
+ labeled(captionize(attr, obj.class), format_attr(obj, attr))
+ end
+
+ # Renders an arbitrary content with the given label. Used for uniform
+ # presentation.
+ def labeled(label, content = nil, &)
+ content = capture(&) if block_given?
+ render('shared/labeled', label: label, content: content)
+ end
+
+ # Transform the given text into a form as used by labels or table headers.
+ def captionize(text, clazz = nil)
+ text = text.to_s
+ if clazz.respond_to?(:human_attribute_name)
+ text_without_id = text.end_with?('_ids') ? text[0..-5].pluralize : text
+ clazz.human_attribute_name(text_without_id)
+ else
+ text.humanize.titleize
+ end
+ end
+
+ private
+
+ # Checks whether a format_{class}_{attr} or format_{attr} helper method is
+ # defined and calls it if is.
+ def format_with_helper(obj, attr)
+ class_name = obj.class.name.underscore.tr('/', '_')
+ format_type_attr_method = :"format_#{class_name}_#{attr}"
+ format_attr_method = :"format_#{attr}"
+
+ if respond_to?(format_type_attr_method)
+ send(format_type_attr_method, obj)
+ elsif respond_to?(format_attr_method)
+ send(format_attr_method, obj)
+ else
+ false
+ end
+ end
+
+ # Checks whether the given attr is an association of obj and formats it
+ # accordingly if it is.
+ def format_association(obj, attr)
+ belongs_to = association(obj, attr, :belongs_to, :has_one)
+ has_many = association(obj, attr, :has_many, :has_and_belongs_to_many)
+
+ if belongs_to
+ format_belongs_to(obj, belongs_to)
+ elsif has_many
+ format_has_many(obj, has_many)
+ else
+ false
+ end
+ end
+
+ # Formats an arbitrary attribute of the given object depending on its data
+ # type. For Active Records, take the defined data type into account for
+ # special types that have no own object class.
+ def format_type(obj, attr) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength
+ val = obj.send(attr)
+ return UtilityHelper::EMPTY_STRING if val.blank? && val != false
+
+ case column_type(obj, attr)
+ when :time then l(val, format: :time)
+ when :date then f(val.to_date)
+ when :datetime, :timestamp then f(val.time)
+ when :text then simple_format(h(val))
+ when :decimal
+ number_with_precision(val.to_s.to_f,
+ precision: column_property(obj, attr, :scale),
+ delimiter: t('number.format.delimiter'))
+ else f(val)
+ end
+ end
+
+ # Formats an ActiveRecord +belongs_to+ or +has_one+ association.
+ def format_belongs_to(obj, assoc)
+ val = obj.send(assoc.name)
+ if val
+ assoc_link(assoc, val)
+ else
+ ta(:no_entry, assoc)
+ end
+ end
+
+ # Formats an ActiveRecord +has_and_belongs_to_many+ or
+ # +has_many+ association.
+ def format_has_many(obj, assoc)
+ values = obj.send(assoc.name)
+ if values.size == 1
+ assoc_link(assoc, values.first)
+ elsif values.present?
+ simple_list(values) { |val| assoc_link(assoc, val) }
+ else
+ ta(:no_entry, assoc)
+ end
+ end
+
+ # Renders a link to the given association entry.
+ def assoc_link(assoc, val)
+ link_to_if(assoc_link?(assoc, val), val.to_s, val)
+ end
+
+ # Returns true if no link should be created when formatting the given
+ # association.
+ def assoc_link?(_assoc, val)
+ respond_to?(:"#{val.class.model_name.singular_route_key}_path")
+ end
+
+end
diff --git a/app/helpers/i18n_helper.rb b/app/helpers/i18n_helper.rb
new file mode 100644
index 000000000..bd256f2ab
--- /dev/null
+++ b/app/helpers/i18n_helper.rb
@@ -0,0 +1,83 @@
+# Translation helpers extending the Rails +translate+ helper to support
+# translation inheritance over the controller class hierarchy.
+module I18nHelper
+
+ # Translates the passed key by looking it up over the controller hierarchy.
+ # The key is searched in the following order:
+ # - {controller}.{current_partial}.{key}
+ # - {controller}.{current_action}.{key}
+ # - {controller}.global.{key}
+ # - {parent_controller}.{current_partial}.{key}
+ # - {parent_controller}.{current_action}.{key}
+ # - {parent_controller}.global.{key}
+ # - ...
+ # - global.{key}
+ def translate_inheritable(key, variables = {})
+ partial = defined?(@virtual_path) ? @virtual_path.gsub(/.*\/_?/, '') : nil
+ defaults = inheritable_translation_defaults(key, partial)
+ variables[:default] ||= defaults
+ t(defaults.shift, **variables)
+ end
+
+ alias ti translate_inheritable
+
+ # Translates the passed key for an active record association. This helper is
+ # used for rendering association dependent keys in forms like :no_entry,
+ # :none_available or :please_select.
+ # The key is looked up in the following order:
+ # - activerecord.associations.models.{model_name}.{association_name}.{key}
+ # - activerecord.associations.{association_model_name}.{key}
+ # - global.associations.{key}
+ def translate_association(key, assoc = nil, variables = {})
+ if assoc && assoc.options[:polymorphic].nil?
+ variables[:default] ||= [association_klass_key(assoc, key).to_sym,
+ :"global.associations.#{key}"]
+ t(association_owner_key(assoc, key), **variables)
+ else
+ t("global.associations.#{key}", **variables)
+ end
+ end
+
+ alias ta translate_association
+
+ private
+
+ # General translation key based on the klass of the association.
+ def association_klass_key(assoc, key)
+ k = 'activerecord.associations.'
+ k << assoc.klass.model_name.singular
+ k << '.'
+ k << key.to_s
+ end
+
+ # Specific translation key based on the owner model and the name
+ # of the association.
+ def association_owner_key(assoc, key)
+ k = 'activerecord.associations.models.'
+ k << assoc.active_record.model_name.singular
+ k << '.'
+ k << assoc.name.to_s
+ k << '.'
+ k << key.to_s
+ end
+
+ def inheritable_translation_defaults(key, partial)
+ defaults = []
+ current = controller.class
+ while current < ActionController::Base
+ folder = current.controller_path
+ if folder.present?
+ append_controller_translation_keys(defaults, folder, partial, key)
+ end
+ current = current.superclass
+ end
+ defaults << :"global.#{key}"
+ end
+
+ def append_controller_translation_keys(defaults, folder, partial, key)
+ defaults << :"#{folder}.#{partial}.#{key}" if partial
+ defaults << :"#{folder}.#{action_name}.#{key}"
+ defaults << :"#{folder}.global.#{key}"
+ end
+
+end
diff --git a/app/helpers/table_helper.rb b/app/helpers/table_helper.rb
new file mode 100644
index 000000000..7f7a2b590
--- /dev/null
+++ b/app/helpers/table_helper.rb
@@ -0,0 +1,82 @@
+# Defines tables to display a list of entries. The helper methods come in
+# different granularities:
+# * #plain_table - A basic table for the given entries and attributes using
+# the Crud::TableBuilder.
+# * #list_table - A sortable #plain_table for the current +entries+, with the
+# given attributes or default.
+# * #crud_table - A sortable #plain_table for the current +entries+, with the
+# given attributes or default and the standard crud action links.
+module TableHelper
+
+ # Renders a table for the given entries. One column is rendered for each
+ # attribute passed. If a block is given, the columns defined therein are
+ # appended to the attribute columns.
+ # If entries is empty, an appropriate message is rendered.
+ # An options hash may be given as the last argument.
+ def plain_table(entries, *attrs)
+ options = attrs.extract_options!
+ add_css_class(options, 'table table-striped table-hover')
+ builder = options.delete(:builder) || DryCrud::Table::Builder
+ builder.table(entries, self, options) do |t|
+ t.attrs(*attrs)
+ yield t if block_given?
+ end
+ end
+
+ # Renders a #plain_table for the given entries.
+ # If entries is empty, an appropriate message is rendered.
+ def plain_table_or_message(entries, *attrs, &)
+ entries.to_a # force evaluation of relations
+ if entries.present?
+ plain_table(entries, *attrs, &)
+ else
+ content_tag(:div, ti(:no_list_entries), class: 'table')
+ end
+ end
+
+ # Create a table of the +entries+ with the default or
+ # the passed attributes in its columns. An options hash may be given
+ # as the last argument.
+ def list_table(*attrs, &)
+ attrs, options = explode_attrs_with_options(attrs, &)
+ plain_table_or_message(entries, options) do |t|
+ t.sortable_attrs(*attrs)
+ yield t if block_given?
+ end
+ end
+
+ # Create a table of the current +entries+ with the default or the passed
+ # attributes in its columns. Edit and destroy actions are added to each row.
+ # If attrs are present, the first column will link to the show
+ # action. Edit and destroy actions are appended to the end of each row.
+ # If a block is given, the column defined there will be inserted
+ # between the given attributes and the actions.
+ # An options hash for the table builder may be given as the last argument.
+ def crud_table(*attrs, &)
+ attrs, options = explode_attrs_with_options(attrs, &)
+ first = attrs.shift
+ plain_table_or_message(entries, options) do |t|
+ t.attr_with_show_link(first) if first
+ t.sortable_attrs(*attrs)
+ yield t if block_given?
+ standard_table_actions(t)
+ end
+ end
+
+ # Adds standard action link columns (edit, destroy) to the given table.
+ def standard_table_actions(table)
+ table.edit_action_col
+ table.destroy_action_col
+ end
+
+ private
+
+ def explode_attrs_with_options(attrs)
+ options = attrs.extract_options!
+ if !block_given? && attrs.blank?
+ attrs = default_crud_attrs
+ end
+ [attrs, options]
+ end
+
+end
diff --git a/app/helpers/utility_helper.rb b/app/helpers/utility_helper.rb
new file mode 100644
index 000000000..f77b2d22b
--- /dev/null
+++ b/app/helpers/utility_helper.rb
@@ -0,0 +1,84 @@
+require 'English'
+
+# View helpers for basic functions used in various other helpers.
+module UtilityHelper
+
+ # non-breaking space asserts better css.
+ EMPTY_STRING = ' '.html_safe.freeze
+
+ # Render a content tag with the collected contents rendered
+ # by &block for each item in collection.
+ def content_tag_nested(tag, collection, options = {}, &)
+ content_tag(tag, safe_join(collection, &), options)
+ end
+
+ # Overridden method that takes a block that is executed for each item in
+ # array before appending the results.
+ def safe_join(array, sep = $OUTPUT_FIELD_SEPARATOR, &)
+ super(block_given? ? array.map(&).compact : array, sep)
+ end
+
+ # Returns the css class for the given flash level.
+ def flash_class(level)
+ case level
+ when :notice then 'success'
+ when :alert then 'error'
+ else level.to_s
+ end
+ end
+
+ # Adds a class to the given options, even if there are already classes.
+ def add_css_class(options, classes)
+ if options[:class]
+ options[:class] += " #{classes}" if classes
+ else
+ options[:class] = classes
+ end
+ end
+
+ # The default attributes to use in attrs, list and form partials.
+ # These are all defined attributes except certain special ones like
+ # 'id' or 'position'.
+ def default_crud_attrs
+ attrs = model_class.column_names.map(&:to_sym)
+ attrs - %i[id position password]
+ end
+
+ # Returns the ActiveRecord column type or nil.
+ def column_type(obj, attr)
+ column_property(obj, attr, :type)
+ end
+
+ # Returns an ActiveRecord column property for the passed attr or nil
+ def column_property(obj, attr, property)
+ if obj.respond_to?(:column_for_attribute) && obj.has_attribute?(attr)
+ obj.column_for_attribute(attr).send(property)
+ end
+ end
+
+ # Returns the association proxy for the given attribute. The attr parameter
+ # may be the _id column or the association name. If a macro (e.g.
+ # :belongs_to) is given, the association must be of this type, otherwise,
+ # any association is returned. Returns nil if no association (or not of the
+ # given macro) was found.
+ def association(obj, attr, *macros)
+ if obj.class.respond_to?(:reflect_on_association)
+ name = assoc_and_id_attr(attr).first.to_sym
+ assoc = obj.class.reflect_on_association(name)
+ assoc if assoc && (macros.blank? || macros.include?(assoc.macro))
+ end
+ end
+
+ # Returns the name of the attr and it's corresponding field
+ def assoc_and_id_attr(attr)
+ attr = attr.to_s
+ if attr.end_with?('_id')
+ [attr[0..-4], attr]
+ elsif attr.end_with?('_ids')
+ [attr[0..-5].pluralize, attr]
+ else
+ [attr, "#{attr}_id"]
+ end
+ end
+
+end
diff --git a/app/views/crud/_actions_edit.html.haml b/app/views/crud/_actions_edit.html.haml
new file mode 100644
index 000000000..63d34669e
--- /dev/null
+++ b/app/views/crud/_actions_edit.html.haml
@@ -0,0 +1,3 @@
+= index_action_link
+= show_action_link
+= destroy_action_link
\ No newline at end of file
diff --git a/app/views/crud/_actions_index.html.haml b/app/views/crud/_actions_index.html.haml
new file mode 100644
index 000000000..f3190cdac
--- /dev/null
+++ b/app/views/crud/_actions_index.html.haml
@@ -0,0 +1 @@
+= add_action_link
diff --git a/app/views/crud/_actions_show.html.haml b/app/views/crud/_actions_show.html.haml
new file mode 100644
index 000000000..16948df9f
--- /dev/null
+++ b/app/views/crud/_actions_show.html.haml
@@ -0,0 +1,3 @@
+= index_action_link
+= edit_action_link
+= destroy_action_link
\ No newline at end of file
diff --git a/app/views/crud/_attrs.html.haml b/app/views/crud/_attrs.html.haml
new file mode 100644
index 000000000..cbf491fd9
--- /dev/null
+++ b/app/views/crud/_attrs.html.haml
@@ -0,0 +1 @@
+= render_attrs entry, *default_crud_attrs
\ No newline at end of file
diff --git a/app/views/crud/_form.html.haml b/app/views/crud/_form.html.haml
new file mode 100644
index 000000000..7cee7eae1
--- /dev/null
+++ b/app/views/crud/_form.html.haml
@@ -0,0 +1 @@
+= crud_form
\ No newline at end of file
diff --git a/app/views/crud/_list.html.haml b/app/views/crud/_list.html.haml
new file mode 100644
index 000000000..24b353cfa
--- /dev/null
+++ b/app/views/crud/_list.html.haml
@@ -0,0 +1 @@
+= crud_table
\ No newline at end of file
diff --git a/app/views/crud/edit.html.haml b/app/views/crud/edit.html.haml
new file mode 100644
index 000000000..434374d49
--- /dev/null
+++ b/app/views/crud/edit.html.haml
@@ -0,0 +1,5 @@
+- @title ||= ti(:title, model: full_entry_label).html_safe
+
+- content_for(:actions, render('actions_edit'))
+
+= render 'form'
diff --git a/app/views/crud/new.html.haml b/app/views/crud/new.html.haml
new file mode 100644
index 000000000..8d70444ef
--- /dev/null
+++ b/app/views/crud/new.html.haml
@@ -0,0 +1,5 @@
+- @title ||= ti(:title, model: models_label(plural: false))
+
+- content_for(:actions, index_action_link)
+
+= render 'form'
diff --git a/app/views/crud/show.html.haml b/app/views/crud/show.html.haml
new file mode 100644
index 000000000..8fec97cd5
--- /dev/null
+++ b/app/views/crud/show.html.haml
@@ -0,0 +1,7 @@
+- @title ||= ti(:title, model: full_entry_label).html_safe
+
+- content_for(:actions, render('actions_show'))
+
+= render 'attrs'
+
+
diff --git a/app/views/crud/show.json.jbuilder b/app/views/crud/show.json.jbuilder
new file mode 100644
index 000000000..7feb40546
--- /dev/null
+++ b/app/views/crud/show.json.jbuilder
@@ -0,0 +1 @@
+json.extract! entry, :id, *default_crud_attrs
diff --git a/app/views/layouts/_flash.html.haml b/app/views/layouts/_flash.html.haml
new file mode 100644
index 000000000..aa2bb0c51
--- /dev/null
+++ b/app/views/layouts/_flash.html.haml
@@ -0,0 +1,2 @@
+- if flash[level].present?
+ %div{class: "alert alert-#{flash_class(level)}"}= flash[level].html_safe
\ No newline at end of file
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index 78b800c92..92a50e68a 100644
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -64,5 +64,6 @@
%a.nav-link.cursor-pointer.ps-2.pe-2{href: skills_path} Skillset
%div.container-fluid
%div.row.d-flex.justify-content-center
+ #flash= render partial: 'layouts/flash', collection: [:notice, :alert], as: :level
= content_for?(:content) ? yield(:content) : yield
= turbo_frame_tag "remote_modal", target: "_top"
\ No newline at end of file
diff --git a/app/views/list/_actions_index.html.haml b/app/views/list/_actions_index.html.haml
new file mode 100644
index 000000000..f3190cdac
--- /dev/null
+++ b/app/views/list/_actions_index.html.haml
@@ -0,0 +1 @@
+= add_action_link
diff --git a/app/views/list/_list.html.haml b/app/views/list/_list.html.haml
new file mode 100644
index 000000000..fadee9caf
--- /dev/null
+++ b/app/views/list/_list.html.haml
@@ -0,0 +1 @@
+= list_table
\ No newline at end of file
diff --git a/app/views/list/_search.html.haml b/app/views/list/_search.html.haml
new file mode 100644
index 000000000..8af2fce13
--- /dev/null
+++ b/app/views/list/_search.html.haml
@@ -0,0 +1,7 @@
+= form_tag(nil, { method: :get, class: 'form-inline', role: 'search' }) do
+ = hidden_field_tag :returning, true
+ = hidden_field_tag :page, 1
+ .input-group
+ = search_field_tag :q, params[:q], class: 'form-control'
+ %span.input-group-append
+ = submit_tag ti(:"button.search"), class: 'btn btn-outline-secondary'
diff --git a/app/views/list/index.html.haml b/app/views/list/index.html.haml
new file mode 100644
index 000000000..863b84c73
--- /dev/null
+++ b/app/views/list/index.html.haml
@@ -0,0 +1,7 @@
+- @title ||= ti(:title, models: models_label)
+
+- content_for(:tools, render('search')) if search_support?
+
+- content_for(:actions, render('actions_index'))
+
+= render 'list'
diff --git a/app/views/list/index.json.jbuilder b/app/views/list/index.json.jbuilder
new file mode 100644
index 000000000..cdb30e9cf
--- /dev/null
+++ b/app/views/list/index.json.jbuilder
@@ -0,0 +1,4 @@
+json.array!(entries) do |entry|
+ json.extract! entry, :id, *default_crud_attrs
+ json.url polymorphic_url(path_args(entry), format: :json)
+end
diff --git a/app/views/shared/_error_messages.html.haml b/app/views/shared/_error_messages.html.haml
new file mode 100644
index 000000000..879953e25
--- /dev/null
+++ b/app/views/shared/_error_messages.html.haml
@@ -0,0 +1,6 @@
+- if errors.any?
+ #error_explanation.alert.alert-danger
+ %h2= ti(:"errors.header", count: errors.count, model: object.to_s)
+ %ul
+ - errors.full_messages.each do |msg|
+ %li= msg
diff --git a/app/views/shared/_labeled.html.haml b/app/views/shared/_labeled.html.haml
new file mode 100644
index 000000000..bdaaf868b
--- /dev/null
+++ b/app/views/shared/_labeled.html.haml
@@ -0,0 +1,2 @@
+%dt= label.presence || raw(UtilityHelper::EMPTY_STRING)
+%dd.value= content.presence || raw(UtilityHelper::EMPTY_STRING)
\ No newline at end of file
diff --git a/config/initializers/active_record.rb b/config/initializers/active_record.rb
new file mode 100644
index 000000000..20c00fece
--- /dev/null
+++ b/config/initializers/active_record.rb
@@ -0,0 +1,11 @@
+class ActiveRecord::Base
+ def destroyable?
+ self.class.reflect_on_all_associations.all? do |assoc|
+ [
+ %i[restrict_with_error restrict_with_exception].exclude?(assoc.options[:dependent]),
+ (assoc.macro == :has_one && send(assoc.name).nil?),
+ (assoc.macro == :has_many && send(assoc.name).empty?)
+ ].any?
+ end
+ end
+end
diff --git a/config/initializers/field_error_proc.rb b/config/initializers/field_error_proc.rb
new file mode 100644
index 000000000..bdf5728b7
--- /dev/null
+++ b/config/initializers/field_error_proc.rb
@@ -0,0 +1,5 @@
+# encoding: UTF-8
+
+# Fields with errors are directly styled in Crud::FormBuilder.
+# Rails should just output the plain html tag.
+ActionView::Base.field_error_proc = proc { |html_tag, instance| html_tag }
\ No newline at end of file
diff --git a/config/locales/crud.de.yml b/config/locales/crud.de.yml
new file mode 100644
index 000000000..ccc652268
--- /dev/null
+++ b/config/locales/crud.de.yml
@@ -0,0 +1,64 @@
+# Translations of all crud strings.
+# See also I18nHelper#translate_inheritable and #translate_association.
+
+de:
+ # global scope
+ global:
+ "yes": "ja"
+ "no": "nein"
+ no_list_entries: Keine Einträge gefunden.
+ confirm_delete: Wollen Sie diesen Eintrag wirklich löschen?
+
+ associations:
+ # association keys may be customized per model with the prefix
+ # 'activerecord.associations.{model}.' or even per actual association with
+ # 'activerecord.associations.models.{holder_model}.{assoc_name}.'
+ no_entry: (keine)
+ none_available: (keine verfügbar)
+ please_select: Bitte auswählen
+
+ button:
+ save: Speichern
+ cancel: Abbrechen
+ search: Suchen
+
+ link:
+ show: Anzeigen
+ edit: Bearbeiten
+ add: Erstellen
+ delete: Löschen
+ list: Liste
+
+ errors:
+ header:
+ one: "Ein Fehler verhinderte das Speichern dieses Eintrages:"
+ other: "%{count} Fehler verhinderten das Speichern dieses Eintrages:"
+
+ # formats
+ time:
+ formats:
+ time: "%H:%M"
+
+ # list controller
+ list:
+ index:
+ title: "%{models}"
+
+ # crud controller
+ crud:
+ show:
+ title: "%{model}"
+ new:
+ title: "%{model} erstellen"
+ edit:
+ title: "%{model} bearbeiten"
+ create:
+ flash:
+ success: "%{model} wurde erfolgreich erstellt."
+ update:
+ flash:
+ success: "%{model} wurde erfolgreich aktualisiert."
+ destroy:
+ flash:
+ success: "%{model} wurde erfolgreich gelöscht."
+ failure: "%{model} konnte nicht gelöscht werden."
diff --git a/config/locales/crud.en.yml b/config/locales/crud.en.yml
new file mode 100644
index 000000000..637fbdfb0
--- /dev/null
+++ b/config/locales/crud.en.yml
@@ -0,0 +1,64 @@
+# Translations of all crud strings.
+# See also StandardHelper#translate_inheritable and #translate_association.
+
+en:
+ # global scope
+ global:
+ "yes": "yes"
+ "no": "no"
+ no_list_entries: No entries found.
+ confirm_delete: Do you really want to delete this entry?
+
+ associations:
+ # association keys may be customized per model with the prefix
+ # 'activerecord.associations.{model}.' or even per actual association with
+ # 'activerecord.associations.models.{holder_model}.{assoc_name}.'
+ no_entry: (none)
+ none_available: (none available)
+ please_select: Please select
+
+ button:
+ save: Save
+ cancel: Cancel
+ search: Search
+
+ link:
+ show: Show
+ edit: Edit
+ add: Add
+ delete: Delete
+ list: List
+
+ errors:
+ header:
+ one: "1 error prohibited this entry from being saved:"
+ other: "%{count} errors prohibited this entry from being saved:"
+
+ # formats
+ time:
+ formats:
+ time: "%H:%M"
+
+ # list controller
+ list:
+ index:
+ title: Listing %{models}
+
+ # crud controller
+ crud:
+ show:
+ title: "%{model}"
+ new:
+ title: "New %{model}"
+ edit:
+ title: "Edit %{model}"
+ create:
+ flash:
+ success: "%{model} was successfully created."
+ update:
+ flash:
+ success: "%{model} was successfully updated."
+ destroy:
+ flash:
+ success: "%{model} was successfully deleted."
+ failure: "%{model} could not be deleted."
diff --git a/config/locales/crud.it.yml b/config/locales/crud.it.yml
new file mode 100644
index 000000000..6e375f352
--- /dev/null
+++ b/config/locales/crud.it.yml
@@ -0,0 +1,64 @@
+# Translations of all crud strings by mberlanda
+# See also StandardHelper#translate_inheritable and #translate_association.
+
+it:
+ # global scope
+ global:
+ "yes": "si"
+ "no": "no"
+ no_list_entries: Nessun elemento trovato.
+ confirm_delete: Vuoi davvero eliminare questo elemento?
+
+ associations:
+ # association keys may be customized per model with the prefix
+ # 'activerecord.associations.{model}.' or even per actual association with
+ # 'activerecord.associations.models.{holder_model}.{assoc_name}.'
+ no_entry: (nessuno)
+ none_available: (non disponibile)
+ please_select: Prego selezionare
+
+ button:
+ save: Salva
+ cancel: Annulla
+ search: Cerca
+
+ link:
+ show: Mostra
+ edit: Modifica
+ add: Aggiungi
+ delete: Elimina
+ list: Elenco
+
+ errors:
+ header:
+ one: "1 errore impedisce il salvataggio di questo elemento:"
+ other: "%{count} errori impediscono il salvataggio di questo elemento:"
+
+ # formats
+ time:
+ formats:
+ time: "%H:%M"
+
+ # list controller
+ list:
+ index:
+ title: Elenco %{models}
+
+ # crud controller
+ crud:
+ show:
+ title: "%{model}"
+ new:
+ title: "Nuovo %{model}"
+ edit:
+ title: "Modifica %{model}"
+ create:
+ flash:
+ success: "%{model} è stato creato con successo."
+ update:
+ flash:
+ success: "%{model} è stato aggiornato con successo."
+ destroy:
+ flash:
+ success: "%{model} è stato eliminato con successo."
+ failure: "Non è stato possibile eliminare %{model}."