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:
Rijk van Zanten
2022-07-25 16:23:45 -04:00
committed by GitHub
parent 515ea4e4d2
commit 88c5edf7a3
64 changed files with 1309 additions and 374 deletions

View 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');
});

View File

@@ -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] || '');

View 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();
});

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View 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('');
});

View File

@@ -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);
}

View 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' });
});

View File

@@ -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;

View 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);
});
});

View 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)');
});

View File

@@ -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;

View 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);
}
});

View File

@@ -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;

View File

@@ -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',

View 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)',
]);
});

View File

@@ -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})`);

View 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';
}

View 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',
});
});

View File

@@ -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 {

View 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/');
});
});

View File

@@ -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('/') + '/';

View File

@@ -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;
}

View 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');
});

View File

@@ -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();

View 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']);
});

View File

@@ -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;
}

View 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);
});

View File

@@ -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);

View 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);
});
});

View 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));
});

View File

@@ -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);
}

View 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);
});

View 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,
});
});

View 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);
});
});

View 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);
});
}

View File

@@ -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;
}
}
}

View 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');
});

View File

@@ -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;
}