From d141f348e553a499a50b82987919425b2faa3fa1 Mon Sep 17 00:00:00 2001 From: Justin Littman Date: Fri, 21 Jul 2023 11:18:55 -0400 Subject: [PATCH] Forms using yaaf --- Gemfile | 3 +- Gemfile.lock | 11 +- app/components/nested_form_component.html.erb | 29 ++++ app/components/nested_form_component.rb | 28 ++++ .../wokes/affiliation_row_component.html.erb | 21 +++ .../wokes/affiliation_row_component.rb | 13 ++ .../wokes/author_row_component.html.erb | 10 ++ app/components/wokes/author_row_component.rb | 13 ++ ...uthors_and_contributors_component.html.erb | 8 + .../authors_and_contributors_component.rb | 14 ++ .../wokes/authors_component.html.erb | 14 ++ app/components/wokes/authors_component.rb | 12 ++ .../wokes/buttons_component.html.erb | 41 +++++ app/components/wokes/buttons_component.rb | 50 ++++++ .../contact_email_row_component.html.erb | 15 ++ .../wokes/contact_email_row_component.rb | 17 ++ .../wokes/contributor_row_component.html.erb | 108 +++++++++++++ .../wokes/contributor_row_component.rb | 122 ++++++++++++++ .../wokes/contributors_component.html.erb | 12 ++ .../wokes/contributors_component.rb | 12 ++ .../wokes/description_component.html.erb | 17 ++ app/components/wokes/description_component.rb | 12 ++ app/components/wokes/form_component.html.erb | 26 +++ app/components/wokes/form_component.rb | 27 ++++ app/components/wokes/title_component.html.erb | 25 +++ app/components/wokes/title_component.rb | 12 ++ app/controllers/wokes_controller.rb | 63 ++++++++ app/forms/h2_form_builder.rb | 42 +++++ app/javascript/controllers/index.js | 7 + .../controllers/ordered_form_controller.js | 72 +++++++++ app/models/forms/affiliation.rb | 41 +++++ app/models/forms/author.rb | 7 + app/models/forms/base.rb | 42 +++++ app/models/forms/contact_email.rb | 33 ++++ app/models/forms/contributor.rb | 99 ++++++++++++ app/models/forms/work.rb | 150 ++++++++++++++++++ app/views/wokes/edit.html.erb | 5 + app/views/wokes/new.html.erb | 5 + config/application.rb | 2 + config/routes.rb | 2 + package.json | 5 +- yarn.lock | 5 + 42 files changed, 1246 insertions(+), 6 deletions(-) create mode 100644 app/components/nested_form_component.html.erb create mode 100644 app/components/nested_form_component.rb create mode 100644 app/components/wokes/affiliation_row_component.html.erb create mode 100644 app/components/wokes/affiliation_row_component.rb create mode 100644 app/components/wokes/author_row_component.html.erb create mode 100644 app/components/wokes/author_row_component.rb create mode 100644 app/components/wokes/authors_and_contributors_component.html.erb create mode 100644 app/components/wokes/authors_and_contributors_component.rb create mode 100644 app/components/wokes/authors_component.html.erb create mode 100644 app/components/wokes/authors_component.rb create mode 100644 app/components/wokes/buttons_component.html.erb create mode 100644 app/components/wokes/buttons_component.rb create mode 100644 app/components/wokes/contact_email_row_component.html.erb create mode 100644 app/components/wokes/contact_email_row_component.rb create mode 100644 app/components/wokes/contributor_row_component.html.erb create mode 100644 app/components/wokes/contributor_row_component.rb create mode 100644 app/components/wokes/contributors_component.html.erb create mode 100644 app/components/wokes/contributors_component.rb create mode 100644 app/components/wokes/description_component.html.erb create mode 100644 app/components/wokes/description_component.rb create mode 100644 app/components/wokes/form_component.html.erb create mode 100644 app/components/wokes/form_component.rb create mode 100644 app/components/wokes/title_component.html.erb create mode 100644 app/components/wokes/title_component.rb create mode 100644 app/controllers/wokes_controller.rb create mode 100644 app/forms/h2_form_builder.rb create mode 100644 app/javascript/controllers/ordered_form_controller.js create mode 100644 app/models/forms/affiliation.rb create mode 100644 app/models/forms/author.rb create mode 100644 app/models/forms/base.rb create mode 100644 app/models/forms/contact_email.rb create mode 100644 app/models/forms/contributor.rb create mode 100644 app/models/forms/work.rb create mode 100644 app/views/wokes/edit.html.erb create mode 100644 app/views/wokes/new.html.erb diff --git a/Gemfile b/Gemfile index fcba1326e..fb3101400 100644 --- a/Gemfile +++ b/Gemfile @@ -85,6 +85,7 @@ gem "sneakers", "~> 2.11" # rabbitMQ background processing gem "state_machines-activerecord" gem "strip_attributes" gem "turbo-rails", "~> 1.0" -gem "view_component", "~> 2.56.2" # https://github.com/github/view_component/issues/1390 +gem "view_component", "~> 3.5" gem "whenever", require: false # Work around https://github.com/javan/whenever/issues/831 +gem "yaaf" # Form objects gem "zipline", "~> 1.4" diff --git a/Gemfile.lock b/Gemfile.lock index e6638b170..695e549b6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -619,8 +619,9 @@ GEM uber (0.1.0) unicode-display_width (2.4.2) version_gem (1.1.3) - view_component (2.56.2) - activesupport (>= 5.0.0, < 8.0) + view_component (3.5.0) + activesupport (>= 5.2.0, < 8.0) + concurrent-ruby (~> 1.0) method_source (~> 1.0) virtus (2.0.0) axiom-types (~> 0.1) @@ -646,6 +647,9 @@ GEM chronic (>= 0.6.3) xpath (3.2.0) nokogiri (~> 1.8) + yaaf (2.2.0) + activemodel (>= 5.2) + activerecord (>= 5.2) zeitwerk (2.6.11) zip_tricks (5.6.0) zipline (1.5.0) @@ -727,10 +731,11 @@ DEPENDENCIES super_diff tty-progressbar turbo-rails (~> 1.0) - view_component (~> 2.56.2) + view_component (~> 3.5) web-console (>= 3.3.0) webmock whenever + yaaf zipline (~> 1.4) BUNDLED WITH diff --git a/app/components/nested_form_component.html.erb b/app/components/nested_form_component.html.erb new file mode 100644 index 000000000..24fc0f473 --- /dev/null +++ b/app/components/nested_form_component.html.erb @@ -0,0 +1,29 @@ +
-wrapper-selector-value=".<%= controller %>-wrapper"> + + + <%= form.fields_for field, nested_forms do |nested_form| %> +
+ <%= nested_form.hidden_field :id %> + <%# :_destroy must come before nested form given queryselector that controller uses for find it. %> + <%= nested_form.hidden_field :_destroy %> + <%# nested_form may have a remove button which has remove action for the controller %> + <%= render row.new(form: nested_form, controller: controller, **other_row_params) %> +
+ <% end %> + + <%# Inserted elements will be injected before that target. %> +
-target="target">
+ + <% if add_another_button? %> + <%= add_another_button %> + <% else %> + <%= form.add_another_button controller, "+ Add another", class: "btn btn-outline-primary" %> + <% end %> +
diff --git a/app/components/nested_form_component.rb b/app/components/nested_form_component.rb new file mode 100644 index 000000000..a90112762 --- /dev/null +++ b/app/components/nested_form_component.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# Support for nested forms. +class NestedFormComponent < ApplicationComponent + # Optional slot. If provided, the button must have add as its action for the controller. + # See H2FormBuilder#add_another_button + renders_one :add_another_button + + def initialize(controller:, form:, field:, clazz:, row:, ordered: false) + @controller = controller + @form = form + @field = field + @clazz = clazz + @row = row + @ordered = ordered + end + + attr_reader :controller, :form, :field, :clazz, :row, :ordered + + def nested_forms + # Form object must have a method that returns nested forms for this field. + form.object.send("#{field}_forms".to_sym) + end + + def other_row_params + ordered ? {ordered:} : {} + end +end diff --git a/app/components/wokes/affiliation_row_component.html.erb b/app/components/wokes/affiliation_row_component.html.erb new file mode 100644 index 000000000..d45b7499e --- /dev/null +++ b/app/components/wokes/affiliation_row_component.html.erb @@ -0,0 +1,21 @@ +
+
+ +
+
+ <%= form.label :department, "Department/Institute/Center", class: "col-form-label" %> + <%= form.text_field :department, class: "form-control affiliation-input" %> +
+
+ <%= form.remove_button controller, "Remove", class: "btn btn-sm", aria: {label: "Remove affiliation"} do %> + + <% end %> +
+
diff --git a/app/components/wokes/affiliation_row_component.rb b/app/components/wokes/affiliation_row_component.rb new file mode 100644 index 000000000..70124e28a --- /dev/null +++ b/app/components/wokes/affiliation_row_component.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Wokes + # Renders a row for an affiliation form. + class AffiliationRowComponent < ApplicationComponent + def initialize(form:, controller:) + @form = form + @controller = controller + end + + attr_reader :form, :controller + end +end diff --git a/app/components/wokes/author_row_component.html.erb b/app/components/wokes/author_row_component.html.erb new file mode 100644 index 000000000..e14df2839 --- /dev/null +++ b/app/components/wokes/author_row_component.html.erb @@ -0,0 +1,10 @@ +
+ <%= form.label :first_name %> + <%= form.text_field :first_name %> +
+
+ <%= form.label :last_name %> + <%= form.text_field :last_name %> +
+<%= render NestedFormComponent.new(form: form, controller: "affiliation-form", row: Wokes::AffiliationRowComponent, field: "affiliations", clazz: Forms::Affiliation) %> +<%= form.remove_button controller, "Remove", class: "btn btn-outline-primary" %> diff --git a/app/components/wokes/author_row_component.rb b/app/components/wokes/author_row_component.rb new file mode 100644 index 000000000..940a32c73 --- /dev/null +++ b/app/components/wokes/author_row_component.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Wokes + # Renders a row for an author form. + class AuthorRowComponent < ApplicationComponent + def initialize(form:, controller:) + @form = form + @controller = controller + end + + attr_reader :form, :controller + end +end diff --git a/app/components/wokes/authors_and_contributors_component.html.erb b/app/components/wokes/authors_and_contributors_component.html.erb new file mode 100644 index 000000000..d949e059a --- /dev/null +++ b/app/components/wokes/authors_and_contributors_component.html.erb @@ -0,0 +1,8 @@ +
+
List authors and contributors *
+

