mirror of
https://github.com/directus/directus.git
synced 2026-01-26 13:08:40 -05:00
Merge branch 'main' into esc-to-close
This commit is contained in:
4248
api/package-lock.json
generated
4248
api/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "directus",
|
||||
"version": "9.0.0-beta.10",
|
||||
"version": "9.0.0-beta.12",
|
||||
"license": "GPL-3.0-only",
|
||||
"homepage": "https://github.com/directus/next#readme",
|
||||
"description": "Directus is a real-time API and App dashboard for managing SQL database content.",
|
||||
@@ -51,9 +51,9 @@
|
||||
"directus": "cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=production node dist/server.js",
|
||||
"start": "npx directus start",
|
||||
"build": "rm -rf dist && tsc --build && copyfiles \"src/**/*.*\" -e \"src/**/*.ts\" -u 1 dist",
|
||||
"dev": "cross-env NODE_ENV=development LOG_LEVEL=trace ts-node-dev --files src/server.ts --respawn --watch \"src/**/*.ts\" --transpile-only",
|
||||
"dev": "cross-env NODE_ENV=development LOG_LEVEL=trace ts-node-dev --files src/start.ts --respawn --watch \"src/**/*.ts\" --transpile-only",
|
||||
"cli": "cross-env NODE_ENV=development ts-node --script-mode --transpile-only src/cli/index.ts",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
@@ -65,8 +65,9 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@directus/app": "file:../app",
|
||||
"@directus/format-title": "^3.2.0",
|
||||
"@directus/format-title": "file:../packages/format-title",
|
||||
"@directus/specs": "file:../packages/spec",
|
||||
"@godaddy/terminus": "^4.4.1",
|
||||
"@slynova/flydrive": "^1.0.2",
|
||||
"@slynova/flydrive-gcs": "^1.0.2",
|
||||
"@slynova/flydrive-s3": "^1.0.2",
|
||||
@@ -101,7 +102,7 @@
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"keyv": "^4.0.1",
|
||||
"knex": "^0.21.4",
|
||||
"knex-schema-inspector": "0.0.20",
|
||||
"knex-schema-inspector": "^0.0.21",
|
||||
"liquidjs": "^9.14.1",
|
||||
"lodash": "^4.17.19",
|
||||
"macos-release": "^2.4.1",
|
||||
@@ -109,6 +110,7 @@
|
||||
"nanoid": "^3.1.12",
|
||||
"node-machine-id": "^1.1.12",
|
||||
"nodemailer": "^6.4.11",
|
||||
"openapi3-ts": "^2.0.0",
|
||||
"ora": "^4.1.1",
|
||||
"otplib": "^12.0.1",
|
||||
"pino": "^6.4.1",
|
||||
@@ -127,14 +129,14 @@
|
||||
"mssql": "^6.2.0",
|
||||
"mysql": "^2.18.1",
|
||||
"oracledb": "^5.0.0",
|
||||
"pg": "^8.4.0",
|
||||
"pg": "^8.4.1",
|
||||
"sqlite3": "^5.0.0"
|
||||
},
|
||||
"gitHead": "4476da28dbbc2824e680137aa28b2b91b5afabec",
|
||||
"devDependencies": {
|
||||
"copyfiles": "^2.4.0",
|
||||
"cross-env": "^7.0.2",
|
||||
"ts-node-dev": "^1.0.0-pre.63",
|
||||
"ts-node-dev": "^1.0.0-pre.64",
|
||||
"typescript": "^4.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import graphqlRouter from './controllers/graphql';
|
||||
|
||||
import notFoundHandler from './controllers/not-found';
|
||||
import sanitizeQuery from './middleware/sanitize-query';
|
||||
import { checkIP } from './middleware/check-ip';
|
||||
import { WebhooksService } from './services/webhooks';
|
||||
import { InvalidPayloadException } from './exceptions';
|
||||
|
||||
@@ -98,6 +99,9 @@ app.use(sanitizeQuery);
|
||||
app.use('/auth', authRouter);
|
||||
|
||||
app.use(authenticate);
|
||||
|
||||
app.use(checkIP);
|
||||
|
||||
app.use(cache);
|
||||
|
||||
app.use('/graphql', graphqlRouter);
|
||||
@@ -133,6 +137,7 @@ registerExtensions(customRouter);
|
||||
|
||||
track('serverStarted');
|
||||
|
||||
emitter.emitAsync('server.started').catch((err) => logger.warn(err));
|
||||
emitter.emit('init.before', { app });
|
||||
emitter.emitAsync('init').catch((err) => logger.warn(err));
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -12,6 +12,7 @@ import argon2 from 'argon2';
|
||||
import runSeed from '../../../database/seeds/run';
|
||||
|
||||
import createDBConnection, { Credentials } from '../../utils/create-db-connection';
|
||||
import Knex from 'knex';
|
||||
|
||||
export default async function init(options: Record<string, any>) {
|
||||
const rootPath = process.cwd();
|
||||
@@ -27,21 +28,54 @@ export default async function init(options: Record<string, any>) {
|
||||
|
||||
const dbClient = getDriverForClient(client)!;
|
||||
|
||||
const spinnerDriver = ora('Installing Database Driver...').start();
|
||||
await execa('npm', ['install', dbClient, '--production']);
|
||||
spinnerDriver.stop();
|
||||
try {
|
||||
require.resolve(dbClient);
|
||||
} catch {
|
||||
const spinnerDriver = ora('Installing Database Driver...').start();
|
||||
await execa('npm', ['install', dbClient, '--production']);
|
||||
spinnerDriver.stop();
|
||||
}
|
||||
|
||||
const credentials: Credentials = await inquirer.prompt(
|
||||
(databaseQuestions[dbClient] as any[]).map((question: Function) =>
|
||||
question({ client: dbClient, filepath: rootPath })
|
||||
)
|
||||
);
|
||||
let attemptsRemaining = 5;
|
||||
|
||||
const db = createDBConnection(dbClient, credentials);
|
||||
const { credentials, db } = await trySeed();
|
||||
|
||||
await runSeed(db);
|
||||
async function trySeed(): Promise<{ credentials: Credentials; db: Knex }> {
|
||||
const credentials: Credentials = await inquirer.prompt(
|
||||
(databaseQuestions[dbClient] as any[]).map((question: Function) =>
|
||||
question({ client: dbClient, filepath: rootPath })
|
||||
)
|
||||
);
|
||||
|
||||
await createEnv(dbClient, credentials, rootPath);
|
||||
const db = createDBConnection(dbClient, credentials!);
|
||||
|
||||
try {
|
||||
await runSeed(db);
|
||||
} catch (err) {
|
||||
console.log();
|
||||
console.log('Something went wrong while seeding the database:');
|
||||
console.log();
|
||||
console.log(`${err.code && chalk.red(`[${err.code}]`)} ${err.message}`);
|
||||
console.log();
|
||||
console.log('Please try again');
|
||||
console.log();
|
||||
attemptsRemaining--;
|
||||
|
||||
if (attemptsRemaining > 0) {
|
||||
return await trySeed();
|
||||
} else {
|
||||
console.log(`Couldn't seed the database. Exiting.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
return { credentials, db };
|
||||
}
|
||||
|
||||
await createEnv(dbClient, credentials!, rootPath);
|
||||
|
||||
console.log();
|
||||
console.log();
|
||||
|
||||
console.log(`Create your first admin user:`);
|
||||
|
||||
@@ -88,6 +122,8 @@ export default async function init(options: Record<string, any>) {
|
||||
console.log(`
|
||||
Your project has been created at ${chalk.green(rootPath)}.
|
||||
|
||||
The configuration can be found in ${chalk.green(rootPath + '/.env')}
|
||||
|
||||
Start Directus by running:
|
||||
${chalk.blue('cd')} ${rootPath}
|
||||
${chalk.blue('npx directus')} start
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import knex from 'knex';
|
||||
import logger from '../../logger';
|
||||
import { Express } from 'express';
|
||||
|
||||
export default async function start() {
|
||||
const { default: env } = require('../../env');
|
||||
const database = require('../../database');
|
||||
const connection = database.default as knex;
|
||||
|
||||
await database.validateDBConnection();
|
||||
|
||||
const app: Express = require('../../app').default;
|
||||
|
||||
const port = env.PORT;
|
||||
|
||||
const server = app.listen(port, () => {
|
||||
logger.info(`Server started at port ${port}`);
|
||||
});
|
||||
|
||||
const signals: NodeJS.Signals[] = ['SIGHUP', 'SIGINT', 'SIGTERM'];
|
||||
signals.forEach((signal) => {
|
||||
process.on(signal, () =>
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
logger.error(`Failed to close server: ${err.message}`, {
|
||||
err,
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
logger.info('Server stopped.');
|
||||
|
||||
connection
|
||||
.destroy()
|
||||
.then(() => {
|
||||
logger.info('Database connection stopped.');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.info(`Failed to destroy database connections: ${err.message}`, {
|
||||
err,
|
||||
});
|
||||
process.exit(1);
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import program from 'commander';
|
||||
|
||||
const pkg = require('../../package.json');
|
||||
|
||||
import start from './commands/start';
|
||||
import start from '../start';
|
||||
import init from './commands/init';
|
||||
import dbInstall from './commands/database/install';
|
||||
import dbMigrate from './commands/database/migrate';
|
||||
|
||||
@@ -12,6 +12,7 @@ import { UsersService, AuthenticationService } from '../services';
|
||||
import grantConfig from '../grant';
|
||||
import { RouteNotFoundException } from '../exceptions';
|
||||
import { respond } from '../middleware/respond';
|
||||
import { toArray } from '../utils/to-array';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -213,7 +214,7 @@ router.post(
|
||||
router.get(
|
||||
'/oauth',
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const providers = env.OAUTH_PROVIDERS.split(',').filter((p: string) => p);
|
||||
const providers = toArray(env.OAUTH_PROVIDERS);
|
||||
res.locals.payload = { data: providers.length > 0 ? providers : null };
|
||||
return next();
|
||||
}),
|
||||
|
||||
@@ -23,7 +23,7 @@ router.get(
|
||||
(req, res, next) => {
|
||||
const service = new ServerService({ accountability: req.accountability });
|
||||
const data = service.serverInfo();
|
||||
res.locals.payload = data;
|
||||
res.locals.payload = { data };
|
||||
return next();
|
||||
},
|
||||
respond
|
||||
|
||||
@@ -15,6 +15,8 @@ for (let [key, value] of Object.entries(env)) {
|
||||
key = key.toLowerCase();
|
||||
if (key.startsWith('db') === false) continue;
|
||||
if (key === 'db_client') continue;
|
||||
if (key === 'db_search_path') continue;
|
||||
if (key === 'db_connection_string') continue;
|
||||
|
||||
key = key.slice(3); // remove `DB_`
|
||||
|
||||
@@ -23,7 +25,8 @@ for (let [key, value] of Object.entries(env)) {
|
||||
|
||||
const knexConfig: Config = {
|
||||
client: env.DB_CLIENT,
|
||||
connection: connectionConfig,
|
||||
searchPath: env.DB_SEARCH_PATH,
|
||||
connection: env.DB_CONNECTION_STRING || connectionConfig,
|
||||
log: {
|
||||
warn: (msg) => {
|
||||
/** @note this is wild */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AST, NestedCollectionAST } from '../types/ast';
|
||||
import { AST, NestedCollectionNode, FieldNode, M2ONode, O2MNode } from '../types/ast';
|
||||
import { clone, cloneDeep, uniq, pick } from 'lodash';
|
||||
import database from './index';
|
||||
import SchemaInspector from 'knex-schema-inspector';
|
||||
@@ -6,6 +6,7 @@ import { Query, Item } from '../types';
|
||||
import { PayloadService } from '../services/payload';
|
||||
import applyQuery from '../utils/apply-query';
|
||||
import Knex, { QueryBuilder } from 'knex';
|
||||
import { toArray } from '../utils/to-array';
|
||||
|
||||
type RunASTOptions = {
|
||||
query?: AST['query'];
|
||||
@@ -13,75 +14,107 @@ type RunASTOptions = {
|
||||
child?: boolean;
|
||||
};
|
||||
|
||||
export default async function runAST(originalAST: AST, options?: RunASTOptions): Promise<null | Item | Item[]> {
|
||||
export default async function runAST(
|
||||
originalAST: AST | NestedCollectionNode,
|
||||
options?: RunASTOptions
|
||||
): Promise<null | Item | Item[]> {
|
||||
const ast = cloneDeep(originalAST);
|
||||
|
||||
const query = options?.query || ast.query;
|
||||
const knex = options?.knex || database;
|
||||
|
||||
// Retrieve the database columns to select in the current AST
|
||||
const { columnsToSelect, primaryKeyField, nestedCollectionASTs } = await parseCurrentLevel(ast, knex);
|
||||
if (ast.type === 'm2a') {
|
||||
const results: { [collection: string]: null | Item | Item[] } = {};
|
||||
|
||||
// The actual knex query builder instance. This is a promise that resolves with the raw items from the db
|
||||
const dbQuery = await getDBQuery(knex, ast.name, columnsToSelect, query, primaryKeyField);
|
||||
|
||||
const rawItems: Item | Item[] = await dbQuery;
|
||||
|
||||
if (!rawItems) return null;
|
||||
|
||||
// Run the items through the special transforms
|
||||
const payloadService = new PayloadService(ast.name, { knex });
|
||||
let items = await payloadService.processValues('read', rawItems);
|
||||
|
||||
if (!items || items.length === 0) return items;
|
||||
|
||||
// Apply the `_in` filters to the nested collection batches
|
||||
const nestedASTs = applyParentFilters(nestedCollectionASTs, items);
|
||||
|
||||
for (const nestedAST of nestedASTs) {
|
||||
let tempLimit: number | null = null;
|
||||
|
||||
// Nested o2m-items are fetched from the db in a single query. This means that we're fetching
|
||||
// all nested items for all parent items at once. Because of this, we can't limit that query
|
||||
// to the "standard" item limit. Instead of _n_ nested items per parent item, it would mean
|
||||
// that there's _n_ items, which are then divided on the parent items. (no good)
|
||||
if (isO2M(nestedAST) && typeof nestedAST.query.limit === 'number') {
|
||||
tempLimit = nestedAST.query.limit;
|
||||
nestedAST.query.limit = -1;
|
||||
for (const collection of ast.names) {
|
||||
results[collection] = await run(
|
||||
collection,
|
||||
ast.children[collection],
|
||||
ast.query[collection]
|
||||
);
|
||||
}
|
||||
|
||||
let nestedItems = await runAST(nestedAST, { knex, child: true });
|
||||
return results;
|
||||
} else {
|
||||
return await run(ast.name, ast.children, options?.query || ast.query);
|
||||
}
|
||||
|
||||
if (nestedItems) {
|
||||
// Merge all fetched nested records with the parent items
|
||||
items = mergeWithParentItems(nestedItems, items, nestedAST, tempLimit);
|
||||
async function run(
|
||||
collection: string,
|
||||
children: (NestedCollectionNode | FieldNode)[],
|
||||
query: Query
|
||||
) {
|
||||
// Retrieve the database columns to select in the current AST
|
||||
const { columnsToSelect, primaryKeyField, nestedCollectionNodes } = await parseCurrentLevel(
|
||||
collection,
|
||||
children,
|
||||
knex
|
||||
);
|
||||
|
||||
// The actual knex query builder instance. This is a promise that resolves with the raw items from the db
|
||||
const dbQuery = await getDBQuery(knex, collection, columnsToSelect, query, primaryKeyField);
|
||||
|
||||
const rawItems: Item | Item[] = await dbQuery;
|
||||
|
||||
if (!rawItems) return null;
|
||||
|
||||
// Run the items through the special transforms
|
||||
const payloadService = new PayloadService(collection, { knex });
|
||||
let items: null | Item | Item[] = await payloadService.processValues('read', rawItems);
|
||||
|
||||
if (!items || items.length === 0) return items;
|
||||
|
||||
// Apply the `_in` filters to the nested collection batches
|
||||
const nestedNodes = applyParentFilters(nestedCollectionNodes, items);
|
||||
|
||||
for (const nestedNode of nestedNodes) {
|
||||
let tempLimit: number | null = null;
|
||||
|
||||
// Nested o2m-items are fetched from the db in a single query. This means that we're fetching
|
||||
// all nested items for all parent items at once. Because of this, we can't limit that query
|
||||
// to the "standard" item limit. Instead of _n_ nested items per parent item, it would mean
|
||||
// that there's _n_ items, which are then divided on the parent items. (no good)
|
||||
if (nestedNode.type === 'o2m' && typeof nestedNode.query.limit === 'number') {
|
||||
tempLimit = nestedNode.query.limit;
|
||||
nestedNode.query.limit = -1;
|
||||
}
|
||||
|
||||
let nestedItems = await runAST(nestedNode, { knex, child: true });
|
||||
|
||||
if (nestedItems) {
|
||||
// Merge all fetched nested records with the parent items
|
||||
items = mergeWithParentItems(nestedItems, items, nestedNode, tempLimit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// During the fetching of data, we have to inject a couple of required fields for the child nesting
|
||||
// to work (primary / foreign keys) even if they're not explicitly requested. After all fetching
|
||||
// and nesting is done, we parse through the output structure, and filter out all non-requested
|
||||
// fields
|
||||
if (options?.child !== true) {
|
||||
items = removeTemporaryFields(items, originalAST);
|
||||
}
|
||||
// During the fetching of data, we have to inject a couple of required fields for the child nesting
|
||||
// to work (primary / foreign keys) even if they're not explicitly requested. After all fetching
|
||||
// and nesting is done, we parse through the output structure, and filter out all non-requested
|
||||
// fields
|
||||
if (options?.child !== true) {
|
||||
items = removeTemporaryFields(items, originalAST, primaryKeyField);
|
||||
}
|
||||
|
||||
return items;
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
async function parseCurrentLevel(ast: AST, knex: Knex) {
|
||||
async function parseCurrentLevel(
|
||||
collection: string,
|
||||
children: (NestedCollectionNode | FieldNode)[],
|
||||
knex: Knex
|
||||
) {
|
||||
const schemaInspector = SchemaInspector(knex);
|
||||
|
||||
const primaryKeyField = await schemaInspector.primary(ast.name);
|
||||
const primaryKeyField = await schemaInspector.primary(collection);
|
||||
|
||||
const columnsInCollection = (await schemaInspector.columns(ast.name)).map(
|
||||
const columnsInCollection = (await schemaInspector.columns(collection)).map(
|
||||
({ column }) => column
|
||||
);
|
||||
|
||||
const columnsToSelect: string[] = [];
|
||||
const nestedCollectionASTs: NestedCollectionAST[] = [];
|
||||
const nestedCollectionNodes: NestedCollectionNode[] = [];
|
||||
|
||||
for (const child of ast.children) {
|
||||
for (const child of children) {
|
||||
if (child.type === 'field') {
|
||||
if (columnsInCollection.includes(child.name) || child.name === '*') {
|
||||
columnsToSelect.push(child.name);
|
||||
@@ -92,13 +125,16 @@ async function parseCurrentLevel(ast: AST, knex: Knex) {
|
||||
|
||||
if (!child.relation) continue;
|
||||
|
||||
const m2o = isM2O(child);
|
||||
|
||||
if (m2o) {
|
||||
if (child.type === 'm2o') {
|
||||
columnsToSelect.push(child.relation.many_field);
|
||||
}
|
||||
|
||||
nestedCollectionASTs.push(child);
|
||||
if (child.type === 'm2a') {
|
||||
columnsToSelect.push(child.relation.many_field);
|
||||
columnsToSelect.push(child.relation.one_collection_field!);
|
||||
}
|
||||
|
||||
nestedCollectionNodes.push(child);
|
||||
}
|
||||
|
||||
/** Always fetch primary key in case there's a nested relation that needs it */
|
||||
@@ -106,10 +142,16 @@ async function parseCurrentLevel(ast: AST, knex: Knex) {
|
||||
columnsToSelect.push(primaryKeyField);
|
||||
}
|
||||
|
||||
return { columnsToSelect, nestedCollectionASTs, primaryKeyField };
|
||||
return { columnsToSelect, nestedCollectionNodes, primaryKeyField };
|
||||
}
|
||||
|
||||
async function getDBQuery(knex: Knex, table: string, columns: string[], query: Query, primaryKeyField: string): Promise<QueryBuilder> {
|
||||
async function getDBQuery(
|
||||
knex: Knex,
|
||||
table: string,
|
||||
columns: string[],
|
||||
query: Query,
|
||||
primaryKeyField: string
|
||||
): Promise<QueryBuilder> {
|
||||
let dbQuery = knex.select(columns.map((column) => `${table}.${column}`)).from(table);
|
||||
|
||||
const queryCopy = clone(query);
|
||||
@@ -127,115 +169,224 @@ async function getDBQuery(knex: Knex, table: string, columns: string[], query: Q
|
||||
return dbQuery;
|
||||
}
|
||||
|
||||
function applyParentFilters(nestedCollectionASTs: NestedCollectionAST[], parentItem: Item | Item[]) {
|
||||
const parentItems = Array.isArray(parentItem) ? parentItem : [parentItem];
|
||||
function applyParentFilters(
|
||||
nestedCollectionNodes: NestedCollectionNode[],
|
||||
parentItem: Item | Item[]
|
||||
) {
|
||||
const parentItems = toArray(parentItem);
|
||||
|
||||
for (const nestedAST of nestedCollectionASTs) {
|
||||
if (!nestedAST.relation) continue;
|
||||
for (const nestedNode of nestedCollectionNodes) {
|
||||
if (!nestedNode.relation) continue;
|
||||
|
||||
if (isM2O(nestedAST)) {
|
||||
nestedAST.query = {
|
||||
...nestedAST.query,
|
||||
if (nestedNode.type === 'm2o') {
|
||||
nestedNode.query = {
|
||||
...nestedNode.query,
|
||||
filter: {
|
||||
...(nestedAST.query.filter || {}),
|
||||
[nestedAST.relation.one_primary]: {
|
||||
_in: uniq(parentItems.map((res) => res[nestedAST.relation.many_field])).filter(
|
||||
(id) => id
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const relatedM2OisFetched = !!nestedAST.children.find((child) => {
|
||||
return child.type === 'field' && child.name === nestedAST.relation.many_field
|
||||
...(nestedNode.query.filter || {}),
|
||||
[nestedNode.relation.one_primary!]: {
|
||||
_in: uniq(
|
||||
parentItems.map((res) => res[nestedNode.relation.many_field])
|
||||
).filter((id) => id),
|
||||
},
|
||||
},
|
||||
};
|
||||
} else if (nestedNode.type === 'o2m') {
|
||||
const relatedM2OisFetched = !!nestedNode.children.find((child) => {
|
||||
return child.type === 'field' && child.name === nestedNode.relation.many_field;
|
||||
});
|
||||
|
||||
if (relatedM2OisFetched === false) {
|
||||
nestedAST.children.push({ type: 'field', name: nestedAST.relation.many_field });
|
||||
nestedNode.children.push({ type: 'field', name: nestedNode.relation.many_field });
|
||||
}
|
||||
|
||||
nestedAST.query = {
|
||||
...nestedAST.query,
|
||||
nestedNode.query = {
|
||||
...nestedNode.query,
|
||||
filter: {
|
||||
...(nestedAST.query.filter || {}),
|
||||
[nestedAST.relation.many_field]: {
|
||||
_in: uniq(parentItems.map((res) => res[nestedAST.parentKey])).filter((id) => id),
|
||||
}
|
||||
}
|
||||
...(nestedNode.query.filter || {}),
|
||||
[nestedNode.relation.many_field]: {
|
||||
_in: uniq(parentItems.map((res) => res[nestedNode.parentKey])).filter(
|
||||
(id) => id
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
} else if (nestedNode.type === 'm2a') {
|
||||
const keysPerCollection: { [collection: string]: (string | number)[] } = {};
|
||||
|
||||
for (const parentItem of parentItems) {
|
||||
const collection = parentItem[nestedNode.relation.one_collection_field!];
|
||||
if (!keysPerCollection[collection]) keysPerCollection[collection] = [];
|
||||
keysPerCollection[collection].push(parentItem[nestedNode.relation.many_field]);
|
||||
}
|
||||
|
||||
for (const relatedCollection of nestedNode.names) {
|
||||
nestedNode.query[relatedCollection] = {
|
||||
...nestedNode.query[relatedCollection],
|
||||
filter: {
|
||||
_and: [
|
||||
nestedNode.query[relatedCollection].filter,
|
||||
{
|
||||
[nestedNode.relatedKey[relatedCollection]]: {
|
||||
_in: uniq(keysPerCollection[relatedCollection]),
|
||||
},
|
||||
},
|
||||
].filter((f) => f),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nestedCollectionASTs;
|
||||
return nestedCollectionNodes;
|
||||
}
|
||||
|
||||
function mergeWithParentItems(nestedItem: Item | Item[], parentItem: Item | Item[], nestedAST: NestedCollectionAST, o2mLimit?: number | null) {
|
||||
const nestedItems = Array.isArray(nestedItem) ? nestedItem : [nestedItem];
|
||||
const parentItems = clone(Array.isArray(parentItem) ? parentItem : [parentItem]);
|
||||
function mergeWithParentItems(
|
||||
nestedItem: Item | Item[],
|
||||
parentItem: Item | Item[],
|
||||
nestedNode: NestedCollectionNode,
|
||||
o2mLimit?: number | null
|
||||
) {
|
||||
const nestedItems = toArray(nestedItem);
|
||||
const parentItems = clone(toArray(parentItem));
|
||||
|
||||
if (isM2O(nestedAST)) {
|
||||
if (nestedNode.type === 'm2o') {
|
||||
for (const parentItem of parentItems) {
|
||||
const itemChild = nestedItems.find((nestedItem) => {
|
||||
return nestedItem[nestedAST.relation.one_primary] === parentItem[nestedAST.fieldKey];
|
||||
return (
|
||||
nestedItem[nestedNode.relation.one_primary!] === parentItem[nestedNode.fieldKey]
|
||||
);
|
||||
});
|
||||
|
||||
parentItem[nestedAST.fieldKey] = itemChild || null;
|
||||
parentItem[nestedNode.fieldKey] = itemChild || null;
|
||||
}
|
||||
} else {
|
||||
} else if (nestedNode.type === 'o2m') {
|
||||
for (const parentItem of parentItems) {
|
||||
let itemChildren = nestedItems.filter((nestedItem) => {
|
||||
if (nestedItem === null) return false;
|
||||
if (Array.isArray(nestedItem[nestedAST.relation.many_field])) return true;
|
||||
if (Array.isArray(nestedItem[nestedNode.relation.many_field])) return true;
|
||||
|
||||
return (
|
||||
nestedItem[nestedAST.relation.many_field] === parentItem[nestedAST.relation.one_primary] ||
|
||||
nestedItem[nestedAST.relation.many_field]?.[nestedAST.relation.many_primary] === parentItem[nestedAST.relation.one_primary]
|
||||
nestedItem[nestedNode.relation.many_field] ===
|
||||
parentItem[nestedNode.relation.one_primary!] ||
|
||||
nestedItem[nestedNode.relation.many_field]?.[
|
||||
nestedNode.relation.many_primary
|
||||
] === parentItem[nestedNode.relation.one_primary!]
|
||||
);
|
||||
});
|
||||
|
||||
// We re-apply the requested limit here. This forces the _n_ nested items per parent concept
|
||||
if (o2mLimit !== null) {
|
||||
itemChildren = itemChildren.slice(0, o2mLimit);
|
||||
nestedAST.query.limit = o2mLimit;
|
||||
nestedNode.query.limit = o2mLimit;
|
||||
}
|
||||
|
||||
parentItem[nestedAST.fieldKey] = itemChildren.length > 0 ? itemChildren : null;
|
||||
parentItem[nestedNode.fieldKey] = itemChildren.length > 0 ? itemChildren : null;
|
||||
}
|
||||
} else if (nestedNode.type === 'm2a') {
|
||||
for (const parentItem of parentItems) {
|
||||
const relatedCollection = parentItem[nestedNode.relation.one_collection_field!];
|
||||
|
||||
const itemChild = (nestedItem as Record<string, any[]>)[relatedCollection].find(
|
||||
(nestedItem) => {
|
||||
return (
|
||||
nestedItem[nestedNode.relatedKey[relatedCollection]] ===
|
||||
parentItem[nestedNode.fieldKey]
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
parentItem[nestedNode.fieldKey] = itemChild || null;
|
||||
}
|
||||
}
|
||||
|
||||
return Array.isArray(parentItem) ? parentItems : parentItems[0];
|
||||
}
|
||||
|
||||
function removeTemporaryFields(rawItem: Item | Item[], ast: AST | NestedCollectionAST): Item | Item[] {
|
||||
const rawItems: Item[] = Array.isArray(rawItem) ? rawItem : [rawItem];
|
||||
|
||||
function removeTemporaryFields(
|
||||
rawItem: Item | Item[],
|
||||
ast: AST | NestedCollectionNode,
|
||||
primaryKeyField: string,
|
||||
parentItem?: Item
|
||||
): null | Item | Item[] {
|
||||
const rawItems = cloneDeep(toArray(rawItem));
|
||||
const items: Item[] = [];
|
||||
|
||||
const fields = ast.children.filter((child) => child.type === 'field').map((child) => child.name);
|
||||
const nestedCollections = ast.children.filter((child) => child.type === 'collection') as NestedCollectionAST[];
|
||||
if (ast.type === 'm2a') {
|
||||
const fields: Record<string, string[]> = {};
|
||||
const nestedCollectionNodes: Record<string, NestedCollectionNode[]> = {};
|
||||
|
||||
for (const rawItem of rawItems) {
|
||||
if (rawItem === null) return rawItem;
|
||||
const item = fields.includes('*') ? rawItem : pick(rawItem, fields);
|
||||
for (const relatedCollection of ast.names) {
|
||||
if (!fields[relatedCollection]) fields[relatedCollection] = [];
|
||||
if (!nestedCollectionNodes[relatedCollection])
|
||||
nestedCollectionNodes[relatedCollection] = [];
|
||||
|
||||
for (const nestedCollection of nestedCollections) {
|
||||
if (item[nestedCollection.fieldKey] !== null) {
|
||||
item[nestedCollection.fieldKey] = removeTemporaryFields(rawItem[nestedCollection.fieldKey], nestedCollection);
|
||||
for (const child of ast.children[relatedCollection]) {
|
||||
if (child.type === 'field') {
|
||||
fields[relatedCollection].push(child.name);
|
||||
} else {
|
||||
fields[relatedCollection].push(child.fieldKey);
|
||||
nestedCollectionNodes[relatedCollection].push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items.push(item);
|
||||
for (const rawItem of rawItems) {
|
||||
const relatedCollection: string = parentItem?.[ast.relation.one_collection_field!];
|
||||
|
||||
if (rawItem === null || rawItem === undefined) return rawItem;
|
||||
|
||||
let item = rawItem;
|
||||
|
||||
for (const nestedNode of nestedCollectionNodes[relatedCollection]) {
|
||||
item[nestedNode.fieldKey] = removeTemporaryFields(
|
||||
item[nestedNode.fieldKey],
|
||||
nestedNode,
|
||||
nestedNode.relation.many_primary,
|
||||
item
|
||||
);
|
||||
}
|
||||
|
||||
item =
|
||||
fields[relatedCollection].length > 0
|
||||
? pick(rawItem, fields[relatedCollection])
|
||||
: rawItem[primaryKeyField];
|
||||
|
||||
items.push(item);
|
||||
}
|
||||
} else {
|
||||
const fields: string[] = [];
|
||||
const nestedCollectionNodes: NestedCollectionNode[] = [];
|
||||
|
||||
for (const child of ast.children) {
|
||||
if (child.type === 'field') {
|
||||
fields.push(child.name);
|
||||
} else {
|
||||
fields.push(child.fieldKey);
|
||||
nestedCollectionNodes.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
for (const rawItem of rawItems) {
|
||||
if (rawItem === null || rawItem === undefined) return rawItem;
|
||||
|
||||
let item = rawItem;
|
||||
|
||||
for (const nestedNode of nestedCollectionNodes) {
|
||||
item[nestedNode.fieldKey] = removeTemporaryFields(
|
||||
item[nestedNode.fieldKey],
|
||||
nestedNode,
|
||||
nestedNode.type === 'm2o'
|
||||
? nestedNode.relation.one_primary!
|
||||
: nestedNode.relation.many_primary,
|
||||
item
|
||||
);
|
||||
}
|
||||
|
||||
item = fields.length > 0 ? pick(rawItem, fields) : rawItem[primaryKeyField];
|
||||
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.isArray(rawItem) ? items : items[0];
|
||||
}
|
||||
|
||||
function isM2O(child: NestedCollectionAST) {
|
||||
return (
|
||||
child.relation.one_collection === child.name && child.relation.many_field === child.fieldKey
|
||||
);
|
||||
}
|
||||
|
||||
function isO2M(child: NestedCollectionAST) {
|
||||
return isM2O(child) === false;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ columns:
|
||||
description:
|
||||
type: text
|
||||
ip_access:
|
||||
type: json
|
||||
type: text
|
||||
enforce_tfa:
|
||||
type: boolean
|
||||
nullable: false
|
||||
|
||||
@@ -30,10 +30,6 @@ columns:
|
||||
type: json
|
||||
avatar:
|
||||
type: uuid
|
||||
timezone:
|
||||
type: string
|
||||
length: 255
|
||||
default: 'America/New_York'
|
||||
language:
|
||||
type: string
|
||||
length: 8
|
||||
@@ -49,7 +45,7 @@ columns:
|
||||
type: string
|
||||
length: 16
|
||||
nullable: false
|
||||
default: draft
|
||||
default: active
|
||||
role:
|
||||
type: uuid
|
||||
nullable: true
|
||||
|
||||
@@ -35,6 +35,13 @@ columns:
|
||||
type: timestamp
|
||||
nullable: false
|
||||
default: '$now'
|
||||
modified_by:
|
||||
type: uuid
|
||||
references:
|
||||
table: directus_users
|
||||
column: id
|
||||
modified_on:
|
||||
type: timestamp
|
||||
charset:
|
||||
type: string
|
||||
length: 50
|
||||
|
||||
@@ -21,7 +21,6 @@ columns:
|
||||
one_collection:
|
||||
type: string
|
||||
length: 64
|
||||
nullable: false
|
||||
references:
|
||||
table: directus_collections
|
||||
column: collection
|
||||
@@ -31,7 +30,11 @@ columns:
|
||||
one_primary:
|
||||
type: string
|
||||
length: 64
|
||||
nullable: false
|
||||
one_collection_field:
|
||||
type: string
|
||||
length: 64
|
||||
one_allowed_collections:
|
||||
type: text
|
||||
junction_field:
|
||||
type: string
|
||||
length: 64
|
||||
|
||||
@@ -44,3 +44,5 @@ columns:
|
||||
default: all
|
||||
storage_asset_presets:
|
||||
type: json
|
||||
custom_css:
|
||||
type: text
|
||||
|
||||
@@ -14,4 +14,4 @@ data:
|
||||
- collection: directus_settings
|
||||
action: read
|
||||
permissions: {}
|
||||
fields: 'project_name,project_logo,project_color,public_foreground,public_background,public_note'
|
||||
fields: 'project_name,project_logo,project_color,public_foreground,public_background,public_note,custom_css'
|
||||
|
||||
@@ -57,9 +57,24 @@ data:
|
||||
many_primary: id
|
||||
one_collection: directus_collections
|
||||
one_field: fields
|
||||
one_primary: id
|
||||
one_primary: collection
|
||||
- many_collection: directus_activity
|
||||
many_field: user
|
||||
many_primary: id
|
||||
one_collection: directus_users
|
||||
one_primary: id
|
||||
- many_collection: directus_settings
|
||||
many_field: project_logo
|
||||
many_primary: id
|
||||
one_collection: directus_files
|
||||
one_primary: id
|
||||
- many_collection: directus_settings
|
||||
many_field: public_foreground
|
||||
many_primary: id
|
||||
one_collection: directus_files
|
||||
one_primary: id
|
||||
- many_collection: directus_settings
|
||||
many_field: public_background
|
||||
many_primary: id
|
||||
one_collection: directus_files
|
||||
one_primary: id
|
||||
|
||||
@@ -67,9 +67,9 @@ fields:
|
||||
special: json
|
||||
interface: repeater
|
||||
options:
|
||||
template: '{{ translation }} ({{ locale }})'
|
||||
template: '{{ translation }} ({{ language }})'
|
||||
fields:
|
||||
- field: locale
|
||||
- field: language
|
||||
name: Language
|
||||
type: string
|
||||
schema:
|
||||
|
||||
@@ -50,7 +50,7 @@ fields:
|
||||
options:
|
||||
placeholder: Add allowed IP addresses, leave empty to allow all...
|
||||
locked: true
|
||||
special: json
|
||||
special: csv
|
||||
sort: 6
|
||||
width: full
|
||||
- collection: directus_roles
|
||||
@@ -77,7 +77,7 @@ fields:
|
||||
locked: true
|
||||
options:
|
||||
template: '{{ name }}'
|
||||
createItemText: Add Module
|
||||
addLabel: Add New Module...
|
||||
fields:
|
||||
- name: Icon
|
||||
field: icon
|
||||
@@ -112,7 +112,7 @@ fields:
|
||||
locked: true
|
||||
options:
|
||||
template: '{{ group_name }}'
|
||||
createItemText: Add Group
|
||||
addLabel: Add New Group...
|
||||
fields:
|
||||
- name: Group Name
|
||||
field: group_name
|
||||
@@ -123,7 +123,9 @@ fields:
|
||||
options:
|
||||
iconRight: title
|
||||
placeholder: Label this group...
|
||||
- name: Accordion
|
||||
schema:
|
||||
is_nullable: false
|
||||
- name: Type
|
||||
field: accordion
|
||||
type: string
|
||||
schema:
|
||||
@@ -145,7 +147,7 @@ fields:
|
||||
meta:
|
||||
interface: repeater
|
||||
options:
|
||||
createItemText: Add Collection
|
||||
addLabel: Add New Collection...
|
||||
template: '{{ collection }}'
|
||||
fields:
|
||||
- name: Collection
|
||||
@@ -154,6 +156,8 @@ fields:
|
||||
meta:
|
||||
interface: collection
|
||||
width: full
|
||||
schema:
|
||||
is_nullable: false
|
||||
special: json
|
||||
sort: 10
|
||||
width: full
|
||||
|
||||
@@ -78,304 +78,6 @@ fields:
|
||||
special: alias
|
||||
sort: 10
|
||||
width: full
|
||||
- collection: directus_users
|
||||
field: timezone
|
||||
interface: dropdown
|
||||
options:
|
||||
choices:
|
||||
- value: Pacific/Midway
|
||||
text: '(UTC-11:00) Midway Island'
|
||||
- value: Pacific/Samoa
|
||||
text: '(UTC-11:00) Samoa'
|
||||
- value: Pacific/Honolulu
|
||||
text: '(UTC-10:00) Hawaii'
|
||||
- value: US/Alaska
|
||||
text: '(UTC-09:00) Alaska'
|
||||
- value: America/Los_Angeles
|
||||
text: '(UTC-08:00) Pacific Time (US & Canada)'
|
||||
- value: America/Tijuana
|
||||
text: '(UTC-08:00) Tijuana'
|
||||
- value: US/Arizona
|
||||
text: '(UTC-07:00) Arizona'
|
||||
- value: America/Chihuahua
|
||||
text: '(UTC-07:00) Chihuahua'
|
||||
- value: America/Mexico/La_Paz
|
||||
text: '(UTC-07:00) La Paz'
|
||||
- value: America/Mazatlan
|
||||
text: '(UTC-07:00) Mazatlan'
|
||||
- value: US/Mountain
|
||||
text: '(UTC-07:00) Mountain Time (US & Canada)'
|
||||
- value: America/Managua
|
||||
text: '(UTC-06:00) Central America'
|
||||
- value: US/Central
|
||||
text: '(UTC-06:00) Central Time (US & Canada)'
|
||||
- value: America/Guadalajara
|
||||
text: '(UTC-06:00) Guadalajara'
|
||||
- value: America/Mexico_City
|
||||
text: '(UTC-06:00) Mexico City'
|
||||
- value: America/Monterrey
|
||||
text: '(UTC-06:00) Monterrey'
|
||||
- value: Canada/Saskatchewan
|
||||
text: '(UTC-06:00) Saskatchewan'
|
||||
- value: America/Bogota
|
||||
text: '(UTC-05:00) Bogota'
|
||||
- value: US/Eastern
|
||||
text: '(UTC-05:00) Eastern Time (US & Canada)'
|
||||
- value: US/East_Indiana
|
||||
text: '(UTC-05:00) Indiana (East)'
|
||||
- value: America/Lima
|
||||
text: '(UTC-05:00) Lima'
|
||||
- value: America/Quito
|
||||
text: '(UTC-05:00) Quito'
|
||||
- value: Canada/Atlantic
|
||||
text: '(UTC-04:00) Atlantic Time (Canada)'
|
||||
- value: America/New_York
|
||||
text: '(UTC-04:00) New York'
|
||||
- value: America/Caracas
|
||||
text: '(UTC-04:30) Caracas'
|
||||
- value: America/La_Paz
|
||||
text: '(UTC-04:00) La Paz'
|
||||
- value: America/Santiago
|
||||
text: '(UTC-04:00) Santiago'
|
||||
- value: America/Santo_Domingo
|
||||
text: '(UTC-04:00) Santo Domingo'
|
||||
- value: Canada/Newfoundland
|
||||
text: '(UTC-03:30) Newfoundland'
|
||||
- value: America/Sao_Paulo
|
||||
text: '(UTC-03:00) Brasilia'
|
||||
- value: America/Argentina/Buenos_Aires
|
||||
text: '(UTC-03:00) Buenos Aires'
|
||||
- value: America/Argentina/GeorgeTown
|
||||
text: '(UTC-03:00) Georgetown'
|
||||
- value: America/Godthab
|
||||
text: '(UTC-03:00) Greenland'
|
||||
- value: America/Noronha
|
||||
text: '(UTC-02:00) Mid-Atlantic'
|
||||
- value: Atlantic/Azores
|
||||
text: '(UTC-01:00) Azores'
|
||||
- value: Atlantic/Cape_Verde
|
||||
text: '(UTC-01:00) Cape Verde Is.'
|
||||
- value: Africa/Casablanca
|
||||
text: '(UTC+00:00) Casablanca'
|
||||
- value: Europe/Edinburgh
|
||||
text: '(UTC+00:00) Edinburgh'
|
||||
- value: Etc/Greenwich
|
||||
text: '(UTC+00:00) Greenwich Mean Time'
|
||||
- value: Europe/Lisbon
|
||||
text: '(UTC+00:00) Lisbon'
|
||||
- value: Europe/London
|
||||
text: '(UTC+00:00) London'
|
||||
- value: Africa/Monrovia
|
||||
text: '(UTC+00:00) Monrovia'
|
||||
- value: UTC
|
||||
text: '(UTC+00:00) UTC'
|
||||
- value: Europe/Amsterdam
|
||||
text: '(UTC+01:00) Amsterdam'
|
||||
- value: Europe/Belgrade
|
||||
text: '(UTC+01:00) Belgrade'
|
||||
- value: Europe/Berlin
|
||||
text: '(UTC+01:00) Berlin'
|
||||
- value: Europe/Bern
|
||||
text: '(UTC+01:00) Bern'
|
||||
- value: Europe/Bratislava
|
||||
text: '(UTC+01:00) Bratislava'
|
||||
- value: Europe/Brussels
|
||||
text: '(UTC+01:00) Brussels'
|
||||
- value: Europe/Budapest
|
||||
text: '(UTC+01:00) Budapest'
|
||||
- value: Europe/Copenhagen
|
||||
text: '(UTC+01:00) Copenhagen'
|
||||
- value: Europe/Ljubljana
|
||||
text: '(UTC+01:00) Ljubljana'
|
||||
- value: Europe/Madrid
|
||||
text: '(UTC+01:00) Madrid'
|
||||
- value: Europe/Paris
|
||||
text: '(UTC+01:00) Paris'
|
||||
- value: Europe/Prague
|
||||
text: '(UTC+01:00) Prague'
|
||||
- value: Europe/Rome
|
||||
text: '(UTC+01:00) Rome'
|
||||
- value: Europe/Sarajevo
|
||||
text: '(UTC+01:00) Sarajevo'
|
||||
- value: Europe/Skopje
|
||||
text: '(UTC+01:00) Skopje'
|
||||
- value: Europe/Stockholm
|
||||
text: '(UTC+01:00) Stockholm'
|
||||
- value: Europe/Vienna
|
||||
text: '(UTC+01:00) Vienna'
|
||||
- value: Europe/Warsaw
|
||||
text: '(UTC+01:00) Warsaw'
|
||||
- value: Africa/Lagos
|
||||
text: '(UTC+01:00) West Central Africa'
|
||||
- value: Europe/Zagreb
|
||||
text: '(UTC+01:00) Zagreb'
|
||||
- value: Europe/Athens
|
||||
text: '(UTC+02:00) Athens'
|
||||
- value: Europe/Bucharest
|
||||
text: '(UTC+02:00) Bucharest'
|
||||
- value: Africa/Cairo
|
||||
text: '(UTC+02:00) Cairo'
|
||||
- value: Africa/Harare
|
||||
text: '(UTC+02:00) Harare'
|
||||
- value: Europe/Helsinki
|
||||
text: '(UTC+02:00) Helsinki'
|
||||
- value: Europe/Istanbul
|
||||
text: '(UTC+02:00) Istanbul'
|
||||
- value: Asia/Jerusalem
|
||||
text: '(UTC+02:00) Jerusalem'
|
||||
- value: Europe/Kyiv
|
||||
text: '(UTC+02:00) Kyiv'
|
||||
- value: Africa/Johannesburg
|
||||
text: '(UTC+02:00) Pretoria'
|
||||
- value: Europe/Riga
|
||||
text: '(UTC+02:00) Riga'
|
||||
- value: Europe/Sofia
|
||||
text: '(UTC+02:00) Sofia'
|
||||
- value: Europe/Tallinn
|
||||
text: '(UTC+02:00) Tallinn'
|
||||
- value: Europe/Vilnius
|
||||
text: '(UTC+02:00) Vilnius'
|
||||
- value: Asia/Baghdad
|
||||
text: '(UTC+03:00) Baghdad'
|
||||
- value: Asia/Kuwait
|
||||
text: '(UTC+03:00) Kuwait'
|
||||
- value: Europe/Minsk
|
||||
text: '(UTC+03:00) Minsk'
|
||||
- value: Africa/Nairobi
|
||||
text: '(UTC+03:00) Nairobi'
|
||||
- value: Asia/Riyadh
|
||||
text: '(UTC+03:00) Riyadh'
|
||||
- value: Europe/Volgograd
|
||||
text: '(UTC+03:00) Volgograd'
|
||||
- value: Asia/Tehran
|
||||
text: '(UTC+03:30) Tehran'
|
||||
- value: Asia/Abu_Dhabi
|
||||
text: '(UTC+04:00) Abu Dhabi'
|
||||
- value: Asia/Baku
|
||||
text: '(UTC+04:00) Baku'
|
||||
- value: Europe/Moscow
|
||||
text: '(UTC+04:00) Moscow'
|
||||
- value: Asia/Muscat
|
||||
text: '(UTC+04:00) Muscat'
|
||||
- value: Europe/St_Petersburg
|
||||
text: '(UTC+04:00) St. Petersburg'
|
||||
- value: Asia/Tbilisi
|
||||
text: '(UTC+04:00) Tbilisi'
|
||||
- value: Asia/Yerevan
|
||||
text: '(UTC+04:00) Yerevan'
|
||||
- value: Asia/Kabul
|
||||
text: '(UTC+04:30) Kabul'
|
||||
- value: Asia/Islamabad
|
||||
text: '(UTC+05:00) Islamabad'
|
||||
- value: Asia/Karachi
|
||||
text: '(UTC+05:00) Karachi'
|
||||
- value: Asia/Tashkent
|
||||
text: '(UTC+05:00) Tashkent'
|
||||
- value: Asia/Calcutta
|
||||
text: '(UTC+05:30) Chennai'
|
||||
- value: Asia/Kolkata
|
||||
text: '(UTC+05:30) Kolkata'
|
||||
- value: Asia/Mumbai
|
||||
text: '(UTC+05:30) Mumbai'
|
||||
- value: Asia/New_Delhi
|
||||
text: '(UTC+05:30) New Delhi'
|
||||
- value: Asia/Sri_Jayawardenepura
|
||||
text: '(UTC+05:30) Sri Jayawardenepura'
|
||||
- value: Asia/Katmandu
|
||||
text: '(UTC+05:45) Kathmandu'
|
||||
- value: Asia/Almaty
|
||||
text: '(UTC+06:00) Almaty'
|
||||
- value: Asia/Astana
|
||||
text: '(UTC+06:00) Astana'
|
||||
- value: Asia/Dhaka
|
||||
text: '(UTC+06:00) Dhaka'
|
||||
- value: Asia/Yekaterinburg
|
||||
text: '(UTC+06:00) Ekaterinburg'
|
||||
- value: Asia/Rangoon
|
||||
text: '(UTC+06:30) Rangoon'
|
||||
- value: Asia/Bangkok
|
||||
text: '(UTC+07:00) Bangkok'
|
||||
- value: Asia/Hanoi
|
||||
text: '(UTC+07:00) Hanoi'
|
||||
- value: Asia/Jakarta
|
||||
text: '(UTC+07:00) Jakarta'
|
||||
- value: Asia/Novosibirsk
|
||||
text: '(UTC+07:00) Novosibirsk'
|
||||
- value: Asia/Beijing
|
||||
text: '(UTC+08:00) Beijing'
|
||||
- value: Asia/Chongqing
|
||||
text: '(UTC+08:00) Chongqing'
|
||||
- value: Asia/Hong_Kong
|
||||
text: '(UTC+08:00) Hong Kong'
|
||||
- value: Asia/Krasnoyarsk
|
||||
text: '(UTC+08:00) Krasnoyarsk'
|
||||
- value: Asia/Kuala_Lumpur
|
||||
text: '(UTC+08:00) Kuala Lumpur'
|
||||
- value: Australia/Perth
|
||||
text: '(UTC+08:00) Perth'
|
||||
- value: Asia/Singapore
|
||||
text: '(UTC+08:00) Singapore'
|
||||
- value: Asia/Taipei
|
||||
text: '(UTC+08:00) Taipei'
|
||||
- value: Asia/Ulan_Bator
|
||||
text: '(UTC+08:00) Ulaan Bataar'
|
||||
- value: Asia/Urumqi
|
||||
text: '(UTC+08:00) Urumqi'
|
||||
- value: Asia/Irkutsk
|
||||
text: '(UTC+09:00) Irkutsk'
|
||||
- value: Asia/Osaka
|
||||
text: '(UTC+09:00) Osaka'
|
||||
- value: Asia/Sapporo
|
||||
text: '(UTC+09:00) Sapporo'
|
||||
- value: Asia/Seoul
|
||||
text: '(UTC+09:00) Seoul'
|
||||
- value: Asia/Tokyo
|
||||
text: '(UTC+09:00) Tokyo'
|
||||
- value: Australia/Adelaide
|
||||
text: '(UTC+09:30) Adelaide'
|
||||
- value: Australia/Darwin
|
||||
text: '(UTC+09:30) Darwin'
|
||||
- value: Australia/Brisbane
|
||||
text: '(UTC+10:00) Brisbane'
|
||||
- value: Australia/Canberra
|
||||
text: '(UTC+10:00) Canberra'
|
||||
- value: Pacific/Guam
|
||||
text: '(UTC+10:00) Guam'
|
||||
- value: Australia/Hobart
|
||||
text: '(UTC+10:00) Hobart'
|
||||
- value: Australia/Melbourne
|
||||
text: '(UTC+10:00) Melbourne'
|
||||
- value: Pacific/Port_Moresby
|
||||
text: '(UTC+10:00) Port Moresby'
|
||||
- value: Australia/Sydney
|
||||
text: '(UTC+10:00) Sydney'
|
||||
- value: Asia/Yakutsk
|
||||
text: '(UTC+10:00) Yakutsk'
|
||||
- value: Asia/Vladivostok
|
||||
text: '(UTC+11:00) Vladivostok'
|
||||
- value: Pacific/Auckland
|
||||
text: '(UTC+12:00) Auckland'
|
||||
- value: Pacific/Fiji
|
||||
text: '(UTC+12:00) Fiji'
|
||||
- value: Pacific/Kwajalein
|
||||
text: '(UTC+12:00) International Date Line West'
|
||||
- value: Asia/Kamchatka
|
||||
text: '(UTC+12:00) Kamchatka'
|
||||
- value: Asia/Magadan
|
||||
text: '(UTC+12:00) Magadan'
|
||||
- value: Pacific/Marshall_Is
|
||||
text: '(UTC+12:00) Marshall Is.'
|
||||
- value: Asia/New_Caledonia
|
||||
text: '(UTC+12:00) New Caledonia'
|
||||
- value: Asia/Solomon_Is
|
||||
text: '(UTC+12:00) Solomon Is.'
|
||||
- value: Pacific/Wellington
|
||||
text: '(UTC+12:00) Wellington'
|
||||
- value: Pacific/Tongatapu
|
||||
text: "(UTC+13:00) Nuku'alofa"
|
||||
placeholder: Choose a timezone...
|
||||
sort: 11
|
||||
width: half
|
||||
- collection: directus_users
|
||||
field: language
|
||||
interface: dropdown
|
||||
@@ -444,7 +146,7 @@ fields:
|
||||
value: uk-UA
|
||||
- text: Vietnamese (Vietnam)
|
||||
value: vi-VN
|
||||
sort: 12
|
||||
sort: 11
|
||||
width: half
|
||||
- collection: directus_users
|
||||
field: theme
|
||||
@@ -458,14 +160,14 @@ fields:
|
||||
text: Light Mode
|
||||
- value: dark
|
||||
text: Dark Mode
|
||||
sort: 13
|
||||
sort: 12
|
||||
width: half
|
||||
- collection: directus_users
|
||||
field: tfa_secret
|
||||
interface: tfa-setup
|
||||
locked: true
|
||||
special: conceal
|
||||
sort: 14
|
||||
sort: 13
|
||||
width: half
|
||||
- collection: directus_users
|
||||
field: admin_divider
|
||||
@@ -476,7 +178,7 @@ fields:
|
||||
title: Admin Options
|
||||
color: '#F2994A'
|
||||
special: alias
|
||||
sort: 15
|
||||
sort: 14
|
||||
width: full
|
||||
- collection: directus_users
|
||||
field: status
|
||||
@@ -494,7 +196,7 @@ fields:
|
||||
value: suspended
|
||||
- text: Archived
|
||||
value: archived
|
||||
sort: 16
|
||||
sort: 15
|
||||
width: half
|
||||
- collection: directus_users
|
||||
field: role
|
||||
@@ -503,7 +205,7 @@ fields:
|
||||
options:
|
||||
template: '{{ name }}'
|
||||
special: m2o
|
||||
sort: 17
|
||||
sort: 16
|
||||
width: half
|
||||
- collection: directus_users
|
||||
field: token
|
||||
@@ -512,7 +214,7 @@ fields:
|
||||
options:
|
||||
iconRight: vpn_key
|
||||
placeholder: Enter a secure access token...
|
||||
sort: 18
|
||||
sort: 17
|
||||
width: full
|
||||
- collection: directus_users
|
||||
field: id
|
||||
@@ -521,5 +223,5 @@ fields:
|
||||
locked: true
|
||||
options:
|
||||
iconRight: vpn_key
|
||||
sort: 19
|
||||
sort: 18
|
||||
width: full
|
||||
|
||||
@@ -33,6 +33,7 @@ fields:
|
||||
special: json
|
||||
sort: 3
|
||||
width: full
|
||||
display: tags
|
||||
- collection: directus_files
|
||||
field: location
|
||||
interface: text-input
|
||||
@@ -91,3 +92,23 @@ fields:
|
||||
- collection: directus_files
|
||||
field: filesize
|
||||
display: filesize
|
||||
- collection: directus_files
|
||||
field: modified_by
|
||||
interface: user
|
||||
locked: true
|
||||
special: user-updated
|
||||
width: half
|
||||
display: user
|
||||
- collection: directus_files
|
||||
field: modified_on
|
||||
interface: dateTime
|
||||
locked: true
|
||||
special: date-updated
|
||||
width: half
|
||||
display: datetime
|
||||
- collection: directus_files
|
||||
field: created_on
|
||||
display: datetime
|
||||
- collection: directus_files
|
||||
field: created_by
|
||||
display: user
|
||||
@@ -10,7 +10,7 @@ fields:
|
||||
placeholder: My project...
|
||||
sort: 1
|
||||
translations:
|
||||
locale: en-US
|
||||
language: en-US
|
||||
translations: Name
|
||||
width: half
|
||||
- collection: directus_settings
|
||||
@@ -22,7 +22,7 @@ fields:
|
||||
placeholder: https://example.com
|
||||
sort: 2
|
||||
translations:
|
||||
locale: en-US
|
||||
language: en-US
|
||||
translations: Website
|
||||
width: half
|
||||
- collection: directus_settings
|
||||
@@ -32,7 +32,7 @@ fields:
|
||||
note: Login & Logo Background
|
||||
sort: 3
|
||||
translations:
|
||||
locale: en-US
|
||||
language: en-US
|
||||
translations: Brand Color
|
||||
width: half
|
||||
- collection: directus_settings
|
||||
@@ -42,7 +42,7 @@ fields:
|
||||
note: White 40x40 SVG/PNG
|
||||
sort: 4
|
||||
translations:
|
||||
locale: en-US
|
||||
language: en-US
|
||||
translations: Brand Logo
|
||||
width: half
|
||||
- collection: directus_settings
|
||||
@@ -62,7 +62,7 @@ fields:
|
||||
locked: true
|
||||
sort: 6
|
||||
translations:
|
||||
locale: en-US
|
||||
language: en-US
|
||||
translations: Login Foreground
|
||||
width: half
|
||||
- collection: directus_settings
|
||||
@@ -71,7 +71,7 @@ fields:
|
||||
locked: true
|
||||
sort: 7
|
||||
translations:
|
||||
locale: en-US
|
||||
language: en-US
|
||||
translations: Login Background
|
||||
width: half
|
||||
- collection: directus_settings
|
||||
@@ -203,3 +203,23 @@ fields:
|
||||
field: id
|
||||
hidden: true
|
||||
locked: true
|
||||
- collection: directus_settings
|
||||
field: overrides_divider
|
||||
interface: divider
|
||||
locked: true
|
||||
options:
|
||||
icon: brush
|
||||
title: App Overrides
|
||||
color: '#2F80ED'
|
||||
special: alias
|
||||
sort: 15
|
||||
width: full
|
||||
- collection: directus_settings
|
||||
field: custom_css
|
||||
interface: code
|
||||
locked: true
|
||||
options:
|
||||
language: css
|
||||
lineNumber: true
|
||||
sort: 16
|
||||
width: full
|
||||
|
||||
@@ -68,6 +68,7 @@ fields:
|
||||
locked: true
|
||||
options:
|
||||
label: Send Event Data
|
||||
special: boolean
|
||||
sort: 5
|
||||
width: half
|
||||
- collection: directus_webhooks
|
||||
|
||||
7
api/src/database/seeds/03-fields/12-relations.yaml
Normal file
7
api/src/database/seeds/03-fields/12-relations.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
table: directus_relations
|
||||
|
||||
fields:
|
||||
- collection: directus_relations
|
||||
field: one_allowed_collections
|
||||
locked: true
|
||||
special: csv
|
||||
@@ -62,6 +62,12 @@ function processValues(env: Record<string, any>) {
|
||||
if (value === 'true') env[key] = true;
|
||||
if (value === 'false') env[key] = false;
|
||||
if (value === 'null') env[key] = null;
|
||||
if (isNaN(value) === false && value.length > 0) env[key] = Number(value);
|
||||
if (typeof value === 'string' && value.includes(','))
|
||||
env[key] = value
|
||||
.split(',')
|
||||
.map((val) => val.trim())
|
||||
.filter((val) => val);
|
||||
}
|
||||
|
||||
return env;
|
||||
|
||||
@@ -3,8 +3,10 @@ export * from './failed-validation';
|
||||
export * from './forbidden';
|
||||
export * from './hit-rate-limit';
|
||||
export * from './invalid-credentials';
|
||||
export * from './invalid-ip';
|
||||
export * from './invalid-otp';
|
||||
export * from './invalid-payload';
|
||||
export * from './invalid-query';
|
||||
export * from './route-not-found';
|
||||
export * from './service-unavailable';
|
||||
export * from './unprocessable-entity';
|
||||
|
||||
7
api/src/exceptions/invalid-ip.ts
Normal file
7
api/src/exceptions/invalid-ip.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { BaseException } from './base';
|
||||
|
||||
export class InvalidIPException extends BaseException {
|
||||
constructor(message = 'Invalid IP address.') {
|
||||
super(message, 401, 'INVALID_IP');
|
||||
}
|
||||
}
|
||||
7
api/src/exceptions/unprocessable-entity.ts
Normal file
7
api/src/exceptions/unprocessable-entity.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { BaseException } from './base';
|
||||
|
||||
export class UnprocessableEntityException extends BaseException {
|
||||
constructor(message: string) {
|
||||
super(message, 422, 'UNPROCESSABLE_ENTITY');
|
||||
}
|
||||
}
|
||||
@@ -78,9 +78,16 @@ function registerHooks(hooks: string[]) {
|
||||
|
||||
function registerHook(hook: string) {
|
||||
const hookPath = path.resolve(extensionsPath, 'hooks', hook, 'index.js');
|
||||
const register: HookRegisterFunction = require(hookPath);
|
||||
const events = register({ services, exceptions, env, database });
|
||||
const hookInstance: HookRegisterFunction | { default?: HookRegisterFunction } = require(hookPath);
|
||||
|
||||
let register: HookRegisterFunction = hookInstance as HookRegisterFunction;
|
||||
if (typeof hookInstance !== "function") {
|
||||
if (hookInstance.default) {
|
||||
register = hookInstance.default;
|
||||
}
|
||||
}
|
||||
|
||||
let events = register({ services, exceptions, env, database });
|
||||
for (const [event, handler] of Object.entries(events)) {
|
||||
emitter.on(event, handler);
|
||||
}
|
||||
@@ -101,7 +108,14 @@ function registerEndpoints(endpoints: string[], router: Router) {
|
||||
|
||||
function registerEndpoint(endpoint: string) {
|
||||
const endpointPath = path.resolve(extensionsPath, 'endpoints', endpoint, 'index.js');
|
||||
const register: EndpointRegisterFunction = require(endpointPath);
|
||||
const endpointInstance: EndpointRegisterFunction | { default?: EndpointRegisterFunction } = require(endpointPath);
|
||||
|
||||
let register: EndpointRegisterFunction = endpointInstance as EndpointRegisterFunction;
|
||||
if (typeof endpointInstance !== "function") {
|
||||
if (endpointInstance.default) {
|
||||
register = endpointInstance.default;
|
||||
}
|
||||
}
|
||||
|
||||
const scopedRouter = express.Router();
|
||||
router.use(`/${endpoint}/`, scopedRouter);
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
*/
|
||||
|
||||
import env from './env';
|
||||
import { toArray } from './utils/to-array';
|
||||
|
||||
const enabledProviders = (env.OAUTH_PROVIDERS as string)
|
||||
.split(',')
|
||||
.map((provider) => provider.trim().toLowerCase());
|
||||
const enabledProviders = toArray(env.OAUTH_PROVIDERS).map((provider) => provider.toLowerCase());
|
||||
|
||||
const config: any = {
|
||||
defaults: {
|
||||
|
||||
@@ -14,7 +14,7 @@ const authenticate: RequestHandler = asyncHandler(async (req, res, next) => {
|
||||
user: null,
|
||||
role: null,
|
||||
admin: false,
|
||||
ip: req.ip,
|
||||
ip: req.ip.startsWith('::ffff:') ? req.ip.substring(7) : req.ip,
|
||||
userAgent: req.get('user-agent'),
|
||||
};
|
||||
|
||||
@@ -74,7 +74,9 @@ const authenticate: RequestHandler = asyncHandler(async (req, res, next) => {
|
||||
}
|
||||
|
||||
if (req.accountability?.user) {
|
||||
await database('directus_users').update({ last_access: new Date() }).where({ id: req.accountability.user });
|
||||
await database('directus_users')
|
||||
.update({ last_access: new Date() })
|
||||
.where({ id: req.accountability.user });
|
||||
}
|
||||
|
||||
return next();
|
||||
|
||||
18
api/src/middleware/check-ip.ts
Normal file
18
api/src/middleware/check-ip.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import asyncHandler from 'express-async-handler';
|
||||
import database from '../database';
|
||||
import { InvalidIPException } from '../exceptions';
|
||||
|
||||
export const checkIP: RequestHandler = asyncHandler(async (req, res, next) => {
|
||||
const role = await database
|
||||
.select('ip_access')
|
||||
.from('directus_roles')
|
||||
.where({ id: req.accountability!.role })
|
||||
.first();
|
||||
|
||||
const ipAllowlist = (role?.ip_access || '').split(',').filter((ip: string) => ip);
|
||||
|
||||
if (ipAllowlist.length > 0 && ipAllowlist.includes(req.accountability!.ip) === false)
|
||||
throw new InvalidIPException();
|
||||
return next();
|
||||
});
|
||||
@@ -2,13 +2,14 @@ import { ErrorRequestHandler } from 'express';
|
||||
import { BaseException } from '../exceptions';
|
||||
import logger from '../logger';
|
||||
import env from '../env';
|
||||
import { toArray } from '../utils/to-array';
|
||||
|
||||
const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
|
||||
let payload: any = {
|
||||
errors: [],
|
||||
};
|
||||
|
||||
const errors = Array.isArray(err) ? err : [err];
|
||||
const errors = toArray(err);
|
||||
|
||||
if (errors.some((err) => err instanceof BaseException === false)) {
|
||||
res.status(500);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { RequestHandler } from 'express';
|
||||
import { sanitizeQuery } from '../utils/sanitize-query';
|
||||
import { validateQuery } from '../utils/validate-query';
|
||||
|
||||
const sanitizeQueryMiddleware: RequestHandler = (req, res, next) => {
|
||||
req.sanitizedQuery = {};
|
||||
@@ -13,15 +14,16 @@ const sanitizeQueryMiddleware: RequestHandler = (req, res, next) => {
|
||||
req.sanitizedQuery = sanitizeQuery(
|
||||
{
|
||||
fields: req.query.fields || '*',
|
||||
...req.query
|
||||
...req.query,
|
||||
},
|
||||
req.accountability || null
|
||||
);
|
||||
|
||||
Object.freeze(req.sanitizedQuery);
|
||||
|
||||
validateQuery(req.sanitizedQuery);
|
||||
|
||||
return next();
|
||||
};
|
||||
|
||||
export default sanitizeQueryMiddleware;
|
||||
|
||||
|
||||
@@ -1,16 +1,43 @@
|
||||
import app from './app';
|
||||
import logger from './logger';
|
||||
import env from './env';
|
||||
import { validateDBConnection } from './database';
|
||||
import { createTerminus, TerminusOptions } from '@godaddy/terminus';
|
||||
import http from 'http';
|
||||
import emitter from './emitter';
|
||||
import database from './database';
|
||||
import app from './app';
|
||||
|
||||
export default async function start() {
|
||||
await validateDBConnection();
|
||||
const server = http.createServer(app);
|
||||
|
||||
const port = env.NODE_ENV === 'development' ? 8055 : env.PORT;
|
||||
const terminusOptions: TerminusOptions = {
|
||||
timeout: 1000,
|
||||
signals: ['SIGINT', 'SIGTERM', 'SIGHUP'],
|
||||
beforeShutdown,
|
||||
onSignal,
|
||||
onShutdown,
|
||||
};
|
||||
|
||||
app.listen(port, () => {
|
||||
logger.info(`Server started at port ${port}`);
|
||||
});
|
||||
createTerminus(server, terminusOptions);
|
||||
|
||||
export default server;
|
||||
|
||||
async function beforeShutdown() {
|
||||
await emitter.emitAsync('server.stop.before', { server });
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
logger.info('Restarting...');
|
||||
} else {
|
||||
logger.info('Shutting down...');
|
||||
}
|
||||
}
|
||||
|
||||
start();
|
||||
async function onSignal() {
|
||||
await database.destroy();
|
||||
logger.info('Database connections destroyed');
|
||||
}
|
||||
|
||||
async function onShutdown() {
|
||||
emitter.emitAsync('server.stop').catch((err) => logger.warn(err));
|
||||
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
logger.info('Directus shut down OK. Bye bye!');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
Accountability,
|
||||
AbstractServiceOptions,
|
||||
AST,
|
||||
NestedCollectionAST,
|
||||
FieldAST,
|
||||
NestedCollectionNode,
|
||||
FieldNode,
|
||||
Query,
|
||||
Permission,
|
||||
PermissionsAction,
|
||||
@@ -18,6 +18,7 @@ import { uniq, merge, flatten } from 'lodash';
|
||||
import generateJoi from '../utils/generate-joi';
|
||||
import { ItemsService } from './items';
|
||||
import { parseFilter } from '../utils/parse-filter';
|
||||
import { toArray } from '../utils/to-array';
|
||||
|
||||
export class AuthorizationService {
|
||||
knex: Knex;
|
||||
@@ -74,30 +75,35 @@ export class AuthorizationService {
|
||||
* Traverses the AST and returns an array of all collections that are being fetched
|
||||
*/
|
||||
function getCollectionsFromAST(
|
||||
ast: AST | NestedCollectionAST
|
||||
ast: AST | NestedCollectionNode
|
||||
): { collection: string; field: string }[] {
|
||||
const collections = [];
|
||||
|
||||
if (ast.type === 'collection') {
|
||||
if (ast.type === 'm2a') {
|
||||
collections.push(
|
||||
...ast.names.map((name) => ({ collection: name, field: ast.fieldKey }))
|
||||
);
|
||||
|
||||
/** @TODO add nestedNode */
|
||||
} else {
|
||||
collections.push({
|
||||
collection: ast.name,
|
||||
field: (ast as NestedCollectionAST).fieldKey
|
||||
? (ast as NestedCollectionAST).fieldKey
|
||||
: null,
|
||||
field: ast.type === 'root' ? null : ast.fieldKey,
|
||||
});
|
||||
}
|
||||
|
||||
for (const subAST of ast.children) {
|
||||
if (subAST.type === 'collection') {
|
||||
collections.push(...getCollectionsFromAST(subAST));
|
||||
for (const nestedNode of ast.children) {
|
||||
if (nestedNode.type !== 'field') {
|
||||
collections.push(...getCollectionsFromAST(nestedNode));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return collections as { collection: string; field: string }[];
|
||||
}
|
||||
|
||||
function validateFields(ast: AST | NestedCollectionAST) {
|
||||
if (ast.type === 'collection') {
|
||||
function validateFields(ast: AST | NestedCollectionNode | FieldNode) {
|
||||
if (ast.type !== 'field' && ast.type !== 'm2a') {
|
||||
/** @TODO remove m2a check */
|
||||
const collection = ast.name;
|
||||
|
||||
// We check the availability of the permissions in the step before this is run
|
||||
@@ -107,15 +113,15 @@ export class AuthorizationService {
|
||||
|
||||
const allowedFields = permissions.fields?.split(',') || [];
|
||||
|
||||
for (const childAST of ast.children) {
|
||||
if (childAST.type === 'collection') {
|
||||
validateFields(childAST);
|
||||
for (const childNode of ast.children) {
|
||||
if (childNode.type !== 'field') {
|
||||
validateFields(childNode);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (allowedFields.includes('*')) continue;
|
||||
|
||||
const fieldKey = childAST.name;
|
||||
const fieldKey = childNode.name;
|
||||
|
||||
if (allowedFields.includes(fieldKey) === false) {
|
||||
throw new ForbiddenException(
|
||||
@@ -127,10 +133,11 @@ export class AuthorizationService {
|
||||
}
|
||||
|
||||
function applyFilters(
|
||||
ast: AST | NestedCollectionAST | FieldAST,
|
||||
ast: AST | NestedCollectionNode | FieldNode,
|
||||
accountability: Accountability | null
|
||||
): AST | NestedCollectionAST | FieldAST {
|
||||
if (ast.type === 'collection') {
|
||||
): AST | NestedCollectionNode | FieldNode {
|
||||
if (ast.type !== 'field' && ast.type !== 'm2a') {
|
||||
/** @TODO remove m2a check */
|
||||
const collection = ast.name;
|
||||
|
||||
// We check the availability of the permissions in the step before this is run
|
||||
@@ -164,8 +171,8 @@ export class AuthorizationService {
|
||||
}
|
||||
|
||||
ast.children = ast.children.map((child) => applyFilters(child, accountability)) as (
|
||||
| NestedCollectionAST
|
||||
| FieldAST
|
||||
| NestedCollectionNode
|
||||
| FieldNode
|
||||
)[];
|
||||
}
|
||||
|
||||
@@ -193,12 +200,22 @@ export class AuthorizationService {
|
||||
): Promise<Partial<Item>[] | Partial<Item>> {
|
||||
const validationErrors: FailedValidationException[] = [];
|
||||
|
||||
let payloads = Array.isArray(payload) ? payload : [payload];
|
||||
let payloads = toArray(payload);
|
||||
|
||||
let permission: Permission | undefined;
|
||||
|
||||
if (this.accountability?.admin === true) {
|
||||
permission = { id: 0, role: this.accountability?.role, collection, action, permissions: {}, validation: {}, limit: null, fields: '*', presets: {}, }
|
||||
permission = {
|
||||
id: 0,
|
||||
role: this.accountability?.role,
|
||||
collection,
|
||||
action,
|
||||
permissions: {},
|
||||
validation: {},
|
||||
limit: null,
|
||||
fields: '*',
|
||||
presets: {},
|
||||
};
|
||||
} else {
|
||||
permission = await this.knex
|
||||
.select<Permission>('*')
|
||||
@@ -238,10 +255,23 @@ export class AuthorizationService {
|
||||
let requiredColumns: string[] = [];
|
||||
|
||||
for (const column of columns) {
|
||||
const field = await this.knex.select<{ special: string }>('special').from('directus_fields').where({ collection, field: column.name }).first();
|
||||
const field = await this.knex
|
||||
.select<{ special: string }>('special')
|
||||
.from('directus_fields')
|
||||
.where({ collection, field: column.name })
|
||||
.first();
|
||||
const specials = (field?.special || '').split(',');
|
||||
const hasGenerateSpecial = ['uuid', 'date-created', 'role-created', 'user-created'].some((name) => specials.includes(name));
|
||||
const isRequired = column.is_nullable === false && column.has_auto_increment === false && column.default_value === null && hasGenerateSpecial === false;
|
||||
const hasGenerateSpecial = [
|
||||
'uuid',
|
||||
'date-created',
|
||||
'role-created',
|
||||
'user-created',
|
||||
].some((name) => specials.includes(name));
|
||||
const isRequired =
|
||||
column.is_nullable === false &&
|
||||
column.has_auto_increment === false &&
|
||||
column.default_value === null &&
|
||||
hasGenerateSpecial === false;
|
||||
|
||||
if (isRequired) {
|
||||
requiredColumns.push(column.name);
|
||||
@@ -250,23 +280,20 @@ export class AuthorizationService {
|
||||
|
||||
if (requiredColumns.length > 0) {
|
||||
permission.validation = {
|
||||
_and: [
|
||||
permission.validation,
|
||||
{}
|
||||
]
|
||||
}
|
||||
_and: [permission.validation, {}],
|
||||
};
|
||||
|
||||
if (action === 'create') {
|
||||
for (const name of requiredColumns) {
|
||||
permission.validation._and[1][name] = {
|
||||
_required: true
|
||||
}
|
||||
_required: true,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
for (const name of requiredColumns) {
|
||||
permission.validation._and[1][name] = {
|
||||
_nnull: true
|
||||
}
|
||||
_nnull: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -282,7 +309,10 @@ export class AuthorizationService {
|
||||
}
|
||||
}
|
||||
|
||||
validateJoi(validation: Record<string, any>, payloads: Partial<Record<string, any>>[]): FailedValidationException[] {
|
||||
validateJoi(
|
||||
validation: Record<string, any>,
|
||||
payloads: Partial<Record<string, any>>[]
|
||||
): FailedValidationException[] {
|
||||
const errors: FailedValidationException[] = [];
|
||||
|
||||
/**
|
||||
@@ -291,13 +321,21 @@ export class AuthorizationService {
|
||||
|
||||
if (Object.keys(validation)[0] === '_and') {
|
||||
const subValidation = Object.values(validation)[0];
|
||||
const nestedErrors = flatten<FailedValidationException>(subValidation.map((subObj: Record<string, any>) => this.validateJoi(subObj, payloads))).filter((err?: FailedValidationException) => err);
|
||||
const nestedErrors = flatten<FailedValidationException>(
|
||||
subValidation.map((subObj: Record<string, any>) =>
|
||||
this.validateJoi(subObj, payloads)
|
||||
)
|
||||
).filter((err?: FailedValidationException) => err);
|
||||
errors.push(...nestedErrors);
|
||||
}
|
||||
|
||||
if (Object.keys(validation)[0] === '_or') {
|
||||
const subValidation = Object.values(validation)[0];
|
||||
const nestedErrors = flatten<FailedValidationException>(subValidation.map((subObj: Record<string, any>) => this.validateJoi(subObj, payloads)));
|
||||
const nestedErrors = flatten<FailedValidationException>(
|
||||
subValidation.map((subObj: Record<string, any>) =>
|
||||
this.validateJoi(subObj, payloads)
|
||||
)
|
||||
);
|
||||
const allErrored = nestedErrors.every((err?: FailedValidationException) => err);
|
||||
|
||||
if (allErrored) {
|
||||
@@ -311,7 +349,9 @@ export class AuthorizationService {
|
||||
const { error } = schema.validate(payload, { abortEarly: false });
|
||||
|
||||
if (error) {
|
||||
errors.push(...error.details.map((details) => new FailedValidationException(details)));
|
||||
errors.push(
|
||||
...error.details.map((details) => new FailedValidationException(details))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import SchemaInspector from 'knex-schema-inspector';
|
||||
import { FieldsService } from '../services/fields';
|
||||
import { ItemsService } from '../services/items';
|
||||
import cache from '../cache';
|
||||
import { toArray } from '../utils/to-array';
|
||||
|
||||
export class CollectionsService {
|
||||
knex: Knex;
|
||||
@@ -23,7 +24,7 @@ export class CollectionsService {
|
||||
throw new ForbiddenException('Only admins can perform this action.');
|
||||
}
|
||||
|
||||
const payloads = (Array.isArray(data) ? data : [data]).map((collection) => {
|
||||
const payloads = toArray(data).map((collection) => {
|
||||
if (!collection.fields) collection.fields = [];
|
||||
|
||||
collection.fields = collection.fields.map((field) => {
|
||||
@@ -105,7 +106,7 @@ export class CollectionsService {
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
});
|
||||
const collectionKeys = Array.isArray(collection) ? collection : [collection];
|
||||
const collectionKeys = toArray(collection);
|
||||
|
||||
if (this.accountability && this.accountability.admin !== true) {
|
||||
const permissions = await this.knex
|
||||
@@ -132,9 +133,9 @@ export class CollectionsService {
|
||||
|
||||
const tablesInDatabase = await schemaInspector.tableInfo();
|
||||
const tables = tablesInDatabase.filter((table) => collectionKeys.includes(table.name));
|
||||
const meta = await collectionItemsService.readByQuery({
|
||||
const meta = (await collectionItemsService.readByQuery({
|
||||
filter: { collection: { _in: collectionKeys } },
|
||||
}) as Collection['meta'][];
|
||||
})) as Collection['meta'][];
|
||||
|
||||
const collections: Collection[] = [];
|
||||
|
||||
@@ -170,9 +171,9 @@ export class CollectionsService {
|
||||
}
|
||||
|
||||
const tablesToFetchInfoFor = tablesInDatabase.map((table) => table.name);
|
||||
const meta = await collectionItemsService.readByQuery({
|
||||
const meta = (await collectionItemsService.readByQuery({
|
||||
filter: { collection: { _in: tablesToFetchInfoFor } },
|
||||
}) as Collection['meta'][];
|
||||
})) as Collection['meta'][];
|
||||
|
||||
const collections: Collection[] = [];
|
||||
|
||||
@@ -212,7 +213,7 @@ export class CollectionsService {
|
||||
throw new InvalidPayloadException(`"meta" key is required`);
|
||||
}
|
||||
|
||||
const keys = Array.isArray(key) ? key : [key];
|
||||
const keys = toArray(key);
|
||||
|
||||
for (const key of keys) {
|
||||
const exists =
|
||||
@@ -232,7 +233,7 @@ export class CollectionsService {
|
||||
return key;
|
||||
}
|
||||
|
||||
const payloads = Array.isArray(data) ? data : [data];
|
||||
const payloads = toArray(data);
|
||||
|
||||
const collectionUpdates = payloads.map((collection) => {
|
||||
return {
|
||||
@@ -264,7 +265,7 @@ export class CollectionsService {
|
||||
|
||||
const tablesInDatabase = await schemaInspector.tables();
|
||||
|
||||
const collectionKeys = Array.isArray(collection) ? collection : [collection];
|
||||
const collectionKeys = toArray(collection);
|
||||
|
||||
for (const collectionKey of collectionKeys) {
|
||||
if (tablesInDatabase.includes(collectionKey) === false) {
|
||||
@@ -287,11 +288,14 @@ export class CollectionsService {
|
||||
for (const relation of relations) {
|
||||
const isM2O = relation.many_collection === collection;
|
||||
|
||||
/** @TODO M2A — Handle m2a case here */
|
||||
|
||||
if (isM2O) {
|
||||
await this.knex('directus_relations')
|
||||
.delete()
|
||||
.where({ many_collection: collection, many_field: relation.many_field });
|
||||
await fieldsService.deleteField(relation.one_collection, relation.one_field);
|
||||
|
||||
await fieldsService.deleteField(relation.one_collection!, relation.one_field!);
|
||||
} else {
|
||||
await this.knex('directus_relations')
|
||||
.update({ one_field: null })
|
||||
|
||||
@@ -52,14 +52,14 @@ export class FieldsService {
|
||||
});
|
||||
|
||||
const columnsWithSystem = columns.map((column) => {
|
||||
const field = fields.find(
|
||||
(field) => field.field === column.name && field.collection === column.table
|
||||
);
|
||||
const field = fields.find((field) => {
|
||||
return field.field === column.name && field.collection === column.table;
|
||||
});
|
||||
|
||||
const data = {
|
||||
collection: column.table,
|
||||
field: column.name,
|
||||
type: column ? getLocalType(column.type, field?.special) : 'alias',
|
||||
type: column ? getLocalType(column, field) : 'alias',
|
||||
schema: column,
|
||||
meta: field || null,
|
||||
};
|
||||
@@ -171,7 +171,7 @@ export class FieldsService {
|
||||
const data = {
|
||||
collection,
|
||||
field,
|
||||
type: column ? getLocalType(column.type, fieldInfo?.special) : 'alias',
|
||||
type: column ? getLocalType(column, fieldInfo) : 'alias',
|
||||
meta: fieldInfo || null,
|
||||
schema: column || null,
|
||||
};
|
||||
@@ -248,8 +248,11 @@ export class FieldsService {
|
||||
);
|
||||
} else if (['float', 'decimal'].includes(field.type)) {
|
||||
const type = field.type as 'float' | 'decimal';
|
||||
/** @todo add precision and scale support */
|
||||
column = table[type](field.field /* precision, scale */);
|
||||
column = table[type](
|
||||
field.field,
|
||||
field.schema?.precision || 10,
|
||||
field.schema?.scale || 5
|
||||
);
|
||||
} else if (field.type === 'csv') {
|
||||
column = table.string(field.field);
|
||||
} else {
|
||||
@@ -325,11 +328,13 @@ export class FieldsService {
|
||||
for (const relation of relations) {
|
||||
const isM2O = relation.many_collection === collection && relation.many_field === field;
|
||||
|
||||
/** @TODO M2A — Handle m2a case here */
|
||||
|
||||
if (isM2O) {
|
||||
await this.knex('directus_relations')
|
||||
.delete()
|
||||
.where({ many_collection: collection, many_field: field });
|
||||
await this.deleteField(relation.one_collection, relation.one_field);
|
||||
await this.deleteField(relation.one_collection!, relation.one_field!);
|
||||
} else {
|
||||
await this.knex('directus_relations')
|
||||
.update({ one_field: null })
|
||||
|
||||
@@ -9,6 +9,7 @@ import { AbstractServiceOptions, File, PrimaryKey } from '../types';
|
||||
import { clone } from 'lodash';
|
||||
import cache from '../cache';
|
||||
import { ForbiddenException } from '../exceptions';
|
||||
import { toArray } from '../utils/to-array';
|
||||
|
||||
export class FilesService extends ItemsService {
|
||||
constructor(options?: AbstractServiceOptions) {
|
||||
@@ -89,14 +90,14 @@ export class FilesService extends ItemsService {
|
||||
delete(key: PrimaryKey): Promise<PrimaryKey>;
|
||||
delete(keys: PrimaryKey[]): Promise<PrimaryKey[]>;
|
||||
async delete(key: PrimaryKey | PrimaryKey[]): Promise<PrimaryKey | PrimaryKey[]> {
|
||||
const keys = Array.isArray(key) ? key : [key];
|
||||
const keys = toArray(key);
|
||||
let files = await super.readByKey(keys, { fields: ['id', 'storage'] });
|
||||
|
||||
if (!files) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
files = Array.isArray(files) ? files : [files];
|
||||
files = toArray(files);
|
||||
|
||||
for (const file of files) {
|
||||
const disk = storage.disk(file.storage);
|
||||
|
||||
@@ -1,7 +1,37 @@
|
||||
import Knex from 'knex';
|
||||
import database from '../database';
|
||||
import { AbstractServiceOptions, Accountability, Collection, Field, Relation, Query, AbstractService } from '../types';
|
||||
import { GraphQLString, GraphQLSchema, GraphQLObjectType, GraphQLList, GraphQLResolveInfo, GraphQLInputObjectType, ObjectFieldNode, GraphQLID, ValueNode, FieldNode, GraphQLFieldConfigMap, GraphQLInt, IntValueNode, StringValueNode, BooleanValueNode, ArgumentNode, GraphQLScalarType, GraphQLBoolean, ObjectValueNode } from 'graphql';
|
||||
import {
|
||||
AbstractServiceOptions,
|
||||
Accountability,
|
||||
Collection,
|
||||
Field,
|
||||
Relation,
|
||||
Query,
|
||||
AbstractService,
|
||||
} from '../types';
|
||||
import {
|
||||
GraphQLString,
|
||||
GraphQLSchema,
|
||||
GraphQLObjectType,
|
||||
GraphQLList,
|
||||
GraphQLResolveInfo,
|
||||
GraphQLInputObjectType,
|
||||
ObjectFieldNode,
|
||||
GraphQLID,
|
||||
ValueNode,
|
||||
FieldNode,
|
||||
GraphQLFieldConfigMap,
|
||||
GraphQLInt,
|
||||
IntValueNode,
|
||||
StringValueNode,
|
||||
BooleanValueNode,
|
||||
ArgumentNode,
|
||||
GraphQLScalarType,
|
||||
GraphQLBoolean,
|
||||
ObjectValueNode,
|
||||
GraphQLUnionType,
|
||||
GraphQLUnionTypeConfig,
|
||||
} from 'graphql';
|
||||
import { getGraphQLType } from '../utils/get-graphql-type';
|
||||
import { RelationsService } from './relations';
|
||||
import { ItemsService } from './items';
|
||||
@@ -21,6 +51,8 @@ import { SettingsService } from './settings';
|
||||
import { UsersService } from './users';
|
||||
import { WebhooksService } from './webhooks';
|
||||
|
||||
import { getRelationType } from '../utils/get-relation-type';
|
||||
|
||||
export class GraphQLService {
|
||||
accountability: Accountability | null;
|
||||
knex: Knex;
|
||||
@@ -38,7 +70,7 @@ export class GraphQLService {
|
||||
|
||||
args = {
|
||||
sort: {
|
||||
type: GraphQLString
|
||||
type: GraphQLString,
|
||||
},
|
||||
limit: {
|
||||
type: GraphQLInt,
|
||||
@@ -51,15 +83,19 @@ export class GraphQLService {
|
||||
},
|
||||
search: {
|
||||
type: GraphQLString,
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
async getSchema() {
|
||||
const collectionsInSystem = await this.collectionsService.readByQuery();
|
||||
const fieldsInSystem = await this.fieldsService.readAll();
|
||||
const relationsInSystem = await this.relationsService.readByQuery({}) as Relation[];
|
||||
const relationsInSystem = (await this.relationsService.readByQuery({})) as Relation[];
|
||||
|
||||
const schema = this.getGraphQLSchema(collectionsInSystem, fieldsInSystem, relationsInSystem);
|
||||
const schema = this.getGraphQLSchema(
|
||||
collectionsInSystem,
|
||||
fieldsInSystem,
|
||||
relationsInSystem
|
||||
);
|
||||
|
||||
return schema;
|
||||
}
|
||||
@@ -77,27 +113,46 @@ export class GraphQLService {
|
||||
description: collection.meta?.note,
|
||||
fields: () => {
|
||||
const fieldsObject: GraphQLFieldConfigMap<any, any> = {};
|
||||
const fieldsInCollection = fields.filter((field) => field.collection === collection.collection);
|
||||
const fieldsInCollection = fields.filter(
|
||||
(field) => field.collection === collection.collection
|
||||
);
|
||||
|
||||
for (const field of fieldsInCollection) {
|
||||
const relationForField = relations.find((relation) => {
|
||||
return relation.many_collection === collection.collection && relation.many_field === field.field ||
|
||||
relation.one_collection === collection.collection && relation.one_field === field.field;
|
||||
return (
|
||||
(relation.many_collection === collection.collection &&
|
||||
relation.many_field === field.field) ||
|
||||
(relation.one_collection === collection.collection &&
|
||||
relation.one_field === field.field)
|
||||
);
|
||||
});
|
||||
|
||||
if (relationForField) {
|
||||
const isM2O = relationForField.many_collection === collection.collection && relationForField.many_field === field.field;
|
||||
const relationType = getRelationType({
|
||||
relation: relationForField,
|
||||
collection: collection.collection,
|
||||
field: field.field,
|
||||
});
|
||||
|
||||
if (isM2O) {
|
||||
const relatedIsSystem = relationForField.one_collection.startsWith('directus_');
|
||||
const relatedType = relatedIsSystem ? schema[relationForField.one_collection.substring(9)].type : schema.items[relationForField.one_collection].type;
|
||||
if (relationType === 'm2o') {
|
||||
const relatedIsSystem = relationForField.one_collection!.startsWith(
|
||||
'directus_'
|
||||
);
|
||||
const relatedType = relatedIsSystem
|
||||
? schema[relationForField.one_collection!.substring(9)].type
|
||||
: schema.items[relationForField.one_collection!].type;
|
||||
|
||||
fieldsObject[field.field] = {
|
||||
type: relatedType,
|
||||
}
|
||||
} else {
|
||||
const relatedIsSystem = relationForField.many_collection.startsWith('directus_');
|
||||
const relatedType = relatedIsSystem ? schema[relationForField.many_collection.substring(9)].type : schema.items[relationForField.many_collection].type;
|
||||
};
|
||||
} else if (relationType === 'o2m') {
|
||||
const relatedIsSystem = relationForField.many_collection.startsWith(
|
||||
'directus_'
|
||||
);
|
||||
|
||||
const relatedType = relatedIsSystem
|
||||
? schema[relationForField.many_collection.substring(9)].type
|
||||
: schema.items[relationForField.many_collection].type;
|
||||
|
||||
fieldsObject[field.field] = {
|
||||
type: new GraphQLList(relatedType),
|
||||
@@ -105,14 +160,44 @@ export class GraphQLService {
|
||||
...this.args,
|
||||
filter: {
|
||||
type: filterTypes[relationForField.many_collection],
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
} else if (relationType === 'm2a') {
|
||||
const relatedCollections = relationForField.one_allowed_collections!;
|
||||
|
||||
const types: any = [];
|
||||
|
||||
for (const relatedCollection of relatedCollections) {
|
||||
const relatedType = relatedCollection.startsWith(
|
||||
'directus_'
|
||||
)
|
||||
? schema[relatedCollection.substring(9)].type
|
||||
: schema.items[relatedCollection].type;
|
||||
|
||||
types.push(relatedType);
|
||||
}
|
||||
|
||||
fieldsObject[field.field] = {
|
||||
type: new GraphQLUnionType({
|
||||
name: field.field,
|
||||
types,
|
||||
resolveType(value, _, info) {
|
||||
/**
|
||||
* @TODO figure out a way to reach the parent level
|
||||
* to be able to read one_collection_field
|
||||
*/
|
||||
return types[0];
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
fieldsObject[field.field] = {
|
||||
type: field.schema?.is_primary_key ? GraphQLID : getGraphQLType(field.type),
|
||||
}
|
||||
type: field.schema?.is_primary_key
|
||||
? GraphQLID
|
||||
: getGraphQLType(field.type),
|
||||
};
|
||||
}
|
||||
|
||||
fieldsObject[field.field].description = field.meta?.note;
|
||||
@@ -121,14 +206,15 @@ export class GraphQLService {
|
||||
return fieldsObject;
|
||||
},
|
||||
}),
|
||||
resolve: (source: any, args: any, context: any, info: GraphQLResolveInfo) => this.resolve(info),
|
||||
resolve: (source: any, args: any, context: any, info: GraphQLResolveInfo) =>
|
||||
this.resolve(info),
|
||||
args: {
|
||||
...this.args,
|
||||
filter: {
|
||||
name: `${collection.collection}_filter`,
|
||||
type: filterTypes[collection.collection],
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (systemCollection) {
|
||||
@@ -145,9 +231,13 @@ export class GraphQLService {
|
||||
const systemCollection = collection.collection.startsWith('directus_');
|
||||
|
||||
if (systemCollection) {
|
||||
schemaWithLists[collection.collection.substring(9)].type = new GraphQLList(schemaWithLists[collection.collection.substring(9)].type);
|
||||
schemaWithLists[collection.collection.substring(9)].type = new GraphQLList(
|
||||
schemaWithLists[collection.collection.substring(9)].type
|
||||
);
|
||||
} else {
|
||||
schemaWithLists.items[collection.collection].type = new GraphQLList(schemaWithLists.items[collection.collection].type);
|
||||
schemaWithLists.items[collection.collection].type = new GraphQLList(
|
||||
schemaWithLists.items[collection.collection].type
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -177,39 +267,55 @@ export class GraphQLService {
|
||||
fields: () => {
|
||||
const filterFields: any = {
|
||||
_and: {
|
||||
type: new GraphQLList(filterTypes[collection.collection])
|
||||
type: new GraphQLList(filterTypes[collection.collection]),
|
||||
},
|
||||
_or: {
|
||||
type: new GraphQLList(filterTypes[collection.collection])
|
||||
type: new GraphQLList(filterTypes[collection.collection]),
|
||||
},
|
||||
};
|
||||
|
||||
const fieldsInCollection = fields.filter((field) => field.collection === collection.collection);
|
||||
const fieldsInCollection = fields.filter(
|
||||
(field) => field.collection === collection.collection
|
||||
);
|
||||
|
||||
for (const field of fieldsInCollection) {
|
||||
const relationForField = relations.find((relation) => {
|
||||
return relation.many_collection === collection.collection && relation.many_field === field.field ||
|
||||
relation.one_collection === collection.collection && relation.one_field === field.field;
|
||||
return (
|
||||
(relation.many_collection === collection.collection &&
|
||||
relation.many_field === field.field) ||
|
||||
(relation.one_collection === collection.collection &&
|
||||
relation.one_field === field.field)
|
||||
);
|
||||
});
|
||||
|
||||
if (relationForField) {
|
||||
const isM2O = relationForField.many_collection === collection.collection && relationForField.many_field === field.field;
|
||||
const relationType = getRelationType({
|
||||
relation: relationForField,
|
||||
collection: collection.collection,
|
||||
field: field.field,
|
||||
});
|
||||
|
||||
if (isM2O) {
|
||||
const relatedType = filterTypes[relationForField.one_collection];
|
||||
if (relationType === 'm2o') {
|
||||
const relatedType = filterTypes[relationForField.one_collection!];
|
||||
|
||||
filterFields[field.field] = {
|
||||
type: relatedType,
|
||||
}
|
||||
} else {
|
||||
};
|
||||
} else if (relationType === 'o2m') {
|
||||
const relatedType = filterTypes[relationForField.many_collection];
|
||||
|
||||
filterFields[field.field] = {
|
||||
type: relatedType
|
||||
}
|
||||
type: relatedType,
|
||||
};
|
||||
}
|
||||
/** @TODO M2A — Handle m2a case here */
|
||||
/** @TODO
|
||||
* Figure out how to setup filter fields for a union type output
|
||||
*/
|
||||
} else {
|
||||
const fieldType = field.schema?.is_primary_key ? GraphQLID : getGraphQLType(field.type);
|
||||
const fieldType = field.schema?.is_primary_key
|
||||
? GraphQLID
|
||||
: getGraphQLType(field.type);
|
||||
|
||||
filterFields[field.field] = {
|
||||
type: new GraphQLInputObjectType({
|
||||
@@ -220,7 +326,7 @@ export class GraphQLService {
|
||||
type: fieldType,
|
||||
},
|
||||
_neq: {
|
||||
type: fieldType
|
||||
type: fieldType,
|
||||
},
|
||||
_contains: {
|
||||
type: fieldType,
|
||||
@@ -257,10 +363,10 @@ export class GraphQLService {
|
||||
},
|
||||
_nempty: {
|
||||
type: GraphQLBoolean,
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,20 +375,26 @@ export class GraphQLService {
|
||||
});
|
||||
}
|
||||
|
||||
return filterTypes
|
||||
return filterTypes;
|
||||
}
|
||||
|
||||
async resolve(info: GraphQLResolveInfo) {
|
||||
const systemField = info.path.prev?.key !== 'items';
|
||||
|
||||
const collection = systemField ? `directus_${info.fieldName}` : info.fieldName;
|
||||
const selections = info.fieldNodes[0]?.selectionSet?.selections?.filter((node) => node.kind === 'Field') as FieldNode[] | undefined;
|
||||
const selections = info.fieldNodes[0]?.selectionSet?.selections?.filter(
|
||||
(node) => node.kind === 'Field'
|
||||
) as FieldNode[] | undefined;
|
||||
if (!selections) return null;
|
||||
|
||||
return await this.getData(collection, selections, info.fieldNodes[0].arguments);
|
||||
}
|
||||
|
||||
async getData(collection: string, selections: FieldNode[], argsArray?: readonly ArgumentNode[]) {
|
||||
async getData(
|
||||
collection: string,
|
||||
selections: FieldNode[],
|
||||
argsArray?: readonly ArgumentNode[]
|
||||
) {
|
||||
const args: Record<string, any> = this.parseArgs(argsArray);
|
||||
|
||||
const query: Query = sanitizeQuery(args, this.accountability);
|
||||
@@ -296,7 +408,12 @@ export class GraphQLService {
|
||||
if (selection.selectionSet === undefined) {
|
||||
fields.push(current);
|
||||
} else {
|
||||
const children = parseFields(selection.selectionSet.selections.filter((selection) => selection.kind === 'Field') as FieldNode[], current);
|
||||
const children = parseFields(
|
||||
selection.selectionSet.selections.filter(
|
||||
(selection) => selection.kind === 'Field'
|
||||
) as FieldNode[],
|
||||
current
|
||||
);
|
||||
fields.push(...children);
|
||||
}
|
||||
|
||||
@@ -309,47 +426,95 @@ export class GraphQLService {
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
};
|
||||
|
||||
query.fields = parseFields(selections.filter((selection) => selection.kind === 'Field') as FieldNode[]);
|
||||
query.fields = parseFields(
|
||||
selections.filter((selection) => selection.kind === 'Field') as FieldNode[]
|
||||
);
|
||||
|
||||
let service: ItemsService;
|
||||
|
||||
switch (collection) {
|
||||
case 'directus_activity':
|
||||
service = new ActivityService({ knex: this.knex, accountability: this.accountability });
|
||||
service = new ActivityService({
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
});
|
||||
// case 'directus_collections':
|
||||
// service = new CollectionsService({ knex: this.knex, accountability: this.accountability });
|
||||
// case 'directus_fields':
|
||||
// service = new FieldsService({ knex: this.knex, accountability: this.accountability });
|
||||
case 'directus_files':
|
||||
service = new FilesService({ knex: this.knex, accountability: this.accountability });
|
||||
service = new FilesService({
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
});
|
||||
case 'directus_folders':
|
||||
service = new FoldersService({ knex: this.knex, accountability: this.accountability });
|
||||
service = new FoldersService({
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
});
|
||||
case 'directus_folders':
|
||||
service = new FoldersService({ knex: this.knex, accountability: this.accountability });
|
||||
service = new FoldersService({
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
});
|
||||
case 'directus_permissions':
|
||||
service = new PermissionsService({ knex: this.knex, accountability: this.accountability });
|
||||
service = new PermissionsService({
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
});
|
||||
case 'directus_presets':
|
||||
service = new PresetsService({ knex: this.knex, accountability: this.accountability });
|
||||
service = new PresetsService({
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
});
|
||||
case 'directus_relations':
|
||||
service = new RelationsService({ knex: this.knex, accountability: this.accountability });
|
||||
service = new RelationsService({
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
});
|
||||
case 'directus_revisions':
|
||||
service = new RevisionsService({ knex: this.knex, accountability: this.accountability });
|
||||
service = new RevisionsService({
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
});
|
||||
case 'directus_roles':
|
||||
service = new RolesService({ knex: this.knex, accountability: this.accountability });
|
||||
service = new RolesService({
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
});
|
||||
case 'directus_settings':
|
||||
service = new SettingsService({ knex: this.knex, accountability: this.accountability });
|
||||
service = new SettingsService({
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
});
|
||||
case 'directus_users':
|
||||
service = new UsersService({ knex: this.knex, accountability: this.accountability });
|
||||
service = new UsersService({
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
});
|
||||
case 'directus_webhooks':
|
||||
service = new WebhooksService({ knex: this.knex, accountability: this.accountability });
|
||||
service = new WebhooksService({
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
});
|
||||
default:
|
||||
service = new ItemsService(collection, { knex: this.knex, accountability: this.accountability });
|
||||
service = new ItemsService(collection, {
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
});
|
||||
}
|
||||
|
||||
const collectionInfo = await this.knex.select('singleton').from('directus_collections').where({ collection: collection }).first();
|
||||
const result = collectionInfo?.singleton === true ? await service.readSingleton(query) : await service.readByQuery(query);
|
||||
const collectionInfo = await this.knex
|
||||
.select('singleton')
|
||||
.from('directus_collections')
|
||||
.where({ collection: collection })
|
||||
.first();
|
||||
const result =
|
||||
collectionInfo?.singleton === true
|
||||
? await service.readSingleton(query)
|
||||
: await service.readByQuery(query);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -359,7 +524,7 @@ export class GraphQLService {
|
||||
|
||||
const parseObjectValue = (arg: ObjectValueNode) => {
|
||||
return this.parseArgs(arg.fields);
|
||||
}
|
||||
};
|
||||
|
||||
const argsObject: any = {};
|
||||
|
||||
@@ -379,11 +544,13 @@ export class GraphQLService {
|
||||
|
||||
argsObject[argument.name.value] = values;
|
||||
} else {
|
||||
argsObject[argument.name.value] = (argument.value as IntValueNode | StringValueNode | BooleanValueNode).value;
|
||||
argsObject[argument.name.value] = (argument.value as
|
||||
| IntValueNode
|
||||
| StringValueNode
|
||||
| BooleanValueNode).value;
|
||||
}
|
||||
}
|
||||
|
||||
return argsObject;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,4 +19,4 @@ export * from './settings';
|
||||
export * from './users';
|
||||
export * from './utils';
|
||||
export * from './webhooks';
|
||||
export * from './specifications'
|
||||
export * from './specifications';
|
||||
|
||||
@@ -16,11 +16,12 @@ import Knex from 'knex';
|
||||
import cache from '../cache';
|
||||
import emitter from '../emitter';
|
||||
import logger from '../logger';
|
||||
import { toArray } from '../utils/to-array';
|
||||
|
||||
import { PayloadService } from './payload';
|
||||
import { AuthorizationService } from './authorization';
|
||||
|
||||
import { pick, clone } from 'lodash';
|
||||
import { pick, clone, cloneDeep } from 'lodash';
|
||||
import getDefaultValue from '../utils/get-default-value';
|
||||
import { InvalidPayloadException } from '../exceptions';
|
||||
|
||||
@@ -29,6 +30,7 @@ export class ItemsService implements AbstractService {
|
||||
knex: Knex;
|
||||
accountability: Accountability | null;
|
||||
eventScope: string;
|
||||
schemaInspector: ReturnType<typeof SchemaInspector>;
|
||||
|
||||
constructor(collection: string, options?: AbstractServiceOptions) {
|
||||
this.collection = collection;
|
||||
@@ -38,17 +40,18 @@ export class ItemsService implements AbstractService {
|
||||
? this.collection.substring(9)
|
||||
: 'items';
|
||||
|
||||
this.schemaInspector = SchemaInspector(this.knex);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
async create(data: Partial<Item>[]): Promise<PrimaryKey[]>;
|
||||
async create(data: Partial<Item>): Promise<PrimaryKey>;
|
||||
async create(data: Partial<Item> | Partial<Item>[]): Promise<PrimaryKey | PrimaryKey[]> {
|
||||
const schemaInspector = SchemaInspector(this.knex);
|
||||
const primaryKeyField = await schemaInspector.primary(this.collection);
|
||||
const columns = await schemaInspector.columns(this.collection);
|
||||
const primaryKeyField = await this.schemaInspector.primary(this.collection);
|
||||
const columns = await this.schemaInspector.columns(this.collection);
|
||||
|
||||
let payloads = clone(Array.isArray(data) ? data : [data]);
|
||||
let payloads = clone(toArray(data));
|
||||
|
||||
const savedPrimaryKeys = await this.knex.transaction(async (trx) => {
|
||||
const payloadService = new PayloadService(this.collection, {
|
||||
@@ -193,6 +196,7 @@ export class ItemsService implements AbstractService {
|
||||
async readByQuery(query: Query): Promise<null | Item | Item[]> {
|
||||
const authorizationService = new AuthorizationService({
|
||||
accountability: this.accountability,
|
||||
knex: this.knex,
|
||||
});
|
||||
|
||||
let ast = await getASTFromQuery(this.collection, query, {
|
||||
@@ -204,7 +208,7 @@ export class ItemsService implements AbstractService {
|
||||
ast = await authorizationService.processAST(ast);
|
||||
}
|
||||
|
||||
const records = await runAST(ast);
|
||||
const records = await runAST(ast, { knex: this.knex });
|
||||
return records;
|
||||
}
|
||||
|
||||
@@ -220,9 +224,8 @@ export class ItemsService implements AbstractService {
|
||||
action: PermissionsAction = 'read'
|
||||
): Promise<null | Item | Item[]> {
|
||||
query = clone(query);
|
||||
const schemaInspector = SchemaInspector(this.knex);
|
||||
const primaryKeyField = await schemaInspector.primary(this.collection);
|
||||
const keys = Array.isArray(key) ? key : [key];
|
||||
const primaryKeyField = await this.schemaInspector.primary(this.collection);
|
||||
const keys = toArray(key);
|
||||
|
||||
if (keys.length === 1) {
|
||||
query.single = true;
|
||||
@@ -263,13 +266,12 @@ export class ItemsService implements AbstractService {
|
||||
data: Partial<Item> | Partial<Item>[],
|
||||
key?: PrimaryKey | PrimaryKey[]
|
||||
): Promise<PrimaryKey | PrimaryKey[]> {
|
||||
const schemaInspector = SchemaInspector(this.knex);
|
||||
const primaryKeyField = await schemaInspector.primary(this.collection);
|
||||
const columns = await schemaInspector.columns(this.collection);
|
||||
const primaryKeyField = await this.schemaInspector.primary(this.collection);
|
||||
const columns = await this.schemaInspector.columns(this.collection);
|
||||
|
||||
// Updating one or more items to the same payload
|
||||
if (data && key) {
|
||||
const keys = Array.isArray(key) ? key : [key];
|
||||
const keys = toArray(key);
|
||||
|
||||
let payload = clone(data);
|
||||
|
||||
@@ -293,6 +295,7 @@ export class ItemsService implements AbstractService {
|
||||
if (this.accountability) {
|
||||
const authorizationService = new AuthorizationService({
|
||||
accountability: this.accountability,
|
||||
knex: this.knex,
|
||||
});
|
||||
|
||||
await authorizationService.checkAccess('update', this.collection, keys);
|
||||
@@ -402,7 +405,7 @@ export class ItemsService implements AbstractService {
|
||||
knex: trx,
|
||||
});
|
||||
|
||||
const payloads = Array.isArray(data) ? data : [data];
|
||||
const payloads = toArray(data);
|
||||
|
||||
for (const single of payloads as Partial<Item>[]) {
|
||||
let payload = clone(single);
|
||||
@@ -421,12 +424,58 @@ export class ItemsService implements AbstractService {
|
||||
return keys;
|
||||
}
|
||||
|
||||
async updateByQuery(data: Partial<Item>, query: Query): Promise<PrimaryKey[]> {
|
||||
const primaryKeyField = await this.schemaInspector.primary(this.collection);
|
||||
const readQuery = cloneDeep(query);
|
||||
readQuery.fields = [primaryKeyField];
|
||||
|
||||
// Not authenticated:
|
||||
const itemsService = new ItemsService(this.collection, { knex: this.knex });
|
||||
|
||||
let itemsToUpdate = await itemsService.readByQuery(readQuery);
|
||||
itemsToUpdate = toArray(itemsToUpdate);
|
||||
|
||||
const keys: PrimaryKey[] = itemsToUpdate.map(
|
||||
(item: Partial<Item>) => item[primaryKeyField]
|
||||
);
|
||||
|
||||
return await this.update(data, keys);
|
||||
}
|
||||
|
||||
upsert(data: Partial<Item>[]): Promise<PrimaryKey[]>;
|
||||
upsert(data: Partial<Item>): Promise<PrimaryKey>;
|
||||
async upsert(data: Partial<Item> | Partial<Item>[]): Promise<PrimaryKey | PrimaryKey[]> {
|
||||
const primaryKeyField = await this.schemaInspector.primary(this.collection);
|
||||
const payloads = toArray(data);
|
||||
const primaryKeys: PrimaryKey[] = [];
|
||||
|
||||
for (const payload of payloads) {
|
||||
const primaryKey = payload[primaryKeyField];
|
||||
const exists =
|
||||
primaryKey &&
|
||||
!!(await this.knex
|
||||
.select(primaryKeyField)
|
||||
.from(this.collection)
|
||||
.where({ [primaryKeyField]: primaryKey })
|
||||
.first());
|
||||
|
||||
if (exists) {
|
||||
const keys = await this.update([payload]);
|
||||
primaryKeys.push(...keys);
|
||||
} else {
|
||||
const key = await this.create(payload);
|
||||
primaryKeys.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.isArray(data) ? primaryKeys : primaryKeys[0];
|
||||
}
|
||||
|
||||
delete(key: PrimaryKey): Promise<PrimaryKey>;
|
||||
delete(keys: PrimaryKey[]): Promise<PrimaryKey[]>;
|
||||
async delete(key: PrimaryKey | PrimaryKey[]): Promise<PrimaryKey | PrimaryKey[]> {
|
||||
const keys = (Array.isArray(key) ? key : [key]) as PrimaryKey[];
|
||||
const schemaInspector = SchemaInspector(this.knex);
|
||||
const primaryKeyField = await schemaInspector.primary(this.collection);
|
||||
const keys = toArray(key);
|
||||
const primaryKeyField = await this.schemaInspector.primary(this.collection);
|
||||
|
||||
if (this.accountability && this.accountability.admin !== true) {
|
||||
const authorizationService = new AuthorizationService({
|
||||
@@ -480,15 +529,31 @@ export class ItemsService implements AbstractService {
|
||||
return key;
|
||||
}
|
||||
|
||||
async deleteByQuery(query: Query): Promise<PrimaryKey[]> {
|
||||
const primaryKeyField = await this.schemaInspector.primary(this.collection);
|
||||
const readQuery = cloneDeep(query);
|
||||
readQuery.fields = [primaryKeyField];
|
||||
|
||||
// Not authenticated:
|
||||
const itemsService = new ItemsService(this.collection);
|
||||
|
||||
let itemsToDelete = await itemsService.readByQuery(readQuery);
|
||||
itemsToDelete = toArray(itemsToDelete);
|
||||
|
||||
const keys: PrimaryKey[] = itemsToDelete.map(
|
||||
(item: Partial<Item>) => item[primaryKeyField]
|
||||
);
|
||||
return await this.delete(keys);
|
||||
}
|
||||
|
||||
async readSingleton(query: Query) {
|
||||
query = clone(query);
|
||||
const schemaInspector = SchemaInspector(this.knex);
|
||||
query.single = true;
|
||||
|
||||
const record = (await this.readByQuery(query)) as Item;
|
||||
|
||||
if (!record) {
|
||||
const columns = await schemaInspector.columnInfo(this.collection);
|
||||
const columns = await this.schemaInspector.columnInfo(this.collection);
|
||||
const defaults: Record<string, any> = {};
|
||||
|
||||
for (const column of columns) {
|
||||
@@ -502,8 +567,7 @@ export class ItemsService implements AbstractService {
|
||||
}
|
||||
|
||||
async upsertSingleton(data: Partial<Item>) {
|
||||
const schemaInspector = SchemaInspector(this.knex);
|
||||
const primaryKeyField = await schemaInspector.primary(this.collection);
|
||||
const primaryKeyField = await this.schemaInspector.primary(this.collection);
|
||||
|
||||
const record = await this.knex
|
||||
.select(primaryKeyField)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import argon2 from 'argon2';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import database from '../database';
|
||||
import { clone, isObject } from 'lodash';
|
||||
import { clone, isObject, cloneDeep } from 'lodash';
|
||||
import { Relation, Item, AbstractServiceOptions, Accountability, PrimaryKey } from '../types';
|
||||
import { ItemsService } from './items';
|
||||
import { URL } from 'url';
|
||||
@@ -15,6 +15,8 @@ import env from '../env';
|
||||
import SchemaInspector from 'knex-schema-inspector';
|
||||
import getLocalType from '../utils/get-local-type';
|
||||
import { format, formatISO } from 'date-fns';
|
||||
import { ForbiddenException } from '../exceptions';
|
||||
import { toArray } from '../utils/to-array';
|
||||
|
||||
type Action = 'create' | 'read' | 'update';
|
||||
|
||||
@@ -140,7 +142,7 @@ export class PayloadService {
|
||||
action: Action,
|
||||
payload: Partial<Item> | Partial<Item>[]
|
||||
): Promise<Partial<Item> | Partial<Item>[]> {
|
||||
let processedPayload = (Array.isArray(payload) ? payload : [payload]) as Partial<Item>[];
|
||||
let processedPayload = toArray(payload);
|
||||
|
||||
if (processedPayload.length === 0) return [];
|
||||
|
||||
@@ -235,7 +237,7 @@ export class PayloadService {
|
||||
|
||||
const columnsWithType = columnsInCollection.map((column) => ({
|
||||
name: column.name,
|
||||
type: getLocalType(column.type),
|
||||
type: getLocalType(column),
|
||||
}));
|
||||
|
||||
const dateColumns = columnsWithType.filter((column) =>
|
||||
@@ -301,6 +303,8 @@ export class PayloadService {
|
||||
});
|
||||
|
||||
for (const relation of relationsToProcess) {
|
||||
if (!relation.one_collection || !relation.one_primary) continue;
|
||||
|
||||
const itemsService = new ItemsService(relation.one_collection, {
|
||||
accountability: this.accountability,
|
||||
knex: this.knex,
|
||||
@@ -313,11 +317,7 @@ export class PayloadService {
|
||||
const exists = hasPrimaryKey && !!(await itemsService.readByKey(relatedPrimaryKey));
|
||||
|
||||
if (exists) {
|
||||
if (relatedRecord.hasOwnProperty('$delete') && relatedRecord.$delete) {
|
||||
await itemsService.delete(relatedPrimaryKey);
|
||||
} else {
|
||||
await itemsService.update(relatedRecord, relatedPrimaryKey);
|
||||
}
|
||||
await itemsService.update(relatedRecord, relatedPrimaryKey);
|
||||
} else {
|
||||
relatedPrimaryKey = await itemsService.create(relatedRecord);
|
||||
}
|
||||
@@ -339,62 +339,81 @@ export class PayloadService {
|
||||
.from('directus_relations')
|
||||
.where({ one_collection: this.collection });
|
||||
|
||||
const payloads = clone(Array.isArray(payload) ? payload : [payload]);
|
||||
const payloads = clone(toArray(payload));
|
||||
|
||||
for (let i = 0; i < payloads.length; i++) {
|
||||
let payload = payloads[i];
|
||||
|
||||
// Only process related records that are actually in the payload
|
||||
const relationsToProcess = relations.filter((relation) => {
|
||||
return (
|
||||
payload.hasOwnProperty(relation.one_field) &&
|
||||
Array.isArray(payload[relation.one_field])
|
||||
);
|
||||
if (!relation.one_field) return false;
|
||||
|
||||
return payload.hasOwnProperty(relation.one_field);
|
||||
});
|
||||
|
||||
for (const relation of relationsToProcess) {
|
||||
const relatedRecords: Partial<Item>[] = payload[relation.one_field].map(
|
||||
(record: string | number | Partial<Item>) => {
|
||||
if (typeof record === 'string' || typeof record === 'number') {
|
||||
record = {
|
||||
[relation.many_primary]: record,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...record,
|
||||
[relation.many_field]: parent || payload[relation.one_primary],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const itemsService = new ItemsService(relation.many_collection, {
|
||||
accountability: this.accountability,
|
||||
knex: this.knex,
|
||||
});
|
||||
|
||||
const toBeCreated = relatedRecords.filter(
|
||||
(record) => record.hasOwnProperty(relation.many_primary) === false
|
||||
const relatedRecords: Partial<Item>[] = [];
|
||||
let savedPrimaryKeys: PrimaryKey[] = [];
|
||||
|
||||
if (payload[relation.one_field!] && Array.isArray(payload[relation.one_field!])) {
|
||||
for (const relatedRecord of payload[relation.one_field!] || []) {
|
||||
let record = cloneDeep(relatedRecord);
|
||||
|
||||
if (
|
||||
typeof relatedRecord === 'string' ||
|
||||
typeof relatedRecord === 'number'
|
||||
) {
|
||||
const exists = !!(await this.knex
|
||||
.select(relation.many_primary)
|
||||
.from(relation.many_collection)
|
||||
.where({ [relation.many_primary]: record })
|
||||
.first());
|
||||
|
||||
if (exists === false) {
|
||||
throw new ForbiddenException(undefined, {
|
||||
item: record,
|
||||
collection: relation.many_collection,
|
||||
});
|
||||
}
|
||||
|
||||
record = {
|
||||
[relation.many_primary]: relatedRecord,
|
||||
};
|
||||
}
|
||||
|
||||
relatedRecords.push({
|
||||
...record,
|
||||
[relation.many_field]: parent || payload[relation.one_primary!],
|
||||
});
|
||||
}
|
||||
|
||||
savedPrimaryKeys = await itemsService.upsert(relatedRecords);
|
||||
}
|
||||
|
||||
await itemsService.updateByQuery(
|
||||
{ [relation.many_field]: null },
|
||||
{
|
||||
filter: {
|
||||
_and: [
|
||||
{
|
||||
[relation.many_field]: {
|
||||
_eq: parent,
|
||||
},
|
||||
},
|
||||
{
|
||||
[relation.many_primary]: {
|
||||
_nin: savedPrimaryKeys,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const toBeUpdated = relatedRecords.filter(
|
||||
(record) =>
|
||||
record.hasOwnProperty(relation.many_primary) === true &&
|
||||
record.hasOwnProperty('$delete') === false
|
||||
);
|
||||
|
||||
const toBeDeleted = relatedRecords
|
||||
.filter(
|
||||
(record) =>
|
||||
record.hasOwnProperty(relation.many_primary) === true &&
|
||||
record.hasOwnProperty('$delete') &&
|
||||
record.$delete === true
|
||||
)
|
||||
.map((record) => record[relation.many_primary]);
|
||||
|
||||
await itemsService.create(toBeCreated);
|
||||
await itemsService.update(toBeUpdated);
|
||||
await itemsService.delete(toBeDeleted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AbstractServiceOptions, PermissionsAction } from '../types';
|
||||
import { AbstractServiceOptions, PermissionsAction, Item, PrimaryKey } from '../types';
|
||||
import { ItemsService } from '../services/items';
|
||||
|
||||
export class PermissionsService extends ItemsService {
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { ItemsService } from './items';
|
||||
import {
|
||||
AbstractServiceOptions,
|
||||
Query,
|
||||
Item,
|
||||
PrimaryKey,
|
||||
PermissionsAction,
|
||||
Relation,
|
||||
} from '../types';
|
||||
import { AbstractServiceOptions, Query, PrimaryKey, PermissionsAction, Relation } from '../types';
|
||||
import { PermissionsService } from './permissions';
|
||||
import { toArray } from '../utils/to-array';
|
||||
|
||||
/**
|
||||
* @TODO update foreign key constraints when relations are updated
|
||||
*/
|
||||
|
||||
type ParsedRelation = Relation & {
|
||||
one_allowed_collections: string[] | null;
|
||||
};
|
||||
|
||||
export class RelationsService extends ItemsService {
|
||||
permissionsService: PermissionsService;
|
||||
|
||||
@@ -22,7 +20,11 @@ export class RelationsService extends ItemsService {
|
||||
}
|
||||
|
||||
async readByQuery(query: Query): Promise<null | Relation | Relation[]> {
|
||||
const results = (await super.readByQuery(query)) as Relation | Relation[] | null;
|
||||
const service = new ItemsService('directus_relations', { knex: this.knex });
|
||||
const results = (await service.readByQuery(query)) as
|
||||
| ParsedRelation
|
||||
| ParsedRelation[]
|
||||
| null;
|
||||
const filteredResults = await this.filterForbidden(results);
|
||||
return filteredResults;
|
||||
}
|
||||
@@ -38,15 +40,17 @@ export class RelationsService extends ItemsService {
|
||||
query: Query = {},
|
||||
action: PermissionsAction = 'read'
|
||||
): Promise<null | Relation | Relation[]> {
|
||||
const results = (await super.readByKey(key as any, query, action)) as
|
||||
| Relation
|
||||
| Relation[]
|
||||
const service = new ItemsService('directus_relations', { knex: this.knex });
|
||||
const results = (await service.readByKey(key as any, query, action)) as
|
||||
| ParsedRelation
|
||||
| ParsedRelation[]
|
||||
| null;
|
||||
|
||||
const filteredResults = await this.filterForbidden(results);
|
||||
return filteredResults;
|
||||
}
|
||||
|
||||
private async filterForbidden(relations: Relation | Relation[] | null) {
|
||||
private async filterForbidden(relations: ParsedRelation | ParsedRelation[] | null) {
|
||||
if (relations === null) return null;
|
||||
if (this.accountability === null || this.accountability?.admin === true) return relations;
|
||||
|
||||
@@ -59,20 +63,50 @@ export class RelationsService extends ItemsService {
|
||||
'read'
|
||||
);
|
||||
|
||||
relations = Array.isArray(relations) ? relations : [relations];
|
||||
relations = toArray(relations);
|
||||
|
||||
return relations.filter((relation) => {
|
||||
const collectionsAllowed =
|
||||
allowedCollections.includes(relation.many_collection) &&
|
||||
allowedCollections.includes(relation.one_collection);
|
||||
let collectionsAllowed = true;
|
||||
let fieldsAllowed = true;
|
||||
|
||||
const fieldsAllowed =
|
||||
allowedFields[relation.one_collection] &&
|
||||
allowedFields[relation.many_collection] &&
|
||||
(allowedFields[relation.many_collection].includes('*') ||
|
||||
allowedFields[relation.many_collection].includes(relation.many_field)) &&
|
||||
(allowedFields[relation.one_collection].includes('*') ||
|
||||
allowedFields[relation.one_collection].includes(relation.one_field));
|
||||
if (allowedCollections.includes(relation.many_collection) === false) {
|
||||
collectionsAllowed = false;
|
||||
}
|
||||
|
||||
if (
|
||||
relation.one_collection &&
|
||||
allowedCollections.includes(relation.one_collection) === false
|
||||
) {
|
||||
collectionsAllowed = false;
|
||||
}
|
||||
|
||||
if (
|
||||
relation.one_allowed_collections &&
|
||||
relation.one_allowed_collections.every((collection) =>
|
||||
allowedCollections.includes(collection)
|
||||
) === false
|
||||
) {
|
||||
collectionsAllowed = false;
|
||||
}
|
||||
|
||||
if (
|
||||
!allowedFields[relation.many_collection] ||
|
||||
(allowedFields[relation.many_collection].includes('*') === false &&
|
||||
allowedFields[relation.many_collection].includes(relation.many_field) === false)
|
||||
) {
|
||||
fieldsAllowed = false;
|
||||
}
|
||||
|
||||
if (
|
||||
relation.one_collection &&
|
||||
relation.one_field &&
|
||||
(!allowedFields[relation.one_collection] ||
|
||||
(allowedFields[relation.one_collection].includes('*') === false &&
|
||||
allowedFields[relation.one_collection].includes(relation.one_field) ===
|
||||
false))
|
||||
) {
|
||||
fieldsAllowed = false;
|
||||
}
|
||||
|
||||
return collectionsAllowed && fieldsAllowed;
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import { AbstractServiceOptions, PrimaryKey } from '../types';
|
||||
import { PermissionsService } from './permissions';
|
||||
import { UsersService } from './users';
|
||||
import { PresetsService } from './presets';
|
||||
import { UnprocessableEntityException } from '../exceptions';
|
||||
import { toArray } from '../utils/to-array';
|
||||
|
||||
export class RolesService extends ItemsService {
|
||||
constructor(options?: AbstractServiceOptions) {
|
||||
@@ -12,17 +14,30 @@ export class RolesService extends ItemsService {
|
||||
delete(key: PrimaryKey): Promise<PrimaryKey>;
|
||||
delete(keys: PrimaryKey[]): Promise<PrimaryKey[]>;
|
||||
async delete(key: PrimaryKey | PrimaryKey[]): Promise<PrimaryKey | PrimaryKey[]> {
|
||||
const keys = Array.isArray(key) ? key : [key];
|
||||
const keys = toArray(key);
|
||||
|
||||
// Make sure there's at least one admin role left after this deletion is done
|
||||
const otherAdminRoles = await this.knex
|
||||
.count('*', { as: 'count' })
|
||||
.from('directus_roles')
|
||||
.whereNotIn('id', keys)
|
||||
.andWhere({ admin_access: true })
|
||||
.first();
|
||||
const otherAdminRolesCount = +(otherAdminRoles?.count || 0);
|
||||
if (otherAdminRolesCount === 0)
|
||||
throw new UnprocessableEntityException(`You can't delete the last admin role.`);
|
||||
|
||||
// Remove all permissions associated with this role
|
||||
const permissionsService = new PermissionsService({
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
});
|
||||
const permissionsForRole = await permissionsService.readByQuery({
|
||||
|
||||
const permissionsForRole = (await permissionsService.readByQuery({
|
||||
fields: ['id'],
|
||||
filter: { role: { _in: keys } },
|
||||
}) as { id: number }[];
|
||||
})) as { id: number }[];
|
||||
|
||||
const permissionIDs = permissionsForRole.map((permission) => permission.id);
|
||||
await permissionsService.delete(permissionIDs);
|
||||
|
||||
@@ -31,10 +46,12 @@ export class RolesService extends ItemsService {
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
});
|
||||
const presetsForRole = await presetsService.readByQuery({
|
||||
|
||||
const presetsForRole = (await presetsService.readByQuery({
|
||||
fields: ['id'],
|
||||
filter: { role: { _in: keys } },
|
||||
}) as { id: string }[];
|
||||
})) as { id: string }[];
|
||||
|
||||
const presetIDs = presetsForRole.map((preset) => preset.id);
|
||||
await presetsService.delete(presetIDs);
|
||||
|
||||
@@ -43,10 +60,12 @@ export class RolesService extends ItemsService {
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
});
|
||||
const usersInRole = await usersService.readByQuery({
|
||||
|
||||
const usersInRole = (await usersService.readByQuery({
|
||||
fields: ['id'],
|
||||
filter: { role: { _in: keys } },
|
||||
}) as { id: string }[];
|
||||
})) as { id: string }[];
|
||||
|
||||
const userIDs = usersInRole.map((user) => user.id);
|
||||
await usersService.update({ status: 'suspended', role: null }, userIDs);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Accountability,
|
||||
Collection,
|
||||
Field,
|
||||
Permission,
|
||||
Relation,
|
||||
types,
|
||||
} from '../types';
|
||||
@@ -12,17 +13,25 @@ import formatTitle from '@directus/format-title';
|
||||
import { cloneDeep, mergeWith } from 'lodash';
|
||||
import { RelationsService } from './relations';
|
||||
import env from '../env';
|
||||
import {
|
||||
OpenAPIObject,
|
||||
PathItemObject,
|
||||
OperationObject,
|
||||
TagObject,
|
||||
SchemaObject,
|
||||
} from 'openapi3-ts';
|
||||
|
||||
// @ts-ignore
|
||||
import { version } from '../../package.json';
|
||||
|
||||
// @ts-ignore
|
||||
import openapi from '@directus/specs';
|
||||
|
||||
type RelationTree = Record<string, Record<string, Relation[]>>;
|
||||
import Knex from 'knex';
|
||||
import database from '../database';
|
||||
import { getRelationType } from '../utils/get-relation-type';
|
||||
|
||||
export class SpecificationService {
|
||||
accountability: Accountability | null;
|
||||
knex: Knex;
|
||||
|
||||
fieldsService: FieldsService;
|
||||
collectionsService: CollectionsService;
|
||||
@@ -32,16 +41,20 @@ export class SpecificationService {
|
||||
|
||||
constructor(options?: AbstractServiceOptions) {
|
||||
this.accountability = options?.accountability || null;
|
||||
this.knex = options?.knex || database;
|
||||
|
||||
this.fieldsService = new FieldsService(options);
|
||||
this.collectionsService = new CollectionsService(options);
|
||||
this.relationsService = new RelationsService(options);
|
||||
|
||||
this.oas = new OASService({
|
||||
fieldsService: this.fieldsService,
|
||||
collectionsService: this.collectionsService,
|
||||
relationsService: this.relationsService,
|
||||
});
|
||||
this.oas = new OASService(
|
||||
{ knex: this.knex, accountability: this.accountability },
|
||||
{
|
||||
fieldsService: this.fieldsService,
|
||||
collectionsService: this.collectionsService,
|
||||
relationsService: this.relationsService,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,34 +63,462 @@ interface SpecificationSubService {
|
||||
}
|
||||
|
||||
class OASService implements SpecificationSubService {
|
||||
accountability: Accountability | null;
|
||||
knex: Knex;
|
||||
|
||||
fieldsService: FieldsService;
|
||||
collectionsService: CollectionsService;
|
||||
relationsService: RelationsService;
|
||||
|
||||
constructor({
|
||||
fieldsService,
|
||||
collectionsService,
|
||||
relationsService,
|
||||
}: {
|
||||
fieldsService: FieldsService;
|
||||
collectionsService: CollectionsService;
|
||||
relationsService: RelationsService;
|
||||
}) {
|
||||
constructor(
|
||||
options: AbstractServiceOptions,
|
||||
{
|
||||
fieldsService,
|
||||
collectionsService,
|
||||
relationsService,
|
||||
}: {
|
||||
fieldsService: FieldsService;
|
||||
collectionsService: CollectionsService;
|
||||
relationsService: RelationsService;
|
||||
}
|
||||
) {
|
||||
this.accountability = options.accountability || null;
|
||||
this.knex = options?.knex || database;
|
||||
|
||||
this.fieldsService = fieldsService;
|
||||
this.collectionsService = collectionsService;
|
||||
this.relationsService = relationsService;
|
||||
}
|
||||
|
||||
private collectionsDenyList = [
|
||||
'directus_collections',
|
||||
'directus_fields',
|
||||
'directus_migrations',
|
||||
'directus_sessions',
|
||||
];
|
||||
async generate() {
|
||||
const collections = await this.collectionsService.readByQuery();
|
||||
const fields = await this.fieldsService.readAll();
|
||||
const relations = (await this.relationsService.readByQuery({})) as Relation[];
|
||||
const permissions: Permission[] = await this.knex
|
||||
.select('*')
|
||||
.from('directus_permissions')
|
||||
.where({ role: this.accountability?.role || null });
|
||||
|
||||
const tags = await this.generateTags(collections);
|
||||
const paths = await this.generatePaths(permissions, tags);
|
||||
const components = await this.generateComponents(collections, fields, relations, tags);
|
||||
|
||||
const spec: OpenAPIObject = {
|
||||
openapi: '3.0.1',
|
||||
info: {
|
||||
title: 'Dynamic API Specification',
|
||||
description:
|
||||
'This is a dynamicly generated API specification for all endpoints existing on the current .',
|
||||
version: version,
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: env.PUBLIC_URL,
|
||||
description: 'Your current Directus instance.',
|
||||
},
|
||||
],
|
||||
tags,
|
||||
paths,
|
||||
components,
|
||||
};
|
||||
|
||||
return spec;
|
||||
}
|
||||
|
||||
private async generateTags(collections: Collection[]): Promise<OpenAPIObject['tags']> {
|
||||
const systemTags = cloneDeep(openapi.tags)!;
|
||||
|
||||
const tags: OpenAPIObject['tags'] = [];
|
||||
|
||||
// System tags that don't have an associated collection are always readable to the user
|
||||
for (const systemTag of systemTags) {
|
||||
if (!systemTag['x-collection']) {
|
||||
tags.push(systemTag);
|
||||
}
|
||||
}
|
||||
|
||||
for (const collection of collections) {
|
||||
const isSystem = collection.collection.startsWith('directus_');
|
||||
|
||||
// If the collection is one of the system collections, pull the tag from the static spec
|
||||
if (isSystem) {
|
||||
for (const tag of openapi.tags!) {
|
||||
if (tag['x-collection'] === collection.collection) {
|
||||
tags.push(tag);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tags.push({
|
||||
name: 'Items' + formatTitle(collection.collection).replace(/ /g, ''),
|
||||
description: collection.meta?.note || undefined,
|
||||
'x-collection': collection.collection,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out the generic Items information
|
||||
return tags.filter((tag) => tag.name !== 'Items');
|
||||
}
|
||||
|
||||
private async generatePaths(
|
||||
permissions: Permission[],
|
||||
tags: OpenAPIObject['tags']
|
||||
): Promise<OpenAPIObject['paths']> {
|
||||
const paths: OpenAPIObject['paths'] = {};
|
||||
|
||||
if (!tags) return paths;
|
||||
|
||||
for (const tag of tags) {
|
||||
const isSystem =
|
||||
tag.hasOwnProperty('x-collection') === false ||
|
||||
tag['x-collection'].startsWith('directus_');
|
||||
|
||||
if (isSystem) {
|
||||
for (const [path, pathItem] of Object.entries<PathItemObject>(openapi.paths)) {
|
||||
for (const [method, operation] of Object.entries<OperationObject>(pathItem)) {
|
||||
if (operation.tags?.includes(tag.name)) {
|
||||
if (!paths[path]) {
|
||||
paths[path] = {};
|
||||
}
|
||||
|
||||
const hasPermission =
|
||||
this.accountability?.admin === true ||
|
||||
tag.hasOwnProperty('x-collection') === false ||
|
||||
!!permissions.find(
|
||||
(permission) =>
|
||||
permission.collection === tag['x-collection'] &&
|
||||
permission.action === this.getActionForMethod(method)
|
||||
);
|
||||
|
||||
if (hasPermission) {
|
||||
paths[path][method] = operation;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const listBase = cloneDeep(openapi.paths['/items/{collection}']);
|
||||
const detailBase = cloneDeep(openapi.paths['/items/{collection}/{id}']);
|
||||
const collection = tag['x-collection'];
|
||||
|
||||
for (const method of ['post', 'get', 'patch', 'delete']) {
|
||||
const hasPermission =
|
||||
this.accountability?.admin === true ||
|
||||
!!permissions.find(
|
||||
(permission) =>
|
||||
permission.collection === collection &&
|
||||
permission.action === this.getActionForMethod(method)
|
||||
);
|
||||
|
||||
if (hasPermission) {
|
||||
if (!paths[`/items/${collection}`]) paths[`/items/${collection}`] = {};
|
||||
if (!paths[`/items/${collection}/{id}`])
|
||||
paths[`/items/${collection}/{id}`] = {};
|
||||
|
||||
if (listBase[method]) {
|
||||
paths[`/items/${collection}`][method] = mergeWith(
|
||||
cloneDeep(listBase[method]),
|
||||
{
|
||||
description: listBase[method].description.replace(
|
||||
'item',
|
||||
collection + ' item'
|
||||
),
|
||||
tags: [tag.name],
|
||||
operationId: `${this.getActionForMethod(method)}${tag.name}`,
|
||||
requestBody: ['get', 'delete'].includes(method)
|
||||
? undefined
|
||||
: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'array',
|
||||
items: {
|
||||
$ref: `#/components/schema/${tag.name}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
$ref: `#/components/schema/${tag.name}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
'200': {
|
||||
content:
|
||||
method === 'delete'
|
||||
? undefined
|
||||
: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
properties: {
|
||||
data: {
|
||||
items: {
|
||||
$ref: `#/components/schema/${tag.name}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
(obj, src) => {
|
||||
if (Array.isArray(obj)) return obj.concat(src);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (detailBase[method]) {
|
||||
paths[`/items/${collection}/{id}`][method] = mergeWith(
|
||||
cloneDeep(detailBase[method]),
|
||||
{
|
||||
description: detailBase[method].description.replace(
|
||||
'item',
|
||||
collection + ' item'
|
||||
),
|
||||
tags: [tag.name],
|
||||
operationId: `${this.getActionForMethod(method)}Single${
|
||||
tag.name
|
||||
}`,
|
||||
requestBody: ['get', 'delete'].includes(method)
|
||||
? undefined
|
||||
: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
$ref: `#/components/schema/${tag.name}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
'200': {
|
||||
content:
|
||||
method === 'delete'
|
||||
? undefined
|
||||
: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
properties: {
|
||||
data: {
|
||||
items: {
|
||||
$ref: `#/components/schema/${tag.name}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
(obj, src) => {
|
||||
if (Array.isArray(obj)) return obj.concat(src);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
private async generateComponents(
|
||||
collections: Collection[],
|
||||
fields: Field[],
|
||||
relations: Relation[],
|
||||
tags: OpenAPIObject['tags']
|
||||
): Promise<OpenAPIObject['components']> {
|
||||
let components: OpenAPIObject['components'] = cloneDeep(openapi.components);
|
||||
|
||||
if (!components) components = {};
|
||||
|
||||
components.schemas = {};
|
||||
|
||||
if (!tags) return;
|
||||
|
||||
for (const collection of collections) {
|
||||
const tag = tags.find((tag) => tag['x-collection'] === collection.collection);
|
||||
|
||||
if (!tag) continue;
|
||||
|
||||
const isSystem = collection.collection.startsWith('directus_');
|
||||
|
||||
const fieldsInCollection = fields.filter(
|
||||
(field) => field.collection === collection.collection
|
||||
);
|
||||
|
||||
if (isSystem) {
|
||||
const schemaComponent: SchemaObject = cloneDeep(
|
||||
openapi.components!.schemas![tag.name]
|
||||
);
|
||||
|
||||
schemaComponent.properties = {};
|
||||
|
||||
for (const field of fieldsInCollection) {
|
||||
schemaComponent.properties[field.field] =
|
||||
(cloneDeep(
|
||||
(openapi.components!.schemas![tag.name] as SchemaObject).properties![
|
||||
field.field
|
||||
]
|
||||
) as SchemaObject) || this.generateField(field, relations, tags, fields);
|
||||
}
|
||||
|
||||
components.schemas[tag.name] = schemaComponent;
|
||||
} else {
|
||||
const schemaComponent: SchemaObject = {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
'x-collection': collection.collection,
|
||||
};
|
||||
|
||||
for (const field of fieldsInCollection) {
|
||||
schemaComponent.properties![field.field] = this.generateField(
|
||||
field,
|
||||
relations,
|
||||
tags,
|
||||
fields
|
||||
);
|
||||
}
|
||||
|
||||
components.schemas[tag.name] = schemaComponent;
|
||||
}
|
||||
}
|
||||
|
||||
return components;
|
||||
}
|
||||
|
||||
private getActionForMethod(method: string): 'create' | 'read' | 'update' | 'delete' {
|
||||
switch (method) {
|
||||
case 'post':
|
||||
return 'create';
|
||||
case 'patch':
|
||||
return 'update';
|
||||
case 'delete':
|
||||
return 'delete';
|
||||
case 'get':
|
||||
default:
|
||||
return 'read';
|
||||
}
|
||||
}
|
||||
|
||||
private generateField(
|
||||
field: Field,
|
||||
relations: Relation[],
|
||||
tags: TagObject[],
|
||||
fields: Field[]
|
||||
): SchemaObject {
|
||||
let propertyObject: SchemaObject = {
|
||||
nullable: field.schema?.is_nullable,
|
||||
description: field.meta?.note || undefined,
|
||||
};
|
||||
|
||||
const relation = relations.find(
|
||||
(relation) =>
|
||||
(relation.many_collection === field.collection &&
|
||||
relation.many_field === field.field) ||
|
||||
(relation.one_collection === field.collection && relation.one_field === field.field)
|
||||
);
|
||||
|
||||
if (!relation) {
|
||||
propertyObject = {
|
||||
...propertyObject,
|
||||
...this.fieldTypes[field.type],
|
||||
};
|
||||
} else {
|
||||
const relationType = getRelationType({
|
||||
relation,
|
||||
field: field.field,
|
||||
collection: field.collection,
|
||||
});
|
||||
|
||||
if (relationType === 'm2o') {
|
||||
const relatedTag = tags.find(
|
||||
(tag) => tag['x-collection'] === relation.one_collection
|
||||
);
|
||||
const relatedPrimaryKeyField = fields.find(
|
||||
(field) =>
|
||||
field.collection === relation.one_collection && field.schema?.is_primary_key
|
||||
);
|
||||
|
||||
if (!relatedTag || !relatedPrimaryKeyField) return propertyObject;
|
||||
|
||||
propertyObject.oneOf = [
|
||||
{
|
||||
...this.fieldTypes[relatedPrimaryKeyField.type],
|
||||
},
|
||||
{
|
||||
$ref: `#/components/schemas/${relatedTag.name}`,
|
||||
},
|
||||
];
|
||||
} else if (relationType === 'o2m') {
|
||||
const relatedTag = tags.find(
|
||||
(tag) => tag['x-collection'] === relation.many_collection
|
||||
);
|
||||
const relatedPrimaryKeyField = fields.find(
|
||||
(field) =>
|
||||
field.collection === relation.many_collection &&
|
||||
field.schema?.is_primary_key
|
||||
);
|
||||
|
||||
if (!relatedTag || !relatedPrimaryKeyField) return propertyObject;
|
||||
|
||||
propertyObject.type = 'array';
|
||||
propertyObject.items = {
|
||||
oneOf: [
|
||||
{
|
||||
...this.fieldTypes[relatedPrimaryKeyField.type],
|
||||
},
|
||||
{
|
||||
$ref: `#/components/schemas/${relatedTag.name}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} else if (relationType === 'm2a') {
|
||||
const relatedTags = tags.filter((tag) =>
|
||||
relation.one_allowed_collections!.includes(tag['x-collection'])
|
||||
);
|
||||
|
||||
propertyObject.type = 'array';
|
||||
propertyObject.items = {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'string',
|
||||
},
|
||||
relatedTags.map((tag) => ({
|
||||
$ref: `#/components/schemas/${tag.name}`,
|
||||
})),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return propertyObject;
|
||||
}
|
||||
|
||||
private fieldTypes: Record<
|
||||
typeof types[number],
|
||||
{ type: string; format?: string; items?: any }
|
||||
{
|
||||
type:
|
||||
| 'string'
|
||||
| 'number'
|
||||
| 'boolean'
|
||||
| 'object'
|
||||
| 'array'
|
||||
| 'integer'
|
||||
| 'null'
|
||||
| undefined;
|
||||
format?: string;
|
||||
items?: any;
|
||||
}
|
||||
> = {
|
||||
bigInteger: {
|
||||
type: 'integer',
|
||||
@@ -139,311 +580,4 @@ class OASService implements SpecificationSubService {
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async generate() {
|
||||
const collections = await this.collectionsService.readByQuery();
|
||||
|
||||
const userCollections = collections.filter(
|
||||
(collection) =>
|
||||
collection.collection.startsWith('directus_') === false ||
|
||||
this.collectionsDenyList.includes(collection.collection) === false
|
||||
);
|
||||
|
||||
const allFields = await this.fieldsService.readAll();
|
||||
|
||||
const fields: Record<string, Field[]> = {};
|
||||
|
||||
for (const field of allFields) {
|
||||
if (
|
||||
field.collection.startsWith('directus_') === false ||
|
||||
this.collectionsDenyList.includes(field.collection) === false
|
||||
) {
|
||||
if (field.collection in fields) {
|
||||
fields[field.collection].push(field);
|
||||
} else {
|
||||
fields[field.collection] = [field];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const relationsResult = await this.relationsService.readByQuery({});
|
||||
if (relationsResult === null) return {};
|
||||
|
||||
const relations = Array.isArray(relationsResult) ? relationsResult : [relationsResult];
|
||||
|
||||
const relationsTree: RelationTree = {};
|
||||
|
||||
for (const relation of relations as Relation[]) {
|
||||
if (relation.many_collection in relationsTree === false)
|
||||
relationsTree[relation.many_collection] = {};
|
||||
if (relation.one_collection in relationsTree === false)
|
||||
relationsTree[relation.one_collection] = {};
|
||||
|
||||
if (relation.many_field in relationsTree[relation.many_collection] === false)
|
||||
relationsTree[relation.many_collection][relation.many_field] = [];
|
||||
if (relation.one_field in relationsTree[relation.one_collection] === false)
|
||||
relationsTree[relation.one_collection][relation.one_field] = [];
|
||||
|
||||
relationsTree[relation.many_collection][relation.many_field].push(relation);
|
||||
relationsTree[relation.one_collection][relation.one_field].push(relation);
|
||||
}
|
||||
|
||||
const dynOpenapi = {
|
||||
openapi: '3.0.1',
|
||||
info: {
|
||||
title: 'Dynamic Api Specification',
|
||||
description:
|
||||
'This is a dynamicly generated api specification for all endpoints existing on the api.',
|
||||
version: version,
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: env.PUBLIC_URL,
|
||||
description: 'Your current api server.',
|
||||
},
|
||||
],
|
||||
tags: this.generateTags(userCollections),
|
||||
paths: this.generatePaths(userCollections),
|
||||
components: {
|
||||
schemas: this.generateSchemas(userCollections, fields, relationsTree),
|
||||
},
|
||||
};
|
||||
|
||||
return mergeWith(cloneDeep(openapi), cloneDeep(dynOpenapi), (obj, src) => {
|
||||
if (Array.isArray(obj)) return obj.concat(src);
|
||||
});
|
||||
}
|
||||
|
||||
private getNameFormats(collection: string) {
|
||||
const isInternal = collection.startsWith('directus_');
|
||||
const schema = formatTitle(
|
||||
isInternal ? collection.replace('directus_', '').replace(/s$/, '') : collection + 'Item'
|
||||
).replace(/ /g, '');
|
||||
const tag = formatTitle(
|
||||
isInternal ? collection.replace('directus_', '') : collection + ' Collection'
|
||||
);
|
||||
const path = isInternal ? collection : '/items/' + collection;
|
||||
const objectRef = `#/components/schemas/${schema}`;
|
||||
|
||||
return { schema, tag, path, objectRef };
|
||||
}
|
||||
|
||||
private generateTags(collections: Collection[]) {
|
||||
const tags: { name: string; description?: string }[] = [];
|
||||
|
||||
for (const collection of collections) {
|
||||
if (collection.collection.startsWith('directus_')) continue;
|
||||
const { tag } = this.getNameFormats(collection.collection);
|
||||
tags.push({ name: tag, description: collection.meta?.note || undefined });
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
private generatePaths(collections: Collection[]) {
|
||||
const paths: Record<string, object> = {};
|
||||
|
||||
for (const collection of collections) {
|
||||
if (collection.collection.startsWith('directus_')) continue;
|
||||
|
||||
const { tag, schema, objectRef, path } = this.getNameFormats(collection.collection);
|
||||
|
||||
const objectSingle = {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
$ref: objectRef,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
(paths[path] = {
|
||||
get: {
|
||||
operationId: `get${schema}s`,
|
||||
description: `List all items from the ${tag}`,
|
||||
tags: [tag],
|
||||
parameters: [
|
||||
{ $ref: '#/components/parameters/Fields' },
|
||||
{ $ref: '#/components/parameters/Limit' },
|
||||
{ $ref: '#/components/parameters/Meta' },
|
||||
{ $ref: '#/components/parameters/Offset' },
|
||||
{ $ref: '#/components/parameters/Single' },
|
||||
{ $ref: '#/components/parameters/Sort' },
|
||||
{ $ref: '#/components/parameters/Filter' },
|
||||
{ $ref: '#/components/parameters/q' },
|
||||
],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Successful request',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
data: {
|
||||
type: 'array',
|
||||
items: {
|
||||
$ref: objectRef,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'401': {
|
||||
$ref: '#/components/responses/UnauthorizedError',
|
||||
},
|
||||
},
|
||||
},
|
||||
post: {
|
||||
operationId: `create${schema}`,
|
||||
description: `Create a new item in the ${tag}`,
|
||||
tags: [tag],
|
||||
parameter: [{ $ref: '#/components/parameters/Meta' }],
|
||||
requestBody: objectSingle,
|
||||
responses: {
|
||||
'200': objectSingle,
|
||||
'401': {
|
||||
$ref: '#/components/responses/UnauthorizedError',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
(paths[path + '/{id}'] = {
|
||||
parameters: [{ $ref: '#/components/parameters/Id' }],
|
||||
get: {
|
||||
operationId: `get${schema}`,
|
||||
description: `Get a singe item from the ${tag}`,
|
||||
tags: [tag],
|
||||
parameters: [
|
||||
{ $ref: '#/components/parameters/Fields' },
|
||||
{ $ref: '#/components/parameters/Meta' },
|
||||
],
|
||||
responses: {
|
||||
'200': objectSingle,
|
||||
'401': {
|
||||
$ref: '#/components/responses/UnauthorizedError',
|
||||
},
|
||||
'404': {
|
||||
$ref: '#/components/responses/NotFoundError',
|
||||
},
|
||||
},
|
||||
},
|
||||
patch: {
|
||||
operationId: `update${schema}`,
|
||||
description: `Update an item from the ${tag}`,
|
||||
tags: [tag],
|
||||
parameters: [
|
||||
{ $ref: '#/components/parameters/Fields' },
|
||||
{ $ref: '#/components/parameters/Meta' },
|
||||
],
|
||||
requestBody: objectSingle,
|
||||
responses: {
|
||||
'200': objectSingle,
|
||||
'401': {
|
||||
$ref: '#/components/responses/UnauthorizedError',
|
||||
},
|
||||
'404': {
|
||||
$ref: '#/components/responses/NotFoundError',
|
||||
},
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
operationId: `delete${schema}`,
|
||||
description: `Delete an item from the ${tag}`,
|
||||
tags: [tag],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Successful request',
|
||||
},
|
||||
'401': {
|
||||
$ref: '#/components/responses/UnauthorizedError',
|
||||
},
|
||||
'404': {
|
||||
$ref: '#/components/responses/NotFoundError',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
private generateSchemas(
|
||||
collections: Collection[],
|
||||
fields: Record<string, Field[]>,
|
||||
relations: RelationTree
|
||||
) {
|
||||
const schemas: Record<string, any> = {};
|
||||
|
||||
for (const collection of collections) {
|
||||
const { schema, tag } = this.getNameFormats(collection.collection);
|
||||
|
||||
if (fields === undefined) return;
|
||||
|
||||
schemas[schema] = {
|
||||
type: 'object',
|
||||
'x-tag': tag,
|
||||
properties: {},
|
||||
};
|
||||
|
||||
for (const field of fields[collection.collection]) {
|
||||
const fieldRelations =
|
||||
field.collection in relations && field.field in relations[field.collection]
|
||||
? relations[field.collection][field.field]
|
||||
: [];
|
||||
|
||||
if (fieldRelations.length !== 0) {
|
||||
const relation = fieldRelations[0];
|
||||
const isM2O =
|
||||
relation.many_collection === field.collection &&
|
||||
relation.many_field === field.field;
|
||||
|
||||
const relatedCollection = isM2O
|
||||
? relation.one_collection
|
||||
: relation.many_collection;
|
||||
if (!relatedCollection) continue;
|
||||
|
||||
const relatedPrimaryField = fields[relatedCollection].find(
|
||||
(field) => field.schema?.is_primary_key
|
||||
);
|
||||
if (relatedPrimaryField?.type === undefined) continue;
|
||||
|
||||
const relatedType = this.fieldTypes[relatedPrimaryField?.type];
|
||||
const { objectRef } = this.getNameFormats(relatedCollection);
|
||||
|
||||
const type = isM2O
|
||||
? {
|
||||
oneOf: [
|
||||
{
|
||||
...relatedType,
|
||||
nullable: field.schema?.is_nullable === true,
|
||||
},
|
||||
{ $ref: objectRef },
|
||||
],
|
||||
}
|
||||
: {
|
||||
type: 'array',
|
||||
items: { $ref: objectRef },
|
||||
nullable: field.schema?.is_nullable === true,
|
||||
};
|
||||
|
||||
schemas[schema].properties[field.field] = {
|
||||
...type,
|
||||
description: field.meta?.note || undefined,
|
||||
};
|
||||
} else {
|
||||
schemas[schema].properties[field.field] = {
|
||||
...this.fieldTypes[field.type],
|
||||
nullable: field.schema?.is_nullable === true,
|
||||
description: field.meta?.note || undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return schemas;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,16 @@ import jwt from 'jsonwebtoken';
|
||||
import { sendInviteMail, sendPasswordResetMail } from '../mail';
|
||||
import database from '../database';
|
||||
import argon2 from 'argon2';
|
||||
import { InvalidPayloadException, ForbiddenException } from '../exceptions';
|
||||
import {
|
||||
InvalidPayloadException,
|
||||
ForbiddenException,
|
||||
UnprocessableEntityException,
|
||||
} from '../exceptions';
|
||||
import { Accountability, PrimaryKey, Item, AbstractServiceOptions } from '../types';
|
||||
import Knex from 'knex';
|
||||
import env from '../env';
|
||||
import cache from '../cache';
|
||||
import { toArray } from '../utils/to-array';
|
||||
|
||||
export class UsersService extends ItemsService {
|
||||
knex: Knex;
|
||||
@@ -35,7 +40,7 @@ export class UsersService extends ItemsService {
|
||||
* This is just an extra bit of hardcoded security. We don't want anybody to be able to disable 2fa through
|
||||
* the regular /users endpoint. Period. You should only be able to manage the 2fa status through the /tfa endpoint.
|
||||
*/
|
||||
const payloads = Array.isArray(data) ? data : [data];
|
||||
const payloads = toArray(data);
|
||||
|
||||
for (const payload of payloads) {
|
||||
if (payload.hasOwnProperty('tfa_secret')) {
|
||||
@@ -50,6 +55,30 @@ export class UsersService extends ItemsService {
|
||||
return this.service.update(data, key as any);
|
||||
}
|
||||
|
||||
delete(key: PrimaryKey): Promise<PrimaryKey>;
|
||||
delete(keys: PrimaryKey[]): Promise<PrimaryKey[]>;
|
||||
async delete(key: PrimaryKey | PrimaryKey[]): Promise<PrimaryKey | PrimaryKey[]> {
|
||||
const keys = toArray(key);
|
||||
|
||||
// Make sure there's at least one admin user left after this deletion is done
|
||||
const otherAdminUsers = await this.knex
|
||||
.count('*', { as: 'count' })
|
||||
.from('directus_users')
|
||||
.whereNotIn('directus_users.id', keys)
|
||||
.andWhere({ 'directus_roles.admin_access': true })
|
||||
.leftJoin('directus_roles', 'directus_users.role', 'directus_roles.id')
|
||||
.first();
|
||||
|
||||
const otherAdminUsersCount = +(otherAdminUsers?.count || 0);
|
||||
|
||||
if (otherAdminUsersCount === 0)
|
||||
throw new UnprocessableEntityException(`You can't delete the last admin user.`);
|
||||
|
||||
await super.delete(keys as any);
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
async inviteUser(email: string, role: string) {
|
||||
await this.service.create({ email, role, status: 'invited' });
|
||||
|
||||
|
||||
33
api/src/start.ts
Normal file
33
api/src/start.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import emitter from './emitter';
|
||||
import env from './env';
|
||||
import logger from './logger';
|
||||
|
||||
// If this file is called directly using node, start the server
|
||||
if (require.main === module) {
|
||||
start();
|
||||
}
|
||||
|
||||
export default async function start() {
|
||||
const server = require('./server').default;
|
||||
const { validateDBConnection } = require('./database');
|
||||
|
||||
await validateDBConnection();
|
||||
|
||||
await emitter.emitAsync('server.start.before', { server });
|
||||
|
||||
const port = env.PORT;
|
||||
|
||||
server
|
||||
.listen(port, () => {
|
||||
logger.info(`Server started at port ${port}`);
|
||||
emitter.emitAsync('server.start').catch((err) => logger.warn(err));
|
||||
})
|
||||
.once('error', (err: any) => {
|
||||
if (err?.code === 'EADDRINUSE') {
|
||||
logger.fatal(`Port ${port} is already in use`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import env from './env';
|
||||
import { validateEnv } from './utils/validate-env';
|
||||
import { getConfigFromEnv } from './utils/get-config-from-env';
|
||||
import { toArray } from './utils/to-array';
|
||||
|
||||
/** @todo dynamically load these storage adapters */
|
||||
import { AmazonWebServicesS3Storage } from '@slynova/flydrive-s3';
|
||||
@@ -25,7 +26,7 @@ function getStorageConfig(): StorageManagerConfig {
|
||||
disks: {},
|
||||
};
|
||||
|
||||
const locations = env.STORAGE_LOCATIONS.split(',');
|
||||
const locations = toArray(env.STORAGE_LOCATIONS);
|
||||
|
||||
locations.forEach((location: string) => {
|
||||
location = location.trim();
|
||||
|
||||
@@ -1,24 +1,55 @@
|
||||
import { Query } from './query';
|
||||
import { Relation } from './relation';
|
||||
|
||||
export type NestedCollectionAST = {
|
||||
type: 'collection';
|
||||
export type M2ONode = {
|
||||
type: 'm2o';
|
||||
name: string;
|
||||
children: (NestedCollectionAST | FieldAST)[];
|
||||
children: (NestedCollectionNode | FieldNode)[];
|
||||
query: Query;
|
||||
fieldKey: string;
|
||||
relation: Relation;
|
||||
parentKey: string;
|
||||
relatedKey: string;
|
||||
};
|
||||
|
||||
export type M2ANode = {
|
||||
type: 'm2a';
|
||||
names: string[];
|
||||
children: {
|
||||
[collection: string]: (NestedCollectionNode | FieldNode)[];
|
||||
};
|
||||
query: {
|
||||
[collection: string]: Query;
|
||||
};
|
||||
relatedKey: {
|
||||
[collection: string]: string;
|
||||
};
|
||||
fieldKey: string;
|
||||
relation: Relation;
|
||||
parentKey: string;
|
||||
};
|
||||
|
||||
export type FieldAST = {
|
||||
export type O2MNode = {
|
||||
type: 'o2m';
|
||||
name: string;
|
||||
children: (NestedCollectionNode | FieldNode)[];
|
||||
query: Query;
|
||||
fieldKey: string;
|
||||
relation: Relation;
|
||||
parentKey: string;
|
||||
relatedKey: string;
|
||||
};
|
||||
|
||||
export type NestedCollectionNode = M2ONode | O2MNode | M2ANode;
|
||||
|
||||
export type FieldNode = {
|
||||
type: 'field';
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type AST = {
|
||||
type: 'collection';
|
||||
type: 'root';
|
||||
name: string;
|
||||
children: (NestedCollectionAST | FieldAST)[];
|
||||
children: (NestedCollectionNode | FieldNode)[];
|
||||
query: Query;
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import env from '../env';
|
||||
import Knex from 'knex';
|
||||
import { Router } from 'express';
|
||||
|
||||
type ExtensionContext = {
|
||||
export type ExtensionContext = {
|
||||
services: typeof services;
|
||||
exceptions: typeof exceptions;
|
||||
database: Knex;
|
||||
|
||||
@@ -28,8 +28,7 @@ export type FieldMeta = {
|
||||
locked: boolean;
|
||||
required: boolean;
|
||||
readonly: boolean;
|
||||
hidden_detail: boolean;
|
||||
hidden_browse: boolean;
|
||||
hidden: boolean;
|
||||
sort: number | null;
|
||||
width: string | null;
|
||||
group: number | null;
|
||||
|
||||
@@ -5,7 +5,10 @@ export type Relation = {
|
||||
many_field: string;
|
||||
many_primary: string;
|
||||
|
||||
one_collection: string;
|
||||
one_field: string;
|
||||
one_primary: string;
|
||||
one_collection: string | null;
|
||||
one_field: string | null;
|
||||
one_primary: string | null;
|
||||
|
||||
one_collection_field: string | null;
|
||||
one_allowed_collections: string | null;
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ export default async function applyQuery(collection: string, dbQuery: QueryBuild
|
||||
dbQuery.orderBy(query.sort);
|
||||
}
|
||||
|
||||
if (typeof query.limit === 'number' && !query.offset) {
|
||||
if (typeof query.limit === 'number') {
|
||||
dbQuery.limit(query.limit);
|
||||
}
|
||||
|
||||
@@ -67,7 +67,10 @@ export async function applyFilter(dbQuery: QueryBuilder, filter: Filter, collect
|
||||
const filterPath = getFilterPath(key, value);
|
||||
const { operator: filterOperator, value: filterValue } = getOperation(key, value);
|
||||
|
||||
const column = filterPath.length > 1 ? await applyJoins(dbQuery, filterPath, collection) : `${collection}.${filterPath[0]}`;
|
||||
const column =
|
||||
filterPath.length > 1
|
||||
? await applyJoins(dbQuery, filterPath, collection)
|
||||
: `${collection}.${filterPath[0]}`;
|
||||
|
||||
applyFilterToQuery(column, filterOperator, filterValue);
|
||||
}
|
||||
@@ -167,8 +170,9 @@ function getFilterPath(key: string, value: Record<string, any>) {
|
||||
return path;
|
||||
}
|
||||
|
||||
function getOperation(key: string, value: Record<string, any>): { operator: string, value: any } {
|
||||
if (key.startsWith('_') && key !== '_and' && key !== '_or') return { operator: key as string, value };
|
||||
function getOperation(key: string, value: Record<string, any>): { operator: string; value: any } {
|
||||
if (key.startsWith('_') && key !== '_and' && key !== '_or')
|
||||
return { operator: key as string, value };
|
||||
return getOperation(Object.keys(value)[0], Object.values(value)[0]);
|
||||
}
|
||||
|
||||
@@ -191,12 +195,21 @@ async function applyJoins(dbQuery: QueryBuilder, path: string[], collection: str
|
||||
|
||||
if (!relation) return;
|
||||
|
||||
const isM2O = relation.many_collection === parentCollection && relation.many_field === pathParts[0];
|
||||
const isM2O =
|
||||
relation.many_collection === parentCollection && relation.many_field === pathParts[0];
|
||||
|
||||
if (isM2O) {
|
||||
dbQuery.leftJoin(relation.one_collection, `${parentCollection}.${relation.many_field}`, `${relation.one_collection}.${relation.one_primary}`);
|
||||
dbQuery.leftJoin(
|
||||
relation.one_collection,
|
||||
`${parentCollection}.${relation.many_field}`,
|
||||
`${relation.one_collection}.${relation.one_primary}`
|
||||
);
|
||||
} else {
|
||||
dbQuery.leftJoin(relation.many_collection, `${relation.one_collection}.${relation.one_primary}`, `${relation.many_collection}.${relation.many_field}`);
|
||||
dbQuery.leftJoin(
|
||||
relation.many_collection,
|
||||
`${relation.one_collection}.${relation.one_primary}`,
|
||||
`${relation.many_collection}.${relation.many_field}`
|
||||
);
|
||||
}
|
||||
|
||||
pathParts.shift();
|
||||
|
||||
@@ -4,17 +4,18 @@
|
||||
|
||||
import {
|
||||
AST,
|
||||
NestedCollectionAST,
|
||||
FieldAST,
|
||||
NestedCollectionNode,
|
||||
FieldNode,
|
||||
Query,
|
||||
Relation,
|
||||
PermissionsAction,
|
||||
Accountability,
|
||||
} from '../types';
|
||||
import database from '../database';
|
||||
import { clone } from 'lodash';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import Knex from 'knex';
|
||||
import SchemaInspector from 'knex-schema-inspector';
|
||||
import { getRelationType } from '../utils/get-relation-type';
|
||||
|
||||
type GetASTOptions = {
|
||||
accountability?: Accountability | null;
|
||||
@@ -27,7 +28,7 @@ export default async function getASTFromQuery(
|
||||
query: Query,
|
||||
options?: GetASTOptions
|
||||
): Promise<AST> {
|
||||
query = clone(query);
|
||||
query = cloneDeep(query);
|
||||
|
||||
const accountability = options?.accountability;
|
||||
const action = options?.action || 'read';
|
||||
@@ -49,7 +50,7 @@ export default async function getASTFromQuery(
|
||||
: null;
|
||||
|
||||
const ast: AST = {
|
||||
type: 'collection',
|
||||
type: 'root',
|
||||
name: collection,
|
||||
query: query,
|
||||
children: [],
|
||||
@@ -62,16 +63,120 @@ export default async function getASTFromQuery(
|
||||
delete query.fields;
|
||||
delete query.deep;
|
||||
|
||||
ast.children = (await parseFields(collection, fields, deep)).filter(filterEmptyChildCollections);
|
||||
ast.children = await parseFields(collection, fields, deep);
|
||||
|
||||
return ast;
|
||||
|
||||
function convertWildcards(parentCollection: string, fields: string[]) {
|
||||
async function parseFields(
|
||||
parentCollection: string,
|
||||
fields: string[],
|
||||
deep?: Record<string, Query>
|
||||
) {
|
||||
fields = await convertWildcards(parentCollection, fields);
|
||||
|
||||
if (!fields) return [];
|
||||
|
||||
const children: (NestedCollectionNode | FieldNode)[] = [];
|
||||
|
||||
const relationalStructure: Record<string, string[]> = {};
|
||||
|
||||
for (const field of fields) {
|
||||
const isRelational =
|
||||
field.includes('.') ||
|
||||
// We'll always treat top level o2m fields as a related item. This is an alias field, otherwise it won't return
|
||||
// anything
|
||||
!!relations.find(
|
||||
(relation) =>
|
||||
relation.one_collection === parentCollection && relation.one_field === field
|
||||
);
|
||||
|
||||
if (isRelational) {
|
||||
// field is relational
|
||||
const parts = field.split('.');
|
||||
|
||||
if (relationalStructure.hasOwnProperty(parts[0]) === false) {
|
||||
relationalStructure[parts[0]] = [];
|
||||
}
|
||||
|
||||
if (parts.length > 1) {
|
||||
relationalStructure[parts[0]].push(parts.slice(1).join('.'));
|
||||
}
|
||||
} else {
|
||||
children.push({ type: 'field', name: field });
|
||||
}
|
||||
}
|
||||
|
||||
for (const [relationalField, nestedFields] of Object.entries(relationalStructure)) {
|
||||
const relatedCollection = getRelatedCollection(parentCollection, relationalField);
|
||||
const relation = getRelation(parentCollection, relationalField);
|
||||
|
||||
if (!relation) continue;
|
||||
|
||||
const relationType = getRelationType({
|
||||
relation,
|
||||
collection: parentCollection,
|
||||
field: relationalField,
|
||||
});
|
||||
|
||||
if (!relationType) continue;
|
||||
|
||||
let child: NestedCollectionNode | null = null;
|
||||
|
||||
if (relationType === 'm2a') {
|
||||
const allowedCollections = relation.one_allowed_collections!.split(',');
|
||||
|
||||
child = {
|
||||
type: 'm2a',
|
||||
names: allowedCollections,
|
||||
children: {},
|
||||
query: {},
|
||||
relatedKey: {},
|
||||
parentKey: await schemaInspector.primary(parentCollection),
|
||||
fieldKey: relationalField,
|
||||
relation: relation,
|
||||
};
|
||||
|
||||
for (const relatedCollection of allowedCollections) {
|
||||
child.children[relatedCollection] = await parseFields(
|
||||
relatedCollection,
|
||||
nestedFields
|
||||
);
|
||||
child.query[relatedCollection] = {};
|
||||
child.relatedKey[relatedCollection] = await schemaInspector.primary(
|
||||
relatedCollection
|
||||
);
|
||||
}
|
||||
} else if (relatedCollection) {
|
||||
child = {
|
||||
type: relationType,
|
||||
name: relatedCollection,
|
||||
fieldKey: relationalField,
|
||||
parentKey: await schemaInspector.primary(parentCollection),
|
||||
relatedKey: await schemaInspector.primary(relatedCollection),
|
||||
relation: relation,
|
||||
query: deep?.[relationalField] || {},
|
||||
children: await parseFields(relatedCollection, nestedFields),
|
||||
};
|
||||
}
|
||||
|
||||
if (child) {
|
||||
children.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
async function convertWildcards(parentCollection: string, fields: string[]) {
|
||||
fields = cloneDeep(fields);
|
||||
|
||||
const fieldsInCollection = await getFieldsInCollection(parentCollection);
|
||||
|
||||
const allowedFields = permissions
|
||||
? permissions
|
||||
.find((permission) => parentCollection === permission.collection)
|
||||
?.fields?.split(',')
|
||||
: ['*'];
|
||||
: fieldsInCollection;
|
||||
|
||||
if (!allowedFields || allowedFields.length === 0) return [];
|
||||
|
||||
@@ -81,8 +186,13 @@ export default async function getASTFromQuery(
|
||||
if (fieldKey.includes('*') === false) continue;
|
||||
|
||||
if (fieldKey === '*') {
|
||||
if (allowedFields.includes('*')) continue;
|
||||
fields.splice(index, 1, ...allowedFields);
|
||||
// Set to all fields in collection
|
||||
if (allowedFields.includes('*')) {
|
||||
fields.splice(index, 1, ...fieldsInCollection);
|
||||
} else {
|
||||
// Set to all allowed fields
|
||||
fields.splice(index, 1, ...allowedFields);
|
||||
}
|
||||
}
|
||||
|
||||
// Swap *.* case for *,<relational-field>.*,<another-relational>.*
|
||||
@@ -97,12 +207,12 @@ export default async function getASTFromQuery(
|
||||
relation.one_collection === parentCollection
|
||||
)
|
||||
.map((relation) => {
|
||||
const isM2O = relation.many_collection === parentCollection;
|
||||
return isM2O ? relation.many_field : relation.one_field;
|
||||
const isMany = relation.many_collection === parentCollection;
|
||||
return isMany ? relation.many_field : relation.one_field;
|
||||
})
|
||||
: allowedFields.filter((fieldKey) => !!getRelation(parentCollection, fieldKey));
|
||||
|
||||
const nonRelationalFields = allowedFields.filter(
|
||||
const nonRelationalFields = fieldsInCollection.filter(
|
||||
(fieldKey) => relationalFields.includes(fieldKey) === false
|
||||
);
|
||||
|
||||
@@ -122,57 +232,6 @@ export default async function getASTFromQuery(
|
||||
return fields;
|
||||
}
|
||||
|
||||
async function parseFields(parentCollection: string, fields: string[], deep?: Record<string, Query>) {
|
||||
fields = convertWildcards(parentCollection, fields);
|
||||
|
||||
if (!fields) return [];
|
||||
|
||||
const children: (NestedCollectionAST | FieldAST)[] = [];
|
||||
|
||||
const relationalStructure: Record<string, string[]> = {};
|
||||
|
||||
for (const field of fields) {
|
||||
if (field.includes('.') === false) {
|
||||
children.push({ type: 'field', name: field });
|
||||
} else {
|
||||
// field is relational
|
||||
const parts = field.split('.');
|
||||
|
||||
if (relationalStructure.hasOwnProperty(parts[0]) === false) {
|
||||
relationalStructure[parts[0]] = [];
|
||||
}
|
||||
|
||||
relationalStructure[parts[0]].push(parts.slice(1).join('.'));
|
||||
}
|
||||
}
|
||||
|
||||
for (const [relationalField, nestedFields] of Object.entries(relationalStructure)) {
|
||||
const relatedCollection = getRelatedCollection(parentCollection, relationalField);
|
||||
|
||||
if (!relatedCollection) continue;
|
||||
|
||||
const relation = getRelation(parentCollection, relationalField);
|
||||
|
||||
if (!relation) continue;
|
||||
|
||||
const child: NestedCollectionAST = {
|
||||
type: 'collection',
|
||||
name: relatedCollection,
|
||||
fieldKey: relationalField,
|
||||
parentKey: await schemaInspector.primary(parentCollection),
|
||||
relation: relation,
|
||||
query: deep?.[relationalField] || {},
|
||||
children: (await parseFields(relatedCollection, nestedFields)).filter(
|
||||
filterEmptyChildCollections
|
||||
),
|
||||
};
|
||||
|
||||
children.push(child);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
function getRelation(collection: string, field: string) {
|
||||
const relation = relations.find((relation) => {
|
||||
return (
|
||||
@@ -184,22 +243,35 @@ export default async function getASTFromQuery(
|
||||
return relation;
|
||||
}
|
||||
|
||||
function getRelatedCollection(collection: string, field: string) {
|
||||
function getRelatedCollection(collection: string, field: string): string | null {
|
||||
const relation = getRelation(collection, field);
|
||||
|
||||
if (!relation) return null;
|
||||
|
||||
if (relation.many_collection === collection && relation.many_field === field) {
|
||||
return relation.one_collection;
|
||||
return relation.one_collection || null;
|
||||
}
|
||||
|
||||
if (relation.one_collection === collection && relation.one_field === field) {
|
||||
return relation.many_collection;
|
||||
return relation.many_collection || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function filterEmptyChildCollections(childAST: FieldAST | NestedCollectionAST) {
|
||||
if (childAST.type === 'collection' && childAST.children.length === 0) return false;
|
||||
return true;
|
||||
async function getFieldsInCollection(collection: string) {
|
||||
const columns = (await schemaInspector.columns(collection)).map((column) => column.column);
|
||||
const fields = (
|
||||
await knex.select('field').from('directus_fields').where({ collection })
|
||||
).map((field) => field.field);
|
||||
|
||||
const fieldsInCollection = [
|
||||
...columns,
|
||||
...fields.filter((field) => {
|
||||
return columns.includes(field) === false;
|
||||
}),
|
||||
];
|
||||
|
||||
return fieldsInCollection;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Column } from 'knex-schema-inspector/dist/types/column';
|
||||
import getLocalType from './get-local-type';
|
||||
|
||||
export default function getDefaultValue(column: Column) {
|
||||
const type = getLocalType(column.type);
|
||||
const type = getLocalType(column);
|
||||
|
||||
let defaultValue = column.default_value || null;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { types } from '../types';
|
||||
import { Column } from 'knex-schema-inspector/lib/types/column';
|
||||
import { FieldMeta, types } from '../types';
|
||||
|
||||
/**
|
||||
* Typemap graciously provided by @gpetrov
|
||||
@@ -80,14 +81,19 @@ const localTypeMap: Record<string, { type: typeof types[number]; useTimezone?: b
|
||||
};
|
||||
|
||||
export default function getLocalType(
|
||||
databaseType: string,
|
||||
special?: string[] | null
|
||||
column: Column,
|
||||
field?: FieldMeta
|
||||
): typeof types[number] | 'unknown' {
|
||||
const type = localTypeMap[databaseType.toLowerCase().split('(')[0]];
|
||||
const type = localTypeMap[column.type.toLowerCase().split('(')[0]];
|
||||
|
||||
if (special?.includes('json')) return 'json';
|
||||
if (special?.includes('csv')) return 'csv';
|
||||
if (special?.includes('uuid')) return 'uuid';
|
||||
/** Handle Postgres numeric decimals */
|
||||
if (column.type === 'numeric' && column.precision !== null && column.scale !== null) {
|
||||
return 'decimal';
|
||||
}
|
||||
|
||||
if (field?.special?.includes('json')) return 'json';
|
||||
if (field?.special?.includes('csv')) return 'csv';
|
||||
if (field?.special?.includes('uuid')) return 'uuid';
|
||||
|
||||
if (type) {
|
||||
return type.type;
|
||||
|
||||
30
api/src/utils/get-relation-type.ts
Normal file
30
api/src/utils/get-relation-type.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Relation } from '../types';
|
||||
|
||||
export function getRelationType(getRelationOptions: {
|
||||
relation: Relation;
|
||||
collection: string | null;
|
||||
field: string;
|
||||
}): 'm2o' | 'o2m' | 'm2a' | null {
|
||||
const { relation, collection, field } = getRelationOptions;
|
||||
|
||||
if (!relation) return null;
|
||||
|
||||
if (
|
||||
relation.many_collection === collection &&
|
||||
relation.many_field === field &&
|
||||
relation.one_collection_field &&
|
||||
relation.one_allowed_collections
|
||||
) {
|
||||
return 'm2a';
|
||||
}
|
||||
|
||||
if (relation.many_collection === collection && relation.many_field === field) {
|
||||
return 'm2o';
|
||||
}
|
||||
|
||||
if (relation.one_collection === collection && relation.one_field === field) {
|
||||
return 'o2m';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ const readdir = promisify(fs.readdir);
|
||||
const stat = promisify(fs.stat);
|
||||
|
||||
export default async function listFolders(location: string) {
|
||||
const fullPath = path.join(process.cwd(), location);
|
||||
const fullPath = path.resolve(location);
|
||||
const files = await readdir(fullPath);
|
||||
|
||||
const directories: string[] = [];
|
||||
|
||||
@@ -2,7 +2,12 @@ import { Filter, Accountability } from '../types';
|
||||
import { deepMap } from './deep-map';
|
||||
|
||||
export function parseFilter(filter: Filter, accountability: Accountability | null) {
|
||||
return deepMap(filter, (val: any) => {
|
||||
return deepMap(filter, (val: any, key: string) => {
|
||||
if (val === 'true') return true;
|
||||
if (val === 'false') return false;
|
||||
|
||||
if (key === '_in' || key === '_nin') return val.split(',').filter((val: any) => val);
|
||||
|
||||
if (val === '$NOW') return new Date();
|
||||
if (val === '$CURRENT_USER') return accountability?.user || null;
|
||||
if (val === '$CURRENT_ROLE') return accountability?.role || null;
|
||||
|
||||
@@ -2,7 +2,10 @@ import { Accountability, Query, Sort, Filter, Meta } from '../types';
|
||||
import logger from '../logger';
|
||||
import { parseFilter } from '../utils/parse-filter';
|
||||
|
||||
export function sanitizeQuery(rawQuery: Record<string, any>, accountability: Accountability | null) {
|
||||
export function sanitizeQuery(
|
||||
rawQuery: Record<string, any>,
|
||||
accountability: Accountability | null
|
||||
) {
|
||||
const query: Query = {};
|
||||
|
||||
if (rawQuery.limit !== undefined) {
|
||||
@@ -49,11 +52,7 @@ export function sanitizeQuery(rawQuery: Record<string, any>, accountability: Acc
|
||||
query.search = rawQuery.search;
|
||||
}
|
||||
|
||||
if (
|
||||
rawQuery.export &&
|
||||
typeof rawQuery.export === 'string' &&
|
||||
['json', 'csv'].includes(rawQuery.export)
|
||||
) {
|
||||
if (rawQuery.export) {
|
||||
query.export = rawQuery.export as 'json' | 'csv';
|
||||
}
|
||||
|
||||
|
||||
3
api/src/utils/to-array.ts
Normal file
3
api/src/utils/to-array.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function toArray<T = any>(val: T | T[]): T[] {
|
||||
return Array.isArray(val) ? val : [val];
|
||||
}
|
||||
@@ -57,9 +57,7 @@ async function getEnvInfo(event: string) {
|
||||
transport: env.EMAIL_TRANSPORT,
|
||||
},
|
||||
oauth: {
|
||||
providers: env.OAUTH_PROVIDERS.split(',')
|
||||
.filter((p?: string) => p)
|
||||
.map((p: string) => p.trim()),
|
||||
providers: env.OAUTH_PROVIDERS,
|
||||
},
|
||||
db_client: env.DB_CLIENT,
|
||||
};
|
||||
@@ -67,9 +65,7 @@ async function getEnvInfo(event: string) {
|
||||
|
||||
function getStorageDrivers() {
|
||||
const drivers: string[] = [];
|
||||
const locations = env.STORAGE_LOCATIONS.split(',')
|
||||
.filter((l?: string) => l)
|
||||
.map((l: string) => l.trim());
|
||||
const locations = env.STORAGE_LOCATIONS;
|
||||
|
||||
for (const location of locations) {
|
||||
const driver = env[`STORAGE_${location.toUpperCase()}_DRIVER`];
|
||||
|
||||
110
api/src/utils/validate-query.ts
Normal file
110
api/src/utils/validate-query.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Query } from '../types';
|
||||
import Joi from 'joi';
|
||||
import { InvalidQueryException } from '../exceptions';
|
||||
|
||||
const querySchema = Joi.object({
|
||||
fields: Joi.array().items(Joi.string()),
|
||||
sort: Joi.array().items(
|
||||
Joi.object({
|
||||
column: Joi.string(),
|
||||
order: Joi.string().valid('asc', 'desc'),
|
||||
})
|
||||
),
|
||||
filter: Joi.object({}).unknown(),
|
||||
limit: Joi.number(),
|
||||
offset: Joi.number(),
|
||||
page: Joi.number(),
|
||||
single: Joi.boolean(),
|
||||
meta: Joi.array().items(Joi.string().valid('total_count', 'result_count')),
|
||||
search: Joi.string(),
|
||||
export: Joi.string().valid('json', 'csv'),
|
||||
deep: Joi.link('#query'),
|
||||
}).id('query');
|
||||
|
||||
export function validateQuery(query: Query) {
|
||||
const { error } = querySchema.validate(query);
|
||||
|
||||
if (query.filter && Object.keys(query.filter).length > 0) {
|
||||
validateFilter(query.filter);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw new InvalidQueryException(error.message);
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
function validateFilter(filter: Query['filter']) {
|
||||
if (!filter) throw new InvalidQueryException('Invalid filter object');
|
||||
|
||||
for (const [key, nested] of Object.entries(filter)) {
|
||||
if (key === '_and' || key === '_or') {
|
||||
nested.forEach(validateFilter);
|
||||
} else if (key.startsWith('_')) {
|
||||
const value = nested;
|
||||
|
||||
switch (key) {
|
||||
case '_eq':
|
||||
case '_neq':
|
||||
case '_contains':
|
||||
case '_ncontains':
|
||||
case '_gt':
|
||||
case '_gte':
|
||||
case '_lt':
|
||||
case '_lte':
|
||||
default:
|
||||
validateFilterPrimitive(value, key);
|
||||
break;
|
||||
case '_in':
|
||||
case '_nin':
|
||||
validateList(value, key);
|
||||
break;
|
||||
case '_null':
|
||||
case '_nnull':
|
||||
case '_empty':
|
||||
case '_nempty':
|
||||
validateBoolean(value, key);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
validateFilter(nested);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateFilterPrimitive(value: any, key: string) {
|
||||
if ((typeof value === 'string' || typeof value === 'number') === false) {
|
||||
throw new InvalidQueryException(
|
||||
`The filter value for "${key}" has to be a string or a number`
|
||||
);
|
||||
}
|
||||
|
||||
if (Number.isNaN(value)) {
|
||||
throw new InvalidQueryException(`The filter value for "${key}" is not a valid number`);
|
||||
}
|
||||
|
||||
if (typeof value === 'string' && value.length === 0) {
|
||||
throw new InvalidQueryException(
|
||||
`You can't filter for an empty string in "${key}". Use "_empty" or "_nempty" instead`
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateList(value: any, key: string) {
|
||||
if (Array.isArray(value) === false || value.length === 0) {
|
||||
throw new InvalidQueryException(`"${key}" has to be an array of values`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateBoolean(value: any, key: string) {
|
||||
if (typeof value !== 'boolean') {
|
||||
throw new InvalidQueryException(`"${key}" has to be a boolean`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -10,7 +10,8 @@
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"lib": ["es2019"],
|
||||
"skipLibCheck": true
|
||||
"skipLibCheck": true,
|
||||
"declaration": true
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
|
||||
@@ -6442,7 +6442,7 @@
|
||||
"options": {
|
||||
"fields": [
|
||||
{
|
||||
"field": "locale",
|
||||
"field": "language",
|
||||
"type": "string",
|
||||
"interface": "language",
|
||||
"options": {
|
||||
@@ -7062,7 +7062,7 @@
|
||||
"options": {
|
||||
"fields": [
|
||||
{
|
||||
"field": "locale",
|
||||
"field": "language",
|
||||
"type": "string",
|
||||
"interface": "language",
|
||||
"options": {
|
||||
@@ -10482,9 +10482,9 @@
|
||||
"group": null,
|
||||
"length": "32"
|
||||
},
|
||||
"locale": {
|
||||
"language": {
|
||||
"collection": "directus_users",
|
||||
"field": "locale",
|
||||
"field": "language",
|
||||
"datatype": "VARCHAR",
|
||||
"unique": false,
|
||||
"primary_key": false,
|
||||
@@ -10512,34 +10512,6 @@
|
||||
"group": null,
|
||||
"length": "8"
|
||||
},
|
||||
"locale_options": {
|
||||
"collection": "directus_users",
|
||||
"field": "locale_options",
|
||||
"datatype": "TEXT",
|
||||
"unique": false,
|
||||
"primary_key": false,
|
||||
"auto_increment": false,
|
||||
"default_value": null,
|
||||
"note": "",
|
||||
"signed": true,
|
||||
"id": 857,
|
||||
"type": "json",
|
||||
"sort": 16,
|
||||
"interface": "json",
|
||||
"display": null,
|
||||
"display_options": null,
|
||||
"hidden_detail": true,
|
||||
"hidden_browse": true,
|
||||
"required": false,
|
||||
"options": null,
|
||||
"locked": 1,
|
||||
"translation": null,
|
||||
"readonly": false,
|
||||
"width": null,
|
||||
"validation": null,
|
||||
"group": null,
|
||||
"length": null
|
||||
},
|
||||
"avatar": {
|
||||
"collection": "directus_users",
|
||||
"field": "avatar",
|
||||
|
||||
@@ -1788,7 +1788,7 @@
|
||||
"options": {
|
||||
"fields": [
|
||||
{
|
||||
"field": "locale",
|
||||
"field": "language",
|
||||
"type": "string",
|
||||
"interface": "language",
|
||||
"options": {
|
||||
@@ -2736,7 +2736,7 @@
|
||||
"options": {
|
||||
"fields": [
|
||||
{
|
||||
"field": "locale",
|
||||
"field": "language",
|
||||
"type": "string",
|
||||
"interface": "language",
|
||||
"options": {
|
||||
@@ -5639,7 +5639,7 @@
|
||||
},
|
||||
{
|
||||
"collection": "directus_users",
|
||||
"field": "locale",
|
||||
"field": "language",
|
||||
"datatype": "VARCHAR",
|
||||
"unique": false,
|
||||
"primary_key": false,
|
||||
@@ -5666,7 +5666,7 @@
|
||||
"validation": null,
|
||||
"group": null,
|
||||
"length": "8",
|
||||
"name": "Locale"
|
||||
"name": "Language"
|
||||
},
|
||||
{
|
||||
"collection": "directus_users",
|
||||
@@ -5740,35 +5740,6 @@
|
||||
"length": "16",
|
||||
"name": "Status"
|
||||
},
|
||||
{
|
||||
"collection": "directus_users",
|
||||
"field": "locale_options",
|
||||
"datatype": "TEXT",
|
||||
"unique": false,
|
||||
"primary_key": false,
|
||||
"auto_increment": false,
|
||||
"default_value": null,
|
||||
"note": "",
|
||||
"signed": true,
|
||||
"id": 857,
|
||||
"type": "json",
|
||||
"sort": 16,
|
||||
"interface": "json",
|
||||
"display": null,
|
||||
"display_options": null,
|
||||
"hidden_detail": true,
|
||||
"hidden_browse": true,
|
||||
"required": false,
|
||||
"options": null,
|
||||
"locked": 1,
|
||||
"translation": null,
|
||||
"readonly": false,
|
||||
"width": null,
|
||||
"validation": null,
|
||||
"group": null,
|
||||
"length": null,
|
||||
"name": "Locale Options"
|
||||
},
|
||||
{
|
||||
"collection": "directus_users",
|
||||
"field": "avatar",
|
||||
@@ -11001,7 +10972,7 @@
|
||||
{
|
||||
"id": 826,
|
||||
"collection": "directus_settings",
|
||||
"field": "default_locale",
|
||||
"field": "default_language",
|
||||
"type": "string",
|
||||
"interface": "language",
|
||||
"options": {
|
||||
@@ -11018,9 +10989,9 @@
|
||||
"sort": 8,
|
||||
"width": "half",
|
||||
"group": null,
|
||||
"note": "Default locale for Directus Users",
|
||||
"note": "Default language for Directus Users",
|
||||
"translation": null,
|
||||
"name": "Default Locale"
|
||||
"name": "Default Language"
|
||||
},
|
||||
{
|
||||
"id": 827,
|
||||
|
||||
4474
app/package-lock.json
generated
4474
app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@directus/app",
|
||||
"version": "9.0.0-beta.10",
|
||||
"version": "9.0.0-beta.12",
|
||||
"private": false,
|
||||
"description": "Directus is an Open-Source Headless CMS & API for Managing Custom Databases",
|
||||
"author": "Rijk van Zanten <rijk@rngr.org>",
|
||||
@@ -31,7 +31,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@directus/docs": "file:../docs",
|
||||
"@directus/format-title": "^3.2.0",
|
||||
"@directus/format-title": "file:../packages/format-title",
|
||||
"@popperjs/core": "^2.4.3",
|
||||
"@sindresorhus/slugify": "^1.0.0",
|
||||
"@tinymce/tinymce-vue": "^3.2.2",
|
||||
@@ -51,7 +51,6 @@
|
||||
"color": "^3.1.2",
|
||||
"color-string": "^1.5.3",
|
||||
"cropperjs": "^1.5.7",
|
||||
"csslint": "^1.0.5",
|
||||
"date-fns": "^2.14.0",
|
||||
"diff": "^4.0.2",
|
||||
"highlight.js": "^10.2.0",
|
||||
@@ -83,15 +82,15 @@
|
||||
},
|
||||
"gitHead": "4476da28dbbc2824e680137aa28b2b91b5afabec",
|
||||
"devDependencies": {
|
||||
"@vue/cli-plugin-babel": "^4.5.6",
|
||||
"@vue/cli-plugin-eslint": "^4.5.6",
|
||||
"@vue/cli-plugin-router": "^4.5.6",
|
||||
"@vue/cli-plugin-typescript": "^4.5.6",
|
||||
"@vue/cli-plugin-unit-jest": "^4.5.6",
|
||||
"@vue/cli-plugin-vuex": "^4.5.6",
|
||||
"@vue/cli-service": "^4.5.6",
|
||||
"@vue/cli-plugin-babel": "^4.5.7",
|
||||
"@vue/cli-plugin-eslint": "^4.5.7",
|
||||
"@vue/cli-plugin-router": "^4.5.7",
|
||||
"@vue/cli-plugin-typescript": "^4.5.7",
|
||||
"@vue/cli-plugin-unit-jest": "^4.5.7",
|
||||
"@vue/cli-plugin-vuex": "^4.5.7",
|
||||
"@vue/cli-service": "^4.5.7",
|
||||
"@vue/eslint-config-prettier": "^6.0.0",
|
||||
"@vue/eslint-config-typescript": "^5.1.0",
|
||||
"@vue/eslint-config-typescript": "^7.0.0",
|
||||
"@vue/test-utils": "^1.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<link rel="shortcut icon" href="/favicon.ico">
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<title>Loading...</title>
|
||||
<title>Loading...</title>
|
||||
<style id="custom-css"></style>
|
||||
</head>
|
||||
<body class="light">
|
||||
<noscript>
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
|
||||
<portal-target name="dialog-outlet" transition="transition-dialog" multiple />
|
||||
<portal-target name="menu-outlet" transition="transition-bounce" multiple />
|
||||
|
||||
<mounting-portal mount-to="#custom-css" target-tag="style">{{ customCSS }}</mounting-portal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -102,6 +104,10 @@ export default defineComponent({
|
||||
}
|
||||
);
|
||||
|
||||
const customCSS = computed(() => {
|
||||
return settingsStore.state?.settings?.custom_css || '';
|
||||
});
|
||||
|
||||
const appAccess = computed(() => {
|
||||
if (!userStore.state.currentUser) return true;
|
||||
return userStore.state.currentUser?.role?.app_access || false;
|
||||
@@ -118,7 +124,7 @@ export default defineComponent({
|
||||
axios,
|
||||
});
|
||||
|
||||
return { hydrating, brandStyle, appAccess, error };
|
||||
return { hydrating, brandStyle, appAccess, error, customCSS };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
@edit-raw="showRaw = true"
|
||||
/>
|
||||
</v-menu>
|
||||
<div class="label-spacer" v-else-if="['full', 'fill'].includes(field.meta && field.meta.width) === false" />
|
||||
|
||||
<form-field-interface
|
||||
:value="_value"
|
||||
@@ -203,4 +204,8 @@ export default defineComponent({
|
||||
.raw-value {
|
||||
--v-textarea-font-family: var(--family-monospace);
|
||||
}
|
||||
|
||||
.label-spacer {
|
||||
height: 28px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div class="v-item">
|
||||
<slot v-bind="{ active, toggle }" />
|
||||
<slot v-bind="{ active: isActive, toggle }" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from '@vue/composition-api';
|
||||
import { defineComponent, toRefs } from '@vue/composition-api';
|
||||
import { useGroupable } from '@/composables/groupable';
|
||||
|
||||
export default defineComponent({
|
||||
@@ -18,14 +18,25 @@ export default defineComponent({
|
||||
type: String,
|
||||
default: 'item-group',
|
||||
},
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: undefined
|
||||
},
|
||||
watch: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const { active, toggle } = useGroupable({
|
||||
const {active} = toRefs(props)
|
||||
const { active: isActive, toggle } = useGroupable({
|
||||
value: props.value,
|
||||
group: props.scope,
|
||||
watch: props.watch,
|
||||
active
|
||||
});
|
||||
|
||||
return { active, toggle };
|
||||
return { isActive, toggle };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
<template>
|
||||
<div class="v-list-group">
|
||||
<v-list-item :active="active" class="activator" :to="to" :exact="exact" @click="onClick" :disabled="disabled">
|
||||
<v-list-item
|
||||
:active="active"
|
||||
class="activator"
|
||||
:to="to"
|
||||
:exact="exact"
|
||||
@click="onClick"
|
||||
:disabled="disabled"
|
||||
:dense="dense"
|
||||
>
|
||||
<slot name="activator" :active="groupActive" />
|
||||
|
||||
<v-list-item-icon class="activator-icon" :class="{ active: groupActive }" v-if="$slots.default">
|
||||
@@ -15,8 +23,8 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, toRefs, watch } from '@vue/composition-api';
|
||||
import { useGroupableParent, useGroupable } from '@/composables/groupable';
|
||||
import { defineComponent, nextTick, toRefs, watch, PropType, ref } from '@vue/composition-api';
|
||||
import { useGroupable } from '@/composables/groupable';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
@@ -40,10 +48,6 @@ export default defineComponent({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disableGroupableParent: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
scope: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
@@ -52,22 +56,19 @@ export default defineComponent({
|
||||
type: [String, Number],
|
||||
default: undefined,
|
||||
},
|
||||
dense: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props, { listeners, emit }) {
|
||||
const { multiple } = toRefs(props);
|
||||
|
||||
const { active: groupActive, toggle, activate, deactivate } = useGroupable({
|
||||
group: props.scope,
|
||||
value: props.value,
|
||||
});
|
||||
|
||||
if (props.disableGroupableParent !== true) {
|
||||
useGroupableParent(
|
||||
{},
|
||||
{
|
||||
multiple: toRefs(props).multiple,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return { groupActive, toggle, onClick };
|
||||
|
||||
function onClick(event: MouseEvent) {
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<script lang="ts">
|
||||
import { Location } from 'vue-router';
|
||||
import { defineComponent, PropType, computed } from '@vue/composition-api';
|
||||
import { useGroupable } from '@/composables/groupable';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
@@ -64,6 +65,10 @@ export default defineComponent({
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
value: {
|
||||
type: [String, Number],
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
setup(props, { listeners }) {
|
||||
const component = computed<string>(() => {
|
||||
@@ -72,6 +77,10 @@ export default defineComponent({
|
||||
return 'li';
|
||||
});
|
||||
|
||||
const { active: groupActive, toggle, activate, deactivate } = useGroupable({
|
||||
value: props.value,
|
||||
});
|
||||
|
||||
const isClickable = computed(() => Boolean(props.to || props.href || listeners.click !== undefined));
|
||||
|
||||
return { component, isClickable };
|
||||
|
||||
@@ -9,7 +9,15 @@ import { defineComponent, PropType, ref, toRefs } from '@vue/composition-api';
|
||||
import { useGroupableParent } from '@/composables/groupable';
|
||||
|
||||
export default defineComponent({
|
||||
model: {
|
||||
prop: 'activeItems',
|
||||
event: 'input',
|
||||
},
|
||||
props: {
|
||||
activeItems: {
|
||||
type: Array as PropType<(number | string)[]>,
|
||||
default: () => [],
|
||||
},
|
||||
large: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -18,13 +26,23 @@ export default defineComponent({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
mandatory: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
setup(props, { emit }) {
|
||||
const { activeItems, multiple, mandatory } = toRefs(props);
|
||||
useGroupableParent(
|
||||
{},
|
||||
{
|
||||
mandatory: ref(false),
|
||||
multiple: toRefs(props).multiple,
|
||||
selection: activeItems,
|
||||
onSelectionChange: (newSelection) => {
|
||||
emit('input', newSelection);
|
||||
},
|
||||
},
|
||||
{
|
||||
mandatory,
|
||||
multiple,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
<modal-browse
|
||||
<modal-collection
|
||||
collection="directus_files"
|
||||
:active="activeDialog === 'choose'"
|
||||
@update:active="activeDialog = null"
|
||||
@@ -83,12 +83,12 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, computed, watch } from '@vue/composition-api';
|
||||
import uploadFiles from '@/utils/upload-files';
|
||||
import ModalBrowse from '@/views/private/components/modal-browse';
|
||||
import ModalCollection from '@/views/private/components/modal-collection';
|
||||
import api from '@/api';
|
||||
import useItem from '@/composables/use-item';
|
||||
|
||||
export default defineComponent({
|
||||
components: { ModalBrowse },
|
||||
components: { ModalCollection },
|
||||
props: {
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
|
||||
@@ -149,10 +149,15 @@ export function useGroupableParent(
|
||||
// Register a child within the context of this group
|
||||
function register(item: GroupableInstance) {
|
||||
items.value = [...items.value, item];
|
||||
const value = getValueForItem(item)
|
||||
|
||||
// If you're required to select a value, make sure a value is selected on first render
|
||||
if (selection.value.length === 0 && options?.mandatory?.value === true && items.value.length === 1) {
|
||||
selection.value = [getValueForItem(item)];
|
||||
selection.value = [value];
|
||||
}
|
||||
|
||||
if(item.active.value && selection.value.includes(value) === false) {
|
||||
toggle(item)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -83,9 +83,14 @@ function mapKeys(key: string) {
|
||||
|
||||
function callHandlers(event: KeyboardEvent) {
|
||||
Object.entries(handlers).forEach(([key, value]) => {
|
||||
const rest = key.split('+').filter((keySegment) => keysdown.has(keySegment) === false);
|
||||
const keys = key.split('+');
|
||||
|
||||
if (rest.length > 0) return;
|
||||
for (key of keysdown) {
|
||||
if (keys.includes(key) === false) return;
|
||||
}
|
||||
for (key of keys) {
|
||||
if (keysdown.has(key) === false) return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
let cancel = false;
|
||||
|
||||
@@ -5,6 +5,7 @@ import adjustFieldsForDisplays from '@/utils/adjust-fields-for-displays';
|
||||
import getRelatedCollection from '@/utils/get-related-collection';
|
||||
import useCollection from '@/composables/use-collection';
|
||||
import { ref } from '@vue/composition-api';
|
||||
import options from './options.vue';
|
||||
|
||||
type Options = {
|
||||
template: string;
|
||||
@@ -16,19 +17,9 @@ export default defineDisplay(({ i18n }) => ({
|
||||
description: i18n.t('displays.related-values.description'),
|
||||
icon: 'settings_ethernet',
|
||||
handler: DisplayRelatedValues,
|
||||
options: [
|
||||
/** @todo make this a component so we have dynamic collection for display template component */
|
||||
{
|
||||
field: 'template',
|
||||
name: i18n.t('display_template'),
|
||||
type: 'string',
|
||||
meta: {
|
||||
interface: 'text-input',
|
||||
width: 'full',
|
||||
},
|
||||
},
|
||||
],
|
||||
options: options,
|
||||
types: ['alias', 'string', 'uuid', 'integer', 'bigInteger', 'json'],
|
||||
localTypes: ['m2m', 'm2o', 'o2m'],
|
||||
fields: (options: Options, { field, collection }) => {
|
||||
const relatedCollection = getRelatedCollection(collection, field);
|
||||
const { primaryKeyField } = useCollection(ref(relatedCollection as string));
|
||||
|
||||
82
app/src/displays/related-values/options.vue
Normal file
82
app/src/displays/related-values/options.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<v-notice type="warning" v-if="collection === null">
|
||||
{{ $t('interfaces.one-to-many.no_collection') }}
|
||||
</v-notice>
|
||||
<div v-else class="form-grid">
|
||||
<div class="field full">
|
||||
<p class="type-label">{{ $t('display_template') }}</p>
|
||||
<v-field-template :collection="relatedCollection" v-model="template" :depth="2" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Field } from '@/types';
|
||||
import { defineComponent, PropType, computed } from '@vue/composition-api';
|
||||
import { useRelationsStore } from '@/stores/';
|
||||
import { Relation } from '@/types/relations';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
value: {
|
||||
type: Object as PropType<any | null>,
|
||||
default: null,
|
||||
},
|
||||
fieldData: {
|
||||
type: Object as PropType<Field>,
|
||||
default: null,
|
||||
},
|
||||
relations: {
|
||||
type: Array as PropType<Relation[]>,
|
||||
default: () => [],
|
||||
},
|
||||
collection: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const relationsStore = useRelationsStore();
|
||||
const template = computed({
|
||||
get() {
|
||||
return props.value?.template;
|
||||
},
|
||||
set(newTemplate: string) {
|
||||
emit('input', {
|
||||
...(props.value || {}),
|
||||
template: newTemplate,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const relatedCollection = computed(() => {
|
||||
if (!props.fieldData || !props.relations || props.relations.length === 0) return null;
|
||||
const { field } = props.fieldData;
|
||||
const m2o = props.relations.find(
|
||||
(relation) => relation.many_collection === props.collection && relation.many_field === field
|
||||
);
|
||||
const o2m = props.relations.find(
|
||||
(relation) => relation.one_collection === props.collection && relation.one_field === field
|
||||
);
|
||||
|
||||
if (m2o !== undefined) {
|
||||
return m2o?.one_collection || null;
|
||||
}
|
||||
|
||||
if (o2m !== undefined) {
|
||||
return o2m?.many_collection || null;
|
||||
}
|
||||
});
|
||||
|
||||
return { template, relatedCollection };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/mixins/form-grid';
|
||||
|
||||
.form-grid {
|
||||
@include form-grid;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
import VueI18n from 'vue-i18n';
|
||||
import { Component } from 'vue';
|
||||
import { Field, types } from '@/types';
|
||||
import { Field, localTypes, types } from '@/types';
|
||||
|
||||
export type DisplayHandlerFunctionContext = {
|
||||
type: string;
|
||||
@@ -30,6 +30,7 @@ export type DisplayConfig = {
|
||||
handler: DisplayHandlerFunction | Component;
|
||||
options: null | DeepPartial<Field>[] | Component;
|
||||
types: readonly typeof types[number][];
|
||||
localTypes?: readonly typeof localTypes[number][];
|
||||
fields?: string[] | DisplayFieldsFunction;
|
||||
};
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ export default defineComponent({
|
||||
if (props.value.avatar?.id) {
|
||||
return `${getRootPath()}assets/${props.value.avatar.id}?key=system-small-cover`;
|
||||
}
|
||||
return null
|
||||
return null;
|
||||
});
|
||||
|
||||
return { src };
|
||||
|
||||
@@ -57,7 +57,7 @@ export async function hydrate(stores = useStores()) {
|
||||
*/
|
||||
await userStore.hydrate();
|
||||
|
||||
setLanguage((userStore.state.currentUser?.locale as Language) || 'en-US');
|
||||
setLanguage((userStore.state.currentUser?.language as Language) || 'en-US');
|
||||
|
||||
await Promise.all(stores.filter(({ id }) => id !== 'userStore').map((store) => store.hydrate?.()));
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ export default defineComponent({
|
||||
}
|
||||
return found;
|
||||
});
|
||||
} else if (lang === 'javascript' || lang === 'htmlmixed' || lang === 'css' || lang === 'yaml') {
|
||||
} else if (lang === 'javascript' || lang === 'htmlmixed' || lang === 'yaml') {
|
||||
let linter = lang;
|
||||
if (lang === 'javascript') {
|
||||
const jshint = await import('jshint');
|
||||
@@ -156,9 +156,6 @@ export default defineComponent({
|
||||
linter = 'html';
|
||||
const htmlhint = await import('htmlhint');
|
||||
(window as any).HTMLHint = htmlhint;
|
||||
} else if (lang === 'css') {
|
||||
const csslint = await import('csslint');
|
||||
(window as any).CSSLint = csslint;
|
||||
} else if (lang === 'yaml') {
|
||||
const jsyaml = await import('js-yaml');
|
||||
(window as any).jsyaml = jsyaml;
|
||||
@@ -166,6 +163,9 @@ export default defineComponent({
|
||||
await import(`codemirror/mode/${lang}/${lang}.js`);
|
||||
await import(`codemirror/addon/lint/${linter}-lint.js`);
|
||||
codemirror.value.setOption('lint', (CodeMirror as any).lint[linter]);
|
||||
|
||||
await import(`codemirror/mode/${lang}/${lang}.js`);
|
||||
codemirror.value.setOption('mode', { name: lang });
|
||||
} else if (lang === 'text/plain') {
|
||||
codemirror.value.setOption('mode', { name: null });
|
||||
} else {
|
||||
|
||||
@@ -3,12 +3,24 @@ import InterfaceCode from './code.vue';
|
||||
import CodeMirror from 'codemirror';
|
||||
import 'codemirror/mode/meta';
|
||||
|
||||
const choices = CodeMirror.modeInfo.map((e) => ({
|
||||
text: e.name,
|
||||
value: e.mode,
|
||||
}));
|
||||
const choicesMap = CodeMirror.modeInfo.reduce((acc: Record<string, string>, choice) => {
|
||||
if (['JSON', 'JSON-LD'].includes(choice.name)) {
|
||||
acc['JSON'] = 'JSON';
|
||||
return acc;
|
||||
}
|
||||
|
||||
choices.push({ text: 'JSON', value: 'JSON' });
|
||||
if (choice.mode in acc) {
|
||||
acc[choice.mode] += ' / ' + choice.name;
|
||||
} else {
|
||||
acc[choice.mode] = choice.name;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const choices = Object.entries(choicesMap).map(([key, value]) => ({
|
||||
text: value,
|
||||
value: key,
|
||||
}));
|
||||
|
||||
export default defineInterface(({ i18n }) => ({
|
||||
id: 'code',
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<modal-browse
|
||||
<modal-collection
|
||||
collection="directus_files"
|
||||
:active="activeDialog === 'choose'"
|
||||
@update:active="activeDialog = null"
|
||||
@@ -107,7 +107,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, watch, computed } from '@vue/composition-api';
|
||||
import ModalBrowse from '@/views/private/components/modal-browse';
|
||||
import ModalCollection from '@/views/private/components/modal-collection';
|
||||
import api from '@/api';
|
||||
import readableMimeType from '@/utils/readable-mime-type';
|
||||
import getRootPath from '@/utils/get-root-path';
|
||||
@@ -119,7 +119,7 @@ type FileInfo = {
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
components: { ModalBrowse },
|
||||
components: { ModalCollection },
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
|
||||
@@ -1,55 +1,60 @@
|
||||
<template>
|
||||
<v-notice type="warning" v-if="!relations || relations.length !== 2">
|
||||
<v-notice type="warning" v-if="!junction || !relation">
|
||||
{{ $t('relationship_not_setup') }}
|
||||
</v-notice>
|
||||
<div v-else class="files">
|
||||
<v-table
|
||||
inline
|
||||
:items="previewItems"
|
||||
:items="displayItems"
|
||||
:loading="loading"
|
||||
:headers.sync="tableHeaders"
|
||||
:item-key="junctionCollectionPrimaryKeyField.field"
|
||||
:item-key="relationFields.junctionPkField"
|
||||
:disabled="disabled"
|
||||
@click:row="editExisting"
|
||||
@click:row="editItem"
|
||||
>
|
||||
<template #item.$thumbnail="{ item }">
|
||||
<render-display
|
||||
:value="get(item, [relationCurrentToJunction.junction_field])"
|
||||
:value="item"
|
||||
display="file"
|
||||
:collection="junctionCollection"
|
||||
:field="relationCurrentToJunction.junction_field"
|
||||
:collection="relationFields.junctionCollection"
|
||||
:field="relationFields.relationPkField"
|
||||
type="file"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #item-append="{ item }" v-if="!disabled">
|
||||
<v-icon name="close" v-tooltip="$t('deselect')" class="deselect" @click.stop="deselect(item)" />
|
||||
<v-icon
|
||||
name="close"
|
||||
v-tooltip="$t('deselect')"
|
||||
class="deselect"
|
||||
@click.stop="deleteItem(item, items)"
|
||||
/>
|
||||
</template>
|
||||
</v-table>
|
||||
|
||||
<div class="actions" v-if="!disabled">
|
||||
<v-button class="new" @click="showUpload = true">{{ $t('upload_file') }}</v-button>
|
||||
<v-button class="existing" @click="showBrowseModal = true">
|
||||
<v-button class="existing" @click="selectModalActive = true">
|
||||
{{ $t('add_existing') }}
|
||||
</v-button>
|
||||
</div>
|
||||
|
||||
<modal-detail
|
||||
<modal-item
|
||||
v-if="!disabled"
|
||||
:active="showDetailModal"
|
||||
:collection="junctionCollection"
|
||||
:primary-key="junctionRowPrimaryKey"
|
||||
:active="currentlyEditing !== null"
|
||||
:collection="relationFields.junctionCollection"
|
||||
:primary-key="currentlyEditing || '+'"
|
||||
:edits="editsAtStart"
|
||||
:junction-field="relationCurrentToJunction.junction_field"
|
||||
:related-primary-key="relatedRowPrimaryKey"
|
||||
:related-primary-key="relationFields.relationPkField"
|
||||
:junction-field="relationFields.junctionRelation"
|
||||
@input="stageEdits"
|
||||
@update:active="cancelEdit"
|
||||
/>
|
||||
|
||||
<modal-browse
|
||||
<modal-collection
|
||||
v-if="!disabled"
|
||||
:active.sync="showBrowseModal"
|
||||
:collection="relationJunctionToRelated.one_collection"
|
||||
:active.sync="selectModalActive"
|
||||
:collection="relation.one_collection"
|
||||
:selection="[]"
|
||||
:filters="selectionFilters"
|
||||
@input="stageSelection"
|
||||
@@ -69,20 +74,21 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, computed, toRefs } from '@vue/composition-api';
|
||||
import { defineComponent, ref, computed, toRefs, PropType } from '@vue/composition-api';
|
||||
import { Header as TableHeader } from '@/components/v-table/types';
|
||||
import ModalBrowse from '@/views/private/components/modal-browse';
|
||||
import ModalDetail from '@/views/private/components/modal-detail';
|
||||
import ModalCollection from '@/views/private/components/modal-collection';
|
||||
import ModalItem from '@/views/private/components/modal-item';
|
||||
import { get } from 'lodash';
|
||||
import i18n from '@/lang';
|
||||
|
||||
import useActions from '@/interfaces/many-to-many/use-actions';
|
||||
import useRelation from '@/interfaces/many-to-many/use-relation';
|
||||
import useSelection from '@/interfaces/many-to-many/use-selection';
|
||||
import usePreview from '@/interfaces/many-to-many/use-preview';
|
||||
import useEdit from '@/interfaces/many-to-many/use-edit';
|
||||
|
||||
export default defineComponent({
|
||||
components: { ModalBrowse, ModalDetail },
|
||||
components: { ModalCollection, ModalItem },
|
||||
props: {
|
||||
primaryKey: {
|
||||
type: [Number, String],
|
||||
@@ -97,8 +103,8 @@ export default defineComponent({
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
type: Array,
|
||||
default: undefined,
|
||||
type: Array as PropType<(string | number | Record<string, any>)[] | null>,
|
||||
default: null,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
@@ -108,24 +114,26 @@ export default defineComponent({
|
||||
setup(props, { emit }) {
|
||||
const { collection, field, value, primaryKey } = toRefs(props);
|
||||
|
||||
const { junction, junctionCollection, relation, relationCollection, relationFields } = useRelation(
|
||||
collection,
|
||||
field
|
||||
);
|
||||
|
||||
function emitter(newVal: any[] | null) {
|
||||
emit('input', newVal);
|
||||
}
|
||||
|
||||
const {
|
||||
relations,
|
||||
relationCurrentToJunction,
|
||||
relationJunctionToRelated,
|
||||
junctionCollectionPrimaryKeyField,
|
||||
junctionCollection,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
relatedCollection,
|
||||
} = useRelation({ collection, field });
|
||||
deleteItem,
|
||||
getUpdatedItems,
|
||||
getNewItems,
|
||||
getPrimaryKeys,
|
||||
getNewSelectedItems,
|
||||
getJunctionItem,
|
||||
getJunctionFromRelatedId,
|
||||
} = useActions(value, relationFields, emitter);
|
||||
|
||||
const fields = computed(() => {
|
||||
if (!relationCurrentToJunction.value) return [];
|
||||
if (!relationCurrentToJunction.value.junction_field) return [];
|
||||
|
||||
const jf = relationCurrentToJunction.value.junction_field;
|
||||
|
||||
return ['id', 'type', 'title'].map((key) => `${jf}.${key}`);
|
||||
});
|
||||
const fields = ref(['id', 'type', 'title']);
|
||||
|
||||
const tableHeaders = ref<TableHeader[]>([
|
||||
{
|
||||
@@ -138,133 +146,86 @@ export default defineComponent({
|
||||
{
|
||||
text: i18n.t('title'),
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
value: relationCurrentToJunction.value!.junction_field + '.title',
|
||||
value: 'title',
|
||||
align: 'left',
|
||||
sortable: true,
|
||||
width: 250,
|
||||
},
|
||||
]);
|
||||
|
||||
const { loading, previewItems, error } = usePreview({
|
||||
const { loading, displayItems, error, items } = usePreview(
|
||||
value,
|
||||
primaryKey,
|
||||
junctionCollectionPrimaryKeyField,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
junctionCollection,
|
||||
relatedCollection,
|
||||
relationCurrentToJunction,
|
||||
relationJunctionToRelated,
|
||||
fields,
|
||||
});
|
||||
relationFields,
|
||||
getNewSelectedItems,
|
||||
getUpdatedItems,
|
||||
getNewItems,
|
||||
getPrimaryKeys
|
||||
);
|
||||
|
||||
const {
|
||||
showDetailModal,
|
||||
cancelEdit,
|
||||
stageEdits,
|
||||
editsAtStart,
|
||||
junctionRowPrimaryKey,
|
||||
editExisting,
|
||||
relatedRowPrimaryKey,
|
||||
initialValues,
|
||||
} = useEdit({
|
||||
relationCurrentToJunction,
|
||||
junctionCollectionPrimaryKeyField,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
const { cancelEdit, stageEdits, editsAtStart, editItem, currentlyEditing } = useEdit(
|
||||
value,
|
||||
onEdit: (newValue) => emit('input', newValue),
|
||||
});
|
||||
items,
|
||||
relationFields,
|
||||
emitter,
|
||||
getJunctionFromRelatedId
|
||||
);
|
||||
|
||||
const { showBrowseModal, stageSelection, selectionFilters } = useSelection({
|
||||
relationCurrentToJunction,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
previewItems,
|
||||
onStageSelection: (selectionAsJunctionRows) => {
|
||||
emit('input', [...(props.value || []), ...selectionAsJunctionRows]);
|
||||
},
|
||||
});
|
||||
const { stageSelection, selectModalActive, selectionFilters } = useSelection(
|
||||
value,
|
||||
displayItems,
|
||||
relationFields,
|
||||
emitter
|
||||
);
|
||||
|
||||
const { showUpload, onUpload } = useUpload();
|
||||
|
||||
return {
|
||||
relations,
|
||||
relationCurrentToJunction,
|
||||
relationJunctionToRelated,
|
||||
junction,
|
||||
relation,
|
||||
tableHeaders,
|
||||
junctionCollectionPrimaryKeyField,
|
||||
junctionCollection,
|
||||
loading,
|
||||
previewItems,
|
||||
displayItems,
|
||||
error,
|
||||
showDetailModal,
|
||||
currentlyEditing,
|
||||
cancelEdit,
|
||||
showUpload,
|
||||
stageEdits,
|
||||
editsAtStart,
|
||||
junctionRowPrimaryKey,
|
||||
editExisting,
|
||||
relatedRowPrimaryKey,
|
||||
showBrowseModal,
|
||||
selectModalActive,
|
||||
stageSelection,
|
||||
selectionFilters,
|
||||
relatedCollection,
|
||||
initialValues,
|
||||
deleteItem,
|
||||
items,
|
||||
get,
|
||||
deselect,
|
||||
onUpload,
|
||||
relationFields,
|
||||
editItem,
|
||||
};
|
||||
|
||||
/**
|
||||
* Deselect an item. This either means undoing any changes made (new item), or adding $delete: true
|
||||
* if the junction row already exists.
|
||||
*/
|
||||
function deselect(junctionRow: any) {
|
||||
const primaryKey = junctionRow[junctionCollectionPrimaryKeyField.value.field];
|
||||
|
||||
// If the junction row has a primary key, it's an existing item in the junction row, and
|
||||
// we want to add the $delete flag so the API can delete the row in the junction table,
|
||||
// effectively deselecting the related item from this item
|
||||
if (primaryKey) {
|
||||
// Once you deselect an item, it's removed from the preview table. You can only
|
||||
// deselect an item once, so we don't have to check if this item was already disabled
|
||||
emit('input', [
|
||||
...(props.value || []),
|
||||
{
|
||||
[junctionCollectionPrimaryKeyField.value.field]: primaryKey,
|
||||
$delete: true,
|
||||
},
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// If the item doesn't exist yet, there must be a staged edit for it's creation, that's
|
||||
// the thing we want to filter out of the staged edits.
|
||||
emit(
|
||||
'input',
|
||||
props.value.filter((stagedValue) => {
|
||||
return stagedValue !== junctionRow && stagedValue !== junctionRow['$stagedEdits'];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function useUpload() {
|
||||
const showUpload = ref(false);
|
||||
|
||||
return { showUpload, onUpload };
|
||||
|
||||
function onUpload(file: { id: number; [key: string]: any }) {
|
||||
if (!relationCurrentToJunction.value) return;
|
||||
if (!relationCurrentToJunction.value.junction_field) return;
|
||||
function onUpload(files: Record<string, any>[]) {
|
||||
showUpload.value = false;
|
||||
|
||||
if (files.length === 0) return;
|
||||
|
||||
const { junctionRelation } = relationFields.value;
|
||||
const file = files[0];
|
||||
|
||||
const fileAsJunctionRow = {
|
||||
[relationCurrentToJunction.value.junction_field]: {
|
||||
[junctionRelation]: {
|
||||
id: file.id,
|
||||
title: file.title,
|
||||
type: file.type,
|
||||
},
|
||||
};
|
||||
|
||||
emit('input', [...(props.value || []), fileAsJunctionRow]);
|
||||
|
||||
showUpload.value = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -13,7 +13,14 @@
|
||||
</template>
|
||||
|
||||
<template #append>
|
||||
<v-icon @click="activate" name="expand_more" class="open-indicator" :class="{ open: active }" />
|
||||
<v-icon v-if="value !== null" @click="setIcon(null)" name="close" />
|
||||
<v-icon
|
||||
v-else
|
||||
@click="activate"
|
||||
name="expand_more"
|
||||
class="open-indicator"
|
||||
:class="{ open: active }"
|
||||
/>
|
||||
</template>
|
||||
</v-input>
|
||||
</template>
|
||||
@@ -83,7 +90,7 @@ export default defineComponent({
|
||||
formatTitle,
|
||||
};
|
||||
|
||||
function setIcon(icon: string) {
|
||||
function setIcon(icon: string | null) {
|
||||
emit('input', icon);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,60 +1,64 @@
|
||||
<template>
|
||||
<v-notice type="warning" v-if="!relations || relations.length !== 2">
|
||||
<v-notice type="warning" v-if="!junction || !relation">
|
||||
{{ $t('relationship_not_setup') }}
|
||||
</v-notice>
|
||||
<div v-else>
|
||||
<div class="one-to-many" v-else>
|
||||
<v-table
|
||||
inline
|
||||
:items="previewItems"
|
||||
:loading="loading"
|
||||
:items="displayItems"
|
||||
:headers.sync="tableHeaders"
|
||||
:item-key="junctionCollectionPrimaryKeyField.field"
|
||||
:disabled="disabled"
|
||||
@click:row="editExisting"
|
||||
show-resize
|
||||
inline
|
||||
@click:row="editItem"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<template v-for="header in tableHeaders" v-slot:[`item.${header.value}`]="{ item }">
|
||||
<render-display
|
||||
:key="header.value"
|
||||
:value="get(item, header.value)"
|
||||
:value="item[header.value]"
|
||||
:display="header.field.display"
|
||||
:options="header.field.displayOptions"
|
||||
:interface="header.field.interface"
|
||||
:interface-options="header.field.interfaceOptions"
|
||||
:type="header.field.type"
|
||||
:collection="junctionCollection"
|
||||
:collection="junctionCollection.collection"
|
||||
:field="header.field.field"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #item-append="{ item }" v-if="!disabled">
|
||||
<v-icon name="close" v-tooltip="$t('deselect')" class="deselect" @click.stop="deselect(item)" />
|
||||
<v-icon
|
||||
name="close"
|
||||
v-tooltip="$t('deselect')"
|
||||
class="deselect"
|
||||
@click.stop="deleteItem(item, items)"
|
||||
/>
|
||||
</template>
|
||||
</v-table>
|
||||
|
||||
<div class="actions" v-if="!disabled">
|
||||
<v-button class="new" @click="addNew">{{ $t('create_new') }}</v-button>
|
||||
<v-button class="existing" @click="showBrowseModal = true">
|
||||
<v-button class="new" @click="currentlyEditing = '+'">{{ $t('create_new') }}</v-button>
|
||||
<v-button class="existing" @click="selectModalActive = true">
|
||||
{{ $t('add_existing') }}
|
||||
</v-button>
|
||||
</div>
|
||||
|
||||
<modal-detail
|
||||
<modal-item
|
||||
v-if="!disabled"
|
||||
:active.sync="showDetailModal"
|
||||
:collection="junctionCollection"
|
||||
:primary-key="junctionRowPrimaryKey"
|
||||
:active="currentlyEditing !== null"
|
||||
:collection="relationCollection.collection"
|
||||
:primary-key="currentlyEditing || '+'"
|
||||
:related-primary-key="relationFields.relationPkField"
|
||||
:junction-field="relationFields.junctionRelation"
|
||||
:edits="editsAtStart"
|
||||
:junction-field="relationCurrentToJunction.junction_field"
|
||||
:related-primary-key="relatedRowPrimaryKey"
|
||||
@input="stageEdits"
|
||||
@update:active="cancelEdit"
|
||||
/>
|
||||
|
||||
<modal-browse
|
||||
<modal-collection
|
||||
v-if="!disabled"
|
||||
:active.sync="showBrowseModal"
|
||||
:collection="relationJunctionToRelated.one_collection"
|
||||
:active.sync="selectModalActive"
|
||||
:collection="relationCollection.collection"
|
||||
:selection="[]"
|
||||
:filters="selectionFilters"
|
||||
@input="stageSelection"
|
||||
@@ -64,41 +68,23 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, watch, PropType, toRefs, computed } from '@vue/composition-api';
|
||||
import { useFieldsStore } from '@/stores/';
|
||||
import { Header as TableHeader } from '@/components/v-table/types';
|
||||
import ModalBrowse from '@/views/private/components/modal-browse';
|
||||
import ModalDetail from '@/views/private/components/modal-detail';
|
||||
import { get } from 'lodash';
|
||||
import { defineComponent, ref, computed, watch, PropType, toRefs } from '@vue/composition-api';
|
||||
import ModalItem from '@/views/private/components/modal-item';
|
||||
import ModalCollection from '@/views/private/components/modal-collection';
|
||||
|
||||
import useActions from './use-actions';
|
||||
import useRelation from './use-relation';
|
||||
import useSelection from './use-selection';
|
||||
import usePreview from './use-preview';
|
||||
import useEdit from './use-edit';
|
||||
import { Field } from '@/types';
|
||||
|
||||
/**
|
||||
* Hi there!
|
||||
*
|
||||
* The many to many is super complex. Please take proper care when jumping in here and making changes,
|
||||
* you might break more than you'd imagine.
|
||||
*
|
||||
* If you have any questions, please feel free to reach out to Rijk <rijkvanzanten@me.com>
|
||||
*
|
||||
* NOTE: Some of the logic here is based on the fact that you can only have 1 copy of a related item
|
||||
* associated in the m2m at a time. Without this requirement, there isn't a way to know which item
|
||||
* you're editing at a time. It would also be near impossible to keep track of the changes made to the
|
||||
* related item. Seeing we stage the made edits nested so the api is able to update it, we would have
|
||||
* to apply the same edits nested to all the junction rows or something like that, pretty tricky stuff
|
||||
*
|
||||
* Another NOTE: There's one other tricky case to be aware of: selecting an existing related item. In that case,
|
||||
* the junction row doesn't exist yet, but the related item does. Be aware that you can't rely on the
|
||||
* primary key of the junction row in some cases.
|
||||
*/
|
||||
import useSelection from './use-selection';
|
||||
|
||||
export default defineComponent({
|
||||
components: { ModalBrowse, ModalDetail },
|
||||
components: { ModalItem, ModalCollection },
|
||||
props: {
|
||||
value: {
|
||||
type: Array as PropType<(number | string | Record<string, any>)[] | null>,
|
||||
default: null,
|
||||
},
|
||||
primaryKey: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
@@ -111,181 +97,82 @@ export default defineComponent({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
type: Array,
|
||||
default: undefined,
|
||||
fields: {
|
||||
type: Array as PropType<string[]>,
|
||||
default: () => [],
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
fields: {
|
||||
type: Array as PropType<string[]>,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const fieldsStore = useFieldsStore();
|
||||
const { value, collection, field, fields } = toRefs(props);
|
||||
|
||||
const { collection, field, value, primaryKey } = toRefs(props);
|
||||
function emitter(newVal: any[] | null) {
|
||||
emit('input', newVal);
|
||||
}
|
||||
|
||||
const { junction, junctionCollection, relation, relationCollection, relationFields } = useRelation(
|
||||
collection,
|
||||
field
|
||||
);
|
||||
|
||||
const {
|
||||
relations,
|
||||
relationCurrentToJunction,
|
||||
relationJunctionToRelated,
|
||||
junctionCollectionPrimaryKeyField,
|
||||
junctionCollection,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
relatedCollection,
|
||||
} = useRelation({ collection, field });
|
||||
deleteItem,
|
||||
getUpdatedItems,
|
||||
getNewItems,
|
||||
getPrimaryKeys,
|
||||
getNewSelectedItems,
|
||||
getJunctionItem,
|
||||
getJunctionFromRelatedId,
|
||||
} = useActions(value, relationFields, emitter);
|
||||
|
||||
const fields = computed(() => {
|
||||
if (!junctionCollection) return [];
|
||||
return (
|
||||
props.fields ||
|
||||
fieldsStore.getFieldsForCollection(junctionCollection.value).map((field: Field) => field.field)
|
||||
);
|
||||
});
|
||||
|
||||
const { tableHeaders } = useTable();
|
||||
|
||||
const { loading, previewItems, error } = usePreview({
|
||||
const { tableHeaders, items, loading, error, displayItems } = usePreview(
|
||||
value,
|
||||
primaryKey,
|
||||
junctionCollectionPrimaryKeyField,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
junctionCollection,
|
||||
relatedCollection,
|
||||
relationCurrentToJunction,
|
||||
relationJunctionToRelated,
|
||||
fields,
|
||||
});
|
||||
relationFields,
|
||||
getNewSelectedItems,
|
||||
getUpdatedItems,
|
||||
getNewItems,
|
||||
getPrimaryKeys
|
||||
);
|
||||
|
||||
const {
|
||||
showDetailModal,
|
||||
cancelEdit,
|
||||
addNew,
|
||||
stageEdits,
|
||||
editsAtStart,
|
||||
junctionRowPrimaryKey,
|
||||
editExisting,
|
||||
relatedRowPrimaryKey,
|
||||
initialValues,
|
||||
} = useEdit({
|
||||
relationCurrentToJunction,
|
||||
junctionCollectionPrimaryKeyField,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
const { currentlyEditing, editItem, editsAtStart, stageEdits, cancelEdit } = useEdit(
|
||||
value,
|
||||
onEdit: (newValue) => emit('input', newValue),
|
||||
});
|
||||
items,
|
||||
relationFields,
|
||||
emitter,
|
||||
getJunctionFromRelatedId
|
||||
);
|
||||
|
||||
const { showBrowseModal, stageSelection, selectionFilters } = useSelection({
|
||||
relationCurrentToJunction,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
previewItems,
|
||||
onStageSelection: (selectionAsJunctionRows) => {
|
||||
emit('input', [...(props.value || []), ...selectionAsJunctionRows]);
|
||||
},
|
||||
});
|
||||
const { stageSelection, selectModalActive, selectionFilters } = useSelection(
|
||||
value,
|
||||
displayItems,
|
||||
relationFields,
|
||||
emitter
|
||||
);
|
||||
|
||||
return {
|
||||
relations,
|
||||
relationCurrentToJunction,
|
||||
relationJunctionToRelated,
|
||||
junction,
|
||||
relation,
|
||||
tableHeaders,
|
||||
junctionCollectionPrimaryKeyField,
|
||||
junctionCollection,
|
||||
loading,
|
||||
previewItems,
|
||||
error,
|
||||
showDetailModal,
|
||||
cancelEdit,
|
||||
addNew,
|
||||
stageEdits,
|
||||
currentlyEditing,
|
||||
editItem,
|
||||
junctionCollection,
|
||||
relationCollection,
|
||||
editsAtStart,
|
||||
junctionRowPrimaryKey,
|
||||
editExisting,
|
||||
relatedRowPrimaryKey,
|
||||
showBrowseModal,
|
||||
stageEdits,
|
||||
cancelEdit,
|
||||
stageSelection,
|
||||
selectModalActive,
|
||||
deleteItem,
|
||||
displayItems,
|
||||
selectionFilters,
|
||||
relatedCollection,
|
||||
initialValues,
|
||||
get,
|
||||
deselect,
|
||||
items,
|
||||
relationFields,
|
||||
};
|
||||
|
||||
/**
|
||||
* Manages the state of the table. This includes the table headers, and the event handlers for
|
||||
* the table events
|
||||
*/
|
||||
function useTable() {
|
||||
// Using a ref for the table headers here means that the table itself can update the
|
||||
// values if it needs to. This allows the user to manually resize the columns for example
|
||||
const tableHeaders = ref<TableHeader[]>([]);
|
||||
|
||||
watch(fields, setHeaders, { immediate: true });
|
||||
|
||||
return { tableHeaders };
|
||||
|
||||
function setHeaders() {
|
||||
tableHeaders.value = fields.value.map(
|
||||
(fieldKey): TableHeader => {
|
||||
const field = fieldsStore.getField(junctionCollection.value, fieldKey);
|
||||
|
||||
return {
|
||||
text: field.name,
|
||||
value: fieldKey,
|
||||
align: 'left',
|
||||
sortable: true,
|
||||
width: null,
|
||||
field: {
|
||||
display: field.meta?.display,
|
||||
displayOptions: field.meta?.display_options,
|
||||
interface: field.meta?.interface,
|
||||
interfaceOptions: field.meta?.options,
|
||||
type: field.type,
|
||||
field: field.field,
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deselect an item. This either means undoing any changes made (new item), or adding $delete: true
|
||||
* if the junction row already exists.
|
||||
*/
|
||||
function deselect(junctionRow: any) {
|
||||
const primaryKey = junctionRow[junctionCollectionPrimaryKeyField.value.field];
|
||||
|
||||
// If the junction row has a primary key, it's an existing item in the junction row, and
|
||||
// we want to add the $delete flag so the API can delete the row in the junction table,
|
||||
// effectively deselecting the related item from this item
|
||||
if (primaryKey) {
|
||||
// Once you deselect an item, it's removed from the preview table. You can only
|
||||
// deselect an item once, so we don't have to check if this item was already disabled
|
||||
emit('input', [
|
||||
...(props.value || []),
|
||||
{
|
||||
[junctionCollectionPrimaryKeyField.value.field]: primaryKey,
|
||||
$delete: true,
|
||||
},
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// If the item doesn't exist yet, there must be a staged edit for it's creation, that's
|
||||
// the thing we want to filter out of the staged edits.
|
||||
emit(
|
||||
'input',
|
||||
props.value.filter((stagedValue) => {
|
||||
return stagedValue !== junctionRow && stagedValue !== junctionRow['$stagedEdits'];
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
<template>
|
||||
<v-notice type="warning" v-if="junctionCollection === null">
|
||||
<v-notice type="warning" v-if="relatedCollection === null">
|
||||
{{ $t('interfaces.one-to-many.no_collection') }}
|
||||
</v-notice>
|
||||
<div v-else class="form-grid">
|
||||
<div class="field full">
|
||||
<p class="type-label">{{ $t('select_fields') }}</p>
|
||||
<v-field-select
|
||||
:collection="junctionCollection"
|
||||
:collection="relatedCollection"
|
||||
v-model="fields"
|
||||
:inject="
|
||||
junctionCollectionExists ? null : { fields: newFields, collections: newCollections, relations }
|
||||
"
|
||||
:inject="relatedCollectionExists ? null : { fields: newFields, collections: newCollections, relations }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,22 +64,33 @@ export default defineComponent({
|
||||
},
|
||||
});
|
||||
|
||||
const junctionCollection = computed(() => {
|
||||
const relatedCollection = computed(() => {
|
||||
if (!props.fieldData || !props.relations || props.relations.length === 0) return null;
|
||||
|
||||
const { field } = props.fieldData;
|
||||
|
||||
const junctionRelation = props.relations.find(
|
||||
(relation) => relation.one_collection === props.collection && relation.one_field === field
|
||||
);
|
||||
return junctionRelation?.many_collection || null;
|
||||
|
||||
if (junctionRelation === undefined) return;
|
||||
|
||||
const relatedCollection = props.relations.find(
|
||||
(relation) =>
|
||||
relation.one_collection !== props.collection &&
|
||||
relation.many_field === junctionRelation.junction_field
|
||||
);
|
||||
|
||||
return relatedCollection?.one_collection || null;
|
||||
});
|
||||
|
||||
const junctionCollectionExists = computed(() => {
|
||||
const relatedCollectionExists = computed(() => {
|
||||
return !!collectionsStore.state.collections.find(
|
||||
(collection) => collection.collection === junctionCollection.value
|
||||
(collection) => collection.collection === relatedCollection.value
|
||||
);
|
||||
});
|
||||
|
||||
return { fields, junctionCollection, junctionCollectionExists };
|
||||
return { fields, relatedCollection, relatedCollectionExists };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
190
app/src/interfaces/many-to-many/use-actions.ts
Normal file
190
app/src/interfaces/many-to-many/use-actions.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { Ref } from '@vue/composition-api';
|
||||
import { RelationInfo } from './use-relation';
|
||||
|
||||
export default function useActions(
|
||||
value: Ref<(string | number | Record<string, any>)[] | null>,
|
||||
relation: Ref<RelationInfo>,
|
||||
emit: (newValue: any[] | null) => void
|
||||
) {
|
||||
function getJunctionItem(id: string | number) {
|
||||
const { junctionPkField } = relation.value;
|
||||
if (value.value === null) return null;
|
||||
|
||||
return (
|
||||
value.value.find(
|
||||
(item) =>
|
||||
(typeof item === 'object' && junctionPkField in item && item[junctionPkField] === id) || item === id
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
function getNewSelectedItems() {
|
||||
const { junctionRelation } = relation.value;
|
||||
|
||||
if (value.value === null || junctionRelation === null) return [];
|
||||
|
||||
return value.value.filter(
|
||||
(item) => typeof item === 'object' && junctionRelation in item && typeof item[junctionRelation] !== 'object'
|
||||
) as Record<string, any>[];
|
||||
}
|
||||
|
||||
function getNewItems() {
|
||||
const { junctionRelation, relationPkField } = relation.value;
|
||||
|
||||
if (value.value === null || junctionRelation === null) return [];
|
||||
|
||||
return value.value.filter(
|
||||
(item) =>
|
||||
typeof item === 'object' &&
|
||||
junctionRelation in item &&
|
||||
typeof item[junctionRelation] === 'object' &&
|
||||
relationPkField in item[junctionRelation] === false
|
||||
) as Record<string, any>[];
|
||||
}
|
||||
|
||||
function getUpdatedItems() {
|
||||
const { junctionRelation, relationPkField } = relation.value;
|
||||
|
||||
if (value.value === null || junctionRelation === null) return [];
|
||||
|
||||
return value.value.filter(
|
||||
(item) =>
|
||||
typeof item === 'object' &&
|
||||
junctionRelation in item &&
|
||||
typeof item[junctionRelation] === 'object' &&
|
||||
relationPkField in item[junctionRelation] === true
|
||||
) as Record<string, any>[];
|
||||
}
|
||||
|
||||
function getExistingItems() {
|
||||
if (value.value === null) return [];
|
||||
|
||||
return value.value.filter((item) => typeof item === 'string' || typeof item === 'number');
|
||||
}
|
||||
|
||||
function getPrimaryKeys(): (string | number)[] {
|
||||
const { junctionPkField } = relation.value;
|
||||
|
||||
if (value.value === null) return [];
|
||||
|
||||
return value.value
|
||||
.map((item) => {
|
||||
if (typeof item === 'object') {
|
||||
if (junctionPkField in item) return item[junctionPkField];
|
||||
} else {
|
||||
return item;
|
||||
}
|
||||
})
|
||||
.filter((i) => i);
|
||||
}
|
||||
|
||||
function getRelatedPrimaryKeys(): (string | number)[] {
|
||||
if (value.value === null) return [];
|
||||
const { junctionRelation, relationPkField } = relation.value;
|
||||
return value.value
|
||||
.map((junctionItem) => {
|
||||
if (
|
||||
typeof junctionItem !== 'object' ||
|
||||
junctionRelation === null ||
|
||||
junctionRelation in junctionItem === false
|
||||
)
|
||||
return undefined;
|
||||
const item = junctionItem[junctionRelation];
|
||||
|
||||
if (typeof item === 'object') {
|
||||
if (junctionRelation in item) return item[relationPkField];
|
||||
} else {
|
||||
return item;
|
||||
}
|
||||
})
|
||||
.filter((i) => i);
|
||||
}
|
||||
|
||||
function deleteItem(item: Record<string, any>, items: Record<string, any>[]) {
|
||||
if (value.value === null) return;
|
||||
const { junctionRelation, relationPkField } = relation.value;
|
||||
|
||||
const id = item[relationPkField] as number | string | undefined;
|
||||
|
||||
if (id !== undefined) return deleteItemWithId(id, items);
|
||||
if (junctionRelation === null) return;
|
||||
|
||||
const newVal = value.value.filter((junctionItem) => {
|
||||
if (typeof junctionItem !== 'object' || junctionRelation in junctionItem === false) return true;
|
||||
return junctionItem[junctionRelation] !== item;
|
||||
});
|
||||
|
||||
if (newVal.length === 0) emit(null);
|
||||
else emit(newVal);
|
||||
}
|
||||
|
||||
function deleteItemWithId(id: string | number, items: Record<string, any>[]) {
|
||||
if (value.value === null) return;
|
||||
const { junctionRelation, relationPkField, junctionPkField } = relation.value;
|
||||
|
||||
const junctionItem = items.find(
|
||||
(item) =>
|
||||
junctionRelation in item &&
|
||||
relationPkField in item[junctionRelation] &&
|
||||
item[junctionRelation][relationPkField] === id
|
||||
);
|
||||
|
||||
if (junctionItem === undefined) return;
|
||||
|
||||
// If it is a newly selected Item
|
||||
if (junctionPkField in junctionItem === false) {
|
||||
const newVal = value.value.filter((item) => {
|
||||
if (typeof item === 'object' && junctionRelation in item) {
|
||||
const jItem = item[junctionRelation];
|
||||
return typeof jItem === 'object' ? jItem[relationPkField] !== id : jItem !== id;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (newVal.length === 0) emit(null);
|
||||
else emit(newVal);
|
||||
return;
|
||||
}
|
||||
|
||||
// If it is an already existing item
|
||||
const newVal = value.value.filter((item) => {
|
||||
if (typeof item === 'object' && junctionPkField in item) {
|
||||
return junctionItem[junctionPkField] !== item[junctionPkField];
|
||||
} else {
|
||||
return junctionItem[junctionPkField] !== item;
|
||||
}
|
||||
});
|
||||
|
||||
if (newVal.length === 0) emit(null);
|
||||
else emit(newVal);
|
||||
}
|
||||
|
||||
function getJunctionFromRelatedId(id: string | number, items: Record<string, any>[]) {
|
||||
const { relationPkField, junctionRelation } = relation.value;
|
||||
|
||||
return (
|
||||
items.find((item) => {
|
||||
return (
|
||||
typeof item === 'object' &&
|
||||
junctionRelation in item &&
|
||||
typeof item[junctionRelation] === 'object' &&
|
||||
relationPkField in item[junctionRelation] &&
|
||||
item[junctionRelation][relationPkField] === id
|
||||
);
|
||||
}) || null
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
getJunctionItem,
|
||||
getNewSelectedItems,
|
||||
getNewItems,
|
||||
getUpdatedItems,
|
||||
getExistingItems,
|
||||
getPrimaryKeys,
|
||||
getRelatedPrimaryKeys,
|
||||
getJunctionFromRelatedId,
|
||||
deleteItem,
|
||||
deleteItemWithId,
|
||||
};
|
||||
}
|
||||
@@ -1,141 +1,77 @@
|
||||
import { ref, Ref } from '@vue/composition-api';
|
||||
import { Field, Relation } from '@/types';
|
||||
import { set } from 'lodash';
|
||||
import { Ref, ref } from '@vue/composition-api';
|
||||
import { RelationInfo } from './use-relation';
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
type EditParam = {
|
||||
relationCurrentToJunction: Ref<Relation | undefined>;
|
||||
junctionCollectionPrimaryKeyField: Ref<Field>;
|
||||
relatedCollectionPrimaryKeyField: Ref<Field>;
|
||||
value: Ref<any[] | null>;
|
||||
onEdit: (newValue: any[] | null) => void;
|
||||
};
|
||||
export default function useEdit(
|
||||
value: Ref<(string | number | Record<string, any>)[] | null>,
|
||||
items: Ref<Record<string, any>[]>,
|
||||
relation: Ref<RelationInfo>,
|
||||
emit: (newVal: any[] | null) => void,
|
||||
getJunctionFromRelatedId: (id: string | number, items: Record<string, any>[]) => Record<string, any> | null
|
||||
) {
|
||||
// Primary key of the item we're currently editing. If null, the edit modal should be
|
||||
// closed
|
||||
const currentlyEditing = ref<string | number | null>(null);
|
||||
|
||||
/**
|
||||
* Everything regarding the edit experience in the detail modal. This also includes adding
|
||||
* a new item
|
||||
*/
|
||||
export default function useEdit({
|
||||
relationCurrentToJunction,
|
||||
junctionCollectionPrimaryKeyField,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
value,
|
||||
onEdit,
|
||||
}: EditParam) {
|
||||
const showDetailModal = ref(false);
|
||||
// The previously made edits when we're starting to edit the item
|
||||
const editsAtStart = ref<any>(null);
|
||||
const junctionRowPrimaryKey = ref<number | string>('+');
|
||||
const relatedRowPrimaryKey = ref<number | string>('+');
|
||||
const initialValues = ref<any>(null);
|
||||
const isNew = ref(false);
|
||||
// This keeps track of the starting values so we can match with it
|
||||
const editsAtStart = ref<Record<string, any>>({});
|
||||
|
||||
return {
|
||||
showDetailModal,
|
||||
editsAtStart,
|
||||
addNew,
|
||||
cancelEdit,
|
||||
stageEdits,
|
||||
junctionRowPrimaryKey,
|
||||
editExisting,
|
||||
relatedRowPrimaryKey,
|
||||
initialValues,
|
||||
};
|
||||
function editItem(item: any) {
|
||||
const { relationPkField } = relation.value;
|
||||
const hasPrimaryKey = relationPkField in item;
|
||||
|
||||
function addNew() {
|
||||
editsAtStart.value = null;
|
||||
showDetailModal.value = true;
|
||||
junctionRowPrimaryKey.value = '+';
|
||||
relatedRowPrimaryKey.value = '+';
|
||||
initialValues.value = null;
|
||||
isNew.value = true;
|
||||
editsAtStart.value = item;
|
||||
currentlyEditing.value = hasPrimaryKey ? item[relationPkField] : -1;
|
||||
}
|
||||
|
||||
// The row here is the item in previewItems that's passed to the table
|
||||
function editExisting(item: any) {
|
||||
if (!relationCurrentToJunction.value) return;
|
||||
if (!relationCurrentToJunction.value.junction_field) return;
|
||||
function stageEdits(edits: any) {
|
||||
const { relationPkField, junctionRelation, junctionPkField } = relation.value;
|
||||
const editsWrapped = { [junctionRelation]: edits };
|
||||
const hasPrimaryKey = relationPkField in editsAtStart.value;
|
||||
const junctionItem = hasPrimaryKey
|
||||
? getJunctionFromRelatedId(editsAtStart.value[relationPkField], items.value)
|
||||
: null;
|
||||
|
||||
if (item.$new === true) isNew.value = true;
|
||||
const newValue = (value.value || []).map((item) => {
|
||||
if (junctionItem !== null && junctionPkField in junctionItem) {
|
||||
const id = junctionItem[junctionPkField];
|
||||
|
||||
if (isNew.value === true) {
|
||||
editsAtStart.value = item;
|
||||
junctionRowPrimaryKey.value = '+';
|
||||
showDetailModal.value = true;
|
||||
initialValues.value = null;
|
||||
return;
|
||||
if (typeof item === 'object' && junctionPkField in item) {
|
||||
if (item[junctionPkField] === id) return { [junctionRelation]: edits, [junctionPkField]: id };
|
||||
} else if (typeof item === 'number' || typeof item === 'string') {
|
||||
if (item === id) return { [junctionRelation]: edits, [junctionPkField]: id };
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof item === 'object' && relationPkField in edits && junctionRelation in item) {
|
||||
const id = edits[relationPkField];
|
||||
const relatedItem = item[junctionRelation] as string | number | Record<string, any>;
|
||||
if (typeof relatedItem === 'object' && relationPkField in relatedItem) {
|
||||
if (relatedItem[relationPkField] === id) return editsWrapped;
|
||||
} else if (typeof relatedItem === 'string' || typeof relatedItem === 'number') {
|
||||
if (relatedItem === id) return editsWrapped;
|
||||
}
|
||||
}
|
||||
|
||||
if (isEqual({ [junctionRelation]: editsAtStart.value }, item)) {
|
||||
return editsWrapped;
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
|
||||
if (hasPrimaryKey === false && newValue.includes(editsWrapped) === false) {
|
||||
newValue.push(editsWrapped);
|
||||
}
|
||||
|
||||
initialValues.value = item;
|
||||
|
||||
/**
|
||||
* @NOTE: Keep in mind there's a case where the junction row doesn't exist yet, but
|
||||
* the related item does (when selecting an existing item)
|
||||
*/
|
||||
|
||||
const junctionPrimaryKey = junctionCollectionPrimaryKeyField.value.field;
|
||||
const junctionField = relationCurrentToJunction.value.junction_field;
|
||||
const relatedPrimaryKey = relatedCollectionPrimaryKeyField.value.field;
|
||||
|
||||
junctionRowPrimaryKey.value = item[junctionPrimaryKey] || '+';
|
||||
relatedRowPrimaryKey.value = item[junctionField]?.[relatedPrimaryKey] || '+';
|
||||
editsAtStart.value = item.$stagedEdits || null;
|
||||
showDetailModal.value = true;
|
||||
if (newValue.length === 0) emit(null);
|
||||
else emit(newValue);
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editsAtStart.value = {};
|
||||
showDetailModal.value = false;
|
||||
junctionRowPrimaryKey.value = '+';
|
||||
currentlyEditing.value = null;
|
||||
}
|
||||
|
||||
function stageEdits(edits: any) {
|
||||
if (!relationCurrentToJunction.value) return;
|
||||
if (!relationCurrentToJunction.value.junction_field) return;
|
||||
|
||||
const junctionPrimaryKey = junctionCollectionPrimaryKeyField.value.field;
|
||||
const junctionField = relationCurrentToJunction.value.junction_field;
|
||||
const relatedPrimaryKey = relatedCollectionPrimaryKeyField.value.field;
|
||||
|
||||
if (isNew.value) {
|
||||
edits.$new = true;
|
||||
}
|
||||
|
||||
const currentValue = [...(value.value || [])];
|
||||
|
||||
// If there weren't any previously made edits, it's safe to assume this change value
|
||||
// doesn't exist yet in the staged value
|
||||
if (!editsAtStart.value) {
|
||||
// If the item that we edited has any of the primary keys (junction/related), we
|
||||
// have to make sure we stage those as well. Otherwise the API will treat it as
|
||||
// a newly created item instead of updated existing
|
||||
if (junctionRowPrimaryKey.value !== '+') {
|
||||
set(edits, junctionPrimaryKey, junctionRowPrimaryKey.value);
|
||||
}
|
||||
|
||||
if (relatedRowPrimaryKey.value !== '+') {
|
||||
set(edits, [junctionField, relatedPrimaryKey], relatedRowPrimaryKey.value);
|
||||
}
|
||||
|
||||
onEdit([...currentValue, edits]);
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue =
|
||||
value.value?.map((stagedValue: any) => {
|
||||
if (stagedValue === editsAtStart.value) return edits;
|
||||
return stagedValue;
|
||||
}) || null;
|
||||
|
||||
onEdit(newValue);
|
||||
reset();
|
||||
|
||||
function reset() {
|
||||
editsAtStart.value = null;
|
||||
showDetailModal.value = true;
|
||||
junctionRowPrimaryKey.value = '+';
|
||||
relatedRowPrimaryKey.value = '+';
|
||||
isNew.value = false;
|
||||
}
|
||||
}
|
||||
return { currentlyEditing, editItem, editsAtStart, stageEdits, cancelEdit };
|
||||
}
|
||||
|
||||
@@ -1,235 +1,175 @@
|
||||
import { Ref, ref, watch } from '@vue/composition-api';
|
||||
import { Ref, ref, watch, computed } from '@vue/composition-api';
|
||||
import { Header } from '@/components/v-table/types';
|
||||
import { RelationInfo } from './use-relation';
|
||||
import { useFieldsStore } from '@/stores/';
|
||||
import { Field, Collection } from '@/types';
|
||||
import api from '@/api';
|
||||
import { Field, Relation } from '@/types';
|
||||
import { merge } from 'lodash';
|
||||
import adjustFieldsForDisplay from '@/utils/adjust-fields-for-displays';
|
||||
import { cloneDeep } from 'lodash';
|
||||
|
||||
/**
|
||||
* Controls what preview is shown in the table. Has some black magic logic to ensure we're able
|
||||
* to show the latest edits, while also maintaining a clean staged value set. This is not responsible
|
||||
* for setting or modifying any data. Preview items should be considered read only
|
||||
*/
|
||||
export default function usePreview(
|
||||
value: Ref<(string | number | Record<string, any>)[] | null>,
|
||||
fields: Ref<string[]>,
|
||||
relation: Ref<RelationInfo>,
|
||||
getNewSelectedItems: () => Record<string, any>[],
|
||||
getUpdatedItems: () => Record<string, any>[],
|
||||
getNewItems: () => Record<string, any>[],
|
||||
getPrimaryKeys: () => (string | number)[]
|
||||
) {
|
||||
// Using a ref for the table headers here means that the table itself can update the
|
||||
// values if it needs to. This allows the user to manually resize the columns for example
|
||||
|
||||
type PreviewParam = {
|
||||
value: Ref<any[] | null>;
|
||||
primaryKey: Ref<string | number>;
|
||||
junctionCollectionPrimaryKeyField: Ref<Field>;
|
||||
relatedCollectionPrimaryKeyField: Ref<Field>;
|
||||
junctionCollection: Ref<string>;
|
||||
relatedCollection: Ref<string>;
|
||||
relationCurrentToJunction: Ref<Relation | undefined>;
|
||||
relationJunctionToRelated: Ref<Relation | null | undefined>;
|
||||
fields: Ref<readonly string[]>;
|
||||
};
|
||||
|
||||
export default function usePreview({
|
||||
value,
|
||||
primaryKey,
|
||||
junctionCollectionPrimaryKeyField,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
relationCurrentToJunction,
|
||||
relationJunctionToRelated,
|
||||
junctionCollection,
|
||||
relatedCollection,
|
||||
fields,
|
||||
}: PreviewParam) {
|
||||
const fieldsStore = useFieldsStore();
|
||||
const tableHeaders = ref<Header[]>([]);
|
||||
const loading = ref(false);
|
||||
const previewItems = ref<readonly any[]>([]);
|
||||
const items = ref<Record<string, any>[]>([]);
|
||||
const error = ref(null);
|
||||
|
||||
// Every time the value changes, we'll reset the preview values. This ensures that we'll
|
||||
// almost show the most up to date information in the preview table, regardless of if this
|
||||
// is the first load or a subsequent edit.
|
||||
watch(value, setPreview, { immediate: true });
|
||||
watch(
|
||||
() => value.value,
|
||||
async (newVal) => {
|
||||
if (newVal === null) {
|
||||
items.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
return { loading, previewItems, error };
|
||||
loading.value = true;
|
||||
const { junctionRelation, relationPkField, junctionPkField } = relation.value;
|
||||
if (junctionRelation === null) return;
|
||||
|
||||
async function setPreview() {
|
||||
loading.value = true;
|
||||
// Load the junction items so we have access to the id's in the related collection
|
||||
const junctionItems = await loadRelatedIds();
|
||||
const relatedPrimaryKeys = junctionItems.map((junction) => junction[junctionRelation]);
|
||||
|
||||
const filteredFields = [...(fields.value.length > 0 ? fields.value : getDefaultFields())];
|
||||
|
||||
if (filteredFields.includes(relationPkField) === false) filteredFields.push(relationPkField);
|
||||
|
||||
try {
|
||||
let responseData: Record<string, any>[] = [];
|
||||
|
||||
if (relatedPrimaryKeys.length > 0) {
|
||||
const endpoint = relation.value.relationCollection.startsWith('directus_')
|
||||
? `/${relation.value.relationCollection.substring(9)}`
|
||||
: `/items/${relation.value.relationCollection}`;
|
||||
|
||||
const response = await api.get(endpoint, {
|
||||
params: {
|
||||
fields: filteredFields,
|
||||
[`filter[${relationPkField}][_in]`]: relatedPrimaryKeys.join(','),
|
||||
},
|
||||
});
|
||||
responseData = response?.data.data as Record<string, any>[];
|
||||
}
|
||||
|
||||
// Insert the related items into the junction items
|
||||
const existingItems = responseData.map((data) => {
|
||||
const id = data[relationPkField];
|
||||
const junction = junctionItems.find((junction) => junction[junctionRelation] === id);
|
||||
if (junction === undefined) return;
|
||||
|
||||
const newJunction = cloneDeep(junction);
|
||||
newJunction[junctionRelation] = data;
|
||||
return newJunction;
|
||||
}) as Record<string, any>[];
|
||||
|
||||
const updatedItems = getUpdatedItems();
|
||||
const newItems = getNewItems();
|
||||
|
||||
// Replace existing items with it's updated counterparts
|
||||
const newVal = existingItems
|
||||
.map((item) => {
|
||||
const updatedItem = updatedItems.find(
|
||||
(updated) => updated[junctionPkField] === item[junctionPkField]
|
||||
);
|
||||
if (updatedItem !== undefined) return updatedItem;
|
||||
return item;
|
||||
})
|
||||
.concat(...newItems);
|
||||
items.value = newVal;
|
||||
} catch (err) {
|
||||
error.value = err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
async function loadRelatedIds() {
|
||||
const { junctionPkField, junctionRelation, relationPkField } = relation.value;
|
||||
|
||||
try {
|
||||
const existingItems = await fetchExisting();
|
||||
const updatedExistingItems = applyUpdatesToExisting(existingItems);
|
||||
const newlyAddedItems = getNewlyAdded();
|
||||
const newlySelectedItems = await fetchNewlySelectedItems();
|
||||
previewItems.value = [...updatedExistingItems, ...newlyAddedItems, ...newlySelectedItems].filter(
|
||||
(stagedEdit: any) => !stagedEdit['$delete']
|
||||
);
|
||||
let data: Record<string, any>[] = [];
|
||||
const primaryKeys = getPrimaryKeys();
|
||||
|
||||
if (primaryKeys.length > 0) {
|
||||
const endpoint = relation.value.junctionCollection.startsWith('directus_')
|
||||
? `/${relation.value.junctionCollection.substring(9)}`
|
||||
: `/items/${relation.value.junctionCollection}`;
|
||||
|
||||
const response = await api.get(endpoint, {
|
||||
params: {
|
||||
[`filter[${junctionPkField}][_in]`]: getPrimaryKeys().join(','),
|
||||
},
|
||||
});
|
||||
data = response?.data.data as Record<string, any>[];
|
||||
}
|
||||
|
||||
const updatedItems = getUpdatedItems().map((item) => ({
|
||||
[junctionRelation]: item[junctionRelation][relationPkField],
|
||||
}));
|
||||
|
||||
// Add all items that already had the id of it's related item
|
||||
return data.concat(...getNewSelectedItems(), ...updatedItems);
|
||||
} catch (err) {
|
||||
error.value = err;
|
||||
throw err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks through props.value and applies all staged changes to the existing selected
|
||||
* items. The array of existing items is an array of junction rows, so we can assume
|
||||
* those have a primary key
|
||||
*/
|
||||
function applyUpdatesToExisting(existing: any[]) {
|
||||
return existing.map((existingValue) => {
|
||||
const junctionPrimaryKey = junctionCollectionPrimaryKeyField.value.field;
|
||||
const existingPrimaryKey = existingValue[junctionPrimaryKey];
|
||||
const displayItems = computed(() => {
|
||||
const { junctionRelation } = relation.value;
|
||||
return items.value.map((item) => item[junctionRelation]);
|
||||
});
|
||||
|
||||
const stagedEdits: any = (value.value || []).find((update: any) => {
|
||||
const updatePrimaryKey = update[junctionPrimaryKey];
|
||||
return existingPrimaryKey === updatePrimaryKey;
|
||||
});
|
||||
// Seeing we don't care about saving those tableHeaders, we can reset it whenever the
|
||||
// fields prop changes (most likely when we're navigating to a different o2m context)
|
||||
watch(
|
||||
() => fields.value,
|
||||
() => {
|
||||
tableHeaders.value = (fields.value.length > 0 ? fields.value : getDefaultFields())
|
||||
.map((fieldKey) => {
|
||||
const field = fieldsStore.getField(relation.value.relationCollection, fieldKey);
|
||||
|
||||
if (stagedEdits === undefined) return existingValue;
|
||||
if (!field) return null;
|
||||
|
||||
return {
|
||||
...merge(existingValue, stagedEdits),
|
||||
$stagedEdits: stagedEdits,
|
||||
};
|
||||
});
|
||||
const header: Header = {
|
||||
text: field.name,
|
||||
value: fieldKey,
|
||||
align: 'left',
|
||||
sortable: true,
|
||||
width: null,
|
||||
field: {
|
||||
display: field.meta?.display,
|
||||
displayOptions: field.meta?.display_options,
|
||||
interface: field.meta?.interface,
|
||||
interfaceOptions: field.meta?.options,
|
||||
type: field.type,
|
||||
field: field.field,
|
||||
},
|
||||
};
|
||||
|
||||
return header;
|
||||
})
|
||||
.filter((h) => h) as Header[];
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
function getDefaultFields(): string[] {
|
||||
const fields = fieldsStore.getFieldsForCollection(relation.value.relationCollection);
|
||||
return fields.slice(0, 3).map((field: Field) => field.field);
|
||||
}
|
||||
|
||||
/**
|
||||
* To get the currently selected items, we'll fetch the rows from the junction table
|
||||
* where the field back to the current collection is equal to the primary key. We go
|
||||
* this route as it's more performant than trying to go an extra level deep in the
|
||||
* current item.
|
||||
*/
|
||||
async function fetchExisting() {
|
||||
if (!relationCurrentToJunction.value) return;
|
||||
if (!relationCurrentToJunction.value.junction_field) return;
|
||||
if (!relationJunctionToRelated.value) return;
|
||||
if (!relationJunctionToRelated.value.junction_field) return;
|
||||
|
||||
// If the current item is being created, we don't have to search for existing relations
|
||||
// yet, as they can't have been saved yet.
|
||||
if (primaryKey.value === '+') return [];
|
||||
|
||||
const junctionTable = relationCurrentToJunction.value.many_collection;
|
||||
|
||||
// The stuff we want to fetch is the related junction row, and the content of the
|
||||
// deeply related item nested. This should match the value that's set in the fields
|
||||
// option. We have to make sure we're fetching the primary key of both the junction
|
||||
// as the related item though, as that makes sure we're able to update the item later,
|
||||
// instead of adding a new one in the API.
|
||||
const fieldsToFetch = [...fields.value];
|
||||
|
||||
// The following will add the PK and related items PK to the request fields, like
|
||||
// "id" and "related.id"
|
||||
const junctionPrimaryKey = junctionCollectionPrimaryKeyField.value.field;
|
||||
const junctionField = relationCurrentToJunction.value.junction_field;
|
||||
const relatedPrimaryKey = relatedCollectionPrimaryKeyField.value.field;
|
||||
const currentInJunction = relationJunctionToRelated.value.junction_field;
|
||||
|
||||
if (fieldsToFetch.includes(junctionPrimaryKey) === false) fieldsToFetch.push(junctionPrimaryKey);
|
||||
if (fieldsToFetch.includes(`${junctionField}.${relatedPrimaryKey}`) === false)
|
||||
fieldsToFetch.push(`${junctionField}.${relatedPrimaryKey}`);
|
||||
|
||||
const response = await api.get(`/items/${junctionTable}`, {
|
||||
params: {
|
||||
fields: adjustFieldsForDisplay(fieldsToFetch, junctionCollection.value),
|
||||
[`filter[${currentInJunction}][_eq]`]: primaryKey.value,
|
||||
},
|
||||
});
|
||||
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the newly created rows from props.value. Values that don't have a junction row
|
||||
* primary key and no primary key in the related item are created "totally" new and should
|
||||
* be added to the array of previews as is.
|
||||
* NOTE: This does not included items where the junction row is new, but the related item
|
||||
* isn't.
|
||||
*/
|
||||
function getNewlyAdded() {
|
||||
if (!relationCurrentToJunction.value) return [];
|
||||
if (!relationCurrentToJunction.value.junction_field) return [];
|
||||
|
||||
/**
|
||||
* @NOTE There's an interesting case here:
|
||||
*
|
||||
* If you create both a new junction row _and_ a new related row, any selected existing
|
||||
* many to one record won't have it's data object staged, as it already exists (so it's just)
|
||||
* the primary key. This will case a template display to show ???, as it only gets the
|
||||
* primary key. If you saw an issue about that on GitHub, this is where to find it.
|
||||
*
|
||||
* Unlike in fetchNewlySelectedItems(), we can't just fetch the related item, as both
|
||||
* junction and related are new. We _could_ traverse through the object of changes, see
|
||||
* if there's any relational field, and fetch the data based on that combined with the
|
||||
* fields adjusted for the display. While that should work, it's too much of an edge case
|
||||
* for me for now to worry about..
|
||||
*/
|
||||
|
||||
const junctionPrimaryKey = junctionCollectionPrimaryKeyField.value.field;
|
||||
|
||||
return (value.value || []).filter(
|
||||
(stagedEdit: any) => !stagedEdit.$delete && !stagedEdit[junctionPrimaryKey] && stagedEdit.$new === true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tricky case where the user selects an existing item from the related collection
|
||||
* This means the junction doesn't have a primary key yet, and the only value that is
|
||||
* staged is the related item's primary key
|
||||
* In this function, we'll fetch the full existing item from the related collection,
|
||||
* so we can still show it's data in the preview table
|
||||
*/
|
||||
async function fetchNewlySelectedItems() {
|
||||
if (!relationCurrentToJunction.value) return [];
|
||||
if (!relationCurrentToJunction.value.junction_field) return [];
|
||||
if (!relationJunctionToRelated.value) return [];
|
||||
if (!relationJunctionToRelated.value.junction_field) return [];
|
||||
|
||||
const junctionPrimaryKey = junctionCollectionPrimaryKeyField.value.field;
|
||||
const junctionField = relationCurrentToJunction.value.junction_field;
|
||||
const relatedPrimaryKey = relatedCollectionPrimaryKeyField.value.field;
|
||||
|
||||
const newlySelectedStagedItems = (value.value || []).filter(
|
||||
(stagedEdit: any) => !stagedEdit.$delete && !stagedEdit[junctionPrimaryKey] && !stagedEdit.$new
|
||||
);
|
||||
|
||||
const newlySelectedRelatedKeys = newlySelectedStagedItems.map(
|
||||
(stagedEdit: any) => stagedEdit[junctionField][relatedPrimaryKey]
|
||||
);
|
||||
|
||||
// If there's no newly selected related items, we can return here, as there's nothing
|
||||
// to fetch
|
||||
if (newlySelectedRelatedKeys.length === 0) return [];
|
||||
|
||||
// The fields option are set from the viewport of the junction table. Seeing we only
|
||||
// fetch from the related table, we have to filter out all the fields from the junction
|
||||
// table and remove the junction field prefix from the related table columns
|
||||
const fieldsToFetch = fields.value
|
||||
.filter((field) => field.startsWith(junctionField))
|
||||
.map((relatedField) => {
|
||||
return relatedField.replace(junctionField + '.', '');
|
||||
});
|
||||
|
||||
if (fieldsToFetch.includes(relatedPrimaryKey) === false) fieldsToFetch.push(relatedPrimaryKey);
|
||||
|
||||
const endpoint = relatedCollection.value.startsWith('directus_')
|
||||
? `/${relatedCollection.value.substring(9)}/${newlySelectedRelatedKeys.join(',')}`
|
||||
: `/items/${relatedCollection.value}/${newlySelectedRelatedKeys.join(',')}`;
|
||||
|
||||
const response = await api.get(endpoint, {
|
||||
params: {
|
||||
fields: adjustFieldsForDisplay(fieldsToFetch, junctionCollection.value),
|
||||
},
|
||||
});
|
||||
|
||||
const data = Array.isArray(response.data.data) ? response.data.data : [response.data.data];
|
||||
|
||||
return newlySelectedStagedItems.map((stagedEdit: any) => {
|
||||
const pk = stagedEdit[junctionField][relatedPrimaryKey];
|
||||
|
||||
const relatedItem = data.find((relatedItem: any) => relatedItem[relatedPrimaryKey] === pk);
|
||||
|
||||
return merge(
|
||||
{
|
||||
[junctionField]: relatedItem,
|
||||
$stagedEdits: stagedEdit,
|
||||
},
|
||||
stagedEdit
|
||||
);
|
||||
});
|
||||
}
|
||||
return { tableHeaders, displayItems, items, loading, error };
|
||||
}
|
||||
|
||||
@@ -1,48 +1,60 @@
|
||||
import { Ref, computed } from '@vue/composition-api';
|
||||
import { useCollectionsStore, useRelationsStore } from '@/stores/';
|
||||
import useCollection from '@/composables/use-collection';
|
||||
import { Relation } from '@/types/';
|
||||
import { useRelationsStore } from '@/stores/';
|
||||
import { Relation } from '@/types';
|
||||
|
||||
type RelationParams = {
|
||||
collection: Ref<string>;
|
||||
field: Ref<string>;
|
||||
export type RelationInfo = {
|
||||
junctionPkField: string;
|
||||
relationPkField: string;
|
||||
junctionRelation: string;
|
||||
junctionCollection: string;
|
||||
relationCollection: string;
|
||||
};
|
||||
|
||||
export default function useRelation({ collection, field }: RelationParams) {
|
||||
export default function useRelation(collection: Ref<string>, field: Ref<string>) {
|
||||
const relationsStore = useRelationsStore();
|
||||
const collectionsStore = useCollectionsStore();
|
||||
|
||||
// We expect two relations to exist for this field: one from this field to the junction
|
||||
// table, and one from the junction table to the related collection
|
||||
const relations = computed<Relation[]>(() => {
|
||||
return relationsStore.getRelationsForField(collection.value, field.value);
|
||||
const relations = computed(() => {
|
||||
return relationsStore.getRelationsForField(collection.value, field.value) as Relation[];
|
||||
});
|
||||
|
||||
const relationCurrentToJunction = computed(() => {
|
||||
return relations.value.find(
|
||||
(relation: Relation) => relation.one_collection === collection.value && relation.one_field === field.value
|
||||
);
|
||||
const junction = computed(() => {
|
||||
return relations.value.find((relation) => relation.one_collection === collection.value) as Relation;
|
||||
});
|
||||
|
||||
const relationJunctionToRelated = computed(() => {
|
||||
if (!relationCurrentToJunction.value) return null;
|
||||
|
||||
const index = relations.value.indexOf(relationCurrentToJunction.value) === 1 ? 0 : 1;
|
||||
return relations.value[index];
|
||||
const relation = computed(() => {
|
||||
return relations.value.find((relation) => relation.one_collection !== collection.value) as Relation;
|
||||
});
|
||||
|
||||
const junctionCollection = computed(() => relations.value[0].many_collection);
|
||||
const relatedCollection = computed(() => relations.value[1].one_collection);
|
||||
const junctionCollection = computed(() => {
|
||||
return collectionsStore.getCollection(junction.value.many_collection)!;
|
||||
});
|
||||
|
||||
const { primaryKeyField: junctionCollectionPrimaryKeyField } = useCollection(junctionCollection);
|
||||
const { primaryKeyField: relatedCollectionPrimaryKeyField } = useCollection(relatedCollection);
|
||||
const relationCollection = computed(() => {
|
||||
return collectionsStore.getCollection(relation.value.one_collection)!;
|
||||
});
|
||||
|
||||
const { primaryKeyField: junctionPrimaryKeyField } = useCollection(junctionCollection.value.collection);
|
||||
const { primaryKeyField: relationPrimaryKeyField } = useCollection(relationCollection.value.collection);
|
||||
|
||||
const relationFields = computed(() => {
|
||||
return {
|
||||
junctionPkField: junctionPrimaryKeyField.value.field,
|
||||
relationPkField: relationPrimaryKeyField.value.field,
|
||||
junctionRelation: junction.value.junction_field as string,
|
||||
junctionCollection: junctionCollection.value.collection,
|
||||
relationCollection: relationCollection.value.collection,
|
||||
} as RelationInfo;
|
||||
});
|
||||
|
||||
return {
|
||||
relations,
|
||||
relationCurrentToJunction,
|
||||
relationJunctionToRelated,
|
||||
junction,
|
||||
junctionCollection,
|
||||
junctionCollectionPrimaryKeyField,
|
||||
relatedCollection,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
relation,
|
||||
relationCollection,
|
||||
relationFields,
|
||||
junctionPrimaryKeyField,
|
||||
relationPrimaryKeyField,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,77 +1,54 @@
|
||||
import { Relation, Filter } from '@/types/';
|
||||
import { Field } from '@/types';
|
||||
import { Ref, ref, computed } from '@vue/composition-api';
|
||||
import { RelationInfo } from './use-relation';
|
||||
import { Filter } from '@/types';
|
||||
|
||||
type SelectionParam = {
|
||||
relationCurrentToJunction: Ref<Relation | undefined>;
|
||||
relatedCollectionPrimaryKeyField: Ref<Field>;
|
||||
previewItems: Ref<readonly any[]>;
|
||||
onStageSelection: (selectionAsJunctionRows: any[]) => void;
|
||||
};
|
||||
export default function useSelection(
|
||||
value: Ref<(string | number | Record<string, any>)[] | null>,
|
||||
displayItems: Ref<Record<string, any>[]>,
|
||||
relation: Ref<RelationInfo>,
|
||||
emit: (newVal: any[] | null) => void
|
||||
) {
|
||||
const selectModalActive = ref(false);
|
||||
|
||||
export default function useSelection({
|
||||
relationCurrentToJunction,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
previewItems,
|
||||
onStageSelection,
|
||||
}: SelectionParam) {
|
||||
const showBrowseModal = ref(false);
|
||||
const selectedPrimaryKeys = computed(() => {
|
||||
if (displayItems.value === null) return [];
|
||||
|
||||
const alreadySelectedRelatedPrimaryKeys = computed(() => {
|
||||
if (!relationCurrentToJunction.value) return [];
|
||||
if (!relationCurrentToJunction.value.junction_field) return [];
|
||||
const { relationPkField } = relation.value;
|
||||
|
||||
const junctionField = relationCurrentToJunction.value.junction_field;
|
||||
const relatedPrimaryKey = relatedCollectionPrimaryKeyField.value.field;
|
||||
const selectedKeys: (number | string)[] = displayItems.value
|
||||
.filter((currentItem) => relationPkField in currentItem)
|
||||
.map((currentItem) => currentItem[relationPkField]);
|
||||
|
||||
return previewItems.value
|
||||
.filter((previewItem: any) => previewItem[junctionField])
|
||||
.map((previewItem: any) => {
|
||||
if (typeof previewItem[junctionField] === 'string' || typeof previewItem[junctionField] === 'number') {
|
||||
return previewItem[junctionField];
|
||||
}
|
||||
|
||||
return previewItem[junctionField][relatedPrimaryKey];
|
||||
})
|
||||
.filter((p) => p);
|
||||
return selectedKeys;
|
||||
});
|
||||
|
||||
const selectionFilters = computed<Filter[]>(() => {
|
||||
const relatedPrimaryKey = relatedCollectionPrimaryKeyField.value.field;
|
||||
const { relationPkField } = relation.value;
|
||||
|
||||
if (selectedPrimaryKeys.value.length === 0) return [];
|
||||
|
||||
const filter: Filter = {
|
||||
key: 'selection',
|
||||
field: relatedPrimaryKey,
|
||||
field: relationPkField,
|
||||
operator: 'nin',
|
||||
value: alreadySelectedRelatedPrimaryKeys.value.join(','),
|
||||
value: selectedPrimaryKeys.value.join(','),
|
||||
locked: true,
|
||||
};
|
||||
|
||||
return [filter];
|
||||
});
|
||||
|
||||
return { showBrowseModal, stageSelection, selectionFilters };
|
||||
function stageSelection(newSelection: (number | string)[]) {
|
||||
const { junctionRelation } = relation.value;
|
||||
|
||||
function stageSelection(selection: any) {
|
||||
const selectionAsJunctionRows = selection.map((relatedPrimaryKey: string | number) => {
|
||||
if (!relationCurrentToJunction.value) return;
|
||||
if (!relationCurrentToJunction.value.junction_field) return;
|
||||
const selection = newSelection
|
||||
.filter((item) => selectedPrimaryKeys.value.includes(item) === false)
|
||||
.map((item) => ({ [junctionRelation]: item }));
|
||||
|
||||
const junctionField = relationCurrentToJunction.value.junction_field;
|
||||
const relatedPrimaryKeyField = relatedCollectionPrimaryKeyField.value.field;
|
||||
|
||||
return {
|
||||
[junctionField]: {
|
||||
// Technically, "junctionField: primaryKey" should be enough for the api
|
||||
// to do it's thing for newly selected items. However, that would require
|
||||
// the previewItems check to be way more complex. This shouldn't introduce
|
||||
// too much overhead in the API, while drastically simplifying this interface
|
||||
[relatedPrimaryKeyField]: relatedPrimaryKey,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Seeing the browse modal only shows items that haven't been selected yet (using the
|
||||
// filter above), we can safely assume that the items don't exist yet in props.value
|
||||
onStageSelection(selectionAsJunctionRows);
|
||||
const newVal = [...selection, ...(value.value || [])];
|
||||
if (newVal.length === 0) emit(null);
|
||||
else emit(newVal);
|
||||
}
|
||||
|
||||
return { stageSelection, selectModalActive, selectedPrimaryKeys, selectionFilters };
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
<modal-detail
|
||||
<modal-item
|
||||
v-if="!disabled"
|
||||
:active.sync="editModalActive"
|
||||
:collection="relatedCollection.collection"
|
||||
@@ -91,7 +91,7 @@
|
||||
@input="stageEdits"
|
||||
/>
|
||||
|
||||
<modal-browse
|
||||
<modal-collection
|
||||
v-if="!disabled"
|
||||
:active.sync="selectModalActive"
|
||||
:collection="relatedCollection.collection"
|
||||
@@ -107,8 +107,8 @@ import { useCollectionsStore, useRelationsStore } from '@/stores/';
|
||||
import useCollection from '@/composables/use-collection';
|
||||
import getFieldsFromTemplate from '@/utils/get-fields-from-template';
|
||||
import api from '@/api';
|
||||
import ModalDetail from '@/views/private/components/modal-detail';
|
||||
import ModalBrowse from '@/views/private/components/modal-browse';
|
||||
import ModalItem from '@/views/private/components/modal-item';
|
||||
import ModalCollection from '@/views/private/components/modal-collection';
|
||||
|
||||
/**
|
||||
* @NOTE
|
||||
@@ -119,7 +119,7 @@ import ModalBrowse from '@/views/private/components/modal-browse';
|
||||
*/
|
||||
|
||||
export default defineComponent({
|
||||
components: { ModalDetail, ModalBrowse },
|
||||
components: { ModalItem, ModalCollection },
|
||||
props: {
|
||||
value: {
|
||||
type: [Number, String, Object],
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
</v-notice>
|
||||
<div class="one-to-many" v-else>
|
||||
<v-table
|
||||
:loading="currentLoading"
|
||||
:items="currentItems"
|
||||
:loading="loading"
|
||||
:items="displayItems"
|
||||
:headers.sync="tableHeaders"
|
||||
show-resize
|
||||
inline
|
||||
@@ -27,7 +27,7 @@
|
||||
</template>
|
||||
|
||||
<template #item-append="{ item }" v-if="!disabled">
|
||||
<v-icon name="close" v-tooltip="$t('deselect')" class="deselect" @click.stop="deselect(item)" />
|
||||
<v-icon name="close" v-tooltip="$t('deselect')" class="deselect" @click.stop="deleteItem(item)" />
|
||||
</template>
|
||||
</v-table>
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
</v-button>
|
||||
</div>
|
||||
|
||||
<modal-detail
|
||||
<modal-item
|
||||
v-if="!disabled"
|
||||
:active="currentlyEditing !== null"
|
||||
:collection="relatedCollection.collection"
|
||||
@@ -48,7 +48,7 @@
|
||||
@update:active="cancelEdit"
|
||||
/>
|
||||
|
||||
<modal-browse
|
||||
<modal-collection
|
||||
v-if="!disabled"
|
||||
:active.sync="selectModalActive"
|
||||
:collection="relatedCollection.collection"
|
||||
@@ -65,17 +65,18 @@ import { defineComponent, ref, computed, watch, PropType } from '@vue/compositio
|
||||
import api from '@/api';
|
||||
import useCollection from '@/composables/use-collection';
|
||||
import { useCollectionsStore, useRelationsStore, useFieldsStore } from '@/stores/';
|
||||
import ModalDetail from '@/views/private/components/modal-detail';
|
||||
import ModalBrowse from '@/views/private/components/modal-browse';
|
||||
import ModalItem from '@/views/private/components/modal-item';
|
||||
import ModalCollection from '@/views/private/components/modal-collection';
|
||||
import { Filter, Field } from '@/types';
|
||||
import { Header } from '@/components/v-table/types';
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
export default defineComponent({
|
||||
components: { ModalDetail, ModalBrowse },
|
||||
components: { ModalItem, ModalCollection },
|
||||
props: {
|
||||
value: {
|
||||
type: Array,
|
||||
default: undefined,
|
||||
type: Array as PropType<(number | string | Record<string, any>)[] | null>,
|
||||
default: null,
|
||||
},
|
||||
primaryKey: {
|
||||
type: [Number, String],
|
||||
@@ -104,16 +105,14 @@ export default defineComponent({
|
||||
const fieldsStore = useFieldsStore();
|
||||
|
||||
const { relation, relatedCollection, relatedPrimaryKeyField } = useRelation();
|
||||
const { loading: currentLoading, items: currentItems } = useCurrent();
|
||||
const { tableHeaders } = useTable();
|
||||
const { tableHeaders, displayItems, loading, error } = useTable();
|
||||
const { currentlyEditing, editItem, editsAtStart, stageEdits, cancelEdit } = useEdits();
|
||||
const { stageSelection, selectModalActive, selectionFilters } = useSelection();
|
||||
|
||||
return {
|
||||
currentLoading,
|
||||
currentItems,
|
||||
relation,
|
||||
tableHeaders,
|
||||
loading,
|
||||
currentlyEditing,
|
||||
editItem,
|
||||
relatedCollection,
|
||||
@@ -122,10 +121,85 @@ export default defineComponent({
|
||||
cancelEdit,
|
||||
stageSelection,
|
||||
selectModalActive,
|
||||
deleteItem,
|
||||
displayItems,
|
||||
selectionFilters,
|
||||
deselect,
|
||||
};
|
||||
|
||||
function getItem(id: string | number) {
|
||||
const pkField = relatedPrimaryKeyField.value.field;
|
||||
if (props.value === null) return null;
|
||||
return (
|
||||
props.value.find(
|
||||
(item) => (typeof item === 'object' && pkField in item && item[pkField] === id) || item === id
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
function getNewItems() {
|
||||
const pkField = relatedPrimaryKeyField.value.field;
|
||||
if (props.value === null) return [];
|
||||
return props.value.filter((item) => typeof item === 'object' && pkField in item === false) as Record<
|
||||
string,
|
||||
any
|
||||
>[];
|
||||
}
|
||||
|
||||
function getUpdatedItems() {
|
||||
const pkField = relatedPrimaryKeyField.value.field;
|
||||
if (props.value === null) return [];
|
||||
return props.value.filter((item) => typeof item === 'object' && pkField in item === true) as Record<
|
||||
string,
|
||||
any
|
||||
>[];
|
||||
}
|
||||
|
||||
function getExistingItems() {
|
||||
if (props.value === null) return [];
|
||||
const pkField = relatedPrimaryKeyField.value.field;
|
||||
return props.value.filter((item) => typeof item === 'string' || typeof item === 'number');
|
||||
}
|
||||
|
||||
function getPrimaryKeys() {
|
||||
if (props.value === null) return [];
|
||||
const pkField = relatedPrimaryKeyField.value.field;
|
||||
return props.value
|
||||
.map((item) => {
|
||||
if (typeof item === 'object') {
|
||||
if (pkField in item) return item[pkField];
|
||||
} else {
|
||||
return item;
|
||||
}
|
||||
})
|
||||
.filter((i) => i);
|
||||
}
|
||||
|
||||
function deleteItem(item: Record<string, any>) {
|
||||
if (props.value === null) return;
|
||||
|
||||
const relatedPrimKey = relatedPrimaryKeyField.value.field;
|
||||
|
||||
if (relatedPrimKey in item === false) {
|
||||
emit(
|
||||
'input',
|
||||
props.value.filter((val) => isEqual(item, val) === false)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const id = item[relatedPrimKey];
|
||||
emit(
|
||||
'input',
|
||||
props.value.filter((item) => {
|
||||
if (typeof item === 'number' || typeof item === 'string') return item !== id;
|
||||
if (typeof item === 'object' && relatedPrimKey in item) {
|
||||
return item[relatedPrimKey] !== id;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds info about the current relationship, like related collection, primary key field
|
||||
* of the other collection etc
|
||||
@@ -144,162 +218,64 @@ export default defineComponent({
|
||||
return { relation, relatedCollection, relatedPrimaryKeyField };
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the current display value (the rows in the table)
|
||||
* This listens to changes in props.value to make sure we always display the correct info
|
||||
* in the table itself
|
||||
*/
|
||||
function useCurrent() {
|
||||
const loading = ref(false);
|
||||
const items = ref<any[]>([]);
|
||||
const error = ref(null);
|
||||
|
||||
// This is the primary key of the parent form, not the related items
|
||||
// By watching the primary key prop for this, it'll load the items fresh on load, but
|
||||
// also when we navigate from edit form to another edit form.
|
||||
watch(
|
||||
() => props.primaryKey,
|
||||
(newKey) => {
|
||||
if (newKey !== null && newKey !== '+' && Array.isArray(props.value) !== true) {
|
||||
fetchCurrent();
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
);
|
||||
|
||||
// The value can either be null (no changes), or an array of primary key / object with changes
|
||||
watch(
|
||||
() => props.value,
|
||||
(newValue) => {
|
||||
// When the value is null, there aren't any changes. It does not mean that all
|
||||
// related items are deselected
|
||||
if (newValue === null) {
|
||||
fetchCurrent();
|
||||
}
|
||||
|
||||
if (Array.isArray(newValue)) {
|
||||
mergeWithItems(newValue);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return { loading, items, error, fetchCurrent };
|
||||
|
||||
/**
|
||||
* Fetch all related items based on the primary key of the current field. This is only
|
||||
* run on first load (or when the parent form primary key changes)
|
||||
*/
|
||||
async function fetchCurrent() {
|
||||
loading.value = true;
|
||||
|
||||
let fields = [...(props.fields.length > 0 ? props.fields : getDefaultFields())];
|
||||
|
||||
if (fields.includes(relatedPrimaryKeyField.value.field) === false) {
|
||||
fields.push(relatedPrimaryKeyField.value.field);
|
||||
}
|
||||
|
||||
// We're fetching these fields nested on the current item, so nest them in the current
|
||||
// field-key
|
||||
fields = fields.map((fieldKey) => `${props.field}.${fieldKey}`);
|
||||
|
||||
try {
|
||||
const endpoint = props.collection.startsWith('directus_')
|
||||
? `/${props.collection.substring(9)}/${props.primaryKey}`
|
||||
: `/items/${props.collection}/${props.primaryKey}`;
|
||||
|
||||
const response = await api.get(endpoint, {
|
||||
params: {
|
||||
fields: fields,
|
||||
},
|
||||
});
|
||||
|
||||
items.value = response.data.data[props.field] || [];
|
||||
} catch (err) {
|
||||
error.value = err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges all changes / newly selected items with the current value array, so we can
|
||||
* display the most up to date information in the table. This will merge edits with the
|
||||
* existing items, and fetch the full item info when the item is newly selected (as it
|
||||
* will only have a pk in the array of changes)
|
||||
*/
|
||||
async function mergeWithItems(changes: any[]) {
|
||||
loading.value = true;
|
||||
|
||||
const pkField = relatedPrimaryKeyField.value.field;
|
||||
|
||||
const itemsWithChangesApplied = items.value
|
||||
.map((item: any) => {
|
||||
const changeForThisItem = changes.find((change) => change[pkField] === item[pkField]);
|
||||
|
||||
if (changeForThisItem) {
|
||||
return {
|
||||
...item,
|
||||
...changeForThisItem,
|
||||
};
|
||||
}
|
||||
|
||||
return item;
|
||||
})
|
||||
.filter((item) => item.hasOwnProperty(pkField))
|
||||
.filter((item) => item[relation.value.many_field] !== null);
|
||||
|
||||
const newlyAddedItems = changes.filter(
|
||||
(change) =>
|
||||
typeof change !== 'string' &&
|
||||
typeof change !== 'number' &&
|
||||
change.hasOwnProperty(pkField) === false
|
||||
);
|
||||
|
||||
const selectedPrimaryKeys = changes
|
||||
.filter((change) => typeof change === 'string' || typeof change === 'number')
|
||||
.filter((primaryKey) => {
|
||||
const isAlsoUpdate = itemsWithChangesApplied.some((update) => update[pkField] === primaryKey);
|
||||
|
||||
return isAlsoUpdate === false;
|
||||
});
|
||||
|
||||
let selectedItems: any[] = [];
|
||||
|
||||
if (selectedPrimaryKeys.length > 0) {
|
||||
const fields = [...props.fields];
|
||||
|
||||
if (fields.includes(relatedPrimaryKeyField.value.field) === false) {
|
||||
fields.push(relatedPrimaryKeyField.value.field);
|
||||
}
|
||||
|
||||
const endpoint = props.collection.startsWith('directus_')
|
||||
? `/${props.collection.substring(9)}/${selectedPrimaryKeys.join(',')}`
|
||||
: `/items/${relatedCollection.value.collection}/${selectedPrimaryKeys.join(',')}`;
|
||||
|
||||
const response = await api.get(endpoint, {
|
||||
params: {
|
||||
fields: fields,
|
||||
},
|
||||
});
|
||||
|
||||
if (Array.isArray(response.data.data)) {
|
||||
selectedItems = response.data.data;
|
||||
} else {
|
||||
selectedItems = [response.data.data];
|
||||
}
|
||||
}
|
||||
|
||||
items.value = [...itemsWithChangesApplied, ...newlyAddedItems, ...selectedItems];
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function useTable() {
|
||||
// Using a ref for the table headers here means that the table itself can update the
|
||||
// values if it needs to. This allows the user to manually resize the columns for example
|
||||
const tableHeaders = ref<Header[]>([]);
|
||||
const loading = ref(false);
|
||||
const displayItems = ref<Record<string, any>[]>([]);
|
||||
const error = ref(null);
|
||||
|
||||
watch(
|
||||
() => props.value,
|
||||
async (newVal) => {
|
||||
loading.value = true;
|
||||
const pkField = relatedPrimaryKeyField.value.field;
|
||||
|
||||
const fields = [...(props.fields.length > 0 ? props.fields : getDefaultFields())];
|
||||
|
||||
if (fields.includes(pkField) === false) {
|
||||
fields.push(pkField);
|
||||
}
|
||||
|
||||
try {
|
||||
const endpoint = relatedCollection.value.collection.startsWith('directus_')
|
||||
? `/${relatedCollection.value.collection.substring(9)}`
|
||||
: `/items/${relatedCollection.value.collection}`;
|
||||
|
||||
const primaryKeys = getPrimaryKeys();
|
||||
|
||||
let existingItems: any[] = [];
|
||||
|
||||
if (primaryKeys && primaryKeys.length > 0) {
|
||||
const response = await api.get(endpoint, {
|
||||
params: {
|
||||
fields: fields,
|
||||
[`filter[${pkField}][_in]`]: primaryKeys.join(','),
|
||||
},
|
||||
});
|
||||
|
||||
existingItems = response.data.data;
|
||||
}
|
||||
|
||||
const updatedItems = getUpdatedItems();
|
||||
const newItems = getNewItems();
|
||||
|
||||
displayItems.value = existingItems
|
||||
.map((item) => {
|
||||
const updatedItem = updatedItems.find((updated) => updated[pkField] === item[pkField]);
|
||||
if (updatedItem !== undefined) return updatedItem;
|
||||
return item;
|
||||
})
|
||||
.concat(...newItems);
|
||||
} catch (err) {
|
||||
error.value = err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Seeing we don't care about saving those tableHeaders, we can reset it whenever the
|
||||
// fields prop changes (most likely when we're navigating to a different o2m context)
|
||||
@@ -335,7 +311,7 @@ export default defineComponent({
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
return { tableHeaders };
|
||||
return { tableHeaders, displayItems, loading, error };
|
||||
}
|
||||
|
||||
function useEdits() {
|
||||
@@ -349,65 +325,45 @@ export default defineComponent({
|
||||
return { currentlyEditing, editItem, editsAtStart, stageEdits, cancelEdit };
|
||||
|
||||
function editItem(item: any) {
|
||||
const primaryKey = item[relatedPrimaryKeyField.value.field];
|
||||
const pkField = relatedPrimaryKeyField.value.field;
|
||||
const hasPrimaryKey = pkField in item;
|
||||
|
||||
// When the currently staged value is an array, we know we made changes / added / removed
|
||||
// certain items. In that case, we have to extract the previously made edits so we can
|
||||
// keep moving forwards with those
|
||||
if (props.value && Array.isArray(props.value)) {
|
||||
const existingEdits = props.value.find((existingChange) => {
|
||||
const existingPK = existingChange[relatedPrimaryKeyField.value.field];
|
||||
if (!existingPK) return item === existingChange;
|
||||
return existingPK === primaryKey;
|
||||
});
|
||||
|
||||
if (existingEdits) {
|
||||
editsAtStart.value = existingEdits;
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure the edits have the primary key included, otherwise the api will create
|
||||
// the item as a new one instead of update the existing
|
||||
if (primaryKey && editsAtStart.value.hasOwnProperty(relatedPrimaryKeyField.value.field) === false) {
|
||||
editsAtStart.value = {
|
||||
...editsAtStart.value,
|
||||
[relatedPrimaryKeyField.value.field]: primaryKey,
|
||||
};
|
||||
}
|
||||
|
||||
currentlyEditing.value = primaryKey;
|
||||
editsAtStart.value = item;
|
||||
currentlyEditing.value = hasPrimaryKey ? item[pkField] : -1;
|
||||
}
|
||||
|
||||
function stageEdits(edits: any) {
|
||||
const pkField = relatedPrimaryKeyField.value.field;
|
||||
|
||||
const hasPrimaryKey = edits.hasOwnProperty(pkField);
|
||||
const hasPrimaryKey = pkField in edits;
|
||||
|
||||
if (props.value && Array.isArray(props.value)) {
|
||||
const newValue = props.value.map((existingChange) => {
|
||||
if (existingChange[pkField] && edits[pkField] && existingChange[pkField] === edits[pkField]) {
|
||||
return edits;
|
||||
}
|
||||
|
||||
if (existingChange === edits[pkField]) {
|
||||
return edits;
|
||||
}
|
||||
|
||||
if (editsAtStart.value === existingChange) {
|
||||
return edits;
|
||||
}
|
||||
|
||||
return existingChange;
|
||||
});
|
||||
|
||||
if (hasPrimaryKey === false && newValue.includes(edits) === false) {
|
||||
newValue.push(edits);
|
||||
const newValue = (props.value || []).map((item) => {
|
||||
if (
|
||||
typeof item === 'object' &&
|
||||
pkField in item &&
|
||||
pkField in edits &&
|
||||
item[pkField] === edits[pkField]
|
||||
) {
|
||||
return edits;
|
||||
}
|
||||
|
||||
emit('input', newValue);
|
||||
} else {
|
||||
emit('input', [edits]);
|
||||
if (item === edits[pkField]) {
|
||||
return edits;
|
||||
}
|
||||
|
||||
if (editsAtStart.value === item) {
|
||||
return edits;
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
|
||||
if (hasPrimaryKey === false && newValue.includes(edits) === false) {
|
||||
newValue.push(edits);
|
||||
}
|
||||
|
||||
if (newValue.length === 0) emit('input', null);
|
||||
else emit('input', newValue);
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
@@ -420,19 +376,26 @@ export default defineComponent({
|
||||
const selectModalActive = ref(false);
|
||||
|
||||
const selectedPrimaryKeys = computed<(number | string)[]>(() => {
|
||||
if (!currentItems.value) return [];
|
||||
if (displayItems.value === null) return [];
|
||||
|
||||
const pkField = relatedPrimaryKeyField.value.field;
|
||||
return currentItems.value
|
||||
.filter((currentItem) => currentItem.hasOwnProperty(pkField))
|
||||
|
||||
return displayItems.value
|
||||
.filter((currentItem) => pkField in currentItem)
|
||||
.map((currentItem) => currentItem[pkField]);
|
||||
});
|
||||
|
||||
const selectionFilters = computed<Filter[]>(() => {
|
||||
const pkField = relatedPrimaryKeyField.value.field;
|
||||
|
||||
if (selectedPrimaryKeys.value.length === 0) return [];
|
||||
|
||||
const filter: Filter = {
|
||||
key: 'selection',
|
||||
field: relatedPrimaryKeyField.value.field,
|
||||
field: pkField,
|
||||
operator: 'nin',
|
||||
value: selectedPrimaryKeys.value.join(','),
|
||||
locked: true,
|
||||
};
|
||||
|
||||
return [filter];
|
||||
@@ -441,83 +404,17 @@ export default defineComponent({
|
||||
return { stageSelection, selectModalActive, selectionFilters };
|
||||
|
||||
function stageSelection(newSelection: (number | string)[]) {
|
||||
if (props.value && Array.isArray(props.value)) {
|
||||
emit('input', [...props.value, ...newSelection]);
|
||||
} else {
|
||||
emit('input', newSelection);
|
||||
}
|
||||
const pkField = relatedPrimaryKeyField.value.field;
|
||||
|
||||
const selection = newSelection.filter((item) => selectedPrimaryKeys.value.includes(item) === false);
|
||||
|
||||
const newVal = [...selection, ...(props.value || [])];
|
||||
|
||||
if (newVal.length === 0) emit('input', null);
|
||||
else emit('input', newVal);
|
||||
}
|
||||
}
|
||||
|
||||
function deselect(item: any) {
|
||||
const pkField = relatedPrimaryKeyField.value.field;
|
||||
|
||||
const itemPrimaryKey = item[pkField];
|
||||
|
||||
// If the edited item doesn't have a primary key, it's new. In that case, filtering
|
||||
// it out of props.value should be enough to remove it
|
||||
if (itemPrimaryKey === undefined) {
|
||||
return emit(
|
||||
'input',
|
||||
props.value.filter((stagedValue) => stagedValue !== item)
|
||||
);
|
||||
}
|
||||
|
||||
// If there's no staged value, it's safe to assume this item was already selected before
|
||||
// and has to be deselected
|
||||
if (props.value === null) {
|
||||
return emit('input', [
|
||||
{
|
||||
[pkField]: itemPrimaryKey,
|
||||
[relation.value.many_field]: null,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
// If the item is selected in the current edits, it will only have staged the primary
|
||||
// key so the API is able to properly set it on first creation. In that case, we have
|
||||
// to filter out the primary key
|
||||
const itemWasNewlySelect = !!props.value.find((stagedItem) => stagedItem === itemPrimaryKey);
|
||||
|
||||
if (itemWasNewlySelect) {
|
||||
currentItems.value = currentItems.value.filter(
|
||||
(itemPreview) => itemPreview[pkField] !== itemPrimaryKey
|
||||
);
|
||||
|
||||
return emit(
|
||||
'input',
|
||||
props.value.filter((stagedValue) => stagedValue !== itemPrimaryKey)
|
||||
);
|
||||
}
|
||||
|
||||
const itemHasEdits =
|
||||
props.value.find((stagedItem: any) => stagedItem[pkField] === itemPrimaryKey) !== undefined;
|
||||
|
||||
if (itemHasEdits) {
|
||||
return emit(
|
||||
'input',
|
||||
props.value.map((stagedValue: any) => {
|
||||
if (stagedValue[pkField] === itemPrimaryKey) {
|
||||
return {
|
||||
[pkField]: itemPrimaryKey,
|
||||
[relation.value.many_field]: null,
|
||||
};
|
||||
}
|
||||
|
||||
return stagedValue;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return emit('input', [
|
||||
...props.value,
|
||||
{
|
||||
[pkField]: itemPrimaryKey,
|
||||
[relation.value.many_field]: null,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function getDefaultFields(): string[] {
|
||||
const fields = fieldsStore.getFieldsForCollection(relatedCollection.value.collection);
|
||||
return fields.slice(0, 3).map((field: Field) => field.field);
|
||||
|
||||
@@ -25,7 +25,7 @@ export default defineComponent({
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: i18n.t('empty_item'),
|
||||
default: null,
|
||||
},
|
||||
toggle: {
|
||||
type: Function,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user