Merge branch 'main' of github.com:directus/directus into robl/cms-385

This commit is contained in:
Rob Luton
2025-07-10 14:06:05 -05:00
78 changed files with 798 additions and 251 deletions

View File

@@ -0,0 +1,5 @@
---
'@directus/system-data': patch
---
Hide accepted terms field in settings

View File

@@ -0,0 +1,5 @@
---
'@directus/app': minor
---
Added the code tool to the WYSIWYG text editor by @Abdallah-Awwad & @robluton

View File

@@ -0,0 +1,5 @@
---
'@directus/api': patch
---
Fixed parsing functions in aliases

View File

@@ -0,0 +1,5 @@
---
'@directus/app': patch
---
Ensured that relational interfaces could reset their saved edits in versions

View File

@@ -0,0 +1,5 @@
---
'@directus/app': patch
---
Fixed a bug that caused “Save as Copy” to mutate edits before saving

View File

@@ -0,0 +1,5 @@
---
'@directus/api': patch
---
Removed duplicate code in fields readAll

View File

@@ -0,0 +1,5 @@
---
'@directus/app': minor
---
Improved the readability of the primary button in dark mode

View File

@@ -0,0 +1,5 @@
---
'@directus/app': minor
---
Ensured that custom validation rules are executed in overlays

View File

@@ -0,0 +1,5 @@
---
'@directus/app': minor
---
Improved custom validation message handling

View File

@@ -0,0 +1,5 @@
---
'@directus/app': patch
---
Added logout flow when user removes own account.

View File

@@ -0,0 +1,5 @@
---
'@directus/app': patch
---
Added redirect to profile page when user registers and not required to verify by email.

View File

@@ -0,0 +1,5 @@
---
'@directus/app': patch
---
Fixed a bug that prevented popups from working in the WYSIWYG interface when opened in a drawer

View File

@@ -0,0 +1,5 @@
---
'@directus/app': patch
---
Fixed a bug that was preventing overlay forms with junction fields from correctly applying filters in M2O fields

View File

@@ -0,0 +1,21 @@
---
'@directus/api': major
---
Exclude database-only tables from snapshots
::: notice
Snapshots now exclude tables not tracked in `directus_collections` (database-only tables).
| Source Version | Target Version | Behavior | Impact |
| -------------- | -------------- | ---------------------------------------------------------- | -------------------------------------------- |
| < 11.10.0 | ≥ 11.10.0 | Database-only tables from source will be created on target | ⚠️ Tables added |
| ≥ 11.10.0 | < 11.10.0 | Database-only tables will be dropped from target | 🚨 Data loss risk |
| ≥ 11.10.0 | ≥ 11.10.0 | Database-only tables are ignored in snapshots | ✅ No changes |
| < 11.10.0 | < 11.10.0 | Database-only tables may be created or dropped | ⚠️ Depends on the diff between source/target |
Please review your snapshot workflows to ensure these changes will not result in unexpected behaviour.
:::

View File

@@ -0,0 +1,5 @@
---
'@directus/app': patch
---
Ensured app access permission rules are applied consistently, regardless of the selection context

View File

@@ -0,0 +1,5 @@
---
'@directus/app': patch
---
Fixed a bug that was preventing translations from displaying in the calendar layout

View File

@@ -0,0 +1,5 @@
---
'@directus/app': patch
---
Fixed a bug that caused the upload modal to appear behind the drawer

View File

@@ -0,0 +1,5 @@
---
'@directus/app': patch
---
Fixed a bug that prevented the horizontal rule from appearing in the WYSIWYG editor

View File

@@ -0,0 +1,5 @@
---
'@directus/app': patch
---
Added code to update the file list ui when importing a file via url

View File

