From c06033aa1004bcbd792a651eb1d43a63dc114499 Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 11 Nov 2024 13:43:06 -0500 Subject: [PATCH 1/5] Convert quicktag to TypeScript --- assets/js/__tests__/quick-tag.spec.ts | 159 ++++++++++++++++++++++++++ assets/js/quick-tag.js | 117 ------------------- assets/js/quick-tag.ts | 124 ++++++++++++++++++++ 3 files changed, 283 insertions(+), 117 deletions(-) create mode 100644 assets/js/__tests__/quick-tag.spec.ts delete mode 100644 assets/js/quick-tag.js create mode 100644 assets/js/quick-tag.ts diff --git a/assets/js/__tests__/quick-tag.spec.ts b/assets/js/__tests__/quick-tag.spec.ts new file mode 100644 index 000000000..6b5d1ecd6 --- /dev/null +++ b/assets/js/__tests__/quick-tag.spec.ts @@ -0,0 +1,159 @@ +import { $, $$ } from '../utils/dom'; +import { assertNotNull } from '../utils/assert'; +import { setupQuickTag } from '../quick-tag'; +import { fetchMock } from '../../test/fetch-mock.ts'; +import { waitFor } from '@testing-library/dom'; + +const quickTagData = `
+ Tag + + + +
+
+
+
+
+
+
+
+
`; + +describe('Batch tagging', () => { + let tagButton: HTMLAnchorElement; + let abortButton: HTMLAnchorElement; + let submitButton: HTMLAnchorElement; + let toggleAllButton: HTMLAnchorElement; + let mediaBoxes: HTMLDivElement[]; + + beforeEach(() => { + localStorage.clear(); + document.body.innerHTML = quickTagData; + + tagButton = assertNotNull($('.js-quick-tag')); + abortButton = assertNotNull($('.js-quick-tag--abort')); + submitButton = assertNotNull($('.js-quick-tag--submit')); + toggleAllButton = assertNotNull($('.js-quick-tag--all')); + mediaBoxes = $$('.media-box'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should prompt the user on click', () => { + const spy = vi.spyOn(window, 'prompt').mockImplementation(() => 'a'); + tagButton.click(); + + expect(spy).toHaveBeenCalledOnce(); + expect(tagButton.classList).toContain('hidden'); + expect(abortButton.classList).not.toContain('hidden'); + expect(submitButton.classList).not.toContain('hidden'); + expect(toggleAllButton.classList).not.toContain('hidden'); + }); + + it('should not modify media boxes before entry', () => { + mediaBoxes[0].click(); + expect(mediaBoxes[0].firstElementChild).not.toHaveClass('media-box__header--selected'); + }); + + it('should restore the list of tagged images on reload', () => { + // TODO: this is less than ideal, because it depends on the internal + // implementation of the quick-tag file. But we can't reload the page + // with jsdom. + localStorage.setItem('quickTagQueue', JSON.stringify(['0', '1'])); + localStorage.setItem('quickTagName', JSON.stringify('a')); + + setupQuickTag(); + expect(mediaBoxes[0].firstElementChild).toHaveClass('media-box__header--selected'); + expect(mediaBoxes[1].firstElementChild).toHaveClass('media-box__header--selected'); + }); + + describe('after entry', () => { + beforeEach(() => { + vi.spyOn(window, 'prompt').mockImplementation(() => 'a'); + tagButton.click(); + }); + + it('should abort the tagging process if accepted', () => { + const spy = vi.spyOn(window, 'confirm').mockImplementation(() => true); + abortButton.click(); + + expect(spy).toHaveBeenCalledOnce(); + expect(tagButton.classList).not.toContain('hidden'); + expect(abortButton.classList).toContain('hidden'); + expect(submitButton.classList).toContain('hidden'); + expect(toggleAllButton.classList).toContain('hidden'); + }); + + it('should not abort the tagging process if rejected', () => { + const spy = vi.spyOn(window, 'confirm').mockImplementation(() => false); + abortButton.click(); + + expect(spy).toHaveBeenCalledOnce(); + expect(tagButton.classList).toContain('hidden'); + expect(abortButton.classList).not.toContain('hidden'); + expect(submitButton.classList).not.toContain('hidden'); + expect(toggleAllButton.classList).not.toContain('hidden'); + }); + + it('should toggle media box state on click', () => { + mediaBoxes[0].click(); + expect(mediaBoxes[0].firstElementChild).toHaveClass('media-box__header--selected'); + expect(mediaBoxes[1].firstElementChild).not.toHaveClass('media-box__header--selected'); + }); + + it('should toggle all media box states', () => { + mediaBoxes[0].click(); + toggleAllButton.click(); + expect(mediaBoxes[0].firstElementChild).not.toHaveClass('media-box__header--selected'); + expect(mediaBoxes[1].firstElementChild).toHaveClass('media-box__header--selected'); + }); + }); + + describe('for submission', () => { + beforeAll(() => { + fetchMock.enableMocks(); + }); + + afterAll(() => { + fetchMock.disableMocks(); + }); + + beforeEach(() => { + vi.spyOn(window, 'prompt').mockImplementation(() => 'a'); + tagButton.click(); + + fetchMock.resetMocks(); + mediaBoxes[0].click(); + mediaBoxes[1].click(); + }); + + it('should return to normal state on successful submission', () => { + fetchMock.mockResponse('{"failed":[]}'); + submitButton.click(); + + expect(fetch).toHaveBeenCalledOnce(); + + return waitFor(() => { + expect(mediaBoxes[0].firstElementChild).not.toHaveClass('media-box__header--selected'); + expect(mediaBoxes[1].firstElementChild).not.toHaveClass('media-box__header--selected'); + }); + }); + + it('should show error on failed submission', () => { + fetchMock.mockResponse('{"failed":[0,1]}'); + submitButton.click(); + + const spy = vi.spyOn(window, 'alert').mockImplementation(() => {}); + + expect(fetch).toHaveBeenCalledOnce(); + + return waitFor(() => { + expect(spy).toHaveBeenCalledOnce(); + expect(mediaBoxes[0].firstElementChild).not.toHaveClass('media-box__header--selected'); + expect(mediaBoxes[1].firstElementChild).not.toHaveClass('media-box__header--selected'); + }); + }); + }); +}); diff --git a/assets/js/quick-tag.js b/assets/js/quick-tag.js deleted file mode 100644 index 4457784a6..000000000 --- a/assets/js/quick-tag.js +++ /dev/null @@ -1,117 +0,0 @@ -/** - * Quick Tag - */ - -import store from './utils/store'; -import { $, $$, toggleEl, onLeftClick } from './utils/dom'; -import { fetchJson, handleError } from './utils/requests'; - -const imageQueueStorage = 'quickTagQueue'; -const currentTagStorage = 'quickTagName'; - -function currentQueue() { - return store.get(imageQueueStorage) || []; -} - -function currentTags() { - return store.get(currentTagStorage) || ''; -} - -function getTagButton() { - return $('.js-quick-tag'); -} - -function setTagButton(text) { - $('.js-quick-tag--submit span').textContent = text; -} - -function toggleActiveState() { - toggleEl($('.js-quick-tag'), $('.js-quick-tag--abort'), $('.js-quick-tag--all'), $('.js-quick-tag--submit')); - - setTagButton(`Submit (${currentTags()})`); - - $$('.media-box__header').forEach(el => el.classList.toggle('media-box__header--unselected')); - $$('.media-box__header').forEach(el => el.classList.remove('media-box__header--selected')); - currentQueue().forEach(id => - $$(`.media-box__header[data-image-id="${id}"]`).forEach(el => el.classList.add('media-box__header--selected')), - ); -} - -function activate() { - store.set(currentTagStorage, window.prompt('A comma-delimited list of tags you want to add:')); - - if (currentTags()) toggleActiveState(); -} - -function reset() { - store.remove(currentTagStorage); - store.remove(imageQueueStorage); - - toggleActiveState(); -} - -function promptReset() { - if (window.confirm('Are you sure you want to abort batch tagging?')) { - reset(); - } -} - -function submit() { - setTagButton(`Wait... (${currentTags()})`); - - fetchJson('PUT', '/admin/batch/tags', { - tags: currentTags(), - image_ids: currentQueue(), - }) - .then(handleError) - .then(r => r.json()) - .then(data => { - if (data.failed.length) window.alert(`Failed to add tags to the images with these IDs: ${data.failed}`); - - reset(); - }); -} - -function modifyImageQueue(mediaBox) { - if (currentTags()) { - const imageId = mediaBox.dataset.imageId; - const queue = currentQueue(); - const isSelected = queue.includes(imageId); - - isSelected ? queue.splice(queue.indexOf(imageId), 1) : queue.push(imageId); - - $$(`.media-box__header[data-image-id="${imageId}"]`).forEach(el => - el.classList.toggle('media-box__header--selected'), - ); - - store.set(imageQueueStorage, queue); - } -} - -function toggleAllImages() { - $$('#imagelist-container .media-box').forEach(modifyImageQueue); -} - -function clickHandler(event) { - const targets = { - '.js-quick-tag': activate, - '.js-quick-tag--abort': promptReset, - '.js-quick-tag--submit': submit, - '.js-quick-tag--all': toggleAllImages, - '.media-box': modifyImageQueue, - }; - - for (const target in targets) { - if (event.target && event.target.closest(target)) { - targets[target](event.target.closest(target)); - currentTags() && event.preventDefault(); - } - } -} - -function setupQuickTag() { - if (getTagButton() && currentTags()) toggleActiveState(); - if (getTagButton()) onLeftClick(clickHandler); -} - -export { setupQuickTag }; diff --git a/assets/js/quick-tag.ts b/assets/js/quick-tag.ts new file mode 100644 index 000000000..3d462cb24 --- /dev/null +++ b/assets/js/quick-tag.ts @@ -0,0 +1,124 @@ +/** + * Quick Tag + */ + +import store from './utils/store'; +import { assertNotNull, assertNotUndefined } from './utils/assert'; +import { $, $$, toggleEl } from './utils/dom'; +import { fetchJson, handleError } from './utils/requests'; +import { delegate, leftClick } from './utils/events'; + +const imageQueueStorage = 'quickTagQueue'; +const currentTagStorage = 'quickTagName'; + +function currentQueue(): string[] { + return store.get(imageQueueStorage) || []; +} + +function currentTags(): string { + return store.get(currentTagStorage) || ''; +} + +function setTagButton(text: string) { + assertNotNull($('.js-quick-tag--submit span')).textContent = text; +} + +function toggleActiveState() { + toggleEl($$('.js-quick-tag,.js-quick-tag--abort,.js-quick-tag--all,.js-quick-tag--submit')); + + setTagButton(`Submit (${currentTags()})`); + + $$('.media-box__header').forEach(el => el.classList.toggle('media-box__header--unselected')); + $$('.media-box__header').forEach(el => el.classList.remove('media-box__header--selected')); + + currentQueue().forEach(id => + $$(`.media-box__header[data-image-id="${id}"]`).forEach(el => el.classList.add('media-box__header--selected')), + ); +} + +function activate(event: Event) { + event.preventDefault(); + + store.set(currentTagStorage, window.prompt('A comma-delimited list of tags you want to add:')); + + if (currentTags()) { + toggleActiveState(); + } +} + +function reset() { + store.remove(currentTagStorage); + store.remove(imageQueueStorage); + + toggleActiveState(); +} + +function promptReset(event: Event) { + event.preventDefault(); + + if (window.confirm('Are you sure you want to abort batch tagging?')) { + reset(); + } +} + +function submit(event: Event) { + event.preventDefault(); + + setTagButton(`Wait... (${currentTags()})`); + + fetchJson('PUT', '/admin/batch/tags', { + tags: currentTags(), + image_ids: currentQueue(), + }) + .then(handleError) + .then(r => r.json()) + .then(data => { + if (data.failed.length) window.alert(`Failed to add tags to the images with these IDs: ${data.failed}`); + + reset(); + }); +} + +function modifyImageQueue(event: Event, mediaBox: HTMLDivElement) { + if (!currentTags()) { + return; + } + + const imageId = assertNotUndefined(mediaBox.dataset.imageId); + const queue = currentQueue(); + const isSelected = queue.includes(imageId); + + if (isSelected) { + queue.splice(queue.indexOf(imageId), 1); + } else { + queue.push(imageId); + } + + for (const boxHeader of $$(`.media-box__header[data-image-id="${imageId}"]`)) { + boxHeader.classList.toggle('media-box__header--selected'); + } + + store.set(imageQueueStorage, queue); + event.preventDefault(); +} + +function toggleAllImages(event: Event, _target: Element) { + for (const mediaBox of $$('#imagelist-container .media-box')) { + modifyImageQueue(event, mediaBox); + } +} + +delegate(document, 'click', { + '.js-quick-tag': leftClick(activate), + '.js-quick-tag--abort': leftClick(promptReset), + '.js-quick-tag--submit': leftClick(submit), + '.js-quick-tag--all': leftClick(toggleAllImages), + '.media-box': leftClick(modifyImageQueue), +}); + +export function setupQuickTag() { + const tagButton = $('.js-quick-tag'); + if (tagButton && currentTags()) { + toggleActiveState(); + } +} From a7e58f588437a486d697ef62acffdcad29d8e735 Mon Sep 17 00:00:00 2001 From: Liam Date: Sat, 16 Nov 2024 12:30:22 -0500 Subject: [PATCH 2/5] Scope tag input select to tag block --- assets/js/tagsinput.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/js/tagsinput.ts b/assets/js/tagsinput.ts index 377db60d3..745749062 100644 --- a/assets/js/tagsinput.ts +++ b/assets/js/tagsinput.ts @@ -9,7 +9,7 @@ import { TermSuggestion } from './utils/suggestions'; export function setupTagsInput(tagBlock: HTMLDivElement) { const form = assertNotNull(tagBlock.closest('form')); const textarea = assertNotNull($('.js-taginput-plain', tagBlock)); - const container = assertNotNull($('.js-taginput-fancy')); + const container = assertNotNull($('.js-taginput-fancy', tagBlock)); const parentField = assertNotNull(tagBlock.parentElement); const setup = assertNotNull($('.js-tag-block ~ button', parentField)); const inputField = assertNotNull($('input', container)); From 3c4b7179179df1f4cea09ea71056ec96ce6c9604 Mon Sep 17 00:00:00 2001 From: Liam Date: Sat, 16 Nov 2024 16:00:58 -0500 Subject: [PATCH 3/5] Add lexical to ignore list --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index da2c70b6f..0c2215449 100644 --- a/.gitignore +++ b/.gitignore @@ -43,8 +43,9 @@ npm-debug.log # VS Code .vscode -# ElixirLS +# Language server .elixir_ls +.lexical # Index dumps *.jsonl From a60c7d9e794c71fc5b418a250b2d1dc26fadfb5e Mon Sep 17 00:00:00 2001 From: Liam Date: Sun, 17 Nov 2024 16:38:51 -0500 Subject: [PATCH 4/5] Add testcase for subscript-autolink interaction philomena-dev/philomena#315 --- native/philomena/src/tests.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/native/philomena/src/tests.rs b/native/philomena/src/tests.rs index 9f1f963f5..f57eb0f25 100644 --- a/native/philomena/src/tests.rs +++ b/native/philomena/src/tests.rs @@ -48,6 +48,14 @@ fn subscript() { html("H%2%O\n", "
H2O
\n"); } +#[test] +fn subscript_autolink_interaction() { + html( + "https://example.com/search?q=1%2C2%2C3", + "\n" + ); +} + #[test] fn spoiler() { html( From 30a552badf0f3d2512bdd6d820092acda3797cc6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Nov 2024 22:53:36 +0000 Subject: [PATCH 5/5] Bump cross-spawn from 7.0.3 to 7.0.6 in /assets Bumps [cross-spawn](https://github.com/moxystudio/node-cross-spawn) from 7.0.3 to 7.0.6. - [Changelog](https://github.com/moxystudio/node-cross-spawn/blob/master/CHANGELOG.md) - [Commits](https://github.com/moxystudio/node-cross-spawn/compare/v7.0.3...v7.0.6) --- updated-dependencies: - dependency-name: cross-spawn dependency-type: indirect ... Signed-off-by: dependabot[bot] --- assets/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/assets/package-lock.json b/assets/package-lock.json index 300a9f34a..69d8c483c 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -2112,9 +2112,9 @@ } }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0",