diff --git a/.eslint_todo.ts b/.eslint_todo.ts index 2def3b075..d5a3c9911 100644 --- a/.eslint_todo.ts +++ b/.eslint_todo.ts @@ -6,40 +6,10 @@ import type {Linter} from "eslint"; const config: Linter.Config[] = [ - // Offense count: 7 - { - files: [ - "app/javascript/application.ts", - ], - rules: { - "@stylistic/comma-dangle": "off", - }, - }, - // Offense count: 5 - { - files: [ - "app/javascript/application.ts", - ], - rules: { - "@stylistic/key-spacing": "off", - }, - }, - // Offense count: 2 - { - files: [ - "app/javascript/application.ts", - ], - rules: { - "@stylistic/keyword-spacing": "off", - }, - }, // Offense count: 15 { files: [ - "app/javascript/application.ts", "spec/javascript/setup.ts", - "spec/javascript/spec/models/story_spec.ts", - "spec/javascript/spec/views/story_view_spec.ts", "vitest.config.ts", ], rules: { @@ -55,42 +25,27 @@ const config: Linter.Config[] = [ "@stylistic/multiline-comment-style": "off", }, }, - // Offense count: 8 - { - files: [ - "app/javascript/application.ts", - "spec/javascript/spec/views/story_view_spec.ts", - ], - rules: { - "@stylistic/multiline-ternary": "off", - }, - }, - // Offense count: 3 + // Offense count: 30 { files: [ - "app/javascript/application.ts", - "spec/javascript/spec/models/story_spec.ts", + "spec/javascript/setup.ts", ], rules: { - "@stylistic/no-extra-parens": "off", + "@stylistic/object-curly-spacing": "off", }, }, - // Offense count: 30 + // Offense count: 1 { files: [ "app/javascript/application.ts", - "spec/javascript/setup.ts", - "spec/javascript/spec/models/story_spec.ts", - "spec/javascript/spec/views/story_view_spec.ts", ], rules: { - "@stylistic/object-curly-spacing": "off", + "import/no-unresolved": "off", }, }, // Offense count: 3 { files: [ - "app/javascript/application.ts", "stylelint.config.mjs", ], rules: { @@ -100,47 +55,16 @@ const config: Linter.Config[] = [ // Offense count: 41 { files: [ - "app/javascript/application.ts", "spec/javascript/setup.ts", ], rules: { "@stylistic/quotes": "off", }, }, - // Offense count: 1 - { - files: [ - "app/javascript/application.ts", - ], - rules: { - "@stylistic/semi": "off", - }, - }, - // Offense count: 1 - { - files: [ - "app/javascript/application.ts", - ], - rules: { - "@stylistic/space-before-blocks": "off", - }, - }, - // Offense count: 50 - { - files: [ - "app/javascript/application.ts", - ], - rules: { - "@stylistic/space-before-function-paren": "off", - }, - }, // Offense count: 4 { files: [ - "app/javascript/application.ts", "spec/javascript/setup.ts", - "spec/javascript/spec/models/story_spec.ts", - "spec/javascript/spec/views/story_view_spec.ts", ], rules: { "@typescript-eslint/ban-ts-comment": "off", @@ -149,60 +73,17 @@ const config: Linter.Config[] = [ // Offense count: 7 { files: [ - "app/javascript/application.ts", "spec/javascript/setup.ts", - "spec/javascript/spec/views/story_view_spec.ts", ], rules: { "@typescript-eslint/explicit-function-return-type": "off", }, }, - // Offense count: 3 - { - files: [ - "spec/javascript/spec/views/story_view_spec.ts", - ], - rules: { - "@typescript-eslint/init-declarations": "off", - }, - }, - // Offense count: 2 - { - files: [ - "app/javascript/application.ts", - ], - rules: { - "@typescript-eslint/no-deprecated": "off", - }, - }, - // Offense count: 5 - { - files: [ - "spec/javascript/spec/models/story_spec.ts", - "spec/javascript/spec/views/story_view_spec.ts", - ], - rules: { - "@typescript-eslint/no-empty-function": "off", - }, - }, - // Offense count: 9 - { - files: [ - "app/javascript/application.ts", - "spec/javascript/spec/views/story_view_spec.ts", - ], - rules: { - "@typescript-eslint/no-unsafe-argument": "off", - }, - }, // Offense count: 45 { files: [ - "app/javascript/application.ts", "eslint.config.mts", "spec/javascript/setup.ts", - "spec/javascript/spec/models/story_spec.ts", - "spec/javascript/spec/views/story_view_spec.ts", ], rules: { "@typescript-eslint/no-unsafe-assignment": "off", @@ -211,83 +92,12 @@ const config: Linter.Config[] = [ // Offense count: 181 { files: [ - "app/javascript/application.ts", "spec/javascript/setup.ts", - "spec/javascript/spec/models/story_spec.ts", - "spec/javascript/spec/views/story_view_spec.ts", ], rules: { "@typescript-eslint/no-unsafe-call": "off", }, }, - // Offense count: 266 - { - files: [ - "app/javascript/application.ts", - "spec/javascript/spec/models/story_spec.ts", - "spec/javascript/spec/views/story_view_spec.ts", - ], - rules: { - "@typescript-eslint/no-unsafe-member-access": "off", - }, - }, - // Offense count: 6 - { - files: [ - "app/javascript/application.ts", - ], - rules: { - "@typescript-eslint/no-unsafe-return": "off", - }, - }, - // Offense count: 1 - { - files: [ - "app/javascript/application.ts", - ], - rules: { - "@typescript-eslint/prefer-destructuring": "off", - }, - }, - // Offense count: 1 - { - files: [ - "app/javascript/application.ts", - ], - rules: { - "@typescript-eslint/prefer-nullish-coalescing": "off", - }, - }, - // Offense count: 1 - { - files: [ - "app/javascript/application.ts", - ], - rules: { - "@typescript-eslint/prefer-optional-chain": "off", - }, - }, - // Offense count: 24 - { - files: [ - "app/javascript/application.ts", - "spec/javascript/spec/models/story_spec.ts", - ], - rules: { - "@typescript-eslint/strict-boolean-expressions": "off", - }, - }, - // Offense count: 9 - { - files: [ - "app/javascript/application.ts", - "spec/javascript/spec/models/story_spec.ts", - "spec/javascript/spec/views/story_view_spec.ts", - ], - rules: { - "camelcase": "off", - }, - }, // Offense count: 1 { files: [ @@ -297,223 +107,55 @@ const config: Linter.Config[] = [ "capitalized-comments": "off", }, }, - // Offense count: 13 - { - files: [ - "app/javascript/application.ts", - ], - rules: { - "curly": "off", - }, - }, - // Offense count: 2 - { - files: [ - "app/javascript/application.ts", - ], - rules: { - "eqeqeq": "off", - }, - }, // Offense count: 111 { files: [ - "app/javascript/application.ts", "spec/javascript/setup.ts", - "spec/javascript/spec/models/story_spec.ts", - "spec/javascript/spec/views/story_view_spec.ts", ], rules: { "func-names": "off", }, }, - // Offense count: 3 - { - files: [ - "spec/javascript/spec/views/story_view_spec.ts", - ], - rules: { - "func-style": "off", - }, - }, // Offense count: 15 { files: [ - "app/javascript/application.ts", "spec/javascript/setup.ts", - "spec/javascript/spec/views/story_view_spec.ts", ], rules: { "id-length": "off", }, }, - // Offense count: 1 - { - files: [ - "app/javascript/application.ts", - ], - rules: { - "import/no-unresolved": "off", - }, - }, // Offense count: 12 { files: [ - "app/javascript/application.ts", "spec/javascript/setup.ts", - "spec/javascript/spec/views/story_view_spec.ts", "vitest.config.ts", ], rules: { "max-len": "off", }, }, - // Offense count: 1 - { - files: [ - "app/javascript/application.ts", - ], - rules: { - "max-lines": "off", - }, - }, - // Offense count: 3 - { - files: [ - "spec/javascript/spec/models/story_spec.ts", - "spec/javascript/spec/views/story_view_spec.ts", - ], - rules: { - "max-lines-per-function": "off", - }, - }, - // Offense count: 1 - { - files: [ - "spec/javascript/spec/views/story_view_spec.ts", - ], - rules: { - "max-statements": "off", - }, - }, // Offense count: 4 { files: [ - "app/javascript/application.ts", "spec/javascript/setup.ts", - "spec/javascript/spec/views/story_view_spec.ts", ], rules: { "new-cap": "off", }, }, - // Offense count: 2 - { - files: [ - "spec/javascript/spec/models/story_spec.ts", - ], - rules: { - "no-implicit-coercion": "off", - }, - }, - // Offense count: 2 - { - files: [ - "spec/javascript/spec/models/story_spec.ts", - ], - rules: { - "no-multi-assign": "off", - }, - }, - // Offense count: 1 - { - files: [ - "spec/javascript/spec/views/story_view_spec.ts", - ], - rules: { - "no-negated-condition": "off", - }, - }, - // Offense count: 1 - { - files: [ - "spec/javascript/spec/views/story_view_spec.ts", - ], - rules: { - "no-param-reassign": "off", - }, - }, - // Offense count: 2 - { - files: [ - "app/javascript/application.ts", - ], - rules: { - "no-plusplus": "off", - }, - }, - // Offense count: 4 - { - files: [ - "app/javascript/application.ts", - "spec/javascript/spec/views/story_view_spec.ts", - ], - rules: { - "no-ternary": "off", - }, - }, - // Offense count: 47 - { - files: [ - "app/javascript/application.ts", - ], - rules: { - "object-shorthand": "off", - }, - }, - // Offense count: 1 - { - files: [ - "spec/javascript/spec/views/story_view_spec.ts", - ], - rules: { - "one-var": "off", - }, - }, - // Offense count: 59 - { - files: [ - "app/javascript/application.ts", - "spec/javascript/spec/models/story_spec.ts", - "spec/javascript/spec/views/story_view_spec.ts", - ], - rules: { - "prefer-arrow-callback": "off", - }, - }, // Offense count: 4 { files: [ - "app/javascript/application.ts", "spec/javascript/setup.ts", ], rules: { "prefer-named-capture-group": "off", }, }, - // Offense count: 1 - { - files: [ - "app/javascript/application.ts", - ], - rules: { - "prefer-template": "off", - }, - }, // Offense count: 4 { files: [ - "app/javascript/application.ts", "spec/javascript/setup.ts", ], rules: { @@ -524,8 +166,6 @@ const config: Linter.Config[] = [ { files: [ "spec/javascript/setup.ts", - "spec/javascript/spec/models/story_spec.ts", - "spec/javascript/spec/views/story_view_spec.ts", ], rules: { "sort-imports": "off", @@ -534,10 +174,7 @@ const config: Linter.Config[] = [ // Offense count: 26 { files: [ - "app/javascript/application.ts", "spec/javascript/setup.ts", - "spec/javascript/spec/models/story_spec.ts", - "spec/javascript/spec/views/story_view_spec.ts", ], rules: { "sort-keys": "off", @@ -546,49 +183,16 @@ const config: Linter.Config[] = [ // Offense count: 26 { files: [ - "app/javascript/application.ts", "spec/javascript/setup.ts", - "spec/javascript/spec/models/story_spec.ts", - "spec/javascript/spec/views/story_view_spec.ts", ], rules: { "sort-keys-fix/sort-keys-fix": "off", }, }, - // Offense count: 8 - { - files: [ - "app/javascript/application.ts", - "spec/javascript/spec/views/story_view_spec.ts", - ], - rules: { - "vars-on-top": "off", - }, - }, - // Offense count: 3 - { - files: [ - "spec/javascript/spec/views/story_view_spec.ts", - ], - rules: { - "vitest/no-hooks": "off", - }, - }, - // Offense count: 4 - { - files: [ - "spec/javascript/spec/models/story_spec.ts", - "spec/javascript/spec/views/story_view_spec.ts", - ], - rules: { - "vitest/prefer-lowercase-title": "off", - }, - }, // Offense count: 4 { files: [ "spec/javascript/setup.ts", - "spec/javascript/spec/views/story_view_spec.ts", ], rules: { "vitest/require-hook": "off", diff --git a/app/javascript/application.ts b/app/javascript/application.ts index 3ad657eee..dc666e6ab 100644 --- a/app/javascript/application.ts +++ b/app/javascript/application.ts @@ -1,329 +1,21 @@ -// @ts-nocheck import "@hotwired/turbo-rails"; import "@rails/activestorage"; import "jquery"; +// @ts-expect-error — Bootstrap 3 has no type declarations import "bootstrap"; -import "mousetrap"; -import "jquery-visible"; -import _ from "underscore"; -import Backbone from "backbone"; import "./controllers/index"; -Turbo.session.drive = false; - -/* global jQuery, Mousetrap */ -var $ = jQuery; - -window.$ = $; - -Backbone.$ = $; - -_.templateSettings = { - interpolate: /\{\{=(.+?)\}\}/g, - evaluate: /\{\{(.+?)\}\}/g -}; - -function CSRFToken() { - const tokenTag = document.getElementsByName('csrf-token')[0]; - - return (tokenTag && tokenTag.content) || ''; -} - -function requestHeaders() { - return { 'X-CSRF-Token': CSRFToken() }; -} - -var Story = Backbone.Model.extend({ - defaults: function() { - return { - "open" : false, - "selected" : false - } - }, - - toggle: function() { - if (this.get("open")) { - this.close(); - } else { - this.open(); - } - }, - - shouldSave: function() { - return this.changedAttributes() && this.get("id") > 0; - }, - - open: function() { - if (!this.get("keep_unread")) this.set("is_read", true); - if (this.shouldSave()) this.save(null, { headers: requestHeaders() }); - - if(this.collection){ - this.collection.closeOthers(this); - this.collection.unselectAll(); - this.collection.setSelection(this); - } - - this.set("open", true); - this.set("selected", true); - }, - - toggleKeepUnread: function() { - if (this.get("keep_unread")) { - this.set("keep_unread", false); - this.set("is_read", true); - } else { - this.set("keep_unread", true); - this.set("is_read", false); - } - - if (this.shouldSave()) this.save(null, { headers: requestHeaders() }); - }, - - close: function() { - this.set("open", false); - }, - - select: function() { - if(this.collection) this.collection.unselectAll(); - this.set("selected", true); - }, - - unselect: function() { - this.set("selected", false); - }, - - openInTab: function() { - window.open(this.get("permalink"), '_blank'); +declare global { + interface JQuery { + modal: (action: string) => JQuery; } -}); - -var StoryView = Backbone.View.extend({ - tagName: "li", - className: "story", - - template: "#story-template", - - events: { - "click .story-preview" : "storyClicked" - }, - - initialize: function() { - this.template = _.template($(this.template).html()); - this.listenTo(this.model, 'add', this.render); - this.listenTo(this.model, 'change:selected', this.itemSelected); - this.listenTo(this.model, 'change:open', this.itemOpened); - this.listenTo(this.model, 'change:is_read', this.itemRead); - this.el.addEventListener('keep-unread-toggle:toggled', (e) => { - var detail = e.detail; - this.model.set({keep_unread: detail.keepUnread, is_read: detail.isRead}, {silent: true}); - this.model.trigger('change:is_read'); - }); - }, - - render: function() { - var jsonModel = this.model.toJSON(); - this.$el.html(this.template(jsonModel)); - if (jsonModel.is_read) { - this.$el.addClass('read'); - } - if (jsonModel.keep_unread) { - this.$el.addClass('keepUnread'); - } - Object.assign(this.el.dataset, { - controller: "star-toggle keep-unread-toggle", - keepUnreadToggleIdValue: String(jsonModel.id), - keepUnreadToggleIsReadValue: String(jsonModel.is_read), - keepUnreadToggleKeepUnreadValue: String(jsonModel.keep_unread), - starToggleIdValue: String(jsonModel.id), - starToggleStarredValue: String(jsonModel.is_starred), - }); - return this; - }, - - itemRead: function() { - this.$el.toggleClass("read", this.model.get("is_read")); - }, - - itemOpened: function() { - if (this.model.get("open")) { - this.$el.addClass("open"); - $(".story-lead", this.$el).fadeOut(1000); - window.scrollTo(0, this.$el.offset().top); - } else { - this.$el.removeClass("open"); - $(".story-lead", this.$el).show(); - } - }, - - itemSelected: function() { - this.$el.toggleClass("cursor", this.model.get("selected")); - if (!this.$el.visible()) window.scrollTo(0, this.$el.offset().top); - }, - - storyClicked: function(e) { - if (e.metaKey || e.ctrlKey || e.which == 2) { - var backgroundTab = window.open(this.model.get("permalink")); - if (backgroundTab) backgroundTab.blur(); - window.focus(); - if (!this.model.get("keep_unread")) this.model.set("is_read", true); - if (this.model.shouldSave()) this.model.save(null, { headers: requestHeaders() }); - } else { - this.model.toggle(); - window.scrollTo(0, this.$el.offset().top); - } - }, - -}); - -var StoryList = Backbone.Collection.extend({ - model: Story, - url: "/stories", - - initialize: function() { - this.cursorPosition = -1; - }, - - max_position: function() { - return this.length - 1; - }, - - unreadCount: function() { - return this.where({is_read: false}).length; - }, - - closeOthers: function(modelToSkip) { - this.each(function(model) { - if (model.id != modelToSkip.id) { - model.close(); - } - }); - }, - - selected: function() { - return this.where({selected: true}); - }, - - unselectAll: function() { - _.invoke(this.selected(), "unselect"); - }, - - selectedStoryId: function() { - var selectedStory = this.at(this.cursorPosition); - return selectedStory ? selectedStory.id : -1; - }, - - setSelection: function(model) { - this.cursorPosition = this.indexOf(model); - }, - - moveCursorDown: function() { - if (this.cursorPosition < this.max_position()) { - this.cursorPosition++; - } else { - this.cursorPosition = 0; - } - - this.at(this.cursorPosition).select(); - }, - - moveCursorUp: function() { - if (this.cursorPosition > 0) { - this.cursorPosition--; - } else { - this.cursorPosition = this.max_position(); - } - - this.at(this.cursorPosition).select(); - }, - - openCurrentSelection: function() { - this.at(this.cursorPosition).open(); - }, - - toggleCurrent: function() { - if (this.cursorPosition < 0) this.cursorPosition = 0; - this.at(this.cursorPosition).toggle(); - }, - - viewCurrentInTab: function() { - if (this.cursorPosition < 0) this.cursorPosition = 0; - this.at(this.cursorPosition).openInTab(); - }, - - toggleCurrentKeepUnread: function() { - if (this.cursorPosition < 0) this.cursorPosition = 0; - this.at(this.cursorPosition).toggleKeepUnread(); - } -}); - -var AppView = Backbone.View.extend({ - el: "#stories", - - initialize: function(collection) { - this.stories = collection; - this.el = $(this.el); - - this.listenTo(this.stories, 'add', this.addOne); - this.listenTo(this.stories, 'reset', this.addAll); - this.listenTo(this.stories, 'all', this.render); - }, - - loadData: function(data) { - this.stories.reset(data); - }, - - render: function() { - var unreadCount = this.stories.unreadCount(); - - if (unreadCount === 0) { - document.title = window.i18n.titleName; - } else { - document.title = "(" + unreadCount + ") " + window.i18n.titleName; - } - }, - - addOne: function(story) { - var view = new StoryView({model: story}); - this.$("#story-list").append(view.render().el); - }, - - addAll: function() { - this.stories.each(this.addOne, this); - }, - - moveCursorDown: function() { - this.stories.moveCursorDown(); - }, - - moveCursorUp: function() { - this.stories.moveCursorUp(); - }, - - openCurrentSelection: function() { - this.stories.openCurrentSelection(); - }, - - toggleCurrent: function() { - this.stories.toggleCurrent(); - }, +} - viewCurrentInTab: function() { - this.stories.viewCurrentInTab(); - }, +Turbo.session.drive = false; - toggleCurrentKeepUnread: function() { - this.stories.toggleCurrentKeepUnread(); +document.addEventListener("keydown", (event: KeyboardEvent) => { + if (event.key === "?") { + jQuery("#shortcuts").modal("toggle"); } }); - -$(document).ready(function() { - Mousetrap.bind("?", function() { - $("#shortcuts").modal('toggle'); - }); -}); - -window.StoryList = StoryList; -window.AppView = AppView; - -export { Story, StoryView, StoryList, AppView }; diff --git a/app/javascript/controllers/index.ts b/app/javascript/controllers/index.ts index 806d7496a..dd03c261f 100644 --- a/app/javascript/controllers/index.ts +++ b/app/javascript/controllers/index.ts @@ -20,3 +20,9 @@ application.register("mark-all-as-read", MarkAllAsReadController); import StarToggleController from "./star_toggle_controller"; application.register("star-toggle", StarToggleController); + +import StoryController from "./story_controller"; +application.register("story", StoryController); + +import StoryListController from "./story_list_controller"; +application.register("story-list", StoryListController); diff --git a/app/javascript/controllers/story_controller.ts b/app/javascript/controllers/story_controller.ts new file mode 100644 index 000000000..2e5473f2b --- /dev/null +++ b/app/javascript/controllers/story_controller.ts @@ -0,0 +1,109 @@ +import {Controller} from "@hotwired/stimulus"; + +import {updateStory} from "helpers/api"; + +function closeStory(story: Element): void { + story.classList.remove("open"); + const sel = "[data-story-target='lead']"; + const lead = story.querySelector(sel); + if (lead) { lead.style.display = ""; } +} + +interface ToggleDetail { + isRead: boolean; + keepUnread: boolean; +} + +export default class extends Controller { + static override values = { + id: String, + isRead: Boolean, + keepUnread: Boolean, + permalink: String, + }; + + static override targets = ["body", "lead"]; + + declare idValue: string; + + declare isReadValue: boolean; + + declare keepUnreadValue: boolean; + + declare permalinkValue: string; + + bodyTarget!: HTMLElement; + + leadTarget!: HTMLElement; + + toggle(event: MouseEvent): void { + if (event.metaKey || event.ctrlKey || event.button === 1) { + this.openInBackgroundTab(); + return; + } + + if (this.element.classList.contains("open")) { + this.close(); + } else { + this.open(); + } + } + + open(): void { + this.closeOthers(); + this.element.classList.add("open"); + this.leadTarget.style.display = "none"; + this.element.scrollIntoView({block: "start"}); + + if (!this.keepUnreadValue) { + this.markAsRead(); + } + } + + close(): void { + this.element.classList.remove("open"); + this.leadTarget.style.display = ""; + } + + keepUnreadToggleToggled(event: CustomEvent): void { + this.keepUnreadValue = event.detail.keepUnread; + this.isReadValue = event.detail.isRead; + } + + private openInBackgroundTab(): void { + const tab = window.open(this.permalinkValue); + // eslint-disable-next-line @typescript-eslint/no-deprecated + if (tab) { tab.blur(); } + window.focus(); + + if (!this.keepUnreadValue) { + this.markAsRead(); + } + } + + private markAsRead(): void { + if (this.isReadValue) { return; } + this.isReadValue = true; + this.element.classList.add("read"); + + /* eslint-disable camelcase */ + updateStory(this.idValue, {is_read: true}).catch(() => { + // Optimistic UI — ignore server errors + }); + /* eslint-enable camelcase */ + + this.dispatch("read", {bubbles: true}); + } + + private closeOthers(): void { + const selector = "[data-controller~='story-list']"; + const list = this.element.closest(selector); + if (!list) { return; } + + for (const story of list.querySelectorAll("li.story.open")) { + if (story !== this.element) { + closeStory(story); + } + } + } +} diff --git a/app/javascript/controllers/story_list_controller.ts b/app/javascript/controllers/story_list_controller.ts new file mode 100644 index 000000000..06df5eecf --- /dev/null +++ b/app/javascript/controllers/story_list_controller.ts @@ -0,0 +1,212 @@ +import {Controller} from "@hotwired/stimulus"; + +declare global { + interface Window { + i18n?: {titleName?: string}; + } +} + +function isVisible(element: HTMLElement): boolean { + const rect = element.getBoundingClientRect(); + + return rect.top >= 0 && + rect.bottom <= window.innerHeight; +} + +function isInputTarget(event: KeyboardEvent): boolean { + const {target} = event; + if (!(target instanceof HTMLElement)) { return false; } + + const tagName = target.tagName.toLowerCase(); + + return tagName === "input" || + tagName === "textarea" || + tagName === "select" || + target.isContentEditable; +} + +export default class extends Controller { + static override targets = ["story"]; + + declare storyTargets: HTMLElement[]; + + cursorPosition = -1; + + private baseTitleName = ""; + + private get maxPosition(): number { + return this.storyTargets.length - 1; + } + + override connect(): void { + this.baseTitleName = window.i18n?.titleName ?? ""; + } + + storyTargetConnected(): void { + this.updateTitle(); + } + + storyTargetDisconnected(): void { + this.updateTitle(); + } + + handleKeydown(event: KeyboardEvent): void { + if (isInputTarget(event)) { return; } + if (!this.handleKey(event.key.toLowerCase())) { return; } + + event.preventDefault(); + } + + storyRead(): void { + this.updateTitle(); + } + + private handleKey(key: string): boolean { + switch (key) { + case "j": + this.moveCursorDown(); + this.openCurrent(); + return true; + case "k": + this.moveCursorUp(); + this.openCurrent(); + return true; + case "n": + this.moveCursorDown(); + return true; + case "p": + this.moveCursorUp(); + return true; + case "o": + case "enter": + this.toggleCurrent(); + return true; + case "b": + case "v": + this.viewInTab(); + return true; + case "m": + this.clickCurrentAction(".story-keep-unread"); + return true; + case "s": + this.clickCurrentAction(".story-starred"); + return true; + default: + return false; + } + } + + private moveCursorDown(): void { + if (this.storyTargets.length === 0) { return; } + + this.clearCursor(); + + if (this.cursorPosition < this.maxPosition) { + this.cursorPosition += 1; + } else { + this.cursorPosition = 0; + } + + this.applyCursor(); + } + + private moveCursorUp(): void { + if (this.storyTargets.length === 0) { return; } + + this.clearCursor(); + + if (this.cursorPosition > 0) { + this.cursorPosition -= 1; + } else { + this.cursorPosition = this.maxPosition; + } + + this.applyCursor(); + } + + private openCurrent(): void { + const story = this.currentStory(); + if (!story) { return; } + + const preview = + story.querySelector(".story-preview"); + preview?.click(); + } + + private toggleCurrent(): void { + if (this.cursorPosition < 0) { + this.cursorPosition = 0; + } + + const story = this.currentStory(); + if (!story) { return; } + + this.clearCursor(); + this.applyCursor(); + + const preview = + story.querySelector(".story-preview"); + preview?.click(); + } + + private viewInTab(): void { + if (this.cursorPosition < 0) { + this.cursorPosition = 0; + } + + const story = this.currentStory(); + if (!story) { return; } + + const permalink = + story.dataset.storyPermalinkValue ?? ""; + if (permalink !== "") { + window.open(permalink, "_blank"); + } + } + + private clickCurrentAction(selector: string): void { + if (this.cursorPosition < 0) { + this.cursorPosition = 0; + } + + const story = this.currentStory(); + if (!story) { return; } + + story.querySelector(selector)?.click(); + } + + private currentStory(): HTMLElement | undefined { + return this.storyTargets[this.cursorPosition]; + } + + private clearCursor(): void { + for (const story of this.storyTargets) { + story.classList.remove("cursor"); + } + } + + private applyCursor(): void { + const story = this.currentStory(); + if (!story) { return; } + + story.classList.add("cursor"); + + if (!isVisible(story)) { + story.scrollIntoView({block: "start"}); + } + } + + private updateTitle(): void { + const unread = this.storyTargets.filter((story) => { + return !story.classList.contains("read"); + }); + const unreadCount = unread.length; + + if (unreadCount === 0) { + document.title = this.baseTitleName; + } else { + document.title = + `(${unreadCount}) ${this.baseTitleName}`; + } + } +} diff --git a/app/views/stories/_js.html.erb b/app/views/stories/_js.html.erb deleted file mode 100644 index 093e42e0e..000000000 --- a/app/views/stories/_js.html.erb +++ /dev/null @@ -1,43 +0,0 @@ -<%= render 'stories/templates' %> - - diff --git a/app/views/stories/_story.html.erb b/app/views/stories/_story.html.erb new file mode 100644 index 000000000..c92097fe2 --- /dev/null +++ b/app/views/stories/_story.html.erb @@ -0,0 +1,66 @@ +
  • +
    +
    +
    + +
    +

    + <%= story.source %> +

    +
    +
    +

    + + <%= story.headline %> + + + — <%= story.lead %> + +

    +
    +
    + +
    +
    +

    + <%= story.title %> + <% if story.enclosure_url.present? %> + + + + <% end %> +

    + <%= sanitize story.body %> +
    +
    +
    + +
    +
    +
    + <%= t('stories.keep_unread') %> +
    +
    + +
    + + + +
    +
    +
    +
  • diff --git a/app/views/stories/_templates.html.erb b/app/views/stories/_templates.html.erb deleted file mode 100644 index c74575fc6..000000000 --- a/app/views/stories/_templates.html.erb +++ /dev/null @@ -1,54 +0,0 @@ - diff --git a/app/views/stories/archived.html.erb b/app/views/stories/archived.html.erb index 3f9e41542..1f1dda490 100644 --- a/app/views/stories/archived.html.erb +++ b/app/views/stories/archived.html.erb @@ -3,10 +3,9 @@ <% unless @read_stories.empty? %> - <%= render "stories/js", { stories: @read_stories } %> - -
    +
      + <%= render partial: "stories/story", collection: @read_stories, as: :story %>
    diff --git a/app/views/stories/index.html.erb b/app/views/stories/index.html.erb index 619793cd9..5e2ffce92 100644 --- a/app/views/stories/index.html.erb +++ b/app/views/stories/index.html.erb @@ -11,10 +11,9 @@ <% if @unread_stories.empty? %> <%= render "stories/zen" %> <% else %> - <%= render "stories/js", { stories: @unread_stories } %> - -
    +
      + <%= render partial: "stories/story", collection: @unread_stories, as: :story %>
    <% end %> diff --git a/app/views/stories/starred.html.erb b/app/views/stories/starred.html.erb index 5744d188f..f86a1aa87 100644 --- a/app/views/stories/starred.html.erb +++ b/app/views/stories/starred.html.erb @@ -3,10 +3,9 @@
    <% unless @starred_stories.empty? %> - <%= render "stories/js", { stories: @starred_stories } %> - -
    +
      + <%= render partial: "stories/story", collection: @starred_stories, as: :story %>
    diff --git a/package.json b/package.json index 7795012e8..de24719bf 100644 --- a/package.json +++ b/package.json @@ -9,25 +9,18 @@ "@hotwired/turbo-rails": "^8.0.20", "@rails/actioncable": "^8.1.100", "@rails/activestorage": "^8.1.100", - "backbone": "1.0.0", "bootstrap": "3.1.1", "font-awesome": "4.7.0", - "jquery": "1.9.1", - "jquery-visible": "1.2.0", - "mousetrap": "1.4.6", - "underscore": "1.4.4" + "jquery": "1.9.1" }, "devDependencies": { "@eslint/eslintrc": "^3.3.3", "@eslint/js": "^10.0.1", "@stylistic/eslint-plugin": "^5.7.0", - "@types/backbone": "latest", "@types/hotwired__turbo": "^8.0.5", "@types/jquery": "latest", - "@types/mousetrap": "latest", "@types/node": "^25.2.3", "@types/rails__actioncable": "^8.0.3", - "@types/underscore": "latest", "@typescript-eslint/eslint-plugin": "^8.50.0", "@typescript-eslint/parser": "^8.50.0", "@vitest/coverage-v8": "4.0.18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca0ad6065..8578f2d5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,9 +29,6 @@ importers: '@rails/activestorage': specifier: ^8.1.100 version: 8.1.200 - backbone: - specifier: 1.0.0 - version: 1.0.0 bootstrap: specifier: 3.1.1 version: 3.1.1 @@ -41,15 +38,6 @@ importers: jquery: specifier: 1.9.1 version: 1.9.1 - jquery-visible: - specifier: 1.2.0 - version: 1.2.0 - mousetrap: - specifier: 1.4.6 - version: 1.4.6 - underscore: - specifier: 1.4.4 - version: 1.4.4 devDependencies: '@eslint/eslintrc': specifier: ^3.3.3 @@ -60,27 +48,18 @@ importers: '@stylistic/eslint-plugin': specifier: ^5.7.0 version: 5.8.0(eslint@9.39.3(jiti@2.6.1)) - '@types/backbone': - specifier: latest - version: 1.4.23 '@types/hotwired__turbo': specifier: ^8.0.5 version: 8.0.6 '@types/jquery': specifier: latest version: 3.5.33 - '@types/mousetrap': - specifier: latest - version: 1.6.15 '@types/node': specifier: ^25.2.3 version: 25.2.3 '@types/rails__actioncable': specifier: ^8.0.3 version: 8.0.3 - '@types/underscore': - specifier: latest - version: 1.13.0 '@typescript-eslint/eslint-plugin': specifier: ^8.50.0 version: 8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) @@ -674,9 +653,6 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} - '@types/backbone@1.4.23': - resolution: {integrity: sha512-B/hN/DAJdWFOusEkEoa5xgfVuxJJPOR/6JQ2uwURPDyKL24PuC76IR6EcSRJ6lvGWtxHRQYMJQhJYm6KpMDtGQ==} - '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -698,9 +674,6 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} - '@types/mousetrap@1.6.15': - resolution: {integrity: sha512-qL0hyIMNPow317QWW/63RvL1x5MVMV+Ru3NaY9f/CuEpCqrmb7WeuK2071ZY5hczOnm38qExWM2i2WtkXLSqFw==} - '@types/node@25.2.3': resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==} @@ -710,9 +683,6 @@ packages: '@types/sizzle@2.3.10': resolution: {integrity: sha512-TC0dmN0K8YcWEAEfiPi5gJP14eJe30TTGjkvek3iM/1NdHHsdCA/Td6GvNndMOo/iSnIsZ4HuuhrYPDAmbxzww==} - '@types/underscore@1.13.0': - resolution: {integrity: sha512-L6LBgy1f0EFQZ+7uSA57+n2g/s4Qs5r06Vwrwn0/nuK1de+adz00NWaztRQ30aEqw5qOaWbPI8u2cGQ52lj6VA==} - '@typescript-eslint/eslint-plugin@8.55.0': resolution: {integrity: sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1019,9 +989,6 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} - backbone@1.0.0: - resolution: {integrity: sha512-8rhC3q3npBssGGBArAY5RF4lvIpciCvDY0lHmFrLqOi0/fj5rm25H0DJrJFXPwRnuIzIhQ6eS0j7URAxV3u9hA==} - balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1767,9 +1734,6 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true - jquery-visible@1.2.0: - resolution: {integrity: sha512-lj6Xqy7GYEwTD1audFYdv7SrBM6z7icPXNvRpS4e15RXtDksjgU7YF7EKrsqF5rCUZA99OqF+d5H8BGdcwMr+w==} - jquery@1.9.1: resolution: {integrity: sha512-gK7jP5cOEUzjyL0dy7MEMfeSFlmt1yNSdZK98CL8W6o0DiNVW5O9hLcD2bdl48mL8q7bEJgd7d9AhhDaN+iDSQ==} deprecated: This version is deprecated. Please upgrade to the latest version or find support at https://www.herodevs.com/support/jquery-nes. @@ -1891,9 +1855,6 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - mousetrap@1.4.6: - resolution: {integrity: sha512-7i4YgGN/m6GMJbn2g4pyrKFUNEjWIICmuiywWDzdph8qASzEGk+VB3vQYrnxdBkuMTP4ZE+K92TKeQHL92wZOQ==} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2383,9 +2344,6 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} - underscore@1.4.4: - resolution: {integrity: sha512-ZqGrAgaqqZM7LGRzNjLnw5elevWb5M8LEoDMadxIW3OWbcv72wMMgKdwOKpd5Fqxe8choLD8HN3iSj3TUh/giQ==} - undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -2967,11 +2925,6 @@ snapshots: tslib: 2.8.1 optional: true - '@types/backbone@1.4.23': - dependencies: - '@types/jquery': 3.5.33 - '@types/underscore': 1.13.0 - '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -2991,8 +2944,6 @@ snapshots: '@types/json5@0.0.29': {} - '@types/mousetrap@1.6.15': {} - '@types/node@25.2.3': dependencies: undici-types: 7.16.0 @@ -3001,8 +2952,6 @@ snapshots: '@types/sizzle@2.3.10': {} - '@types/underscore@1.13.0': {} - '@typescript-eslint/eslint-plugin@8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -3350,10 +3299,6 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 - backbone@1.0.0: - dependencies: - underscore: 1.4.4 - balanced-match@1.0.2: {} balanced-match@3.0.1: {} @@ -4224,8 +4169,6 @@ snapshots: jiti@2.6.1: {} - jquery-visible@1.2.0: {} - jquery@1.9.1: {} js-tokens@10.0.0: {} @@ -4346,8 +4289,6 @@ snapshots: minimist@1.2.8: {} - mousetrap@1.4.6: {} - ms@2.1.3: {} nanoid@3.3.11: {} @@ -4926,8 +4867,6 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 - underscore@1.4.4: {} - undici-types@7.16.0: {} undici@7.22.0: {} diff --git a/spec/javascript/setup.ts b/spec/javascript/setup.ts index ce858c5fc..d9fa25eb1 100644 --- a/spec/javascript/setup.ts +++ b/spec/javascript/setup.ts @@ -2,8 +2,6 @@ /// import "jquery"; -import underscore from "underscore"; -import Backbone from "backbone"; beforeEach(() => { expect.hasAssertions(); @@ -12,89 +10,3 @@ beforeEach(() => { const jquery = window.jQuery; globalThis.$ = jquery; globalThis.jQuery = jquery; -globalThis._ = underscore; -globalThis.Backbone = Backbone; - -Backbone.$ = jquery; - -_.templateSettings = { - interpolate: /\{\{=(.+?)\}\}/g, - evaluate: /\{\{(.+?)\}\}/g, -}; - -globalThis.CSRFToken = function () { - return ""; -}; - -globalThis.requestHeaders = function () { - return { "X-CSRF-Token": CSRFToken() }; -}; - -// Inject the story template into the DOM. -// This is a static test fixture extracted from _templates.html.erb, -// with the ERB t() call replaced by a plain string. -const templateHTML = [ - '", -].join("\n"); - -document.body.insertAdjacentHTML("beforeend", templateHTML); - -import { Story, StoryView, StoryList, AppView } from "../../app/javascript/application"; - -globalThis.Story = Story; -globalThis.StoryView = StoryView; -globalThis.StoryList = StoryList; -globalThis.AppView = AppView; diff --git a/spec/javascript/spec/models/story_spec.ts b/spec/javascript/spec/models/story_spec.ts deleted file mode 100644 index 401ae1ede..000000000 --- a/spec/javascript/spec/models/story_spec.ts +++ /dev/null @@ -1,145 +0,0 @@ -// @ts-nocheck - -describe("Story", function () { - it("should exist", function () { - expect(Story).toBeDefined(); - }); - - it("uses defaults", function () { - var story = new Story(); - expect(story.get("open")).toBe(false); - expect(story.get("selected")).toBe(false); - }); - - describe("open", function () { - it("sets open and selected to true", function () { - var story = new Story(); - story.open(); - expect(story.get("open")).toBe(true); - expect(story.get("selected")).toBe(true); - }); - - it("sets is_read to true if keep_unread isn't true", function () { - var story = new Story(); - expect(!!story.get("keep_unread")).toBe(false); - story.open(); - expect(story.get("is_read")).toBe(true); - }); - - it("doesn't sets is_read to true if keep_unread is true", function () { - var story = new Story(); - story.set("keep_unread", true); - story.open(); - expect(!!story.get("is_read")).toBe(false); - }); - - it("calls closeOthers, unselectAll, setSelection on collection", function () { - var story = new Story(); - var spy = (story.collection = { - closeOthers: vi.fn(), - unselectAll: vi.fn(), - setSelection: vi.fn(), - }); - story.open(); - expect(spy.closeOthers).toHaveBeenCalledWith(story); - expect(spy.unselectAll).toHaveBeenCalledOnce(); - expect(spy.setSelection).toHaveBeenCalledWith(story); - }); - - it("calls save if it should save", function () { - var story = new Story(); - - vi.spyOn(story, "save").mockImplementation(function () {}); - vi.spyOn(story, "shouldSave").mockImplementation(function () { - return true; - }); - - story.open(); - expect(story.shouldSave).toHaveBeenCalledOnce(); - expect(story.save).toHaveBeenCalledOnce(); - }); - }); - - describe("close", function () { - it("sets open to false", function () { - var story = new Story(); - story.set("open", true); - expect(story.get("open")).toBe(true); - story.close(); - expect(story.get("open")).toBe(false); - }); - }); - - describe("select", function () { - it("sets selected to true", function () { - var story = new Story(); - story.select(); - expect(story.get("selected")).toBe(true); - }); - - it("calls unselectAll on collection", function () { - var story = new Story(); - var spy = (story.collection = { - unselectAll: vi.fn(), - }); - story.select(); - expect(spy.unselectAll).toHaveBeenCalledOnce(); - }); - }); - - describe("toggle", function () { - it("calles open/close based on state", function () { - var story = new Story(); - vi.spyOn(story, "open").mockImplementation(function () {}); - vi.spyOn(story, "close").mockImplementation(function () {}); - - story.toggle(); - expect(story.open).toHaveBeenCalledOnce(); - expect(story.close).not.toHaveBeenCalled(); - - story.set("open", true); - story.toggle(); - expect(story.open).toHaveBeenCalledOnce(); - expect(story.close).toHaveBeenCalledOnce(); - }); - }); - - describe("shouldSave", function () { - it("returns false in there are no changed Attributes", function () { - var story = new Story(); - vi.spyOn(story, "changedAttributes").mockImplementation(function () { - return false; - }); - expect(story.shouldSave()).toBe(false); - expect(story.changedAttributes).toHaveBeenCalledOnce(); - }); - - it("returns false if it has changedAttributes but no id", function () { - var story = new Story(); - vi.spyOn(story, "changedAttributes").mockImplementation(function () { - return { is_read: true }; - }); - expect(story.shouldSave()).toBe(false); - expect(story.changedAttributes).toHaveBeenCalledOnce(); - }); - - it("returns true if it has changedAttributes and an id", function () { - var story = new Story({ id: 1 }); - vi.spyOn(story, "changedAttributes").mockImplementation(function () { - return { is_read: true }; - }); - expect(story.shouldSave()).toBe(true); - expect(story.changedAttributes).toHaveBeenCalledOnce(); - }); - }); - - describe("openInTab", function () { - it("opens a new window", function () { - var story = new Story({ permalink: "http://localhost" }); - vi.spyOn(window, "open").mockImplementation(function () {}); - - story.openInTab(); - expect(window.open).toHaveBeenCalledWith("http://localhost", "_blank"); - }); - }); -}); diff --git a/spec/javascript/spec/views/story_view_spec.ts b/spec/javascript/spec/views/story_view_spec.ts deleted file mode 100644 index dfbf06cbc..000000000 --- a/spec/javascript/spec/views/story_view_spec.ts +++ /dev/null @@ -1,180 +0,0 @@ -// @ts-nocheck - -describe("StoryView", function () { - it("should exist", function () { - expect(StoryView).toBeDefined(); - }); - - describe("Rendering the view", function () { - let story, view; - - beforeAll(function () { - story = new Story({ - source: "TechKrunch", - enclosure_url: null, - headline: "Every startups acquired by Yahoo!", - lead: "This is the lead.", - title: "Every startups acquired by Yahoo! NOT!!", - body: "All remote workers have been exiled to Ohio.", - pretty_date: "Mon July 1, 2013", - permalink: "http://example.com/krunch", - keep_unread: false, - is_read: false, - is_starred: false, - }); - - view = new StoryView({ model: story }); - view.render(); - }); - - it("should render li.story items", function () { - expect(view.$el.hasClass("story")).toBe(true); - }); - - var assertTagExists = function (el, tagName, count) { - count = typeof count !== "undefined" ? count : 1; - expect(el.find(tagName)).toHaveLength(count); - }; - - var assertNoTagExists = function (el, tagName) { - expect(el.find(tagName)).toHaveLength(0); - }; - - var assertPropertyRendered = function (el, model, propName) { - expect(el.html()).toContain(model.get(propName)); - }; - - it("should render blog title", function () { - assertTagExists(view.$el, ".blog-title"); - assertPropertyRendered(view.$el, story, "source"); - }); - - it("should render story headline", function () { - assertTagExists(view.$el, ".story-title"); - assertPropertyRendered(view.$el, story, "headline"); - }); - - it("should render story lead", function () { - assertTagExists(view.$el, ".story-lead"); - assertPropertyRendered(view.$el, story, "lead"); - }); - - it("should render story full title", function () { - assertTagExists(view.$el, ".story-body"); - assertPropertyRendered(view.$el, story, "title"); - }); - - it("should render story full title as link", function () { - assertTagExists(view.$el, ".story-body h1 a"); - }); - - it("should render story full body", function () { - assertTagExists(view.$el, ".story-body"); - assertPropertyRendered(view.$el, story, "body"); - }); - - it("should render story date", function () { - assertTagExists(view.$el, ".story-published"); - assertPropertyRendered(view.$el, story, "pretty_date"); - }); - - it("should render story permalink", function () { - assertTagExists(view.$el, ".story-permalink"); - assertPropertyRendered(view.$el, story, "permalink"); - }); - - it("should render keep as unread button", function () { - assertTagExists(view.$el, ".story-keep-unread"); - }); - - it("should autofill unread button based on item", function () { - assertTagExists(view.$el, ".story-keep-unread .fa-square-o"); - - story.set("keep_unread", true); - view.render(); - - assertTagExists(view.$el, ".story-keep-unread .fa-check"); - }); - - it("should set keep-unread-toggle Stimulus data attributes", function () { - story.set({ keep_unread: false, is_read: false }); - view.render(); - - expect(view.el.dataset.keepUnreadToggleIdValue).toBeDefined(); - expect(view.el.dataset.keepUnreadToggleKeepUnreadValue).toBe("false"); - expect(view.el.dataset.keepUnreadToggleIsReadValue).toBe("false"); - expect(view.el.dataset.controller).toContain("keep-unread-toggle"); - }); - - it("should wire keep-unread action to Stimulus controller", function () { - view.render(); - var keepUnreadDiv = view.$el.find(".story-keep-unread"); - expect(keepUnreadDiv.attr("data-action")).toContain("keep-unread-toggle#toggle"); - }); - - it("should set keep-unread-toggle target on icon", function () { - view.render(); - var icon = view.$el.find(".story-keep-unread i"); - expect(icon.attr("data-keep-unread-toggle-target")).toBe("icon"); - }); - - it("should render two instances of the star button", function () { - assertTagExists(view.$el, ".story-actions .story-starred"); - assertTagExists(view.$el, ".story-preview .story-starred"); - }); - - it("should autofill star button based on item", function () { - assertTagExists(view.$el, ".story-starred .fa-star-o", 2); - - story.set("is_starred", true); - view.render(); - - assertTagExists(view.$el, ".story-starred .fa-star", 2); - }); - - it("should not render enclosure link when not present", function () { - assertNoTagExists(view.$el, ".story-enclosure"); - }); - - it("should render enclosure link when present", function () { - story.set("enclosure_url", "http://example.com/enclosure"); - view.render(); - - assertTagExists(view.$el, ".story-enclosure"); - assertPropertyRendered(view.$el, story, "enclosure_url"); - }); - - describe("Handling click on story", function () { - let toggleStub; - - beforeEach(function () { - toggleStub = vi.spyOn(story, "toggle").mockImplementation(function () {}); - }); - - afterEach(function () { - toggleStub.mockRestore(); - }); - - it("should open story when clicked on it", function () { - view.$(".story-preview").click(); - expect(toggleStub).toHaveBeenCalledOnce(); - }); - - it("should not open story when clicked on it with metaKey pressed", function () { - var e = jQuery.Event("click"); - e.metaKey = true; - view.$(".story-preview").trigger(e); - - expect(toggleStub).not.toHaveBeenCalled(); - }); - - it("should not open story when clicked on it with ctrlKey pressed", function () { - var e = jQuery.Event("click"); - e.ctrlKey = true; - view.$(".story-preview").trigger(e); - - expect(toggleStub).not.toHaveBeenCalled(); - }); - }); - }); -}); diff --git a/vitest.config.ts b/vitest.config.ts index 95a048986..f286fbbfb 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -17,8 +17,6 @@ export default defineConfig({ {find: /^spec\//u, replacement: `${path.resolve(root, "spec")}/`}, {find: "jquery", replacement: path.resolve(root, "node_modules/jquery/jquery.js")}, {find: "bootstrap", replacement: path.resolve(root, "node_modules/bootstrap/dist/js/bootstrap.js")}, - {find: "mousetrap", replacement: path.resolve(root, "node_modules/mousetrap/mousetrap.js")}, - {find: "jquery-visible", replacement: path.resolve(root, "node_modules/jquery-visible/jquery.visible.min.js")}, { find: /^support\//u, replacement: `${path.resolve(root, "spec/javascript/support")}/`,