@@ -15,8 +15,9 @@ export const snapshotBeforeCreateCollection: Snapshot = {
item_duplication_fields: null,
note: null,
singleton: false,
translations: null,
versioning: false,
translations: {},
system: false,
},
schema: {
comment: null,
@@ -44,7 +45,7 @@ export const snapshotBeforeCreateCollection: Snapshot = {
required: false,
sort: null,
special: null,
translations: {},
translations: null,
validation: null,
validation_message: null,
width: 'full',
@@ -90,8 +91,9 @@ export const snapshotCreateCollection: Snapshot = {
item_duplication_fields: null,
note: null,
singleton: false,
translations: null,
versioning: false,
translations: {},
system: false,
},
schema: {
comment: null,
@@ -110,8 +112,9 @@ export const snapshotCreateCollection: Snapshot = {
item_duplication_fields: null,
note: null,
singleton: false,
translations: null,
versioning: false,
translations: {},
system: false,
},
schema: {
comment: null,
@@ -130,8 +133,9 @@ export const snapshotCreateCollection: Snapshot = {
item_duplication_fields: null,
note: null,
singleton: false,
translations: null,
versioning: false,
translations: {},
system: false,
},
schema: {
comment: null,
@@ -159,7 +163,7 @@ export const snapshotCreateCollection: Snapshot = {
required: false,
sort: null,
special: null,
translations: {},
translations: null,
validation: null,
validation_message: null,
width: 'full',
@@ -204,7 +208,7 @@ export const snapshotCreateCollection: Snapshot = {
required: false,
sort: null,
special: null,
translations: {},
translations: null,
validation: null,
validation_message: null,
width: 'full',
@@ -249,7 +253,7 @@ export const snapshotCreateCollection: Snapshot = {
required: false,
sort: null,
special: null,
translations: {},
translations: null,
validation: null,
validation_message: null,
width: 'full',
@@ -296,7 +300,8 @@ export const snapshotCreateCollectionNotNested: Snapshot = {
note: null,
singleton: false,
versioning: false,
translations: {},
translations: null,
system: false,
},
schema: {
comment: null,
@@ -316,7 +321,8 @@ export const snapshotCreateCollectionNotNested: Snapshot = {
note: null,
singleton: false,
versioning: false,
translations: {},
translations: null,
system: false,
},
schema: {
comment: null,
@@ -344,7 +350,7 @@ export const snapshotCreateCollectionNotNested: Snapshot = {
required: false,
sort: null,
special: null,
translations: {},
translations: null,
validation: null,
validation_message: null,
width: 'full',
@@ -389,7 +395,7 @@ export const snapshotCreateCollectionNotNested: Snapshot = {
required: false,
sort: null,
special: null,
translations: {},
translations: null,
validation: null,
validation_message: null,
width: 'full',
@@ -436,7 +442,8 @@ export const snapshotBeforeDeleteCollection: Snapshot = {
note: null,
singleton: false,
versioning: false,
translations: {},
translations: null,
system: false,
},
schema: {
comment: null,
@@ -456,7 +463,8 @@ export const snapshotBeforeDeleteCollection: Snapshot = {
note: null,
singleton: false,
versioning: false,
translations: {},
translations: null,
system: false,
},
schema: {
comment: null,
@@ -476,7 +484,8 @@ export const snapshotBeforeDeleteCollection: Snapshot = {
note: null,
singleton: false,
versioning: false,
translations: {},
translations: null,
system: false,
},
schema: {
comment: null,
@@ -504,7 +513,7 @@ export const snapshotBeforeDeleteCollection: Snapshot = {
required: false,
sort: null,
special: null,
translations: [],
translations: null,
validation: null,
validation_message: null,
width: 'full',
@@ -549,7 +558,7 @@ export const snapshotBeforeDeleteCollection: Snapshot = {
required: false,
sort: null,
special: ['translations'],
translations: [],
translations: null,
validation: null,
validation_message: null,
width: 'full',
@@ -575,7 +584,7 @@ export const snapshotBeforeDeleteCollection: Snapshot = {
required: false,
sort: null,
special: null,
translations: [],
translations: null,
validation: null,
validation_message: null,
width: 'full',
@@ -620,7 +629,7 @@ export const snapshotBeforeDeleteCollection: Snapshot = {
required: false,
sort: null,
special: null,
translations: [],
translations: null,
validation: null,
validation_message: null,
width: 'full',
@@ -665,7 +674,7 @@ export const snapshotBeforeDeleteCollection: Snapshot = {
required: false,
sort: null,
special: null,
translations: [],
translations: null,
validation: null,
validation_message: null,
width: 'full',
@@ -710,7 +719,7 @@ export const snapshotBeforeDeleteCollection: Snapshot = {
required: false,
sort: null,
special: null,
translations: [],
translations: null,
validation: null,
validation_message: null,
width: 'full',
@@ -778,7 +787,7 @@ export const snapshotBeforeDeleteCollection: Snapshot = {
required: false,
sort: null,
special: null,
translations: [],
translations: null,
validation: null,
validation_message: null,
width: 'full',
@@ -823,7 +832,7 @@ export const snapshotBeforeDeleteCollection: Snapshot = {
required: false,
sort: null,
special: null,
translations: [],
translations: null,
validation: null,
validation_message: null,
width: 'full',

View File

@@ -2,7 +2,6 @@ import { SchemaBuilder } from '@directus/schema-builder';
import { expect, test, vi } from 'vitest';
import { Client_SQLite3 } from '../../run-ast/lib/apply-query/mock.js';
import { convertWildcards } from './convert-wildcards.js';
import { fetchAllowedFields } from '../../../permissions/modules/fetch-allowed-fields/fetch-allowed-fields.js';
import type { Accountability } from '@directus/types';
import knex from 'knex';
@@ -48,12 +47,14 @@ test('parse fields with id and title', async () => {
expect(result).toEqual([
{
alias: false,
fieldKey: 'id',
name: 'id',
type: 'field',
whenCase: [],
},
{
alias: false,
fieldKey: 'title',
name: 'title',
type: 'field',
@@ -87,6 +88,7 @@ test('parse fields with m2o relation', async () => {
expect(result).toEqual([
{
alias: false,
fieldKey: 'id',
name: 'id',
type: 'field',
@@ -96,6 +98,7 @@ test('parse fields with m2o relation', async () => {
cases: [],
children: [
{
alias: false,
fieldKey: 'name',
name: 'name',
type: 'field',
@@ -124,6 +127,7 @@ test('parse fields with o2m relation', async () => {
expect(result).toEqual([
{
alias: false,
fieldKey: 'id',
name: 'id',
type: 'field',
@@ -133,6 +137,7 @@ test('parse fields with o2m relation', async () => {
cases: [],
children: [
{
alias: false,
fieldKey: 'id',
name: 'id',
type: 'field',
@@ -163,6 +168,7 @@ test('parse fields with m2m relation', async () => {
expect(result).toEqual([
{
alias: false,
fieldKey: 'id',
name: 'id',
type: 'field',
@@ -175,6 +181,7 @@ test('parse fields with m2m relation', async () => {
cases: [],
children: [
{
alias: false,
fieldKey: 'id',
name: 'id',
type: 'field',
@@ -215,18 +222,21 @@ test('parse fields with *.*.*', async () => {
expect(result).toEqual([
{
alias: false,
fieldKey: 'id',
name: 'id',
type: 'field',
whenCase: [],
},
{
alias: false,
fieldKey: 'title',
name: 'title',
type: 'field',
whenCase: [],
},
{
alias: false,
fieldKey: 'date',
name: 'date',
type: 'field',
@@ -236,12 +246,14 @@ test('parse fields with *.*.*', async () => {
cases: [],
children: [
{
alias: false,
fieldKey: 'id',
name: 'id',
type: 'field',
whenCase: [],
},
{
alias: false,
fieldKey: 'name',
name: 'name',
type: 'field',
@@ -261,6 +273,7 @@ test('parse fields with *.*.*', async () => {
cases: [],
children: [
{
alias: false,
fieldKey: 'id',
name: 'id',
type: 'field',
@@ -270,24 +283,28 @@ test('parse fields with *.*.*', async () => {
cases: [],
children: [
{
alias: false,
fieldKey: 'id',
name: 'id',
type: 'field',
whenCase: [],
},
{
alias: false,
fieldKey: 'title',
name: 'title',
type: 'field',
whenCase: [],
},
{
alias: false,
fieldKey: 'date',
name: 'date',
type: 'field',
whenCase: [],
},
{
alias: false,
fieldKey: 'author',
name: 'author',
type: 'field',
@@ -347,6 +364,7 @@ test('parse fields with *.*.*', async () => {
cases: [],
children: [
{
alias: false,
fieldKey: 'id',
name: 'id',
type: 'field',
@@ -356,24 +374,28 @@ test('parse fields with *.*.*', async () => {
cases: [],
children: [
{
alias: false,
fieldKey: 'id',
name: 'id',
type: 'field',
whenCase: [],
},
{
alias: false,
fieldKey: 'title',
name: 'title',
type: 'field',
whenCase: [],
},
{
alias: false,
fieldKey: 'date',
name: 'date',
type: 'field',
whenCase: [],
},
{
alias: false,
fieldKey: 'author',
name: 'author',
type: 'field',
@@ -421,6 +443,7 @@ test('parse fields with *.*.*', async () => {
cases: [],
children: [
{
alias: false,
fieldKey: 'id',
name: 'id',
type: 'field',
@@ -464,12 +487,14 @@ test('parse fields with links.*.* and backlinks disabled', async () => {
cases: [],
children: [
{
alias: false,
fieldKey: 'id',
name: 'id',
type: 'field',
whenCase: [],
},
{
alias: false,
fieldKey: 'article_id',
name: 'article_id',
type: 'field',

View File

@@ -60,11 +60,13 @@ export async function parseFields(
const relationalStructure: Record<string, string[] | CollectionScope> = Object.create(null);
for (const fieldKey of fields) {
let alias = false;
let name = fieldKey;
if (options.query.alias) {
// check for field alias (is one of the key)
if (name in options.query.alias) {
alias = true;
name = options.query.alias[fieldKey]!;
}
}
@@ -151,7 +153,7 @@ export async function parseFields(
continue;
}
children.push({ type: 'field', name, fieldKey, whenCase: [] });
children.push({ type: 'field', name, fieldKey, whenCase: [], alias });
}
}

View File

@@ -29,6 +29,7 @@ export function applyParentFilters(
name: nestedNode.relation.field,
fieldKey: nestedNode.relation.field,
whenCase: [],
alias: false,
});
}
@@ -38,6 +39,7 @@ export function applyParentFilters(
name: nestedNode.relation.meta.sort_field,
fieldKey: nestedNode.relation.meta.sort_field,
whenCase: [],
alias: false,
});
}

View File

@@ -2,5 +2,6 @@ import type { FieldNode, FunctionFieldNode, M2ONode, O2MNode } from '../../../ty
import { applyFunctionToColumnName } from './apply-function-to-column-name.js';
export function getNodeAlias(node: FieldNode | FunctionFieldNode | M2ONode | O2MNode) {
if ('alias' in node && node.alias === true) return node.fieldKey;
return applyFunctionToColumnName(node.fieldKey);
}

View File

@@ -58,10 +58,15 @@ export function removeTemporaryFields(
}
} else {
const fields: string[] = [];
const aliasFields: string[] = [];
const nestedCollectionNodes: NestedCollectionNode[] = [];
for (const child of ast.children) {
fields.push(child.fieldKey);
if ('alias' in child && child.alias === true) {
aliasFields.push(child.fieldKey);
} else {
fields.push(child.fieldKey);
}
if (child.type !== 'field' && child.type !== 'functionField') {
nestedCollectionNodes.push(child);
@@ -98,7 +103,7 @@ export function removeTemporaryFields(
const fieldsWithFunctionsApplied = fields.map((field) => applyFunctionToColumnName(field));
item = fields.length > 0 ? pick(rawItem, fieldsWithFunctionsApplied) : rawItem[primaryKeyField];
item = fields.length > 0 ? pick(rawItem, fieldsWithFunctionsApplied, aliasFields) : rawItem[primaryKeyField];
items.push(item);
}

View File

@@ -39,7 +39,7 @@ test('Throws InvalidPayloadError on missing body', async () => {
await validateBatch('read')(mockRequest as Request, mockResponse as Response, nextFunction);
expect(nextFunction).toHaveBeenCalledTimes(1);
expect(vi.mocked(nextFunction).mock.calls[0][0]).toBeInstanceOf(InvalidPayloadError);
expect(vi.mocked(nextFunction)?.mock?.calls?.[0]?.[0]).toBeInstanceOf(InvalidPayloadError);
});
test(`Short circuits on Array body in update/delete use`, async () => {
@@ -79,7 +79,7 @@ test(`Doesn't allow both query and keys in a batch delete`, async () => {
await validateBatch('delete')(mockRequest as Request, mockResponse as Response, nextFunction);
expect(nextFunction).toHaveBeenCalledTimes(1);
expect(vi.mocked(nextFunction).mock.calls[0][0]).toBeInstanceOf(InvalidPayloadError);
expect(vi.mocked(nextFunction)?.mock?.calls?.[0]?.[0]).toBeInstanceOf(InvalidPayloadError);
});
test(`Requires 'data' on batch update`, async () => {
@@ -93,7 +93,7 @@ test(`Requires 'data' on batch update`, async () => {
await validateBatch('update')(mockRequest as Request, mockResponse as Response, nextFunction);
expect(nextFunction).toHaveBeenCalledTimes(1);
expect(vi.mocked(nextFunction).mock.calls[0][0]).toBeInstanceOf(InvalidPayloadError);
expect(vi.mocked(nextFunction)?.mock?.calls?.[0]?.[0]).toBeInstanceOf(InvalidPayloadError);
});
test(`Calls next when all is well`, async () => {
@@ -107,5 +107,5 @@ test(`Calls next when all is well`, async () => {
await validateBatch('update')(mockRequest as Request, mockResponse as Response, nextFunction);
expect(nextFunction).toHaveBeenCalledTimes(1);
expect(vi.mocked(nextFunction).mock.calls[0][0]).toBeUndefined();
expect(vi.mocked(nextFunction)?.mock?.calls?.[0]?.[0]).toBeUndefined();
});

View File

@@ -31,7 +31,7 @@ test('Returns highest result if user is passed', async () => {
vi.mocked(fetchGlobalAccessForRoles).mockResolvedValue(mockRolesAccess);
vi.mocked(fetchGlobalAccessForUser).mockResolvedValue(mockUserAccess);
const res = await fetchGlobalAccess({ user: 'user', roles: [] }, knex);
const res = await fetchGlobalAccess({ user: 'user', roles: [], ip: '' }, knex);
expect(res).toEqual({ app: true, admin: true });
});
@@ -42,7 +42,7 @@ test('Combines result of role and user', async () => {
vi.mocked(fetchGlobalAccessForRoles).mockResolvedValue(mockRolesAccess);
vi.mocked(fetchGlobalAccessForUser).mockResolvedValue(mockUserAccess);
const res = await fetchGlobalAccess({ user: 'user', roles: [] }, knex);
const res = await fetchGlobalAccess({ user: 'user', roles: [], ip: '' }, knex);
expect(res).toEqual({ app: true, admin: true });
});

View File

@@ -28,7 +28,9 @@ export async function validateItemAccess(options: ValidateItemAccessOptions, con
name: options.collection,
query: { limit: options.primaryKeys.length },
// Act as if every field was a "normal" field
children: options.fields?.map((field) => ({ type: 'field', name: field, fieldKey: field, whenCase: [] })) ?? [],
children:
options.fields?.map((field) => ({ type: 'field', name: field, fieldKey: field, whenCase: [], alias: false })) ??
[],
cases: [],
};

View File

@@ -1,16 +1,18 @@
import { expect, test } from 'vitest';
import type { AccessRow } from '../modules/process-ast/types.js';
import type { AccessRow } from '../lib/fetch-policies.js';
import { filterPoliciesByIp } from './filter-policies-by-ip.js';
test('Keeps policies that do not have a ip access rule set configured when IP is null', () => {
const policies: AccessRow[] = [
{
role: null,
policy: {
id: 'test-policy-1',
ip_access: null,
},
},
{
role: null,
policy: {
id: 'test-policy-1',
ip_access: ['127.0.0.1'],
@@ -22,6 +24,7 @@ test('Keeps policies that do not have a ip access rule set configured when IP is
expect(output).toEqual([
{
role: null,
policy: {
id: 'test-policy-1',
ip_access: null,
@@ -33,12 +36,14 @@ test('Keeps policies that do not have a ip access rule set configured when IP is
test('Keeps policies that match the IP cidr block', () => {
const policies: AccessRow[] = [
{
role: null,
policy: {
id: 'test-policy-1',
ip_access: ['192.168.1.0/22'],
},
},
{
role: null,
policy: {
id: 'test-policy-1',
ip_access: ['127.0.0.1'],
@@ -50,6 +55,7 @@ test('Keeps policies that match the IP cidr block', () => {
expect(output).toEqual([
{
role: null,
policy: {
id: 'test-policy-1',
ip_access: ['192.168.1.0/22'],

View File

@@ -25,7 +25,7 @@ afterEach(() => {
test('Blocks request if host is missing', () => {
const options = {};
expect(() => mockAgent.createConnection(options, () => {})).toThrowError(
expect(() => mockAgent.createConnection?.(options, () => {})).toThrowError(
`Request cannot be verified due to missing host`,
);
});
@@ -33,7 +33,7 @@ test('Blocks request if host is missing', () => {
test('Does not call IP check on createConnection if host is not an IP', () => {
const options = { host: 'directus.io' };
mockAgent.createConnection(options, () => {});
mockAgent.createConnection?.(options, () => {});
expect(isDeniedIp).not.toHaveBeenCalled();
});
@@ -41,7 +41,7 @@ test('Does not call IP check on createConnection if host is not an IP', () => {
test('Calls IP check on createConnection if host is IP', async () => {
const options = { host: '127.0.0.1' };
mockAgent.createConnection(options, () => {});
mockAgent.createConnection?.(options, () => {});
expect(isDeniedIp).toHaveBeenCalled();
});
@@ -51,7 +51,7 @@ test('Blocks on createConnection if IP is denied', async () => {
const options = { host: '127.0.0.1' };
expect(() => mockAgent.createConnection(options, () => {})).toThrowError(
expect(() => mockAgent.createConnection?.(options, () => {})).toThrowError(
`Requested domain "${options.host}" resolves to a denied IP address`,
);
});
@@ -61,7 +61,7 @@ test('Blocks on resolve if IP is denied', async () => {
const options = { host: 'baddomain' };
mockAgent.createConnection(options, () => {});
mockAgent.createConnection?.(options, () => {});
mockSocket.emit('lookup', null, '127.0.0.1');
@@ -75,7 +75,7 @@ test('Does not block on resolve if IP is allowed', async () => {
const options = { host: 'directus.io' };
mockAgent.createConnection(options, () => {});
mockAgent.createConnection?.(options, () => {});
mockSocket.emit('lookup', null, '127.0.0.1');
@@ -88,7 +88,7 @@ test('Checks each resolved IP', async () => {
const options = { host: 'baddomain' };
mockAgent.createConnection(options, () => {});
mockAgent.createConnection?.(options, () => {});
mockSocket.emit('lookup', null, '192.158.1.38');
mockSocket.emit('lookup', null, '127.0.0.1');

View File

@@ -199,7 +199,7 @@ export class FieldsService {
const knownCollections = Object.keys(this.schema.collections);
const result = [...columnsWithSystem, ...aliasFieldsAsField].filter((field) =>
let result = [...columnsWithSystem, ...aliasFieldsAsField].filter((field) =>
knownCollections.includes(field.collection),
);
@@ -239,7 +239,7 @@ export class FieldsService {
throw new ForbiddenError();
}
return result.filter((field) => {
result = result.filter((field) => {
if (field.collection in allowedFieldsInCollection === false) return false;
const allowedFields = allowedFieldsInCollection[field.collection]!;
if (allowedFields.has('*')) return true;
@@ -249,12 +249,6 @@ export class FieldsService {
// Update specific database type overrides
for (const field of result) {
if (field.meta?.special?.includes('cast-timestamp')) {
field.type = 'timestamp';
} else if (field.meta?.special?.includes('cast-datetime')) {
field.type = 'dateTime';
}
field.type = this.helpers.schema.processFieldType(field);
}

View File

@@ -1,5 +1,5 @@
import { useEnv } from '@directus/env';
import { getSharpInstance } from './get-sharp-instance';
import { getSharpInstance } from './get-sharp-instance.js';
import { beforeAll, expect, test, vi } from 'vitest';

View File

@@ -2,6 +2,7 @@ import { GraphQLError } from 'graphql';
import { describe, expect, test } from 'vitest';
import processError from './process-error.js';
import { createError } from '@directus/errors';
import type { Accountability } from '@directus/types';
describe('GraphQL processError util', () => {
const sampleError = new GraphQLError('An error message', { path: ['test_collection'] });
@@ -19,11 +20,15 @@ describe('GraphQL processError util', () => {
});
test('returns redacted error when authenticated but not an admin', () => {
expect(processError({ role: 'd674e22b-f405-48ba-9958-9a7bd16a1aa9' }, sampleError)).toEqual(redactedError);
expect(processError({ role: 'd674e22b-f405-48ba-9958-9a7bd16a1aa9' } as Accountability, sampleError)).toEqual(
redactedError,
);
});
test('returns original error when authenticated and is an admin', () => {
expect(processError({ role: 'd674e22b-f405-48ba-9958-9a7bd16a1aa9', admin: true }, sampleError)).toEqual({
expect(
processError({ role: 'd674e22b-f405-48ba-9958-9a7bd16a1aa9', admin: true } as Accountability, sampleError),
).toEqual({
message: 'An error message',
path: ['test_collection'],
extensions: {

View File

@@ -7,7 +7,7 @@ import type { Helpers } from '../database/helpers/index.js';
import { getHelpers } from '../database/helpers/index.js';
import { PayloadService } from './index.js';
import { SchemaBuilder } from '@directus/schema-builder';
import type { Item } from '@directus/types';
import type { Item, Accountability } from '@directus/types';
vi.mock('../../src/database/index', () => ({
getDatabaseClient: vi.fn().mockReturnValue('postgres'),
@@ -18,6 +18,7 @@ describe('Integration Tests', () => {
let tracker: Tracker;
beforeAll(async () => {
vi.stubEnv('TZ', 'UTC');
db = vi.mocked(knex.default({ client: MockClient }));
tracker = createTracker(db);
});
@@ -46,7 +47,7 @@ describe('Integration Tests', () => {
value: 123,
action: 'read',
payload: {},
accountability: { role: null },
accountability: { role: null } as Accountability,
specials: [],
helpers,
});
@@ -59,7 +60,7 @@ describe('Integration Tests', () => {
value: '',
action: 'read',
payload: {},
accountability: { role: null },
accountability: { role: null } as Accountability,
specials: [],
helpers,
});
@@ -72,7 +73,7 @@ describe('Integration Tests', () => {
value: ['test', 'directus'],
action: 'read',
payload: {},
accountability: { role: null },
accountability: { role: null } as Accountability,
specials: [],
helpers,
});
@@ -85,7 +86,7 @@ describe('Integration Tests', () => {
value: 'test,directus',
action: 'read',
payload: {},
accountability: { role: null },
accountability: { role: null } as Accountability,
specials: [],
helpers,
});
@@ -98,7 +99,7 @@ describe('Integration Tests', () => {
value: ['test', 'directus'],
action: 'create',
payload: {},
accountability: { role: null },
accountability: { role: null } as Accountability,
specials: [],
helpers,
});
@@ -111,7 +112,7 @@ describe('Integration Tests', () => {
value: 'test,directus',
action: 'create',
payload: {},
accountability: { role: null },
accountability: { role: null } as Accountability,
specials: [],
helpers,
});

View File

@@ -4,6 +4,7 @@ import knex from 'knex';
import { createTracker, MockClient, Tracker } from 'knex-mock-client';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { ForbiddenError } from '@directus/errors';
import type { Accountability } from '@directus/types';
import type { Collection } from '../types/collection.js';
import type { Snapshot, SnapshotDiffWithHash } from '../types/snapshot.js';
import { applyDiff } from '../utils/apply-diff.js';
@@ -54,7 +55,9 @@ const testCollectionDiff = {
item_duplication_fields: null,
note: null,
singleton: false,
translations: {},
translations: null,
system: false,
versioning: false,
},
schema: { name: 'test' },
},
@@ -77,7 +80,7 @@ describe('Services / Schema', () => {
it('should throw ForbiddenError for non-admin user', async () => {
vi.mocked(getSnapshot).mockResolvedValueOnce(testSnapshot);
const service = new SchemaService({ knex: db, accountability: { role: 'test', admin: false } });
const service = new SchemaService({ knex: db, accountability: { role: 'test', admin: false } as Accountability });
expect(service.snapshot()).rejects.toThrowError(ForbiddenError);
});
@@ -85,7 +88,7 @@ describe('Services / Schema', () => {
it('should return snapshot for admin user', async () => {
vi.mocked(getSnapshot).mockResolvedValueOnce(testSnapshot);
const service = new SchemaService({ knex: db, accountability: { role: 'admin', admin: true } });
const service = new SchemaService({ knex: db, accountability: { role: 'admin', admin: true } as Accountability });
expect(service.snapshot()).resolves.toEqual(testSnapshot);
});
@@ -104,7 +107,7 @@ describe('Services / Schema', () => {
it('should throw ForbiddenError for non-admin user', async () => {
vi.mocked(getSnapshot).mockResolvedValueOnce(testSnapshot);
const service = new SchemaService({ knex: db, accountability: { role: 'test', admin: false } });
const service = new SchemaService({ knex: db, accountability: { role: 'test', admin: false } as Accountability });
expect(service.apply(snapshotDiffWithHash)).rejects.toThrowError(ForbiddenError);
expect(vi.mocked(applyDiff)).not.toHaveBeenCalledOnce();
@@ -113,7 +116,7 @@ describe('Services / Schema', () => {
it('should apply for admin user', async () => {
vi.mocked(getSnapshot).mockResolvedValueOnce(testSnapshot);
const service = new SchemaService({ knex: db, accountability: { role: 'admin', admin: true } });
const service = new SchemaService({ knex: db, accountability: { role: 'admin', admin: true } as Accountability });
await service.apply(snapshotDiffWithHash);
@@ -138,7 +141,9 @@ describe('Services / Schema', () => {
item_duplication_fields: null,
note: null,
singleton: false,
translations: {},
translations: null,
system: false,
versioning: false,
},
schema: {
name: 'test',
@@ -150,7 +155,7 @@ describe('Services / Schema', () => {
} satisfies Snapshot;
it('should throw ForbiddenError for non-admin user', async () => {
const service = new SchemaService({ knex: db, accountability: { role: 'test', admin: false } });
const service = new SchemaService({ knex: db, accountability: { role: 'test', admin: false } as Accountability });
expect(service.diff(snapshotToApply, { currentSnapshot: testSnapshot, force: true })).rejects.toThrowError(
ForbiddenError,
@@ -158,7 +163,7 @@ describe('Services / Schema', () => {
});
it('should return diff for admin user', async () => {
const service = new SchemaService({ knex: db, accountability: { role: 'admin', admin: true } });
const service = new SchemaService({ knex: db, accountability: { role: 'admin', admin: true } as Accountability });
expect(service.diff(snapshotToApply, { currentSnapshot: testSnapshot, force: true })).resolves.toEqual({
collections: [testCollectionDiff],
@@ -168,7 +173,7 @@ describe('Services / Schema', () => {
});
it('should return null for empty diff', async () => {
const service = new SchemaService({ knex: db, accountability: { role: 'admin', admin: true } });
const service = new SchemaService({ knex: db, accountability: { role: 'admin', admin: true } as Accountability });
expect(service.diff(testSnapshot, { currentSnapshot: testSnapshot, force: true })).resolves.toBeNull();
});
@@ -176,7 +181,7 @@ describe('Services / Schema', () => {
describe('getHashedSnapshot', () => {
it('should return snapshot for admin user', async () => {
const service = new SchemaService({ knex: db, accountability: { role: 'admin', admin: true } });
const service = new SchemaService({ knex: db, accountability: { role: 'admin', admin: true } as Accountability });
expect(service.getHashedSnapshot(testSnapshot)).toEqual(
expect.objectContaining({

View File

@@ -5,6 +5,8 @@ import { createTracker, MockClient, Tracker } from 'knex-mock-client';
import type { MockedFunction } from 'vitest';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { SpecificationService } from './index.js';
import type { Accountability } from '@directus/types';
import type { RequestBodyObject } from 'openapi3-ts/oas30';
class Client_PG extends MockClient {}
@@ -48,7 +50,7 @@ describe('Integration Tests', () => {
const service = new SpecificationService({
knex: db,
schema,
accountability: { role: 'admin', admin: true },
accountability: { role: 'admin', admin: true } as Accountability,
});
const spec = await service.oas.generate();
@@ -259,12 +261,13 @@ describe('Integration Tests', () => {
const service = new SpecificationService({
knex: db,
schema: schema2,
accountability: { role: 'admin', admin: true },
accountability: { role: 'admin', admin: true } as Accountability,
});
const spec = await service.oas.generate();
const requestBody = spec.paths['/items/test_table']?.post?.requestBody as RequestBodyObject;
const targetSchema = spec.paths['/items/test_table']?.post?.requestBody?.content['application/json'].schema;
const targetSchema = requestBody?.content?.['application/json']?.schema;
expect(targetSchema).toHaveProperty('oneOf');
expect(targetSchema).not.toHaveProperty('type');

View File

@@ -65,13 +65,13 @@ describe('Integration Tests', () => {
describe('createOne', () => {
it('should error because of deprecation', async () => {
return expect(service.createOne({})).rejects.toEqual(errorDeprecation);
return expect(service.createOne()).rejects.toEqual(errorDeprecation);
});
});
describe('createMany', () => {
it('should error because of deprecation', async () => {
return expect(service.createMany([{}])).rejects.toEqual(errorDeprecation);
return expect(service.createMany()).rejects.toEqual(errorDeprecation);
});
});
@@ -83,7 +83,7 @@ describe('Integration Tests', () => {
describe('updateMany', () => {
it('should error because of deprecation', async () => {
return expect(service.updateMany([1], {})).rejects.toEqual(errorDeprecation);
return expect(service.updateMany()).rejects.toEqual(errorDeprecation);
});
});

View File

@@ -57,7 +57,11 @@ describe('WebSocketService', () => {
});
test('broadcast with role filter', () => {
const clients = [mockClient({ user: 'test', role: 'test' }), mockClient({ user: 'test2', role: 'test2' })];
const clients = [
mockClient({ user: 'test', role: 'test' } as Accountability),
mockClient({ user: 'test2', role: 'test2' } as Accountability),
];
const message = 'test 123';
vi.mocked(getWebSocketController).mockReturnValue({ clients: new Set(clients) } as unknown as WebSocketController);
@@ -70,7 +74,11 @@ describe('WebSocketService', () => {
});
test('broadcast with user filter', () => {
const clients = [mockClient({ user: 'test', role: 'test' }), mockClient({ user: 'test2', role: 'test2' })];
const clients = [
mockClient({ user: 'test', role: 'test' } as Accountability),
mockClient({ user: 'test2', role: 'test2' } as Accountability),
];
const message = 'test 123';
vi.mocked(getWebSocketController).mockReturnValue({ clients: new Set(clients) } as unknown as WebSocketController);

View File

@@ -7,7 +7,7 @@ import { getExtensionCount, type ExtensionCount } from './get-extension-count.js
vi.mock('../../utils/get-schema.js');
vi.mock('../../services/extensions.js');
let mockDb: Knex;
const mockDb: Knex = {} as Knex;
function generateParentBundleExtension(enabled: boolean) {
return {

View File

@@ -78,6 +78,8 @@ export type FieldNode = {
type: 'field';
name: string;
fieldKey: string;
/** If the field was created through alias query parameters */
alias: boolean;
/**
* Which permission cases have to be met on the current item for this field to return a value

View File

@@ -50,7 +50,8 @@ describe('applySnapshot', () => {
item_duplication_fields: null,
note: null,
singleton: false,
translations: {},
translations: null,
system: false,
versioning: false,
},
schema: { name: 'test_table_2' },
@@ -73,7 +74,7 @@ describe('applySnapshot', () => {
required: false,
sort: null,
special: null,
translations: {},
translations: null,
validation: null,
validation_message: null,
width: 'full',
@@ -132,8 +133,9 @@ describe('applySnapshot', () => {
item_duplication_fields: null,
note: null,
singleton: false,
translations: {},
translations: null,
versioning: false,
system: false,
},
schema: { name: 'test_table_2' },
fields: [
@@ -155,7 +157,7 @@ describe('applySnapshot', () => {
required: false,
sort: null,
special: null,
translations: {},
translations: null,
validation: null,
validation_message: null,
width: 'full',
@@ -203,7 +205,7 @@ describe('applySnapshot', () => {
required: false,
sort: null,
special: null,
translations: {},
translations: null,
validation: null,
validation_message: null,
width: 'full',
@@ -237,8 +239,9 @@ describe('applySnapshot', () => {
item_duplication_fields: null,
note: null,
singleton: false,
translations: {},
translations: null,
versioning: false,
system: false,
},
schema: { name: 'test_table_3' },
};
@@ -286,7 +289,9 @@ describe('applySnapshot', () => {
item_duplication_fields: null,
note: null,
singleton: false,
translations: {},
translations: null,
system: false,
versioning: false,
},
schema: { name: 'test_uuid_table' },
},
@@ -295,7 +300,9 @@ describe('applySnapshot', () => {
{
collection: 'test_uuid_table',
field: 'id',
name: 'id',
meta: {
id: 1,
collection: 'test_uuid_table',
conditions: null,
display: null,
@@ -310,7 +317,7 @@ describe('applySnapshot', () => {
required: false,
sort: null,
special: null,
translations: {},
translations: null,
validation: null,
validation_message: null,
width: 'full',
@@ -351,14 +358,18 @@ describe('applySnapshot', () => {
item_duplication_fields: null,
note: null,
singleton: false,
translations: {},
translations: null,
versioning: false,
system: false,
},
schema: { name: 'test_uuid_table' },
fields: [
{
collection: 'test_uuid_table',
field: 'id',
name: 'id',
meta: {
id: 1,
collection: 'test_uuid_table',
conditions: null,
display: null,
@@ -373,7 +384,7 @@ describe('applySnapshot', () => {
required: false,
sort: null,
special: null,
translations: {},
translations: null,
validation: null,
validation_message: null,
width: 'full',

View File

@@ -3,8 +3,8 @@ import '../types/express.d.ts';
import asyncHandler from './async-handler.js';
import { expect, vi, test } from 'vitest';
let mockRequest: Partial<Request & { token?: string }>;
let mockResponse: Partial<Response>;
const mockRequest: Partial<Request & { token?: string }> = {};
const mockResponse: Partial<Response> = {};
const nextFunction = vi.fn();
test('Wraps async middleware in Promise resolve that will catch rejects and pass them to the nextFn', async () => {

View File

@@ -2,10 +2,24 @@ import type { Request } from 'express';
import { describe, expect, test, vi } from 'vitest';
import { getCacheControlHeader } from './get-cache-headers.js';
import { useEnv } from '@directus/env';
import type { Accountability } from '@directus/types';
vi.mock('@directus/env');
const scenarios = [
type Scenario = {
name: string;
input: {
env: Record<string, any>;
headers: Record<string, string>;
accountability: Accountability | null;
ttl?: number;
globalCacheSettings: boolean;
personalized: boolean;
};
output: string;
};
const scenarios: Scenario[] = [
// Test the cache-control header
{
name: 'when cache-Control header includes no-store',
@@ -133,7 +147,7 @@ const scenarios = [
headers: {},
accountability: {
role: '7efc7413-7ffe-4e6f-a0ac-687bbf9f8076',
},
} as Accountability,
ttl: 5678910,
globalCacheSettings: false,
personalized: true,
@@ -145,7 +159,7 @@ const scenarios = [
input: {
env: {},
headers: {},
accountability: {},
accountability: {} as Accountability,
ttl: 5678910,
globalCacheSettings: false,
personalized: true,

View File

@@ -1,4 +1,4 @@
import type { SchemaOverview } from '@directus/types';
import type { Field, Relation, SchemaOverview } from '@directus/types';
import { version } from 'directus/version';
import type { Knex } from 'knex';
import { fromPairs, isArray, isPlainObject, mapValues, omit, sortBy, toPairs } from 'lodash-es';
@@ -25,9 +25,9 @@ export async function getSnapshot(options?: { database?: Knex; schema?: SchemaOv
relationsService.readAll(),
]);
const collectionsFiltered = collectionsRaw.filter((item: any) => excludeSystem(item));
const fieldsFiltered = fieldsRaw.filter((item: any) => excludeSystem(item));
const relationsFiltered = relationsRaw.filter((item: any) => excludeSystem(item));
const collectionsFiltered = collectionsRaw.filter((item: any) => excludeSystem(item) && excludeUntracked(item));
const fieldsFiltered = fieldsRaw.filter((item: any) => excludeSystem(item) && excludeUntracked(item));
const relationsFiltered = relationsRaw.filter((item: any) => excludeSystem(item) && excludeUntracked(item));
const collectionsSorted = sortBy(mapValues(collectionsFiltered, sortDeep), ['collection']);
@@ -49,11 +49,16 @@ export async function getSnapshot(options?: { database?: Knex; schema?: SchemaOv
};
}
function excludeSystem(item: { meta?: { system?: boolean } }) {
function excludeSystem(item: Collection | Field | Relation) {
if (item?.meta?.system === true) return false;
return true;
}
function excludeUntracked(item: Collection | Field | Relation) {
if (item?.meta === null) return false;
return true;
}
function omitID(item: Record<string, any>) {
return omit(item, 'meta.id');
}

View File

@@ -17,7 +17,9 @@ describe('sanitizeCollection', () => {
item_duplication_fields: null,
note: null,
singleton: false,
translations: {},
translations: null,
system: false,
versioning: false,
},
schema: { comment: null, name: 'test', schema: 'public' },
},
@@ -33,7 +35,9 @@ describe('sanitizeCollection', () => {
item_duplication_fields: null,
note: null,
singleton: false,
translations: {},
translations: null,
system: false,
versioning: false,
},
schema: { collation: 'latin1_swedish_ci', name: 'test', engine: 'InnoDB' },
},
@@ -49,7 +53,9 @@ describe('sanitizeCollection', () => {
item_duplication_fields: null,
note: null,
singleton: false,
translations: {},
translations: null,
system: false,
versioning: false,
},
schema: { name: 'test', owner: 'postgres' },
},
@@ -65,7 +71,9 @@ describe('sanitizeCollection', () => {
item_duplication_fields: null,
note: null,
singleton: false,
translations: {},
translations: null,
system: false,
versioning: false,
},
schema: { name: 'test', sql: 'CREATE TABLE `test` (`id` integer not null primary key autoincrement)' },
},
@@ -81,7 +89,9 @@ describe('sanitizeCollection', () => {
item_duplication_fields: null,
note: null,
singleton: false,
translations: {},
translations: null,
system: false,
versioning: false,
},
schema: { name: 'test', catalog: 'test-db' },
},
@@ -99,7 +109,9 @@ describe('sanitizeCollection', () => {
item_duplication_fields: null,
note: null,
singleton: false,
translations: {},
translations: null,
system: false,
versioning: false,
},
schema: { name: 'test' },
});
@@ -142,6 +154,7 @@ describe('sanitizeField', () => {
foreign_key_table: null,
generation_expression: null,
has_auto_increment: true,
is_indexed: false,
is_generated: false,
is_nullable: false,
is_primary_key: true,
@@ -190,6 +203,7 @@ describe('sanitizeField', () => {
foreign_key_table: null,
generation_expression: null,
has_auto_increment: true,
is_indexed: false,
is_generated: false,
is_nullable: false,
is_primary_key: true,
@@ -198,7 +212,6 @@ describe('sanitizeField', () => {
name: 'id',
numeric_precision: 32,
numeric_scale: 0,
table: 'test',
},
type: 'integer',
@@ -238,6 +251,7 @@ describe('sanitizeField', () => {
foreign_key_table: null,
generation_expression: null,
has_auto_increment: true,
is_indexed: false,
is_generated: false,
is_nullable: false,
is_primary_key: true,

View File

@@ -27,6 +27,9 @@ const inputFile = {
modified_on: '',
focal_point_x: null,
focal_point_y: null,
created_on: '',
tus_data: null,
tus_id: null,
} satisfies File;
describe('resolvePreset', () => {

View File

@@ -270,6 +270,12 @@ async function onClick(event: MouseEvent) {
min-width: 100%;
}
body.dark .button {
--v-button-color: var(--theme--foreground);
--v-button-color-hover: var(--theme--foreground);
--v-button-color-active: var(--theme--foreground);
}
.button {
position: relative;
display: flex;

View File

@@ -151,7 +151,7 @@ function useOverlayFocusTrap() {
z-index: 600;
&.keep-behind {
z-index: 490;
z-index: 500;
}
}

View File

@@ -75,6 +75,15 @@ const validationPrefix = computed(() => {
return null;
});
const showCustomValidationMessage = computed(() => {
if (!props.validationError) return false;
const customValidationMessage = !!props.field.meta?.validation_message;
const hasCustomValidation = !!props.field.meta?.validation;
return customValidationMessage && (!hasCustomValidation || props.validationError.code === 'FAILED_VALIDATION');
});
function emitValue(value: any) {
if (
(isEqual(value, props.initialValue) || (props.initialValue === undefined && isEqual(value, defaultValue.value))) &&
@@ -215,7 +224,7 @@ function useComputedValues() {
<small v-if="field.meta && field.meta.note" v-md="{ value: field.meta.note, target: '_blank' }" class="type-note" />
<small v-if="validationError" class="validation-error selectable">
<template v-if="field.meta?.validation_message">
<template v-if="showCustomValidationMessage">
{{ field.meta?.validation_message }}
<v-icon v-tooltip="validationMessage" small right name="help" />
</template>

View File

@@ -389,6 +389,7 @@ function useRawEditor() {
:badge="badge"
:raw-editor-enabled="rawEditorEnabled"
:direction="direction"
:version
v-bind="fieldsMap[fieldName]!.meta?.options || {}"
@apply="apply"
/>

View File

@@ -5,6 +5,13 @@ import { ValidationError, Field } from '@directus/types';
import { formatFieldFunction } from '@/utils/format-field-function';
import { extractFieldFromFunction } from '@/utils/extract-field-from-function';
type ValidationErrorWithDetails = ValidationError & {
fieldName: string;
groupName: string;
hasCustomValidation: boolean;
customValidationMessage: string | null;
};
const props = defineProps<{
validationErrors: ValidationError[];
fields: Field[];
@@ -14,9 +21,7 @@ defineEmits(['scroll-to-field']);
const { t } = useI18n();
const validationErrorsWithNames = computed<
(ValidationError & { fieldName: string; groupName: string; customValidationMessage: string | null })[]
>(() => {
const validationErrorsWithDetails = computed<ValidationErrorWithDetails[]>(() => {
return props.validationErrors.map(
(validationError: ValidationError & { nestedNames?: Record<string, string>; validation_message?: string }) => {
const { field: _fieldKey, fn: functionName } = extractFieldFromFunction(validationError.field);
@@ -30,6 +35,7 @@ const validationErrorsWithNames = computed<
field: fieldKey,
fieldName,
groupName: group?.name ?? validationError.group,
hasCustomValidation: !!field?.meta?.validation,
customValidationMessage: validationError.validation_message ?? field?.meta?.validation_message,
};
@@ -45,8 +51,22 @@ const validationErrorsWithNames = computed<
return `${separator}${nestedFieldKeys.map((name) => nestedNames?.[name] ?? name).join(separator)}`;
}
},
) as (ValidationError & { fieldName: string; groupName: string; customValidationMessage: string | null })[];
) as ValidationErrorWithDetails[];
});
function getDefaultValidationMessage(validationError: ValidationError) {
const isNotUnique = validationError.code === 'RECORD_NOT_UNIQUE';
if (isNotUnique) return t('validationError.unique', validationError);
return t(`validationError.${validationError.type}`, validationError);
}
function showCustomValidationMessage(validationError: ValidationErrorWithDetails) {
return (
validationError.customValidationMessage &&
(!validationError.hasCustomValidation || validationError.code === 'FAILED_VALIDATION')
);
}
</script>
<template>
@@ -54,7 +74,7 @@ const validationErrorsWithNames = computed<
<div>
<p>{{ t('validation_errors_notice') }}</p>
<ul class="validation-errors-list">
<li v-for="(validationError, index) of validationErrorsWithNames" :key="index" class="validation-error">
<li v-for="(validationError, index) of validationErrorsWithDetails" :key="index" class="validation-error">
<strong class="field" @click="$emit('scroll-to-field', validationError.group || validationError.field)">
<template v-if="validationError.field && validationError.hidden && validationError.group">
{{
@@ -69,27 +89,13 @@ const validationErrorsWithNames = computed<
<template v-else-if="validationError.field">{{ validationError.fieldName }}</template>
</strong>
<strong>{{ ': ' }}</strong>
<template v-if="validationError.customValidationMessage">
<template v-if="showCustomValidationMessage(validationError)">
{{ validationError.customValidationMessage }}
<v-icon
v-tooltip="
validationError.code === 'RECORD_NOT_UNIQUE'
? t('validationError.unique', validationError)
: t(`validationError.${validationError.type}`, validationError)
"
small
right
name="help"
/>
</template>
<template v-else>
<template v-if="validationError.code === 'RECORD_NOT_UNIQUE'">
{{ t('validationError.unique', validationError) }}
</template>
<template v-else>
{{ t(`validationError.${validationError.type}`, validationError) }}
</template>
<v-icon v-tooltip="getDefaultValidationMessage(validationError)" small right name="help" />
</template>
<template v-else>{{ getDefaultValidationMessage(validationError) }}</template>
</li>
</ul>
</div>

View File

@@ -241,6 +241,8 @@ function useSelection() {
function useURLImport() {
const url = ref('');
const loading = ref(false);
const filesStore = useFilesStore();
const newUpload = filesStore.upload();
const isValidURL = computed(() => {
try {
@@ -257,6 +259,7 @@ function useURLImport() {
if (!isValidURL.value || loading.value) return;
loading.value = true;
newUpload.start(1);
const data = {
...props.preset,
@@ -270,6 +273,9 @@ function useURLImport() {
data,
});
newUpload.progress.value = 100;
newUpload.done.value = 1;
emitter.emit(Events.upload);
if (props.multiple) {
@@ -284,6 +290,7 @@ function useURLImport() {
unexpectedError(error);
} finally {
loading.value = false;
newUpload.finish();
}
}
}

View File

@@ -17,7 +17,7 @@ import { Alterations, Field, Item, PrimaryKey, Query, Relation } from '@directus
import { getEndpoint, isObject } from '@directus/utils';
import { AxiosResponse } from 'axios';
import { jsonToGraphQLQuery } from 'json-to-graphql-query';
import { mergeWith } from 'lodash';
import { mergeWith, cloneDeep } from 'lodash';
import { ComputedRef, MaybeRef, Ref, computed, isRef, ref, unref, watch } from 'vue';
import { UsablePermissions, usePermissions } from '../use-permissions';
import { getGraphqlQueryFields } from './lib/get-graphql-query-fields';
@@ -217,7 +217,7 @@ export function useItem<T extends Item>(
const newItem: Item = {
...(itemData || {}),
...edits.value,
...cloneDeep(edits.value),
};
clearPrimaryKey(primaryKeyField.value, newItem);

View File

@@ -108,7 +108,7 @@ const TestComponent = defineComponent({
});
// eslint-disable-next-line vue/no-dupe-keys
return { value: valueRef, ...useRelationMultiple(valueRef, query, relation, id) };
return { value: valueRef, ...useRelationMultiple(valueRef, query, relation, id, ref(null)) };
},
render: () => h('div'),
});
@@ -436,7 +436,7 @@ const TestComponentM2A = defineComponent({
});
// eslint-disable-next-line vue/no-dupe-keys
return { value: valueRef, ...useRelationMultiple(valueRef, query, relation, id) };
return { value: valueRef, ...useRelationMultiple(valueRef, query, relation, id, ref(null)) };
},
render: () => h('div'),
});

View File

@@ -4,7 +4,7 @@ import { RelationM2M } from '@/composables/use-relation-m2m';
import { RelationO2M } from '@/composables/use-relation-o2m';
import { fetchAll } from '@/utils/fetch-all';
import { unexpectedError } from '@/utils/unexpected-error';
import { Filter, Item } from '@directus/types';
import { ContentVersion, Filter, Item } from '@directus/types';
import { getEndpoint, toArray } from '@directus/utils';
import { clamp, cloneDeep, get, isEqual, merge } from 'lodash';
import { Ref, computed, ref, watch } from 'vue';
@@ -32,10 +32,11 @@ export type ChangesItem = {
};
export function useRelationMultiple(
value: Ref<Record<string, any> | any[] | undefined>,
value: Ref<Record<string, any> | any[] | undefined | null>,
previewQuery: Ref<RelationQueryMultiple>,
relation: Ref<RelationM2A | RelationM2M | RelationO2M | undefined>,
itemId: Ref<string | number | null>,
version: Ref<ContentVersion | null>,
) {
const loading = ref(false);
const fetchedItems = ref<Record<string, any>[]>([]);
@@ -43,6 +44,18 @@ export function useRelationMultiple(
const { cleanItem, getPage, isLocalItem, getItemEdits, isEmpty } = useUtil();
const targetPKField = computed(() => {
if (!relation.value) return 'id';
return relation.value.type === 'o2m'
? relation.value.relatedPrimaryKeyField.field
: relation.value.junctionPrimaryKeyField.field;
});
const fetchedItemsPKs = computed(() => {
return fetchedItems.value.map((item) => item[targetPKField.value]);
});
const _value = computed<ChangesItem>({
get() {
if (!value.value || Array.isArray(value.value)) {
@@ -57,6 +70,13 @@ export function useRelationMultiple(
},
set(newValue) {
if (newValue.create.length === 0 && newValue.update.length === 0 && newValue.delete.length === 0) {
const isVersion = version.value !== null;
if (isVersion) {
value.value = fetchedItemsPKs.value;
return;
}
value.value = undefined;
return;
}
@@ -118,16 +138,11 @@ export function useRelationMultiple(
const displayItems = computed(() => {
if (!relation.value) return [];
const targetPKField =
relation.value.type === 'o2m'
? relation.value.relatedPrimaryKeyField.field
: relation.value.junctionPrimaryKeyField.field;
const items: DisplayItem[] = fetchedItems.value.map((item: Record<string, any>) => {
let edits;
for (const [index, value] of _value.value.update.entries()) {
if (typeof value === 'object' && value[targetPKField] === item[targetPKField]) {
if (typeof value === 'object' && value[targetPKField.value] === item[targetPKField.value]) {
edits = { index, value };
break;
}
@@ -153,7 +168,7 @@ export function useRelationMultiple(
updatedItem.$edits = edits.index;
}
const deleteIndex = _value.value.delete.findIndex((id) => id === item[targetPKField]);
const deleteIndex = _value.value.delete.findIndex((id) => id === item[targetPKField.value]);
if (deleteIndex !== -1) {
merge(updatedItem, { $type: 'deleted', $index: deleteIndex });
@@ -166,7 +181,7 @@ export function useRelationMultiple(
const fetchedItem = fetchedSelectItems.value.find((item) => {
switch (relation.value?.type) {
case 'o2m':
return edit[targetPKField] === item[targetPKField];
return edit[targetPKField.value] === item[targetPKField.value];
case 'm2m':
return (
edit[relation.value.junctionField.field][relation.value.relatedPrimaryKeyField.field] ===
@@ -262,21 +277,16 @@ export function useRelationMultiple(
function remove(...items: DisplayItem[]) {
if (!relation.value) return;
const pkField =
relation.value.type === 'o2m'
? relation.value.relatedPrimaryKeyField.field
: relation.value.junctionPrimaryKeyField.field;
for (const item of items) {
if (item.$type === undefined || item.$index === undefined) {
target.value.delete.push(item[pkField]);
target.value.delete.push(item[targetPKField.value]);
} else if (item.$type === 'created') {
target.value.create.splice(item.$index, 1);
} else if (item.$type === 'updated') {
if (isItemSelected(item)) {
target.value.update.splice(item.$index, 1);
} else {
target.value.delete.push(item[pkField]);
target.value.delete.push(item[targetPKField.value]);
}
} else if (item.$type === 'deleted') {
target.value.delete.splice(item.$index, 1);
@@ -428,21 +438,17 @@ export function useRelationMultiple(
}
let targetCollection: string;
let targetPKField: string;
const reverseJunctionField = relation.value.reverseJunctionField.field;
switch (relation.value.type) {
case 'm2a':
targetCollection = relation.value.junctionCollection.collection;
targetPKField = relation.value.junctionPrimaryKeyField.field;
break;
case 'm2m':
targetCollection = relation.value.junctionCollection.collection;
targetPKField = relation.value.junctionPrimaryKeyField.field;
break;
case 'o2m':
targetCollection = relation.value.relatedCollection.collection;
targetPKField = relation.value.relatedPrimaryKeyField.field;
break;
}
@@ -456,13 +462,13 @@ export function useRelationMultiple(
params: {
search: previewQuery.value.search,
aggregate: {
count: targetPKField,
count: targetPKField.value,
},
filter,
},
});
existingItemCount.value = Number(response.data.data[0].count[targetPKField]);
existingItemCount.value = Number(response.data.data[0].count[targetPKField.value]);
}
function useSelected() {
@@ -545,13 +551,12 @@ export function useRelationMultiple(
if (relation.sortField) fields.add(relation.sortField);
const targetCollection = relation.relatedCollection.collection;
const targetPKField = relation.relatedPrimaryKeyField.field;
fetchedSelectItems.value = await fetchAll(getEndpoint(targetCollection), {
params: {
fields: Array.from(fields),
filter: {
[targetPKField]: {
[targetPKField.value]: {
_in: selectedOnPage.value.map(getRelatedIDs),
},
},

View File

@@ -7,7 +7,7 @@ import { getAssetUrl } from '@/utils/get-asset-url';
import { parseFilter } from '@/utils/parse-filter';
import DrawerFiles from '@/views/private/components/drawer-files.vue';
import DrawerItem from '@/views/private/components/drawer-item.vue';
import { Filter } from '@directus/types';
import type { ContentVersion, Filter } from '@directus/types';
import { deepMap, getFieldsFromTemplate } from '@directus/utils';
import { clamp, get, isEmpty, isNil, set } from 'lodash';
import { render } from 'micromustache';
@@ -21,8 +21,9 @@ const props = withDefaults(
primaryKey: string | number;
collection: string;
field: string;
template?: string | null;
disabled?: boolean;
version: ContentVersion | null;
template?: string | null;
enableCreate?: boolean;
enableSelect?: boolean;
folder?: string;
@@ -43,7 +44,7 @@ const emit = defineEmits<{
}>();
const { t } = useI18n();
const { collection, field, primaryKey, limit } = toRefs(props);
const { collection, field, primaryKey, limit, version } = toRefs(props);
const { relationInfo } = useRelationM2M(collection, field);
const value = computed({
@@ -108,7 +109,7 @@ const {
isItemSelected,
isLocalItem,
getItemEdits,
} = useRelationMultiple(value, query, relationInfo, primaryKey);
} = useRelationMultiple(value, query, relationInfo, primaryKey, version);
const { createAllowed, updateAllowed, selectAllowed, deleteAllowed } = useRelationPermissionsM2M(relationInfo);

View File

@@ -109,7 +109,7 @@ code {
font-weight: 500;
padding: 2px 4px;
font-family: ${cssVar('--theme--fonts--monospace--font-family')}, monospace;
background-color: ${cssVar('--theme--background-accent')};
background-color: ${cssVar('--theme--background-normal')};
border-radius: ${cssVar('--theme--border-radius')};
overflow-wrap: break-word;
}
@@ -119,7 +119,7 @@ pre {
font-weight: 500;
padding: 1em;
font-family: ${cssVar('--theme--fonts--monospace--font-family')}, monospace;
background-color: ${cssVar('--theme--background-accent')};
background-color: ${cssVar('--theme--background-normal')};
border-radius: ${cssVar('--theme--border-radius')};
overflow: auto;
}

View File

@@ -1,5 +1,6 @@
import { defineInterface } from '@directus/extensions';
import { defineAsyncComponent } from 'vue';
import toolbarDefault from './toolbar-default';
import PreviewSVG from './preview.svg?raw';
const InterfaceWYSIWYG = defineAsyncComponent(() => import('./input-rich-text-html.vue'));
@@ -20,24 +21,7 @@ export default defineInterface({
name: '$t:interfaces.input-rich-text-html.toolbar',
type: 'json',
schema: {
default_value: [
'bold',
'italic',
'underline',
'h1',
'h2',
'h3',
'numlist',
'bullist',
'removeformat',
'blockquote',
'customLink',
'customImage',
'customMedia',
'hr',
'code',
'fullscreen',
],
default_value: toolbarDefault,
},
meta: {
width: 'half',
@@ -108,6 +92,10 @@ export default defineInterface({
value: 'h6',
text: '$t:wysiwyg_options.h6',
},
{
value: 'customPre',
text: '$t:wysiwyg_options.pre',
},
{
value: 'alignleft',
text: '$t:wysiwyg_options.alignleft',
@@ -180,6 +168,10 @@ export default defineInterface({
value: 'blockquote',
text: '$t:wysiwyg_options.blockquote',
},
{
value: 'customInlineCode',
text: '$t:wysiwyg_options.codeblock',
},
{
value: 'customLink',
text: '$t:wysiwyg_options.link',
@@ -204,10 +196,6 @@ export default defineInterface({
value: 'hr',
text: '$t:wysiwyg_options.hr',
},
{
value: 'code',
text: '$t:wysiwyg_options.source_code',
},
{
value: 'fullscreen',
text: '$t:wysiwyg_options.fullscreen',
@@ -220,6 +208,10 @@ export default defineInterface({
value: 'ltr rtl',
text: '$t:wysiwyg_options.directionality',
},
{
value: 'code',
text: '$t:wysiwyg_options.source_code',
},
],
},
},

View File

@@ -8,10 +8,13 @@ import { cloneDeep, isEqual } from 'lodash';
import { ComponentPublicInstance, computed, onMounted, ref, toRefs, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import getEditorStyles from './get-editor-styles';
import toolbarDefault from './toolbar-default';
import useImage from './useImage';
import useLink from './useLink';
import useMedia from './useMedia';
import useSourceCode from './useSourceCode';
import usePre from './usePre';
import useInlineCode from './useInlineCode';
import tinymce from 'tinymce/tinymce';
import 'tinymce/skins/ui/oxide/skin.css';
@@ -58,23 +61,7 @@ const props = withDefaults(
direction?: string;
}>(),
{
toolbar: () => [
'bold',
'italic',
'underline',
'h1',
'h2',
'h3',
'numlist',
'bullist',
'removeformat',
'blockquote',
'customLink',
'customImage',
'customMedia',
'code',
'fullscreen',
],
toolbar: () => toolbarDefault,
font: 'sans-serif',
customFormats: () => [],
},
@@ -127,6 +114,9 @@ const { linkButton, linkDrawerOpen, closeLinkDrawer, saveLink, linkSelection, is
const { codeDrawerOpen, code, closeCodeDrawer, saveCode, sourceCodeButton } = useSourceCode(editorRef);
const { preButton } = usePre(editorRef);
const { inlineCodeButton } = useInlineCode(editorRef);
const internalValue = computed({
get() {
return props.value || '';
@@ -180,7 +170,9 @@ const editorOptions = computed(() => {
.replace(/^link$/g, 'customLink')
.replace(/^media$/g, 'customMedia')
.replace(/^code$/g, 'customCode')
.replace(/^image$/g, 'customImage'),
.replace(/^image$/g, 'customImage')
.replace(/^pre$/g, 'customPre')
.replace(/^inlinecode$/g, 'customInlineCode'),
)
.join(' ');
@@ -223,6 +215,7 @@ const editorOptions = computed(() => {
paste_data_images: false,
setup,
language: i18n.global.locale.value,
ui_mode: 'split',
...(props.tinymceOverrides && cloneDeep(props.tinymceOverrides)),
};
});
@@ -268,9 +261,12 @@ function setup(editor: any) {
const linkShortcut = 'meta+k';
editor.ui.registry.addToggleButton('customPre', preButton);
editor.ui.registry.addToggleButton('customImage', imageButton);
editor.ui.registry.addToggleButton('customMedia', mediaButton);
editor.ui.registry.addToggleButton('customLink', { ...linkButton, shortcut: linkShortcut });
editor.ui.registry.addToggleButton('customInlineCode', inlineCodeButton);
editor.ui.registry.addButton('customCode', sourceCodeButton);
editor.on('init', function () {

View File

@@ -0,0 +1,18 @@
export default [
'bold',
'italic',
'underline',
'h1',
'h2',
'h3',
'numlist',
'bullist',
'removeformat',
'blockquote',
'customLink',
'customImage',
'customMedia',
'hr',
'code',
'fullscreen',
];

View File

@@ -0,0 +1,63 @@
import { Ref } from 'vue';
import { i18n } from '@/lang';
type InlineCodeButton = {
icon: string;
tooltip: string;
onAction: () => void;
onSetup: (api: { setActive: (isActive: boolean) => void }) => () => void;
};
type UsableInlineCode = {
inlineCodeButton: InlineCodeButton;
};
function isInlineCode(node: Node): boolean {
// check if node is a code node, or if it has a code node as a child
return node.nodeName === 'CODE' || (node as Element).querySelector('code') !== null;
}
export default function useInlineCode(editor: Ref<any>): UsableInlineCode {
let keydownHandler: ((event: KeyboardEvent) => void) | null = null;
const inlineCodeButton: InlineCodeButton = {
tooltip: i18n.global.t('wysiwyg_options.codeblock'),
icon: 'code-sample',
onAction: () => {
editor.value.execCommand('mceToggleFormat', false, 'code');
},
onSetup: (api) => {
const updateActiveState = () => {
const isInlineCode = editor.value.formatter.match('code');
api.setActive(isInlineCode);
};
updateActiveState();
const codeFormatChangedUnbind = editor.value.formatter.formatChanged('code', updateActiveState).unbind;
keydownHandler = (event: KeyboardEvent) => {
const currentNode = editor.value.selection.getNode();
if (!isInlineCode(currentNode)) return;
if (event.key === 'Enter') {
event.preventDefault();
editor.value.execCommand('removeformat');
}
};
editor.value.on('keydown', keydownHandler);
return () => {
if (codeFormatChangedUnbind) codeFormatChangedUnbind();
if (keydownHandler) editor.value.off('keydown', keydownHandler);
};
},
};
return { inlineCodeButton };
}

View File

@@ -0,0 +1,132 @@
import { i18n } from '@/lang';
import { Ref } from 'vue';
type PreButton = {
text: string;
tooltip: string;
onAction: () => void;
onSetup: (api: { setActive: (isActive: boolean) => void }) => () => void;
};
type UsablePre = {
preButton: PreButton;
};
function isPre(node: Node): boolean {
return node.nodeName === 'PRE';
}
export default function usePre(editor: Ref<any>): UsablePre {
let keydownHandler: ((event: KeyboardEvent) => void) | null = null;
const preButton: PreButton = {
tooltip: i18n.global.t('wysiwyg_options.pre'),
text: 'Pre',
onAction: () => {
// Remove all existing formatting before applying code formatting
editor.value.execCommand('removeformat');
editor.value.execCommand('mceToggleFormat', false, 'pre');
},
onSetup: (api) => {
const updateActiveState = () => {
const isPre = editor.value.formatter.match('pre');
api.setActive(isPre);
};
updateActiveState();
const preFormatUnbind = editor.value.formatter.formatChanged('pre', updateActiveState).unbind;
keydownHandler = (event: KeyboardEvent) => {
const currentNode = editor.value.selection.getNode();
if (!isPre(currentNode)) return;
if (event.key === 'Enter') {
event.preventDefault();
if (handleTripleEnterInPre(editor.value, currentNode)) {
event.preventDefault();
}
}
if (event.key === 'Backspace') {
// if the current node is a pre node, and it is empty, remove it
if (
(isPre(currentNode) && currentNode.childNodes.length === 0) ||
(currentNode.childNodes.length === 1 && currentNode.childNodes[0].nodeName === 'BR')
) {
editor.value.dom.remove(currentNode);
}
}
};
editor.value.on('keydown', keydownHandler);
return () => {
if (preFormatUnbind) preFormatUnbind();
if (keydownHandler) editor.value.off('keydown', keydownHandler);
};
},
};
return { preButton };
}
function handleTripleEnterInPre(editorInstance: any, currentNode: Node): boolean {
const preNode = editorInstance.dom.is(currentNode, 'pre')
? currentNode
: editorInstance.dom.getParent(currentNode, 'pre');
if (preNode && hasTrailingBrs(preNode, 4)) {
removeTrailingBrs(preNode, 3);
insertParagraphAfter(editorInstance, preNode);
return true;
}
return false;
}
function hasTrailingBrs(node: Node, count: number): boolean {
const childNodes = Array.from(node.childNodes);
let trailingBrCount = 0;
for (let i = childNodes.length - 1; i >= 0; i--) {
const child = childNodes[i];
if (!child) break;
if (child.nodeName === 'BR') {
trailingBrCount++;
} else if (child.nodeType === Node.TEXT_NODE && child.textContent === '') {
continue;
} else {
break;
}
}
return trailingBrCount >= count;
}
function removeTrailingBrs(node: Node, maxRemove: number) {
let removedCount = 0;
while (removedCount < maxRemove) {
const lastChild = node.lastChild;
if (lastChild && lastChild.nodeName === 'BR') {
node.removeChild(lastChild);
removedCount++;
} else {
break;
}
}
}
function insertParagraphAfter(editorInstance: any, referenceNode: Node) {
const newParagraph = editorInstance.dom.create('p', {}, '<br>');
editorInstance.dom.insertAfter(newParagraph, referenceNode);
editorInstance.selection.setCursorLocation(newParagraph, 0);
editorInstance.nodeChanged();
}

View File

@@ -9,7 +9,7 @@ import { hideDragImage } from '@/utils/hide-drag-image';
import { renderStringTemplate } from '@/utils/render-string-template';
import DrawerCollection from '@/views/private/components/drawer-collection.vue';
import DrawerItem from '@/views/private/components/drawer-item.vue';
import { Filter } from '@directus/types';
import type { ContentVersion, Filter } from '@directus/types';
import { getFieldsFromTemplate } from '@directus/utils';
import { clamp, get, isEmpty, isNil, set } from 'lodash';
import { computed, ref, toRefs, unref, watch } from 'vue';
@@ -23,6 +23,7 @@ const props = withDefaults(
collection: string;
field: string;
disabled?: boolean;
version: ContentVersion | null;
enableCreate?: boolean;
enableSelect?: boolean;
limit?: number;
@@ -41,7 +42,7 @@ const props = withDefaults(
const emit = defineEmits(['input']);
const { t, te } = useI18n();
const { collection, field, primaryKey, limit } = toRefs(props);
const { collection, field, primaryKey, limit, version } = toRefs(props);
const { relationInfo } = useRelationM2A(collection, field);
const value = computed({
@@ -121,7 +122,7 @@ const {
isItemSelected,
isLocalItem,
getItemEdits,
} = useRelationMultiple(value, query, relationInfo, primaryKey);
} = useRelationMultiple(value, query, relationInfo, primaryKey, version);
function sortItems(items: DisplayItem[]) {
const info = relationInfo.value;
@@ -131,7 +132,7 @@ function sortItems(items: DisplayItem[]) {
const sortedItems = items.map((item, index) => {
const junctionId = item?.[info.junctionPrimaryKeyField.field];
const collection = item?.[info.collectionField.field];
const pkField = info.relationPrimaryKeyFields[collection].field;
const pkField = info.relationPrimaryKeyFields[collection]!.field;
const relatedId = item?.[info.junctionField.field]?.[pkField];
const changes: Record<string, any> = {
@@ -187,7 +188,7 @@ function editItem(item: DisplayItem) {
if (!relationInfo.value) return;
const relationPkField =
relationInfo.value.relationPrimaryKeyFields[item[relationInfo.value.collectionField.field]].field;
relationInfo.value.relationPrimaryKeyFields[item[relationInfo.value.collectionField.field]]!.field;
const junctionField = relationInfo.value.junctionField.field;
const junctionPkField = relationInfo.value.junctionPrimaryKeyField.field;
@@ -297,7 +298,7 @@ const customFilter = computed(() => {
const selectedPrimaryKeys = selected.value.reduce(
(acc, item) => {
const relatedPKField = info.relationPrimaryKeyFields[item[info.collectionField.field]].field;
const relatedPKField = info.relationPrimaryKeyFields[item[info.collectionField.field]]!.field;
if (item[info.collectionField.field] === selectingFrom.value) acc.push(item[junctionField][relatedPKField]);
return acc;
},
@@ -306,7 +307,7 @@ const customFilter = computed(() => {
if (selectedPrimaryKeys.length > 0) {
filter._and.push({
[info.relationPrimaryKeyFields[selectingFrom.value].field]: {
[info.relationPrimaryKeyFields[selectingFrom.value]!.field]: {
_nin: selectedPrimaryKeys,
},
});

View File

@@ -14,7 +14,7 @@ import DrawerBatch from '@/views/private/components/drawer-batch.vue';
import DrawerCollection from '@/views/private/components/drawer-collection.vue';
import DrawerItem from '@/views/private/components/drawer-item.vue';
import SearchInput from '@/views/private/components/search-input.vue';
import { Filter } from '@directus/types';
import type { ContentVersion, Filter } from '@directus/types';
import { deepMap, getFieldsFromTemplate } from '@directus/utils';
import { clamp, get, isEmpty, isNil, merge, set } from 'lodash';
import { render } from 'micromustache';
@@ -29,6 +29,7 @@ const props = withDefaults(
collection: string;
field: string;
width: string;
version: ContentVersion | null;
layout?: LAYOUTS;
tableSpacing?: 'compact' | 'cozy' | 'comfortable';
fields?: Array<string>;
@@ -64,7 +65,7 @@ const props = withDefaults(
const emit = defineEmits(['input']);
const { t, n } = useI18n();
const { collection, field, primaryKey } = toRefs(props);
const { collection, field, primaryKey, version } = toRefs(props);
const { relationInfo } = useRelationM2M(collection, field);
const fieldsStore = useFieldsStore();
@@ -177,7 +178,7 @@ const {
isItemSelected,
isLocalItem,
getItemEdits,
} = useRelationMultiple(value, query, relationInfo, primaryKey);
} = useRelationMultiple(value, query, relationInfo, primaryKey, version);
const { createAllowed, updateAllowed, deleteAllowed, selectAllowed } = useRelationPermissionsM2M(relationInfo);
@@ -229,7 +230,7 @@ watch(
return {
text: field.name,
value: key,
width: contentWidth[key] < 10 ? contentWidth[key] * 16 + 10 : 160,
width: contentWidth[key] !== undefined && contentWidth[key] < 10 ? contentWidth[key] * 16 + 10 : 160,
sortable: !['json'].includes(field.type),
};
})

View File

@@ -4,7 +4,7 @@ import { useRelationO2M } from '@/composables/use-relation-o2m';
import { addRelatedPrimaryKeyToFields } from '@/utils/add-related-primary-key-to-fields';
import { adjustFieldsForDisplays } from '@/utils/adjust-fields-for-displays';
import { parseFilter } from '@/utils/parse-filter';
import { Filter } from '@directus/types';
import type { ContentVersion, Filter } from '@directus/types';
import { deepMap, getFieldsFromTemplate } from '@directus/utils';
import { render } from 'micromustache';
import { computed, inject, ref, toRefs } from 'vue';
@@ -14,11 +14,12 @@ import NestedDraggable from './nested-draggable.vue';
const props = withDefaults(
defineProps<{
value?: (number | string | Record<string, any>)[] | Record<string, any>;
displayTemplate?: string;
disabled?: boolean;
collection: string;
field: string;
primaryKey: string | number;
version: ContentVersion | null;
displayTemplate?: string;
enableCreate?: boolean;
enableSelect?: boolean;
filter?: Filter | null;
@@ -135,6 +136,7 @@ const fields = computed(() => {
:enable-select="enableSelect"
:custom-filter="customFilter"
:items-moved="itemsMoved"
:version
root
/>
</div>

View File

@@ -9,7 +9,7 @@ import { RelationO2M } from '@/composables/use-relation-o2m';
import { hideDragImage } from '@/utils/hide-drag-image';
import DrawerCollection from '@/views/private/components/drawer-collection.vue';
import DrawerItem from '@/views/private/components/drawer-item.vue';
import { Filter } from '@directus/types';
import type { ContentVersion, Filter } from '@directus/types';
import { moveInArray } from '@directus/utils';
import { cloneDeep } from 'lodash';
import { computed, ref, toRefs } from 'vue';
@@ -41,11 +41,12 @@ type ChangeEvent =
const props = withDefaults(
defineProps<{
modelValue?: ChangesItem;
template: string;
disabled?: boolean;
collection: string;
field: string;
primaryKey: string | number;
disabled?: boolean;
version: ContentVersion | null;
template: string;
filter?: Filter | null;
fields: string[];
relationInfo: RelationO2M;
@@ -76,7 +77,7 @@ const value = computed<ChangesItem | any[]>({
},
});
const { collection, field, primaryKey, relationInfo, root, fields, template, customFilter } = toRefs(props);
const { collection, field, primaryKey, relationInfo, root, fields, template, customFilter, version } = toRefs(props);
const drag = ref(false);
const open = ref<Record<string, boolean>>({});
@@ -91,7 +92,7 @@ const query = computed<RelationQueryMultiple>(() => ({
}));
const { displayItems, loading, create, update, remove, select, cleanItem, isLocalItem, getItemEdits } =
useRelationMultiple(value, query, relationInfo, primaryKey);
useRelationMultiple(value, query, relationInfo, primaryKey, version);
const selectDrawer = ref(false);

View File

@@ -14,7 +14,7 @@ import DrawerBatch from '@/views/private/components/drawer-batch.vue';
import DrawerCollection from '@/views/private/components/drawer-collection.vue';
import DrawerItem from '@/views/private/components/drawer-item.vue';
import SearchInput from '@/views/private/components/search-input.vue';
import { Filter } from '@directus/types';
import type { ContentVersion, Filter } from '@directus/types';
import { deepMap, getFieldsFromTemplate } from '@directus/utils';
import { clamp, get, isEmpty, isNil } from 'lodash';
import { render } from 'micromustache';
@@ -29,11 +29,12 @@ const props = withDefaults(
collection: string;
field: string;
width: string;
disabled?: boolean;
version: ContentVersion | null;
layout?: LAYOUTS;
tableSpacing?: 'compact' | 'cozy' | 'comfortable';
fields?: Array<string>;
template?: string | null;
disabled?: boolean;
enableCreate?: boolean;
enableSelect?: boolean;
filter?: Filter | null;
@@ -61,7 +62,7 @@ const props = withDefaults(
const emit = defineEmits(['input']);
const { t, n } = useI18n();
const { collection, field, primaryKey } = toRefs(props);
const { collection, field, primaryKey, version } = toRefs(props);
const { relationInfo } = useRelationO2M(collection, field);
const fieldsStore = useFieldsStore();
@@ -148,7 +149,7 @@ const {
isItemSelected,
isLocalItem,
getItemEdits,
} = useRelationMultiple(value, query, relationInfo, primaryKey);
} = useRelationMultiple(value, query, relationInfo, primaryKey, version);
const { createAllowed, deleteAllowed, updateAllowed } = useRelationPermissionsO2M(relationInfo);
@@ -200,7 +201,7 @@ watch(
return {
text: field.name,
value: key,
width: contentWidth[key] < 10 ? contentWidth[key] * 16 + 10 : 160,
width: contentWidth[key] !== undefined && contentWidth[key] < 10 ? contentWidth[key] * 16 + 10 : 160,
sortable: !['json'].includes(field.type),
};
})

View File

@@ -7,6 +7,7 @@ import { useInjectNestedValidation } from '@/composables/use-nested-validation';
import vTooltip from '@/directives/tooltip';
import { useFieldsStore } from '@/stores/fields';
import { fetchAll } from '@/utils/fetch-all';
import type { ContentVersion } from '@directus/types';
import { unexpectedError } from '@/utils/unexpected-error';
import { getEndpoint } from '@directus/utils';
import { isNil } from 'lodash';
@@ -28,6 +29,7 @@ const props = withDefaults(
value: (number | string | Record<string, any>)[] | Record<string, any> | null;
autofocus?: boolean;
disabled?: boolean;
version: ContentVersion | null;
}>(),
{
languageField: null,
@@ -50,7 +52,7 @@ const value = computed({
},
});
const { collection, field, primaryKey } = toRefs(props);
const { collection, field, primaryKey, version } = toRefs(props);
const { relationInfo } = useRelationM2M(collection, field);
const { t, locale } = useI18n();
@@ -107,7 +109,7 @@ const {
loading: itemsLoading,
fetchedItems,
getItemEdits,
} = useRelationMultiple(value, query, relationInfo, primaryKey);
} = useRelationMultiple(value, query, relationInfo, primaryKey, version);
useNestedValidation();

View File

@@ -1123,6 +1123,7 @@ wysiwyg_options:
h4: Heading 4
h5: Heading 5
h6: Heading 6
pre: Preformatted
fontselect: Select Font
fontsizeselect: Select Font Size
indent: Indent

View File

@@ -1,6 +1,7 @@
import api from '@/api';
import { useLayoutClickHandler } from '@/composables/use-layout-click-handler';
import { useServerStore } from '@/stores/server';
import { adjustFieldsForDisplays } from '@/utils/adjust-fields-for-displays';
import { formatItemsCountRelative } from '@/utils/format-items-count';
import { getFullcalendarLocale } from '@/utils/get-fullcalendar-locale';
import { renderDisplayStringTemplate } from '@/utils/render-string-template';
@@ -108,7 +109,8 @@ export default defineLayout<LayoutOptions>({
if (template.value) fields.push(...getFieldsFromTemplate(template.value));
if (startDateField.value) fields.push(startDateField.value);
if (endDateField.value) fields.push(endDateField.value);
return fields;
return [...fields, ...adjustFieldsForDisplays(fields, collection.value!)];
});
const limit = computed(() => (info.queryLimit?.max && info.queryLimit.max !== -1 ? info.queryLimit.max : 1000));

View File

@@ -1,5 +1,5 @@
import api from '@/api';
import { appRecommendedPermissions } from '@/app-permissions.js';
import { appAccessMinimalPermissions } from '@directus/system-data';
import { unexpectedError } from '@/utils/unexpected-error';
import type { Ref } from 'vue';
import { ref } from 'vue';
@@ -33,7 +33,7 @@ export function useSave({ name, adminAccess, appAccess }: UseSaveOptions) {
if (appAccess.value === true && adminAccess.value === false) {
await api.post(
'/permissions',
appRecommendedPermissions.map((permission) => ({
appAccessMinimalPermissions.map((permission) => ({
...permission,
policy: policyResponse.data.data.id,
})),

View File

@@ -16,10 +16,8 @@ import { useI18n } from 'vue-i18n';
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router';
import UsersNavigation from '../components/navigation.vue';
import useNavigation from '../composables/use-navigation';
type Item = {
[field: string]: any;
};
import { logout } from '@/auth';
import { useUserStore } from '@/stores/user';
const props = defineProps<{ role?: string }>();
@@ -29,9 +27,10 @@ const { t } = useI18n();
const { roles } = useNavigation(role);
const userInviteModalActive = ref(false);
const serverStore = useServerStore();
const userStore = useUserStore();
const layoutRef = ref();
const selection = ref<Item[]>([]);
const selection = ref<string[]>([]);
const { layout, layoutOptions, layoutQuery, filter, search, resetPreset } = usePreset(ref('directus_users'));
const { addNewLink } = useLinks();
@@ -108,6 +107,19 @@ function useBatch() {
data: batchPrimaryKeys,
});
// Check if the current user was among the deleted users
const currentUserId = userStore.currentUser && 'id' in userStore.currentUser ? userStore.currentUser.id : null;
if (
currentUserId &&
batchPrimaryKeys.some((key) => {
return key === currentUserId;
})
) {
await logout();
return;
}
await refresh();
selection.value = [];

View File

@@ -19,6 +19,7 @@ import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import UsersNavigation from '../components/navigation.vue';
import UserInfoSidebarDetail from '../components/user-info-sidebar-detail.vue';
import { logout } from '@/auth';
const props = defineProps<{
primaryKey: string;
@@ -205,6 +206,15 @@ async function deleteAndQuit() {
if (deleting.value) return;
try {
const currentUserId = userStore.currentUser && 'id' in userStore.currentUser ? userStore.currentUser.id : null;
// If the deleted user is the current user, we want to log them out
if (currentUserId && currentUserId === item.value?.id) {
await remove();
await logout();
return;
}
await remove();
edits.value = {};
router.replace(`/users`);

View File

@@ -1,9 +1,13 @@
<script setup lang="ts">
import api, { RequestError } from '@/api';
import { login } from '@/auth';
import { translateAPIError } from '@/lang';
import { useServerStore } from '@/stores/server';
import { useUserStore } from '@/stores/user';
import { ErrorCode } from '@directus/errors';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
type Credentials = {
email: string;
@@ -11,6 +15,9 @@ type Credentials = {
};
const { t } = useI18n();
const router = useRouter();
const serverStore = useServerStore();
const userStore = useUserStore();
const isLoading = ref(false);
const email = ref<string | null>(null);
const password = ref<string | null>(null);
@@ -20,6 +27,8 @@ const emit = defineEmits<{
wasSuccessful: [boolean];
}>();
const requiresEmailVerification = computed(() => serverStore.info.project?.public_registration_verify_email);
const errorFormatted = computed(() => {
// Show "Wrong username or password" for wrongly formatted emails as well
if (error.value === ErrorCode.InvalidPayload) {
@@ -52,7 +61,20 @@ async function onSubmit() {
await api.post('/users/register', credentials);
emit('wasSuccessful', true);
// If email verification is not required, automatically log in the user
if (!requiresEmailVerification.value) {
await login({ credentials });
const currentUser = userStore.currentUser;
if (currentUser && 'id' in currentUser) {
router.push(`/users/${currentUser.id}`);
} else {
router.push('/login');
}
} else {
// If email verification is required, show success message
emit('wasSuccessful', true);
}
} catch (err: any) {
error.value = err.errors?.[0]?.extensions?.code || err;
emit('wasSuccessful', false);

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import { Field, PrimaryKey } from '@directus/types';
import { computed, useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n';
import ValidationErrors from '@/components/v-form/validation-errors.vue';
import FilePreviewReplace from '@/views/private/components/file-preview-replace.vue';
import type { Field, PrimaryKey } from '@directus/types';
import { cloneDeep } from 'lodash';
import { computed, useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n';
const {
collection,
@@ -13,6 +14,7 @@ const {
initialValues,
fields,
junctionFieldLocation = 'bottom',
relatedPrimaryKeyField,
} = defineProps<{
collection: string;
primaryKey: PrimaryKey | null;
@@ -26,15 +28,15 @@ const {
junctionFieldLocation?: string;
relatedCollectionFields: Field[];
relatedPrimaryKey: PrimaryKey;
relatedPrimaryKeyField: string | null;
refresh: () => void;
}>();
const internalEdits = defineModel<Record<string, any>>('internal-edits');
const { t } = useI18n();
const { mainInitialValues, junctionInitialValues } = useInitialValues();
const { file } = useFile();
const { scrollToField } = useValidationScrollToField();
const swapFormOrder = computed(() => junctionFieldLocation === 'top');
@@ -48,15 +50,37 @@ function setRelationEdits(edits: any) {
internalEdits.value[junctionField] = edits;
}
function useInitialValues() {
const mainInitialValues = computed(() => {
if (!initialValues) return null;
if (!junctionField || !initialValues[junctionField] || !relatedPrimaryKeyField) return initialValues;
const tempInitialValues = cloneDeep(initialValues);
tempInitialValues[junctionField] = initialValues[junctionField][relatedPrimaryKeyField];
return tempInitialValues;
});
const junctionInitialValues = computed(() => {
if (!initialValues || !junctionField || !initialValues[junctionField]) return null;
return initialValues[junctionField];
});
return {
mainInitialValues,
junctionInitialValues,
};
}
function useFile() {
const isDirectusFiles = computed(() => {
return collection === 'directus_files' || relatedCollection === 'directus_files';
});
const file = computed(() => {
if (isDirectusFiles.value === false || !initialValues) return null;
if (isDirectusFiles.value === false) return null;
const fileData = junctionField ? initialValues?.[junctionField] : initialValues;
const fileData = junctionField ? junctionInitialValues.value : mainInitialValues.value;
return fileData || null;
});
@@ -107,7 +131,7 @@ function useValidationScrollToField() {
:disabled="disabled"
:loading="loading"
:show-no-visible-fields="false"
:initial-values="initialValues?.[junctionField]"
:initial-values="junctionInitialValues"
:autofocus="!swapFormOrder"
:show-divider="!swapFormOrder && hasVisibleFieldsJunction"
:primary-key="relatedPrimaryKey"
@@ -121,7 +145,7 @@ function useValidationScrollToField() {
:disabled="disabled"
:loading="loading"
:show-no-visible-fields="false"
:initial-values="initialValues"
:initial-values="mainInitialValues"
:autofocus="swapFormOrder"
:show-divider="swapFormOrder && hasVisibleFieldsRelated"
:primary-key="primaryKey"

View File

@@ -232,6 +232,7 @@ const overlayItemContentProps = computed(() => {
junctionFieldLocation: props.junctionFieldLocation,
relatedCollectionFields: relatedCollectionFields.value,
relatedPrimaryKey: props.relatedPrimaryKey,
relatedPrimaryKeyField: relatedPrimaryKeyField.value?.field ?? null,
refresh,
};
});
@@ -423,7 +424,7 @@ function useActions() {
}
function validateForm({ defaultValues, existingValues, editsToValidate, fieldsToValidate }: Record<string, any>) {
return validateItem(merge({}, defaultValues, existingValues, editsToValidate), fieldsToValidate, isNew.value);
return validateItem(merge({}, defaultValues, existingValues, editsToValidate), fieldsToValidate, isNew.value, true);
}
function save() {

View File

@@ -217,3 +217,5 @@
- Mehdi-YC
- MrGreenTea
- smgrol
- Abdallah-Awwad
- DantonMariano

View File

@@ -683,6 +683,7 @@ fields:
- field: accepted_terms
interface: boolean
width: half
hidden: true
options:
label: $t:accepted
special: