mirror of
https://github.com/directus/directus.git
synced 2026-02-11 09:55:06 -05:00
Add app unit tests using vitest (#14583)
* Install / bootstrap vitest * Install c8 * Add tests for add-query-to-path * Don't set global maplibre token on individual style This feels wrong to me. We have a single global access token that should be used for these things. Overriding that with a token that's found for individual styles on top of the same global object in order of configured maps feels weird, as the latter tokens will override the earlier ones. Needs more research though * Install testing libraries * Use happy-dom as env in vitest * Enable ts checking in tests * Remove unused jest config * Organize store imports * Remove types from TSDoc in add-query-to-path * Improve check in add-related-primary-key-to-fields * Add reusable stub for anything touching nanoid * Add tests for add-related-primary-key-to-fields * Move adjust date to shared * Remove arraysAreEqual util in favor of relying on lodash * Fix add-related-primary-key-to-fields test * Add test coverage for capitlize-first * Add TSDoc/tests for extract-field-from-function * Add test coverage for formatFieldFunction * Add test coverage for format-filesize * Add test coverage for get-groups * Add tests for get-root-path * cleanup imports * Move tests to live next to source files * Add tests for user-name * Update type to match function behavior * Add test coverage for point-on-line * Add tests for is-empty * Add test coverage for is-hex * Remove getSetting util Bit pointless to have a util function to just read a value from a store * Add test coverage for get-related-collection * Add test coverage for get-theme * Add test coverage for get-with-arrays * Add test coverage for hide-drag-image * Add test coverage for is-permission-empty * Remove unused import * Add test for jwt-payload * Add snapshot rendering test for v-sheet * Add whitespace * Rename __test_utils__ -> __utils__ * Add composable test * Update app/tsconfig.json Co-authored-by: Pascal Jufer <pascal-jufer@bluewin.ch> Co-authored-by: Pascal Jufer <pascal-jufer@bluewin.ch>
This commit is contained in:
20
app/src/utils/add-query-to-path.test.ts
Normal file
20
app/src/utils/add-query-to-path.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import { addQueryToPath } from '@/utils/add-query-to-path';
|
||||
|
||||
test('Adds query parameters to given path', () => {
|
||||
const output = addQueryToPath('/path/to/something', {
|
||||
test: 'hello',
|
||||
another: 'world',
|
||||
});
|
||||
|
||||
expect(output).toBe('/path/to/something?test=hello&another=world');
|
||||
});
|
||||
|
||||
test('Keeps existing query parameters intact', () => {
|
||||
const output = addQueryToPath('/path/to/something?existing=param', {
|
||||
test: 'hello',
|
||||
another: 'world',
|
||||
});
|
||||
|
||||
expect(output).toBe('/path/to/something?existing=param&test=hello&another=world');
|
||||
});
|
||||
@@ -1,3 +1,10 @@
|
||||
/**
|
||||
* Add a object of strings to a given path as query parameters. It will keep whatever query
|
||||
* parameters already existed on the path
|
||||
*
|
||||
* @param path - URL path to add query parameters too
|
||||
* @param query - Object style query parameters to add
|
||||
*/
|
||||
export function addQueryToPath(path: string, query: Record<string, string>): string {
|
||||
const queryParams = new URLSearchParams(path.split('?')[1] || '');
|
||||
|
||||
|
||||
57
app/src/utils/add-related-primary-key-to-fields.test.ts
Normal file
57
app/src/utils/add-related-primary-key-to-fields.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { test, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
|
||||
import { cryptoStub } from '@/__utils__/crypto';
|
||||
vi.stubGlobal('crypto', cryptoStub);
|
||||
|
||||
import { useFieldsStore } from '@/stores/fields';
|
||||
import { addRelatedPrimaryKeyToFields } from '@/utils/add-related-primary-key-to-fields';
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(
|
||||
createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('Returns empty array when empty array is passed', () => {
|
||||
const output = addRelatedPrimaryKeyToFields('articles', []);
|
||||
expect(output).toEqual([]);
|
||||
});
|
||||
|
||||
test('Returns array as-is if no relational fields are passed', () => {
|
||||
const output = addRelatedPrimaryKeyToFields('articles', ['id', 'title']);
|
||||
expect(output).toEqual(['id', 'title']);
|
||||
});
|
||||
|
||||
test('Retrieves related primary key from store and adds to array', () => {
|
||||
const store = useFieldsStore();
|
||||
(store.getField as Mock).mockReturnValueOnce({ collection: 'test' });
|
||||
(store.getPrimaryKeyFieldForCollection as Mock).mockReturnValueOnce({ field: 'test_pk' });
|
||||
|
||||
const output = addRelatedPrimaryKeyToFields('articles', ['id', 'title', 'author.name']);
|
||||
expect(output).toEqual(['id', 'title', 'author.name', 'author.test_pk']);
|
||||
});
|
||||
|
||||
test('Ignores adding primary key related collection cannot be found', () => {
|
||||
const store = useFieldsStore();
|
||||
(store.getField as Mock).mockReturnValueOnce(undefined);
|
||||
|
||||
const output = addRelatedPrimaryKeyToFields('articles', ['id', 'title', 'author.name']);
|
||||
expect(output).toEqual(['id', 'title', 'author.name']);
|
||||
});
|
||||
|
||||
test('Ignores adding primary key if it already exists', () => {
|
||||
const store = useFieldsStore();
|
||||
(store.getField as Mock).mockReturnValueOnce({ collection: 'test' });
|
||||
(store.getPrimaryKeyFieldForCollection as Mock).mockReturnValueOnce({ field: 'test_pk' });
|
||||
|
||||
const output = addRelatedPrimaryKeyToFields('articles', ['id', 'title', 'author.name', 'author.title']);
|
||||
expect(output).toEqual(['id', 'title', 'author.name', 'author.test_pk', 'author.title']);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -5,6 +5,9 @@ import { useFieldsStore } from '@/stores/fields';
|
||||
* Useful for cases where you need to fetch a single piece of nested relational data, but also need
|
||||
* access to its primary key.
|
||||
*
|
||||
* @param currentCollection - Current root collection
|
||||
* @param fields - Array of (dot-notation) fields you want to augment
|
||||
*
|
||||
* @example
|
||||
* const collection = 'articles';
|
||||
* const fields = ['title', 'user.name'];
|
||||
@@ -26,12 +29,14 @@ export function addRelatedPrimaryKeyToFields(currentCollection: string, fields:
|
||||
const fieldParts = fieldName.split('.');
|
||||
|
||||
const field = fieldsStore.getField(currentCollection, fieldName);
|
||||
const primaryKeyField = fieldsStore.getPrimaryKeyFieldForCollection(field?.collection ?? '');
|
||||
if (!field) continue;
|
||||
|
||||
const includeField = primaryKeyField && fieldParts.slice(0, -1).concat(primaryKeyField.field).join('.');
|
||||
const primaryKeyField = fieldsStore.getPrimaryKeyFieldForCollection(field.collection);
|
||||
|
||||
if (includeField && !sanitizedFields.includes(includeField)) {
|
||||
sanitizedFields.push(includeField);
|
||||
const fieldToInclude = primaryKeyField && fieldParts.slice(0, -1).concat(primaryKeyField.field).join('.');
|
||||
|
||||
if (fieldToInclude && !sanitizedFields.includes(fieldToInclude)) {
|
||||
sanitizedFields.push(fieldToInclude);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
/**
|
||||
* @TODO this has to go in a shared package. Another one copy pasted from the API
|
||||
*/
|
||||
|
||||
import {
|
||||
addYears,
|
||||
subWeeks,
|
||||
subYears,
|
||||
addWeeks,
|
||||
subMonths,
|
||||
addMonths,
|
||||
subDays,
|
||||
addDays,
|
||||
subHours,
|
||||
addHours,
|
||||
subMinutes,
|
||||
addMinutes,
|
||||
subSeconds,
|
||||
addSeconds,
|
||||
addMilliseconds,
|
||||
subMilliseconds,
|
||||
} from 'date-fns';
|
||||
import { clone } from 'lodash';
|
||||
|
||||
/**
|
||||
* Adjust a given date by a given change in duration. The adjustment value uses the exact same syntax
|
||||
* and logic as Vercel's `ms`.
|
||||
*
|
||||
* The conversion is lifted straight from `ms`.
|
||||
*/
|
||||
export function adjustDate(date: Date, adjustment: string): Date | undefined {
|
||||
date = clone(date);
|
||||
|
||||
const subtract = adjustment.startsWith('-');
|
||||
|
||||
if (subtract || adjustment.startsWith('+')) {
|
||||
adjustment = adjustment.substring(1);
|
||||
}
|
||||
|
||||
const match =
|
||||
/^(-?(?:\d+)?\.?\d+) *(milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|months?|mth|mo|years?|yrs?|y)?$/i.exec(
|
||||
adjustment
|
||||
);
|
||||
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
|
||||
const amount = parseFloat(match[1]);
|
||||
const type = (match[2] || 'days').toLowerCase();
|
||||
|
||||
switch (type) {
|
||||
case 'years':
|
||||
case 'year':
|
||||
case 'yrs':
|
||||
case 'yr':
|
||||
case 'y':
|
||||
return subtract ? subYears(date, amount) : addYears(date, amount);
|
||||
case 'months':
|
||||
case 'month':
|
||||
case 'mth':
|
||||
case 'mo':
|
||||
return subtract ? subMonths(date, amount) : addMonths(date, amount);
|
||||
case 'weeks':
|
||||
case 'week':
|
||||
case 'w':
|
||||
return subtract ? subWeeks(date, amount) : addWeeks(date, amount);
|
||||
case 'days':
|
||||
case 'day':
|
||||
case 'd':
|
||||
return subtract ? subDays(date, amount) : addDays(date, amount);
|
||||
case 'hours':
|
||||
case 'hour':
|
||||
case 'hrs':
|
||||
case 'hr':
|
||||
case 'h':
|
||||
return subtract ? subHours(date, amount) : addHours(date, amount);
|
||||
case 'minutes':
|
||||
case 'minute':
|
||||
case 'mins':
|
||||
case 'min':
|
||||
case 'm':
|
||||
return subtract ? subMinutes(date, amount) : addMinutes(date, amount);
|
||||
case 'seconds':
|
||||
case 'second':
|
||||
case 'secs':
|
||||
case 'sec':
|
||||
case 's':
|
||||
return subtract ? subSeconds(date, amount) : addSeconds(date, amount);
|
||||
case 'milliseconds':
|
||||
case 'millisecond':
|
||||
case 'msecs':
|
||||
case 'msec':
|
||||
case 'ms':
|
||||
return subtract ? subMilliseconds(date, amount) : addMilliseconds(date, amount);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
export function arraysAreEqual(a1: readonly (string | number)[], a2: readonly (string | number)[]): boolean {
|
||||
const superSet: {
|
||||
[key: string]: number;
|
||||
} = {};
|
||||
|
||||
for (let i = 0; i < a1.length; i++) {
|
||||
const e = a1[i] + typeof a1[i];
|
||||
superSet[e] = 1;
|
||||
}
|
||||
|
||||
for (let i = 0; i < a2.length; i++) {
|
||||
const e = a2[i] + typeof a2[i];
|
||||
if (!superSet[e]) {
|
||||
return false;
|
||||
}
|
||||
superSet[e] = 2;
|
||||
}
|
||||
|
||||
for (const e in superSet) {
|
||||
if (superSet[e] === 1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
11
app/src/utils/capitalize-first.test.ts
Normal file
11
app/src/utils/capitalize-first.test.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { test, expect } from 'vitest';
|
||||
|
||||
import { capitalizeFirst } from '@/utils/capitalize-first';
|
||||
|
||||
test('Capitalizes first character', () => {
|
||||
expect(capitalizeFirst('test')).toBe('Test');
|
||||
});
|
||||
|
||||
test('Does not explode on empty strings', () => {
|
||||
expect(capitalizeFirst('')).toBe('');
|
||||
});
|
||||
@@ -1,3 +1,15 @@
|
||||
/**
|
||||
* Capitalizes the first character in a given string
|
||||
*
|
||||
* @param str - String to capitalize
|
||||
* @returns same string with first character capitalized
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* capitalizeFirst('test');
|
||||
* // => 'Test'
|
||||
* ```
|
||||
*/
|
||||
export function capitalizeFirst(str: string): string {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
|
||||
11
app/src/utils/extract-field-from-function.test.ts
Normal file
11
app/src/utils/extract-field-from-function.test.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { test, expect } from 'vitest';
|
||||
|
||||
import { extractFieldFromFunction } from '@/utils/extract-field-from-function';
|
||||
|
||||
test('Returns original field if no function is given', () => {
|
||||
expect(extractFieldFromFunction('title')).toEqual({ fn: null, field: 'title' });
|
||||
});
|
||||
|
||||
test('Returns function extracted', () => {
|
||||
expect(extractFieldFromFunction('year(date_created)')).toEqual({ fn: 'year', field: 'date_created' });
|
||||
});
|
||||
@@ -1,6 +1,18 @@
|
||||
import { REGEX_BETWEEN_PARENS } from '@directus/shared/constants';
|
||||
import { FieldFunction } from '@directus/shared/types';
|
||||
|
||||
/**
|
||||
* Extracts the function and field name of a field wrapped in a function
|
||||
*
|
||||
* @param fieldKey - Field in function, for example `year(date_created)`
|
||||
* @return Object of function name and field key
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* extractFieldFromFunction('year(date_created)');
|
||||
* // => { fn: 'year', field: 'date_created' }
|
||||
* ```
|
||||
*/
|
||||
export function extractFieldFromFunction(fieldKey: string): { fn: FieldFunction | null; field: string } {
|
||||
let functionName;
|
||||
|
||||
|
||||
123
app/src/utils/flatten-field-groups.test.ts
Normal file
123
app/src/utils/flatten-field-groups.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { FieldNode } from '../../src/composables/use-field-tree';
|
||||
import { flattenFieldGroups } from '../../src/utils/flatten-field-groups';
|
||||
|
||||
describe('utils/flatten-field-groups', () => {
|
||||
it('Returns the original tree when no groups are present', () => {
|
||||
const TreeWithoutGroups: FieldNode[] = [
|
||||
{ name: 'ID', field: 'id', collection: 'test', key: 'id', path: 'id', type: 'integer' },
|
||||
{ name: 'Test Field', field: 'test', collection: 'test', key: 'test', path: 'test', type: 'string' },
|
||||
];
|
||||
expect(flattenFieldGroups(TreeWithoutGroups)).toEqual(TreeWithoutGroups);
|
||||
});
|
||||
it('Returns a tree without groups', () => {
|
||||
const TreeWithGroups: FieldNode[] = [
|
||||
{ name: 'ID', field: 'id', collection: 'test', key: 'id', path: 'id', type: 'integer' },
|
||||
{
|
||||
name: 'Group',
|
||||
field: 'group',
|
||||
collection: 'test',
|
||||
key: '',
|
||||
path: 'group',
|
||||
group: true,
|
||||
type: 'alias',
|
||||
children: [
|
||||
{
|
||||
name: 'Nested Field',
|
||||
field: 'nested_field',
|
||||
collection: 'test',
|
||||
key: 'nested_field',
|
||||
path: 'group.nested_field',
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const TreeWithoutGroups: FieldNode[] = [
|
||||
{ name: 'ID', field: 'id', collection: 'test', key: 'id', path: 'id', type: 'integer' },
|
||||
{
|
||||
name: 'Nested Field',
|
||||
field: 'nested_field',
|
||||
collection: 'test',
|
||||
key: 'nested_field',
|
||||
path: 'group.nested_field',
|
||||
type: 'string',
|
||||
},
|
||||
];
|
||||
expect(flattenFieldGroups(TreeWithGroups)).toEqual(TreeWithoutGroups);
|
||||
});
|
||||
it('Returns a tree without deeply nested groups', () => {
|
||||
const TreeWithNestedGroups: FieldNode[] = [
|
||||
{ name: 'ID', field: 'id', collection: 'test', key: 'id', path: 'id', type: 'integer' },
|
||||
{
|
||||
name: 'Group',
|
||||
field: 'group1',
|
||||
collection: 'test',
|
||||
key: '',
|
||||
path: 'group1',
|
||||
group: true,
|
||||
type: 'alias',
|
||||
children: [
|
||||
{
|
||||
name: 'Group',
|
||||
field: 'group2',
|
||||
collection: 'test',
|
||||
key: '',
|
||||
path: 'group2',
|
||||
group: true,
|
||||
type: 'alias',
|
||||
children: [
|
||||
{
|
||||
name: 'Nested Field 1',
|
||||
field: 'nested_field_1',
|
||||
collection: 'test',
|
||||
key: 'nested_field',
|
||||
path: 'group.nested_field_1',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
name: 'Group',
|
||||
field: 'group3',
|
||||
collection: 'test',
|
||||
key: '',
|
||||
path: 'group3',
|
||||
group: true,
|
||||
type: 'alias',
|
||||
children: [
|
||||
{
|
||||
name: 'Nested Field 2',
|
||||
field: 'nested_field_2',
|
||||
collection: 'test',
|
||||
key: 'nested_field',
|
||||
path: 'group.nested_field_2',
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const TreeWithoutGroups: FieldNode[] = [
|
||||
{ name: 'ID', field: 'id', collection: 'test', key: 'id', path: 'id', type: 'integer' },
|
||||
{
|
||||
name: 'Nested Field 1',
|
||||
field: 'nested_field_1',
|
||||
collection: 'test',
|
||||
key: 'nested_field',
|
||||
path: 'group.nested_field_1',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
name: 'Nested Field 2',
|
||||
field: 'nested_field_2',
|
||||
collection: 'test',
|
||||
key: 'nested_field',
|
||||
path: 'group.nested_field_2',
|
||||
type: 'string',
|
||||
},
|
||||
];
|
||||
expect(flattenFieldGroups(TreeWithNestedGroups)).toEqual(TreeWithoutGroups);
|
||||
});
|
||||
});
|
||||
52
app/src/utils/format-field-function.test.ts
Normal file
52
app/src/utils/format-field-function.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { test, expect, vi, beforeEach, Mock } from 'vitest';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
|
||||
import { cryptoStub } from '@/__utils__/crypto';
|
||||
vi.stubGlobal('crypto', cryptoStub);
|
||||
|
||||
import { useFieldsStore } from '@/stores/fields';
|
||||
import { formatFieldFunction } from '@/utils/format-field-function';
|
||||
|
||||
vi.mock('@/lang', () => {
|
||||
return {
|
||||
i18n: createI18n({
|
||||
legacy: false,
|
||||
locale: 'en-US',
|
||||
messages: {
|
||||
'en-US': {
|
||||
functions: {
|
||||
year: 'Year',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(
|
||||
createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('Returns original field key if no function is passed, and field cannot be found', () => {
|
||||
expect(formatFieldFunction('articles', 'date_created')).toBe('date_created');
|
||||
});
|
||||
|
||||
test('Returns translated field key if no function is passed', () => {
|
||||
(useFieldsStore().getField as Mock).mockReturnValueOnce({ name: 'Date Created' });
|
||||
expect(formatFieldFunction('articles', 'date_created')).toBe('Date Created');
|
||||
});
|
||||
|
||||
test('Returns translated function with field key if field cannot be found in store', () => {
|
||||
expect(formatFieldFunction('articles', 'year(date_created)')).toBe('Year (date_created)');
|
||||
});
|
||||
|
||||
test('Returns translated field key and function', () => {
|
||||
(useFieldsStore().getField as Mock).mockReturnValueOnce({ name: 'Date Created' });
|
||||
expect(formatFieldFunction('articles', 'year(date_created)')).toBe('Year (Date Created)');
|
||||
});
|
||||
@@ -1,16 +1,26 @@
|
||||
import { useFieldsStore } from '@/stores/fields';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { i18n } from '@/lang';
|
||||
import { extractFieldFromFunction } from '@/utils/extract-field-from-function';
|
||||
|
||||
/**
|
||||
* Renders a function-wrapped field as the formatted function name and translated field key
|
||||
*
|
||||
* @param collection - Name of the collection the field lives in
|
||||
* @param fieldKey - Key of the field to format (including function)
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* formatFieldFunction('articles', 'year(date_created)');
|
||||
* // => "Year (Date Created)"
|
||||
* ```
|
||||
*/
|
||||
export function formatFieldFunction(collection: string, fieldKey: string) {
|
||||
const { t } = useI18n();
|
||||
|
||||
const { field, fn } = extractFieldFromFunction(fieldKey);
|
||||
|
||||
const fieldName = useFieldsStore().getField(collection, field)?.name ?? field;
|
||||
|
||||
if (fn) {
|
||||
return t(`functions.${fn}`) + ` (${fieldName})`;
|
||||
return i18n.global.t(`functions.${fn}`) + ` (${fieldName})`;
|
||||
}
|
||||
|
||||
return fieldName;
|
||||
|
||||
55
app/src/utils/format-filesize.test.ts
Normal file
55
app/src/utils/format-filesize.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { test, expect } from 'vitest';
|
||||
|
||||
import { formatFilesize } from '@/utils/format-filesize';
|
||||
|
||||
test('Returns -- for 0 bytes', () => {
|
||||
expect(formatFilesize(0)).toBe('--');
|
||||
});
|
||||
|
||||
test('Returns n B bytes below threshold', () => {
|
||||
expect(formatFilesize(50)).toBe('50 B');
|
||||
expect(formatFilesize(50, false)).toBe('50 B');
|
||||
|
||||
expect(formatFilesize(1100)).not.toBe('1100 B');
|
||||
expect(formatFilesize(1100, false)).not.toBe('1100 B');
|
||||
});
|
||||
|
||||
test('Returns the correct unit for given bytes (decimal)', () => {
|
||||
const testCases: [number, string][] = [
|
||||
[50, '50 B'],
|
||||
[500, '500 B'],
|
||||
[1000, '1.0 kB'],
|
||||
[1500, '1.5 kB'],
|
||||
[15e5, '1.5 MB'],
|
||||
[15e8, '1.5 GB'],
|
||||
[15e11, '1.5 TB'],
|
||||
[15e14, '1.5 PB'],
|
||||
[15e17, '1.5 EB'],
|
||||
[15e20, '1.5 ZB'],
|
||||
[15e23, '1.5 YB'],
|
||||
];
|
||||
|
||||
for (const [input, output] of testCases) {
|
||||
expect(formatFilesize(input)).toBe(output);
|
||||
}
|
||||
});
|
||||
|
||||
test('Returns the correct unit for given bytes (base 2)', () => {
|
||||
const testCases: [number, string][] = [
|
||||
[50, '50 B'],
|
||||
[500, '500 B'],
|
||||
[1000, '1000 B'],
|
||||
[1500, '1.5 KiB'],
|
||||
[15e5, '1.4 MiB'],
|
||||
[15e8, '1.4 GiB'],
|
||||
[15e11, '1.4 TiB'],
|
||||
[15e14, '1.3 PiB'],
|
||||
[15e17, '1.3 EiB'],
|
||||
[15e20, '1.3 ZiB'],
|
||||
[15e23, '1.2 YiB'],
|
||||
];
|
||||
|
||||
for (const [input, output] of testCases) {
|
||||
expect(formatFilesize(input, false)).toBe(output);
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,15 @@
|
||||
/**
|
||||
* Turns a bytes number into a human readable filesize
|
||||
*
|
||||
* @param bytes - Number of bytes
|
||||
* @param decimal - Whether you want power of ten (default) or power of two (f.e. MB vs MiB)
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* formatFilesize(12500);
|
||||
* // => "12.5 kB"
|
||||
* ```
|
||||
*/
|
||||
export function formatFilesize(bytes = 0, decimal = true): string {
|
||||
const threshold = decimal ? 1000 : 1024;
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Style, RasterSource } from 'maplibre-gl';
|
||||
import { getSetting } from '@/utils/get-setting';
|
||||
import maplibre from 'maplibre-gl';
|
||||
import { getTheme } from '@/utils/get-theme';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
|
||||
export type BasemapSource = {
|
||||
name: string;
|
||||
@@ -25,16 +24,16 @@ const baseStyle: Style = {
|
||||
};
|
||||
|
||||
export function getBasemapSources(): BasemapSource[] {
|
||||
if (getSetting('mapbox_key')) {
|
||||
return [getDefaultMapboxBasemap(), defaultBasemap, ...(getSetting('basemaps') || [])];
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
if (settingsStore.settings?.mapbox_key) {
|
||||
return [getDefaultMapboxBasemap(), defaultBasemap, ...(settingsStore.settings?.basemaps || [])];
|
||||
}
|
||||
|
||||
return [defaultBasemap, ...(getSetting('basemaps') || [])];
|
||||
return [defaultBasemap, ...(settingsStore.settings?.basemaps || [])];
|
||||
}
|
||||
|
||||
export function getStyleFromBasemapSource(basemap: BasemapSource): Style | string {
|
||||
setMapboxAccessToken(basemap.url);
|
||||
|
||||
if (basemap.type == 'style') {
|
||||
return basemap.url;
|
||||
} else {
|
||||
@@ -89,20 +88,6 @@ function expandUrl(url: string): string[] {
|
||||
return urls;
|
||||
}
|
||||
|
||||
function setMapboxAccessToken(styleURL: string): void {
|
||||
styleURL = styleURL.replace(/^mapbox:\//, 'https://api.mapbox.com/styles/v1');
|
||||
|
||||
try {
|
||||
const url = new URL(styleURL);
|
||||
if (url.host == 'api.mapbox.com') {
|
||||
const token = url.searchParams.get('access_token');
|
||||
if (token) maplibre.accessToken = token;
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultMapboxBasemap(): BasemapSource {
|
||||
const defaultMapboxBasemap: BasemapSource = {
|
||||
name: 'Mapbox',
|
||||
|
||||
43
app/src/utils/get-groups.test.ts
Normal file
43
app/src/utils/get-groups.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { test, expect } from 'vitest';
|
||||
|
||||
import { getGroups } from '@/utils/get-groups';
|
||||
|
||||
test('Returns correct groupings for given precision', () => {
|
||||
const testCases: ['year' | 'month' | 'week' | 'day' | 'hour' | 'minute' | 'second', string, string[]][] = [
|
||||
['year', 'date_created', ['year(date_created)']],
|
||||
['month', 'date_created', ['year(date_created)', 'month(date_created)']],
|
||||
['week', 'date_created', ['year(date_created)', 'month(date_created)', 'week(date_created)']],
|
||||
['day', 'date_created', ['year(date_created)', 'month(date_created)', 'day(date_created)']],
|
||||
['hour', 'date_created', ['year(date_created)', 'month(date_created)', 'day(date_created)', 'hour(date_created)']],
|
||||
[
|
||||
'minute',
|
||||
'date_created',
|
||||
['year(date_created)', 'month(date_created)', 'day(date_created)', 'hour(date_created)', 'minute(date_created)'],
|
||||
],
|
||||
[
|
||||
'second',
|
||||
'date_created',
|
||||
[
|
||||
'year(date_created)',
|
||||
'month(date_created)',
|
||||
'day(date_created)',
|
||||
'hour(date_created)',
|
||||
'minute(date_created)',
|
||||
'second(date_created)',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
for (const [precision, dateField, output] of testCases) {
|
||||
expect(getGroups(precision, dateField)).toEqual(output);
|
||||
}
|
||||
});
|
||||
|
||||
test('Defaults to hour if precision is undefined', () => {
|
||||
expect(getGroups(undefined, 'date_created')).toEqual([
|
||||
'year(date_created)',
|
||||
'month(date_created)',
|
||||
'day(date_created)',
|
||||
'hour(date_created)',
|
||||
]);
|
||||
});
|
||||
@@ -1,7 +1,22 @@
|
||||
export function getGroups(precision: string, dateField: string) {
|
||||
/**
|
||||
* Return array of formatted field groups required to fetch a data set in a given precision
|
||||
*
|
||||
* @param precision - What precision you want to group by
|
||||
* @param dateField - Field you're grouping on
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* getGroups('day', 'date_created');
|
||||
* // => ['year(date_created)', 'month(date_created)', 'day(date_created)']
|
||||
* ```
|
||||
*/
|
||||
export function getGroups(
|
||||
precision: 'year' | 'month' | 'week' | 'day' | 'hour' | 'minute' | 'second' | undefined,
|
||||
dateField: string
|
||||
) {
|
||||
let groups: string[] = [];
|
||||
|
||||
switch (precision || 'hour') {
|
||||
switch (precision ?? 'hour') {
|
||||
case 'year':
|
||||
groups = ['year'];
|
||||
break;
|
||||
@@ -23,9 +38,6 @@ export function getGroups(precision: string, dateField: string) {
|
||||
case 'second':
|
||||
groups = ['year', 'month', 'day', 'hour', 'minute', 'second'];
|
||||
break;
|
||||
default:
|
||||
groups = ['year', 'month', 'day', 'hour'];
|
||||
break;
|
||||
}
|
||||
|
||||
return groups.map((datePart) => `${datePart}(${dateField})`);
|
||||
|
||||
60
app/src/utils/get-local-type.ts
Normal file
60
app/src/utils/get-local-type.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useFieldsStore } from '@/stores/fields';
|
||||
import { useRelationsStore } from '@/stores/relations';
|
||||
import { LocalType, Relation } from '@directus/shared/types';
|
||||
|
||||
export function getLocalTypeForField(collection: string, field: string): LocalType | null {
|
||||
const fieldsStore = useFieldsStore();
|
||||
const relationsStore = useRelationsStore();
|
||||
|
||||
const fieldInfo = fieldsStore.getField(collection, field);
|
||||
const relations: Relation[] = relationsStore.getRelationsForField(collection, field);
|
||||
|
||||
if (!fieldInfo) return null;
|
||||
|
||||
if (relations.length === 0) {
|
||||
if (fieldInfo.type === 'alias') {
|
||||
if (fieldInfo.meta?.special?.includes('group')) {
|
||||
return 'group';
|
||||
}
|
||||
|
||||
return 'presentation';
|
||||
}
|
||||
|
||||
return 'standard';
|
||||
}
|
||||
|
||||
if (relations.length === 1) {
|
||||
const relation = relations[0];
|
||||
if (relation.related_collection === 'directus_files') return 'file';
|
||||
if (relation.collection === collection && relation.field === field) return 'm2o';
|
||||
return 'o2m';
|
||||
}
|
||||
|
||||
if (relations.length === 2) {
|
||||
if ((fieldInfo.meta?.special || []).includes('translations')) {
|
||||
return 'translations';
|
||||
}
|
||||
if ((fieldInfo.meta?.special || []).includes('m2a')) {
|
||||
return 'm2a';
|
||||
}
|
||||
|
||||
const relationForCurrent = relations.find((relation: Relation) => {
|
||||
return (
|
||||
(relation.collection === collection && relation.field === field) ||
|
||||
(relation.related_collection === collection && relation.meta?.one_field === field)
|
||||
);
|
||||
});
|
||||
|
||||
if (relationForCurrent?.collection === collection && relationForCurrent?.field === field) {
|
||||
return 'm2o';
|
||||
}
|
||||
|
||||
if (relations[0].related_collection === 'directus_files' || relations[1].related_collection === 'directus_files') {
|
||||
return 'files';
|
||||
} else {
|
||||
return 'm2m';
|
||||
}
|
||||
}
|
||||
|
||||
return 'standard';
|
||||
}
|
||||
81
app/src/utils/get-related-collection.test.ts
Normal file
81
app/src/utils/get-related-collection.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { test, expect, vi, beforeEach, Mock } from 'vitest';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
|
||||
import { cryptoStub } from '@/__utils__/crypto';
|
||||
vi.stubGlobal('crypto', cryptoStub);
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(
|
||||
createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
vi.mock('@/utils/get-local-type');
|
||||
|
||||
import { getRelatedCollection } from '@/utils/get-related-collection';
|
||||
import { useRelationsStore } from '@/stores/relations';
|
||||
import { getLocalTypeForField } from '@/utils/get-local-type';
|
||||
|
||||
test('Returns M2M as related + junction', () => {
|
||||
const relationsStore = useRelationsStore();
|
||||
|
||||
(relationsStore.getRelationsForField as Mock).mockReturnValue([
|
||||
{
|
||||
collection: 'articles_categories',
|
||||
field: 'articles_id',
|
||||
related_collection: 'articles',
|
||||
},
|
||||
{
|
||||
collection: 'articles_categories',
|
||||
field: 'categories_id',
|
||||
related_collection: 'categories',
|
||||
},
|
||||
]);
|
||||
|
||||
(getLocalTypeForField as Mock).mockReturnValue('m2m');
|
||||
|
||||
expect(getRelatedCollection('articles', 'categories')).toEqual({
|
||||
relatedCollection: 'categories',
|
||||
junctionCollection: 'articles_categories',
|
||||
path: ['categories_id'],
|
||||
});
|
||||
});
|
||||
|
||||
test('Returns other O2M as just related', () => {
|
||||
const relationsStore = useRelationsStore();
|
||||
|
||||
(relationsStore.getRelationsForField as Mock).mockReturnValue([
|
||||
{
|
||||
collection: 'users',
|
||||
field: 'favorite_article',
|
||||
related_collection: 'articles',
|
||||
},
|
||||
]);
|
||||
|
||||
(getLocalTypeForField as Mock).mockReturnValue('o2m');
|
||||
|
||||
expect(getRelatedCollection('articles', 'favorited_by')).toEqual({
|
||||
relatedCollection: 'users',
|
||||
});
|
||||
});
|
||||
|
||||
test('Returns M2O from related_collection rather than collection', () => {
|
||||
const relationsStore = useRelationsStore();
|
||||
|
||||
(relationsStore.getRelationsForField as Mock).mockReturnValue([
|
||||
{
|
||||
collection: 'users',
|
||||
field: 'favorite_article',
|
||||
related_collection: 'articles',
|
||||
},
|
||||
]);
|
||||
|
||||
(getLocalTypeForField as Mock).mockReturnValue('m2o');
|
||||
|
||||
expect(getRelatedCollection('users', 'favorite_article')).toEqual({
|
||||
relatedCollection: 'articles',
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRelationsStore } from '@/stores/relations';
|
||||
import { Relation } from '@directus/shared/types';
|
||||
import { getLocalTypeForField } from '../modules/settings/routes/data-model/get-local-type';
|
||||
import { getLocalTypeForField } from './get-local-type';
|
||||
|
||||
export interface RelatedCollectionData {
|
||||
relatedCollection: string;
|
||||
@@ -8,6 +8,14 @@ export interface RelatedCollectionData {
|
||||
path?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the related collection for a given relational field
|
||||
* For many-to-many type fields, it will return both the junction as the related collection
|
||||
*
|
||||
* @param collection - Name of the current parent collection
|
||||
* @param field - Name of the relational field in the current collection
|
||||
* @returns Related collection name(s)
|
||||
*/
|
||||
export function getRelatedCollection(collection: string, field: string): RelatedCollectionData {
|
||||
const relationsStore = useRelationsStore();
|
||||
|
||||
@@ -15,6 +23,7 @@ export function getRelatedCollection(collection: string, field: string): Related
|
||||
const localType = getLocalTypeForField(collection, field);
|
||||
|
||||
const o2mTypes = ['o2m', 'm2m', 'm2a', 'translations', 'files'];
|
||||
|
||||
if (localType && o2mTypes.includes(localType)) {
|
||||
if (localType == 'm2m' && relations.length > 1) {
|
||||
return {
|
||||
|
||||
23
app/src/utils/get-root-path.test.ts
Normal file
23
app/src/utils/get-root-path.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { extract, getPublicURL, getRootPath } from '@/utils/get-root-path';
|
||||
|
||||
describe('extract', () => {
|
||||
it('Returns the part of the string leading up to /admin', () => {
|
||||
expect(extract('/path/to/admin/something')).toBe('/path/to/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRootPath', () => {
|
||||
it('Extracts the root path, using the current window pathname as the input path', () => {
|
||||
window.location.pathname = '/path/to/admin/something';
|
||||
expect(getRootPath()).toBe('/path/to/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPublicURL', () => {
|
||||
it('Extracts the root URL, using the current window href as the input path', () => {
|
||||
window.location.href = 'https://example.com/path/to/admin/something';
|
||||
expect(getPublicURL()).toBe('https://example.com/path/to/');
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,24 @@
|
||||
/**
|
||||
* Get the API root location from the current window path
|
||||
*/
|
||||
export function getRootPath(): string {
|
||||
const path = window.location.pathname;
|
||||
const parts = path.split('/');
|
||||
const adminIndex = parts.indexOf('admin');
|
||||
const rootPath = parts.slice(0, adminIndex).join('/') + '/';
|
||||
return rootPath;
|
||||
return extract(window.location.pathname);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full API root URL from the current page href
|
||||
*/
|
||||
export function getPublicURL(): string {
|
||||
const path = window.location.href;
|
||||
return extract(window.location.href);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the root path of the admin app from a given input path/url
|
||||
*
|
||||
* @param path - Path or URL string of the current page
|
||||
* @returns - Root URL of the Directus instance
|
||||
*/
|
||||
export function extract(path: string) {
|
||||
const parts = path.split('/');
|
||||
const adminIndex = parts.indexOf('admin');
|
||||
const rootPath = parts.slice(0, adminIndex).join('/') + '/';
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { Settings } from '@directus/shared/types';
|
||||
|
||||
export function getSetting(setting: keyof Settings): any {
|
||||
const settingsStore = useSettingsStore();
|
||||
if (settingsStore.settings) return settingsStore.settings[setting];
|
||||
return null;
|
||||
}
|
||||
51
app/src/utils/get-theme.test.ts
Normal file
51
app/src/utils/get-theme.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { test, expect, beforeEach, vi } from 'vitest';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
|
||||
import { cryptoStub } from '@/__utils__/crypto';
|
||||
vi.stubGlobal('crypto', cryptoStub);
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(
|
||||
createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
import { getTheme } from '@/utils/get-theme';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
|
||||
test(`Defaults to light when configured to auto and matchMedia isn't available in the browser`, () => {
|
||||
const userStore = useUserStore();
|
||||
|
||||
userStore.currentUser = undefined as any;
|
||||
window.matchMedia = undefined as any;
|
||||
|
||||
expect(getTheme()).toBe('light');
|
||||
|
||||
userStore.currentUser = {} as any;
|
||||
|
||||
expect(getTheme()).toBe('light');
|
||||
});
|
||||
|
||||
test(`Uses matchMedia to find browser preference for dark mode`, () => {
|
||||
const userStore = useUserStore();
|
||||
|
||||
userStore.currentUser = undefined as any;
|
||||
window.matchMedia = vi.fn(() => ({ matches: true })) as any;
|
||||
|
||||
expect(getTheme()).toBe('dark');
|
||||
});
|
||||
|
||||
test(`Returns configured theme if not set to auto in store`, () => {
|
||||
const userStore = useUserStore();
|
||||
|
||||
userStore.currentUser = { theme: 'light' } as any;
|
||||
|
||||
expect(getTheme()).toBe('light');
|
||||
|
||||
userStore.currentUser = { theme: 'dark' } as any;
|
||||
|
||||
expect(getTheme()).toBe('dark');
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useUserStore } from '@/stores/user';
|
||||
|
||||
/**
|
||||
* Returns the used theme for the current user. Will check prefers-color-scheme dark if theme is
|
||||
* configured to be "auto"
|
||||
*/
|
||||
export function getTheme(): 'light' | 'dark' {
|
||||
const userStore = useUserStore();
|
||||
|
||||
|
||||
18
app/src/utils/get-with-arrays.test.ts
Normal file
18
app/src/utils/get-with-arrays.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { test, expect } from 'vitest';
|
||||
|
||||
import { get } from '@/utils/get-with-arrays';
|
||||
|
||||
test('Returns static value from object', () => {
|
||||
const input = { test: { path: 'example' } };
|
||||
expect(get(input, 'test.path')).toBe('example');
|
||||
});
|
||||
|
||||
test('Returns default value if path does not exist in object', () => {
|
||||
const input = { test: { path: 'example' } };
|
||||
expect(get(input, 'test.wrong', 'default value')).toBe('default value');
|
||||
});
|
||||
|
||||
test('Returns values in array path as flattened array', () => {
|
||||
const input = { test: [{ path: 'example' }, { path: 'another' }] };
|
||||
expect(get(input, 'test.path')).toEqual(['example', 'another']);
|
||||
});
|
||||
@@ -10,9 +10,12 @@
|
||||
*/
|
||||
export function get(object: Record<string, any> | any[], path: string, defaultValue?: any): any {
|
||||
const [key, ...follow] = path.split('.');
|
||||
|
||||
const result = Array.isArray(object) ? object.map((entry) => entry[key!]) : object?.[key!];
|
||||
|
||||
if (follow.length > 0) {
|
||||
return get(result, follow.join('.'), defaultValue);
|
||||
}
|
||||
|
||||
return result ?? defaultValue;
|
||||
}
|
||||
|
||||
13
app/src/utils/hide-drag-image.test.ts
Normal file
13
app/src/utils/hide-drag-image.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { test, expect, vi } from 'vitest';
|
||||
|
||||
import { hideDragImage } from '@/utils/hide-drag-image';
|
||||
|
||||
test('Sets drag image to empty image', () => {
|
||||
const dataTransfer = {
|
||||
setDragImage: vi.fn() as any,
|
||||
} as DataTransfer;
|
||||
|
||||
hideDragImage(dataTransfer);
|
||||
|
||||
expect(dataTransfer.setDragImage).toHaveBeenCalledWith(new Image(), 0, 0);
|
||||
});
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* Set the passed data transfer's drag image to an empty image object of 0x0 size
|
||||
*/
|
||||
export function hideDragImage(dataTransfer: DataTransfer): void {
|
||||
const emptyImg = new Image();
|
||||
dataTransfer.setDragImage(emptyImg, 0, 0);
|
||||
|
||||
41
app/src/utils/is-empty.test.ts
Normal file
41
app/src/utils/is-empty.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { isEmpty, notEmpty } from '@/utils/is-empty';
|
||||
|
||||
describe('isEmpty', () => {
|
||||
it('Returns true for null', () => {
|
||||
expect(isEmpty(null)).toBe(true);
|
||||
});
|
||||
|
||||
it('Returns true for undefined', () => {
|
||||
expect(isEmpty(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('Returns false for strings/numbers/etc', () => {
|
||||
expect(isEmpty('')).toBe(false);
|
||||
expect(isEmpty('hello')).toBe(false);
|
||||
expect(isEmpty(123)).toBe(false);
|
||||
expect(isEmpty(0)).toBe(false);
|
||||
expect(isEmpty([])).toBe(false);
|
||||
expect(isEmpty({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('notEmpty', () => {
|
||||
it('Returns false for null', () => {
|
||||
expect(notEmpty(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('Returns false for undefined', () => {
|
||||
expect(notEmpty(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('Returns true for strings/numbers/etc', () => {
|
||||
expect(notEmpty('')).toBe(true);
|
||||
expect(notEmpty('hello')).toBe(true);
|
||||
expect(notEmpty(123)).toBe(true);
|
||||
expect(notEmpty(0)).toBe(true);
|
||||
expect(notEmpty([])).toBe(true);
|
||||
expect(notEmpty({})).toBe(true);
|
||||
});
|
||||
});
|
||||
13
app/src/utils/is-hex.test.ts
Normal file
13
app/src/utils/is-hex.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { test, expect } from 'vitest';
|
||||
|
||||
import { isHex } from '@/utils/is-hex';
|
||||
|
||||
test('Returns true for valid hex', () => {
|
||||
const cases = ['#64f', '#64ff', '#6644ff', '#6644ffff'];
|
||||
cases.forEach((testCase) => expect(isHex(testCase)).toBe(true));
|
||||
});
|
||||
|
||||
test('Returns false for anything non-hash', () => {
|
||||
const cases = ['123', 'hello', 'rgba(255, 255, 255, 0)', 'hsl(123, 123, 123)'];
|
||||
cases.forEach((testCase) => expect(isHex(testCase)).toBe(false));
|
||||
});
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* Returns whether or not a given string is a valid hex color
|
||||
*/
|
||||
export function isHex(hex: string): boolean {
|
||||
return /^#(([a-f\d]{2}){3,4})$/i.test(hex);
|
||||
return /^#(([a-f\d]{3,4}))$/i.test(hex) || /^#(([a-f\d]{2}){3,4})$/i.test(hex);
|
||||
}
|
||||
|
||||
47
app/src/utils/is-permission-empty.test.ts
Normal file
47
app/src/utils/is-permission-empty.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { test, expect } from 'vitest';
|
||||
|
||||
import { isPermissionEmpty } from '@/utils/is-permission-empty';
|
||||
|
||||
test('Returns true if all fields are missing', () => {
|
||||
const perm: any = {};
|
||||
|
||||
expect(isPermissionEmpty(perm)).toBe(true);
|
||||
});
|
||||
|
||||
test('Returns true if fields is empty, others are missing', () => {
|
||||
const perm: any = {
|
||||
fields: [],
|
||||
};
|
||||
|
||||
expect(isPermissionEmpty(perm)).toBe(true);
|
||||
});
|
||||
|
||||
test('Returns true fields, validation are empty, others are missing', () => {
|
||||
const perm: any = {
|
||||
fields: [],
|
||||
validation: {},
|
||||
};
|
||||
|
||||
expect(isPermissionEmpty(perm)).toBe(true);
|
||||
});
|
||||
|
||||
test('Returns true fields, validation, presets are empty, permissions is missing', () => {
|
||||
const perm: any = {
|
||||
fields: [],
|
||||
validation: {},
|
||||
presets: {},
|
||||
};
|
||||
|
||||
expect(isPermissionEmpty(perm)).toBe(true);
|
||||
});
|
||||
|
||||
test('Returns true if fields, validation, presets, and permissions is empty', () => {
|
||||
const perm: any = {
|
||||
fields: [],
|
||||
validation: {},
|
||||
presets: {},
|
||||
permissions: {},
|
||||
};
|
||||
|
||||
expect(isPermissionEmpty(perm)).toBe(true);
|
||||
});
|
||||
15
app/src/utils/jwt-payload.test.ts
Normal file
15
app/src/utils/jwt-payload.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { test, expect } from 'vitest';
|
||||
|
||||
import { jwtPayload } from '@/utils/jwt-payload';
|
||||
|
||||
test('Returns payload as JSON object from JWT', () => {
|
||||
const token =
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
|
||||
const payload = jwtPayload(token);
|
||||
|
||||
expect(payload).toEqual({
|
||||
sub: '1234567890',
|
||||
name: 'John Doe',
|
||||
iat: 1516239022,
|
||||
});
|
||||
});
|
||||
16
app/src/utils/percentage.test.ts
Normal file
16
app/src/utils/percentage.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { percentage } from '@/utils/percentage';
|
||||
|
||||
describe('utils/percentage', () => {
|
||||
it('Returns null for undefined upper limits', () => {
|
||||
expect(percentage(5, undefined)).toBe(null);
|
||||
});
|
||||
|
||||
it('Returns 100 percent remaining for value 0', () => {
|
||||
expect(percentage(0, 100)).toBe(100);
|
||||
});
|
||||
|
||||
it('Returns the percentage remaining', () => {
|
||||
expect(percentage(50, 100)).toBe(50);
|
||||
});
|
||||
});
|
||||
19
app/src/utils/point-on-line.test.ts
Normal file
19
app/src/utils/point-on-line.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { test, expect } from 'vitest';
|
||||
|
||||
import { pointOnLine, Point } from '@/utils/point-on-line';
|
||||
|
||||
const cases: [boolean, Point, Point, Point][] = [
|
||||
[false, [0, 0], [1, 10], [10, 1]],
|
||||
|
||||
[true, [0, 0], [0, 10], [0, 0]],
|
||||
[true, [0, 0], [10, 0], [0, 0]],
|
||||
|
||||
[true, [0, 0], [0, 0], [0, 10]],
|
||||
[true, [0, 0], [0, 0], [10, 0]],
|
||||
];
|
||||
|
||||
for (const [result, current, p1, p2] of cases) {
|
||||
test(`(${current}) on line (${p1})—(${p2})`, () => {
|
||||
expect(pointOnLine(current, p1, p2)).toBe(result);
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
type Point = [number, number];
|
||||
export type Point = [number, number];
|
||||
|
||||
/**
|
||||
* Check if a given X, Y coordinate is on the line between two other points
|
||||
@@ -18,6 +18,17 @@ export function pointOnLine(current: Point, point1: Point, point2: Point): boole
|
||||
|
||||
if (cross !== 0) return false;
|
||||
|
||||
if (Math.abs(dxl) >= Math.abs(dyl)) return dxl > 0 ? p1X <= curX && curX <= p2X : p2X <= curX && curX <= p1X;
|
||||
else return dyl > 0 ? p1Y <= curY && curY <= p2Y : p2Y <= curY && curY <= p1Y;
|
||||
if (Math.abs(dxl) >= Math.abs(dyl)) {
|
||||
if (dxl > 0) {
|
||||
return p1X <= curX && curX <= p2X;
|
||||
} else {
|
||||
return p2X <= curX && curX <= p1X;
|
||||
}
|
||||
} else {
|
||||
if (dyl > 0) {
|
||||
return p1Y <= curY && curY <= p2Y;
|
||||
} else {
|
||||
return p2Y <= curY && curY <= p1Y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
39
app/src/utils/user-name.test.ts
Normal file
39
app/src/utils/user-name.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { test, expect, vi } from 'vitest';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
|
||||
import { userName } from '@/utils/user-name';
|
||||
|
||||
vi.mock('@/lang', () => {
|
||||
return {
|
||||
i18n: createI18n({
|
||||
legacy: false,
|
||||
locale: 'en-US',
|
||||
messages: {
|
||||
'en-US': {
|
||||
unknown_user: 'TEST_UNKNOWN',
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
test(`Returns unknown when user isn't passed`, () => {
|
||||
expect(userName()).toBe('TEST_UNKNOWN');
|
||||
expect(userName(undefined)).toBe('TEST_UNKNOWN');
|
||||
});
|
||||
|
||||
test(`Returns first + last name if both exist`, () => {
|
||||
expect(userName({ first_name: 'Test', last_name: 'Last' })).toBe('Test Last');
|
||||
});
|
||||
|
||||
test(`Returns just first name if last name doesn't exist`, () => {
|
||||
expect(userName({ first_name: 'Test' })).toBe('Test');
|
||||
});
|
||||
|
||||
test(`Returns email address if first name doesn't exist`, () => {
|
||||
expect(userName({ email: 'test@example.com' })).toBe('test@example.com');
|
||||
});
|
||||
|
||||
test(`Returns unknown if name and email are missing`, () => {
|
||||
expect(userName({ id: '12345' })).toBe('TEST_UNKNOWN');
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { i18n } from '@/lang';
|
||||
import { User } from '@directus/shared/types';
|
||||
|
||||
export function userName(user: Partial<User>): string {
|
||||
export function userName(user?: Partial<User>): string {
|
||||
if (!user) {
|
||||
return i18n.global.t('unknown_user') as string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user