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}."