Merge branch 'main' into main

This commit is contained in:
Dmytro Sichkar 2024-07-11 17:46:48 +03:00 committed by GitHub
commit f83b5b082c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 368 additions and 33 deletions

View File

@ -11,7 +11,8 @@
"scripts": {
"build": "concurrently \"vite build\" \"tsc -p tsconfig.declaration.json\"",
"prepare": "yarn build",
"test": "yarn test:types",
"test": "yarn test:unit && yarn test:types",
"test:unit": "yarn nx build && vitest run",
"test:types": "tsc --noEmit",
"lint:code": "eslint --ext .js,.ts,.cjs,.tsx src/ --cache",
"lint": "yarn lint:code && yarn lint:test",
@ -36,6 +37,7 @@
"@storybook/react-vite": "7.6.4",
"@storybook/testing-library": "0.2.2",
"@testing-library/react": "14.1.0",
"@testing-library/react-hooks" : "8.0.1",
"@vitejs/plugin-react": "4.2.1",
"c8": "8.0.1",
"eslint-plugin-react-hooks": "4.6.0",
@ -43,6 +45,7 @@
"eslint-plugin-tailwindcss": "3.13.0",
"jsdom": "24.1.0",
"mocha": "10.2.0",
"chai": "4.3.8",
"react": "18.3.1",
"react-dom": "18.3.1",
"rollup-plugin-node-builtins": "2.1.2",

View File

@ -3,6 +3,6 @@ import assert from 'assert/strict';
describe('Hello world', function () {
it('Runs a test', function () {
// TODO: Write me!
assert.ok(require('../'));
assert.equal(1, 1);
});
});

View File

@ -0,0 +1,116 @@
import {expect} from 'chai';
import {renderHook, act} from '@testing-library/react-hooks';
import {usePagination, PaginationMeta, PaginationData} from '../../../src/hooks/usePagination';
describe('usePagination', function () {
const initialMeta: PaginationMeta = {
limit: 10,
pages: 5,
total: 50,
next: null,
prev: null
};
it('should initialize with the given meta and page', function () {
const {result} = renderHook(() => usePagination({
meta: initialMeta,
limit: 10,
page: 1,
setPage: () => {}
})
);
const expectedData: PaginationData = {
page: 1,
pages: initialMeta.pages,
total: initialMeta.total,
limit: initialMeta.limit,
setPage: result.current.setPage,
nextPage: result.current.nextPage,
prevPage: result.current.prevPage
};
expect(result.current).to.deep.equal(expectedData);
});
it('should update page correctly when nextPage and prevPage are called', function () {
let currentPage = 1;
const setPage = (newPage: number) => {
currentPage = newPage;
};
const {result} = renderHook(() => usePagination({
meta: initialMeta,
limit: 10,
page: currentPage,
setPage
})
);
act(() => {
result.current.nextPage();
});
expect(currentPage).to.equal(2);
act(() => {
result.current.prevPage();
});
expect(currentPage).to.equal(1);
});
it('should update page correctly when setPage is called', function () {
let currentPage = 3;
const setPage = (newPage: number) => {
currentPage = newPage;
};
const {result} = renderHook(() => usePagination({
meta: initialMeta,
limit: 10,
page: currentPage,
setPage
})
);
const newPage = 5;
act(() => {
result.current.setPage(newPage);
});
expect(currentPage).to.equal(newPage);
});
it('should handle edge cases where meta.pages < page when setting meta', function () {
let currentPage = 5;
const setPage = (newPage: number) => {
currentPage = newPage;
};
const {rerender} = renderHook(
({meta}) => usePagination({
meta,
limit: 10,
page: currentPage,
setPage
}),
{initialProps: {meta: initialMeta}}
);
const updatedMeta: PaginationMeta = {
limit: 10,
pages: 4,
total: 40,
next: null,
prev: null
};
act(() => {
rerender({meta: updatedMeta});
});
expect(currentPage).to.equal(4);
});
});

View File

@ -0,0 +1,150 @@
import {expect} from 'chai';
import {renderHook, act} from '@testing-library/react-hooks';
import useSortableIndexedList from '../../../src/hooks/useSortableIndexedList';
import sinon from 'sinon';
describe('useSortableIndexedList', function () {
// Mock initial items and blank item
const initialItems = [{name: 'Item 1'}, {name: 'Item 2'}];
const blankItem = {name: ''};
// Mock canAddNewItem function
const canAddNewItem = (item: { name: string }) => !!item.name;
it('should initialize with the given items', function () {
const setItems = sinon.spy();
const {result} = renderHook(() => useSortableIndexedList({
items: initialItems,
setItems,
blank: blankItem,
canAddNewItem
})
);
// Assert initial items setup correctly
expect(result.current.items).to.deep.equal(initialItems.map((item, index) => ({item, id: index.toString()})));
});
it('should add a new item', function () {
let items = initialItems;
const setItems = (newItems: any[]) => {
items = newItems;
};
const {result} = renderHook(() => useSortableIndexedList({
items,
setItems,
blank: blankItem,
canAddNewItem
})
);
act(() => {
result.current.setNewItem({name: 'New Item'});
result.current.addItem();
});
// Assert items updated correctly after adding new item
expect(items).to.deep.equal([...initialItems, {name: 'New Item'}]);
});
it('should update an item', function () {
let items = initialItems;
const setItems = (newItems: any[]) => {
items = newItems;
};
const {result} = renderHook(() => useSortableIndexedList({
items,
setItems,
blank: blankItem,
canAddNewItem
})
);
act(() => {
result.current.updateItem('0', {name: 'Updated Item 1'});
});
// Assert item updated correctly
expect(items[0]).to.deep.equal({name: 'Updated Item 1'});
});
it('should remove an item', function () {
let items = initialItems;
const setItems = (newItems: any[]) => {
items = newItems;
};
const {result} = renderHook(() => useSortableIndexedList({
items,
setItems,
blank: blankItem,
canAddNewItem
})
);
act(() => {
result.current.removeItem('0');
});
// Assert item removed correctly
expect(items).to.deep.equal([initialItems[1]]);
});
it('should move an item', function () {
let items = initialItems;
const setItems = (newItems: any[]) => {
items = newItems;
};
const {result} = renderHook(() => useSortableIndexedList({
items,
setItems,
blank: blankItem,
canAddNewItem
})
);
act(() => {
result.current.moveItem('0', '1');
});
// Assert item moved correctly
expect(items).to.deep.equal([initialItems[1], initialItems[0]]);
});
it('should not setItems for deeply equal items regardless of property order', function () {
const setItems = sinon.spy();
const initialItem = [{name: 'Item 1', url: 'http://example.com'}];
const blankItem1 = {name: '', url: ''};
const {rerender} = renderHook(
// eslint-disable-next-line
({items, setItems}) => useSortableIndexedList({
items,
setItems,
blank: blankItem1,
canAddNewItem
}),
{
initialProps: {
items: initialItem,
setItems
}
}
);
expect(setItems.callCount).to.equal(0);
// Re-render with items in different order but same content
rerender({
items: [{url: 'http://example.com', name: 'Item 1'}],
setItems
});
// Expect no additional calls because the items are deeply equal
expect(setItems.callCount).to.equal(0);
});
});

View File

@ -142,7 +142,7 @@ const Sidebar: React.FC = () => {
unstyled
onChange={updateSearch}
/>
{filter ? <Button className='absolute right-3 top-3 p-1' icon='close' iconColorClass='text-grey-700 !w-[10px] !h-[10px]' size='sm' unstyled onClick={() => {
{filter ? <Button className='absolute top-3 p-1 sm:right-14 tablet:right-3' icon='close' iconColorClass='text-grey-700 !w-[10px] !h-[10px]' size='sm' unstyled onClick={() => {
setFilter('');
searchInputRef.current?.focus();
}} /> : <div className='absolute -right-1/2 top-[9px] hidden rounded border border-grey-400 bg-white px-1.5 py-0.5 text-2xs font-semibold uppercase tracking-wider text-grey-600 shadow-[0px_1px_#CED4D9] dark:border-grey-800 dark:bg-grey-900 dark:text-grey-500 dark:shadow-[0px_1px_#626D79] tablet:!visible tablet:right-3 tablet:!block'>/</div>}

View File

@ -129,8 +129,8 @@ const Sidebar: React.FC<{
NiceModal.show(ConfirmationModal, {
title: 'Archive newsletter',
prompt: <>
<p>Your newsletter <strong>{newsletter.name}</strong> will no longer be visible to members or available as an option when publishing new posts.</p>
<p>Existing posts previously sent as this newsletter will remain unchanged.</p>
<div className="mb-6">Your newsletter <strong>{newsletter.name}</strong> will no longer be visible to members or available as an option when publishing new posts.</div>
<div>Existing posts previously sent as this newsletter will remain unchanged.</div>
</>,
okLabel: 'Archive',
okColor: 'red',

View File

@ -865,6 +865,7 @@
.gh-post-analytics-resource h3 {
font-size: 1.8rem;
font-weight: 700;
text-wrap: pretty;
}
.gh-post-analytics-box h4.gh-main-section-header.small {

View File

@ -1,6 +1,6 @@
const debug = require('@tryghost/debug')('utils:image-size');
const sizeOf = require('image-size');
const probeSizeOf = require('probe-image-size');
const url = require('url');
const path = require('path');
const _ = require('lodash');
@ -17,13 +17,14 @@ const FETCH_ONLY_FORMATS = [
];
class ImageSize {
constructor({config, storage, storageUtils, validator, urlUtils, request}) {
constructor({config, storage, storageUtils, validator, urlUtils, request, probe}) {
this.config = config;
this.storage = storage;
this.storageUtils = storageUtils;
this.validator = validator;
this.urlUtils = urlUtils;
this.request = request;
this.probe = probe;
this.REQUEST_OPTIONS = {
// we need the user-agent, otherwise some https request may fail (e.g. cloudfare)
@ -82,7 +83,18 @@ class ImageSize {
}));
}
return probeSizeOf(imageUrl, this.NEEDLE_OPTIONS);
// wrap probe-image-size in a promise in case it is unresponsive/the timeout itself doesn't work
return (Promise.race([
this.probe(imageUrl, this.NEEDLE_OPTIONS),
new Promise((res, rej) => {
setTimeout(() => {
rej(new errors.InternalServerError({
message: 'Probe unresponsive.',
code: 'IMAGE_SIZE_URL'
}));
}, this.NEEDLE_OPTIONS.response_timeout);
})
]));
}
// download full image then use image-size to get it's dimensions

View File

@ -2,11 +2,12 @@ const BlogIcon = require('./BlogIcon');
const CachedImageSizeFromUrl = require('./CachedImageSizeFromUrl');
const Gravatar = require('./Gravatar');
const ImageSize = require('./ImageSize');
const probe = require('probe-image-size');
class ImageUtils {
constructor({config, urlUtils, settingsCache, storageUtils, storage, validator, request, cacheStore}) {
this.blogIcon = new BlogIcon({config, urlUtils, settingsCache, storageUtils});
this.imageSize = new ImageSize({config, storage, storageUtils, validator, urlUtils, request});
this.imageSize = new ImageSize({config, storage, storageUtils, validator, urlUtils, request, probe});
this.cachedImageSizeFromUrl = new CachedImageSizeFromUrl({
getImageSizeFromUrl: this.imageSize.getImageSizeFromUrl.bind(this.imageSize),
cache: cacheStore

View File

@ -5,6 +5,7 @@ const path = require('path');
const errors = require('@tryghost/errors');
const fs = require('fs');
const ImageSize = require('../../../../../core/server/lib/image/ImageSize');
const probe = require('probe-image-size');
describe('lib/image: image size', function () {
// use a 1x1 gif in nock responses because it's really small and easy to work with
@ -18,7 +19,7 @@ describe('lib/image: image size', function () {
it('[success] should have an image size function', function () {
const imageSize = new ImageSize({config: {
get: () => {}
}, tpl: {}, storage: {}, storageUtils: {}, validator: {}, urlUtils: {}, request: {}});
}, tpl: {}, storage: {}, storageUtils: {}, validator: {}, urlUtils: {}, request: {}, probe});
should.exist(imageSize.getImageSizeFromUrl);
should.exist(imageSize.getImageSizeFromStoragePath);
});
@ -42,7 +43,7 @@ describe('lib/image: image size', function () {
isLocalImage: () => false
}, validator: {
isURL: () => true
}, urlUtils: {}, request: {}});
}, urlUtils: {}, request: {}, probe});
imageSize.getImageSizeFromUrl(url).then(function (res) {
requestMock.isDone().should.be.true();
@ -77,7 +78,7 @@ describe('lib/image: image size', function () {
});
}
return Promise.reject();
}});
}, probe});
imageSize.getImageSizeFromUrl(url).then(function (res) {
requestMock.isDone().should.be.false();
@ -107,7 +108,7 @@ describe('lib/image: image size', function () {
isLocalImage: () => false
}, validator: {
isURL: () => true
}, urlUtils: {}, request: {}});
}, urlUtils: {}, request: {}, probe});
imageSize.getImageSizeFromUrl(url).then(function (res) {
requestMock.isDone().should.be.true();
@ -138,7 +139,7 @@ describe('lib/image: image size', function () {
isLocalImage: () => false
}, validator: {
isURL: () => true
}, urlUtils: {}, request: {}});
}, urlUtils: {}, request: {}, probe});
imageSize.getImageSizeFromUrl(url).then(function (res) {
requestMockNotFound.isDone().should.be.false();
@ -188,7 +189,7 @@ describe('lib/image: image size', function () {
});
}
return Promise.reject();
}});
}, probe});
imageSize.getImageSizeFromUrl(url).then(function (res) {
requestMock.isDone().should.be.true();
@ -218,7 +219,7 @@ describe('lib/image: image size', function () {
isLocalImage: () => false
}, validator: {
isURL: () => true
}, urlUtils: {}, request: {}});
}, urlUtils: {}, request: {}, probe});
imageSize.getImageSizeFromUrl(url).then(function (res) {
requestMock.isDone().should.be.true();
@ -254,7 +255,7 @@ describe('lib/image: image size', function () {
isLocalImage: () => false
}, validator: {
isURL: () => true
}, urlUtils: {}, request: {}});
}, urlUtils: {}, request: {}, probe});
imageSize.getImageSizeFromUrl(url).then(function (res) {
requestMock.isDone().should.be.true();
@ -300,7 +301,7 @@ describe('lib/image: image size', function () {
}, validator: {}, urlUtils: {
urlFor: urlForStub,
getSubdir: urlGetSubdirStub
}, request: {}});
}, request: {}, probe});
imageSize.getImageSizeFromUrl(url).then(function (res) {
requestMock.isDone().should.be.false();
@ -328,7 +329,7 @@ describe('lib/image: image size', function () {
isLocalImage: () => false
}, validator: {
isURL: () => true
}, urlUtils: {}, request: {}});
}, urlUtils: {}, request: {}, probe});
imageSize.getImageSizeFromUrl(url)
.catch(function (err) {
@ -366,7 +367,7 @@ describe('lib/image: image size', function () {
return Promise.reject(new NotFound());
}
return Promise.reject();
}});
}}, probe);
imageSize.getImageSizeFromUrl(url)
.catch(function (err) {
@ -387,7 +388,7 @@ describe('lib/image: image size', function () {
isLocalImage: () => false
}, validator: {
isURL: () => false
}, urlUtils: {}, request: {}});
}, urlUtils: {}, request: {}, probe});
imageSize.getImageSizeFromUrl(url)
.catch(function (err) {
@ -398,7 +399,7 @@ describe('lib/image: image size', function () {
}).catch(done);
});
it('[failure] will timeout', function (done) {
it('[failure] will handle responses timing out', function (done) {
const url = 'https://static.wixstatic.com/media/355241_d31358572a2542c5a44738ddcb59e7ea.jpg_256';
const requestMock = nock('https://static.wixstatic.com')
@ -409,14 +410,18 @@ describe('lib/image: image size', function () {
const imageSize = new ImageSize({config: {
get: (key) => {
if (key === 'times:getImageSizeTimeoutInMS') {
return 1;
return 10;
}
}
}, tpl: {}, storage: {}, storageUtils: {
isLocalImage: () => false
}, validator: {
isURL: () => true
}, urlUtils: {}, request: {}});
}, urlUtils: {}, request: {},
probe(reqUrl, options) {
// simulate probe the request timing out by probe's option
return probe(reqUrl, {...options, response_timeout: 1});
}});
imageSize.getImageSizeFromUrl(url)
.catch(function (err) {
@ -441,7 +446,7 @@ describe('lib/image: image size', function () {
isLocalImage: () => false
}, validator: {
isURL: () => true
}, urlUtils: {}, request: {}});
}, urlUtils: {}, request: {}, probe});
imageSize.getImageSizeFromUrl(url)
.then(() => {
@ -475,7 +480,7 @@ describe('lib/image: image size', function () {
});
}
return Promise.reject();
}});
}, probe});
imageSize.getImageSizeFromUrl(url)
.then(() => {
@ -500,7 +505,7 @@ describe('lib/image: image size', function () {
isURL: () => true
}, urlUtils: {}, request: () => {
return Promise.reject({});
}});
}, probe});
imageSize.getImageSizeFromUrl(url)
.catch(function (err) {
@ -510,6 +515,38 @@ describe('lib/image: image size', function () {
done();
}).catch(done);
});
it('[failure] handles probe being unresponsive', function (done) {
const url = 'http://img.stockfresh.com/files/f/feedough/x/11/1540353_20925115.jpg';
const requestMock = nock('http://img.stockfresh.com')
.get('/files/f/feedough/x/11/1540353_20925115.jpg')
.reply(200, GIF1x1);
const imageSize = new ImageSize({config: {
get: (key) => {
if (key === 'times:getImageSizeTimeoutInMS') {
return 1;
}
}
}, tpl: {}, storage: {}, storageUtils: {
isLocalImage: () => false
}, validator: {
isURL: () => true
}, urlUtils: {}, request: {},
probe(reqUrl, options) {
// simulate probe being unresponsive by making the timeout longer than the request
return probe(reqUrl, {...options, response_timeout: 10});
}});
imageSize.getImageSizeFromUrl(url)
.catch(function (err) {
requestMock.isDone().should.be.true();
should.exist(err);
err.errorType.should.be.equal('InternalServerError');
err.message.should.be.equal('Probe unresponsive.');
done();
}).catch(done);
});
});
describe('getImageSizeFromStoragePath', function () {
@ -542,7 +579,7 @@ describe('lib/image: image size', function () {
getSubdir: urlGetSubdirStub
}, request: () => {
return Promise.reject({});
}});
}, probe});
imageSize.getImageSizeFromStoragePath(url).then(function (res) {
should.exist(res);
@ -585,7 +622,7 @@ describe('lib/image: image size', function () {
getSubdir: urlGetSubdirStub
}, request: () => {
return Promise.reject({});
}});
}, probe});
imageSize.getImageSizeFromStoragePath(url).then(function (res) {
should.exist(res);
@ -628,7 +665,7 @@ describe('lib/image: image size', function () {
getSubdir: urlGetSubdirStub
}, request: () => {
return Promise.reject({});
}});
}, probe});
imageSize.getImageSizeFromStoragePath(url).then(function (res) {
should.exist(res);
@ -671,7 +708,7 @@ describe('lib/image: image size', function () {
getSubdir: urlGetSubdirStub
}, request: () => {
return Promise.reject({});
}});
}, probe});
imageSize.getImageSizeFromStoragePath(url).then(function (res) {
should.exist(res);
@ -711,7 +748,7 @@ describe('lib/image: image size', function () {
getSubdir: urlGetSubdirStub
}, request: () => {
return Promise.reject({});
}});
}, probe});
imageSize.getImageSizeFromStoragePath(url)
.catch(function (err) {
@ -746,7 +783,7 @@ describe('lib/image: image size', function () {
getSubdir: urlGetSubdirStub
}, request: () => {
return Promise.reject({});
}});
}, probe});
imageSize.getImageSizeFromStoragePath(url)
.catch(function (err) {

View File

@ -7810,6 +7810,14 @@
lodash "^4.17.15"
redent "^3.0.0"
"@testing-library/react-hooks@8.0.1":
version "8.0.1"
resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz#0924bbd5b55e0c0c0502d1754657ada66947ca12"
integrity sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==
dependencies:
"@babel/runtime" "^7.12.5"
react-error-boundary "^3.1.0"
"@testing-library/react@12.1.5":
version "12.1.5"
resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.5.tgz#bb248f72f02a5ac9d949dea07279095fa577963b"
@ -27785,6 +27793,13 @@ react-element-to-jsx-string@^15.0.0:
is-plain-object "5.0.0"
react-is "18.1.0"
react-error-boundary@^3.1.0:
version "3.1.4"
resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0"
integrity sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==
dependencies:
"@babel/runtime" "^7.12.5"
react-hot-toast@2.4.1:
version "2.4.1"
resolved "https://registry.yarnpkg.com/react-hot-toast/-/react-hot-toast-2.4.1.tgz#df04295eda8a7b12c4f968e54a61c8d36f4c0994"