diff --git a/app/assets/images/icons/panel-left.svg b/app/assets/images/icons/panel-left.svg new file mode 100644 index 0000000000..c89675c6a8 --- /dev/null +++ b/app/assets/images/icons/panel-left.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/stylesheets/custom_styles/layout.css b/app/assets/stylesheets/custom_styles/layout.css index 9f32dfe445..9c1d5001e6 100644 --- a/app/assets/stylesheets/custom_styles/layout.css +++ b/app/assets/stylesheets/custom_styles/layout.css @@ -2,8 +2,40 @@ @apply max-w-7xl mx-auto py-10 px-4 sm:py-14 sm:px-6 lg:px-8; } +html:has([data-controller~="lessons--sidebar-toggle"]) { + scroll-padding-top: 3.5rem; +} + @utility highlight-white { @layer utilities { box-shadow: inset 0 1px 0 0 hsl(0deg 0% 100% / 5%); } } + +@utility scrollbar-thin { + scrollbar-width: thin; + scrollbar-color: var(--color-gray-300) transparent; + scrollbar-gutter: stable; + + &::-webkit-scrollbar { + width: 6px; + height: 6px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background-color: var(--color-gray-300); + border-radius: 9999px; + } + + .dark & { + scrollbar-color: var(--color-gray-700) transparent; + } + + .dark &::-webkit-scrollbar-thumb { + background-color: var(--color-gray-700); + } +} diff --git a/app/components/lessons/sidebar/completion_icon_component.html.erb b/app/components/lessons/sidebar/completion_icon_component.html.erb new file mode 100644 index 0000000000..092a4877ae --- /dev/null +++ b/app/components/lessons/sidebar/completion_icon_component.html.erb @@ -0,0 +1,7 @@ +<%= inline_svg_tag( + 'icons/checkmark-circle-solid.svg', + class: "sidebar-completion-icon-#{lesson.id} h-5 w-5 shrink-0 #{color_class}", + aria: true, + title: title, + data: { test_id: 'lesson-sidebar-completion' } + ) %> diff --git a/app/components/lessons/sidebar/completion_icon_component.rb b/app/components/lessons/sidebar/completion_icon_component.rb new file mode 100644 index 0000000000..d93f163226 --- /dev/null +++ b/app/components/lessons/sidebar/completion_icon_component.rb @@ -0,0 +1,21 @@ +module Lessons + module Sidebar + class CompletionIconComponent < ApplicationComponent + def initialize(lesson:) + @lesson = lesson + end + + private + + attr_reader :lesson + + def color_class + lesson.completed? ? 'text-teal-600' : 'text-gray-300 dark:text-gray-700' + end + + def title + lesson.completed? ? 'Lesson completed' : 'Lesson not completed' + end + end + end +end diff --git a/app/components/lessons/sidebar/lesson_component.html.erb b/app/components/lessons/sidebar/lesson_component.html.erb new file mode 100644 index 0000000000..c6f4482063 --- /dev/null +++ b/app/components/lessons/sidebar/lesson_component.html.erb @@ -0,0 +1,15 @@ +<%= link_to( + lesson_path(lesson), + class: 'flex items-center justify-between gap-2 px-2 py-1.5 rounded-md no-underline text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-200 aria-[current=page]:bg-gray-100 dark:aria-[current=page]:bg-gray-800 aria-[current=page]:font-semibold aria-[current=page]:text-gray-900 dark:aria-[current=page]:text-gray-100', + aria: { current: ('page' if current?) }, + data: { test_id: 'lesson-sidebar-link', 'lessons--sidebar-toggle-target': 'item' } + ) do %> + + <%= inline_svg_tag "icons/#{icon_name}.svg", class: 'h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500' %> + <%= lesson.display_title %> + + + <% if current_user %> + <%= render Lessons::Sidebar::CompletionIconComponent.new(lesson:) %> + <% end %> +<% end %> diff --git a/app/components/lessons/sidebar/lesson_component.rb b/app/components/lessons/sidebar/lesson_component.rb new file mode 100644 index 0000000000..4def10cc0e --- /dev/null +++ b/app/components/lessons/sidebar/lesson_component.rb @@ -0,0 +1,23 @@ +module Lessons + module Sidebar + class LessonComponent < ApplicationComponent + def initialize(lesson:, current_lesson:, current_user: nil) + @lesson = lesson + @current_lesson = current_lesson + @current_user = current_user + end + + private + + attr_reader :lesson, :current_lesson, :current_user + + def current? + lesson.id == current_lesson.id + end + + def icon_name + lesson.is_project? ? 'wrench-screwdriver' : 'book' + end + end + end +end diff --git a/app/components/lessons/sidebar/progress_bar_component.html.erb b/app/components/lessons/sidebar/progress_bar_component.html.erb new file mode 100644 index 0000000000..459143b9e2 --- /dev/null +++ b/app/components/lessons/sidebar/progress_bar_component.html.erb @@ -0,0 +1,16 @@ + diff --git a/app/components/lessons/sidebar/progress_bar_component.rb b/app/components/lessons/sidebar/progress_bar_component.rb new file mode 100644 index 0000000000..dd8821f0fb --- /dev/null +++ b/app/components/lessons/sidebar/progress_bar_component.rb @@ -0,0 +1,13 @@ +module Lessons + module Sidebar + class ProgressBarComponent < ApplicationComponent + def initialize(percentage:) + @percentage = percentage + end + + private + + attr_reader :percentage + end + end +end diff --git a/app/components/lessons/sidebar/section_component.html.erb b/app/components/lessons/sidebar/section_component.html.erb new file mode 100644 index 0000000000..06f6c62e67 --- /dev/null +++ b/app/components/lessons/sidebar/section_component.html.erb @@ -0,0 +1,14 @@ +
> + + <%= section.title %> + <%= inline_svg_tag 'icons/chevron-down.svg', class: 'h-4 w-4 shrink-0 text-gray-400 transition-transform group-open:rotate-180' %> + + + +
diff --git a/app/components/lessons/sidebar/section_component.rb b/app/components/lessons/sidebar/section_component.rb new file mode 100644 index 0000000000..b23653c5f9 --- /dev/null +++ b/app/components/lessons/sidebar/section_component.rb @@ -0,0 +1,19 @@ +module Lessons + module Sidebar + class SectionComponent < ApplicationComponent + def initialize(section:, current_lesson:, current_user: nil) + @section = section + @current_lesson = current_lesson + @current_user = current_user + end + + private + + attr_reader :section, :current_lesson, :current_user + + def expanded? + section.id == current_lesson.section_id + end + end + end +end diff --git a/app/components/lessons/sidebar_component.html.erb b/app/components/lessons/sidebar_component.html.erb new file mode 100644 index 0000000000..a8886c72b6 --- /dev/null +++ b/app/components/lessons/sidebar_component.html.erb @@ -0,0 +1,90 @@ +<%# Off-canvas drawer — below xl %> + + +<%# Static desktop sidebar — xl+ %> + diff --git a/app/components/lessons/sidebar_component.rb b/app/components/lessons/sidebar_component.rb new file mode 100644 index 0000000000..a3c5b11ba8 --- /dev/null +++ b/app/components/lessons/sidebar_component.rb @@ -0,0 +1,20 @@ +module Lessons + class SidebarComponent < ApplicationComponent + def initialize(course:, sections:, current_lesson:, current_user: nil) + @course = course + @sections = sections + @current_lesson = current_lesson + @current_user = current_user + end + + private + + attr_reader :course, :sections, :current_lesson, :current_user + + def progress_percentage + return if current_user.blank? + + course.progress_for(current_user).percentage + end + end +end diff --git a/app/components/lessons/sidebar_toggle_controller.js b/app/components/lessons/sidebar_toggle_controller.js new file mode 100644 index 0000000000..374bcb5d20 --- /dev/null +++ b/app/components/lessons/sidebar_toggle_controller.js @@ -0,0 +1,53 @@ +import { Controller } from '@hotwired/stimulus' + +export default class SidebarToggleController extends Controller { + static outlets = ['visibility'] + static classes = ['collapsed'] + static targets = ['item'] + static values = { storageKey: String } + + connect () { + if (this.isDesktop() && window.localStorage.getItem(this.storageKeyValue) === 'true') { + this.element.classList.add(this.collapsedClass) + } + } + + itemTargetConnected (item) { + if (item.getAttribute('href') === window.location.pathname) { + item.setAttribute('aria-current', 'page') + item.closest('details').open = true + } else { + item.removeAttribute('aria-current') + } + } + + toggle () { + if (this.isDesktop()) { + this.isCollapsed() ? this.expand() : this.collapse() + } else if (this.hasVisibilityOutlet) { + this.visibilityOutlet.toggle() + } + } + + collapse () { + this.element.classList.add(this.collapsedClass) + this.persist(true) + } + + expand () { + this.element.classList.remove(this.collapsedClass) + this.persist(false) + } + + isDesktop () { + return window.matchMedia('(min-width: 1280px)').matches + } + + isCollapsed () { + return this.element.classList.contains(this.collapsedClass) + } + + persist (collapsed) { + window.localStorage.setItem(this.storageKeyValue, String(collapsed)) + } +} diff --git a/app/controllers/lessons/course_contents_controller.rb b/app/controllers/lessons/course_contents_controller.rb new file mode 100644 index 0000000000..c3a622cb81 --- /dev/null +++ b/app/controllers/lessons/course_contents_controller.rb @@ -0,0 +1,18 @@ +module Lessons + class CourseContentsController < ApplicationController + before_action :set_cache_control_header_to_no_store + + def show + @lesson = Lesson.find(params[:lesson_id]) + @course = @lesson.course + @sections = @course.sections.includes(:lessons) + + return unless user_signed_in? + + Courses::MarkCompletedLessons.call( + user: current_user, + lessons: @sections.flat_map(&:lessons), + ) + end + end +end diff --git a/app/javascript/controllers/sticky_state_controller.js b/app/javascript/controllers/sticky_state_controller.js new file mode 100644 index 0000000000..70d08188c8 --- /dev/null +++ b/app/javascript/controllers/sticky_state_controller.js @@ -0,0 +1,24 @@ +import { Controller } from '@hotwired/stimulus' + +const FULLY_VISIBLE = 1 + +export default class StickyStateController extends Controller { + static classes = ['stuck'] + + connect () { + this.observer = new window.IntersectionObserver( + ([entry]) => this.toggleStuck(entry.intersectionRatio < FULLY_VISIBLE), + { threshold: [FULLY_VISIBLE] } + ) + + this.observer.observe(this.element) + } + + disconnect () { + this.observer?.disconnect() + } + + toggleStuck (stuck) { + this.stuckClasses.forEach((cls) => this.element.classList.toggle(cls, stuck)) + } +} diff --git a/app/views/lessons/_header.html.erb b/app/views/lessons/_header.html.erb index 1b1eab3feb..bc3d9872aa 100644 --- a/app/views/lessons/_header.html.erb +++ b/app/views/lessons/_header.html.erb @@ -1,24 +1,12 @@ -
-
- <%= render Course::BadgeComponent.new(course: lesson.course, options: {show_badge: true }) %> - -
-
-

