mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
Merge branch 'main' into feature-redis-cache
This commit is contained in:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -4,3 +4,11 @@ node_modules
|
||||
.env
|
||||
npm-debug.log
|
||||
lerna-debug.log
|
||||
|
||||
# This is always a point of debate, but:
|
||||
# * package-lock is auto-generate by lerna, which generates it differently from npm
|
||||
# * We actually _want_ people to be on the latests semver versions for local development
|
||||
# * package-locks are ignored when publishing to NPM _anyway_, so it doesn't matter for releases
|
||||
# * the app is bundled on release, so its package versions are locked by definition
|
||||
|
||||
package-lock.json
|
||||
|
||||
8496
api/package-lock.json
generated
8496
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-alpha.20",
|
||||
"version": "9.0.0-alpha.27",
|
||||
"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.",
|
||||
@@ -68,7 +68,7 @@
|
||||
"example.env"
|
||||
],
|
||||
"dependencies": {
|
||||
"@directus/app": "^9.0.0-alpha.20",
|
||||
"@directus/app": "^9.0.0-alpha.27",
|
||||
"@directus/format-title": "^3.2.0",
|
||||
"@slynova/flydrive": "^1.0.2",
|
||||
"@slynova/flydrive-gcs": "^1.0.2",
|
||||
@@ -101,6 +101,7 @@
|
||||
"knex-schema-inspector": "0.0.9",
|
||||
"liquidjs": "^9.14.1",
|
||||
"lodash": "^4.17.19",
|
||||
"macos-release": "^2.4.1",
|
||||
"ms": "^2.1.2",
|
||||
"nanoid": "^3.1.12",
|
||||
"node-cache": "^5.1.2",
|
||||
@@ -167,5 +168,5 @@
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"gitHead": "9092740d708c1e9ed1575b0932a01f531d224f77"
|
||||
"gitHead": "c22537b6d0dd7dc6cc651a9200a68f6b060353d4"
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { InvalidPayloadException } from '../exceptions/invalid-payload';
|
||||
import ms from 'ms';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import env from '../env';
|
||||
import UsersService from '../services/users';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -17,7 +18,7 @@ const loginSchema = Joi.object({
|
||||
email: Joi.string().email().required(),
|
||||
password: Joi.string().required(),
|
||||
mode: Joi.string().valid('cookie', 'json'),
|
||||
otp: Joi.string()
|
||||
otp: Joi.string(),
|
||||
});
|
||||
|
||||
router.post(
|
||||
@@ -153,6 +154,55 @@ router.post(
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/password/request',
|
||||
asyncHandler(async (req, res) => {
|
||||
if (!req.body.email) {
|
||||
throw new InvalidPayloadException(`"email" field is required.`);
|
||||
}
|
||||
|
||||
const accountability = {
|
||||
ip: req.ip,
|
||||
userAgent: req.get('user-agent'),
|
||||
role: null,
|
||||
};
|
||||
|
||||
const service = new UsersService({ accountability });
|
||||
|
||||
try {
|
||||
await service.requestPasswordReset(req.body.email);
|
||||
} catch {
|
||||
// We don't want to give away what email addresses exist, so we'll always return a 200
|
||||
// from this endpoint
|
||||
} finally {
|
||||
return res.status(200).end();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/password/reset',
|
||||
asyncHandler(async (req, res) => {
|
||||
if (!req.body.token) {
|
||||
throw new InvalidPayloadException(`"token" field is required.`);
|
||||
}
|
||||
|
||||
if (!req.body.password) {
|
||||
throw new InvalidPayloadException(`"password" field is required.`);
|
||||
}
|
||||
|
||||
const accountability = {
|
||||
ip: req.ip,
|
||||
userAgent: req.get('user-agent'),
|
||||
role: null,
|
||||
};
|
||||
|
||||
const service = new UsersService({ accountability });
|
||||
await service.resetPassword(req.body.token, req.body.password);
|
||||
return res.status(200).end();
|
||||
})
|
||||
);
|
||||
|
||||
router.use(
|
||||
'/sso',
|
||||
session({ secret: env.SECRET as string, saveUninitialized: false, resave: false })
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { Router } from 'express';
|
||||
import asyncHandler from 'express-async-handler';
|
||||
import redis from 'redis';
|
||||
import FieldsService from '../services/fields';
|
||||
import validateCollection from '../middleware/collection-exists';
|
||||
import checkCacheMiddleware from '../middleware/check-cache';
|
||||
import setCacheMiddleware from '../middleware/set-cache';
|
||||
import delCacheMiddleware from '../middleware/delete-cache';
|
||||
import { schemaInspector } from '../database';
|
||||
import { FieldNotFoundException, InvalidPayloadException } from '../exceptions';
|
||||
import { FieldNotFoundException, InvalidPayloadException, ForbiddenException } from '../exceptions';
|
||||
import Joi from 'joi';
|
||||
import { Field } from '../types/field';
|
||||
import useCollection from '../middleware/use-collection';
|
||||
@@ -59,7 +58,7 @@ router.get(
|
||||
const service = new FieldsService({ accountability: req.accountability });
|
||||
|
||||
const exists = await schemaInspector.hasColumn(req.collection, req.params.field);
|
||||
if (exists === false) throw new FieldNotFoundException(req.collection, req.params.field);
|
||||
if (exists === false) throw new ForbiddenException();
|
||||
|
||||
const field = await service.readOne(req.params.collection, req.params.field);
|
||||
|
||||
@@ -73,11 +72,11 @@ const newFieldSchema = Joi.object({
|
||||
field: Joi.string().required(),
|
||||
type: Joi.string().valid(...types),
|
||||
schema: Joi.object({
|
||||
comment: Joi.string(),
|
||||
comment: Joi.string().allow(null),
|
||||
default_value: Joi.any(),
|
||||
max_length: [Joi.number(), Joi.string()],
|
||||
is_nullable: Joi.bool(),
|
||||
}),
|
||||
}).unknown(),
|
||||
/** @todo base this on default validation */
|
||||
meta: Joi.any(),
|
||||
});
|
||||
@@ -120,7 +119,7 @@ router.patch(
|
||||
let results: any = [];
|
||||
|
||||
for (const field of req.body) {
|
||||
await service.updateField(req.params.collection, field, req.accountability);
|
||||
await service.updateField(req.params.collection, field);
|
||||
|
||||
const updatedField = await service.readOne(req.params.collection, field.field);
|
||||
|
||||
@@ -139,12 +138,11 @@ router.patch(
|
||||
// @todo: validate field
|
||||
asyncHandler(async (req, res) => {
|
||||
const service = new FieldsService({ accountability: req.accountability });
|
||||
|
||||
const fieldData: Partial<Field> & { field: string; type: typeof types[number] } = req.body;
|
||||
|
||||
if (!fieldData.field) fieldData.field = req.params.field;
|
||||
|
||||
await service.updateField(req.params.collection, fieldData, req.accountability);
|
||||
await service.updateField(req.params.collection, fieldData);
|
||||
|
||||
const updatedField = await service.readOne(req.params.collection, req.params.field);
|
||||
|
||||
@@ -159,8 +157,7 @@ router.delete(
|
||||
delCacheMiddleware,
|
||||
asyncHandler(async (req, res) => {
|
||||
const service = new FieldsService({ accountability: req.accountability });
|
||||
|
||||
await service.deleteField(req.params.collection, req.params.field, req.accountability);
|
||||
await service.deleteField(req.params.collection, req.params.field);
|
||||
|
||||
res.status(200).end();
|
||||
})
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { Router } from 'express';
|
||||
import ServerService from '../services/server';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/ping', (req, res) => res.send('pong'));
|
||||
router.get('/info', (req, res) => res.json({ data: process.versions }));
|
||||
|
||||
router.get('/info', (req, res) => {
|
||||
const service = new ServerService({ accountability: req.accountability });
|
||||
const data = service.serverInfo();
|
||||
res.json({ data });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -161,9 +161,9 @@ router.post(
|
||||
}
|
||||
|
||||
const service = new UsersService({ accountability: req.accountability });
|
||||
const url = await service.enableTFA(req.accountability.user);
|
||||
const { url, secret } = await service.enableTFA(req.accountability.user);
|
||||
|
||||
return res.json({ data: { otpauth_url: url } });
|
||||
return res.json({ data: { secret, otpauth_url: url } });
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@ import asyncHandler from 'express-async-handler';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { InvalidQueryException, InvalidPayloadException } from '../exceptions';
|
||||
import argon2 from 'argon2';
|
||||
import collectionExists from '../middleware/collection-exists';
|
||||
import UtilsService from '../services/utils';
|
||||
import Joi from 'joi';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -48,4 +51,23 @@ router.post(
|
||||
})
|
||||
);
|
||||
|
||||
const SortSchema = Joi.object({
|
||||
item: Joi.alternatives(Joi.string(), Joi.number()).required(),
|
||||
to: Joi.alternatives(Joi.string(), Joi.number()).required(),
|
||||
});
|
||||
|
||||
router.post(
|
||||
'/sort/:collection',
|
||||
collectionExists,
|
||||
asyncHandler(async (req, res) => {
|
||||
const { error } = SortSchema.validate(req.body);
|
||||
if (error) throw new InvalidPayloadException(error.message);
|
||||
|
||||
const service = new UtilsService({ accountability: req.accountability });
|
||||
await service.sort(req.collection, req.body);
|
||||
|
||||
return res.status(200).end();
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -44,6 +44,11 @@ export default async function runAST(ast: AST, query = ast.query) {
|
||||
|
||||
// Query defaults
|
||||
query.limit = query.limit || 100;
|
||||
|
||||
if (query.limit === -1) {
|
||||
delete query.limit;
|
||||
}
|
||||
|
||||
query.sort = query.sort || [{ column: primaryKeyField, order: 'asc' }];
|
||||
|
||||
await applyQuery(ast.name, dbQuery, query);
|
||||
@@ -116,7 +121,7 @@ export default async function runAST(ast: AST, query = ast.query) {
|
||||
*/
|
||||
if (batchQuery.limit) {
|
||||
tempLimit = batchQuery.limit;
|
||||
delete batchQuery.limit;
|
||||
batchQuery.limit = -1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,14 @@ tables:
|
||||
collection:
|
||||
type: string
|
||||
primary: true
|
||||
icon:
|
||||
type: string
|
||||
length: 30
|
||||
note:
|
||||
type: text
|
||||
display_template:
|
||||
type: string
|
||||
length: 255
|
||||
hidden:
|
||||
type: boolean
|
||||
nullable: false
|
||||
@@ -12,14 +20,19 @@ tables:
|
||||
type: boolean
|
||||
nullable: false
|
||||
default: false
|
||||
icon:
|
||||
type: string
|
||||
length: 30
|
||||
note:
|
||||
type: text
|
||||
translation:
|
||||
type: json
|
||||
display_template:
|
||||
archive_field:
|
||||
type: string
|
||||
length: 64
|
||||
archive_app_filter:
|
||||
type: boolean
|
||||
nullable: false
|
||||
default: true
|
||||
archive_value:
|
||||
type: string
|
||||
length: 255
|
||||
unarchive_value:
|
||||
type: string
|
||||
length: 255
|
||||
sort_field:
|
||||
@@ -310,12 +323,14 @@ tables:
|
||||
references:
|
||||
table: directus_collections
|
||||
column: collection
|
||||
operation:
|
||||
action:
|
||||
type: string
|
||||
length: 10
|
||||
nullable: false
|
||||
permissions:
|
||||
type: json
|
||||
validation:
|
||||
type: json
|
||||
presets:
|
||||
type: json
|
||||
fields:
|
||||
@@ -551,58 +566,74 @@ rows:
|
||||
note: null
|
||||
data:
|
||||
- collection: directus_collections
|
||||
field: collection
|
||||
interface: text-input
|
||||
field: collection_divider
|
||||
special: alias
|
||||
interface: divider
|
||||
options:
|
||||
icon: box
|
||||
title: Collection Setup
|
||||
color: '#2F80ED'
|
||||
locked: true
|
||||
sort: 1
|
||||
width: full
|
||||
- collection: directus_collections
|
||||
field: collection
|
||||
special:
|
||||
interface: text-input
|
||||
options:
|
||||
font: monospace
|
||||
readonly: true
|
||||
sort: 1
|
||||
width: half
|
||||
- collection: directus_collections
|
||||
field: icon
|
||||
interface: icon
|
||||
locked: true
|
||||
readonly: true
|
||||
sort: 2
|
||||
width: half
|
||||
- collection: directus_collections
|
||||
field: note
|
||||
interface: text-input
|
||||
field: icon
|
||||
special:
|
||||
interface: icon
|
||||
options:
|
||||
locked: true
|
||||
sort: 3
|
||||
width: half
|
||||
- collection: directus_collections
|
||||
field: note
|
||||
special:
|
||||
interface: text-input
|
||||
options:
|
||||
placeholder: A description of this collection...
|
||||
sort: 3
|
||||
width: full
|
||||
- collection: directus_collections
|
||||
field: display_template
|
||||
interface: text-input
|
||||
locked: true
|
||||
options:
|
||||
placeholder: 'Reference title for items, eg: {{ first_name }} {{ last_name }}'
|
||||
sort: 4
|
||||
width: full
|
||||
- collection: directus_collections
|
||||
field: hidden
|
||||
interface: toggle
|
||||
field: display_template
|
||||
special:
|
||||
interface: display-template
|
||||
options:
|
||||
collectionField: collection
|
||||
locked: true
|
||||
sort: 5
|
||||
width: full
|
||||
- collection: directus_collections
|
||||
field: hidden
|
||||
special: boolean
|
||||
interface: toggle
|
||||
options:
|
||||
label: Hide within the App
|
||||
sort: 5
|
||||
special: boolean
|
||||
locked: true
|
||||
sort: 6
|
||||
width: half
|
||||
- collection: directus_collections
|
||||
field: singleton
|
||||
special: boolean
|
||||
interface: toggle
|
||||
locked: true
|
||||
options:
|
||||
label: Treat as single object
|
||||
sort: 6
|
||||
special: boolean
|
||||
locked: true
|
||||
sort: 7
|
||||
width: half
|
||||
- collection: directus_collections
|
||||
field: translation
|
||||
special: json
|
||||
interface: repeater
|
||||
locked: true
|
||||
options:
|
||||
template: '{{ locale }}'
|
||||
fields:
|
||||
@@ -618,9 +649,88 @@ rows:
|
||||
system:
|
||||
interface: text-input
|
||||
width: half
|
||||
special: json
|
||||
sort: 7
|
||||
locked: true
|
||||
sort: 8
|
||||
width: full
|
||||
- collection: directus_collections
|
||||
field: archive_divider
|
||||
special: alias
|
||||
interface: divider
|
||||
options:
|
||||
icon: archive
|
||||
title: Archive
|
||||
color: '#2F80ED'
|
||||
locked: true
|
||||
sort: 9
|
||||
width: full
|
||||
- collection: directus_collections
|
||||
field: archive_field
|
||||
special:
|
||||
interface: field
|
||||
options:
|
||||
collectionField: collection
|
||||
allowNone: true
|
||||
placeholder: Choose a field...
|
||||
locked: true
|
||||
sort: 10
|
||||
width: half
|
||||
- collection: directus_collections
|
||||
field: archive_app_filter
|
||||
special:
|
||||
interface: toggle
|
||||
options:
|
||||
label: Enable App Archive Filter
|
||||
locked: true
|
||||
sort: 11
|
||||
width: half
|
||||
- collection: directus_collections
|
||||
field: archive_value
|
||||
special:
|
||||
interface: text-input
|
||||
options:
|
||||
font: monospace
|
||||
iconRight: archive
|
||||
placeholder: Value set when archiving...
|
||||
locked: true
|
||||
sort: 12
|
||||
width: half
|
||||
- collection: directus_collections
|
||||
field: unarchive_value
|
||||
special:
|
||||
interface: text-input
|
||||
options:
|
||||
font: monospace
|
||||
iconRight: unarchive
|
||||
placeholder: Value set when unarchiving...
|
||||
locked: true
|
||||
sort: 13
|
||||
width: half
|
||||
- collection: directus_collections
|
||||
field: sort_divider
|
||||
special: alias
|
||||
interface: divider
|
||||
options:
|
||||
icon: sort
|
||||
title: Sort
|
||||
color: '#2F80ED'
|
||||
locked: true
|
||||
sort: 14
|
||||
width: full
|
||||
- collection: directus_collections
|
||||
field: sort_field
|
||||
special: ''
|
||||
interface: field
|
||||
options:
|
||||
collectionField: collection
|
||||
placeholder: Choose a field...
|
||||
typeAllowList:
|
||||
- float
|
||||
- decimal
|
||||
- integer
|
||||
allowNone: true
|
||||
locked: true
|
||||
sort: 15
|
||||
width: half
|
||||
|
||||
- collection: directus_roles
|
||||
field: id
|
||||
@@ -804,7 +914,7 @@ rows:
|
||||
width: half
|
||||
- collection: directus_users
|
||||
field: password
|
||||
special: hash
|
||||
special: hash, conceal
|
||||
interface: hash
|
||||
locked: true
|
||||
options:
|
||||
@@ -1239,7 +1349,9 @@ rows:
|
||||
width: half
|
||||
- collection: directus_users
|
||||
field: tfa_secret
|
||||
interface: tfa-setup
|
||||
locked: true
|
||||
special: conceal
|
||||
sort: 14
|
||||
width: half
|
||||
- collection: directus_users
|
||||
@@ -1408,7 +1520,7 @@ rows:
|
||||
options:
|
||||
icon: insert_drive_file
|
||||
title: File Naming
|
||||
color: '#B0BEC5'
|
||||
color: '#2F80ED'
|
||||
special: alias
|
||||
sort: 6
|
||||
width: full
|
||||
@@ -1537,7 +1649,7 @@ rows:
|
||||
options:
|
||||
icon: public
|
||||
title: Public Pages
|
||||
color: '#B0BEC5'
|
||||
color: '#2F80ED'
|
||||
special: alias
|
||||
sort: 5
|
||||
width: full
|
||||
@@ -1574,7 +1686,7 @@ rows:
|
||||
options:
|
||||
icon: security
|
||||
title: Security
|
||||
color: '#B0BEC5'
|
||||
color: '#2F80ED'
|
||||
special: alias
|
||||
sort: 9
|
||||
width: full
|
||||
@@ -1615,7 +1727,7 @@ rows:
|
||||
options:
|
||||
icon: storage
|
||||
title: Files & Thumbnails
|
||||
color: '#B0BEC5'
|
||||
color: '#2F80ED'
|
||||
special: alias
|
||||
sort: 13
|
||||
width: full
|
||||
@@ -1699,7 +1811,7 @@ rows:
|
||||
options:
|
||||
icon: pending
|
||||
title: Miscellaneous
|
||||
color: '#B0BEC5'
|
||||
color: '#2F80ED'
|
||||
special: alias
|
||||
sort: 16
|
||||
width: full
|
||||
@@ -1785,15 +1897,16 @@ rows:
|
||||
defaults:
|
||||
role: null
|
||||
collection: null
|
||||
operation: null
|
||||
action: null
|
||||
permissions: null
|
||||
validation: null
|
||||
presets: null
|
||||
fields: null
|
||||
limit: null
|
||||
|
||||
data:
|
||||
- collection: directus_settings
|
||||
operation: read
|
||||
action: read
|
||||
permissions: {}
|
||||
fields: "project_name,project_logo,project_color,public_foreground,public_background,public_note"
|
||||
|
||||
@@ -1827,7 +1940,7 @@ rows:
|
||||
view_type: cards
|
||||
view_options:
|
||||
cards:
|
||||
icon: person
|
||||
icon: account_circle
|
||||
title: '{{ first_name }} {{ last_name }}'
|
||||
subtitle: '{{ title }}'
|
||||
size: 4
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
export class BaseException extends Error {
|
||||
status: number;
|
||||
code: string;
|
||||
extensions: Record<string, any>;
|
||||
|
||||
constructor(message: string, status: number, code: string) {
|
||||
constructor(message: string, status: number, code: string, extensions?: Record<string, any>) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
|
||||
this.extensions = extensions || {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { BaseException } from './base';
|
||||
import { Permission } from '../types';
|
||||
|
||||
type Extensions = {
|
||||
field?: string;
|
||||
collection?: string;
|
||||
item?: string | number | (string | number)[];
|
||||
action?: Permission['action'];
|
||||
};
|
||||
|
||||
export class ForbiddenException extends BaseException {
|
||||
constructor(message = `You don't have permission to access this.`) {
|
||||
super(message, 403, 'NO_PERMISSION');
|
||||
constructor(message = `You don't have permission to access this.`, extensions?: Extensions) {
|
||||
super(message, 403, 'NO_PERMISSION', extensions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export * from './forbidden';
|
||||
export * from './hit-rate-limit';
|
||||
export * from './invalid-cache-key';
|
||||
export * from './invalid-credentials';
|
||||
export * from './invalid-otp';
|
||||
export * from './invalid-payload';
|
||||
export * from './invalid-query';
|
||||
export * from './item-limit';
|
||||
|
||||
7
api/src/exceptions/invalid-otp.ts
Normal file
7
api/src/exceptions/invalid-otp.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { BaseException } from './base';
|
||||
|
||||
export class InvalidOTPException extends BaseException {
|
||||
constructor(message = 'Invalid user OTP.') {
|
||||
super(message, 401, 'INVALID_OTP');
|
||||
}
|
||||
}
|
||||
@@ -65,3 +65,13 @@ export async function sendInviteMail(email: string, url: string) {
|
||||
const html = await liquidEngine.renderFile('user-invitation', { email, url, projectName });
|
||||
await transporter.sendMail({ from: env.EMAIL_FROM, to: email, html: html });
|
||||
}
|
||||
|
||||
export async function sendPasswordResetMail(email: string, url: string) {
|
||||
/**
|
||||
* @TODO pull this from directus_settings
|
||||
*/
|
||||
const projectName = 'directus';
|
||||
|
||||
const html = await liquidEngine.renderFile('password-reset', { email, url, projectName });
|
||||
await transporter.sendMail({ from: env.EMAIL_FROM, to: email, html: html });
|
||||
}
|
||||
|
||||
15
api/src/mail/templates/password-reset.liquid
Normal file
15
api/src/mail/templates/password-reset.liquid
Normal file
@@ -0,0 +1,15 @@
|
||||
{% layout "base" %}
|
||||
{% block content %}
|
||||
|
||||
<p>You requested to reset your password. Please click the link below to reset your password:</p>
|
||||
|
||||
<p><a href="{{ url }}">{{ url }}</a></p>
|
||||
|
||||
{% comment %}
|
||||
@TODO
|
||||
Make this white-labeled
|
||||
{% endcomment %}
|
||||
|
||||
<p>Love,<br>Directus</p>
|
||||
|
||||
{% endblock %}
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
<p>You have been invited to {{ projectName }}. Please click the link below to join:</p>
|
||||
|
||||
<p><a href="url">{{ url }}</a></p>
|
||||
<p><a href="{{ url }}">{{ url }}</a></p>
|
||||
|
||||
{% comment %}
|
||||
@TODO
|
||||
|
||||
@@ -4,41 +4,48 @@ import logger from '../logger';
|
||||
import env from '../env';
|
||||
|
||||
const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
|
||||
let payload: any;
|
||||
|
||||
if (err instanceof BaseException) {
|
||||
logger.debug(err);
|
||||
|
||||
res.status(err.status);
|
||||
|
||||
const payload: any = {
|
||||
error: {
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
},
|
||||
payload = {
|
||||
errors: [
|
||||
{
|
||||
message: err.message,
|
||||
extensions: {
|
||||
...err.extensions,
|
||||
code: err.code,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (env.NODE_ENV === 'development') {
|
||||
payload.error.stack = err.stack;
|
||||
}
|
||||
|
||||
return res.json(payload);
|
||||
} else {
|
||||
logger.error(err);
|
||||
|
||||
res.status(500);
|
||||
|
||||
const payload: any = {
|
||||
error: {
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: err.message,
|
||||
},
|
||||
payload = {
|
||||
errors: [
|
||||
{
|
||||
message: err.message,
|
||||
extensions: {
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (env.NODE_ENV === 'development') {
|
||||
payload.error.stack = err.stack;
|
||||
}
|
||||
|
||||
return res.json(payload);
|
||||
}
|
||||
|
||||
if (env.NODE_ENV === 'development') {
|
||||
payload.errors[0].extensions.exception = {
|
||||
stack: err.stack,
|
||||
};
|
||||
}
|
||||
|
||||
return res.json(payload);
|
||||
};
|
||||
|
||||
export default errorHandler;
|
||||
|
||||
@@ -3,7 +3,11 @@ import jwt from 'jsonwebtoken';
|
||||
import argon2 from 'argon2';
|
||||
import { nanoid } from 'nanoid';
|
||||
import ms from 'ms';
|
||||
import { InvalidCredentialsException, InvalidPayloadException } from '../exceptions';
|
||||
import {
|
||||
InvalidCredentialsException,
|
||||
InvalidPayloadException,
|
||||
InvalidOTPException,
|
||||
} from '../exceptions';
|
||||
import { Session, Accountability, AbstractServiceOptions, Action } from '../types';
|
||||
import Knex from 'knex';
|
||||
import ActivityService from '../services/activity';
|
||||
@@ -51,14 +55,14 @@ export default class AuthenticationService {
|
||||
}
|
||||
|
||||
if (user.tfa_secret && !otp) {
|
||||
throw new InvalidPayloadException(`"otp" is required`);
|
||||
throw new InvalidOTPException(`"otp" is required`);
|
||||
}
|
||||
|
||||
if (user.tfa_secret && otp) {
|
||||
const otpValid = await this.verifyOTP(user.id, otp);
|
||||
|
||||
if (otpValid === false) {
|
||||
throw new InvalidPayloadException(`"otp" is invalid`);
|
||||
throw new InvalidOTPException(`"otp" is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,29 +115,37 @@ export default class AuthenticationService {
|
||||
}
|
||||
|
||||
const record = await database
|
||||
.select<Session & { email: string }>('directus_sessions.*', 'directus_users.email')
|
||||
.select<Session & { email: string; id: string }>(
|
||||
'directus_sessions.*',
|
||||
'directus_users.email',
|
||||
'directus_users.id'
|
||||
)
|
||||
.from('directus_sessions')
|
||||
.where({ 'directus_sessions.token': refreshToken })
|
||||
.leftJoin('directus_users', 'directus_sessions.user', 'directus_users.id')
|
||||
.first();
|
||||
|
||||
/** @todo
|
||||
* Check if it's worth checking for ip address and/or user agent. We could make this a little
|
||||
* more secure by requiring the refresh token to be used from the same device / location as the
|
||||
* auth session was created in the first place
|
||||
*/
|
||||
|
||||
if (!record || !record.email || record.expires < new Date()) {
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
|
||||
await this.knex.delete().from('directus_sessions').where({ token: refreshToken });
|
||||
|
||||
return await this.authenticate({
|
||||
email: record.email,
|
||||
ip: record.ip,
|
||||
userAgent: record.user_agent,
|
||||
const accessToken = jwt.sign({ id: record.id }, env.SECRET as string, {
|
||||
expiresIn: env.ACCESS_TOKEN_TTL,
|
||||
});
|
||||
|
||||
const newRefreshToken = nanoid(64);
|
||||
const refreshTokenExpiration = new Date(Date.now() + ms(env.REFRESH_TOKEN_TTL as string));
|
||||
|
||||
await this.knex('directus_sessions')
|
||||
.update({ token: newRefreshToken, expires: refreshTokenExpiration })
|
||||
.where({ token: refreshToken });
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
expires: ms(env.ACCESS_TOKEN_TTL as string) / 1000,
|
||||
id: record.id,
|
||||
};
|
||||
}
|
||||
|
||||
async logout(refreshToken: string) {
|
||||
@@ -146,13 +158,21 @@ export default class AuthenticationService {
|
||||
}
|
||||
|
||||
async generateOTPAuthURL(pk: string, secret: string) {
|
||||
const user = await this.knex.select('first_name', 'last_name').from('directus_users').where({ id: pk }).first();
|
||||
const user = await this.knex
|
||||
.select('first_name', 'last_name')
|
||||
.from('directus_users')
|
||||
.where({ id: pk })
|
||||
.first();
|
||||
const name = `${user.first_name} ${user.last_name}`;
|
||||
return authenticator.keyuri(name, 'Directus', secret);
|
||||
}
|
||||
|
||||
async verifyOTP(pk: string, otp: string): Promise<boolean> {
|
||||
const user = await this.knex.select('tfa_secret').from('directus_users').where({ id: pk }).first();
|
||||
const user = await this.knex
|
||||
.select('tfa_secret')
|
||||
.from('directus_users')
|
||||
.where({ id: pk })
|
||||
.first();
|
||||
|
||||
if (!user.tfa_secret) {
|
||||
throw new InvalidPayloadException(`User "${pk}" doesn't have TFA enabled.`);
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
FieldAST,
|
||||
Query,
|
||||
Permission,
|
||||
Operation,
|
||||
PermissionsAction,
|
||||
Item,
|
||||
PrimaryKey,
|
||||
} from '../types';
|
||||
@@ -26,13 +26,13 @@ export default class AuthorizationService {
|
||||
this.accountability = options?.accountability || null;
|
||||
}
|
||||
|
||||
async processAST(ast: AST, operation: Operation = 'read'): Promise<AST> {
|
||||
async processAST(ast: AST, action: PermissionsAction = 'read'): Promise<AST> {
|
||||
const collectionsRequested = getCollectionsFromAST(ast);
|
||||
|
||||
const permissionsForCollections = await this.knex
|
||||
.select<Permission[]>('*')
|
||||
.from('directus_permissions')
|
||||
.where({ operation, role: this.accountability?.role })
|
||||
.where({ action, role: this.accountability?.role })
|
||||
.whereIn(
|
||||
'collection',
|
||||
collectionsRequested.map(({ collection }) => collection)
|
||||
@@ -165,18 +165,18 @@ export default class AuthorizationService {
|
||||
/**
|
||||
* Checks if the provided payload matches the configured permissions, and adds the presets to the payload.
|
||||
*/
|
||||
processValues(
|
||||
operation: Operation,
|
||||
validatePayload(
|
||||
action: PermissionsAction,
|
||||
collection: string,
|
||||
payloads: Partial<Item>[]
|
||||
): Promise<Partial<Item>[]>;
|
||||
processValues(
|
||||
operation: Operation,
|
||||
validatePayload(
|
||||
action: PermissionsAction,
|
||||
collection: string,
|
||||
payload: Partial<Item>
|
||||
): Promise<Partial<Item>>;
|
||||
async processValues(
|
||||
operation: Operation,
|
||||
async validatePayload(
|
||||
action: PermissionsAction,
|
||||
collection: string,
|
||||
payload: Partial<Item>[] | Partial<Item>
|
||||
): Promise<Partial<Item>[] | Partial<Item>> {
|
||||
@@ -185,7 +185,7 @@ export default class AuthorizationService {
|
||||
const permission = await this.knex
|
||||
.select<Permission>('*')
|
||||
.from('directus_permissions')
|
||||
.where({ operation, collection, role: this.accountability?.role || null })
|
||||
.where({ action, collection, role: this.accountability?.role || null })
|
||||
.first();
|
||||
|
||||
if (!permission) throw new ForbiddenException();
|
||||
@@ -200,7 +200,9 @@ export default class AuthorizationService {
|
||||
);
|
||||
|
||||
if (invalidKeys.length > 0) {
|
||||
throw new InvalidPayloadException(`Field "${invalidKeys[0]}" doesn't exist.`);
|
||||
throw new ForbiddenException(
|
||||
`You're not allowed to ${action} field "${invalidKeys[0]}" in collection "${collection}".`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -209,7 +211,7 @@ export default class AuthorizationService {
|
||||
|
||||
payloads = payloads.map((payload) => merge({}, preset, payload));
|
||||
|
||||
const schema = generateJoi(permission.permissions);
|
||||
const schema = generateJoi(permission.validation);
|
||||
|
||||
for (const payload of payloads) {
|
||||
const { error } = schema.validate(payload);
|
||||
@@ -226,7 +228,11 @@ export default class AuthorizationService {
|
||||
}
|
||||
}
|
||||
|
||||
async checkAccess(operation: Operation, collection: string, pk: PrimaryKey | PrimaryKey[]) {
|
||||
async checkAccess(
|
||||
action: PermissionsAction,
|
||||
collection: string,
|
||||
pk: PrimaryKey | PrimaryKey[]
|
||||
) {
|
||||
const itemsService = new ItemsService(collection, { accountability: this.accountability });
|
||||
|
||||
try {
|
||||
@@ -234,12 +240,18 @@ export default class AuthorizationService {
|
||||
fields: ['*'],
|
||||
};
|
||||
|
||||
const result = await itemsService.readByKey(pk as any, query, operation);
|
||||
const result = await itemsService.readByKey(pk as any, query, action);
|
||||
|
||||
if (!result) throw '';
|
||||
if (Array.isArray(pk) && result.length !== pk.length) throw '';
|
||||
} catch {
|
||||
throw new ForbiddenException(
|
||||
`You're not allowed to ${operation} item "${pk}" in collection "${collection}".`
|
||||
`You're not allowed to ${action} item "${pk}" in collection "${collection}".`,
|
||||
{
|
||||
collection,
|
||||
item: pk,
|
||||
action,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import database, { schemaInspector } from '../database';
|
||||
import { AbstractServiceOptions, Accountability, Collection } from '../types';
|
||||
import { AbstractServiceOptions, Accountability, Collection, Relation } from '../types';
|
||||
import Knex from 'knex';
|
||||
import { ForbiddenException, InvalidPayloadException } from '../exceptions';
|
||||
import SchemaInspector from 'knex-schema-inspector';
|
||||
@@ -101,7 +101,7 @@ export default class CollectionsService {
|
||||
const permissions = await this.knex
|
||||
.select('collection')
|
||||
.from('directus_permissions')
|
||||
.where({ operation: 'read' })
|
||||
.where({ action: 'read' })
|
||||
.where({ role: this.accountability.role })
|
||||
.whereIn('collection', collectionKeys);
|
||||
|
||||
@@ -150,7 +150,7 @@ export default class CollectionsService {
|
||||
const collectionsYouHavePermissionToRead: string[] = (
|
||||
await this.knex.select('collection').from('directus_permissions').where({
|
||||
role: this.accountability.role,
|
||||
operation: 'read',
|
||||
action: 'read',
|
||||
})
|
||||
).map(({ collection }) => collection);
|
||||
|
||||
@@ -228,6 +228,11 @@ export default class CollectionsService {
|
||||
throw new ForbiddenException('Only admins can perform this action.');
|
||||
}
|
||||
|
||||
const fieldsService = new FieldsService({
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
});
|
||||
|
||||
const tablesInDatabase = await schemaInspector.tables();
|
||||
|
||||
const collectionKeys = Array.isArray(collection) ? collection : [collection];
|
||||
@@ -242,11 +247,29 @@ export default class CollectionsService {
|
||||
await this.knex('directus_presets').delete().whereIn('collection', collectionKeys);
|
||||
await this.knex('directus_revisions').delete().whereIn('collection', collectionKeys);
|
||||
await this.knex('directus_activity').delete().whereIn('collection', collectionKeys);
|
||||
await this.knex('directus_permissions').delete().whereIn('collection', collectionKeys);
|
||||
|
||||
await this.knex('directus_relations')
|
||||
.delete()
|
||||
.whereIn('many_collection', collectionKeys)
|
||||
.orWhereIn('one_collection', collectionKeys);
|
||||
const relations = await this.knex
|
||||
.select<Relation[]>('*')
|
||||
.from('directus_relations')
|
||||
.where({ many_collection: collection })
|
||||
.orWhere({ one_collection: collection });
|
||||
|
||||
for (const relation of relations) {
|
||||
const isM2O = relation.many_collection === collection;
|
||||
|
||||
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);
|
||||
} else {
|
||||
await this.knex('directus_relations')
|
||||
.update({ one_field: null })
|
||||
.where({ one_collection: collection, field: relation.one_field });
|
||||
await fieldsService.deleteField(relation.many_collection, relation.many_field);
|
||||
}
|
||||
}
|
||||
|
||||
const collectionItemsService = new ItemsService('directus_collections', {
|
||||
knex: this.knex,
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import database, { schemaInspector } from '../database';
|
||||
import { Field } from '../types/field';
|
||||
import { uniq } from 'lodash';
|
||||
import { Accountability, AbstractServiceOptions, FieldMeta } from '../types';
|
||||
import { Accountability, AbstractServiceOptions, FieldMeta, Relation } from '../types';
|
||||
import ItemsService from '../services/items';
|
||||
import { ColumnBuilder } from 'knex';
|
||||
import getLocalType from '../utils/get-local-type';
|
||||
import { types } from '../types';
|
||||
import { FieldNotFoundException } from '../exceptions';
|
||||
import { FieldNotFoundException, ForbiddenException } from '../exceptions';
|
||||
import Knex, { CreateTableBuilder } from 'knex';
|
||||
import PayloadService from '../services/payload';
|
||||
import getDefaultValue from '../utils/get-default-value';
|
||||
@@ -34,24 +33,17 @@ export default class FieldsService {
|
||||
this.payloadService = new PayloadService('directus_fields');
|
||||
}
|
||||
|
||||
async fieldsInCollection(collection: string) {
|
||||
const [fields, columns] = await Promise.all([
|
||||
this.itemsService.readByQuery({ filter: { collection: { _eq: collection } } }),
|
||||
schemaInspector.columns(collection),
|
||||
]);
|
||||
|
||||
return uniq([...fields.map(({ field }) => field), ...columns.map(({ column }) => column)]);
|
||||
}
|
||||
|
||||
async readAll(collection?: string) {
|
||||
let fields: FieldMeta[];
|
||||
const nonAuthorizedItemsService = new ItemsService('directus_fields', { knex: this.knex });
|
||||
|
||||
if (collection) {
|
||||
fields = (await this.itemsService.readByQuery({
|
||||
fields = (await nonAuthorizedItemsService.readByQuery({
|
||||
filter: { collection: { _eq: collection } },
|
||||
limit: -1,
|
||||
})) as FieldMeta[];
|
||||
} else {
|
||||
fields = (await this.itemsService.readByQuery({})) as FieldMeta[];
|
||||
fields = (await nonAuthorizedItemsService.readByQuery({ limit: -1 })) as FieldMeta[];
|
||||
}
|
||||
|
||||
fields = (await this.payloadService.processValues('read', fields)) as FieldMeta[];
|
||||
@@ -106,11 +98,57 @@ export default class FieldsService {
|
||||
return data;
|
||||
});
|
||||
|
||||
return [...columnsWithSystem, ...aliasFieldsAsField];
|
||||
const result = [...columnsWithSystem, ...aliasFieldsAsField];
|
||||
|
||||
// Filter the result so we only return the fields you have read access to
|
||||
if (this.accountability && this.accountability.admin !== true) {
|
||||
const permissions = await this.knex
|
||||
.select('collection', 'fields')
|
||||
.from('directus_permissions')
|
||||
.where({ role: this.accountability.role, action: 'read' });
|
||||
const allowedFieldsInCollection: Record<string, string[]> = {};
|
||||
|
||||
permissions.forEach((permission) => {
|
||||
allowedFieldsInCollection[permission.collection] = (permission.fields || '').split(
|
||||
','
|
||||
);
|
||||
});
|
||||
|
||||
if (collection && allowedFieldsInCollection.hasOwnProperty(collection) === false) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return result.filter((field) => {
|
||||
if (allowedFieldsInCollection.hasOwnProperty(field.collection) === false)
|
||||
return false;
|
||||
const allowedFields = allowedFieldsInCollection[field.collection];
|
||||
if (allowedFields[0] === '*') return true;
|
||||
return allowedFields.includes(field.field);
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** @todo add accountability */
|
||||
async readOne(collection: string, field: string) {
|
||||
if (this.accountability && this.accountability.admin !== true) {
|
||||
const permissions = await this.knex
|
||||
.select('fields')
|
||||
.from('directus_permissions')
|
||||
.where({
|
||||
role: this.accountability.role,
|
||||
collection,
|
||||
action: 'read',
|
||||
})
|
||||
.first();
|
||||
|
||||
if (!permissions) throw new ForbiddenException();
|
||||
if (permissions.fields !== '*') {
|
||||
const allowedFields = (permissions.fields || '').split(',');
|
||||
if (allowedFields.includes(field) === false) throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
|
||||
let column;
|
||||
let fieldInfo = await this.knex
|
||||
.select('*')
|
||||
@@ -141,6 +179,10 @@ export default class FieldsService {
|
||||
field: Partial<Field> & { field: string; type: typeof types[number] },
|
||||
table?: CreateTableBuilder // allows collection creation to
|
||||
) {
|
||||
if (this.accountability && this.accountability.admin !== true) {
|
||||
throw new ForbiddenException('Only admins can perform this action.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @todo
|
||||
* Check if table / directus_fields row already exists
|
||||
@@ -167,7 +209,11 @@ export default class FieldsService {
|
||||
|
||||
/** @todo research how to make this happen in SQLite / Redshift */
|
||||
|
||||
async updateField(collection: string, field: RawField, accountability?: Accountability) {
|
||||
async updateField(collection: string, field: RawField) {
|
||||
if (this.accountability && this.accountability.admin !== true) {
|
||||
throw new ForbiddenException('Only admins can perform this action.');
|
||||
}
|
||||
|
||||
if (field.schema) {
|
||||
await this.knex.schema.alterTable(collection, (table) => {
|
||||
let column: ColumnBuilder;
|
||||
@@ -208,21 +254,54 @@ export default class FieldsService {
|
||||
.where({ collection, field: field.field })
|
||||
.first();
|
||||
if (!record) throw new FieldNotFoundException(collection, field.field);
|
||||
await database('directus_fields')
|
||||
.update(field.meta)
|
||||
.where({ collection, field: field.field });
|
||||
|
||||
await this.itemsService.update(
|
||||
{
|
||||
...field.meta,
|
||||
collection: collection,
|
||||
field: field.field,
|
||||
},
|
||||
record.id
|
||||
);
|
||||
}
|
||||
|
||||
return field.field;
|
||||
}
|
||||
|
||||
/** @todo save accountability */
|
||||
async deleteField(collection: string, field: string, accountability?: Accountability) {
|
||||
await database('directus_fields').delete().where({ collection, field });
|
||||
async deleteField(collection: string, field: string) {
|
||||
if (this.accountability && this.accountability.admin !== true) {
|
||||
throw new ForbiddenException('Only admins can perform this action.');
|
||||
}
|
||||
|
||||
await database.schema.table(collection, (table) => {
|
||||
table.dropColumn(field);
|
||||
});
|
||||
await this.knex('directus_fields').delete().where({ collection, field });
|
||||
|
||||
if (await schemaInspector.hasColumn(collection, field)) {
|
||||
await this.knex.schema.table(collection, (table) => {
|
||||
table.dropColumn(field);
|
||||
});
|
||||
}
|
||||
|
||||
const relations = await this.knex
|
||||
.select<Relation[]>('*')
|
||||
.from('directus_relations')
|
||||
.where({ many_collection: collection, many_field: field })
|
||||
.orWhere({ one_collection: collection, one_field: field });
|
||||
|
||||
for (const relation of relations) {
|
||||
const isM2O = relation.many_collection === collection && relation.many_field === field;
|
||||
|
||||
if (isM2O) {
|
||||
await this.knex('directus_relations')
|
||||
.delete()
|
||||
.where({ many_collection: collection, many_field: field });
|
||||
await this.deleteField(relation.one_collection, relation.one_field);
|
||||
} else {
|
||||
await this.knex('directus_relations')
|
||||
.update({ one_field: null })
|
||||
.where({ one_collection: collection, one_field: field });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public addColumnToTable(table: CreateTableBuilder, field: Field) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import getASTFromQuery from '../utils/get-ast-from-query';
|
||||
import {
|
||||
Action,
|
||||
Accountability,
|
||||
Operation,
|
||||
PermissionsAction,
|
||||
Item,
|
||||
Query,
|
||||
PrimaryKey,
|
||||
@@ -69,7 +69,7 @@ export default class ItemsService implements AbstractService {
|
||||
);
|
||||
|
||||
if (this.accountability && this.accountability.admin !== true) {
|
||||
payloads = await authorizationService.processValues(
|
||||
payloads = await authorizationService.validatePayload(
|
||||
'create',
|
||||
this.collection,
|
||||
payloads
|
||||
@@ -165,12 +165,12 @@ export default class ItemsService implements AbstractService {
|
||||
return records;
|
||||
}
|
||||
|
||||
readByKey(keys: PrimaryKey[], query?: Query, operation?: Operation): Promise<Item[]>;
|
||||
readByKey(key: PrimaryKey, query?: Query, operation?: Operation): Promise<Item>;
|
||||
readByKey(keys: PrimaryKey[], query?: Query, action?: PermissionsAction): Promise<Item[]>;
|
||||
readByKey(key: PrimaryKey, query?: Query, action?: PermissionsAction): Promise<Item>;
|
||||
async readByKey(
|
||||
key: PrimaryKey | PrimaryKey[],
|
||||
query: Query = {},
|
||||
operation: Operation = 'read'
|
||||
action: PermissionsAction = 'read'
|
||||
): Promise<Item | Item[]> {
|
||||
const schemaInspector = SchemaInspector(this.knex);
|
||||
const primaryKeyField = await schemaInspector.primary(this.collection);
|
||||
@@ -190,14 +190,14 @@ export default class ItemsService implements AbstractService {
|
||||
this.collection,
|
||||
queryWithFilter,
|
||||
this.accountability,
|
||||
operation
|
||||
action
|
||||
);
|
||||
|
||||
if (this.accountability && this.accountability.admin !== true) {
|
||||
const authorizationService = new AuthorizationService({
|
||||
accountability: this.accountability,
|
||||
});
|
||||
ast = await authorizationService.processAST(ast, operation);
|
||||
ast = await authorizationService.processAST(ast, action);
|
||||
}
|
||||
|
||||
const records = await runAST(ast);
|
||||
@@ -226,8 +226,8 @@ export default class ItemsService implements AbstractService {
|
||||
accountability: this.accountability,
|
||||
});
|
||||
await authorizationService.checkAccess('update', this.collection, keys);
|
||||
payload = await authorizationService.processValues(
|
||||
'validate',
|
||||
payload = await authorizationService.validatePayload(
|
||||
'update',
|
||||
this.collection,
|
||||
payload
|
||||
);
|
||||
@@ -246,7 +246,10 @@ export default class ItemsService implements AbstractService {
|
||||
columns.map(({ column }) => column)
|
||||
);
|
||||
|
||||
payloadWithoutAliases = await payloadService.processValues('update', payloadWithoutAliases);
|
||||
payloadWithoutAliases = await payloadService.processValues(
|
||||
'update',
|
||||
payloadWithoutAliases
|
||||
);
|
||||
|
||||
if (Object.keys(payloadWithoutAliases).length > 0) {
|
||||
await trx(this.collection)
|
||||
@@ -331,7 +334,7 @@ export default class ItemsService implements AbstractService {
|
||||
const schemaInspector = SchemaInspector(this.knex);
|
||||
const primaryKeyField = await schemaInspector.primary(this.collection);
|
||||
|
||||
if (this.accountability && this.accountability.admin !== false) {
|
||||
if (this.accountability && this.accountability.admin !== true) {
|
||||
const authorizationService = new AuthorizationService({
|
||||
accountability: this.accountability,
|
||||
});
|
||||
|
||||
@@ -14,10 +14,15 @@ import { URL } from 'url';
|
||||
import Knex from 'knex';
|
||||
import env from '../env';
|
||||
|
||||
type Operation = 'create' | 'read' | 'update';
|
||||
type Action = 'create' | 'read' | 'update';
|
||||
|
||||
type Transformers = {
|
||||
[type: string]: (operation: Operation, value: any, payload: Partial<Item>) => Promise<any>;
|
||||
[type: string]: (
|
||||
action: Action,
|
||||
value: any,
|
||||
payload: Partial<Item>,
|
||||
accountability: Accountability | null
|
||||
) => Promise<any>;
|
||||
};
|
||||
|
||||
export default class PayloadService {
|
||||
@@ -41,24 +46,24 @@ export default class PayloadService {
|
||||
* in order to work
|
||||
*/
|
||||
public transformers: Transformers = {
|
||||
async hash(operation, value) {
|
||||
async hash(action, value) {
|
||||
if (!value) return;
|
||||
|
||||
if (operation === 'create' || operation === 'update') {
|
||||
if (action === 'create' || action === 'update') {
|
||||
return await argon2.hash(String(value));
|
||||
}
|
||||
|
||||
return value;
|
||||
},
|
||||
async uuid(operation, value) {
|
||||
if (operation === 'create' && !value) {
|
||||
async uuid(action, value) {
|
||||
if (action === 'create' && !value) {
|
||||
return uuidv4();
|
||||
}
|
||||
|
||||
return value;
|
||||
},
|
||||
async 'file-links'(operation, value, payload) {
|
||||
if (operation === 'read' && payload && payload.storage && payload.filename_disk) {
|
||||
async 'file-links'(action, value, payload) {
|
||||
if (action === 'read' && payload && payload.storage && payload.filename_disk) {
|
||||
const publicKey = `STORAGE_${payload.storage.toUpperCase()}_PUBLIC_URL`;
|
||||
|
||||
return {
|
||||
@@ -70,15 +75,15 @@ export default class PayloadService {
|
||||
// This is an non-existing column, so there isn't any data to save
|
||||
return undefined;
|
||||
},
|
||||
async boolean(operation, value) {
|
||||
if (operation === 'read') {
|
||||
async boolean(action, value) {
|
||||
if (action === 'read') {
|
||||
return value === true || value === 1 || value === '1';
|
||||
}
|
||||
|
||||
return value;
|
||||
},
|
||||
async json(operation, value) {
|
||||
if (operation === 'read') {
|
||||
async json(action, value) {
|
||||
if (action === 'read') {
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
@@ -87,13 +92,43 @@ export default class PayloadService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
},
|
||||
async conceal(action, value) {
|
||||
if (action === 'read') return value ? '**********' : null;
|
||||
return value;
|
||||
},
|
||||
async 'user-created'(action, value, payload, accountability) {
|
||||
if (action === 'create') return accountability?.user || null;
|
||||
return value;
|
||||
},
|
||||
async 'user-updated'(action, value, payload, accountability) {
|
||||
if (action === 'update') return accountability?.user || null;
|
||||
return value;
|
||||
},
|
||||
async 'role-created'(action, value, payload, accountability) {
|
||||
if (action === 'create') return accountability?.role || null;
|
||||
return value;
|
||||
},
|
||||
async 'role-updated'(action, value, payload, accountability) {
|
||||
if (action === 'update') return accountability?.role || null;
|
||||
return value;
|
||||
},
|
||||
async 'date-created'(action, value) {
|
||||
if (action === 'create') return new Date();
|
||||
return value;
|
||||
},
|
||||
async 'date-updated'(action, value) {
|
||||
if (action === 'update') return new Date();
|
||||
return value;
|
||||
},
|
||||
};
|
||||
|
||||
processValues(operation: Operation, payloads: Partial<Item>[]): Promise<Partial<Item>[]>;
|
||||
processValues(operation: Operation, payload: Partial<Item>): Promise<Partial<Item>>;
|
||||
processValues(action: Action, payloads: Partial<Item>[]): Promise<Partial<Item>[]>;
|
||||
processValues(action: Action, payload: Partial<Item>): Promise<Partial<Item>>;
|
||||
async processValues(
|
||||
operation: Operation,
|
||||
action: Action,
|
||||
payload: Partial<Item> | Partial<Item>[]
|
||||
): Promise<Partial<Item> | Partial<Item>[]> {
|
||||
const processedPayload = (Array.isArray(payload) ? payload : [payload]) as Partial<Item>[];
|
||||
@@ -108,7 +143,7 @@ export default class PayloadService {
|
||||
.where({ collection: this.collection })
|
||||
.whereNotNull('special');
|
||||
|
||||
if (operation === 'read') {
|
||||
if (action === 'read') {
|
||||
specialFieldsQuery.whereIn('field', fieldsInPayload);
|
||||
}
|
||||
|
||||
@@ -118,14 +153,19 @@ export default class PayloadService {
|
||||
processedPayload.map(async (record: any) => {
|
||||
await Promise.all(
|
||||
specialFieldsInCollection.map(async (field) => {
|
||||
const newValue = await this.processField(field, record, operation);
|
||||
const newValue = await this.processField(
|
||||
field,
|
||||
record,
|
||||
action,
|
||||
this.accountability
|
||||
);
|
||||
if (newValue !== undefined) record[field.field] = newValue;
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
if (['create', 'update'].includes(operation)) {
|
||||
if (['create', 'update'].includes(action)) {
|
||||
processedPayload.forEach((record) => {
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (Array.isArray(value) || (typeof value === 'object' && value !== null)) {
|
||||
@@ -145,15 +185,22 @@ export default class PayloadService {
|
||||
async processField(
|
||||
field: Pick<FieldMeta, 'field' | 'special'>,
|
||||
payload: Partial<Item>,
|
||||
operation: Operation
|
||||
action: Action,
|
||||
accountability: Accountability | null
|
||||
) {
|
||||
if (!field.special) return payload[field.field];
|
||||
|
||||
if (this.transformers.hasOwnProperty(field.special)) {
|
||||
return await this.transformers[field.special](operation, payload[field.field], payload);
|
||||
const fieldSpecials = field.special.split(',').map((s) => s.trim());
|
||||
|
||||
let value = clone(payload[field.field]);
|
||||
|
||||
for (const special of fieldSpecials) {
|
||||
if (this.transformers.hasOwnProperty(special)) {
|
||||
value = await this.transformers[special](action, value, payload, accountability);
|
||||
}
|
||||
}
|
||||
|
||||
return payload[field.field];
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -253,12 +300,19 @@ export default class PayloadService {
|
||||
);
|
||||
|
||||
const toBeUpdated = relatedRecords.filter(
|
||||
(record) => record.hasOwnProperty(relation.many_primary) === true && record.hasOwnProperty('$delete') === false
|
||||
(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]);
|
||||
.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);
|
||||
@@ -267,3 +321,4 @@ export default class PayloadService {
|
||||
}
|
||||
}
|
||||
}
|
||||
0;
|
||||
|
||||
46
api/src/services/server.ts
Normal file
46
api/src/services/server.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { AbstractServiceOptions, Accountability } from '../types';
|
||||
import Knex from 'knex';
|
||||
import database from '../database';
|
||||
import os from 'os';
|
||||
import { ForbiddenException } from '../exceptions';
|
||||
// @ts-ignore
|
||||
import { version } from '../../package.json';
|
||||
import macosRelease from 'macos-release';
|
||||
|
||||
export default class ServerService {
|
||||
knex: Knex;
|
||||
accountability: Accountability | null;
|
||||
|
||||
constructor(options?: AbstractServiceOptions) {
|
||||
this.knex = options?.knex || database;
|
||||
this.accountability = options?.accountability || null;
|
||||
}
|
||||
|
||||
serverInfo() {
|
||||
if (this.accountability?.admin !== true) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
const osType = os.type() === 'Darwin' ? 'macOS' : os.type();
|
||||
const osVersion =
|
||||
osType === 'macOS'
|
||||
? `${macosRelease().name} (${macosRelease().version})`
|
||||
: os.release();
|
||||
|
||||
return {
|
||||
directus: {
|
||||
version,
|
||||
},
|
||||
node: {
|
||||
version: process.versions.node,
|
||||
uptime: Math.round(process.uptime()),
|
||||
},
|
||||
os: {
|
||||
type: osType,
|
||||
version: osVersion,
|
||||
uptime: Math.round(os.uptime()),
|
||||
totalmem: os.totalmem(),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import AuthService from './authentication';
|
||||
import ItemsService from './items';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { sendInviteMail } from '../mail';
|
||||
import { sendInviteMail, sendPasswordResetMail } from '../mail';
|
||||
import database from '../database';
|
||||
import argon2 from 'argon2';
|
||||
import { InvalidPayloadException } from '../exceptions';
|
||||
import { Accountability, Query, Item, AbstractServiceOptions } from '../types';
|
||||
import { InvalidPayloadException, ForbiddenException } from '../exceptions';
|
||||
import { Accountability, PrimaryKey, Item, AbstractServiceOptions } from '../types';
|
||||
import Knex from 'knex';
|
||||
import env from '../env';
|
||||
|
||||
@@ -22,10 +22,33 @@ export default class UsersService extends ItemsService {
|
||||
this.service = new ItemsService('directus_users', options);
|
||||
}
|
||||
|
||||
update(data: Partial<Item>, keys: PrimaryKey[]): Promise<PrimaryKey[]>;
|
||||
update(data: Partial<Item>, key: PrimaryKey): Promise<PrimaryKey>;
|
||||
update(data: Partial<Item>[]): Promise<PrimaryKey[]>;
|
||||
async update(
|
||||
data: Partial<Item> | Partial<Item>[],
|
||||
key?: PrimaryKey | PrimaryKey[]
|
||||
): Promise<PrimaryKey | PrimaryKey[]> {
|
||||
/**
|
||||
* @NOTE
|
||||
* 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];
|
||||
|
||||
for (const payload of payloads) {
|
||||
if (payload.hasOwnProperty('tfa_secret')) {
|
||||
throw new InvalidPayloadException(`You can't change the tfa_secret manually.`);
|
||||
}
|
||||
}
|
||||
|
||||
return this.service.update(data, key as any);
|
||||
}
|
||||
|
||||
async inviteUser(email: string, role: string) {
|
||||
await this.service.create({ email, role, status: 'invited' });
|
||||
|
||||
const payload = { email };
|
||||
const payload = { email, scope: 'invite' };
|
||||
const token = jwt.sign(payload, env.SECRET as string, { expiresIn: '7d' });
|
||||
const acceptURL = env.PUBLIC_URL + '/admin/accept-invite?token=' + token;
|
||||
|
||||
@@ -33,9 +56,14 @@ export default class UsersService extends ItemsService {
|
||||
}
|
||||
|
||||
async acceptInvite(token: string, password: string) {
|
||||
const { email } = jwt.verify(token, env.SECRET as string) as { email: string };
|
||||
const { email, scope } = jwt.verify(token, env.SECRET as string) as {
|
||||
email: string;
|
||||
scope: string;
|
||||
};
|
||||
|
||||
const user = await database
|
||||
if (scope !== 'invite') throw new ForbiddenException();
|
||||
|
||||
const user = await this.knex
|
||||
.select('id', 'status')
|
||||
.from('directus_users')
|
||||
.where({ email })
|
||||
@@ -47,13 +75,53 @@ export default class UsersService extends ItemsService {
|
||||
|
||||
const passwordHashed = await argon2.hash(password);
|
||||
|
||||
await database('directus_users')
|
||||
await this.knex('directus_users')
|
||||
.update({ password: passwordHashed, status: 'active' })
|
||||
.where({ id: user.id });
|
||||
}
|
||||
|
||||
async requestPasswordReset(email: string) {
|
||||
const user = await this.knex.select('id').from('directus_users').where({ email }).first();
|
||||
if (!user) throw new ForbiddenException();
|
||||
|
||||
const payload = { email, scope: 'password-reset' };
|
||||
const token = jwt.sign(payload, env.SECRET as string, { expiresIn: '7d' });
|
||||
const acceptURL = env.PUBLIC_URL + '/admin/reset-password?token=' + token;
|
||||
|
||||
await sendPasswordResetMail(email, acceptURL);
|
||||
}
|
||||
|
||||
async resetPassword(token: string, password: string) {
|
||||
const { email, scope } = jwt.verify(token, env.SECRET as string) as {
|
||||
email: string;
|
||||
scope: string;
|
||||
};
|
||||
|
||||
if (scope !== 'password-reset') throw new ForbiddenException();
|
||||
|
||||
const user = await this.knex
|
||||
.select('id', 'status')
|
||||
.from('directus_users')
|
||||
.where({ email })
|
||||
.first();
|
||||
|
||||
if (!user || user.status !== 'active') {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
const passwordHashed = await argon2.hash(password);
|
||||
|
||||
await this.knex('directus_users')
|
||||
.update({ password: passwordHashed, status: 'active' })
|
||||
.where({ id: user.id });
|
||||
}
|
||||
|
||||
async enableTFA(pk: string) {
|
||||
const user = await this.knex.select('tfa_secret').from('directus_users').where({ id: pk }).first();
|
||||
const user = await this.knex
|
||||
.select('tfa_secret')
|
||||
.from('directus_users')
|
||||
.where({ id: pk })
|
||||
.first();
|
||||
|
||||
if (user?.tfa_secret !== null) {
|
||||
throw new InvalidPayloadException('TFA Secret is already set for this user');
|
||||
@@ -64,7 +132,10 @@ export default class UsersService extends ItemsService {
|
||||
|
||||
await this.knex('directus_users').update({ tfa_secret: secret }).where({ id: pk });
|
||||
|
||||
return await authService.generateOTPAuthURL(pk, secret);
|
||||
return {
|
||||
secret,
|
||||
url: await authService.generateOTPAuthURL(pk, secret),
|
||||
};
|
||||
}
|
||||
|
||||
async disableTFA(pk: string) {
|
||||
|
||||
115
api/src/services/utils.ts
Normal file
115
api/src/services/utils.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { AbstractServiceOptions, Accountability, PrimaryKey } from '../types';
|
||||
import database from '../database';
|
||||
import Knex from 'knex';
|
||||
import { InvalidPayloadException, ForbiddenException } from '../exceptions';
|
||||
import SchemaInspector from 'knex-schema-inspector';
|
||||
|
||||
export default class UtilsService {
|
||||
knex: Knex;
|
||||
accountability: Accountability | null;
|
||||
|
||||
constructor(options?: AbstractServiceOptions) {
|
||||
this.knex = options?.knex || database;
|
||||
this.accountability = options?.accountability || null;
|
||||
}
|
||||
|
||||
async sort(collection: string, { item, to }: { item: PrimaryKey; to: PrimaryKey }) {
|
||||
const schemaInspector = SchemaInspector(this.knex);
|
||||
|
||||
const sortFieldResponse = await this.knex
|
||||
.select('sort_field')
|
||||
.from('directus_collections')
|
||||
.where({ collection })
|
||||
.first();
|
||||
|
||||
const sortField = sortFieldResponse?.sort_field;
|
||||
|
||||
if (!sortField) {
|
||||
throw new InvalidPayloadException(
|
||||
`Collection "${collection}" doesn't have a sort field.`
|
||||
);
|
||||
}
|
||||
|
||||
if (this.accountability?.admin !== true) {
|
||||
const permissions = await this.knex
|
||||
.select('fields')
|
||||
.from('directus_permissions')
|
||||
.where({
|
||||
collection,
|
||||
action: 'update',
|
||||
role: this.accountability?.role || null,
|
||||
})
|
||||
.first();
|
||||
|
||||
if (!permissions) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
const allowedFields = permissions.fields.split(',');
|
||||
|
||||
if (allowedFields[0] !== '*' && allowedFields.includes(sortField) === false) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
|
||||
const primaryKeyField = await schemaInspector.primary(collection);
|
||||
|
||||
// Make sure all rows have a sort value
|
||||
const countResponse = await this.knex
|
||||
.count('* as count')
|
||||
.from(collection)
|
||||
.whereNull(sortField)
|
||||
.first();
|
||||
|
||||
if (countResponse?.count && +countResponse.count !== 0) {
|
||||
const lastSortValueResponse = await this.knex.max(sortField).from(collection).first();
|
||||
|
||||
const rowsWithoutSortValue = await this.knex
|
||||
.select(primaryKeyField, sortField)
|
||||
.from(collection)
|
||||
.whereNull(sortField);
|
||||
|
||||
let lastSortValue = lastSortValueResponse ? Object.values(lastSortValueResponse)[0] : 0;
|
||||
|
||||
for (const row of rowsWithoutSortValue) {
|
||||
lastSortValue++;
|
||||
await this.knex(collection)
|
||||
.update({ [sortField]: lastSortValue })
|
||||
.where({ [primaryKeyField]: row[primaryKeyField] });
|
||||
}
|
||||
}
|
||||
|
||||
const targetSortValueResponse = await this.knex
|
||||
.select(sortField)
|
||||
.from(collection)
|
||||
.where({ [primaryKeyField]: to })
|
||||
.first();
|
||||
const targetSortValue = targetSortValueResponse[sortField];
|
||||
|
||||
const sourceSortValueResponse = await this.knex
|
||||
.select(sortField)
|
||||
.from(collection)
|
||||
.where({ [primaryKeyField]: item })
|
||||
.first();
|
||||
const sourceSortValue = sourceSortValueResponse[sortField];
|
||||
|
||||
// Set the target item to the new sort value
|
||||
await this.knex(collection)
|
||||
.update({ [sortField]: targetSortValue })
|
||||
.where({ [primaryKeyField]: item });
|
||||
|
||||
if (sourceSortValue < targetSortValue) {
|
||||
await this.knex(collection)
|
||||
.decrement(sortField, 1)
|
||||
.where(sortField, '>', sourceSortValue)
|
||||
.andWhere(sortField, '<=', targetSortValue)
|
||||
.andWhereNot({ [primaryKeyField]: item });
|
||||
} else {
|
||||
await this.knex(collection)
|
||||
.increment(sortField, 1)
|
||||
.where(sortField, '>=', targetSortValue)
|
||||
.andWhere(sortField, '<=', sourceSortValue)
|
||||
.andWhereNot({ [primaryKeyField]: item });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,12 @@
|
||||
export type Operation =
|
||||
| 'create'
|
||||
| 'read'
|
||||
| 'update'
|
||||
| 'validate'
|
||||
| 'delete'
|
||||
| 'comment'
|
||||
| 'explain';
|
||||
export type PermissionsAction = 'create' | 'read' | 'update' | 'delete' | 'comment' | 'explain';
|
||||
|
||||
export type Permission = {
|
||||
id: number;
|
||||
role: string | null;
|
||||
collection: string;
|
||||
operation: Operation;
|
||||
action: PermissionsAction;
|
||||
permissions: Record<string, any>;
|
||||
validation: Record<string, any>;
|
||||
limit: number | null;
|
||||
presets: Record<string, any> | null;
|
||||
fields: string | null;
|
||||
|
||||
@@ -2,7 +2,7 @@ import Knex from 'knex';
|
||||
import { Accountability } from './accountability';
|
||||
import { Item, PrimaryKey } from './items';
|
||||
import { Query } from './query';
|
||||
import { Operation } from './permissions';
|
||||
import { PermissionsAction } from './permissions';
|
||||
|
||||
export type AbstractServiceOptions = {
|
||||
knex?: Knex;
|
||||
@@ -18,8 +18,8 @@ export interface AbstractService {
|
||||
|
||||
readByQuery(query: Query): Promise<Item[]>;
|
||||
|
||||
readByKey(keys: PrimaryKey[], query: Query, operation: Operation): Promise<Item[]>;
|
||||
readByKey(key: PrimaryKey, query: Query, operation: Operation): Promise<Item>;
|
||||
readByKey(keys: PrimaryKey[], query: Query, action: PermissionsAction): Promise<Item[]>;
|
||||
readByKey(key: PrimaryKey, query: Query, action: PermissionsAction): Promise<Item>;
|
||||
|
||||
update(data: Partial<Item>, keys: PrimaryKey[]): Promise<PrimaryKey[]>;
|
||||
update(data: Partial<Item>, key: PrimaryKey): Promise<PrimaryKey>;
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { Filter } from '../types';
|
||||
import Joi, { AnySchema } from 'joi';
|
||||
|
||||
export default function generateJoi(filter: Filter) {
|
||||
/**
|
||||
* @TODO
|
||||
* This is copy pasted between app and api. Make this a reusable module.
|
||||
*/
|
||||
|
||||
export default function generateJoi(filter: Filter | null) {
|
||||
filter = filter || {};
|
||||
|
||||
const schema: Record<string, AnySchema> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(filter)) {
|
||||
@@ -10,10 +17,6 @@ export default function generateJoi(filter: Filter) {
|
||||
if (isField) {
|
||||
const operator = Object.keys(value)[0];
|
||||
|
||||
/** @TODO
|
||||
* - Extend with all operators
|
||||
*/
|
||||
|
||||
if (operator === '_eq') {
|
||||
schema[key] = Joi.any().equal(Object.values(value)[0]);
|
||||
}
|
||||
@@ -22,6 +25,30 @@ export default function generateJoi(filter: Filter) {
|
||||
schema[key] = Joi.any().not(Object.values(value)[0]);
|
||||
}
|
||||
|
||||
if (operator === '_contains') {
|
||||
schema[key] = Joi.string().custom((value, helpers) => {
|
||||
const contains = value.includes(Object.values(value)[0]);
|
||||
|
||||
if (contains === false) {
|
||||
return helpers.error(`"${key}" must include "${Object.values(value)[0]}"`);
|
||||
}
|
||||
|
||||
return value;
|
||||
});
|
||||
}
|
||||
|
||||
if (operator === '_ncontains') {
|
||||
schema[key] = Joi.string().custom((value, helpers) => {
|
||||
const contains = value.includes(Object.values(value)[0]);
|
||||
|
||||
if (contains === true) {
|
||||
return helpers.error(`"${key}" can't include "${Object.values(value)[0]}"`);
|
||||
}
|
||||
|
||||
return value;
|
||||
});
|
||||
}
|
||||
|
||||
if (operator === '_in') {
|
||||
schema[key] = Joi.any().equal(...(Object.values(value)[0] as (string | number)[]));
|
||||
}
|
||||
@@ -34,9 +61,43 @@ export default function generateJoi(filter: Filter) {
|
||||
schema[key] = Joi.number().greater(Number(Object.values(value)[0]));
|
||||
}
|
||||
|
||||
if (operator === '_gte') {
|
||||
schema[key] = Joi.number().min(Number(Object.values(value)[0]));
|
||||
}
|
||||
|
||||
if (operator === '_lt') {
|
||||
schema[key] = Joi.number().less(Number(Object.values(value)[0]));
|
||||
}
|
||||
|
||||
if (operator === '_lte') {
|
||||
schema[key] = Joi.number().max(Number(Object.values(value)[0]));
|
||||
}
|
||||
|
||||
if (operator === '_null') {
|
||||
schema[key] = Joi.any().valid(null);
|
||||
}
|
||||
|
||||
if (operator === '_nnull') {
|
||||
schema[key] = Joi.any().invalid(null);
|
||||
}
|
||||
|
||||
if (operator === '_empty') {
|
||||
schema[key] = Joi.any().valid('');
|
||||
}
|
||||
|
||||
if (operator === '_nempty') {
|
||||
schema[key] = Joi.any().invalid('');
|
||||
}
|
||||
|
||||
if (operator === '_between') {
|
||||
const values = Object.values(value)[0] as number[];
|
||||
schema[key] = Joi.number().greater(values[0]).less(values[1]);
|
||||
}
|
||||
|
||||
if (operator === '_nbetween') {
|
||||
const values = Object.values(value)[0] as number[];
|
||||
schema[key] = Joi.number().less(values[0]).greater(values[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
FieldAST,
|
||||
Query,
|
||||
Relation,
|
||||
Operation,
|
||||
PermissionsAction,
|
||||
Accountability,
|
||||
} from '../types';
|
||||
import database from '../database';
|
||||
@@ -17,7 +17,7 @@ export default async function getASTFromQuery(
|
||||
collection: string,
|
||||
query: Query,
|
||||
accountability?: Accountability | null,
|
||||
operation?: Operation
|
||||
action?: PermissionsAction
|
||||
): Promise<AST> {
|
||||
/**
|
||||
* we might not need al this info at all times, but it's easier to fetch it all once, than trying to fetch it for every
|
||||
@@ -30,7 +30,7 @@ export default async function getASTFromQuery(
|
||||
? await database
|
||||
.select<{ collection: string; fields: string }[]>('collection', 'fields')
|
||||
.from('directus_permissions')
|
||||
.where({ role: accountability.role, operation: operation || 'read' })
|
||||
.where({ role: accountability.role, action: action || 'read' })
|
||||
: null;
|
||||
|
||||
const ast: AST = {
|
||||
|
||||
@@ -56,7 +56,7 @@ const localTypeMap: Record<string, { type: typeof types[number]; useTimezone?: b
|
||||
|
||||
// Postgres
|
||||
json: { type: 'json' },
|
||||
uuid: { type: 'string' },
|
||||
uuid: { type: 'uuid' },
|
||||
int2: { type: 'integer' },
|
||||
serial4: { type: 'integer' },
|
||||
int4: { type: 'integer' },
|
||||
|
||||
@@ -11,6 +11,6 @@
|
||||
"lib": [
|
||||
"es2019"
|
||||
],
|
||||
"skipLibCheck": true
|
||||
"skipLibCheck": true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,7 @@
|
||||
"custom-properties",
|
||||
"declarations",
|
||||
"at-variables",
|
||||
"rules",
|
||||
"at-rules"
|
||||
"rules"
|
||||
],
|
||||
"at-rule-no-unknown": null,
|
||||
"scss/at-rule-no-unknown": true,
|
||||
|
||||
24361
app/package-lock.json
generated
24361
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-alpha.20",
|
||||
"version": "9.0.0-alpha.27",
|
||||
"private": false,
|
||||
"description": "Directus is an Open-Source Headless CMS & API for Managing Custom Databases",
|
||||
"author": "Rijk van Zanten <rijk@rngr.org>",
|
||||
@@ -50,6 +50,7 @@
|
||||
"date-fns": "^2.14.0",
|
||||
"diff": "^4.0.2",
|
||||
"htmlhint": "^0.14.1",
|
||||
"joi": "^17.2.1",
|
||||
"js-yaml": "^3.14.0",
|
||||
"jshint": "^2.11.1",
|
||||
"jsonlint": "^1.6.3",
|
||||
@@ -63,6 +64,8 @@
|
||||
"nanoid": "^3.1.10",
|
||||
"pinia": "^0.0.7",
|
||||
"portal-vue": "^2.1.7",
|
||||
"pretty-ms": "^7.0.0",
|
||||
"qrcode": "^1.4.4",
|
||||
"resize-observer": "^1.0.0",
|
||||
"semver": "^7.3.2",
|
||||
"stylelint-config-prettier": "^8.0.2",
|
||||
@@ -90,6 +93,7 @@
|
||||
"@types/jest": "^26.0.5",
|
||||
"@types/marked": "^1.1.0",
|
||||
"@types/mime-types": "^2.1.0",
|
||||
"@types/qrcode": "^1.3.5",
|
||||
"@types/semver": "^7.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "^2.34.0",
|
||||
"@typescript-eslint/parser": "^2.34.0",
|
||||
@@ -155,5 +159,5 @@
|
||||
"stylelint --fix"
|
||||
]
|
||||
},
|
||||
"gitHead": "9092740d708c1e9ed1575b0932a01f531d224f77"
|
||||
"gitHead": "c22537b6d0dd7dc6cc651a9200a68f6b060353d4"
|
||||
}
|
||||
|
||||
@@ -51,15 +51,26 @@ export const onError = async (error: RequestError) => {
|
||||
/* istanbul ignore next */
|
||||
const status = error.response?.status;
|
||||
/* istanbul ignore next */
|
||||
const code = error.response?.data?.error?.code;
|
||||
const code = error.response?.data?.errors?.[0]?.extensions?.code;
|
||||
|
||||
if (status === 401 && code === 'INVALID_CREDENTIALS' && error.request.responseURL.includes('refresh') === false) {
|
||||
if (
|
||||
status === 401 &&
|
||||
code === 'INVALID_CREDENTIALS' &&
|
||||
error.request.responseURL.includes('refresh') === false &&
|
||||
error.request.responseURL.includes('login') === false
|
||||
) {
|
||||
try {
|
||||
await refresh();
|
||||
const newToken = await refresh();
|
||||
|
||||
/** @todo retry failed request after successful refresh */
|
||||
return api.request({
|
||||
...error.config,
|
||||
headers: {
|
||||
Authorization: `Bearer ${newToken}`,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
logout({ reason: LogoutReason.ERROR_SESSION_EXPIRED });
|
||||
return Promise.reject();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,15 @@
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<router-view v-if="!hydrating && appAccess" />
|
||||
<v-info v-if="error" type="danger" :title="$t('unexpected_error')" icon="error" center>
|
||||
{{ $t('unexpected_error_copy') }}
|
||||
|
||||
<template #append>
|
||||
<v-error :error="error" />
|
||||
</template>
|
||||
</v-info>
|
||||
|
||||
<router-view v-else-if="!hydrating && appAccess" />
|
||||
|
||||
<v-info v-else-if="appAccess === false" center :title="$t('no_app_access')" type="danger" icon="block">
|
||||
{{ $t('no_app_access_copy') }}
|
||||
@@ -79,10 +87,12 @@ export default defineComponent({
|
||||
|
||||
const appAccess = computed(() => {
|
||||
if (!userStore.state.currentUser) return true;
|
||||
return userStore.state.currentUser?.role?.app_access;
|
||||
return userStore.state.currentUser?.role?.app_access || false;
|
||||
});
|
||||
|
||||
return { hydrating, brandStyle, appAccess };
|
||||
const error = computed(() => appStore.state.error);
|
||||
|
||||
return { hydrating, brandStyle, appAccess, error };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<path fill="#ECEFF1" d="M0 0h64v64H0z"/>
|
||||
<path d="M32 32c4.42 0 8-3.58 8-8s-3.58-8-8-8-8 3.58-8 8 3.58 8 8 8zm0 4c-5.34 0-16 2.68-16 8v4h32v-4c0-5.32-10.66-8-16-8z" fill="#B0BEC5" fill-rule="nonzero"/>
|
||||
<path d="M8 8h48v48H8z"/>
|
||||
<path d="M32 12a20 20 0 100 40 20 20 0 000-40zm-9.86 32.56C23 42.76 28.24 41 32 41s9.02 1.76 9.86 3.56a15.8 15.8 0 01-19.72 0zm22.58-2.9C41.86 38.18 34.92 37 32 37s-9.86 1.18-12.72 4.66A16.02 16.02 0 1148 32a15.9 15.9 0 01-3.28 9.66zM32 20c-3.88 0-7 3.12-7 7s3.12 7 7 7 7-3.12 7-7-3.12-7-7-7zm0 10a3 3 0 110-6 3 3 0 010 6z" fill="#B0BEC5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 392 B After Width: | Height: | Size: 539 B |
@@ -46,6 +46,8 @@ export async function refresh({ navigate }: LogoutOptions = { navigate: true })
|
||||
// logged in without any noticable hickups or delays
|
||||
setTimeout(() => refresh(), response.data.data.expires * 1000 - 10 * 1000);
|
||||
appStore.state.authenticated = true;
|
||||
|
||||
return accessToken;
|
||||
} catch (error) {
|
||||
await logout({ navigate, reason: LogoutReason.ERROR_SESSION_EXPIRED });
|
||||
}
|
||||
|
||||
@@ -46,9 +46,12 @@ body {
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/mixins/breakpoint';
|
||||
|
||||
.v-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
|
||||
.section {
|
||||
display: contents;
|
||||
@@ -89,5 +92,9 @@ body {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include breakpoint(small) {
|
||||
font-size: inherit;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -122,6 +122,7 @@ export default defineComponent({
|
||||
const sizeClass = useSizeClass(props);
|
||||
|
||||
const component = computed<'a' | 'router-link' | 'button'>(() => {
|
||||
if (props.disabled) return 'button';
|
||||
if (notEmpty(props.href)) return 'a';
|
||||
if (notEmpty(props.to)) return 'router-link';
|
||||
return 'button';
|
||||
@@ -208,6 +209,12 @@ body {
|
||||
border-color: var(--v-button-background-color-hover);
|
||||
}
|
||||
|
||||
&.activated {
|
||||
color: var(--v-button-color);
|
||||
background-color: var(--v-button-background-color);
|
||||
border-color: var(--v-button-background-color);
|
||||
}
|
||||
|
||||
&.align-left {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
@@ -240,8 +247,9 @@ body {
|
||||
|
||||
background-color: transparent;
|
||||
|
||||
&:hover {
|
||||
&:not(.activated):hover {
|
||||
color: var(--v-button-background-color-hover);
|
||||
background-color: transparent;
|
||||
border-color: var(--v-button-background-color-hover);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,5 +8,7 @@
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
padding: var(--v-card-padding);
|
||||
margin-top: 12px;
|
||||
line-height: 1.6em;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -107,7 +107,7 @@ export const htmlLabel = () => ({
|
||||
},
|
||||
template: `
|
||||
<v-checkbox v-model="checked" @change="onChange">
|
||||
<template #label>
|
||||
<template>
|
||||
Any <i>custom</i> markup in here
|
||||
</template>
|
||||
</v-checkbox>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<div class="prepend" v-if="$scopedSlots.prepend"><slot name="prepend" /></div>
|
||||
<v-icon class="checkbox" :name="icon" @click.stop="toggleInput" />
|
||||
<span class="label type-text">
|
||||
<slot name="label" v-if="customValue === false">{{ label }}</slot>
|
||||
<slot v-if="customValue === false">{{ label }}</slot>
|
||||
<input @click.stop class="custom-input" v-else v-model="_value" />
|
||||
</span>
|
||||
<div class="append" v-if="$scopedSlots.append"><slot name="append" /></div>
|
||||
@@ -132,6 +132,7 @@ body {
|
||||
appearance: none;
|
||||
|
||||
.label:not(:empty) {
|
||||
flex-grow: 1;
|
||||
margin-left: 8px;
|
||||
transition: color var(--fast) var(--transition);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="v-error">
|
||||
<output>{{ code }}</output>
|
||||
<output>Code: {{ code }}</output>
|
||||
<v-icon
|
||||
v-tooltip="$t('copy_details')"
|
||||
v-if="showCopy"
|
||||
@@ -24,7 +24,7 @@ export default defineComponent({
|
||||
},
|
||||
setup(props) {
|
||||
const code = computed(() => {
|
||||
return props.error?.response?.data?.error?.code || 'UNKNOWN';
|
||||
return props.error?.response?.data?.errors?.[0]?.extensions?.code || 'UNKNOWN';
|
||||
});
|
||||
|
||||
const copied = ref(false);
|
||||
@@ -34,7 +34,8 @@ export default defineComponent({
|
||||
return { code, copyError, showCopy, copied };
|
||||
|
||||
async function copyError() {
|
||||
await navigator.clipboard.writeText(JSON.stringify(props.error, null, 2));
|
||||
const error = props.error?.response?.data || props.error;
|
||||
await navigator.clipboard.writeText(JSON.stringify(error, null, 2));
|
||||
copied.value = true;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -10,11 +10,13 @@
|
||||
@keydown="onKeyDown"
|
||||
@input="onInput"
|
||||
@click="onClick"
|
||||
/>
|
||||
>
|
||||
<span class="text" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #append>
|
||||
<v-icon name="add_box" @click="toggle" />
|
||||
<v-icon name="add_box" outline @click="toggle" />
|
||||
</template>
|
||||
</v-input>
|
||||
</template>
|
||||
@@ -26,7 +28,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, toRefs, ref, watch, onMounted } from '@vue/composition-api';
|
||||
import { defineComponent, toRefs, ref, watch, onMounted, onUnmounted } from '@vue/composition-api';
|
||||
import FieldListItem from './field-list-item.vue';
|
||||
import { useFieldsStore } from '@/stores';
|
||||
import { Field } from '@/types/';
|
||||
@@ -58,15 +60,26 @@ export default defineComponent({
|
||||
const { tree } = useFieldTree(collection);
|
||||
|
||||
watch(() => props.value, setContent, { immediate: true });
|
||||
onMounted(setContent);
|
||||
|
||||
return { tree, addField, onInput, contentEl, onClick, onKeyDown, menuActive };
|
||||
onMounted(() => {
|
||||
if (contentEl.value) {
|
||||
contentEl.value.addEventListener('selectstart', onSelect);
|
||||
setContent();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (contentEl.value) {
|
||||
contentEl.value.removeEventListener('selectstart', onSelect);
|
||||
}
|
||||
});
|
||||
|
||||
return { tree, addField, onInput, contentEl, onClick, onKeyDown, menuActive, onSelect };
|
||||
|
||||
function onInput() {
|
||||
if (!contentEl.value) return;
|
||||
|
||||
const valueString = getInputValue();
|
||||
|
||||
emit('input', valueString);
|
||||
}
|
||||
|
||||
@@ -78,11 +91,14 @@ export default defineComponent({
|
||||
const field = target.dataset.field;
|
||||
emit('input', props.value.replace(`{{${field}}}`, ''));
|
||||
|
||||
// A button is wrapped in two empty `<span></span>` elements
|
||||
target.previousElementSibling?.remove();
|
||||
target.nextElementSibling?.remove();
|
||||
target.remove();
|
||||
const before = target.previousElementSibling;
|
||||
const after = target.nextElementSibling;
|
||||
|
||||
if (!before || !after || !(before instanceof HTMLElement) || !(after instanceof HTMLElement)) return;
|
||||
|
||||
target.remove();
|
||||
joinElements(before, after);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
onInput();
|
||||
}
|
||||
|
||||
@@ -91,6 +107,44 @@ export default defineComponent({
|
||||
event.preventDefault();
|
||||
menuActive.value = true;
|
||||
}
|
||||
|
||||
if (contentEl.value?.innerHTML === '') {
|
||||
contentEl.value.innerHTML = '<span class="text"></span>';
|
||||
}
|
||||
}
|
||||
|
||||
function onSelect() {
|
||||
if (!contentEl.value) return;
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount <= 0) return;
|
||||
const range = selection.getRangeAt(0);
|
||||
if (!range) return;
|
||||
const start = range.startContainer;
|
||||
|
||||
if (
|
||||
!(start instanceof HTMLElement && start.classList.contains('text')) &&
|
||||
!start.parentElement?.classList.contains('text')
|
||||
) {
|
||||
selection.removeAllRanges();
|
||||
const range = new Range();
|
||||
let textSpan = null;
|
||||
|
||||
for (let i = 0; i < contentEl.value.childNodes.length || !textSpan; i++) {
|
||||
const child = contentEl.value.children[i];
|
||||
if (child.classList.contains('text')) {
|
||||
textSpan = child;
|
||||
}
|
||||
}
|
||||
|
||||
if (!textSpan) {
|
||||
textSpan = document.createElement('span');
|
||||
textSpan.classList.add('text');
|
||||
contentEl.value.appendChild(textSpan);
|
||||
}
|
||||
|
||||
range.setStart(textSpan, 0);
|
||||
selection.addRange(range);
|
||||
}
|
||||
}
|
||||
|
||||
function addField(fieldKey: string) {
|
||||
@@ -104,22 +158,64 @@ export default defineComponent({
|
||||
button.innerText = String(field.name);
|
||||
|
||||
const range = window.getSelection()?.getRangeAt(0);
|
||||
range?.deleteContents();
|
||||
range?.insertNode(button);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
if (!range) return;
|
||||
range.deleteContents();
|
||||
|
||||
const end = splitElements();
|
||||
|
||||
if (end) {
|
||||
contentEl.value.insertBefore(button, end);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
} else {
|
||||
contentEl.value.appendChild(button);
|
||||
const span = document.createElement('span');
|
||||
span.classList.add('text');
|
||||
contentEl.value.appendChild(span);
|
||||
}
|
||||
|
||||
onInput();
|
||||
}
|
||||
|
||||
function joinElements(first: HTMLElement, second: HTMLElement) {
|
||||
first.innerText += second.innerText;
|
||||
second.remove();
|
||||
}
|
||||
|
||||
function splitElements() {
|
||||
const range = window.getSelection()?.getRangeAt(0);
|
||||
if (!range) return;
|
||||
|
||||
const textNode = range.startContainer;
|
||||
if (textNode.nodeType !== Node.TEXT_NODE) return;
|
||||
const start = textNode.parentElement;
|
||||
if (!start || !(start instanceof HTMLSpanElement) || !start.classList.contains('text')) return;
|
||||
|
||||
const startOffset = range.startOffset;
|
||||
|
||||
const left = start.textContent?.substr(0, startOffset) || '';
|
||||
const right = start.textContent?.substr(startOffset) || '';
|
||||
|
||||
start.innerText = left;
|
||||
|
||||
const nextSpan = document.createElement('span');
|
||||
nextSpan.classList.add('text');
|
||||
nextSpan.innerText = right;
|
||||
contentEl.value?.insertBefore(nextSpan, start.nextSibling);
|
||||
return nextSpan;
|
||||
}
|
||||
|
||||
function getInputValue() {
|
||||
if (!contentEl.value) return null;
|
||||
|
||||
return Array.from(contentEl.value.childNodes).reduce((acc, node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) return (acc += node.textContent);
|
||||
|
||||
const el = node as HTMLElement;
|
||||
const tag = el.tagName;
|
||||
if (tag.toLowerCase() === 'button') return (acc += `{{${el.dataset.field}}}`);
|
||||
|
||||
if (tag) {
|
||||
if (tag.toLowerCase() === 'button') return (acc += `{{${el.dataset.field}}}`);
|
||||
if (tag.toLowerCase() === 'span') return (acc += el.textContent);
|
||||
}
|
||||
|
||||
return (acc += '');
|
||||
}, '');
|
||||
}
|
||||
@@ -127,30 +223,31 @@ export default defineComponent({
|
||||
function setContent() {
|
||||
if (!contentEl.value) return;
|
||||
|
||||
if (props.value === null) {
|
||||
contentEl.value.innerHTML = '';
|
||||
if (props.value === null || props.value === '') {
|
||||
contentEl.value.innerHTML = '<span class="text"></span>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.value !== getInputValue()) {
|
||||
const regex = /({{.*?}})/g;
|
||||
|
||||
const before = null;
|
||||
const after = null;
|
||||
|
||||
const newInnerHTML = props.value
|
||||
.split(regex)
|
||||
.map((part) => {
|
||||
if (part.startsWith('{{') === false) return part;
|
||||
|
||||
const fieldKey = part.replace(/{{/g, '').replace(/}}/g, '').trim();
|
||||
if (part.startsWith('{{') === false) {
|
||||
return `<span class="text">${part}</span>`;
|
||||
}
|
||||
const fieldKey = part.replaceAll(/({|})/g, '').trim();
|
||||
const field: Field | null = fieldsStore.getField(props.collection, fieldKey);
|
||||
|
||||
// Instead of crashing when the field doesn't exist, we'll render a couple question
|
||||
// marks to indicate it's absence
|
||||
if (!field) return '???';
|
||||
if (!field) return '';
|
||||
|
||||
return `<button contenteditable="false" data-field="${field.field}">${field.name}</button>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
contentEl.value.innerHTML = newInnerHTML;
|
||||
}
|
||||
}
|
||||
@@ -169,7 +266,7 @@ export default defineComponent({
|
||||
|
||||
::v-deep {
|
||||
> * {
|
||||
display: inline;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -177,8 +274,13 @@ export default defineComponent({
|
||||
display: none;
|
||||
}
|
||||
|
||||
span {
|
||||
min-width: 1px;
|
||||
min-height: 1em;
|
||||
}
|
||||
|
||||
button {
|
||||
margin: 0;
|
||||
margin: 0 4px;
|
||||
padding: 0 4px;
|
||||
color: var(--primary);
|
||||
background-color: var(--primary-alt);
|
||||
|
||||
@@ -9,11 +9,13 @@
|
||||
|
||||
<component
|
||||
v-if="interfaceExists"
|
||||
:is="`interface-${field.meta.interface}`"
|
||||
v-bind="field.meta.options"
|
||||
:is="
|
||||
field.meta ? `interface-${field.meta.interface}` : `interface-${getDefaultInterfaceForType(field.type)}`
|
||||
"
|
||||
v-bind="(field.meta && field.meta.options) || {}"
|
||||
:disabled="disabled"
|
||||
:value="value === undefined ? field.schema.default_value : value"
|
||||
:width="field.meta.width"
|
||||
:width="(field.meta && field.meta.width) || 'full'"
|
||||
:type="field.type"
|
||||
:collection="field.collection"
|
||||
:field="field.field"
|
||||
@@ -23,7 +25,7 @@
|
||||
/>
|
||||
|
||||
<v-notice v-else type="warning">
|
||||
{{ $t('interface_not_found', { interface: field.meta.interface }) }}
|
||||
{{ $t('interface_not_found', { interface: field.meta && field.meta.interface }) }}
|
||||
</v-notice>
|
||||
</div>
|
||||
</template>
|
||||
@@ -31,7 +33,8 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, computed } from '@vue/composition-api';
|
||||
import { Field } from '@/types';
|
||||
import interfaces from '@/interfaces';
|
||||
import { getInterfaces } from '@/interfaces';
|
||||
import { getDefaultInterfaceForType } from '@/utils/get-default-interface-for-type';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
@@ -65,11 +68,13 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const interfaces = getInterfaces();
|
||||
|
||||
const interfaceExists = computed(() => {
|
||||
return !!interfaces.find((inter) => inter.id === props.field.meta.interface);
|
||||
return !!interfaces.value.find((inter) => inter.id === props.field?.meta?.interface || 'text-input');
|
||||
});
|
||||
|
||||
return { interfaceExists };
|
||||
return { interfaceExists, getDefaultInterfaceForType };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="field" :key="field.field" :class="field.meta.width">
|
||||
<div class="field" :key="field.field" :class="(field.meta && field.meta.width) || 'full'">
|
||||
<v-menu v-if="field.hideLabel !== true" placement="bottom-start" show-arrow :disabled="isDisabled">
|
||||
<template #activator="{ toggle, active }">
|
||||
<form-field-label
|
||||
@@ -33,7 +33,7 @@
|
||||
@input="$emit('input', $event)"
|
||||
/>
|
||||
|
||||
<small class="note" v-if="field.meta.note" v-html="marked(field.meta.note)" />
|
||||
<small class="note" v-if="field.meta && field.meta.note" v-html="marked(field.meta.note)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -84,7 +84,7 @@ export default defineComponent({
|
||||
setup(props) {
|
||||
const isDisabled = computed(() => {
|
||||
if (props.disabled) return true;
|
||||
if (props.field.meta.readonly) return true;
|
||||
if (props.field?.meta?.readonly === true) return true;
|
||||
if (props.batchMode && props.batchActive === false) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, computed, ref } from '@vue/composition-api';
|
||||
import { defineComponent, PropType, computed, ref, provide } from '@vue/composition-api';
|
||||
import { useFieldsStore } from '@/stores/';
|
||||
import { Field } from '@/types';
|
||||
import { useElementSize } from '@/composables/use-element-size';
|
||||
@@ -83,6 +83,8 @@ export default defineComponent({
|
||||
|
||||
const { toggleBatchField, batchActiveFields } = useBatch();
|
||||
|
||||
provide('values', values);
|
||||
|
||||
return {
|
||||
el,
|
||||
formFields,
|
||||
@@ -115,7 +117,7 @@ export default defineComponent({
|
||||
const gridClass = computed<string | null>(() => {
|
||||
if (el.value === null) return null;
|
||||
|
||||
if (width.value > 612 && width.value <= 792) {
|
||||
if (width.value > 588 && width.value <= 792) {
|
||||
return 'grid';
|
||||
} else {
|
||||
return 'grid with-fill';
|
||||
@@ -130,7 +132,7 @@ export default defineComponent({
|
||||
return (
|
||||
props.loading ||
|
||||
props.disabled === true ||
|
||||
field.meta.readonly === true ||
|
||||
field.meta?.readonly === true ||
|
||||
(props.batchMode && batchActiveFields.value.includes(field.field) === false)
|
||||
);
|
||||
}
|
||||
@@ -180,6 +182,8 @@ body {
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/mixins/breakpoint';
|
||||
|
||||
.v-form {
|
||||
&.grid {
|
||||
display: grid;
|
||||
@@ -196,15 +200,27 @@ body {
|
||||
& > .half,
|
||||
& > .half-left,
|
||||
& > .half-space {
|
||||
grid-column: start / half;
|
||||
grid-column: start / fill;
|
||||
|
||||
@include breakpoint(medium) {
|
||||
grid-column: start / half;
|
||||
}
|
||||
}
|
||||
|
||||
& > .half-right {
|
||||
grid-column: half / full;
|
||||
grid-column: start / fill;
|
||||
|
||||
@include breakpoint(medium) {
|
||||
grid-column: half / full;
|
||||
}
|
||||
}
|
||||
|
||||
& > .full {
|
||||
grid-column: start / full;
|
||||
grid-column: start / fill;
|
||||
|
||||
@include breakpoint(medium) {
|
||||
grid-column: start / full;
|
||||
}
|
||||
}
|
||||
|
||||
& > .fill {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M20 6h-8l-2-2H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm.295 7l-5-5v2.852H11v4.296h4.295V18l5-5z"
|
||||
d="M11.17 8l-2-2H4v12h16V8h-8.83zM4 4h6l2 2h8a2 2 0 012 2v10a2 2 0 01-2 2H4a2 2 0 01-2-2l.01-12A2 2 0 014 4zm10.44 5l4 4-4 4v-3H11v-2h3.44V9z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
22
app/src/components/v-icon/custom-icons/logout.vue
Normal file
22
app/src/components/v-icon/custom-icons/logout.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<template functional>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z" fill="none"/>
|
||||
<path
|
||||
d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4a2 2 0 00-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {};
|
||||
</script>
|
||||
@@ -54,7 +54,6 @@ export const interactive = () => ({
|
||||
template: `
|
||||
<v-icon
|
||||
:name="name"
|
||||
:outline="outline"
|
||||
:sup="sup"
|
||||
:style="{'--v-icon-color': color}"
|
||||
:x-small="size === 'xSmall'"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
:tabindex="hasClick ? 0 : null"
|
||||
>
|
||||
<component v-if="customIconName" :is="customIconName" />
|
||||
<i v-else :class="{ outline }">{{ name }}</i>
|
||||
<i v-else :class="{ filled }">{{ name }}</i>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -30,6 +30,7 @@ import CustomIconSignalWifi3Bar from './custom-icons/signal_wifi_3_bar.vue';
|
||||
import CustomIconFlipHorizontal from './custom-icons/flip_horizontal.vue';
|
||||
import CustomIconFlipVertical from './custom-icons/flip_vertical.vue';
|
||||
import CustomIconFolderMove from './custom-icons/folder_move.vue';
|
||||
import CustomIconLogout from './custom-icons/logout.vue';
|
||||
|
||||
const customIcons: string[] = [
|
||||
'directus',
|
||||
@@ -47,6 +48,7 @@ const customIcons: string[] = [
|
||||
'flip_horizontal',
|
||||
'flip_vertical',
|
||||
'folder_move',
|
||||
'logout',
|
||||
];
|
||||
|
||||
export default defineComponent({
|
||||
@@ -66,13 +68,14 @@ export default defineComponent({
|
||||
CustomIconFlipHorizontal,
|
||||
CustomIconFlipVertical,
|
||||
CustomIconFolderMove,
|
||||
CustomIconLogout,
|
||||
},
|
||||
props: {
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
outline: {
|
||||
filled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
@@ -126,6 +129,7 @@ export default defineComponent({
|
||||
<style>
|
||||
body {
|
||||
--v-icon-color: currentColor;
|
||||
--v-icon-color-hover: currentColor;
|
||||
--v-icon-size: 24px;
|
||||
}
|
||||
</style>
|
||||
@@ -146,7 +150,7 @@ body {
|
||||
font-weight: normal;
|
||||
font-size: var(--v-icon-size);
|
||||
/* stylelint-disable-next-line font-family-no-missing-generic-family-keyword */
|
||||
font-family: 'Material Icons';
|
||||
font-family: 'Material Icons Outline';
|
||||
font-style: normal;
|
||||
line-height: 1;
|
||||
letter-spacing: normal;
|
||||
@@ -155,9 +159,9 @@ body {
|
||||
word-wrap: normal;
|
||||
font-feature-settings: 'liga';
|
||||
|
||||
&.outline {
|
||||
&.filled {
|
||||
/* stylelint-disable-next-line font-family-no-missing-generic-family-keyword */
|
||||
font-family: 'Material Icons Outline';
|
||||
font-family: 'Material Icons';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +173,11 @@ body {
|
||||
|
||||
&.has-click {
|
||||
cursor: pointer;
|
||||
transition: color var(--fast) var(--transition);
|
||||
|
||||
&:hover {
|
||||
color: var(--v-icon-color-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&.sup {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="v-info" :class="[type, { center }]">
|
||||
<div class="icon">
|
||||
<v-icon large :name="icon" />
|
||||
<v-icon large :name="icon" outline />
|
||||
</div>
|
||||
<h2 class="title type-title">{{ title }}</h2>
|
||||
<p class="content"><slot /></p>
|
||||
|
||||
@@ -138,6 +138,7 @@ export default defineComponent({
|
||||
return { _listeners, hasClick, stepUp, stepDown, input };
|
||||
|
||||
function processValue(event: KeyboardEvent) {
|
||||
if (!event.key) return;
|
||||
const key = event.key.toLowerCase();
|
||||
const systemKeys = ['meta', 'shift', 'alt', 'backspace', 'tab'];
|
||||
const value = (event.target as HTMLInputElement).value;
|
||||
|
||||
@@ -17,6 +17,12 @@ export default defineComponent({
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
--v-list-item-icon-color: var(--foreground-subdued);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-list-item-icon {
|
||||
$this: &;
|
||||
@@ -62,8 +68,8 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
|
||||
&.dense:not(.nav) #{$this} {
|
||||
color: var(--foreground-subdued);
|
||||
&.dense:not(.nav) #{$this} .v-icon {
|
||||
--v-icon-color: var(--v-list-item-icon-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,7 +351,7 @@ body {
|
||||
}
|
||||
|
||||
.v-menu-content {
|
||||
max-height: 30vh;
|
||||
// max-height: 30vh;
|
||||
padding: 0 4px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -164,6 +164,7 @@ body {
|
||||
background-color: var(--background-normal);
|
||||
transform: translateX(-100%);
|
||||
transition: transform var(--slow) var(--transition-out);
|
||||
z-index: 2;
|
||||
|
||||
&.active {
|
||||
transform: translateX(0);
|
||||
@@ -177,16 +178,17 @@ body {
|
||||
}
|
||||
|
||||
.v-overlay {
|
||||
--v-overlay-z-index: none;
|
||||
--v-overlay-z-index: 1;
|
||||
|
||||
@include breakpoint(medium) {
|
||||
--v-overlay-z-index: none;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.main {
|
||||
flex-grow: 1;
|
||||
padding: 8px 16px;
|
||||
padding: 16px 16px 32px;
|
||||
overflow: auto;
|
||||
|
||||
@include breakpoint(medium) {
|
||||
|
||||
@@ -33,7 +33,7 @@ export default defineComponent({
|
||||
|
||||
<style>
|
||||
body {
|
||||
--v-overlay-color: rgba(38, 50, 56, 0.9);
|
||||
--v-overlay-color: var(--overlay-color);
|
||||
--v-overlay-z-index: 500;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<template>
|
||||
<div class="v-pagination">
|
||||
<v-button v-if="value !== 1" :disabled="disabled" secondary icon small @click="toPrev">
|
||||
<v-button v-if="value !== 1" class="previous" :disabled="disabled" secondary icon small @click="toPrev">
|
||||
<v-icon name="chevron_left" />
|
||||
</v-button>
|
||||
|
||||
<v-button
|
||||
v-if="showFirstLast && value > Math.ceil(totalVisible / 2)"
|
||||
class="page"
|
||||
@click="toPage(1)"
|
||||
secondary
|
||||
small
|
||||
@@ -20,6 +21,7 @@
|
||||
v-for="page in visiblePages"
|
||||
:key="page"
|
||||
:class="{ active: value === page }"
|
||||
class="page"
|
||||
@click="toPage(page)"
|
||||
secondary
|
||||
small
|
||||
@@ -33,8 +35,9 @@
|
||||
</span>
|
||||
|
||||
<v-button
|
||||
v-if="showFirstLast && value < length - Math.ceil(totalVisible / 2)"
|
||||
v-if="showFirstLast && value <= length - Math.ceil(totalVisible / 2)"
|
||||
:class="{ active: value === length }"
|
||||
class="page"
|
||||
@click="toPage(length)"
|
||||
secondary
|
||||
small
|
||||
@@ -43,7 +46,7 @@
|
||||
{{ length }}
|
||||
</v-button>
|
||||
|
||||
<v-button v-if="value !== length" :disabled="disabled" secondary icon small @click="toNext">
|
||||
<v-button v-if="value !== length" class="next" :disabled="disabled" secondary icon small @click="toNext">
|
||||
<v-icon name="chevron_right" />
|
||||
</v-button>
|
||||
</div>
|
||||
@@ -130,10 +133,20 @@ body {
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/mixins/breakpoint';
|
||||
|
||||
.v-pagination {
|
||||
display: flex;
|
||||
|
||||
.gap {
|
||||
margin: 0 4px;
|
||||
color: var(--foreground-subdued);
|
||||
display: none;
|
||||
line-height: 2em;
|
||||
|
||||
@include breakpoint(small) {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.v-button {
|
||||
@@ -144,6 +157,14 @@ body {
|
||||
margin: 0 2px;
|
||||
vertical-align: middle;
|
||||
|
||||
&.page:not(.active) {
|
||||
display: none;
|
||||
|
||||
@include breakpoint(small) {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
& ::v-deep {
|
||||
.small {
|
||||
--v-button-min-width: 32px;
|
||||
|
||||
@@ -90,7 +90,7 @@ export const htmlLabel = () => ({
|
||||
},
|
||||
template: `
|
||||
<v-switch v-model="checked" @change="onChange">
|
||||
<template #label>
|
||||
<template>
|
||||
Any <i>custom</i> markup in here
|
||||
</template>
|
||||
</v-switch>
|
||||
|
||||
@@ -45,8 +45,8 @@
|
||||
tag="tbody"
|
||||
handle=".drag-handle"
|
||||
:disabled="disabled || _sort.by !== manualSortKey"
|
||||
@change="onSortChange"
|
||||
:set-data="hideDragImage"
|
||||
@end="onSortChange"
|
||||
>
|
||||
<table-row
|
||||
v-for="item in _items"
|
||||
@@ -363,25 +363,18 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
|
||||
interface VueDraggableChangeEvent extends CustomEvent {
|
||||
moved?: {
|
||||
oldIndex: number;
|
||||
newIndex: number;
|
||||
|
||||
element: Record<string, any>;
|
||||
};
|
||||
interface EndEvent extends CustomEvent {
|
||||
oldIndex: number;
|
||||
newIndex: number;
|
||||
}
|
||||
|
||||
function onSortChange(event: VueDraggableChangeEvent) {
|
||||
function onSortChange(event: EndEvent) {
|
||||
if (props.disabled) return;
|
||||
|
||||
if (event.moved) {
|
||||
emit('manual-sort', {
|
||||
item: event.moved.element,
|
||||
oldIndex: event.moved.oldIndex,
|
||||
newIndex: event.moved.newIndex,
|
||||
});
|
||||
}
|
||||
const item = _items.value[event.oldIndex][props.itemKey];
|
||||
const to = _items.value[event.newIndex][props.itemKey];
|
||||
|
||||
emit('manual-sort', { item, to });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@ export default defineComponent({
|
||||
|
||||
<style>
|
||||
body {
|
||||
--v-tab-color: var(--foreground-normal);
|
||||
--v-tab-color: var(--foreground-subdued);
|
||||
--v-tab-background-color: var(--background-page);
|
||||
--v-tab-color-active: var(--foreground-normal);
|
||||
--v-tab-background-color-active: var(--background-page);
|
||||
@@ -50,12 +50,13 @@ body {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-tab.horizontal {
|
||||
transition: color var(--fast) var(--transition);
|
||||
color: var(--v-tab-color);
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
font-size: 14px;
|
||||
background-color: var(--v-tab-background-color);
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
color: var(--v-tab-color-active);
|
||||
background-color: var(--v-tab-background-color-active);
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
</v-list>
|
||||
<div v-else class="v-tabs horizontal">
|
||||
<slot />
|
||||
<div class="slider" :style="slideStyle" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -40,20 +39,11 @@ export default defineComponent({
|
||||
'v-tabs'
|
||||
);
|
||||
|
||||
const slideStyle = computed(() => {
|
||||
const activeIndex = items.value.findIndex((item) => item.active.value);
|
||||
|
||||
return {
|
||||
'--_v-tabs-items': items.value.length,
|
||||
'--_v-tabs-selected': activeIndex,
|
||||
};
|
||||
});
|
||||
|
||||
function update(newSelection: readonly (string | number)[]) {
|
||||
emit('input', newSelection);
|
||||
}
|
||||
|
||||
return { update, slideStyle, items };
|
||||
return { update, items };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -67,7 +57,7 @@ body {
|
||||
<style lang="scss" scoped>
|
||||
.v-tabs.horizontal {
|
||||
position: relative;
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
|
||||
::v-deep .v-tab {
|
||||
display: flex;
|
||||
@@ -76,20 +66,9 @@ body {
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 44px;
|
||||
padding: 12px 20px;
|
||||
height: 38px;
|
||||
padding: 8px 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: calc(100% / var(--_v-tabs-items) * var(--_v-tabs-selected));
|
||||
width: calc(100% / var(--_v-tabs-items));
|
||||
height: 2px;
|
||||
background-color: var(--v-tabs-underline-color);
|
||||
transition: var(--medium) cubic-bezier(0.66, 0, 0.33, 1);
|
||||
transition-property: left, top;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { usePresetsStore, useUserStore } from '@/stores';
|
||||
import { ref, Ref, computed, watch } from '@vue/composition-api';
|
||||
import { debounce } from 'lodash';
|
||||
import { useCollection } from '@/composables/use-collection';
|
||||
|
||||
import { Filter, Preset } from '@/types/';
|
||||
|
||||
@@ -8,6 +9,8 @@ export function usePreset(collection: Ref<string>, bookmark: Ref<number | null>
|
||||
const presetsStore = usePresetsStore();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const { info: collectionInfo } = useCollection(collection);
|
||||
|
||||
const bookmarkExists = computed(() => {
|
||||
if (!bookmark.value) return false;
|
||||
|
||||
@@ -156,11 +159,28 @@ export function usePreset(collection: Ref<string>, bookmark: Ref<number | null>
|
||||
};
|
||||
}
|
||||
|
||||
if (!localPreset.value.view_type)
|
||||
if (!localPreset.value.view_type) {
|
||||
localPreset.value = {
|
||||
...localPreset.value,
|
||||
view_type: 'tabular',
|
||||
};
|
||||
}
|
||||
|
||||
if (collectionInfo.value?.meta?.archive_field && collectionInfo.value?.meta?.archive_app_filter === true) {
|
||||
localPreset.value = {
|
||||
...localPreset.value,
|
||||
filters: [
|
||||
...(localPreset.value.filters || []),
|
||||
{
|
||||
key: 'hide-archived',
|
||||
field: collectionInfo.value.meta.archive_field,
|
||||
operator: 'neq',
|
||||
value: collectionInfo.value.meta.archive_value!,
|
||||
locked: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -28,34 +28,9 @@ export function useCollection(collectionKey: string | Ref<string>) {
|
||||
return fields.value?.find((field) => field.meta?.special === 'user_created') || null;
|
||||
});
|
||||
|
||||
const statusField = computed(() => {
|
||||
return fields.value?.find((field) => field.meta?.special === 'status') || null;
|
||||
});
|
||||
|
||||
const sortField = computed(() => {
|
||||
return info.value?.meta?.sort_field || null;
|
||||
});
|
||||
|
||||
type Status = {
|
||||
background_color: string;
|
||||
browse_badge: string;
|
||||
browse_subdued: string;
|
||||
name: string;
|
||||
published: boolean;
|
||||
required_fields: boolean;
|
||||
soft_delete: boolean;
|
||||
text_color: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
const softDeleteStatus = computed<string | null>(() => {
|
||||
if (statusField.value === null) return null;
|
||||
|
||||
const statuses = Object.values(statusField.value?.meta?.options?.status_mapping || {});
|
||||
return (
|
||||
(statuses.find((status) => (status as Status).soft_delete === true) as Status | undefined)?.value || null
|
||||
);
|
||||
});
|
||||
|
||||
return { info, fields, primaryKeyField, userCreatedField, statusField, softDeleteStatus, sortField };
|
||||
return { info, fields, primaryKeyField, userCreatedField, sortField };
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
|
||||
import { computed, Ref } from '@vue/composition-api';
|
||||
import { isEmpty } from '@/utils/is-empty';
|
||||
import getDefaultInterfaceForType from '@/utils/get-default-interface-for-type';
|
||||
import interfaces from '@/interfaces';
|
||||
import { getInterfaces } from '@/interfaces';
|
||||
import { FormField } from '@/components/v-form/types';
|
||||
import { Field } from '@/types';
|
||||
import { clone } from 'lodash';
|
||||
|
||||
export default function useFormFields(fields: Ref<Field[]>) {
|
||||
const interfaces = getInterfaces();
|
||||
|
||||
const formFields = computed(() => {
|
||||
let formFields = [...fields.value];
|
||||
let formFields = clone(fields.value);
|
||||
|
||||
// Sort the fields on the sort column value
|
||||
formFields = formFields.sort((a, b) => {
|
||||
@@ -23,40 +25,16 @@ export default function useFormFields(fields: Ref<Field[]>) {
|
||||
});
|
||||
|
||||
formFields = formFields.map((field, index) => {
|
||||
if (!field.meta) {
|
||||
field.meta = {
|
||||
id: -1,
|
||||
collection: field.collection,
|
||||
field: field.field,
|
||||
group: null,
|
||||
hidden: false,
|
||||
locked: false,
|
||||
interface: null,
|
||||
options: null,
|
||||
display: null,
|
||||
display_options: null,
|
||||
readonly: false,
|
||||
required: false,
|
||||
sort: null,
|
||||
special: null,
|
||||
translation: null,
|
||||
width: 'full',
|
||||
note: null,
|
||||
};
|
||||
}
|
||||
if (!field.meta) return field;
|
||||
|
||||
if (!field.meta.width) {
|
||||
field.meta.width = 'full';
|
||||
}
|
||||
|
||||
let interfaceUsed = interfaces.find((int) => int.id === field.meta.interface);
|
||||
let interfaceUsed = interfaces.value.find((int) => int.id === field.meta?.interface);
|
||||
const interfaceExists = interfaceUsed !== undefined;
|
||||
|
||||
if (interfaceExists === false) {
|
||||
field.meta.interface = getDefaultInterfaceForType(field.type);
|
||||
}
|
||||
|
||||
interfaceUsed = interfaces.find((int) => int.id === field.meta.interface);
|
||||
interfaceUsed = interfaces.value.find((int) => int.id === field.meta?.interface);
|
||||
|
||||
if (interfaceUsed?.hideLabel === true) {
|
||||
(field as FormField).hideLabel = true;
|
||||
@@ -69,7 +47,7 @@ export default function useFormFields(fields: Ref<Field[]>) {
|
||||
if (index !== 0 && field.meta!.width === 'half') {
|
||||
const prevField = formFields[index - 1];
|
||||
|
||||
if (prevField.meta.width === 'half') {
|
||||
if (prevField.meta?.width === 'half') {
|
||||
field.meta.width = 'half-right';
|
||||
}
|
||||
}
|
||||
@@ -80,7 +58,7 @@ export default function useFormFields(fields: Ref<Field[]>) {
|
||||
// Filter out the fields that are marked hidden on detail
|
||||
formFields = formFields.filter((field) => {
|
||||
const hidden = field.meta?.hidden;
|
||||
const systemFake = field.field.startsWith('$');
|
||||
const systemFake = field.field?.startsWith('$') || false;
|
||||
return hidden !== true && systemFake === false;
|
||||
});
|
||||
|
||||
|
||||
@@ -6,19 +6,29 @@ import useCollection from '@/composables/use-collection';
|
||||
import { AxiosResponse } from 'axios';
|
||||
|
||||
export function useItem(collection: Ref<string>, primaryKey: Ref<string | number | null>) {
|
||||
const { info: collectionInfo, primaryKeyField, softDeleteStatus, statusField } = useCollection(collection);
|
||||
const { info: collectionInfo, primaryKeyField } = useCollection(collection);
|
||||
|
||||
const item = ref<any>(null);
|
||||
const error = ref(null);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const deleting = ref(false);
|
||||
const softDeleting = ref(false);
|
||||
const archiving = ref(false);
|
||||
const edits = ref({});
|
||||
const isNew = computed(() => primaryKey.value === '+');
|
||||
const isBatch = computed(() => typeof primaryKey.value === 'string' && primaryKey.value.includes(','));
|
||||
const isSingle = computed(() => !!collectionInfo.value?.meta?.singleton);
|
||||
|
||||
const isArchived = computed(() => {
|
||||
if (!collectionInfo.value?.meta?.archive_field) return null;
|
||||
|
||||
if (collectionInfo.value.meta.archive_value === 'true') {
|
||||
return item.value?.[collectionInfo.value.meta.archive_field] === true;
|
||||
}
|
||||
|
||||
return item.value?.[collectionInfo.value.meta.archive_field] === collectionInfo.value.meta.archive_value;
|
||||
});
|
||||
|
||||
const endpoint = computed(() => {
|
||||
return collection.value.startsWith('directus_')
|
||||
? `/${collection.value.substring(9)}`
|
||||
@@ -46,7 +56,9 @@ export function useItem(collection: Ref<string>, primaryKey: Ref<string | number
|
||||
isNew,
|
||||
remove,
|
||||
deleting,
|
||||
softDeleting,
|
||||
archive,
|
||||
isArchived,
|
||||
archiving,
|
||||
saveAsCopy,
|
||||
isBatch,
|
||||
getItem,
|
||||
@@ -176,25 +188,65 @@ export function useItem(collection: Ref<string>, primaryKey: Ref<string | number
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(soft = false) {
|
||||
if (soft) {
|
||||
softDeleting.value = true;
|
||||
} else {
|
||||
deleting.value = true;
|
||||
}
|
||||
async function archive() {
|
||||
if (!collectionInfo.value?.meta?.archive_field) return;
|
||||
|
||||
archiving.value = true;
|
||||
|
||||
const field = collectionInfo.value.meta.archive_field;
|
||||
|
||||
let archiveValue: any = collectionInfo.value.meta.archive_value;
|
||||
if (archiveValue === 'true') archiveValue = true;
|
||||
if (archiveValue === 'false') archiveValue = false;
|
||||
|
||||
let unarchiveValue: any = collectionInfo.value.meta.unarchive_value;
|
||||
if (unarchiveValue === 'true') unarchiveValue = true;
|
||||
if (unarchiveValue === 'false') unarchiveValue = false;
|
||||
|
||||
try {
|
||||
if (soft) {
|
||||
if (!statusField.value || softDeleteStatus.value === null) {
|
||||
throw new Error('[useItem] You cant soft-delete without a status field');
|
||||
}
|
||||
let value: any = item.value[field] === archiveValue ? unarchiveValue : archiveValue;
|
||||
|
||||
await api.patch(itemEndpoint.value, {
|
||||
[statusField.value.field]: softDeleteStatus.value,
|
||||
});
|
||||
} else {
|
||||
await api.delete(itemEndpoint.value);
|
||||
}
|
||||
if (value === 'true') value = true;
|
||||
if (value === 'false') value = false;
|
||||
|
||||
item.value = {
|
||||
...item.value,
|
||||
[field]: value,
|
||||
};
|
||||
|
||||
await api.patch(itemEndpoint.value, {
|
||||
[field]: value,
|
||||
});
|
||||
|
||||
notify({
|
||||
title: i18n.tc('item_delete_success', isBatch.value ? 2 : 1),
|
||||
text: i18n.tc('item_in', isBatch.value ? 2 : 1, {
|
||||
collection: collection.value,
|
||||
primaryKey: isBatch.value ? (primaryKey.value as string).split(',').join(', ') : primaryKey.value,
|
||||
}),
|
||||
type: 'success',
|
||||
});
|
||||
} catch (err) {
|
||||
notify({
|
||||
title: i18n.tc('item_delete_failed', isBatch.value ? 2 : 1),
|
||||
text: i18n.tc('item_in', isBatch.value ? 2 : 1, {
|
||||
collection: collection.value,
|
||||
primaryKey: isBatch.value ? (primaryKey.value as string).split(',').join(', ') : primaryKey.value,
|
||||
}),
|
||||
type: 'error',
|
||||
});
|
||||
|
||||
throw err;
|
||||
} finally {
|
||||
archiving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
deleting.value = true;
|
||||
|
||||
try {
|
||||
await api.delete(itemEndpoint.value);
|
||||
|
||||
item.value = null;
|
||||
|
||||
@@ -218,11 +270,7 @@ export function useItem(collection: Ref<string>, primaryKey: Ref<string | number
|
||||
|
||||
throw err;
|
||||
} finally {
|
||||
if (soft) {
|
||||
softDeleting.value = false;
|
||||
} else {
|
||||
deleting.value = false;
|
||||
}
|
||||
deleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -216,50 +216,20 @@ export function useItems(collection: Ref<string>, query: Query) {
|
||||
}
|
||||
|
||||
type ManualSortData = {
|
||||
item: Record<string, any>;
|
||||
oldIndex: number;
|
||||
newIndex: number;
|
||||
item: string | number;
|
||||
to: string | number;
|
||||
};
|
||||
|
||||
async function changeManualSort({ item, oldIndex, newIndex }: ManualSortData) {
|
||||
async function changeManualSort({ item, to }: ManualSortData) {
|
||||
const pk = primaryKeyField.value?.field;
|
||||
if (!pk) return;
|
||||
|
||||
const move = newIndex > oldIndex ? 'down' : 'up';
|
||||
const selectionRange = move === 'down' ? [oldIndex + 1, newIndex + 1] : [newIndex, oldIndex];
|
||||
const fromIndex = items.value.findIndex((existing: Record<string, any>) => existing[pk] === item);
|
||||
const toIndex = items.value.findIndex((existing: Record<string, any>) => existing[pk] === to);
|
||||
|
||||
const updates = items.value.slice(...selectionRange).map((toBeUpdatedItem: any) => {
|
||||
const sortValue = getPositionForItem(toBeUpdatedItem);
|
||||
items.value = moveInArray(items.value, fromIndex, toIndex);
|
||||
|
||||
return {
|
||||
[pk]: toBeUpdatedItem[pk],
|
||||
sort: move === 'down' ? sortValue - 1 : sortValue + 1,
|
||||
};
|
||||
});
|
||||
|
||||
const sortOfItemOnNewIndex = newIndex + 1 + limit.value * (page.value - 1);
|
||||
|
||||
updates.push({
|
||||
[pk]: item[pk],
|
||||
sort: sortOfItemOnNewIndex,
|
||||
});
|
||||
|
||||
// Reflect changes in local items array
|
||||
items.value = moveInArray(items.value, oldIndex, newIndex);
|
||||
|
||||
// Save updates to items
|
||||
await api.patch(endpoint.value, updates);
|
||||
}
|
||||
|
||||
// Used as default value for the sort position. This is the index of the given item in the array
|
||||
// of items, offset by the page count and current page
|
||||
|
||||
function getPositionForItem(item: any) {
|
||||
const pk = primaryKeyField.value?.field;
|
||||
if (!pk) return;
|
||||
|
||||
const index = items.value.findIndex((existingItem: any) => existingItem[pk] === item[pk]);
|
||||
|
||||
return index + 1 + limit.value * (page.value - 1);
|
||||
const endpoint = computed(() => `/utils/sort/${collection.value}`);
|
||||
await api.post(endpoint.value, { item, to });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ const Tooltip: DirectiveOptions = {
|
||||
bind(element, binding);
|
||||
} else if (!binding.value && binding.oldValue) {
|
||||
unbind(element);
|
||||
} else {
|
||||
unbind(element);
|
||||
bind(element, binding);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ export default defineDisplay(({ i18n }) => ({
|
||||
id: 'collection',
|
||||
name: i18n.t('collection'),
|
||||
types: ['string'],
|
||||
icon: 'box',
|
||||
icon: 'label',
|
||||
handler: DisplayCollection,
|
||||
options: [
|
||||
{
|
||||
|
||||
@@ -9,7 +9,6 @@ import DisplayImage from './image';
|
||||
import DisplayMimeType from './mime-type';
|
||||
import DisplayRating from './rating';
|
||||
import DisplayRaw from './raw';
|
||||
import DisplayStatusBadge from './status-badge/';
|
||||
import DisplayStatusDot from './status-dot/';
|
||||
import DisplayTags from './tags/';
|
||||
import DisplayTemplate from './template';
|
||||
@@ -27,7 +26,6 @@ export const displays = [
|
||||
DisplayImage,
|
||||
DisplayMimeType,
|
||||
DisplayRating,
|
||||
DisplayStatusBadge,
|
||||
DisplayStatusDot,
|
||||
DisplayTags,
|
||||
DisplayTemplate,
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { defineDisplay } from '@/displays/define';
|
||||
import DisplayStatusBadge from './status-badge.vue';
|
||||
|
||||
export default defineDisplay(({ i18n }) => ({
|
||||
id: 'status-badge',
|
||||
name: i18n.t('status_badge'),
|
||||
types: ['string'],
|
||||
icon: 'flag',
|
||||
handler: DisplayStatusBadge,
|
||||
options: null,
|
||||
}));
|
||||
@@ -1,4 +0,0 @@
|
||||
# Status Badge
|
||||
|
||||
Renders the set status formatted according to the status mapping set in the interface options.
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import withPadding from '../../../.storybook/decorators/with-padding';
|
||||
import { withKnobs, text, object } from '@storybook/addon-knobs';
|
||||
import readme from './readme.md';
|
||||
import { defineComponent } from '@vue/composition-api';
|
||||
|
||||
export default {
|
||||
title: 'Displays / Status (Badge)',
|
||||
decorators: [withPadding, withKnobs],
|
||||
parameters: {
|
||||
notes: readme,
|
||||
},
|
||||
};
|
||||
|
||||
const defaultStatusMapping = {
|
||||
published: {
|
||||
name: 'Published',
|
||||
value: 'published',
|
||||
text_color: '#fff',
|
||||
background_color: 'var(--primary)',
|
||||
},
|
||||
draft: {
|
||||
name: 'Draft',
|
||||
value: 'draft',
|
||||
text_color: 'var(--primary-subdued)',
|
||||
background_color: 'var(--background-subdued)',
|
||||
},
|
||||
deleted: {
|
||||
name: 'Deleted',
|
||||
value: 'deleted',
|
||||
text_color: 'var(--danger)',
|
||||
background_color: 'var(--danger-alt)',
|
||||
},
|
||||
};
|
||||
|
||||
export const basic = () =>
|
||||
defineComponent({
|
||||
props: {
|
||||
value: {
|
||||
default: text('Value', 'published'),
|
||||
},
|
||||
statusMapping: {
|
||||
default: object('Status Mapping', defaultStatusMapping),
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<display-status-badge
|
||||
:value="value"
|
||||
:interface-options="{
|
||||
status_mapping: statusMapping,
|
||||
}"
|
||||
/>
|
||||
`,
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
import DisplayStatusBadge from './status-badge.vue';
|
||||
import { createLocalVue, shallowMount } from '@vue/test-utils';
|
||||
import VIcon from '@/components/v-icon';
|
||||
import VueCompositionAPI from '@vue/composition-api';
|
||||
import Tooltip from '@/directives/tooltip';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.component('v-icon', VIcon);
|
||||
localVue.use(VueCompositionAPI);
|
||||
localVue.directive('tooltip', Tooltip);
|
||||
|
||||
describe('Displays / Status Badge', () => {
|
||||
it('Renders an empty span if no value is passed', () => {
|
||||
const component = shallowMount(DisplayStatusBadge, {
|
||||
localVue,
|
||||
propsData: {
|
||||
value: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(component.find('span').exists()).toBe(true);
|
||||
expect(component.find('span').text()).toBe('');
|
||||
});
|
||||
|
||||
it('Renders a question mark icon is status is unknown in interface options', () => {
|
||||
const component = shallowMount(DisplayStatusBadge, {
|
||||
localVue,
|
||||
propsData: {
|
||||
value: 'draft',
|
||||
interfaceOptions: {
|
||||
status_mapping: {
|
||||
published: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(component.find(VIcon).exists()).toBe(true);
|
||||
expect(component.attributes('name')).toBe('help_outline');
|
||||
});
|
||||
|
||||
it('Renders the badge with the correct colors', () => {
|
||||
const component = shallowMount(DisplayStatusBadge, {
|
||||
localVue,
|
||||
propsData: {
|
||||
value: 'draft',
|
||||
interfaceOptions: {
|
||||
status_mapping: {
|
||||
draft: {
|
||||
background_color: 'rgb(171, 202, 188)',
|
||||
text_color: 'rgb(150, 100, 125)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(component.exists()).toBe(true);
|
||||
expect(component.attributes('style')).toBe('background-color: rgb(171, 202, 188); color: rgb(150, 100, 125);');
|
||||
});
|
||||
|
||||
it('Sets status to null if interface options are missing', () => {
|
||||
const component = shallowMount(DisplayStatusBadge, {
|
||||
localVue,
|
||||
propsData: {
|
||||
value: 'draft',
|
||||
interfaceOptions: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect((component.vm as any).status).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
<template>
|
||||
<span v-if="!value" />
|
||||
<v-icon name="help_outline" v-else-if="!status" />
|
||||
<div
|
||||
v-else
|
||||
class="badge type-text"
|
||||
:style="{
|
||||
backgroundColor: status.background_color,
|
||||
color: status.text_color,
|
||||
}"
|
||||
>
|
||||
{{ status.name }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed } from '@vue/composition-api';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
interfaceOptions: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const status = computed(() => {
|
||||
if (props.interfaceOptions === null) return null;
|
||||
|
||||
return props.interfaceOptions.status_mapping?.[props.value];
|
||||
});
|
||||
|
||||
return { status };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 8px;
|
||||
color: var(--foreground-inverted);
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
</style>
|
||||
@@ -12,6 +12,9 @@
|
||||
<v-list-item-content>
|
||||
<render-template :template="template" :item="item" :collection="relatedCollection" />
|
||||
</v-list-item-content>
|
||||
<v-list-item-icon>
|
||||
<v-icon name="launch" small />
|
||||
</v-list-item-icon>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
useRelationsStore,
|
||||
usePermissionsStore,
|
||||
} from '@/stores';
|
||||
import { register as registerModules, unregister as unregisterModules } from '@/modules/register';
|
||||
|
||||
import { setLanguage, Language } from '@/lang';
|
||||
|
||||
@@ -59,6 +60,8 @@ export async function hydrate(stores = useStores()) {
|
||||
setLanguage((userStore.state.currentUser?.locale as Language) || 'en-US');
|
||||
|
||||
await Promise.all(stores.filter(({ id }) => id !== 'userStore').map((store) => store.hydrate?.()));
|
||||
|
||||
registerModules();
|
||||
} catch (error) {
|
||||
appStore.state.error = error;
|
||||
} finally {
|
||||
@@ -78,5 +81,7 @@ export async function dehydrate(stores = useStores()) {
|
||||
await store.dehydrate?.();
|
||||
}
|
||||
|
||||
unregisterModules();
|
||||
|
||||
appStore.state.hydrated = false;
|
||||
}
|
||||
|
||||
@@ -73,7 +73,15 @@ export default defineComponent({
|
||||
const content = cm.getValue();
|
||||
|
||||
if (props.type === 'json') {
|
||||
emit('input', JSON.parse(content));
|
||||
if (content.length === 0) {
|
||||
return emit('input', null);
|
||||
}
|
||||
|
||||
try {
|
||||
emit('input', JSON.parse(content));
|
||||
} catch {
|
||||
// We won't stage invalid JSON
|
||||
}
|
||||
} else {
|
||||
emit('input', content);
|
||||
}
|
||||
@@ -130,9 +138,7 @@ export default defineComponent({
|
||||
if (text.length > 0) {
|
||||
try {
|
||||
jsonlint.parse(text);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
return found;
|
||||
});
|
||||
@@ -274,11 +280,7 @@ export default defineComponent({
|
||||
.interface-code {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--primary-125);
|
||||
}
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.small {
|
||||
|
||||
48
app/src/interfaces/display-template/display-template.vue
Normal file
48
app/src/interfaces/display-template/display-template.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<v-notice v-if="!collectionField" type="warning">
|
||||
{{ $t('collection_field_not_setup') }}
|
||||
</v-notice>
|
||||
<v-notice v-else-if="collection === null" type="warning">
|
||||
{{ $t('select_a_collection') }}
|
||||
</v-notice>
|
||||
<v-field-template v-else :collection="collection" @input="$listeners.input" :value="value" :disabled="disabled" />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, inject, ref, computed } from '@vue/composition-api';
|
||||
import { useCollectionsStore } from '@/stores/collections';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
collectionField: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const collectionsStore = useCollectionsStore();
|
||||
|
||||
const values = inject('values', ref<Record<string, any>>({}));
|
||||
|
||||
const collection = computed(() => {
|
||||
if (!props.collectionField) return null;
|
||||
const collectionName = values.value[props.collectionField];
|
||||
const collectionExists = !!collectionsStore.state.collections.find(
|
||||
(collection) => collection.collection === collectionName
|
||||
);
|
||||
if (collectionExists === false) return null;
|
||||
return collectionName;
|
||||
});
|
||||
|
||||
return { collection };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
12
app/src/interfaces/display-template/index.ts
Normal file
12
app/src/interfaces/display-template/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineInterface } from '@/interfaces/define';
|
||||
import InterfaceDisplayTemplate from './display-template.vue';
|
||||
|
||||
export default defineInterface(({ i18n }) => ({
|
||||
id: 'display-template',
|
||||
name: i18n.t('display-template'),
|
||||
icon: 'arrow_drop_down_circle',
|
||||
component: InterfaceDisplayTemplate,
|
||||
types: ['string'],
|
||||
system: true,
|
||||
options: [],
|
||||
}));
|
||||
9
app/src/interfaces/display-template/readme.md
Normal file
9
app/src/interfaces/display-template/readme.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Display template
|
||||
|
||||
This is the interface version of the v-field-template component.
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Description | Default |
|
||||
|---------------|----------------------------------------|---------|
|
||||
| `*collection` | Fields of collection to use | `null` |
|
||||
@@ -36,6 +36,6 @@ export default defineComponent({
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.margin {
|
||||
margin-top: 48px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
80
app/src/interfaces/field/field.vue
Normal file
80
app/src/interfaces/field/field.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<v-notice v-if="!collectionField" type="warning">
|
||||
{{ $t('collection_field_not_setup') }}
|
||||
</v-notice>
|
||||
<v-notice v-else-if="selectItems.length === 0" type="warning">
|
||||
{{ $t('select_a_collection') }}
|
||||
</v-notice>
|
||||
<v-select
|
||||
v-else
|
||||
:show-deselect="allowNone"
|
||||
@input="$listeners.input"
|
||||
:value="value"
|
||||
:disabled="disabled"
|
||||
:items="selectItems"
|
||||
:placeholder="placeholder"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, inject, ref, PropType } from '@vue/composition-api';
|
||||
import { useFieldsStore } from '@/stores';
|
||||
import { Field } from '@/types';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
collectionField: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
typeAllowList: {
|
||||
type: Array as PropType<string[]>,
|
||||
default: () => [],
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
allowNone: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const fieldsStore = useFieldsStore();
|
||||
|
||||
const values = inject('values', ref<Record<string, any>>({}));
|
||||
|
||||
const fields = computed(() => {
|
||||
if (!props.collectionField) return [];
|
||||
return fieldsStore.getFieldsForCollection(values.value[props.collectionField]);
|
||||
});
|
||||
|
||||
const selectItems = computed(() =>
|
||||
fields.value.map((field: Field) => {
|
||||
let disabled = false;
|
||||
|
||||
if (field?.schema?.is_primary_key === true) disabled = true;
|
||||
if (props.typeAllowList.length > 0 && props.typeAllowList.includes(field.type) === false)
|
||||
disabled = true;
|
||||
|
||||
return {
|
||||
text: field.name,
|
||||
value: field.field,
|
||||
disabled,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return { selectItems };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
12
app/src/interfaces/field/index.ts
Normal file
12
app/src/interfaces/field/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineInterface } from '../define';
|
||||
import InterfaceField from './field.vue';
|
||||
|
||||
export default defineInterface(({ i18n }) => ({
|
||||
id: 'field',
|
||||
name: i18n.t('field'),
|
||||
icon: 'box',
|
||||
component: InterfaceField,
|
||||
types: ['string'],
|
||||
options: [],
|
||||
system: true,
|
||||
}));
|
||||
@@ -1,67 +1,12 @@
|
||||
import InterfaceCheckboxes from './checkboxes';
|
||||
import InterfaceCode from './code';
|
||||
import InterfaceCollections from './collections';
|
||||
import InterfaceColor from './color';
|
||||
import InterfaceDateTime from './datetime';
|
||||
import InterfaceDivider from './divider/';
|
||||
import InterfaceDropdown from './dropdown/';
|
||||
import InterfaceDropdownMultiselect from './dropdown-multiselect/';
|
||||
import InterfaceFile from './file';
|
||||
import InterfaceFiles from './files';
|
||||
import InterfaceHash from './hash';
|
||||
import InterfaceIcon from './icon';
|
||||
import InterfaceImage from './image';
|
||||
import InterfaceManyToMany from './many-to-many';
|
||||
import InterfaceManyToOne from './many-to-one';
|
||||
import InterfaceMarkdown from './markdown';
|
||||
import InterfaceNotice from './notice';
|
||||
import InterfaceNumeric from './numeric/';
|
||||
import InterfaceOneToMany from './one-to-many';
|
||||
import InterfaceRadioButtons from './radio-buttons';
|
||||
import InterfaceRepeater from './repeater';
|
||||
import InterfaceSlider from './slider/';
|
||||
import InterfaceSlug from './slug';
|
||||
import InterfaceStatus from './status';
|
||||
import InterfaceTags from './tags';
|
||||
import InterfaceTextarea from './textarea/';
|
||||
import InterfaceTextInput from './text-input/';
|
||||
import InterfaceToggle from './toggle/';
|
||||
import InterfaceTranslations from './translations';
|
||||
import InterfaceUser from './user';
|
||||
import InterfaceWYSIWYG from './wysiwyg/';
|
||||
import { ref, Ref } from '@vue/composition-api';
|
||||
import { InterfaceConfig } from './types';
|
||||
|
||||
export const interfaces = [
|
||||
InterfaceCheckboxes,
|
||||
InterfaceCode,
|
||||
InterfaceCollections,
|
||||
InterfaceColor,
|
||||
InterfaceDateTime,
|
||||
InterfaceDivider,
|
||||
InterfaceDropdown,
|
||||
InterfaceDropdownMultiselect,
|
||||
InterfaceFile,
|
||||
InterfaceFiles,
|
||||
InterfaceHash,
|
||||
InterfaceIcon,
|
||||
InterfaceImage,
|
||||
InterfaceManyToMany,
|
||||
InterfaceManyToOne,
|
||||
InterfaceMarkdown,
|
||||
InterfaceNotice,
|
||||
InterfaceNumeric,
|
||||
InterfaceOneToMany,
|
||||
InterfaceRadioButtons,
|
||||
InterfaceRepeater,
|
||||
InterfaceSlider,
|
||||
InterfaceSlug,
|
||||
InterfaceStatus,
|
||||
InterfaceTags,
|
||||
InterfaceTextarea,
|
||||
InterfaceTextInput,
|
||||
InterfaceToggle,
|
||||
InterfaceTranslations,
|
||||
InterfaceUser,
|
||||
InterfaceWYSIWYG,
|
||||
];
|
||||
let interfaces: Ref<InterfaceConfig[]>;
|
||||
|
||||
export default interfaces;
|
||||
export function getInterfaces() {
|
||||
if (!interfaces) {
|
||||
interfaces = ref([]);
|
||||
}
|
||||
|
||||
return interfaces;
|
||||
}
|
||||
|
||||
12
app/src/interfaces/interface-options/index.ts
Normal file
12
app/src/interfaces/interface-options/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineInterface } from '../define';
|
||||
import InterfaceOptions from './interface-options.vue';
|
||||
|
||||
export default defineInterface(({ i18n }) => ({
|
||||
id: 'interface-options',
|
||||
name: 'Interface Options',
|
||||
icon: 'box',
|
||||
component: InterfaceOptions,
|
||||
types: ['string'],
|
||||
options: [],
|
||||
system: true,
|
||||
}));
|
||||
66
app/src/interfaces/interface-options/interface-options.vue
Normal file
66
app/src/interfaces/interface-options/interface-options.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<v-notice v-if="!selectedInterface">
|
||||
{{ $t('select_interface') }}
|
||||
</v-notice>
|
||||
|
||||
<v-notice v-else-if="!selectedInterface.options">
|
||||
{{ $t('no_options_available') }}
|
||||
</v-notice>
|
||||
|
||||
<div class="inset" v-else>
|
||||
<v-form
|
||||
v-if="Array.isArray(selectedInterface.options)"
|
||||
:fields="selectedInterface.options"
|
||||
primary-key="+"
|
||||
:edits="value"
|
||||
@input="$listeners.input"
|
||||
/>
|
||||
|
||||
<component
|
||||
:value="value"
|
||||
@input="$listeners.input"
|
||||
:field-data="fieldData"
|
||||
:is="`interface-options-${selectedInterface.id}`"
|
||||
v-else
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, inject, ref } from '@vue/composition-api';
|
||||
import { getInterfaces } from '@/interfaces';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
interfaceField: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props, { parent }) {
|
||||
const interfaces = getInterfaces();
|
||||
|
||||
const values = inject('values', ref<Record<string, any>>({}));
|
||||
|
||||
const selectedInterface = computed(() => {
|
||||
if (!values.value[props.interfaceField]) return;
|
||||
|
||||
return interfaces.value.find((inter) => inter.id === values.value[props.interfaceField]);
|
||||
});
|
||||
|
||||
return { selectedInterface, values };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.inset {
|
||||
padding: 8px;
|
||||
border: var(--border-width) solid var(--border-normal);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
</style>
|
||||
12
app/src/interfaces/interface/index.ts
Normal file
12
app/src/interfaces/interface/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineInterface } from '../define';
|
||||
import InterfaceInterface from './interface.vue';
|
||||
|
||||
export default defineInterface(({ i18n }) => ({
|
||||
id: 'interface',
|
||||
name: 'Interface',
|
||||
icon: 'box',
|
||||
component: InterfaceInterface,
|
||||
types: ['string'],
|
||||
system: true,
|
||||
options: [],
|
||||
}));
|
||||
34
app/src/interfaces/interface/interface.vue
Normal file
34
app/src/interfaces/interface/interface.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<v-select :items="items" @input="$listeners.input" :value="value" />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed } from '@vue/composition-api';
|
||||
import i18n from '@/lang';
|
||||
import { getInterfaces } from '@/interfaces';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const interfaces = getInterfaces();
|
||||
|
||||
const items = computed(() => {
|
||||
return interfaces.value
|
||||
.filter((inter) => inter.relationship === undefined && inter.system !== true)
|
||||
.map((inter) => {
|
||||
return {
|
||||
text: inter.name,
|
||||
value: inter.id,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
return { items };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -7,7 +7,7 @@
|
||||
{{ $t('edit') }}
|
||||
</v-tab>
|
||||
<v-tab>
|
||||
<v-icon name="visibility" left />
|
||||
<v-icon name="visibility" outline left />
|
||||
{{ $t('preview') }}
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
@@ -65,16 +65,23 @@ export default defineComponent({
|
||||
--v-textarea-min-height: var(--input-height-tall);
|
||||
--v-textarea-max-height: 400px;
|
||||
|
||||
--v-tab-background-color: var(--background-subdued);
|
||||
--v-tab-background-color-active: var(--background-subdued);
|
||||
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.toolbar {
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
border: var(--border-width) solid var(--border-normal);
|
||||
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||
background-color: var(--background-subdued);
|
||||
}
|
||||
|
||||
.v-textarea {
|
||||
height: unset;
|
||||
min-height: var(--input-height-tall);
|
||||
border-radius: var(--border-radius) 0 0 var(--border-radius);
|
||||
}
|
||||
|
||||
@@ -109,15 +116,17 @@ export default defineComponent({
|
||||
|
||||
::v-deep {
|
||||
.preview {
|
||||
font-weight: 400;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
& > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
& > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
@@ -221,10 +230,6 @@ export default defineComponent({
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
& > h1:first-child + h2 {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
& > h3:first-child,
|
||||
& > h4:first-child,
|
||||
& > h5:first-child,
|
||||
|
||||
@@ -7,5 +7,19 @@ export default defineInterface(({ i18n }) => ({
|
||||
icon: 'arrow_right_alt',
|
||||
component: InterfaceOneToMany,
|
||||
types: ['alias'],
|
||||
options: [],
|
||||
relationship: 'o2m',
|
||||
options: [
|
||||
{
|
||||
field: 'fields',
|
||||
type: 'json',
|
||||
name: i18n.tc('field', 0),
|
||||
meta: {
|
||||
interface: 'tags',
|
||||
width: 'full',
|
||||
options: {
|
||||
placeholder: i18n.t('readable_fields_copy'),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
@@ -67,7 +67,7 @@ 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 { Filter } from '@/types';
|
||||
import { Filter, Field } from '@/types';
|
||||
import { Header } from '@/components/v-table/types';
|
||||
|
||||
export default defineComponent({
|
||||
@@ -91,7 +91,7 @@ export default defineComponent({
|
||||
},
|
||||
fields: {
|
||||
type: Array as PropType<string[]>,
|
||||
required: true,
|
||||
default: () => [],
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
@@ -194,7 +194,7 @@ export default defineComponent({
|
||||
async function fetchCurrent() {
|
||||
loading.value = true;
|
||||
|
||||
let fields = [...props.fields];
|
||||
let fields = [...(props.fields.length > 0 ? props.fields : getDefaultFields())];
|
||||
|
||||
if (fields.includes(relatedPrimaryKeyField.value.field) === false) {
|
||||
fields.push(relatedPrimaryKeyField.value.field);
|
||||
@@ -306,7 +306,7 @@ export default defineComponent({
|
||||
watch(
|
||||
() => props.fields,
|
||||
() => {
|
||||
tableHeaders.value = props.fields
|
||||
tableHeaders.value = (props.fields.length > 0 ? props.fields : getDefaultFields())
|
||||
.map((fieldKey) => {
|
||||
const field = fieldsStore.getField(relatedCollection.value.collection, fieldKey);
|
||||
|
||||
@@ -517,6 +517,11 @@ export default defineComponent({
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function getDefaultFields(): string[] {
|
||||
const fields = fieldsStore.getFieldsForCollection(relatedCollection.value.collection);
|
||||
return fields.slice(0, 3).map((field: Field) => field.field);
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import registerComponent from '@/utils/register-component/';
|
||||
import interfaces from './index';
|
||||
import { getInterfaces } from './index';
|
||||
import { Component } from 'vue';
|
||||
|
||||
interfaces.forEach((inter) => {
|
||||
const interfaces = getInterfaces();
|
||||
|
||||
const context = require.context('.', true, /^.*index\.ts$/);
|
||||
const modules = context
|
||||
.keys()
|
||||
.map((key) => context(key))
|
||||
.map((mod) => mod.default)
|
||||
.filter((m) => m);
|
||||
|
||||
interfaces.value = modules;
|
||||
|
||||
interfaces.value.forEach((inter) => {
|
||||
registerComponent('interface-' + inter.id, inter.component);
|
||||
|
||||
if (typeof inter.options === 'function') {
|
||||
registerComponent(`interface-options-${inter.id}`, inter.options);
|
||||
if (typeof inter.options !== 'function' && Array.isArray(inter.options) === false) {
|
||||
registerComponent(`interface-options-${inter.id}`, inter.options as Component);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineInterface } from '../define';
|
||||
import InterfaceRepeater from './repeater.vue';
|
||||
import RepeaterOptions from './options.vue';
|
||||
|
||||
export default defineInterface(({ i18n }) => ({
|
||||
id: 'repeater',
|
||||
@@ -7,5 +8,5 @@ export default defineInterface(({ i18n }) => ({
|
||||
icon: 'replay',
|
||||
component: InterfaceRepeater,
|
||||
types: ['json'],
|
||||
options: [],
|
||||
options: RepeaterOptions,
|
||||
}));
|
||||
|
||||
143
app/src/interfaces/repeater/options.vue
Normal file
143
app/src/interfaces/repeater/options.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div>
|
||||
<p class="type-label">Template</p>
|
||||
<v-input class="input" v-model="template" :placeholder="`{{ field }}`" />
|
||||
|
||||
<p class="type-label">Fields</p>
|
||||
<repeater v-model="repeaterValue" :template="`{{ field }} — {{ interface }}`" :fields="repeaterFields" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, computed } from '@vue/composition-api';
|
||||
import Repeater from './repeater.vue';
|
||||
import { Field, FieldMeta } from '@/types';
|
||||
import i18n from '@/lang';
|
||||
|
||||
export default defineComponent({
|
||||
components: { Repeater },
|
||||
props: {
|
||||
value: {
|
||||
type: Object as PropType<any>,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const repeaterValue = computed({
|
||||
get() {
|
||||
return props.value?.fields?.map((field: Field) => field.meta);
|
||||
},
|
||||
set(newVal: FieldMeta[]) {
|
||||
const fields = newVal.map((meta) => ({
|
||||
field: meta.field,
|
||||
meta,
|
||||
}));
|
||||
|
||||
emit('input', {
|
||||
...(props.value || {}),
|
||||
fields: fields,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const repeaterFields: DeepPartial<Field>[] = [
|
||||
{
|
||||
name: i18n.tc('field', 1),
|
||||
field: 'field',
|
||||
type: 'string',
|
||||
meta: {
|
||||
interface: 'text-input',
|
||||
width: 'half',
|
||||
sort: 1,
|
||||
options: {
|
||||
font: 'monospace',
|
||||
},
|
||||
},
|
||||
schema: null,
|
||||
},
|
||||
{
|
||||
name: i18n.t('field_width'),
|
||||
field: 'width',
|
||||
type: 'string',
|
||||
meta: {
|
||||
interface: 'dropdown',
|
||||
width: 'half',
|
||||
sort: 2,
|
||||
options: {
|
||||
choices: [
|
||||
{
|
||||
value: 'half',
|
||||
text: i18n.t('half_width'),
|
||||
},
|
||||
{
|
||||
value: 'full',
|
||||
text: i18n.t('full_width'),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
schema: null,
|
||||
},
|
||||
{
|
||||
name: i18n.t('interface'),
|
||||
field: 'interface',
|
||||
type: 'string',
|
||||
meta: {
|
||||
interface: 'interface',
|
||||
width: 'half',
|
||||
sort: 3,
|
||||
},
|
||||
schema: null,
|
||||
},
|
||||
{
|
||||
name: i18n.t('note'),
|
||||
field: 'note',
|
||||
type: 'string',
|
||||
meta: {
|
||||
interface: 'text-input',
|
||||
width: 'half',
|
||||
sort: 4,
|
||||
},
|
||||
schema: null,
|
||||
},
|
||||
{
|
||||
name: i18n.t('options'),
|
||||
field: 'options',
|
||||
type: 'string',
|
||||
meta: {
|
||||
interface: 'interface-options',
|
||||
width: 'full',
|
||||
sort: 5,
|
||||
options: {
|
||||
interfaceField: 'interface',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const template = computed({
|
||||
get() {
|
||||
return props.value?.template;
|
||||
},
|
||||
set(newTemplate: string) {
|
||||
emit('input', {
|
||||
...(props.value || {}),
|
||||
template: newTemplate,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return { repeaterValue, repeaterFields, template };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.type-label {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.input {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,25 +0,0 @@
|
||||
import InterfaceStatus from './status.vue';
|
||||
import { defineInterface } from '@/interfaces/define';
|
||||
|
||||
export default defineInterface(({ i18n }) => ({
|
||||
id: 'status',
|
||||
name: i18n.t('status'),
|
||||
icon: 'bubble_chart',
|
||||
component: InterfaceStatus,
|
||||
types: ['string'],
|
||||
options: [
|
||||
/** @TODO change this to a custom options element */
|
||||
{
|
||||
field: 'status_mapping',
|
||||
name: i18n.t('status_mapping'),
|
||||
type: 'json',
|
||||
meta: {
|
||||
width: 'full',
|
||||
interface: 'code',
|
||||
options: {
|
||||
language: 'json'
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
}));
|
||||
@@ -1,26 +0,0 @@
|
||||
# Status Interface
|
||||
|
||||
Renders a dropdown with the available status options.
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Description | Default |
|
||||
|------------------|-----------------------------|---------|
|
||||
| `status_mapping` | What statuses are available | `null` |
|
||||
|
||||
### Status Mapping format
|
||||
|
||||
```ts
|
||||
type Status = {
|
||||
[key: string]: {
|
||||
name: string;
|
||||
text_color: string;
|
||||
background_color: string;
|
||||
soft_delete: boolean;
|
||||
published: boolean;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`status_mapping` is the only option for an interface that isn't camelCased. This is due to the fact
|
||||
that the API relies on the same setting for it's permissions management.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user