Enter the name(s) of people, organizations or events responsible for producing the deposit.

+ + <%= render Wokes::AuthorsComponent.new(form: form) %> + + <%= render Wokes::ContributorsComponent.new(form: form) %> +
diff --git a/app/components/wokes/authors_and_contributors_component.rb b/app/components/wokes/authors_and_contributors_component.rb new file mode 100644 index 000000000..6c13b2e39 --- /dev/null +++ b/app/components/wokes/authors_and_contributors_component.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Wokes + # A widget for managing both the ordered authors and unordered contributors to the work. + # We make this distinction between different types of contributors, because only authors + # appear in the automatically generated citation. + class AuthorsAndContributorsComponent < ApplicationComponent + def initialize(form:) + @form = form + end + + attr_reader :form + end +end diff --git a/app/components/wokes/authors_component.html.erb b/app/components/wokes/authors_component.html.erb new file mode 100644 index 000000000..6d39289ae --- /dev/null +++ b/app/components/wokes/authors_component.html.erb @@ -0,0 +1,14 @@ +
+
+ Authors to include in citation + <%= render PopoverComponent.new key: "work.author" %> +
+

When there are multiple authors, list them in the order they should appear in the citation. If you need to change the order of the authors, click the arrows to move individual authors up or down in the list.

+ + <%= render NestedFormComponent.new(form: form, controller: "author-form", row: Wokes::ContributorRowComponent, + field: "authors", clazz: Forms::Author, ordered: true) do |component| %> + <% component.with_add_another_button do %> + <%= form.add_another_button "author-form", "+ Add another author", class: "btn btn-outline-primary" %> + <% end %> + <% end %> +
diff --git a/app/components/wokes/authors_component.rb b/app/components/wokes/authors_component.rb new file mode 100644 index 000000000..62af2c824 --- /dev/null +++ b/app/components/wokes/authors_component.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Wokes + # A widget for managing the collection of contributors to the work. + class AuthorsComponent < ApplicationComponent + def initialize(form:) + @form = form + end + + attr_reader :form + end +end diff --git a/app/components/wokes/buttons_component.html.erb b/app/components/wokes/buttons_component.html.erb new file mode 100644 index 000000000..f64aa3658 --- /dev/null +++ b/app/components/wokes/buttons_component.html.erb @@ -0,0 +1,41 @@ + +
+
+ <% if show_first_draft_cancel? %> + <%= link_to model, method: :delete, aria: {label: "Delete #{title}"}, + data: { + confirm: "Are you sure you want to delete this draft work? It cannot be undone.", + action: "unsaved-changes#allowFormSubmission" + } do %> + Discard draft + <% end %> + <% elsif show_version_draft_cancel? %> + <%= link_to work_version, method: :delete, aria: {label: "Delete #{title}"}, + data: { + confirm: "Are you sure you want to revert to the previously published version? It cannot be undone.", + action: "unsaved-changes#allowFormSubmission" + } do %> + Discard draft + <% end %> + <% end %> +
+
+ <%= link_to "Cancel", cancel_link_location, class: "btn btn-link" %> + <%= form.submit "Save as draft", class: "btn btn-primary", id: "save-draft-button", + data: {action: "unsaved-changes#allowFormSubmission"} %> + <%= form.submit submit_button_label, class: "btn btn-primary", + disabled: false, + data: {action: "unsaved-changes#allowFormSubmission", deposit_button_target: "depositButton"} %> +
+
diff --git a/app/components/wokes/buttons_component.rb b/app/components/wokes/buttons_component.rb new file mode 100644 index 000000000..2313ed7e4 --- /dev/null +++ b/app/components/wokes/buttons_component.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Wokes + # Displays the button for saving a draft or depositing for a work + class ButtonsComponent < ApplicationComponent + def initialize(form:) + @form = form + end + + attr_reader :form + + def submit_button_label + work_in_reviewed_coll? ? "Submit for approval" : "Deposit" + end + + private + + def collection + form.object.collection + end + + def work_version + form.object.work_version + end + + def work + form.object.work + end + + def work_in_reviewed_coll? + collection.review_enabled? + end + + def show_version_draft_cancel? + work_version.version_draft? && work_version.persisted? + end + + def show_first_draft_cancel? + work_version.deleteable? + end + + def cancel_link_location + if work_version.persisted? + work_path(work) + else + collection_works_path(collection) + end + end + end +end diff --git a/app/components/wokes/contact_email_row_component.html.erb b/app/components/wokes/contact_email_row_component.html.erb new file mode 100644 index 000000000..04aeb4164 --- /dev/null +++ b/app/components/wokes/contact_email_row_component.html.erb @@ -0,0 +1,15 @@ +
+
+ <%= form.label :email, "Contact email *", class: "col-form-label" %> <%= render PopoverComponent.new key: "work.contact_email" %> +
+
+ <%= form.email_field :email, class: "form-control#{" is-invalid" if error?}", + pattern: Devise.email_regexp.source, required: true %> +
You must provide a valid email address
+
+
+ <%= form.remove_button controller, class: "btn btn-sm float-end", aria: {label: "Remove"} do %> + + <% end %> +
+
diff --git a/app/components/wokes/contact_email_row_component.rb b/app/components/wokes/contact_email_row_component.rb new file mode 100644 index 000000000..ac35b4413 --- /dev/null +++ b/app/components/wokes/contact_email_row_component.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Wokes + # Renders a row for an affiliation form. + class ContactEmailRowComponent < ApplicationComponent + def initialize(form:, controller:) + @form = form + @controller = controller + end + + attr_reader :form, :controller + + def error? + form.object.errors.where(:email).present? + end + end +end diff --git a/app/components/wokes/contributor_row_component.html.erb b/app/components/wokes/contributor_row_component.html.erb new file mode 100644 index 000000000..d8b4fd279 --- /dev/null +++ b/app/components/wokes/contributor_row_component.html.erb @@ -0,0 +1,108 @@ +
+
+
+
+ <%= form.label :role_term, role_term_label, class: "form-label" %> + <%= render PopoverComponent.new key: "work.role_term" %> +
+
+ <%= render Works::ContributorRoleComponent.new(form:, data_options: data_options_for_select) %> +
+
+
+
+
+
+
+ <%= form.radio_button :with_orcid, "false", html_options_for_radio(true, !orcid?) %> + <%= form.label :with_orcid, "Enter author name", value: "false", class: "form-check-label fw-semibold" %> +
+
+
+ <%= form.label :first_name, first_name_label, class: "form-label" %> + <%= render PopoverComponent.new key: "work.first_name" %> + <%= form.text_field :first_name, html_options("contributorFirst", disabled: orcid?).merge("aria-describedby": "popover-work.first_name") %> +
You must provide a first name
+
+
+ <%= form.label :last_name, last_name_label, class: "form-label" %> + <%= form.text_field :last_name, html_options("contributorLast", disabled: orcid?) %> +
You must provide a last name
+
+ <%= form.hidden_field :orcid %> +
+ +
+
OR
+
+ +
+ <%= form.radio_button :with_orcid, "true", html_options_for_radio(false, orcid?) %> + <%= form.label :with_orcid, "Enter ORCID iD", value: "true", class: "form-check-label fw-semibold" %> +
+
+
+ <%= form.label :orcid, orcid_label, class: "form-label" %> + <%= render PopoverComponent.new key: "work.orcid" %> + <%= form.text_field :orcid, class: "form-control", required: author?, data: {contributors_target: "orcid", action: "contributors#lookupOrcid"}, "aria-describedby": "popover-work.orcid" %> +
You must provide an ORCID iD
+
+
+
+
+ <%= form.label :first_name, first_name_label, class: "form-label" %> + <%= render PopoverComponent.new key: "work.orcid_name" %> + <%= form.text_field :first_name, html_options("contributorFirst", contributors_target: "orcidFirstName", disabled: !orcid?).merge("aria-describedby": "popover-work.orcid_name") %> +
You must provide a first name
+
+
+ <%= form.label :last_name, last_name_label, class: "form-label" %> + <%= form.text_field :last_name, html_options("contributorLast", contributors_target: "orcidLastName", disabled: !orcid?) %> +
You must provide a last name
+
+
+
+
+
+
+
+ <%= render NestedFormComponent.new(controller: "affiliation-form", form: form, field: "affiliations", clazz: Forms::Affiliation, row: Wokes::AffiliationRowComponent) do |component| %> + <% component.with_add_another_button do %> + <%= form.add_another_button "affiliation-form", "+ Add another affiliation", class: "btn btn-outline-primary" %> + <% end %> + <% end %> +
+
+
+ <%= form.label :full_name, organization_label, class: "form-label" %> + <%= render PopoverComponent.new key: "work.organization" %> + <%= form.text_field :full_name, html_options("contributorOrg").merge("aria-describedby": "popover-work.organization") %> +
You must provide a name
+
+
+
+
+ <%= form.remove_button controller, html_options_for_delete do %> + + <% end %> + <% if ordered? %> + <%= form.hidden_field :weight %> +
+ <%= form.move_up_button controller, class: "btn btn-sm", aria: {label: "Move up"}, + data: { + action: "auto-citation#updateDisplay" + } do %> + + <% end %> +
+
+ <%= form.move_down_button controller, class: "btn btn-sm", aria: {label: "Move down"}, + data: { + action: "auto-citation#updateDisplay" + } do %> + + <% end %> +
+ <% end %> +
+
diff --git a/app/components/wokes/contributor_row_component.rb b/app/components/wokes/contributor_row_component.rb new file mode 100644 index 000000000..39f7f3f4c --- /dev/null +++ b/app/components/wokes/contributor_row_component.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +module Wokes + # Renders a widget corresponding to a single contributor / author of the work. + class ContributorRowComponent < ApplicationComponent + def initialize(form:, controller:, ordered: false) + @form = form + @controller = controller + @ordered = ordered + end + + attr_reader :form, :controller + + def ordered? + @ordered + end + + def contributor? + !author? + end + + def author? + contributor.is_a?(Author) + end + + def html_options(auto_citation_target, contributors_target: nil, disabled: false) + { + class: "form-control", + data: { + contributors_target: + }.tap do |data| + if author? + data[:action] = "change->auto-citation#updateDisplay" + data[:auto_citation_target] = auto_citation_target + end + end.compact, + required: author?, + disabled: + } + end + + def first_name_label + with_required("First name") + end + + def last_name_label + with_required("Last name") + end + + def role_term_label + with_required("Role term") + end + + def orcid_label + with_required("ORCID iD") + end + + def organization_label + with_required("Organization Name") + end + + def orcid? + contributor.orcid.present? + end + + def contributor + form.object.contributor + end + + def contributor_remove_label + "Remove #{contributor_name.blank? ? "blank #{contributor.class.name.downcase}" : contributor_name}" + end + + def contributor_name + contributor.full_name.blank? ? "#{contributor.first_name} #{contributor.last_name}".strip : contributor.full_name + end + + def html_options_for_delete + { + class: "btn btn-sm", + aria: {label: contributor_remove_label}, + data: {}.tap do |data| + data[:action] = "auto-citation#updateDisplay" if author? + end + } + end + + def html_options_for_radio(is_name, checked) + { + checked:, + class: "form-check-input", + data: {}.tap do |data| + actions = ["contributors#personChanged"] + actions << "auto-citation#updateDisplay" if author? + data[:action] = actions.join(" ") + data[:contributors_target] = "personNameSelect" if is_name + end, + "aria-label": is_name ? "Enter contributor by name" : "Enter contributor by ORCID iD" + } + end + + def with_required(label) + return label if contributor? + + "#{label} *" + end + + def data_options_for_select + { + action: "change->contributors#roleChanged change->auto-citation#updateDisplay", + contributors_target: "role" + }.tap do |opts| + actions = ["change->contributors#roleChanged"] + if author? + opts[:auto_citation_target] = "contributorRole" + actions << ["change->auto-citation#updateDisplay"] + end + opts[:action] = actions.join(" ") + end + end + end +end diff --git a/app/components/wokes/contributors_component.html.erb b/app/components/wokes/contributors_component.html.erb new file mode 100644 index 000000000..398c94200 --- /dev/null +++ b/app/components/wokes/contributors_component.html.erb @@ -0,0 +1,12 @@ +
+
Additional contributors + <%= render PopoverComponent.new key: "work.contributor" %> +
+

Names will be listed in the order shown below.

+ + <%= render NestedFormComponent.new(form: form, controller: "contributor-form", row: Wokes::ContributorRowComponent, field: "contributors", clazz: Forms::Contributor) do |component| %> + <% component.with_add_another_button do %> + <%= form.add_another_button "contributor-form", "+ Add another contributor", class: "btn btn-outline-primary" %> + <% end %> + <% end %> +
diff --git a/app/components/wokes/contributors_component.rb b/app/components/wokes/contributors_component.rb new file mode 100644 index 000000000..53df2e38c --- /dev/null +++ b/app/components/wokes/contributors_component.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Wokes + # A widget for managing the collection of contributors to the work. + class ContributorsComponent < ApplicationComponent + def initialize(form:) + @form = form + end + + attr_reader :form + end +end diff --git a/app/components/wokes/description_component.html.erb b/app/components/wokes/description_component.html.erb new file mode 100644 index 000000000..9303fa0ef --- /dev/null +++ b/app/components/wokes/description_component.html.erb @@ -0,0 +1,17 @@ +
+
Describe your deposit *
+

Enter a summary statement about the deposit (600 words max) to help others + discover your deposits in SearchWorks and on the internet. Add at least one + keyword that relates to the content of the deposit.

+ +
+
+ <%= form.label :abstract, "Abstract *", class: "col-form-label" %> + <%= render PopoverComponent.new key: "work.abstract" %> +
+
+ <%= form.text_area :abstract, class: "form-control", required: true, "aria-describedby": "popover-work.abstract" %> +
You must provide an abstract
+
+
+
diff --git a/app/components/wokes/description_component.rb b/app/components/wokes/description_component.rb new file mode 100644 index 000000000..139b53980 --- /dev/null +++ b/app/components/wokes/description_component.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Wokes + # Renders a widget for describing (abstract, keywords, citation, etc.) a work. + class DescriptionComponent < ApplicationComponent + def initialize(form:) + @form = form + end + + attr_reader :form + end +end diff --git a/app/components/wokes/form_component.html.erb b/app/components/wokes/form_component.html.erb new file mode 100644 index 000000000..4601bfc9e --- /dev/null +++ b/app/components/wokes/form_component.html.erb @@ -0,0 +1,26 @@ +<% if form.errors.any? %> +
+

<%= pluralize(form.errors.count, "error") %> prohibited this article from being saved:

+ + +
+<% end %> +<%= form_with model: form, url: url, scope: :work, builder: H2FormBuilder, + data: { + # controller: "auto-citation unsaved-changes deposit-button", + action: "change->unsaved-changes#changed beforeunload@window->unsaved-changes#leavingPage turbo:before-visit@window->unsaved-changes#leavingPage", + auto_citation_purl: form.purl, + auto_citation_doi: doi_field + }, + html: {class: "needs-validation work-editor", novalidate: true, multipart: true} do |form| %> + <%= form.hidden_field :collection_id %> + <%= render Wokes::TitleComponent.new(form: form) %> + <%= render Wokes::AuthorsAndContributorsComponent.new(form: form) %> + <%= render Wokes::DescriptionComponent.new(form: form) %> + + <%= render Wokes::ButtonsComponent.new(form: form) %> +<% end %> diff --git a/app/components/wokes/form_component.rb b/app/components/wokes/form_component.rb new file mode 100644 index 000000000..0ff9eca99 --- /dev/null +++ b/app/components/wokes/form_component.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Wokes + # The component that renders the form for editing or creating a work. + class FormComponent < ApplicationComponent + attr_reader :form + + def initialize(form:) + @form = form + end + + def url + form.persisted? ? woke_path(id: form.id) : collection_wokes_path(collection_id: form.collection_id) + end + + def doi_field + return "https://doi.org/#{form.doi}." if form.doi + + # create DOI URL or placeholder for works in collections that allow DOI assignment + return unless form.will_assign_doi? + + return "https://doi.org/#{Doi.for(form.druid)}." if form.druid + + WorkVersion::DOI_TEXT + end + end +end diff --git a/app/components/wokes/title_component.html.erb b/app/components/wokes/title_component.html.erb new file mode 100644 index 000000000..88a687add --- /dev/null +++ b/app/components/wokes/title_component.html.erb @@ -0,0 +1,25 @@ +
+
Title of deposit and contact information *
+
+
+ <%= form.label :title, "Title of deposit *", class: "col-form-label" %> + <%= render PopoverComponent.new key: "work.title" %> +
+
+ <%= form.text_area :title, class: "form-control", required: true, rows: 1, + "aria-describedby": "popover-work.title", + data: {action: "no-newlines#change change->auto-citation#updateDisplay", + auto_citation_target: "titleField", + no_newlines_target: "input", + controller: "no-newlines"} %> +
You must provide a title
+
+
+
+ <%= render NestedFormComponent.new(controller: "contact-email-form", form: form, field: "contact_emails", clazz: Forms::ContactEmail, row: Wokes::ContactEmailRowComponent) do |component| %> + <% component.with_add_another_button do %> + <%= form.add_another_button "contact-email-form", "+ Add another email", class: "col-sm-2 btn btn-outline-primary" %> + <% end %> + <% end %> +
+
diff --git a/app/components/wokes/title_component.rb b/app/components/wokes/title_component.rb new file mode 100644 index 000000000..d2084d4c6 --- /dev/null +++ b/app/components/wokes/title_component.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Wokes + # Renders a widget for setting the title and contact for the work. + class TitleComponent < ApplicationComponent + def initialize(form:) + @form = form + end + + attr_reader :form + end +end diff --git a/app/controllers/wokes_controller.rb b/app/controllers/wokes_controller.rb new file mode 100644 index 000000000..a5e86f94f --- /dev/null +++ b/app/controllers/wokes_controller.rb @@ -0,0 +1,63 @@ +class WokesController < ObjectsController + def new + @form = Forms::Work.new(collection_id: params[:collection_id]) + end + + def create + @form = Forms::Work.new(work_params.merge( + collection_id: params[:collection_id], + depositor: current_user, + owner: current_user + ).merge(addl_params)) + + if @form.save + redirect_to work_path(@form.work) + else + render :new, status: :unprocessable_entity + end + end + + def edit + work = Work.find(params[:id]) + @form = Forms::Work.new_from_model(work) + end + + def update + @form = Forms::Work.new(work_params.merge( + id: params[:id] + ).merge(addl_params)) + + if @form.save + redirect_to work_path(@form.work) + else + render :edit, status: :unprocessable_entity + end + end + + private + + def addl_params + { + work_type: "text", # Hardcoded for now + license: "Apache-2.0", # Hardcoded for now + _deposit: deposit_button_pushed? # This indicates that full validation should be performed. + } + end + + def work_params + params.require(:work).permit( + :title, :abstract, :collection_id, + contact_emails_attributes: [:id, :email, :_destroy], + authors_attributes: contributor_attributes, + contributors_attributes: contributor_attributes + ) + end + + def contributor_attributes + [ + :id, :first_name, :last_name, :_destroy, + :full_name, :orcid, :role_term, :with_orcid, :weight, + affiliations_attributes: [:id, :label, :uri, :department, :_destroy] + ] + end +end diff --git a/app/forms/h2_form_builder.rb b/app/forms/h2_form_builder.rb new file mode 100644 index 000000000..33bf10fc8 --- /dev/null +++ b/app/forms/h2_form_builder.rb @@ -0,0 +1,42 @@ +class H2FormBuilder < ActionView::Helpers::FormBuilder + def add_another_button(controller, content_or_options = nil, options = nil, &block) + button_with_action("#{controller}#add", content_or_options, options, &block) + end + + def remove_button(controller, content_or_options = nil, options = nil, &block) + button_with_action("#{controller}#remove", content_or_options, options, &block) + end + + def move_up_button(controller, content_or_options = nil, options = nil, &block) + button_with_action("#{controller}#moveUp", content_or_options, options, {"#{controller}_target": "upButton"}, &block) + end + + def move_down_button(controller, content_or_options = nil, options = nil, &block) + button_with_action("#{controller}#moveDown", content_or_options, options, "#{controller}_target": "downButton", &block) + end + + private + + def button_with_action(action, content_or_options = nil, options = nil, addl_data = nil, &block) + if content_or_options.is_a? Hash + options = content_or_options + else + options ||= {} + end + + # Handle merging data-action. + options = {type: "button", data: {action: action}}.deep_stringify_keys.deep_merge({"data" => addl_data || {}}).deep_merge(options.deep_stringify_keys) do |key, this_val, other_val| + if key == "action" + "#{this_val} #{other_val}" + else + this_val + end + end + + if block + @template.content_tag :button, options, nil, false, &block + else + @template.content_tag :button, content_or_options || "Button", options + end + end +end diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index fd2d6b755..298869a1c 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -1,8 +1,15 @@ import { Application } from "@hotwired/stimulus" import { Autocomplete } from 'stimulus-autocomplete' +import NestedForm from 'stimulus-rails-nested-form' +import OrderedForm from './ordered_form_controller' import { definitions } from 'stimulus:./' const application = Application.start() application.load(definitions) application.register('autocomplete', Autocomplete) +application.register('author-form', OrderedForm) +application.register('contributor-form', NestedForm) +application.register('affiliation-form', NestedForm) +application.register('contact-email-form', NestedForm) + diff --git a/app/javascript/controllers/ordered_form_controller.js b/app/javascript/controllers/ordered_form_controller.js new file mode 100644 index 000000000..7c8350861 --- /dev/null +++ b/app/javascript/controllers/ordered_form_controller.js @@ -0,0 +1,72 @@ +import NestedForm from 'stimulus-rails-nested-form' + +export default class extends NestedForm { + + connect() { + super.connect() + this.renumber() + } + + add(event) { + super.add(event) + // This depends on the superclass inserting the element before target. + const element = this.targetTarget.previousElementSibling + this.setWeight(element, this.count) + this.rows().forEach((row, index) => { + this.filterButtons(row, index) + }) + } + + remove(event) { + super.remove(event) + this.renumber() + } + + moveUp(event) { + const item = this.getItemForButton(event.target) + const previous = item.previousElementSibling + previous.remove() + item.insertAdjacentElement('afterend', previous) + this.renumber() + } + + moveDown(event) { + const item = this.getItemForButton(event.target) + const next = item.nextElementSibling + next.remove() + item.insertAdjacentElement('beforebegin', next) + this.renumber() + } + + renumber() { + this.rows().forEach((row, index) => { + this.setWeight(row, index) + this.filterButtons(row, index) + }) + } + + setWeight(element, value) { + element.querySelector("input[name*='weight']").value = value + } + + filterButtons(element, index) { + // Not setting these as targets, since overriding targets from superclass is problematic. + element.querySelector(`button[data-${this.identifier}-target='upButton']`).hidden = (index == 0) + element.querySelector(`button[data-${this.identifier}-target='downButton']`).hidden = (index == this.count - 1) + } + + // Returns all of the not deleted rows (we don't want controls showing if there is only one visibile row, but many deleted rows) + rows() { + const allRows = this.element.querySelectorAll(this.wrapperSelectorValue) + return Array.from(allRows).filter((item) => item.querySelector("input[name*='_destroy']").value != "1") + } + + get count() { + return this.rows().length + } + + getItemForButton(button) { + return button.closest(this.wrapperSelectorValue) + } + +} \ No newline at end of file diff --git a/app/models/forms/affiliation.rb b/app/models/forms/affiliation.rb new file mode 100644 index 000000000..c669ad5e5 --- /dev/null +++ b/app/models/forms/affiliation.rb @@ -0,0 +1,41 @@ +module Forms + class Affiliation < Base + # Indicates that this is a deposit, and therefore should be fully validated. + attr_accessor :_deposit + attr_accessor :id + attr_accessor :label + attr_accessor :uri + attr_accessor :department + attr_accessor :abstract_contributor + attr_accessor :_destroy + + validates :label, presence: true, if: :_deposit + + def main_model + affiliation + end + + def affiliation + @affiliation ||= begin + affiliation = if id.present? + ::Affiliation.find(id) + else + ::Affiliation.new + end + affiliation.label = label + affiliation.uri = uri + affiliation.department = department + affiliation.abstract_contributor = abstract_contributor + affiliation + end + end + + def self.new_from_model(affiliation) + new(id: affiliation.id, + abstract_contributor: affiliation.abstract_contributor, + label: affiliation.label, + uri: affiliation.uri, + department: affiliation.department) + end + end +end diff --git a/app/models/forms/author.rb b/app/models/forms/author.rb new file mode 100644 index 000000000..2a55297b8 --- /dev/null +++ b/app/models/forms/author.rb @@ -0,0 +1,7 @@ +module Forms + class Author < Contributor + def clazz + ::Author + end + end +end diff --git a/app/models/forms/base.rb b/app/models/forms/base.rb new file mode 100644 index 000000000..e76fd6950 --- /dev/null +++ b/app/models/forms/base.rb @@ -0,0 +1,42 @@ +module Forms + class Base < YAAF::Form + after_save :destroy_associated_models + attr_reader :main_models, :related_modes, :associated_forms + + delegate :to_param, :persisted?, to: :main_model + + def initialize(attributes = {}) + super(attributes) + + @models = parent_models + [main_model] + associated_forms + end + + # Must be implemented by subclasses + def main_model + raise NotImplementedError + end + + # May be implemented by subclasses. + # Parent models are saved before the main model. + def parent_models + [] + end + + # May be implemented by subclasses + def associated_forms + [] + end + + def self.reject_all_blank?(params) + # Unlike ActiveRecord::NestedAttributes::ClassMethods::REJECT_ALL_BLANK_PROC, + # this is recursive. + params.all? { |key, value| key == "_destroy" || value.blank? || (value.is_a?(Hash) && value.values.all? { |value_value| reject_all_blank?(value_value) }) } + end + + def destroy_associated_models + associated_forms.each do |form| + form.main_model.destroy if form._destroy == "1" + end + end + end +end diff --git a/app/models/forms/contact_email.rb b/app/models/forms/contact_email.rb new file mode 100644 index 000000000..ef5d90a5f --- /dev/null +++ b/app/models/forms/contact_email.rb @@ -0,0 +1,33 @@ +module Forms + class ContactEmail < Base + attr_accessor :id + attr_accessor :email + attr_accessor :emailable + attr_accessor :_destroy + + validates :email, presence: true + + def main_model + contact_email + end + + def contact_email + @contact_email ||= begin + contact_email = if id.present? + ::ContactEmail.find(id) + else + ::ContactEmail.new + end + contact_email.email = email + contact_email.emailable = emailable + contact_email + end + end + + def self.new_from_model(contact_email) + new(id: contact_email.id, + emailable: contact_email.emailable, + email: contact_email.email) + end + end +end diff --git a/app/models/forms/contributor.rb b/app/models/forms/contributor.rb new file mode 100644 index 000000000..1729dcf58 --- /dev/null +++ b/app/models/forms/contributor.rb @@ -0,0 +1,99 @@ +module Forms + class Contributor < Base + # Indicates that this is a deposit, and therefore should be fully validated. + attr_accessor :_deposit + attr_accessor :id + attr_accessor :first_name + attr_accessor :last_name + attr_accessor :full_name + attr_accessor :with_orcid # radio button + attr_accessor :orcid + attr_accessor :work_version + attr_accessor :_destroy + # role term is a composite field. See AbstractContributor + attr_accessor :role_term + attr_accessor :weight + + # affiliations_attributes is needed in order to use the + # fields_for helper with a collection + attr_accessor :affiliations_attributes + + with_options if: -> { _deposit && role_term&.start_with?("person") } do + validates :first_name, presence: true + validates :last_name, presence: true + end + + with_options if: -> { _deposit && !role_term&.start_with?("person") } do + validates :full_name, presence: true + end + + # Override to use a different ActiveRecord model, e.g., Author + def clazz + ::Contributor + end + + def main_model + contributor + end + + def associated_forms + affiliations + end + + def contributor + @contributor ||= begin + contributor = if id.present? + clazz.find(id) + else + clazz.new + end + contributor.first_name = first_name + contributor.last_name = last_name + contributor.work_version = work_version + contributor.role_term = role_term if role_term.present? + contributor.full_name = full_name + contributor.orcid = with_orcid? ? orcid : nil + contributor.weight = weight + contributor + end + end + + def with_orcid? + with_orcid == "true" + end + + def affiliations + @affiliations ||= if affiliations_attributes.present? + affiliations_attributes.filter_map do |_, affiliation_params| + Forms::Affiliation.new(affiliation_params.merge(abstract_contributor: contributor, _deposit: _deposit)) unless Forms::Affiliation.reject_all_blank?(affiliation_params) + end + elsif contributor.affiliations.present? + contributor.affiliations.map do |affiliation| + Forms::Affiliation.new_from_model(affiliation) + end + else + [] + end + end + + def affiliations_forms + affiliations.present? ? affiliations : [Forms::Affiliation.new] + end + + def self.new_from_model(contributor) + new(id: contributor.id, + first_name: contributor.first_name, + last_name: contributor.last_name, + work_version: contributor.work_version, + role_term: contributor.role_term, + full_name: contributor.full_name, + orcid: contributor.orcid, + with_orcid: contributor.orcid.present?, + weight: contributor.weight) + end + + def self.reject_all_blank?(params) + super(params.except("with_orcid", "role_term")) + end + end +end diff --git a/app/models/forms/work.rb b/app/models/forms/work.rb new file mode 100644 index 000000000..2b4e2c708 --- /dev/null +++ b/app/models/forms/work.rb @@ -0,0 +1,150 @@ +module Forms + class Work < Base + # Indicates that this is a deposit, and therefore should be fully validated. + attr_accessor :_deposit + attr_accessor :id + attr_accessor :title + attr_accessor :abstract + attr_accessor :collection_id + attr_accessor :work_type + attr_accessor :license + attr_accessor :depositor + attr_accessor :owner + attr_accessor :assign_doi + + # *_attributes is needed in order to use the + # fields_for helper with a collection + attr_accessor :authors_attributes + attr_accessor :contributors_attributes + attr_accessor :contact_emails_attributes + + delegate :purl, :druid, :doi, to: :work + + with_options if: :_deposit do + validates :title, presence: true, allow_nil: false + validates :abstract, presence: true, allow_nil: false + validates :contact_emails, length: {minimum: 1, message: "Please add at least 1 contact email."} + end + + # Required override of base class + def main_model + work_version + end + + # Optional override of base class + # The work is saved before the work version. + def parent_models + [work] + end + + # Optional override of base class + def associated_forms + authors + contributors + contact_emails + end + + def work + @work ||= if id.present? + ::Work.find(id) + else + ::Work.new( + collection: collection, + depositor: depositor, + owner: owner, + assign_doi: assign_doi + ) + end + end + + def work_version + @work_version ||= begin + work_version = if work.head.present? + work.head + else + WorkVersion.new(work: work) + end + work.head = work_version + work_version.title = title + work_version.abstract = abstract + work_version.work_type = work_type + work_version.license = license + work_version + end + end + + def collection + @collection ||= Collection.find(collection_id) + end + + def authors + @authors ||= if authors_attributes.present? + authors_attributes.filter_map do |_, author_params| + # This filter out blank forms. + Forms::Author.new(author_params.merge(work_version: work_version, _deposit: _deposit)) unless Forms::Author.reject_all_blank?(author_params) + end + elsif work_version.authors.present? + work_version.authors.map do |author| + Forms::Author.new_from_model(author) + end + else + [] + end + end + + # This adds a blank form when none are present. + def authors_forms + authors.present? ? authors : [Forms::Author.new] + end + + def contributors + @contributors ||= if contributors_attributes.present? + contributors_attributes.filter_map do |_, contributor_params| + # This filter out blank forms. + Forms::Contributor.new(contributor_params.merge(work_version: work_version, _deposit: _deposit)) unless Forms::Contributor.reject_all_blank?(contributor_params) + end + elsif work_version.contributors.present? + work_version.contributors.map do |contributor| + Forms::Contributor.new_from_model(contributor) + end + else + [] + end + end + + # This adds a blank form when none are present. + def contributors_forms + contributors.present? ? contributors : [Forms::Contributor.new] + end + + def contact_emails + @contact_emails ||= if contact_emails_attributes.present? + contact_emails_attributes.filter_map do |_, contact_email_params| + # This filter out blank forms. + Forms::ContactEmail.new(contact_email_params.merge(emailable: work_version)) unless Forms::ContactEmail.reject_all_blank?(contact_email_params) + end + elsif work_version.contact_emails.present? + work_version.contact_emails.map do |contact_email| + Forms::ContactEmail.new_from_model(contact_email) + end + else + [] + end + end + + # This adds a blank form when none are present. + def contact_emails_forms + contact_emails.present? ? contact_emails : [Forms::ContactEmail.new] + end + + def self.new_from_model(work) + new(collection_id: work.collection_id, + id: work.id, + title: work.head.title, + abstract: work.head.abstract, + assign_doi: work.assign_doi) + end + + def will_assign_doi? + (collection.doi_option == "depositor-selects" && assign_doi) || collection.doi_option == "yes" + end + end +end diff --git a/app/views/wokes/edit.html.erb b/app/views/wokes/edit.html.erb new file mode 100644 index 000000000..56a7f4374 --- /dev/null +++ b/app/views/wokes/edit.html.erb @@ -0,0 +1,5 @@ +
+
+ <%= render Wokes::FormComponent.new(form: @form) %> +
+
diff --git a/app/views/wokes/new.html.erb b/app/views/wokes/new.html.erb new file mode 100644 index 000000000..56a7f4374 --- /dev/null +++ b/app/views/wokes/new.html.erb @@ -0,0 +1,5 @@ +
+
+ <%= render Wokes::FormComponent.new(form: @form) %> +
+
diff --git a/config/application.rb b/config/application.rb index 2df3a1681..8921ba18d 100644 --- a/config/application.rb +++ b/config/application.rb @@ -49,5 +49,7 @@ class Application < Rails::Application config.report_data = false end end + + config.view_component.capture_compatibility_patch_enabled = true end end diff --git a/config/routes.rb b/config/routes.rb index a39a97c7c..03e1df5f3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -25,6 +25,8 @@ resource :decommission, only: %i[edit update], controller: "collection_decommission", as: :collection_decommission end + resources :wokes, shallow: true + resources :works, shallow: true do member do get :delete_button diff --git a/package.json b/package.json index 8823abbd2..beb38efad 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "dependencies": { "@fortawesome/fontawesome-free": "^6.1.2", "@github/time-elements": "^3.1.2", + "@hotwired/stimulus": "^3.2.1", "@hotwired/turbo-rails": "^7.0.1", "@popperjs/core": "2", "@rails/actioncable": "^6.0.0", @@ -16,8 +17,8 @@ "lodash.debounce": "^4.0.8", "sass": "^1.43.2", "simple-datatables": "^3.0.0", - "@hotwired/stimulus": "^3.2.1", - "stimulus-autocomplete": "^3.1.0" + "stimulus-autocomplete": "^3.1.0", + "stimulus-rails-nested-form": "^4.1.0" }, "version": "0.1.0", "devDependencies": { diff --git a/yarn.lock b/yarn.lock index 92ef2d11a..ff4c9d478 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1357,6 +1357,11 @@ stimulus-autocomplete@^3.1.0: resolved "https://registry.yarnpkg.com/stimulus-autocomplete/-/stimulus-autocomplete-3.1.0.tgz#7c9292706556ed0a87abf60ea2688bf0ea1176a8" integrity sha512-SmVViCdA8yCl99oV2kzllNOqYjx7wruY+1OjAVsDTkZMNFZG5j+SqDKHMYbu+dRFy/SWq/PParzwZHvLAgH+YA== +stimulus-rails-nested-form@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/stimulus-rails-nested-form/-/stimulus-rails-nested-form-4.1.0.tgz#bfce185cff908170a4eb9973875b72517c3bc83a" + integrity sha512-ORqcTsg3sa4PGFEyUkbvcPG56F4K2fx1qJCUQIgngO1GaW5taKcvDkT0HvdTqtQAFe/1lN4CpJAqoSCt+nYF/Q== + string-width@^4.1.0, string-width@^4.2.0: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"