- <%= lesson.display_title %> -

- <% if lesson.recently_added? %> - <%= render Ui::BadgeComponent.new(color: 'green') do %> - new - <% end %> - <% end %> -
- - <%= link_to path_course_url(lesson.course.path, lesson.course), class: 'no-underline' do %> -

- <%= lesson.course.title %> Course -

+
+
+

+ <%= lesson.display_title %> +

+ <% if lesson.recently_added? %> + <%= render Ui::BadgeComponent.new(color: 'green') do %> + new <% end %> -
+ <% end %>
diff --git a/app/views/lessons/_sidebar_skeleton.html.erb b/app/views/lessons/_sidebar_skeleton.html.erb new file mode 100644 index 0000000000..e7833319e1 --- /dev/null +++ b/app/views/lessons/_sidebar_skeleton.html.erb @@ -0,0 +1,16 @@ + diff --git a/app/views/lessons/_sub_navbar.html.erb b/app/views/lessons/_sub_navbar.html.erb new file mode 100644 index 0000000000..3868ef4aa0 --- /dev/null +++ b/app/views/lessons/_sub_navbar.html.erb @@ -0,0 +1,15 @@ +
+
+ +
+
diff --git a/app/views/lessons/completions/create.turbo_stream.erb b/app/views/lessons/completions/create.turbo_stream.erb index d63c02aac4..56a1efd6d1 100644 --- a/app/views/lessons/completions/create.turbo_stream.erb +++ b/app/views/lessons/completions/create.turbo_stream.erb @@ -11,3 +11,11 @@ <%= turbo_stream.update dom_id(@lesson.course, 'progress') do %> <%= render ProgressCircle::Component.new(percentage: @lesson.course.progress_for(current_user).percentage) %> <% end %> + +<%= turbo_stream.replace_all ".sidebar-completion-icon-#{@lesson.id}" do %> + <%= render Lessons::Sidebar::CompletionIconComponent.new(lesson: @lesson) %> +<% end %> + +<%= turbo_stream.replace_all '.sidebar-progress-bar' do %> + <%= render Lessons::Sidebar::ProgressBarComponent.new(percentage: @lesson.course.progress_for(current_user).percentage) %> +<% end %> diff --git a/app/views/lessons/course_contents/show.html.erb b/app/views/lessons/course_contents/show.html.erb new file mode 100644 index 0000000000..5ce50ca1d5 --- /dev/null +++ b/app/views/lessons/course_contents/show.html.erb @@ -0,0 +1,3 @@ +<%= turbo_frame_tag 'lesson-sidebar-frame', target: '_top' do %> + <%= render Lessons::SidebarComponent.new(course: @course, sections: @sections, current_lesson: @lesson, current_user:) %> +<% end %> diff --git a/app/views/lessons/show.html.erb b/app/views/lessons/show.html.erb index 0afd0198dc..72b440e3c8 100644 --- a/app/views/lessons/show.html.erb +++ b/app/views/lessons/show.html.erb @@ -1,68 +1,90 @@ <%= title(@lesson.display_title) %> -
-
-
+
-
- <%= render 'lessons/header', lesson: @lesson %> -
+
-
+ <%= turbo_frame_tag( + 'lesson-sidebar-frame', + src: lesson_course_contents_path(@lesson), + loading: :lazy, + target: '_top', + data: { turbo_permanent: true }, + class: 'block xl:w-72 xl:shrink-0 border-gray-200 dark:border-gray-800 xl:border-r group-[.sidebar-collapsed]/sidebar-layout:xl:hidden' + ) do %> + <%= render 'lessons/sidebar_skeleton' %> + <% end %> -
- <%= render ContentContainerComponent.new(classes: 'xl:mx-0 pb-6', data_attributes: { lesson_toc_target: 'lessonContent' }) do |component| %> - <%= @lesson.body.html_safe %> +
- <% component.with_footer do %> -
- <%= render NewTabTextLinkComponent.new( - text: 'Edit on GitHub', - href: github_edit_url(@lesson), - classes: 'inline-flex items-center underline hover:no-underline text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-300 text-sm' - ) %> - <%= render NewTabTextLinkComponent.new( - text: 'Report broken link', - href: github_report_url(@lesson, 'broken_link'), - classes: 'inline-flex items-center underline hover:no-underline text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-300 text-sm' - ) %> - <%= render NewTabTextLinkComponent.new( - text: 'Report other issue/suggestion', - href: github_report_url(@lesson, 'suggestion'), - classes: 'inline-flex items-center underline hover:no-underline text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-300 text-sm' - ) %> -
+ <%= render 'lessons/sub_navbar' %> -
- <%= render NewTabTextLinkComponent.new( - text: 'See lesson changelog', - href: github_commits_url(@lesson), - classes: 'inline-flex items-center underline hover:no-underline text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-300 text-sm' - ) %> -
- <% end %> - <% end %> +
- <% if user_signed_in? && @lesson.accepts_submission? %> -
-
- <%= render 'lessons/project_solution', lesson: @lesson, project_submission: @project_submission %> -
-
- <% end %> +
+
+ <%= render 'lessons/header', lesson: @lesson %> + +
+ <%= render ContentContainerComponent.new(classes: 'xl:mx-0 pb-6', data_attributes: { lesson_toc_target: 'lessonContent' }) do |component| %> + <%= @lesson.body.html_safe %> -
- <%= render 'lesson_buttons', lesson: @lesson, course: @lesson.course, user: @user %> + <% component.with_footer do %> +
+ <%= render NewTabTextLinkComponent.new( + text: 'Edit on GitHub', + href: github_edit_url(@lesson), + classes: 'inline-flex items-center underline hover:no-underline text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-300 text-sm' + ) %> + <%= render NewTabTextLinkComponent.new( + text: 'Report broken link', + href: github_report_url(@lesson, 'broken_link'), + classes: 'inline-flex items-center underline hover:no-underline text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-300 text-sm' + ) %> + <%= render NewTabTextLinkComponent.new( + text: 'Report other issue/suggestion', + href: github_report_url(@lesson, 'suggestion'), + classes: 'inline-flex items-center underline hover:no-underline text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-300 text-sm' + ) %> +
+ +
+ <%= render NewTabTextLinkComponent.new( + text: 'See lesson changelog', + href: github_commits_url(@lesson), + classes: 'inline-flex items-center underline hover:no-underline text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-300 text-sm' + ) %> +
+ <% end %> + <% end %> + + <% if user_signed_in? && @lesson.accepts_submission? %> +
+
+ <%= render 'lessons/project_solution', lesson: @lesson, project_submission: @project_submission %> +
+
+ <% end %> + +
+ <%= render 'lesson_buttons', lesson: @lesson, course: @lesson.course, user: @user %> +
+
-
+
-
diff --git a/config/routes.rb b/config/routes.rb index 1a883e63e5..49ca12fce3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -89,6 +89,7 @@ resources :lessons, only: :show do resources :project_submissions, except: :show, controller: 'lessons/project_submissions' resource :completion, only: %i[create destroy], controller: 'lessons/completions' + resource :course_contents, only: :show, controller: 'lessons/course_contents' end resources :project_submissions, only: :index do diff --git a/spec/components/lessons/sidebar/completion_icon_component_spec.rb b/spec/components/lessons/sidebar/completion_icon_component_spec.rb new file mode 100644 index 0000000000..cc4a9b0d41 --- /dev/null +++ b/spec/components/lessons/sidebar/completion_icon_component_spec.rb @@ -0,0 +1,41 @@ +require 'rails_helper' + +RSpec.describe Lessons::Sidebar::CompletionIconComponent, type: :component do + context 'when the lesson is completed' do + it 'fills the icon with the completed color' do + lesson = create(:lesson) + lesson.completed = true + + render_inline(described_class.new(lesson:)) + + expect(page).to have_css('[data-test-id="lesson-sidebar-completion"].text-teal-600') + end + + it 'labels the icon "Lesson completed"' do + lesson = create(:lesson) + lesson.completed = true + + render_inline(described_class.new(lesson:)) + + expect(page).to have_css('svg title', text: 'Lesson completed', visible: :all) + end + end + + context 'when the lesson is incomplete' do + it 'fills the icon with the incomplete color' do + lesson = create(:lesson) + + render_inline(described_class.new(lesson:)) + + expect(page).to have_css('[data-test-id="lesson-sidebar-completion"].text-gray-300') + end + + it 'labels the icon "Lesson not completed"' do + lesson = create(:lesson) + + render_inline(described_class.new(lesson:)) + + expect(page).to have_css('svg title', text: 'Lesson not completed', visible: :all) + end + end +end diff --git a/spec/components/lessons/sidebar/lesson_component_spec.rb b/spec/components/lessons/sidebar/lesson_component_spec.rb new file mode 100644 index 0000000000..15173b2cff --- /dev/null +++ b/spec/components/lessons/sidebar/lesson_component_spec.rb @@ -0,0 +1,58 @@ +require 'rails_helper' + +RSpec.describe Lessons::Sidebar::LessonComponent, type: :component do + it 'renders the lesson title as a link' do + lesson = create(:lesson, title: 'HTML Basics') + component = described_class.new(lesson:, current_lesson: lesson) + + render_inline(component) + + expect(page).to have_link('HTML Basics', href: "/lessons/#{lesson.friendly_id}") + end + + context 'when rendered as the current lesson' do + it 'marks the link with aria-current="page"' do + lesson = create(:lesson) + component = described_class.new(lesson:, current_lesson: lesson) + + render_inline(component) + + expect(page).to have_css('a[aria-current="page"]') + end + end + + context 'when rendered as a sibling lesson' do + it 'does not set aria-current' do + lesson = create(:lesson) + other = create(:lesson, section: lesson.section) + component = described_class.new(lesson:, current_lesson: other) + + render_inline(component) + + expect(page).to have_no_css('a[aria-current]') + end + end + + context 'when a current_user is given' do + it 'renders a completion icon' do + lesson = create(:lesson) + user = create(:user) + component = described_class.new(lesson:, current_lesson: lesson, current_user: user) + + render_inline(component) + + expect(page).to have_css('[data-test-id="lesson-sidebar-completion"]') + end + end + + context 'when no current_user is given' do + it 'does not render the completion icon' do + lesson = create(:lesson) + component = described_class.new(lesson:, current_lesson: lesson) + + render_inline(component) + + expect(page).to have_no_css('[data-test-id="lesson-sidebar-completion"]') + end + end +end diff --git a/spec/components/lessons/sidebar/section_component_spec.rb b/spec/components/lessons/sidebar/section_component_spec.rb new file mode 100644 index 0000000000..085a703bcb --- /dev/null +++ b/spec/components/lessons/sidebar/section_component_spec.rb @@ -0,0 +1,51 @@ +require 'rails_helper' + +RSpec.describe Lessons::Sidebar::SectionComponent, type: :component do + it 'renders the section title' do + section = create(:section, title: 'Getting Started') + lesson = create(:lesson, section:) + component = described_class.new(section:, current_lesson: lesson) + + render_inline(component) + + expect(page).to have_content('Getting Started') + end + + it 'renders one link per lesson in the section' do + section = create(:section) + lessons = create_list(:lesson, 3, section:) + section.reload + component = described_class.new(section:, current_lesson: lessons.first) + + render_inline(component) + + expect(page).to have_css('li', count: 3) + end + + context 'when the section contains the current lesson' do + it 'renders
with the open attribute' do + section = create(:section) + lesson = create(:lesson, section:) + component = described_class.new(section:, current_lesson: lesson) + + render_inline(component) + + expect(page).to have_css('details[open]') + end + end + + context 'when the section does not contain the current lesson' do + it 'renders
without the open attribute' do + section = create(:section) + create(:lesson, section:) + other_section = create(:section, course: section.course) + other_lesson = create(:lesson, section: other_section) + component = described_class.new(section:, current_lesson: other_lesson) + + render_inline(component) + + expect(page).to have_css('details') + expect(page).to have_no_css('details[open]') + end + end +end diff --git a/spec/components/lessons/sidebar_component_spec.rb b/spec/components/lessons/sidebar_component_spec.rb new file mode 100644 index 0000000000..ea7d32e890 --- /dev/null +++ b/spec/components/lessons/sidebar_component_spec.rb @@ -0,0 +1,108 @@ +require 'rails_helper' + +RSpec.describe Lessons::SidebarComponent, type: :component do + def setup_course + path = create(:path) + course = create(:course, path:) + section_one = create(:section, course:) + section_two = create(:section, course:) + lesson_one = create(:lesson, section: section_one) + lesson_two = create(:lesson, section: section_two) + { course:, sections: [section_one, section_two], lesson_one:, lesson_two: } + end + + it 'renders both the off-canvas drawer and the static desktop aside' do + data = setup_course + component = described_class.new( + course: data[:course], + sections: data[:sections], + current_lesson: data[:lesson_one], + ) + + render_inline(component) + + expect(page).to have_css('.lesson-sidebar-drawer') + expect(page).to have_css('[data-test-id="lesson-sidebar"]') + end + + it 'renders a details element for each section' do + data = setup_course + component = described_class.new( + course: data[:course], + sections: data[:sections], + current_lesson: data[:lesson_one], + ) + + render_inline(component) + + within '[data-test-id="lesson-sidebar"]' do + expect(page).to have_css('details', count: 2) + end + end + + it 'opens only the section that contains the current lesson' do + data = setup_course + component = described_class.new( + course: data[:course], + sections: data[:sections], + current_lesson: data[:lesson_two], + ) + + render_inline(component) + + within '[data-test-id="lesson-sidebar"]' do + expect(page).to have_css('details[open]', count: 1) + end + end + + it 'links back to the course via the course title' do + data = setup_course + component = described_class.new( + course: data[:course], + sections: data[:sections], + current_lesson: data[:lesson_one], + ) + + render_inline(component) + + within '[data-test-id="lesson-sidebar"]' do + expect(page).to have_link(data[:course].title) + end + end + + context 'when a current_user is given' do + it 'renders a course-progress bar reflecting their completions' do + data = setup_course + user = create(:user) + create(:lesson_completion, user:, lesson: data[:lesson_one], course: data[:course]) + component = described_class.new( + course: data[:course], + sections: data[:sections], + current_lesson: data[:lesson_one], + current_user: user, + ) + + render_inline(component) + + within '[data-test-id="lesson-sidebar"]' do + expect(page).to have_css('[role="progressbar"][aria-valuenow="50"]') + expect(page).to have_text('50% complete') + end + end + end + + context 'when no current_user is given' do + it 'does not render a progress bar' do + data = setup_course + component = described_class.new( + course: data[:course], + sections: data[:sections], + current_lesson: data[:lesson_one], + ) + + render_inline(component) + + expect(page).to have_no_css('[role="progressbar"]') + end + end +end diff --git a/spec/requests/lessons/course_contents_spec.rb b/spec/requests/lessons/course_contents_spec.rb new file mode 100644 index 0000000000..06c09c8644 --- /dev/null +++ b/spec/requests/lessons/course_contents_spec.rb @@ -0,0 +1,31 @@ +require 'rails_helper' + +RSpec.describe 'Lesson course contents' do + describe 'GET #show' do + let(:course) { create(:course, path: create(:path, default_path: true)) } + let(:section) { create(:section, course:) } + let(:lesson) { create(:lesson, section:) } + + it 'renders the sidebar' do + get lesson_course_contents_path(lesson) + + expect(response).to have_http_status(:success) + expect(response.body).to include(course.title) + end + + context 'when signed in' do + let(:user) { create(:user) } + + before { sign_in(user) } + + it 'marks completed lessons across the whole course' do + sibling = create(:lesson, section:) + create(:lesson_completion, user:, lesson: sibling, course:) + + get lesson_course_contents_path(lesson) + + expect(response.body).to include('Lesson completed') + end + end + end +end diff --git a/spec/system/lessons/sidebar_spec.rb b/spec/system/lessons/sidebar_spec.rb new file mode 100644 index 0000000000..e4bcb75bfe --- /dev/null +++ b/spec/system/lessons/sidebar_spec.rb @@ -0,0 +1,38 @@ +require 'rails_helper' + +RSpec.describe 'Lesson show sidebar' do + let(:user) { create(:user) } + let(:course) { create(:course, path: create(:path, default_path: true)) } + + def build_curriculum(course) + section_one = create(:section, position: 1, course:, title: 'Getting Started') + section_two = create(:section, position: 2, course:, title: 'Advanced Topics') + { + current: create(:lesson, position: 1, section: section_one, title: 'Intro'), + sibling: create(:lesson, position: 2, section: section_one, title: 'Next Steps'), + other: create(:lesson, position: 1, section: section_two, title: 'Deep Dive') + } + end + + before do + sign_in(user) + page.driver.resize(1400, 900) + end + + it 'keeps manually expanded sections open after navigating' do + lessons = build_curriculum(course) + + visit lesson_path(lessons[:current]) + + within('[data-test-id="lesson-sidebar"]') do + find('summary', text: 'Advanced Topics').click + click_link lessons[:other].title + end + + within('[data-test-id="lesson-sidebar"]') do + expect(page).to have_css('details[open]', text: 'Advanced Topics') + expect(page).to have_css('details[open]', text: 'Getting Started') + expect(page).to have_css('a[aria-current="page"]', text: 'Deep Dive') + end + end +end