Some code cleanup, add jest coverage and begin using it for utility functions
This commit is contained in:
parent
ca39830333
commit
1eb3ea2ee4
29 changed files with 2044 additions and 134 deletions
|
@ -25,6 +25,7 @@ extends:
|
||||||
- "standard"
|
- "standard"
|
||||||
- "plugin:react/recommended"
|
- "plugin:react/recommended"
|
||||||
- "plugin:@typescript-eslint/recommended"
|
- "plugin:@typescript-eslint/recommended"
|
||||||
|
- "plugin:jest-dom/recommended"
|
||||||
rules:
|
rules:
|
||||||
quotes:
|
quotes:
|
||||||
- warn
|
- warn
|
||||||
|
|
|
@ -1,14 +1,6 @@
|
||||||
module.exports = {
|
module.exports = function (api) {
|
||||||
presets: [
|
let targets = {};
|
||||||
'@babel/typescript',
|
const plugins = [
|
||||||
['@babel/env', {
|
|
||||||
modules: false,
|
|
||||||
useBuiltIns: 'entry',
|
|
||||||
corejs: 3,
|
|
||||||
}],
|
|
||||||
'@babel/react',
|
|
||||||
],
|
|
||||||
plugins: [
|
|
||||||
'babel-plugin-macros',
|
'babel-plugin-macros',
|
||||||
'styled-components',
|
'styled-components',
|
||||||
'react-hot-loader/babel',
|
'react-hot-loader/babel',
|
||||||
|
@ -19,5 +11,24 @@ module.exports = {
|
||||||
'@babel/proposal-optional-chaining',
|
'@babel/proposal-optional-chaining',
|
||||||
'@babel/proposal-nullish-coalescing-operator',
|
'@babel/proposal-nullish-coalescing-operator',
|
||||||
'@babel/syntax-dynamic-import',
|
'@babel/syntax-dynamic-import',
|
||||||
],
|
];
|
||||||
|
|
||||||
|
if (api.env('test')) {
|
||||||
|
targets = { node: 'current' };
|
||||||
|
plugins.push('@babel/transform-modules-commonjs');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
plugins,
|
||||||
|
presets: [
|
||||||
|
'@babel/typescript',
|
||||||
|
['@babel/env', {
|
||||||
|
modules: false,
|
||||||
|
useBuiltIns: 'entry',
|
||||||
|
corejs: 3,
|
||||||
|
targets,
|
||||||
|
}],
|
||||||
|
'@babel/react',
|
||||||
|
]
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
28
jest.config.js
Normal file
28
jest.config.js
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
const { pathsToModuleNameMapper } = require('ts-jest');
|
||||||
|
const { compilerOptions } = require('./tsconfig');
|
||||||
|
|
||||||
|
/** @type {import('ts-jest').InitialOptionsTsJest} */
|
||||||
|
module.exports = {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
globals: {
|
||||||
|
'ts-jest': {
|
||||||
|
isolatedModules: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
moduleFileExtensions: ['js', 'ts', 'tsx', 'd.ts', 'json', 'node'],
|
||||||
|
moduleNameMapper: {
|
||||||
|
'\\.(jpe?g|png|gif|svg)$': '<rootDir>/resources/scripts/__mocks__/file.ts',
|
||||||
|
'\\.(s?css|less)$': 'identity-obj-proxy',
|
||||||
|
...pathsToModuleNameMapper(compilerOptions.paths, {
|
||||||
|
prefix: '<rootDir>/',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
setupFilesAfterEnv: [
|
||||||
|
'<rootDir>/resources/scripts/setup-tests.ts',
|
||||||
|
],
|
||||||
|
transform: {
|
||||||
|
'.*\\.[t|j]sx$': 'babel-jest',
|
||||||
|
'.*\\.ts$': 'ts-jest',
|
||||||
|
},
|
||||||
|
testPathIgnorePatterns: ['/node_modules/'],
|
||||||
|
};
|
12
package.json
12
package.json
|
@ -60,16 +60,22 @@
|
||||||
"@babel/plugin-proposal-object-rest-spread": "^7.12.1",
|
"@babel/plugin-proposal-object-rest-spread": "^7.12.1",
|
||||||
"@babel/plugin-proposal-optional-chaining": "^7.12.1",
|
"@babel/plugin-proposal-optional-chaining": "^7.12.1",
|
||||||
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
||||||
|
"@babel/plugin-transform-modules-commonjs": "^7.18.2",
|
||||||
"@babel/plugin-transform-react-jsx": "^7.12.1",
|
"@babel/plugin-transform-react-jsx": "^7.12.1",
|
||||||
"@babel/plugin-transform-runtime": "^7.12.1",
|
"@babel/plugin-transform-runtime": "^7.12.1",
|
||||||
"@babel/preset-env": "^7.12.1",
|
"@babel/preset-env": "^7.12.1",
|
||||||
"@babel/preset-react": "^7.12.1",
|
"@babel/preset-react": "^7.12.1",
|
||||||
"@babel/preset-typescript": "^7.12.1",
|
"@babel/preset-typescript": "^7.12.1",
|
||||||
"@babel/runtime": "^7.12.1",
|
"@babel/runtime": "^7.12.1",
|
||||||
|
"@testing-library/dom": "^8.14.0",
|
||||||
|
"@testing-library/jest-dom": "^5.16.4",
|
||||||
|
"@testing-library/react": "12.1.5",
|
||||||
|
"@testing-library/user-event": "^14.2.1",
|
||||||
"@types/chart.js": "^2.8.5",
|
"@types/chart.js": "^2.8.5",
|
||||||
"@types/codemirror": "^0.0.98",
|
"@types/codemirror": "^0.0.98",
|
||||||
"@types/debounce": "^1.2.0",
|
"@types/debounce": "^1.2.0",
|
||||||
"@types/events": "^3.0.0",
|
"@types/events": "^3.0.0",
|
||||||
|
"@types/jest": "^28.1.3",
|
||||||
"@types/node": "^14.11.10",
|
"@types/node": "^14.11.10",
|
||||||
"@types/qrcode.react": "^1.0.1",
|
"@types/qrcode.react": "^1.0.1",
|
||||||
"@types/query-string": "^6.3.0",
|
"@types/query-string": "^6.3.0",
|
||||||
|
@ -88,6 +94,7 @@
|
||||||
"@typescript-eslint/eslint-plugin": "^4.25.0",
|
"@typescript-eslint/eslint-plugin": "^4.25.0",
|
||||||
"@typescript-eslint/parser": "^4.25.0",
|
"@typescript-eslint/parser": "^4.25.0",
|
||||||
"autoprefixer": "^10.4.7",
|
"autoprefixer": "^10.4.7",
|
||||||
|
"babel-jest": "^28.1.1",
|
||||||
"babel-loader": "^8.2.5",
|
"babel-loader": "^8.2.5",
|
||||||
"babel-plugin-styled-components": "^2.0.7",
|
"babel-plugin-styled-components": "^2.0.7",
|
||||||
"cross-env": "^7.0.2",
|
"cross-env": "^7.0.2",
|
||||||
|
@ -95,11 +102,14 @@
|
||||||
"eslint": "^7.27.0",
|
"eslint": "^7.27.0",
|
||||||
"eslint-config-standard": "^16.0.3",
|
"eslint-config-standard": "^16.0.3",
|
||||||
"eslint-plugin-import": "^2.23.3",
|
"eslint-plugin-import": "^2.23.3",
|
||||||
|
"eslint-plugin-jest-dom": "^4.0.2",
|
||||||
"eslint-plugin-node": "^11.1.0",
|
"eslint-plugin-node": "^11.1.0",
|
||||||
"eslint-plugin-promise": "^5.1.0",
|
"eslint-plugin-promise": "^5.1.0",
|
||||||
"eslint-plugin-react": "^7.23.2",
|
"eslint-plugin-react": "^7.23.2",
|
||||||
"eslint-plugin-react-hooks": "^4.2.0",
|
"eslint-plugin-react-hooks": "^4.2.0",
|
||||||
"fork-ts-checker-webpack-plugin": "^6.2.10",
|
"fork-ts-checker-webpack-plugin": "^6.2.10",
|
||||||
|
"identity-obj-proxy": "^3.0.0",
|
||||||
|
"jest": "^28.1.1",
|
||||||
"postcss": "^8.4.14",
|
"postcss": "^8.4.14",
|
||||||
"postcss-import": "^14.1.0",
|
"postcss-import": "^14.1.0",
|
||||||
"postcss-loader": "^4.0.0",
|
"postcss-loader": "^4.0.0",
|
||||||
|
@ -111,6 +121,7 @@
|
||||||
"svg-url-loader": "^7.1.1",
|
"svg-url-loader": "^7.1.1",
|
||||||
"terser-webpack-plugin": "^4.2.3",
|
"terser-webpack-plugin": "^4.2.3",
|
||||||
"ts-essentials": "^9.1.2",
|
"ts-essentials": "^9.1.2",
|
||||||
|
"ts-jest": "^28.0.5",
|
||||||
"twin.macro": "^2.8.2",
|
"twin.macro": "^2.8.2",
|
||||||
"typescript": "^4.7.3",
|
"typescript": "^4.7.3",
|
||||||
"webpack": "^4.43.0",
|
"webpack": "^4.43.0",
|
||||||
|
@ -122,6 +133,7 @@
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "cd public/assets && find . \\( -name \"*.js\" -o -name \"*.map\" \\) -type f -delete",
|
"clean": "cd public/assets && find . \\( -name \"*.js\" -o -name \"*.map\" \\) -type f -delete",
|
||||||
|
"test": "jest",
|
||||||
"lint": "eslint ./resources/scripts/**/*.{ts,tsx} --ext .ts,.tsx",
|
"lint": "eslint ./resources/scripts/**/*.{ts,tsx} --ext .ts,.tsx",
|
||||||
"watch": "cross-env NODE_ENV=development ./node_modules/.bin/webpack --watch --progress",
|
"watch": "cross-env NODE_ENV=development ./node_modules/.bin/webpack --watch --progress",
|
||||||
"build": "cross-env NODE_ENV=development ./node_modules/.bin/webpack --progress",
|
"build": "cross-env NODE_ENV=development ./node_modules/.bin/webpack --progress",
|
||||||
|
|
1
resources/scripts/__mocks__/file.ts
Normal file
1
resources/scripts/__mocks__/file.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
module.exports = 'test-file-stub';
|
|
@ -4,7 +4,7 @@ import { faEthernet, faHdd, faMemory, faMicrochip, faServer } from '@fortawesome
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Server } from '@/api/server/getServer';
|
import { Server } from '@/api/server/getServer';
|
||||||
import getServerResourceUsage, { ServerPowerState, ServerStats } from '@/api/server/getServerResourceUsage';
|
import getServerResourceUsage, { ServerPowerState, ServerStats } from '@/api/server/getServerResourceUsage';
|
||||||
import { bytesToHuman, formatIp, megabytesToHuman } from '@/helpers';
|
import { bytesToString, ip, mbToBytes } from '@/lib/formatters';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import GreyRowBox from '@/components/elements/GreyRowBox';
|
import GreyRowBox from '@/components/elements/GreyRowBox';
|
||||||
import Spinner from '@/components/elements/Spinner';
|
import Spinner from '@/components/elements/Spinner';
|
||||||
|
@ -74,8 +74,8 @@ export default ({ server, className }: { server: Server; className?: string }) =
|
||||||
alarms.disk = server.limits.disk === 0 ? false : isAlarmState(stats.diskUsageInBytes, server.limits.disk);
|
alarms.disk = server.limits.disk === 0 ? false : isAlarmState(stats.diskUsageInBytes, server.limits.disk);
|
||||||
}
|
}
|
||||||
|
|
||||||
const diskLimit = server.limits.disk !== 0 ? megabytesToHuman(server.limits.disk) : 'Unlimited';
|
const diskLimit = server.limits.disk !== 0 ? bytesToString(mbToBytes(server.limits.disk)) : 'Unlimited';
|
||||||
const memoryLimit = server.limits.memory !== 0 ? megabytesToHuman(server.limits.memory) : 'Unlimited';
|
const memoryLimit = server.limits.memory !== 0 ? bytesToString(mbToBytes(server.limits.memory)) : 'Unlimited';
|
||||||
const cpuLimit = server.limits.cpu !== 0 ? server.limits.cpu + ' %' : 'Unlimited';
|
const cpuLimit = server.limits.cpu !== 0 ? server.limits.cpu + ' %' : 'Unlimited';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -98,7 +98,7 @@ export default ({ server, className }: { server: Server; className?: string }) =
|
||||||
{
|
{
|
||||||
server.allocations.filter(alloc => alloc.isDefault).map(allocation => (
|
server.allocations.filter(alloc => alloc.isDefault).map(allocation => (
|
||||||
<React.Fragment key={allocation.ip + allocation.port.toString()}>
|
<React.Fragment key={allocation.ip + allocation.port.toString()}>
|
||||||
{allocation.alias || formatIp(allocation.ip)}:{allocation.port}
|
{allocation.alias || ip(allocation.ip)}:{allocation.port}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
@ -146,7 +146,7 @@ export default ({ server, className }: { server: Server; className?: string }) =
|
||||||
<div css={tw`flex justify-center`}>
|
<div css={tw`flex justify-center`}>
|
||||||
<Icon icon={faMemory} $alarm={alarms.memory}/>
|
<Icon icon={faMemory} $alarm={alarms.memory}/>
|
||||||
<IconDescription $alarm={alarms.memory}>
|
<IconDescription $alarm={alarms.memory}>
|
||||||
{bytesToHuman(stats.memoryUsageInBytes)}
|
{bytesToString(stats.memoryUsageInBytes)}
|
||||||
</IconDescription>
|
</IconDescription>
|
||||||
</div>
|
</div>
|
||||||
<p css={tw`text-xs text-neutral-600 text-center mt-1`}>of {memoryLimit}</p>
|
<p css={tw`text-xs text-neutral-600 text-center mt-1`}>of {memoryLimit}</p>
|
||||||
|
@ -155,7 +155,7 @@ export default ({ server, className }: { server: Server; className?: string }) =
|
||||||
<div css={tw`flex justify-center`}>
|
<div css={tw`flex justify-center`}>
|
||||||
<Icon icon={faHdd} $alarm={alarms.disk}/>
|
<Icon icon={faHdd} $alarm={alarms.disk}/>
|
||||||
<IconDescription $alarm={alarms.disk}>
|
<IconDescription $alarm={alarms.disk}>
|
||||||
{bytesToHuman(stats.diskUsageInBytes)}
|
{bytesToString(stats.diskUsageInBytes)}
|
||||||
</IconDescription>
|
</IconDescription>
|
||||||
</div>
|
</div>
|
||||||
<p css={tw`text-xs text-neutral-600 text-center mt-1`}>of {diskLimit}</p>
|
<p css={tw`text-xs text-neutral-600 text-center mt-1`}>of {diskLimit}</p>
|
||||||
|
|
|
@ -13,7 +13,8 @@ import { Link } from 'react-router-dom';
|
||||||
import styled from 'styled-components/macro';
|
import styled from 'styled-components/macro';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import Input from '@/components/elements/Input';
|
import Input from '@/components/elements/Input';
|
||||||
import { formatIp } from '@/helpers';
|
import { ip } from '@/lib/formatters';
|
||||||
|
|
||||||
type Props = RequiredModalProps;
|
type Props = RequiredModalProps;
|
||||||
|
|
||||||
interface Values {
|
interface Values {
|
||||||
|
@ -109,7 +110,7 @@ export default ({ ...props }: Props) => {
|
||||||
<p css={tw`mt-1 text-xs text-neutral-400`}>
|
<p css={tw`mt-1 text-xs text-neutral-400`}>
|
||||||
{
|
{
|
||||||
server.allocations.filter(alloc => alloc.isDefault).map(allocation => (
|
server.allocations.filter(alloc => alloc.isDefault).map(allocation => (
|
||||||
<span key={allocation.ip + allocation.port.toString()}>{allocation.alias || formatIp(allocation.ip)}:{allocation.port}</span>
|
<span key={allocation.ip + allocation.port.toString()}>{allocation.alias || ip(allocation.ip)}:{allocation.port}</span>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FormikErrors, FormikTouched } from 'formik';
|
import { FormikErrors, FormikTouched } from 'formik';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import { capitalize } from '@/helpers';
|
import { capitalize } from '@/lib/strings';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
errors: FormikErrors<any>;
|
errors: FormikErrors<any>;
|
||||||
|
|
|
@ -8,7 +8,7 @@ import ActivityLogMetaButton from '@/components/elements/activity/ActivityLogMet
|
||||||
import { TerminalIcon } from '@heroicons/react/solid';
|
import { TerminalIcon } from '@heroicons/react/solid';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import style from './style.module.css';
|
import style from './style.module.css';
|
||||||
import { isObject } from '@/helpers';
|
import { isObject } from '@/lib/objects';
|
||||||
import Avatar from '@/components/Avatar';
|
import Avatar from '@/components/Avatar';
|
||||||
import useLocationHash from '@/plugins/useLocationHash';
|
import useLocationHash from '@/plugins/useLocationHash';
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faArchive, faEllipsisH, faLock } from '@fortawesome/free-solid-svg-icons';
|
import { faArchive, faEllipsisH, faLock } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { format, formatDistanceToNow } from 'date-fns';
|
import { format, formatDistanceToNow } from 'date-fns';
|
||||||
import Spinner from '@/components/elements/Spinner';
|
import Spinner from '@/components/elements/Spinner';
|
||||||
import { bytesToHuman } from '@/helpers';
|
import { bytesToString } from '@/lib/formatters';
|
||||||
import Can from '@/components/elements/Can';
|
import Can from '@/components/elements/Can';
|
||||||
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
|
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
|
||||||
import BackupContextMenu from '@/components/server/backups/BackupContextMenu';
|
import BackupContextMenu from '@/components/server/backups/BackupContextMenu';
|
||||||
|
@ -64,7 +64,7 @@ export default ({ backup, className }: Props) => {
|
||||||
{backup.name}
|
{backup.name}
|
||||||
</p>
|
</p>
|
||||||
{(backup.completedAt !== null && backup.isSuccessful) &&
|
{(backup.completedAt !== null && backup.isSuccessful) &&
|
||||||
<span css={tw`ml-3 text-neutral-300 text-xs font-extralight hidden sm:inline`}>{bytesToHuman(backup.bytes)}</span>
|
<span css={tw`ml-3 text-neutral-300 text-xs font-extralight hidden sm:inline`}>{bytesToString(backup.bytes)}</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<p css={tw`mt-1 md:mt-0 text-xs text-neutral-400 font-mono truncate`}>
|
<p css={tw`mt-1 md:mt-0 text-xs text-neutral-400 font-mono truncate`}>
|
||||||
|
|
|
@ -8,7 +8,7 @@ import {
|
||||||
faMicrochip,
|
faMicrochip,
|
||||||
faWifi,
|
faWifi,
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
import { bytesToHuman, formatIp, megabytesToHuman } from '@/helpers';
|
import { bytesToString, ip, mbToBytes } from '@/lib/formatters';
|
||||||
import { ServerContext } from '@/state/server';
|
import { ServerContext } from '@/state/server';
|
||||||
import { SocketEvent, SocketRequest } from '@/components/server/events';
|
import { SocketEvent, SocketRequest } from '@/components/server/events';
|
||||||
import UptimeDuration from '@/components/server/UptimeDuration';
|
import UptimeDuration from '@/components/server/UptimeDuration';
|
||||||
|
@ -41,7 +41,7 @@ const ServerDetailsBlock = ({ className }: { className?: string }) => {
|
||||||
const allocation = ServerContext.useStoreState(state => {
|
const allocation = ServerContext.useStoreState(state => {
|
||||||
const match = state.server.data!.allocations.find(allocation => allocation.isDefault);
|
const match = state.server.data!.allocations.find(allocation => allocation.isDefault);
|
||||||
|
|
||||||
return !match ? 'n/a' : `${match.alias || formatIp(match.ip)}:${match.port}`;
|
return !match ? 'n/a' : `${match.alias || ip(match.ip)}:${match.port}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -106,14 +106,14 @@ const ServerDetailsBlock = ({ className }: { className?: string }) => {
|
||||||
title={'Memory'}
|
title={'Memory'}
|
||||||
color={getBackgroundColor(stats.memory / 1024, limits.memory * 1024)}
|
color={getBackgroundColor(stats.memory / 1024, limits.memory * 1024)}
|
||||||
description={limits.memory
|
description={limits.memory
|
||||||
? `This server is allowed to use up to ${megabytesToHuman(limits.memory)} of memory.`
|
? `This server is allowed to use up to ${bytesToString(mbToBytes(limits.memory))} of memory.`
|
||||||
: 'No memory limit has been configured for this server.'
|
: 'No memory limit has been configured for this server.'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{status === 'offline' ?
|
{status === 'offline' ?
|
||||||
<span className={'text-gray-400'}>Offline</span>
|
<span className={'text-gray-400'}>Offline</span>
|
||||||
:
|
:
|
||||||
bytesToHuman(stats.memory)
|
bytesToString(stats.memory)
|
||||||
}
|
}
|
||||||
</StatBlock>
|
</StatBlock>
|
||||||
<StatBlock
|
<StatBlock
|
||||||
|
@ -121,11 +121,11 @@ const ServerDetailsBlock = ({ className }: { className?: string }) => {
|
||||||
title={'Disk'}
|
title={'Disk'}
|
||||||
color={getBackgroundColor(stats.disk / 1024, limits.disk * 1024)}
|
color={getBackgroundColor(stats.disk / 1024, limits.disk * 1024)}
|
||||||
description={limits.disk
|
description={limits.disk
|
||||||
? `This server is allowed to use up to ${megabytesToHuman(limits.disk)} of disk space.`
|
? `This server is allowed to use up to ${bytesToString(mbToBytes(limits.disk))} of disk space.`
|
||||||
: 'No disk space limit has been configured for this server.'
|
: 'No disk space limit has been configured for this server.'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{bytesToHuman(stats.disk)}
|
{bytesToString(stats.disk)}
|
||||||
</StatBlock>
|
</StatBlock>
|
||||||
<StatBlock
|
<StatBlock
|
||||||
icon={faCloudDownloadAlt}
|
icon={faCloudDownloadAlt}
|
||||||
|
@ -135,7 +135,7 @@ const ServerDetailsBlock = ({ className }: { className?: string }) => {
|
||||||
{status === 'offline' ?
|
{status === 'offline' ?
|
||||||
<span className={'text-gray-400'}>Offline</span>
|
<span className={'text-gray-400'}>Offline</span>
|
||||||
:
|
:
|
||||||
bytesToHuman(stats.tx)
|
bytesToString(stats.tx)
|
||||||
}
|
}
|
||||||
</StatBlock>
|
</StatBlock>
|
||||||
<StatBlock
|
<StatBlock
|
||||||
|
@ -146,7 +146,7 @@ const ServerDetailsBlock = ({ className }: { className?: string }) => {
|
||||||
{status === 'offline' ?
|
{status === 'offline' ?
|
||||||
<span className={'text-gray-400'}>Offline</span>
|
<span className={'text-gray-400'}>Offline</span>
|
||||||
:
|
:
|
||||||
bytesToHuman(stats.rx)
|
bytesToString(stats.rx)
|
||||||
}
|
}
|
||||||
</StatBlock>
|
</StatBlock>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -4,7 +4,8 @@ import { SocketEvent } from '@/components/server/events';
|
||||||
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
|
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
|
||||||
import { Line } from 'react-chartjs-2';
|
import { Line } from 'react-chartjs-2';
|
||||||
import { useChart, useChartTickLabel } from '@/components/server/console/chart';
|
import { useChart, useChartTickLabel } from '@/components/server/console/chart';
|
||||||
import { bytesToHuman, toRGBA } from '@/helpers';
|
import { hexToRgba } from '@/lib/helpers';
|
||||||
|
import { bytesToString } from '@/lib/formatters';
|
||||||
import { CloudDownloadIcon, CloudUploadIcon } from '@heroicons/react/solid';
|
import { CloudDownloadIcon, CloudUploadIcon } from '@heroicons/react/solid';
|
||||||
import { theme } from 'twin.macro';
|
import { theme } from 'twin.macro';
|
||||||
import ChartBlock from '@/components/server/console/ChartBlock';
|
import ChartBlock from '@/components/server/console/ChartBlock';
|
||||||
|
@ -24,7 +25,7 @@ export default () => {
|
||||||
y: {
|
y: {
|
||||||
ticks: {
|
ticks: {
|
||||||
callback (value) {
|
callback (value) {
|
||||||
return bytesToHuman(typeof value === 'string' ? parseInt(value, 10) : value);
|
return bytesToString(typeof value === 'string' ? parseInt(value, 10) : value);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -35,7 +36,7 @@ export default () => {
|
||||||
...opts,
|
...opts,
|
||||||
label: !index ? 'Network In' : 'Network Out',
|
label: !index ? 'Network In' : 'Network Out',
|
||||||
borderColor: !index ? theme('colors.cyan.400') : theme('colors.yellow.400'),
|
borderColor: !index ? theme('colors.cyan.400') : theme('colors.yellow.400'),
|
||||||
backgroundColor: toRGBA(!index ? theme('colors.cyan.700') : theme('colors.yellow.700'), 0.5),
|
backgroundColor: hexToRgba(!index ? theme('colors.cyan.700') : theme('colors.yellow.700'), 0.5),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -12,7 +12,7 @@ import { DeepPartial } from 'ts-essentials';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { deepmerge, deepmergeCustom } from 'deepmerge-ts';
|
import { deepmerge, deepmergeCustom } from 'deepmerge-ts';
|
||||||
import { theme } from 'twin.macro';
|
import { theme } from 'twin.macro';
|
||||||
import { toRGBA } from '@/helpers';
|
import { hexToRgba } from '@/lib/helpers';
|
||||||
|
|
||||||
ChartJS.register(LineElement, PointElement, Filler, LinearScale);
|
ChartJS.register(LineElement, PointElement, Filler, LinearScale);
|
||||||
|
|
||||||
|
@ -86,7 +86,7 @@ function getEmptyData (label: string, sets = 1, callback?: ChartDatasetCallback
|
||||||
label,
|
label,
|
||||||
data: Array(20).fill(0),
|
data: Array(20).fill(0),
|
||||||
borderColor: theme('colors.cyan.400'),
|
borderColor: theme('colors.cyan.400'),
|
||||||
backgroundColor: toRGBA(theme('colors.cyan.700'), 0.5),
|
backgroundColor: hexToRgba(theme('colors.cyan.700'), 0.5),
|
||||||
}, index)),
|
}, index)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import features from './index';
|
import features from './index';
|
||||||
import { getObjectKeys } from '@/helpers';
|
import { getObjectKeys } from '@/lib/objects';
|
||||||
|
|
||||||
type ListItems = [ string, React.ComponentType ][];
|
type ListItems = [ string, React.ComponentType ][];
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faFileAlt, faFileArchive, faFileImport, faFolder } from '@fortawesome/free-solid-svg-icons';
|
import { faFileAlt, faFileArchive, faFileImport, faFolder } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { bytesToHuman, encodePathSegments } from '@/helpers';
|
import { encodePathSegments } from '@/helpers';
|
||||||
import { differenceInHours, format, formatDistanceToNow } from 'date-fns';
|
import { differenceInHours, format, formatDistanceToNow } from 'date-fns';
|
||||||
import React, { memo } from 'react';
|
import React, { memo } from 'react';
|
||||||
import { FileObject } from '@/api/server/files/loadDirectory';
|
import { FileObject } from '@/api/server/files/loadDirectory';
|
||||||
|
@ -13,6 +13,7 @@ import styled from 'styled-components/macro';
|
||||||
import SelectFileCheckbox from '@/components/server/files/SelectFileCheckbox';
|
import SelectFileCheckbox from '@/components/server/files/SelectFileCheckbox';
|
||||||
import { usePermissions } from '@/plugins/usePermissions';
|
import { usePermissions } from '@/plugins/usePermissions';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
import { bytesToString } from '@/lib/formatters';
|
||||||
|
|
||||||
const Row = styled.div`
|
const Row = styled.div`
|
||||||
${tw`flex bg-neutral-700 rounded-sm mb-px text-sm hover:text-neutral-100 cursor-pointer items-center no-underline hover:bg-neutral-600`};
|
${tw`flex bg-neutral-700 rounded-sm mb-px text-sm hover:text-neutral-100 cursor-pointer items-center no-underline hover:bg-neutral-600`};
|
||||||
|
@ -61,7 +62,7 @@ const FileObjectRow = ({ file }: { file: FileObject }) => (
|
||||||
</div>
|
</div>
|
||||||
{file.isFile &&
|
{file.isFile &&
|
||||||
<div css={tw`w-1/6 text-right mr-4 hidden sm:block`}>
|
<div css={tw`w-1/6 text-right mr-4 hidden sm:block`}>
|
||||||
{bytesToHuman(file.size)}
|
{bytesToString(file.size)}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div
|
<div
|
||||||
|
|
|
@ -18,7 +18,7 @@ import CopyOnClick from '@/components/elements/CopyOnClick';
|
||||||
import DeleteAllocationButton from '@/components/server/network/DeleteAllocationButton';
|
import DeleteAllocationButton from '@/components/server/network/DeleteAllocationButton';
|
||||||
import setPrimaryServerAllocation from '@/api/server/network/setPrimaryServerAllocation';
|
import setPrimaryServerAllocation from '@/api/server/network/setPrimaryServerAllocation';
|
||||||
import getServerAllocations from '@/api/swr/getServerAllocations';
|
import getServerAllocations from '@/api/swr/getServerAllocations';
|
||||||
import { formatIp } from '@/helpers';
|
import { ip } from '@/lib/formatters';
|
||||||
import Code from '@/components/elements/Code';
|
import Code from '@/components/elements/Code';
|
||||||
|
|
||||||
const Label = styled.label`${tw`uppercase text-xs mt-1 text-neutral-400 block px-1 select-none transition-colors duration-150`}`;
|
const Label = styled.label`${tw`uppercase text-xs mt-1 text-neutral-400 block px-1 select-none transition-colors duration-150`}`;
|
||||||
|
@ -67,7 +67,7 @@ const AllocationRow = ({ allocation }: Props) => {
|
||||||
<div className={'mr-4 flex-1 md:w-40'}>
|
<div className={'mr-4 flex-1 md:w-40'}>
|
||||||
{allocation.alias ?
|
{allocation.alias ?
|
||||||
<CopyOnClick text={allocation.alias}><Code dark className={'w-40 truncate'}>{allocation.alias}</Code></CopyOnClick> :
|
<CopyOnClick text={allocation.alias}><Code dark className={'w-40 truncate'}>{allocation.alias}</Code></CopyOnClick> :
|
||||||
<CopyOnClick text={formatIp(allocation.ip)}><Code dark>{formatIp(allocation.ip)}</Code></CopyOnClick>}
|
<CopyOnClick text={ip(allocation.ip)}><Code dark>{ip(allocation.ip)}</Code></CopyOnClick>}
|
||||||
<Label>{allocation.alias ? 'Hostname' : 'IP Address'}</Label>
|
<Label>{allocation.alias ? 'Hostname' : 'IP Address'}</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className={'w-16 md:w-24 overflow-hidden'}>
|
<div className={'w-16 md:w-24 overflow-hidden'}>
|
||||||
|
|
|
@ -12,7 +12,7 @@ import Label from '@/components/elements/Label';
|
||||||
import ServerContentBlock from '@/components/elements/ServerContentBlock';
|
import ServerContentBlock from '@/components/elements/ServerContentBlock';
|
||||||
import isEqual from 'react-fast-compare';
|
import isEqual from 'react-fast-compare';
|
||||||
import CopyOnClick from '@/components/elements/CopyOnClick';
|
import CopyOnClick from '@/components/elements/CopyOnClick';
|
||||||
import { formatIp } from '@/helpers';
|
import { ip } from '@/lib/formatters';
|
||||||
import { Button } from '@/components/elements/button/index';
|
import { Button } from '@/components/elements/button/index';
|
||||||
|
|
||||||
export default () => {
|
export default () => {
|
||||||
|
@ -31,10 +31,10 @@ export default () => {
|
||||||
<TitledGreyBox title={'SFTP Details'} css={tw`mb-6 md:mb-10`}>
|
<TitledGreyBox title={'SFTP Details'} css={tw`mb-6 md:mb-10`}>
|
||||||
<div>
|
<div>
|
||||||
<Label>Server Address</Label>
|
<Label>Server Address</Label>
|
||||||
<CopyOnClick text={`sftp://${formatIp(sftp.ip)}:${sftp.port}`}>
|
<CopyOnClick text={`sftp://${ip(sftp.ip)}:${sftp.port}`}>
|
||||||
<Input
|
<Input
|
||||||
type={'text'}
|
type={'text'}
|
||||||
value={`sftp://${formatIp(sftp.ip)}:${sftp.port}`}
|
value={`sftp://${ip(sftp.ip)}:${sftp.port}`}
|
||||||
readOnly
|
readOnly
|
||||||
/>
|
/>
|
||||||
</CopyOnClick>
|
</CopyOnClick>
|
||||||
|
@ -58,7 +58,7 @@ export default () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div css={tw`ml-4`}>
|
<div css={tw`ml-4`}>
|
||||||
<a href={`sftp://${username}.${id}@${formatIp(sftp.ip)}:${sftp.port}`}>
|
<a href={`sftp://${username}.${id}@${ip(sftp.ip)}:${sftp.port}`}>
|
||||||
<Button.Text variant={Button.Variants.Secondary}>Launch SFTP</Button.Text>
|
<Button.Text variant={Button.Variants.Secondary}>Launch SFTP</Button.Text>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,24 +1,7 @@
|
||||||
export const megabytesToBytes = (mb: number) => Math.floor(mb * 1024 * 1024);
|
|
||||||
|
|
||||||
export function bytesToHuman (bytes: number): string {
|
|
||||||
if (bytes < 1) {
|
|
||||||
return '0 Bytes';
|
|
||||||
}
|
|
||||||
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
||||||
return `${Number((bytes / Math.pow(1024, i)).toFixed(2))} ${[ 'Bytes', 'kB', 'MB', 'GB', 'TB' ][i]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function megabytesToHuman (mb: number): string {
|
|
||||||
return bytesToHuman(megabytesToBytes(mb));
|
|
||||||
}
|
|
||||||
|
|
||||||
export const randomInt = (low: number, high: number) => Math.floor(Math.random() * (high - low) + low);
|
export const randomInt = (low: number, high: number) => Math.floor(Math.random() * (high - low) + low);
|
||||||
|
|
||||||
export const cleanDirectoryPath = (path: string) => path.replace(/(\/(\/*))|(^$)/g, '/');
|
export const cleanDirectoryPath = (path: string) => path.replace(/(\/(\/*))|(^$)/g, '/');
|
||||||
|
|
||||||
export const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();
|
|
||||||
|
|
||||||
export function fileBitsToString (mode: string, directory: boolean): string {
|
export function fileBitsToString (mode: string, directory: boolean): string {
|
||||||
const m = parseInt(mode, 8);
|
const m = parseInt(mode, 8);
|
||||||
|
|
||||||
|
@ -61,23 +44,3 @@ export function encodePathSegments (path: string): string {
|
||||||
export function hashToPath (hash: string): string {
|
export function hashToPath (hash: string): string {
|
||||||
return hash.length > 0 ? decodeURIComponent(hash.substr(1)) : '/';
|
return hash.length > 0 ? decodeURIComponent(hash.substr(1)) : '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatIp (ip: string): string {
|
|
||||||
return /([a-f0-9:]+:+)+[a-f0-9]+/.test(ip) ? `[${ip}]` : ip;
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
||||||
export const isObject = (o: unknown): o is {} => typeof o === 'object' && o !== null;
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
||||||
export const isEmptyObject = (o: {}): boolean =>
|
|
||||||
Object.keys(o).length === 0 && Object.getPrototypeOf(o) === Object.prototype;
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
||||||
export const getObjectKeys = <T extends {}> (o: T): (keyof T)[] => Object.keys(o) as (keyof typeof o)[];
|
|
||||||
|
|
||||||
export const toRGBA = (hex: string, alpha = 1): string => {
|
|
||||||
const [ r, g, b ] = hex.match(/\w\w/g)!.map(v => parseInt(v, 16));
|
|
||||||
|
|
||||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
||||||
};
|
|
||||||
|
|
56
resources/scripts/lib/formatters.spec.ts
Normal file
56
resources/scripts/lib/formatters.spec.ts
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
import { bytesToString, ip, mbToBytes } from '@/lib/formatters';
|
||||||
|
|
||||||
|
describe('@/lib/formatters.ts', function () {
|
||||||
|
describe('mbToBytes()', function () {
|
||||||
|
it('should convert from MB to Bytes', function () {
|
||||||
|
expect(mbToBytes(1)).toBe(1_000_000);
|
||||||
|
expect(mbToBytes(0)).toBe(0);
|
||||||
|
expect(mbToBytes(0.1)).toBe(100_000);
|
||||||
|
expect(mbToBytes(0.001)).toBe(1000);
|
||||||
|
expect(mbToBytes(1024)).toBe(1_024_000_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bytesToString()', function () {
|
||||||
|
it.each([
|
||||||
|
[ 0, '0 Bytes' ],
|
||||||
|
[ 0.5, '0 Bytes' ],
|
||||||
|
[ 0.9, '0 Bytes' ],
|
||||||
|
[ 100, '100 Bytes' ],
|
||||||
|
[ 100.25, '100.25 Bytes' ],
|
||||||
|
[ 100.998, '101 Bytes' ],
|
||||||
|
[ 512, '512 Bytes' ],
|
||||||
|
[ 1000, '1 KB' ],
|
||||||
|
[ 1024, '1.02 KB' ],
|
||||||
|
[ 5068, '5.07 KB' ],
|
||||||
|
[ 10_000, '10 KB' ],
|
||||||
|
[ 11_864, '11.86 KB' ],
|
||||||
|
[ 1_000_000, '1 MB' ],
|
||||||
|
[ 1_356_000, '1.36 MB' ],
|
||||||
|
[ 1_024_000, '1.02 MB' ],
|
||||||
|
[ 1_000_000_000, '1 GB' ],
|
||||||
|
[ 1_024_000_000, '1.02 GB' ],
|
||||||
|
[ 1_678_342_000, '1.68 GB' ],
|
||||||
|
[ 1_000_000_000_000, '1 TB' ],
|
||||||
|
])('should format %d bytes as "%s"', function (input, output) {
|
||||||
|
expect(bytesToString(input)).toBe(output);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ip()', function () {
|
||||||
|
it('should format an IPv4 address', function () {
|
||||||
|
expect(ip('127.0.0.1')).toBe('127.0.0.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format an IPv6 address', function () {
|
||||||
|
expect(ip(':::1')).toBe('[:::1]');
|
||||||
|
expect(ip('2001:db8::')).toBe('[2001:db8::]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle random inputs', function () {
|
||||||
|
expect(ip('1')).toBe('1');
|
||||||
|
expect(ip('foobar')).toBe('foobar');
|
||||||
|
expect(ip('127.0.0.1:25565')).toBe('[127.0.0.1:25565]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
35
resources/scripts/lib/formatters.ts
Normal file
35
resources/scripts/lib/formatters.ts
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
const _CONVERSION_UNIT = 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a value in megabytes converts it back down into bytes.
|
||||||
|
*/
|
||||||
|
function mbToBytes (megabytes: number): number {
|
||||||
|
return Math.floor(megabytes * _CONVERSION_UNIT * _CONVERSION_UNIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given an amount of bytes, converts them into a human readable string format
|
||||||
|
* using "1000" as the divisor.
|
||||||
|
*/
|
||||||
|
function bytesToString (bytes: number): string {
|
||||||
|
if (bytes < 1) return '0 Bytes';
|
||||||
|
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(_CONVERSION_UNIT));
|
||||||
|
const value = Number((bytes / Math.pow(_CONVERSION_UNIT, i)).toFixed(2));
|
||||||
|
|
||||||
|
return `${value} ${[ 'Bytes', 'KB', 'MB', 'GB', 'TB' ][i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats an IPv4 or IPv6 address.
|
||||||
|
*/
|
||||||
|
function ip (value: string): string {
|
||||||
|
// noinspection RegExpSimplifiable
|
||||||
|
return /([a-f0-9:]+:+)+[a-f0-9]+/.test(value) ? `[${value}]` : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ip,
|
||||||
|
mbToBytes,
|
||||||
|
bytesToString,
|
||||||
|
};
|
29
resources/scripts/lib/helpers.spec.ts
Normal file
29
resources/scripts/lib/helpers.spec.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import { hexToRgba } from '@/lib/helpers';
|
||||||
|
|
||||||
|
describe('@/lib/helpers.ts', function () {
|
||||||
|
describe('hexToRgba()', function () {
|
||||||
|
it('should return the expected rgba', function () {
|
||||||
|
expect(hexToRgba('#ffffff')).toBe('rgba(255, 255, 255, 1)');
|
||||||
|
expect(hexToRgba('#00aabb')).toBe('rgba(0, 170, 187, 1)');
|
||||||
|
expect(hexToRgba('#efefef')).toBe('rgba(239, 239, 239, 1)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore case', function () {
|
||||||
|
expect(hexToRgba('#FF00A3')).toBe('rgba(255, 0, 163, 1)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow alpha channel changes', function () {
|
||||||
|
expect(hexToRgba('#ece5a8', 0.5)).toBe('rgba(236, 229, 168, 0.5)');
|
||||||
|
expect(hexToRgba('#ece5a8', 0.1)).toBe('rgba(236, 229, 168, 0.1)');
|
||||||
|
expect(hexToRgba('#000000', 0)).toBe('rgba(0, 0, 0, 0)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle invalid strings', function () {
|
||||||
|
expect(hexToRgba('')).toBe('');
|
||||||
|
expect(hexToRgba('foobar')).toBe('foobar');
|
||||||
|
expect(hexToRgba('#fff')).toBe('#fff');
|
||||||
|
expect(hexToRgba('#')).toBe('#');
|
||||||
|
expect(hexToRgba('#fffffy')).toBe('#fffffy');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
17
resources/scripts/lib/helpers.ts
Normal file
17
resources/scripts/lib/helpers.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
/**
|
||||||
|
* Given a valid six character HEX color code, converts it into its associated
|
||||||
|
* RGBA value with a user controllable alpha channel.
|
||||||
|
*/
|
||||||
|
function hexToRgba (hex: string, alpha = 1): string {
|
||||||
|
// noinspection RegExpSimplifiable
|
||||||
|
if (!/#?([a-fA-F0-9]{2}){3}/.test(hex)) {
|
||||||
|
return hex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// noinspection RegExpSimplifiable
|
||||||
|
const [ r, g, b ] = hex.match(/[a-fA-F0-9]{2}/g)!.map(v => parseInt(v, 16));
|
||||||
|
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { hexToRgba };
|
30
resources/scripts/lib/objects.spec.ts
Normal file
30
resources/scripts/lib/objects.spec.ts
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import { isObject } from '@/lib/objects';
|
||||||
|
|
||||||
|
describe('@/lib/objects.ts', function () {
|
||||||
|
describe('isObject()', function () {
|
||||||
|
it('should return true for objects', function () {
|
||||||
|
expect(isObject({})).toBe(true);
|
||||||
|
expect(isObject({ foo: 123 })).toBe(true);
|
||||||
|
expect(isObject(Object.freeze({}))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for null', function () {
|
||||||
|
expect(isObject(null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
undefined,
|
||||||
|
123,
|
||||||
|
'foobar',
|
||||||
|
() => ({}),
|
||||||
|
Function,
|
||||||
|
String(123),
|
||||||
|
isObject,
|
||||||
|
() => null,
|
||||||
|
[],
|
||||||
|
[ 1, 2, 3 ],
|
||||||
|
])('should return false for %p', function (value) {
|
||||||
|
expect(isObject(value)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
29
resources/scripts/lib/objects.ts
Normal file
29
resources/scripts/lib/objects.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
/**
|
||||||
|
* Determines if the value provided to the function is an object type that
|
||||||
|
* is not null.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||||
|
function isObject (val: unknown): val is {} {
|
||||||
|
return typeof val === 'object' && val !== null && !Array.isArray(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if an object is truly empty by looking at the keys present
|
||||||
|
* and the prototype value.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||||
|
function isEmptyObject (val: {}): boolean {
|
||||||
|
return Object.keys(val).length === 0 && Object.getPrototypeOf(val) === Object.prototype;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A helper function for use in TypeScript that returns all of the keys
|
||||||
|
* for an object, but in a typed manner to make working with them a little
|
||||||
|
* easier.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||||
|
function getObjectKeys<T extends {}> (o: T): (keyof T)[] {
|
||||||
|
return Object.keys(o) as (keyof typeof o)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export { isObject, isEmptyObject, getObjectKeys };
|
14
resources/scripts/lib/strings.spec.ts
Normal file
14
resources/scripts/lib/strings.spec.ts
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import { capitalize } from '@/lib/strings';
|
||||||
|
|
||||||
|
describe('@/lib/strings.ts', function () {
|
||||||
|
describe('capitalize()', function () {
|
||||||
|
it('should capitalize a string', function () {
|
||||||
|
expect(capitalize('foo bar')).toBe('Foo bar');
|
||||||
|
expect(capitalize('FOOBAR')).toBe('Foobar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty strings', function () {
|
||||||
|
expect(capitalize('')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
5
resources/scripts/lib/strings.ts
Normal file
5
resources/scripts/lib/strings.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
function capitalize (value: string): string {
|
||||||
|
return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export { capitalize };
|
|
@ -3,7 +3,7 @@
|
||||||
* undefined, or empty string key values. This allows the parameters to be used for
|
* undefined, or empty string key values. This allows the parameters to be used for
|
||||||
* caching without having to account for all of the different data combinations.
|
* caching without having to account for all of the different data combinations.
|
||||||
*/
|
*/
|
||||||
import { isEmptyObject, isObject } from '@/helpers';
|
import { isEmptyObject, isObject } from '@/lib/objects';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||||
export default <T extends {}>(data: T): T => {
|
export default <T extends {}>(data: T): T => {
|
||||||
|
|
1
resources/scripts/setup-tests.ts
Normal file
1
resources/scripts/setup-tests.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
import '@testing-library/jest-dom';
|
Loading…
Reference in a new issue