diff --git a/app/assets/images/lock.svg b/app/assets/images/lock.svg new file mode 100644 index 000000000..c614a38db --- /dev/null +++ b/app/assets/images/lock.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/controllers/admin/pages_controller.rb b/app/controllers/admin/pages_controller.rb index 5a52ce503..19c54438e 100644 --- a/app/controllers/admin/pages_controller.rb +++ b/app/controllers/admin/pages_controller.rb @@ -3,13 +3,14 @@ module Admin layout "admin" def index - @pages = Page.all + @pages = Page.all.order(created_at: :desc) @code_of_conduct = Page.find_by(slug: "code-of-conduct") @privacy = Page.find_by(slug: "privacy") @terms = Page.find_by(slug: "terms") end def new + @landing_page = Page.find_by(landing_page: true) if (slug = params[:slug]) prepopulate_new_form(slug) else @@ -19,13 +20,12 @@ module Admin def edit @page = Page.find(params[:id]) + @landing_page = Page.find_by(landing_page: true) end def update @page = Page.find(params[:id]) - @page.assign_attributes(page_params) - if @page.valid? - @page.update!(page_params) + if update_and_overwrite_landing_page flash[:success] = "Page has been successfully updated." redirect_to admin_pages_path else @@ -36,8 +36,7 @@ module Admin def create @page = Page.new(page_params) - if @page.valid? - @page.save! + if create_and_overwrite_landing_page flash[:success] = "Page has been successfully created." redirect_to admin_pages_path else @@ -57,7 +56,7 @@ module Admin def page_params allowed_params = %i[title slug body_markdown body_html body_json description template is_top_level_path - social_image] + social_image landing_page overwrite_landing_page] params.require(:page).permit(allowed_params) end @@ -96,5 +95,31 @@ module Admin Page.new end end + + def update_and_overwrite_landing_page + if page_params["overwrite_landing_page"] == "true" + Page.transaction do + current_landing_page = Page.find_by(landing_page: true) + current_landing_page&.update(landing_page: false) + + @page.update(page_params) + end + else + @page.update(page_params) + end + end + + def create_and_overwrite_landing_page + if page_params["overwrite_landing_page"] == "true" + Page.transaction do + current_landing_page = Page.find_by(landing_page: true) + current_landing_page&.update(landing_page: false) + + @page.save + end + else + @page.save + end + end end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 65b96c532..770c55134 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -54,8 +54,12 @@ class ApplicationController < ActionController::Base return if self.class.module_parent.to_s == "Admin" return if user_signed_in? || Settings::UserExperience.public + @page = Page.landing_page + if api_action? authenticate! + elsif @page + render template: "pages/show" else @user ||= User.new render template: "devise/registrations/new" diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 53c2dac86..6f46d6cf2 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -22,6 +22,12 @@ class PagesController < ApplicationController set_surrogate_key_header "about_listings_page" end + def badge + @html_variant = HtmlVariant.find_for_test([], "badge_landing_page") + render layout: false + set_surrogate_key_header "badge_page" + end + def bounty @page = Page.find_by(slug: "security") render :show if @page @@ -52,22 +58,16 @@ class PagesController < ApplicationController set_surrogate_key_header "faq_page" end - def privacy - @page = Page.find_by(slug: "privacy") - render :show if @page - set_surrogate_key_header "privacy_page" - end - def post_a_job @page = Page.find_by(slug: "post-a-job") render :show if @page set_surrogate_key_header "post_a_job_page" end - def terms - @page = Page.find_by(slug: "terms") + def privacy + @page = Page.find_by(slug: "privacy") render :show if @page - set_surrogate_key_header "terms_page" + set_surrogate_key_header "privacy_page" end def tag_moderation @@ -76,10 +76,10 @@ class PagesController < ApplicationController set_surrogate_key_header "tag_moderation_page" end - def badge - @html_variant = HtmlVariant.find_for_test([], "badge_landing_page") - render layout: false - set_surrogate_key_header "badge_page" + def terms + @page = Page.find_by(slug: "terms") + render :show if @page + set_surrogate_key_header "terms_page" end def report_abuse diff --git a/app/javascript/admin/controllers/landing_page_modal_controller.js b/app/javascript/admin/controllers/landing_page_modal_controller.js new file mode 100644 index 000000000..5480e7b66 --- /dev/null +++ b/app/javascript/admin/controllers/landing_page_modal_controller.js @@ -0,0 +1,26 @@ +import ModalController from './modal_controller'; + +export default class LandingPageModalController extends ModalController { + static targets = ['overwrite', 'landingPageCheckbox']; + + openModal() { + if (this.landingPageCheckboxTarget.checked) { + this.toggleModal(); + } + } + + confirm(event) { + event.preventDefault(); + + this.overwriteTarget.value = true; + this.closeModal(); + } + + cancel(event) { + event.preventDefault(); + + this.landingPageCheckboxTarget.checked = false; + this.overwriteTarget.value = false; + this.closeModal(); + } +} diff --git a/app/models/page.rb b/app/models/page.rb index 2dcaf5774..ac0eff45b 100644 --- a/app/models/page.rb +++ b/app/models/page.rb @@ -1,4 +1,6 @@ class Page < ApplicationRecord + attr_accessor :overwrite_landing_page + TEMPLATE_OPTIONS = %w[contained full_within_layout json].freeze validates :title, presence: true @@ -7,6 +9,7 @@ class Page < ApplicationRecord validates :template, inclusion: { in: TEMPLATE_OPTIONS } validate :body_present validate :unique_slug_including_users_and_orgs, if: :slug_changed? + validate :single_landing_page, if: :will_save_change_to_landing_page? before_validation :set_default_template before_save :evaluate_markdown @@ -23,6 +26,10 @@ class Page < ApplicationRecord "page_#{slug}" end + def self.landing_page + find_by(landing_page: true) + end + private def evaluate_markdown @@ -56,6 +63,17 @@ class Page < ApplicationRecord errors.add(:slug, "is taken.") end + def single_landing_page + # Only add errors if we are trying to modify a landing page + # while another landing page is already being used to ensure + # that only one can be set to "true" at a time. + + landing_page = Page.where.not(id: id).find_by(landing_page: true) + return unless landing_page + + errors.add(:base, "Only one page at a time can be used as a 'locked screen.'") + end + def bust_cache Pages::BustCacheWorker.perform_async(slug) end diff --git a/app/views/admin/pages/_form.html.erb b/app/views/admin/pages/_form.html.erb index 89ef86d99..9077fa04a 100644 --- a/app/views/admin/pages/_form.html.erb +++ b/app/views/admin/pages/_form.html.erb @@ -1,4 +1,9 @@ -
+
<%= form_for [:admin, @page] do |form| %>
<%= form.label :title %> @@ -38,9 +43,31 @@
<%= form.label :is_top_level_path %> - <%= form.check_box :is_top_level_path %> -

(Determines if it is accessible by /page-slug vs /page/page-slug) Be careful! ⚠️

+ <%= form.check_box :is_top_level_path, class: "crayons-checkbox" %> +

Determines if it is accessible by /page-slug vs /page/page-slug Be careful! ⚠️

+ +
+ <%= form.label :landing_page, "Use as 'Locked Screen'" %> + <%= form.check_box :landing_page, class: "crayons-checkbox", "aria-describedby": "lock-screen-description", + data: { + "landing-page-modal-target": "landingPageCheckbox", + action: (@landing_page.present? ? "landing-page-modal#openModal" : "") + } %> + <%= form.hidden_field :overwrite_landing_page, data: { "landing-page-modal-target": "overwrite" } %> +

Determines if this page will be used as a landing page for anonymous viewers.

+
+ + <% if @page.errors.count > 0 %> + + <% end %> + + <% if @landing_page %> + <%= render partial: "landing_page_modal", locals: { page: @landing_page } %> + <% end %> +

<%= link_to "Feature Flag", "/admin/feature_flags" %> @@ -58,12 +85,12 @@

- <%= form.submit class: "btn btn-primary" %> + <%= form.submit class: "crayons-btn" %> <% end %> <% if @page.persisted? %> <%= form_with model: [:admin, @page], local: true, method: :delete, class: "mt-3" do |f| %> - <%= f.submit "Delete Page", class: "btn btn-danger", data: { confirm: "Are you sure?" } %> + <%= f.submit "Delete Page", class: "crayons-btn crayons-btn--danger", data: { confirm: "Are you sure?" } %> <% end %> <% end %>
diff --git a/app/views/admin/pages/_landing_page_modal.html.erb b/app/views/admin/pages/_landing_page_modal.html.erb new file mode 100644 index 000000000..01feff211 --- /dev/null +++ b/app/views/admin/pages/_landing_page_modal.html.erb @@ -0,0 +1,18 @@ + + + diff --git a/app/views/admin/pages/index.html.erb b/app/views/admin/pages/index.html.erb index 7f9f3d6c6..26e52f16f 100644 --- a/app/views/admin/pages/index.html.erb +++ b/app/views/admin/pages/index.html.erb @@ -7,7 +7,10 @@
<% @pages.each do |page| %> -
+
+ <% if page.landing_page? %> + <%= inline_svg_tag("lock.svg", class: "crayons-icon", aria: true, title: "Current locked screen") %> + <% end %> <%= link_to page.title, page.path %> <%= link_to "Edit", edit_admin_page_path(page.id), class: "ml-auto crayons-btn crayons-btn--s crayons-btn--secondary" %>
diff --git a/cypress/integration/adminFlows/pages/landingPage.spec.js b/cypress/integration/adminFlows/pages/landingPage.spec.js new file mode 100644 index 000000000..c254e9508 --- /dev/null +++ b/cypress/integration/adminFlows/pages/landingPage.spec.js @@ -0,0 +1,139 @@ +describe('Set a landing page from the admin portal', () => { + beforeEach(() => { + cy.testSetup(); + cy.fixture('users/adminUser.json').as('user'); + + cy.get('@user').then((user) => { + cy.loginUser(user).then(() => { + cy.visit('/admin/customization/pages'); + }); + }); + }); + + it('should set a landing page when no other landing page exists', () => { + cy.findAllByRole('link', { name: 'Edit' }).first().click(); + cy.findByRole('checkbox', { name: "Use as 'Locked Screen'" }).check(); + cy.findByRole('button', { name: 'Update Page' }).click(); + + // Verify that the form has submitted and the page has changed to the confirmation page + cy.url().should('contain', '/admin/customization/pages'); + + cy.findByRole('img', { name: 'Current locked screen' }).should( + 'be.visible', + ); + }); + + it('should overwrite the landing page when choosing to set a new landing page', () => { + cy.findAllByRole('link', { name: 'Edit' }).first().click(); + cy.findByRole('checkbox', { name: "Use as 'Locked Screen'" }); + // Set landing page + cy.findByRole('main').within(() => { + cy.findByRole('checkbox', { name: "Use as 'Locked Screen'" }).check(); + + cy.findByRole('button', { name: 'Update Page' }).click(); + }); + cy.url().should('contain', '/admin/customization/pages'); + // Retrieve the title of the landing page + let landingPageTitle; + cy.findByRole('main').within(() => { + cy.findAllByTestId('page').should((elements) => { + for (let i = elements.length - 1; i >= 0; i--) { + const el = elements[i]; + const isLandingPage = + el.getElementsByClassName('crayons-icon').length > 0; + if (isLandingPage) { + landingPageTitle = el.querySelector('a').innerHTML; + } + } + }); + + cy.findAllByRole('link', { name: 'Edit' }).eq(1).click(); + }); + cy.findByRole('checkbox', { name: "Use as 'Locked Screen'" }); + // Change landing page + cy.findByRole('main').within(() => { + cy.findByRole('checkbox', { name: "Use as 'Locked Screen'" }).check(); + + cy.findAllByRole('button', { + name: 'Overwrite current locked screen', + }).click(); + + cy.findByRole('button', { name: 'Update Page' }).click(); + }); + + // Check the title of the landing page has changed + cy.findByRole('main').within(() => { + let newLandingPageTitle; + cy.findAllByTestId('page').should((elements) => { + for (let i = elements.length - 1; i >= 0; i--) { + const el = elements[i]; + const isLandingPage = + el.getElementsByClassName('crayons-icon').length > 0; + if (isLandingPage) { + newLandingPageTitle = el.querySelector('a').innerHTML; + } + } + + assert.notEqual(landingPageTitle, newLandingPageTitle); + }); + }); + }); + + it('should not change the landing page when clicking cancel', () => { + cy.findAllByRole('link', { name: 'Edit' }).first().click(); + + // Set landing page + cy.findByRole('main').within(() => { + cy.findAllByRole('checkbox', { name: "Use as 'Locked Screen'" }) + .first() + .check(); + + cy.findAllByRole('button', { name: 'Update Page' }).first().click(); + }); + + // Retrieve the title of the landing page + let landingPageTitle; + cy.findByRole('main').within(() => { + cy.findAllByTestId('page').should((elements) => { + for (let i = elements.length - 1; i >= 0; i--) { + const el = elements[i]; + const isLandingPage = + el.getElementsByClassName('crayons-icon').length > 0; + if (isLandingPage) { + landingPageTitle = el.querySelector('a').innerHTML; + } + } + }); + + cy.findAllByRole('link', { name: 'Edit' }).eq(1).click(); + }); + + // Change landing page but then Cancel + cy.findByRole('main').within(() => { + cy.findAllByRole('checkbox', { name: "Use as 'Locked Screen'" }) + .first() + .check(); + + cy.findAllByRole('button', { name: 'Cancel' }).first().click(); + + cy.findAllByRole('button', { name: 'Update Page' }).first().click(); + }); + + // Check the title of the landing page has not changed + cy.findByRole('main').within(() => { + let newLandingPageTitle; + cy.findAllByTestId('page').should((elements) => { + for (let i = elements.length - 1; i >= 0; i--) { + const el = elements[i]; + const isLandingPage = + el.getElementsByClassName('crayons-icon').length > 0; + if (isLandingPage) { + newLandingPageTitle = el.querySelector('a').innerHTML; + } + } + + assert.equal(landingPageTitle, newLandingPageTitle); + }); + }); + }); +}); diff --git a/spec/models/page_spec.rb b/spec/models/page_spec.rb index 828cdb3c4..b589cd72e 100644 --- a/spec/models/page_spec.rb +++ b/spec/models/page_spec.rb @@ -36,6 +36,13 @@ RSpec.describe Page, type: :model do expect(page).not_to be_valid expect(page.errors[:slug].to_s.include?("taken")).to be true end + + it "only allows a single landing_page to be set to true" do + create(:page, landing_page: true) + page = build(:page, landing_page: true) + expect(page).not_to be_valid + expect(page.errors[:base].to_s.include?("Only one page")).to be true + end end context "when callbacks are triggered before save" do diff --git a/spec/requests/stories_index_spec.rb b/spec/requests/stories_index_spec.rb index 240070ccb..44be8389a 100644 --- a/spec/requests/stories_index_spec.rb +++ b/spec/requests/stories_index_spec.rb @@ -49,6 +49,14 @@ RSpec.describe "StoriesIndex", type: :request do expect(response.body).to include("Continue with") end + it "renders a landing page if one is active and if the site config is set to private" do + allow(Settings::UserExperience).to receive(:public).and_return(false) + create(:page, title: "This is a landing page!", landing_page: true) + + get root_path + expect(response.body).to include("This is a landing page!") + end + it "renders all display_ads when published and approved" do org = create(:organization) ad = create(:display_ad, published: true, approved: true, organization: org) diff --git a/spec/support/seeds/seeds_e2e.rb b/spec/support/seeds/seeds_e2e.rb index de52c53fa..0943f6d51 100644 --- a/spec/support/seeds/seeds_e2e.rb +++ b/spec/support/seeds/seeds_e2e.rb @@ -230,3 +230,18 @@ seeder.create_if_none(Badge) do rewarding_context_message_markdown: Faker::Markdown.random, ) end + +############################################################################## + +seeder.create_if_none(Page) do + 2.times do + Page.create!( + slug: Faker::Lorem.word, + body_html: "

#{Faker::Hipster.paragraph(sentence_count: 2)}

", + title: "#{Faker::Lorem.word} #{rand(100)}", + description: "A test page", + is_top_level_path: true, + landing_page: false, + ) + end +end diff --git a/spec/system/admin/admin_creates_new_page_spec.rb b/spec/system/admin/admin_creates_new_page_spec.rb index 63415c4c5..36892a2ab 100644 --- a/spec/system/admin/admin_creates_new_page_spec.rb +++ b/spec/system/admin/admin_creates_new_page_spec.rb @@ -13,6 +13,7 @@ RSpec.describe "Admin creates new page", type: :system do expect(find_field("page[title]").value).to eq("Code of Conduct") expect(find_field("page[slug]").value).to eq("code-of-conduct") expect(find_field("page[is_top_level_path]").value).to eq("1") + expect(find_field("page[landing_page]").value).to eq("1") text = "All participants of #{community_name} are expected to abide by our Code of Conduct" expect(find_field("page[body_html]").value).to include(text) diff --git a/spec/system/admin/admin_manages_pages_spec.rb b/spec/system/admin/admin_manages_pages_spec.rb index 072dcface..636283dfc 100644 --- a/spec/system/admin/admin_manages_pages_spec.rb +++ b/spec/system/admin/admin_manages_pages_spec.rb @@ -9,7 +9,8 @@ RSpec.describe "Admin manages pages", type: :system do body_html: "
hello there
", title: "Test Page", description: "A test page", - is_top_level_path: true) + is_top_level_path: true, + landing_page: false) sign_in admin visit admin_pages_path end @@ -111,4 +112,37 @@ RSpec.describe "Admin manages pages", type: :system do end end end + + describe "when there is a landing page" do + let(:current_landing_page) { create(:page, landing_page: true) } + let(:new_landing_page) { create(:page, landing_page: true) } + + it "allows a landing page to be updated", :aggregate_failures do + visit edit_admin_page_path(current_landing_page.id) + expect(page).to have_content("Use as 'Locked Screen") + uncheck "Use as 'Locked Screen'" + click_on("Update Page") + expect(page).to have_current_path(admin_pages_path) + end + + it "allows an Admin to click through to the current landing page via the modal", :aggregate_failures do + visit edit_admin_page_path(new_landing_page.id) + expect(page).to have_content("Use as 'Locked Screen") + check "Use as 'Locked Screen'" + expect(page).to have_link("Current Locked Screen: #{new_landing_page.title}") + click_on("Current Locked Screen") + expect(page).to have_current_path(new_landing_page.path) + expect(page).to have_content(new_landing_page.title) + end + + it "allows an Admin to overwrite the current landing page via the checkbox and modal", :aggregate_failures do + visit edit_admin_page_path(new_landing_page.id) + expect(page).to have_content("Use as 'Locked Screen") + check "Use as 'Locked Screen'" + expect(page).to have_link("Current Locked Screen: #{new_landing_page.title}") + click_on("Overwrite current locked screen") + click_on("Update Page") + expect(page).to have_current_path(admin_pages_path) + end + end end