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
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 { Server } from '@/api/server/getServer';
|
||||
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 GreyRowBox from '@/components/elements/GreyRowBox';
|
||||
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);
|
||||
}
|
||||
|
||||
const diskLimit = server.limits.disk !== 0 ? megabytesToHuman(server.limits.disk) : 'Unlimited';
|
||||
const memoryLimit = server.limits.memory !== 0 ? megabytesToHuman(server.limits.memory) : 'Unlimited';
|
||||
const diskLimit = server.limits.disk !== 0 ? bytesToString(mbToBytes(server.limits.disk)) : 'Unlimited';
|
||||
const memoryLimit = server.limits.memory !== 0 ? bytesToString(mbToBytes(server.limits.memory)) : 'Unlimited';
|
||||
const cpuLimit = server.limits.cpu !== 0 ? server.limits.cpu + ' %' : 'Unlimited';
|
||||
|
||||
return (
|
||||
|
@ -98,7 +98,7 @@ export default ({ server, className }: { server: Server; className?: string }) =
|
|||
{
|
||||
server.allocations.filter(alloc => alloc.isDefault).map(allocation => (
|
||||
<React.Fragment key={allocation.ip + allocation.port.toString()}>
|
||||
{allocation.alias || formatIp(allocation.ip)}:{allocation.port}
|
||||
{allocation.alias || ip(allocation.ip)}:{allocation.port}
|
||||
</React.Fragment>
|
||||
))
|
||||
}
|
||||
|
@ -146,7 +146,7 @@ export default ({ server, className }: { server: Server; className?: string }) =
|
|||
<div css={tw`flex justify-center`}>
|
||||
<Icon icon={faMemory} $alarm={alarms.memory}/>
|
||||
<IconDescription $alarm={alarms.memory}>
|
||||
{bytesToHuman(stats.memoryUsageInBytes)}
|
||||
{bytesToString(stats.memoryUsageInBytes)}
|
||||
</IconDescription>
|
||||
</div>
|
||||
<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`}>
|
||||
<Icon icon={faHdd} $alarm={alarms.disk}/>
|
||||
<IconDescription $alarm={alarms.disk}>
|
||||
{bytesToHuman(stats.diskUsageInBytes)}
|
||||
{bytesToString(stats.diskUsageInBytes)}
|
||||
</IconDescription>
|
||||
</div>
|
||||
<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 tw from 'twin.macro';
|
||||
import Input from '@/components/elements/Input';
|
||||
import { formatIp } from '@/helpers';
|
||||
import { ip } from '@/lib/formatters';
|
||||
|
||||
type Props = RequiredModalProps;
|
||||
|
||||
interface Values {
|
||||
|
@ -109,7 +110,7 @@ export default ({ ...props }: Props) => {
|
|||
<p css={tw`mt-1 text-xs text-neutral-400`}>
|
||||
{
|
||||
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>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import { FormikErrors, FormikTouched } from 'formik';
|
||||
import tw from 'twin.macro';
|
||||
import { capitalize } from '@/helpers';
|
||||
import { capitalize } from '@/lib/strings';
|
||||
|
||||
interface Props {
|
||||
errors: FormikErrors<any>;
|
||||
|
|
|
@ -8,7 +8,7 @@ import ActivityLogMetaButton from '@/components/elements/activity/ActivityLogMet
|
|||
import { TerminalIcon } from '@heroicons/react/solid';
|
||||
import classNames from 'classnames';
|
||||
import style from './style.module.css';
|
||||
import { isObject } from '@/helpers';
|
||||
import { isObject } from '@/lib/objects';
|
||||
import Avatar from '@/components/Avatar';
|
||||
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 { format, formatDistanceToNow } from 'date-fns';
|
||||
import Spinner from '@/components/elements/Spinner';
|
||||
import { bytesToHuman } from '@/helpers';
|
||||
import { bytesToString } from '@/lib/formatters';
|
||||
import Can from '@/components/elements/Can';
|
||||
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
|
||||
import BackupContextMenu from '@/components/server/backups/BackupContextMenu';
|
||||
|
@ -64,7 +64,7 @@ export default ({ backup, className }: Props) => {
|
|||
{backup.name}
|
||||
</p>
|
||||
{(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>
|
||||
<p css={tw`mt-1 md:mt-0 text-xs text-neutral-400 font-mono truncate`}>
|
||||
|
|
|
@ -8,7 +8,7 @@ import {
|
|||
faMicrochip,
|
||||
faWifi,
|
||||
} 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 { SocketEvent, SocketRequest } from '@/components/server/events';
|
||||
import UptimeDuration from '@/components/server/UptimeDuration';
|
||||
|
@ -41,7 +41,7 @@ const ServerDetailsBlock = ({ className }: { className?: string }) => {
|
|||
const allocation = ServerContext.useStoreState(state => {
|
||||
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(() => {
|
||||
|
@ -106,14 +106,14 @@ const ServerDetailsBlock = ({ className }: { className?: string }) => {
|
|||
title={'Memory'}
|
||||
color={getBackgroundColor(stats.memory / 1024, limits.memory * 1024)}
|
||||
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.'
|
||||
}
|
||||
>
|
||||
{status === 'offline' ?
|
||||
<span className={'text-gray-400'}>Offline</span>
|
||||
:
|
||||
bytesToHuman(stats.memory)
|
||||
bytesToString(stats.memory)
|
||||
}
|
||||
</StatBlock>
|
||||
<StatBlock
|
||||
|
@ -121,11 +121,11 @@ const ServerDetailsBlock = ({ className }: { className?: string }) => {
|
|||
title={'Disk'}
|
||||
color={getBackgroundColor(stats.disk / 1024, limits.disk * 1024)}
|
||||
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.'
|
||||
}
|
||||
>
|
||||
{bytesToHuman(stats.disk)}
|
||||
{bytesToString(stats.disk)}
|
||||
</StatBlock>
|
||||
<StatBlock
|
||||
icon={faCloudDownloadAlt}
|
||||
|
@ -135,7 +135,7 @@ const ServerDetailsBlock = ({ className }: { className?: string }) => {
|
|||
{status === 'offline' ?
|
||||
<span className={'text-gray-400'}>Offline</span>
|
||||
:
|
||||
bytesToHuman(stats.tx)
|
||||
bytesToString(stats.tx)
|
||||
}
|
||||
</StatBlock>
|
||||
<StatBlock
|
||||
|
@ -146,7 +146,7 @@ const ServerDetailsBlock = ({ className }: { className?: string }) => {
|
|||
{status === 'offline' ?
|
||||
<span className={'text-gray-400'}>Offline</span>
|
||||
:
|
||||
bytesToHuman(stats.rx)
|
||||
bytesToString(stats.rx)
|
||||
}
|
||||
</StatBlock>
|
||||
</div>
|
||||
|
|
|
@ -4,7 +4,8 @@ import { SocketEvent } from '@/components/server/events';
|
|||
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
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 { theme } from 'twin.macro';
|
||||
import ChartBlock from '@/components/server/console/ChartBlock';
|
||||
|
@ -24,7 +25,7 @@ export default () => {
|
|||
y: {
|
||||
ticks: {
|
||||
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,
|
||||
label: !index ? 'Network In' : 'Network Out',
|
||||
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 { deepmerge, deepmergeCustom } from 'deepmerge-ts';
|
||||
import { theme } from 'twin.macro';
|
||||
import { toRGBA } from '@/helpers';
|
||||
import { hexToRgba } from '@/lib/helpers';
|
||||
|
||||
ChartJS.register(LineElement, PointElement, Filler, LinearScale);
|
||||
|
||||
|
@ -86,7 +86,7 @@ function getEmptyData (label: string, sets = 1, callback?: ChartDatasetCallback
|
|||
label,
|
||||
data: Array(20).fill(0),
|
||||
borderColor: theme('colors.cyan.400'),
|
||||
backgroundColor: toRGBA(theme('colors.cyan.700'), 0.5),
|
||||
backgroundColor: hexToRgba(theme('colors.cyan.700'), 0.5),
|
||||
}, index)),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import features from './index';
|
||||
import { getObjectKeys } from '@/helpers';
|
||||
import { getObjectKeys } from '@/lib/objects';
|
||||
|
||||
type ListItems = [ string, React.ComponentType ][];
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
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 React, { memo } from 'react';
|
||||
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 { usePermissions } from '@/plugins/usePermissions';
|
||||
import { join } from 'path';
|
||||
import { bytesToString } from '@/lib/formatters';
|
||||
|
||||
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`};
|
||||
|
@ -61,7 +62,7 @@ const FileObjectRow = ({ file }: { file: FileObject }) => (
|
|||
</div>
|
||||
{file.isFile &&
|
||||
<div css={tw`w-1/6 text-right mr-4 hidden sm:block`}>
|
||||
{bytesToHuman(file.size)}
|
||||
{bytesToString(file.size)}
|
||||
</div>
|
||||
}
|
||||
<div
|
||||
|
|
|
@ -18,7 +18,7 @@ import CopyOnClick from '@/components/elements/CopyOnClick';
|
|||
import DeleteAllocationButton from '@/components/server/network/DeleteAllocationButton';
|
||||
import setPrimaryServerAllocation from '@/api/server/network/setPrimaryServerAllocation';
|
||||
import getServerAllocations from '@/api/swr/getServerAllocations';
|
||||
import { formatIp } from '@/helpers';
|
||||
import { ip } from '@/lib/formatters';
|
||||
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`}`;
|
||||
|
@ -67,7 +67,7 @@ const AllocationRow = ({ allocation }: Props) => {
|
|||
<div className={'mr-4 flex-1 md:w-40'}>
|
||||
{allocation.alias ?
|
||||
<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>
|
||||
</div>
|
||||
<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 isEqual from 'react-fast-compare';
|
||||
import CopyOnClick from '@/components/elements/CopyOnClick';
|
||||
import { formatIp } from '@/helpers';
|
||||
import { ip } from '@/lib/formatters';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
|
||||
export default () => {
|
||||
|
@ -31,10 +31,10 @@ export default () => {
|
|||
<TitledGreyBox title={'SFTP Details'} css={tw`mb-6 md:mb-10`}>
|
||||
<div>
|
||||
<Label>Server Address</Label>
|
||||
<CopyOnClick text={`sftp://${formatIp(sftp.ip)}:${sftp.port}`}>
|
||||
<CopyOnClick text={`sftp://${ip(sftp.ip)}:${sftp.port}`}>
|
||||
<Input
|
||||
type={'text'}
|
||||
value={`sftp://${formatIp(sftp.ip)}:${sftp.port}`}
|
||||
value={`sftp://${ip(sftp.ip)}:${sftp.port}`}
|
||||
readOnly
|
||||
/>
|
||||
</CopyOnClick>
|
||||
|
@ -58,7 +58,7 @@ export default () => {
|
|||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</a>
|
||||
</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 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 {
|
||||
const m = parseInt(mode, 8);
|
||||
|
||||
|
@ -61,23 +44,3 @@ export function encodePathSegments (path: string): string {
|
|||
export function hashToPath (hash: string): string {
|
||||
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
|
||||
* 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
|
||||
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…
Add table
Add a link
Reference in a new issue