diff --git a/app/assets/javascripts/favicon.ts b/app/assets/javascripts/favicon.ts new file mode 100644 index 0000000000..a09f519d6a --- /dev/null +++ b/app/assets/javascripts/favicon.ts @@ -0,0 +1,49 @@ +/** + * Manager for the favicon. + */ +export class FaviconManager { + private readonly tags: Set = new Set(); + + public constructor(initial: string[]) { + initial.forEach(e => this.tags.add(e)); + } + + /** + * Request that you want to show a dot in the favicon. This will always + * result in a dot being shown. + * + * @param {string} tag The tag you want to add. + */ + public requestDot(tag: string): void { + // Thread safe, since JS is single threaded in the browser. + this.tags.add(tag); + FaviconManager.showDot(); + } + + /** + * Indicate you no longer want to show a dot in the favicon. If you were the + * last one requesting the dot, the dot will be hidden. + * + * @param {string} tag The tag you want to release. + */ + public releaseDot(tag: string): void { + this.tags.delete(tag); + if (this.tags.size === 0) { + FaviconManager.hideDot(); + } + } + + private static showDot(): void { + document.querySelector("link[rel=\"shortcut icon\"][href=\"/icon.png\"]") + ?.setAttribute("href", "/icon-not.png"); + document.querySelector("link[rel=\"shortcut icon\"][href=\"/favicon.ico\"]") + ?.setAttribute("href", "/favicon-not.ico"); + } + + private static hideDot(): void { + document.querySelector("link[rel=\"shortcut icon\"][href=\"/icon-not.png\"]") + ?.setAttribute("href", "/icon.png"); + document.querySelector("link[rel=\"shortcut icon\"][href=\"/favicon-not.ico\"]") + ?.setAttribute("href", "/favicon.ico"); + } +} diff --git a/app/assets/javascripts/notification.ts b/app/assets/javascripts/notification.ts index 7e484573e6..ba92049cb9 100644 --- a/app/assets/javascripts/notification.ts +++ b/app/assets/javascripts/notification.ts @@ -1,3 +1,4 @@ +import { FaviconManager } from "favicon"; import { fetch } from "util.js"; /** * Model for a notification in the navbar. It adds three listeners to the notification view: @@ -15,13 +16,15 @@ export class Notification { private readonly element: Element; private readonly url: string; private readonly notifiableUrl: string; + private readonly faviconManager: FaviconManager; private read: boolean; - constructor(id: number, url: string, read: boolean, notifiableUrl: string, installClickHandler: boolean) { + constructor(id: number, url: string, read: boolean, notifiableUrl: string, installClickHandler: boolean, manager: FaviconManager) { this.element = document.querySelector(`.notification[data-id="${id}"]`); this.read = read; this.url = url; this.notifiableUrl = notifiableUrl; + this.faviconManager = manager; this.element.querySelector(".read-toggle-button").addEventListener("click", event => { this.toggleRead(); @@ -63,12 +66,10 @@ export class Notification { } if (document.querySelectorAll(".notification.unread").length === 0) { document.querySelector("#navbar-notifications .dropdown-toggle")?.classList?.remove("notification"); - document.querySelector("link[rel=\"shortcut icon\"][href=\"/icon-not.png\"]")?.setAttribute("href", "/icon.png"); - document.querySelector("link[rel=\"shortcut icon\"][href=\"/favicon-not.ico\"]")?.setAttribute("href", "/favicon.ico"); + this.faviconManager.releaseDot("notifications"); } else { document.querySelector("#navbar-notifications .dropdown-toggle")?.classList?.add("notification"); - document.querySelector("link[rel=\"shortcut icon\"][href=\"/icon.png\"]")?.setAttribute("href", "/icon-not.png"); - document.querySelector("link[rel=\"shortcut icon\"][href=\"/favicon.ico\"]")?.setAttribute("href", "/favicon-not.ico"); + this.faviconManager.requestDot("notifications"); } } diff --git a/app/assets/javascripts/util.js b/app/assets/javascripts/util.js index 51ff030032..5967e45f77 100644 --- a/app/assets/javascripts/util.js +++ b/app/assets/javascripts/util.js @@ -209,6 +209,15 @@ function makeVisible(element) { element.style.visibility = "visible"; } +/** + * Set the title of the webpage. + * + * @param {string} title The new title. + */ +function setDocumentTitle(title) { + document.title = title; +} + export { delay, fetch, @@ -224,5 +233,6 @@ export { initTooltips, initTokenClickables, makeInvisible, - makeVisible + makeVisible, + setDocumentTitle }; diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index adc7dd3f83..1fb12510ed 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -186,5 +186,8 @@ def set_notifications true end end + # This variable counts for which services the dot in the favicon should be shown. + # On most pages this will be empty or contain :notifications + @dot_icon = @unread_notifications.any? ? %i[notifications] : [] end end diff --git a/app/controllers/courses_controller.rb b/app/controllers/courses_controller.rb index 544a6e57d6..2db0235905 100644 --- a/app/controllers/courses_controller.rb +++ b/app/controllers/courses_controller.rb @@ -196,7 +196,6 @@ def scoresheet end def questions - @title = I18n.t('courses.questions.questions.title') @crumbs = [[@course.name, course_path(@course)], [I18n.t('courses.questions.questions.title'), '#']] @refresh = ActiveRecord::Type::Boolean.new.deserialize(params.fetch(:refresh, @course.enabled_questions?.to_s)) @@ -212,6 +211,13 @@ def questions .order(updated_at: :desc) .includes(:user, :last_updated_by, submission: [:exercise]) .paginate(page: parse_pagination_param(params[:answered_page]), per_page: 10) + count = @course.unanswered_questions.count + @title = if count == 0 + I18n.t('courses.questions.questions.title') + else + I18n.t('courses.questions.questions.page_title', count: count) + end + @dot_icon.push(:questions) if @unanswered.any? end def update_membership diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index c643cffd7b..fc4279b220 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -25,6 +25,7 @@ import { Toast } from "toast"; import { Notification } from "notification"; import { checkTimeZone, checkIframe, initCSRF, initTooltips } from "util.js"; import { initClipboard } from "copy"; +import { FaviconManager } from "favicon"; // Initialize clipboard.js initClipboard(); @@ -42,6 +43,7 @@ $(initTooltips); // Use a global dodona object to prevent polluting the global na const dodona = window.dodona || {}; +dodona.dotManager = new FaviconManager(dodona.dotCount || []); dodona.checkTimeZone = checkTimeZone; dodona.Toast = Toast; dodona.Notification = Notification; diff --git a/app/javascript/packs/course.js b/app/javascript/packs/course.js index 4859dda69a..6dfd8d7246 100644 --- a/app/javascript/packs/course.js +++ b/app/javascript/packs/course.js @@ -10,6 +10,7 @@ import { import { QuestionTable } from "question_table.ts"; +import { setDocumentTitle } from "util.js"; window.dodona.initSeriesReorder = initSeriesReorder; window.dodona.initCourseForm = initCourseForm; @@ -19,3 +20,4 @@ window.dodona.initCourseMembers = initCourseMembers; window.dodona.loadUsers = loadUsers; window.dodona.questionTable = QuestionTable; +window.dodona.setDocumentTitle = setDocumentTitle; diff --git a/app/views/courses/questions.js.erb b/app/views/courses/questions.js.erb index f788fddf56..f3eceac5eb 100644 --- a/app/views/courses/questions.js.erb +++ b/app/views/courses/questions.js.erb @@ -6,3 +6,9 @@ document.querySelector(".question-table-answered").innerHTML = "<%= escape_javas <% else %> document.getElementById("questions-in-progress").classList.remove("hidden"); <% end %> +dodona.setDocumentTitle("<%= @title %> - Dodona"); +<% if @unanswered.any? %> + dodona.dotManager.requestDot("questions"); +<% else %> + dodona.dotManager.releaseDot("questions"); +<% end %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index b71900d44a..43025b5ae0 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -17,8 +17,8 @@ - "> - "> + "> + "> <%= "#{@title} - " if @title %>Dodona @@ -29,6 +29,7 @@ <% end %> diff --git a/app/views/notifications/_notifications_table.html.erb b/app/views/notifications/_notifications_table.html.erb index 74fcd8a88c..1ea4c5cdbc 100644 --- a/app/views/notifications/_notifications_table.html.erb +++ b/app/views/notifications/_notifications_table.html.erb @@ -32,7 +32,7 @@ diff --git a/app/views/notifications/_small_notifications_table.html.erb b/app/views/notifications/_small_notifications_table.html.erb index bac6dcbbdc..32aa6835c7 100644 --- a/app/views/notifications/_small_notifications_table.html.erb +++ b/app/views/notifications/_small_notifications_table.html.erb @@ -30,7 +30,7 @@ diff --git a/config/locales/views/courses/en.yml b/config/locales/views/courses/en.yml index ce1fa3711c..771ee647d2 100644 --- a/config/locales/views/courses/en.yml +++ b/config/locales/views/courses/en.yml @@ -173,6 +173,7 @@ en: open: "Unanswered questions" in_progress: "Questions in progress" closed: "Answered questions" + page_title: "(%{count}) Questions" title: "Questions" auto_refresh: Automatically refresh questions ago: "%{when} ago" diff --git a/config/locales/views/courses/nl.yml b/config/locales/views/courses/nl.yml index 78bf2017b2..01a0cdaeba 100644 --- a/config/locales/views/courses/nl.yml +++ b/config/locales/views/courses/nl.yml @@ -170,6 +170,7 @@ nl: in_progress: "Vragen in behandeling" closed: "Beantwoorde vragen" title: "Vragen" + page_title: "(%{count}) Vragen" auto_refresh: Vragen automatisch vernieuwen ago: "%{when} geleden" last_edited_by: diff --git a/test/controllers/courses_controller_test.rb b/test/controllers/courses_controller_test.rb index ec2dd03023..e85b277a6c 100644 --- a/test/controllers/courses_controller_test.rb +++ b/test/controllers/courses_controller_test.rb @@ -571,4 +571,17 @@ def with_users_signed_in(users) assert :ok, "#{who} should not be able to view questions" end end + + test 'question page title is correct' do + sign_in @admins.first + get questions_course_path(@course) + assert_select 'title', /^([^0-9]*)$/ + + submission = create :submission, course: @course + create :question, question_state: :answered, submission: submission + create :question, question_state: :unanswered, submission: submission + create :question, question_state: :in_progress, submission: submission + get questions_course_path(@course) + assert_select 'title', /\(1\)/ + end end